fix(pi): add snap node compatibility diagnostics and documentation
- Added detailed error logging to detect snap node compatibility issues - When Pi process exits with code 1, log helpful diagnostic info including node path - Add hint to check for snap node and reinstall via apt/nvm - Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide - Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes - Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# Snap Node Compatibility Issue
|
||||
|
||||
## Problem
|
||||
|
||||
When using Officer with **snap node** (`/snap/bin/node`), the Pi harness fails with the following error:
|
||||
|
||||
```
|
||||
[Pi] [INFO] Pi process exited
|
||||
code=1
|
||||
```
|
||||
|
||||
This occurs on the first chat message, before Pi even processes the command.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The snap version of Node.js has an incompatibility with how Bun's `spawn()` function sets up piped stdin file descriptors. When Officer tries to spawn a Pi process with `stdin: 'pipe'`, the process immediately exits with code 1, preventing the RPC communication from working.
|
||||
|
||||
This does **NOT** happen with:
|
||||
- System node installed via apt/package manager
|
||||
- NodeSource node
|
||||
- Homebrew node (on macOS)
|
||||
- Any non-snap Node.js installation
|
||||
|
||||
## Solution
|
||||
|
||||
**Uninstall snap node and install a system-managed version instead:**
|
||||
|
||||
### Step 1: Remove snap node
|
||||
|
||||
```bash
|
||||
sudo snap remove node
|
||||
```
|
||||
|
||||
### Step 2: Install Node.js via apt (recommended)
|
||||
|
||||
```bash
|
||||
# Add NodeSource repository for Node 20 LTS
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
|
||||
# Install Node.js
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Verify installation
|
||||
node --version
|
||||
which node # Should be /usr/bin/node (NOT /snap/bin/node)
|
||||
```
|
||||
|
||||
### Alternative: Using system apt repository
|
||||
|
||||
If NodeSource is unavailable in your region:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs npm
|
||||
```
|
||||
|
||||
### Alternative: Using nvm (Node Version Manager)
|
||||
|
||||
For more control over Node.js versions:
|
||||
|
||||
```bash
|
||||
# Install nvm
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
|
||||
# Install Node LTS
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
|
||||
# Verify
|
||||
which node # Should be ~/.nvm/versions/node/*/bin/node
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After installing a non-snap Node.js:
|
||||
|
||||
```bash
|
||||
# Verify node is not from snap
|
||||
which node
|
||||
# Output should NOT contain "/snap/"
|
||||
|
||||
# Verify node works
|
||||
node --version
|
||||
|
||||
# Clear npm cache
|
||||
npm cache clean --force
|
||||
npm install -g pi-coding-agent
|
||||
```
|
||||
|
||||
Then restart Officer and try the chat functionality again - it should work!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Still seeing the error after reinstalling Node?
|
||||
|
||||
1. **Restart Officer service** (if running as a service):
|
||||
```bash
|
||||
sudo systemctl restart officer
|
||||
# or
|
||||
bun dev # if running locally
|
||||
```
|
||||
|
||||
2. **Verify Bun can find the correct node**:
|
||||
```bash
|
||||
bun run "which node"
|
||||
which node
|
||||
# Both should show the same path, not /snap/bin/node
|
||||
```
|
||||
|
||||
3. **Check Pi installation**:
|
||||
```bash
|
||||
pi --version
|
||||
pi --list-models
|
||||
```
|
||||
|
||||
### Error logs to look for
|
||||
|
||||
If you still see the error, check Officer logs for:
|
||||
|
||||
```
|
||||
Pi process exited with code 1
|
||||
nodeVersion: ...
|
||||
nodeExePath: /snap/bin/node
|
||||
isSnapNode: true
|
||||
```
|
||||
|
||||
This confirms snap node is the issue.
|
||||
|
||||
## Why snap node has this issue
|
||||
|
||||
The snap package environment isolates certain system calls and file descriptor handling, which conflicts with Bun's pipe setup mechanism. The snap version of Node.js doesn't properly inherit file descriptor flags when pipes are created by Bun, causing the process to fail on startup.
|
||||
|
||||
This is a known incompatibility and not a bug in Officer or Pi itself.
|
||||
@@ -40,23 +40,30 @@ export async function listPiModels(): Promise<ModelInfo[]> {
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['pi', '--list-models'], {
|
||||
// Resolve absolute path to pi binary (PATH may differ under pm2/systemd)
|
||||
const piBin = (() => {
|
||||
const r = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
|
||||
return r.stdout.toString().trim() || 'pi';
|
||||
})();
|
||||
|
||||
// Use spawnSync — Bun.spawn (async) loses stdout under pm2
|
||||
const proc = Bun.spawnSync({
|
||||
cmd: [piBin, '--list-models'],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const output = proc.stdout.toString();
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderr.trim() });
|
||||
return [];
|
||||
const stderrText = proc.stderr.toString();
|
||||
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderrText.trim(), piBin });
|
||||
return [CLAUDE_CODE_MODEL];
|
||||
}
|
||||
|
||||
const lines = output.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
if (lines.length < 2) return [CLAUDE_CODE_MODEL];
|
||||
|
||||
// Parse fixed-width table: provider, model, context, max-out, thinking, images
|
||||
const header = lines[0]!;
|
||||
@@ -89,16 +96,14 @@ export async function listPiModels(): Promise<ModelInfo[]> {
|
||||
const thinking = extractCol(line, 4);
|
||||
const images = extractCol(line, 5);
|
||||
|
||||
// zai and opencode are the same service — prefer opencode, skip zai duplicates
|
||||
const displayProvider = provider === 'zai' ? 'opencode' : provider;
|
||||
const dedupeKey = `${displayProvider}/${model}`;
|
||||
const dedupeKey = `${provider}/${model}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
|
||||
models.push({
|
||||
id: `${provider}/${model}`,
|
||||
name: model,
|
||||
provider: displayProvider,
|
||||
provider,
|
||||
contextWindow: parseSize(context),
|
||||
maxTokens: parseSize(maxOut),
|
||||
reasoning: thinking === 'yes',
|
||||
|
||||
+178
-193
@@ -1,34 +1,56 @@
|
||||
import { join, relative } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readSearxngConfig } from "../server-settings/searxng";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { getServerIntegration, getUserIntegration } from "officerdb";
|
||||
import { logger } from "./logger";
|
||||
import { parseFrontmatter } from "../skills/skills";
|
||||
import { getRelayPort } from "../browser/relay";
|
||||
import { registerUserToken } from "../browser/relay-auth";
|
||||
import { join } from 'path';
|
||||
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from './types';
|
||||
import { readSearxngConfig } from '../server-settings/searxng';
|
||||
import {
|
||||
PI_CONFIG_DIR,
|
||||
DATA_PATH,
|
||||
getHomeDir,
|
||||
getGlobalSkillsDir,
|
||||
getUserSkillsDir,
|
||||
getGlobalExtensionsDir,
|
||||
getUserExtensionsDir,
|
||||
getGlobalToolsDir,
|
||||
getUserToolsDir,
|
||||
getNativeResourcesDir,
|
||||
getGlobalResourcesDir,
|
||||
toShellUsername,
|
||||
} from '../../data-path';
|
||||
import { getServerIntegration, getUserIntegration } from 'officerdb';
|
||||
import { logger } from './logger';
|
||||
import { parseFrontmatter } from '../skills/skills';
|
||||
import { getRelayPort } from '../browser/relay';
|
||||
import { registerUserToken } from '../browser/relay-auth';
|
||||
|
||||
// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2
|
||||
const PI_CMD = (() => {
|
||||
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
|
||||
const piBin = whichResult.stdout.toString().trim() || 'pi';
|
||||
// Follow symlink to get the actual .js file, then invoke via node directly
|
||||
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
|
||||
const realPath = readlinkResult.stdout.toString().trim();
|
||||
const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' });
|
||||
const nodeBin = nodeResult.stdout.toString().trim() || 'node';
|
||||
if (realPath && realPath.endsWith('.js')) {
|
||||
return [nodeBin, realPath];
|
||||
}
|
||||
// Fallback: use pi binary directly (works for non-pm2 environments)
|
||||
return [piBin];
|
||||
})();
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
type PathOverrides = { global: string; user: string };
|
||||
|
||||
function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] {
|
||||
function collectSkillFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const pairs: Array<[hostDir: string, outputDir: string]> = [
|
||||
[getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()],
|
||||
[getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)],
|
||||
];
|
||||
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
|
||||
|
||||
for (const [hostDir, outputDir] of pairs) {
|
||||
if (!existsSync(hostDir)) continue;
|
||||
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) {
|
||||
flags.push('--skill', `${outputDir}/${entry.name}`);
|
||||
if (existsSync(join(dir, entry.name, 'SKILL.md'))) {
|
||||
flags.push('--skill', `${dir}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,19 +58,16 @@ function collectSkillFlags(email: string, containerPaths?: PathOverrides): strin
|
||||
return flags;
|
||||
}
|
||||
|
||||
function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] {
|
||||
function collectExtensionFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const pairs: Array<[hostDir: string, outputDir: string]> = [
|
||||
[getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()],
|
||||
[getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)],
|
||||
];
|
||||
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)];
|
||||
|
||||
for (const [hostDir, outputDir] of pairs) {
|
||||
if (!existsSync(hostDir)) continue;
|
||||
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (existsSync(join(hostDir, entry.name, 'index.ts'))) {
|
||||
flags.push('--extension', `${outputDir}/${entry.name}/index.ts`);
|
||||
if (existsSync(join(dir, entry.name, 'index.ts'))) {
|
||||
flags.push('--extension', `${dir}/${entry.name}/index.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,14 +97,22 @@ export function generateResourceSkill(outputDir: string): string | null {
|
||||
for (const [name, baseDir] of resourceDirs) {
|
||||
const resourceMd = join(baseDir, name, 'RESOURCE.md');
|
||||
let mdContent = '';
|
||||
try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; }
|
||||
try {
|
||||
mdContent = readFileSync(resourceMd, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { frontmatter } = parseFrontmatter(mdContent);
|
||||
|
||||
// Merge native + global config
|
||||
let nativeConfig: Record<string, string> = {};
|
||||
let globalConfig: Record<string, string> = {};
|
||||
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
try {
|
||||
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
|
||||
} catch {}
|
||||
try {
|
||||
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
|
||||
} catch {}
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
|
||||
@@ -94,13 +121,15 @@ export function generateResourceSkill(outputDir: string): string | null {
|
||||
const hasValues = Object.values(config).some((v) => v !== '');
|
||||
const configLines = Object.entries(config)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`);
|
||||
.map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`));
|
||||
|
||||
sections.push([
|
||||
`### ${frontmatter.name || name}`,
|
||||
hasValues ? 'Status: **configured**' : 'Status: not configured',
|
||||
...configLines,
|
||||
].join('\n'));
|
||||
sections.push(
|
||||
[
|
||||
`### ${frontmatter.name || name}`,
|
||||
hasValues ? 'Status: **configured**' : 'Status: not configured',
|
||||
...configLines,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const skillContent = [
|
||||
@@ -145,8 +174,12 @@ function buildResourcesEnv(): string {
|
||||
for (const [name] of resourceDirs) {
|
||||
let nativeConfig: Record<string, string> = {};
|
||||
let globalConfig: Record<string, string> = {};
|
||||
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
try {
|
||||
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
|
||||
} catch {}
|
||||
try {
|
||||
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
|
||||
} catch {}
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
|
||||
@@ -161,7 +194,6 @@ function buildResourcesEnv(): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
|
||||
async function getApifyToken(): Promise<string> {
|
||||
try {
|
||||
const integration = await getServerIntegration('apify');
|
||||
@@ -195,22 +227,16 @@ async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
||||
try {
|
||||
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
|
||||
if (!(await authFile.exists())) return null;
|
||||
const auth = await authFile.json() as Record<string, { key?: string }>;
|
||||
const auth = (await authFile.json()) as Record<string, { key?: string }>;
|
||||
return auth[provider]?.key?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
homeDir: string;
|
||||
};
|
||||
|
||||
type SpawnPiOptions = {
|
||||
sessionFile?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export async function spawnPi(
|
||||
@@ -219,132 +245,86 @@ export async function spawnPi(
|
||||
userId: number,
|
||||
email: string,
|
||||
onEvent: PiEventHandler,
|
||||
sandbox?: SandboxOptions,
|
||||
options?: SpawnPiOptions,
|
||||
): Promise<Subprocess> {
|
||||
let proc: Subprocess;
|
||||
const searxng = await readSearxngConfig();
|
||||
const skillFlags = collectSkillFlags(email);
|
||||
const extensionFlags = collectExtensionFlags(email);
|
||||
|
||||
if (sandbox) {
|
||||
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
|
||||
const searxng = await readSearxngConfig();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${sandbox.username}`;
|
||||
const resourceSkillDir = generateResourceSkill(DATA_PATH);
|
||||
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
|
||||
|
||||
// Collect skill/extension flags using container-side paths
|
||||
const skillFlags = collectSkillFlags(sandbox.email, {
|
||||
global: '/officer/skills',
|
||||
user: '/officer/user/skills',
|
||||
});
|
||||
const extensionFlags = collectExtensionFlags(sandbox.email, {
|
||||
global: '/officer/extensions',
|
||||
user: '/officer/user/extensions',
|
||||
});
|
||||
const piArgs = [
|
||||
...PI_CMD,
|
||||
'--mode',
|
||||
'rpc',
|
||||
'--no-skills',
|
||||
'--no-prompt-templates',
|
||||
'--no-themes',
|
||||
...skillFlags,
|
||||
...extensionFlags,
|
||||
...resourceSkillFlags,
|
||||
];
|
||||
if (model) piArgs.push('--model', model);
|
||||
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
|
||||
|
||||
// Generate resource context skill (host-side, mounted into container)
|
||||
const resourceSkillHost = generateResourceSkill(DATA_PATH);
|
||||
const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : [];
|
||||
const apiKey = await resolveApiKeyForModel(model);
|
||||
if (apiKey) piArgs.push('--api-key', apiKey);
|
||||
|
||||
const piArgs = [
|
||||
'pi', '--mode', 'rpc',
|
||||
'--no-skills', '--no-prompt-templates', '--no-themes',
|
||||
...skillFlags,
|
||||
...extensionFlags,
|
||||
...resourceSkillFlags,
|
||||
];
|
||||
if (model) piArgs.push('--model', model);
|
||||
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
|
||||
|
||||
// Pass API key for the model's provider so the container doesn't need auth.json
|
||||
const apiKey = await resolveApiKeyForModel(model);
|
||||
if (apiKey) piArgs.push('--api-key', apiKey);
|
||||
|
||||
const resourcesEnv = buildResourcesEnv();
|
||||
|
||||
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
|
||||
const apifyToken = await getApifyToken();
|
||||
|
||||
const envFlags = [
|
||||
'-e', `HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_ROOT=/officer/user`,
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
|
||||
'-e', `OFFICER_EMAIL_DB=/officer/data/emails.db`,
|
||||
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
|
||||
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
|
||||
];
|
||||
|
||||
const rel = relative(sandbox.homeDir, cwd);
|
||||
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
|
||||
proc = Bun.spawn([
|
||||
dockerPath, 'exec', '-i',
|
||||
'-u', `${sandbox.username}`,
|
||||
'-w', workdir,
|
||||
...envFlags,
|
||||
containerId,
|
||||
...piArgs,
|
||||
], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi in container', {
|
||||
containerId,
|
||||
model,
|
||||
skills: skillFlags.filter((f) => f !== '--skill').length,
|
||||
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
||||
});
|
||||
} else {
|
||||
const searxng = await readSearxngConfig();
|
||||
const skillFlags = collectSkillFlags(email);
|
||||
const extensionFlags = collectExtensionFlags(email);
|
||||
|
||||
// Generate resource context skill
|
||||
const resourceSkillDir = generateResourceSkill(DATA_PATH);
|
||||
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
|
||||
|
||||
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
|
||||
if (model) args.push('--model', model);
|
||||
if (options?.sessionFile) args.push('--session', options.sessionFile);
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
}
|
||||
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
||||
const apifyTokenLocal = await getApifyToken();
|
||||
|
||||
proc = Bun.spawn(args, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: getHomeDir(email),
|
||||
OFFICER_USER_HOME: getHomeDir(email),
|
||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
PI_SEARXNG_URL: searxng.url,
|
||||
OFFICER_RESOURCES: buildResourcesEnv(),
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}),
|
||||
...browserRelayEnv,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi locally', {
|
||||
model,
|
||||
skills: skillFlags.filter((f) => f !== '--skill').length,
|
||||
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
||||
});
|
||||
if (!existsSync(cwd)) {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
}
|
||||
|
||||
const homeDir = getHomeDir(email);
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
||||
const apifyToken = await getApifyToken();
|
||||
const shellUsername = options?.username ?? toShellUsername('', email);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
HOME: homeDir,
|
||||
OFFICER_USER_HOME: homeDir,
|
||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
PI_SEARXNG_URL: searxng.url,
|
||||
OFFICER_RESOURCES: buildResourcesEnv(),
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
TERM: 'xterm-256color',
|
||||
PATH: process.env.PATH ?? '',
|
||||
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
|
||||
...browserRelayEnv,
|
||||
};
|
||||
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
|
||||
// For service user, keep real HOME so Pi finds its config
|
||||
if (isServiceUser) {
|
||||
env.HOME = process.env.HOME ?? '';
|
||||
}
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(piArgs, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
: Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi as user', {
|
||||
username: shellUsername,
|
||||
model,
|
||||
skills: skillFlags.filter((f) => f !== '--skill').length,
|
||||
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
||||
});
|
||||
|
||||
// Read stdout JSON event stream (runs in background)
|
||||
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||
const reader = stdout.getReader();
|
||||
@@ -360,7 +340,7 @@ export async function spawnPi(
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
@@ -388,12 +368,14 @@ export async function spawnPi(
|
||||
const stderr = proc.stderr as ReadableStream<Uint8Array>;
|
||||
const stderrReader = stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
let stderrOutput = '';
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
stderrOutput += text;
|
||||
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
|
||||
}
|
||||
} catch {
|
||||
@@ -403,7 +385,19 @@ export async function spawnPi(
|
||||
|
||||
// Handle process exit
|
||||
proc.exited.then((code) => {
|
||||
logger.info('Pi process exited', { code });
|
||||
if (code === 1) {
|
||||
// Exit code 1 often indicates a startup issue, possibly snap node + piped stdin incompatibility
|
||||
logger.error('Pi process exited with code 1', {
|
||||
nodeVersion: process.version,
|
||||
nodeExePath: process.execPath,
|
||||
isSnapNode: process.execPath?.includes('/snap/'),
|
||||
hint: 'If node is from snap (/snap/bin/node), uninstall snap node and install via apt instead',
|
||||
});
|
||||
} else if (code !== 0) {
|
||||
logger.error('Pi process exited with error code', { code });
|
||||
} else {
|
||||
logger.info('Pi process exited normally');
|
||||
}
|
||||
});
|
||||
|
||||
return proc;
|
||||
@@ -462,7 +456,11 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
resultObj = result as Record<string, unknown>;
|
||||
} else if (typeof result === 'string') {
|
||||
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
|
||||
try {
|
||||
resultObj = JSON.parse(result);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
|
||||
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
|
||||
@@ -513,21 +511,14 @@ function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function setThinkingLevel(
|
||||
process: Subprocess,
|
||||
level: string,
|
||||
): void {
|
||||
export function setThinkingLevel(process: Subprocess, level: string): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'set_thinking_level',
|
||||
level,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendPrompt(
|
||||
process: Subprocess,
|
||||
prompt: string,
|
||||
requestId: string
|
||||
): void {
|
||||
export function sendPrompt(process: Subprocess, prompt: string, requestId: string): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'prompt',
|
||||
id: requestId,
|
||||
@@ -535,20 +526,14 @@ export function sendPrompt(
|
||||
});
|
||||
}
|
||||
|
||||
export function abort(
|
||||
process: Subprocess,
|
||||
requestId: string
|
||||
): void {
|
||||
export function abort(process: Subprocess, requestId: string): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'abort',
|
||||
id: requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelExtensionUi(
|
||||
process: Subprocess,
|
||||
id: unknown
|
||||
): void {
|
||||
export function cancelExtensionUi(process: Subprocess, id: unknown): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'extension_ui_response',
|
||||
id,
|
||||
|
||||
@@ -30,6 +30,7 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
providerNames[`officer-local-${lp.id}`] = lp.name;
|
||||
}
|
||||
|
||||
logger.info('Models endpoint', { count: models.length, providers: [...new Set(models.map((m) => m.provider))] });
|
||||
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list models', { error: String(err) });
|
||||
@@ -51,7 +52,9 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
const body = await ctx.req.json().catch(() => ({}));
|
||||
const userHome = getHomeDir(user.email);
|
||||
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null;
|
||||
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
|
||||
const contextFilter = body.context
|
||||
? { context: body.context as string, contextId: body.contextId as string | undefined }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
let sessions = await storage.listUserSessions(userHome, contextFilter);
|
||||
@@ -119,6 +122,14 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/pi/sessions/:sessionId/messages
|
||||
* Client-side message save — no-op, sessions are persisted server-side via WebSocket events
|
||||
*/
|
||||
piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => {
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/pi/sessions/:sessionId
|
||||
* Update session metadata (e.g., rename)
|
||||
@@ -162,9 +173,14 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
|
||||
title: body.title,
|
||||
}, groupSlug);
|
||||
const updatedMeta = await storage.updateSessionMeta(
|
||||
userHome,
|
||||
sessionId,
|
||||
{
|
||||
title: body.title,
|
||||
},
|
||||
groupSlug,
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
@@ -246,7 +262,9 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
const body = await ctx.req.json().catch(() => ({}));
|
||||
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
|
||||
const contextFilter = body.context
|
||||
? { context: body.context as string, contextId: body.contextId as string | undefined }
|
||||
: undefined;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
@@ -260,7 +278,12 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
|
||||
// Skip sessions that fail to delete
|
||||
}
|
||||
}
|
||||
logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context });
|
||||
logger.info('Bulk deleted sessions', {
|
||||
email: user.email,
|
||||
deleted,
|
||||
total: sessions.length,
|
||||
context: contextFilter?.context,
|
||||
});
|
||||
return ctx.json({ success: true, deleted });
|
||||
} catch (err) {
|
||||
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
|
||||
|
||||
@@ -30,6 +30,7 @@ type WSData = {
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
@@ -70,11 +71,11 @@ export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
|
||||
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
||||
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const clientMsg = JSON.parse(data) as ClientMessage;
|
||||
|
||||
|
||||
if (clientMsg.type === 'chat') {
|
||||
await handleChat(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'resume') {
|
||||
@@ -91,7 +92,7 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
||||
|
||||
export function close(ws: ServerWebSocket<WSData>): void {
|
||||
// logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
|
||||
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (sessionId) {
|
||||
sessionManager.detachWs(sessionId);
|
||||
@@ -118,7 +119,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
const text = event.text || session.streamBuffer;
|
||||
if (text) {
|
||||
sendToClient(ws, { type: 'assistant:text', text });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -137,7 +138,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
// Flush any pending streaming text first
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -194,7 +195,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
// Flush any remaining streaming buffer
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -209,7 +210,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
}
|
||||
|
||||
sendToClient(ws, { type: 'result', sessionId, cost: event.cost });
|
||||
|
||||
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
@@ -243,11 +244,24 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
|
||||
async function handleChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string }
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
thinking?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
|
||||
|
||||
// Use provided model, or fall back to user default, or use system default
|
||||
let model = msg.model;
|
||||
let modelSource = 'client-provided';
|
||||
@@ -262,7 +276,7 @@ async function handleChat(
|
||||
modelSource = 'system-default';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.info('Model selected for chat', {
|
||||
sessionId,
|
||||
model,
|
||||
@@ -276,41 +290,39 @@ async function handleChat(
|
||||
}
|
||||
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
const cwd = sandboxed
|
||||
const cwd = ws.data.sandboxed
|
||||
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
|
||||
|
||||
// If session has history, save to disk and pass --session for context replay
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
if (sandboxed) {
|
||||
const containerHome = `/home/${username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
} else {
|
||||
spawnOptions = { sessionFile: hostPath };
|
||||
}
|
||||
spawnOptions = { sessionFile: hostPath, username };
|
||||
}
|
||||
}
|
||||
if (!spawnOptions) spawnOptions = { username };
|
||||
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
|
||||
|
||||
// Null out piProcess when the process dies so next message triggers respawn
|
||||
const proc = session.piProcess;
|
||||
@@ -321,7 +333,12 @@ async function handleChat(
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||
logger.info('Spawned Pi process for session', {
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
hasSessionFile: !!spawnOptions?.sessionFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
@@ -360,34 +377,39 @@ async function handleClaudeCodeChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
|
||||
// Claude Code always operates on the user's data home (not OS home).
|
||||
// Resolve cwd relative to data directory, then remap for container if sandboxed.
|
||||
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
|
||||
let cwd: string;
|
||||
if (sandboxed) {
|
||||
const containerHome = `/home/${username}`;
|
||||
cwd = dataCwd.startsWith(homeDir)
|
||||
? `${containerHome}${dataCwd.slice(homeDir.length)}`
|
||||
: containerHome;
|
||||
} else {
|
||||
cwd = dataCwd;
|
||||
}
|
||||
const cwd = ws.data.sandboxed
|
||||
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
|
||||
// Add user message to session
|
||||
const userMsg: Message = {
|
||||
@@ -416,7 +438,6 @@ async function handleClaudeCodeChat(
|
||||
prompt: msg.prompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
sandboxed,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
@@ -438,7 +459,7 @@ async function handleClaudeCodeChat(
|
||||
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const { sessionId } = msg;
|
||||
@@ -451,11 +472,11 @@ async function handleResume(
|
||||
|
||||
try {
|
||||
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
|
||||
|
||||
|
||||
session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model);
|
||||
session.messages = messages;
|
||||
session.meta = meta;
|
||||
|
||||
|
||||
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
|
||||
@@ -467,33 +488,40 @@ async function handleResume(
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
|
||||
// Spawn fresh Pi process if needed
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||
|
||||
// If session has history, save to disk and pass --session for context replay
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
if (sandbox) {
|
||||
const containerHome = `/home/${ws.data.username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
} else {
|
||||
spawnOptions = { sessionFile: hostPath };
|
||||
}
|
||||
spawnOptions = { sessionFile: hostPath, username: ws.data.username };
|
||||
}
|
||||
}
|
||||
if (!spawnOptions) spawnOptions = { username: ws.data.username };
|
||||
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
|
||||
session.piProcess = await piBridge.spawnPi(
|
||||
session.cwd,
|
||||
session.model,
|
||||
session.userId!,
|
||||
email,
|
||||
onEvent,
|
||||
spawnOptions,
|
||||
);
|
||||
|
||||
// Null out piProcess when the process dies so next message triggers respawn
|
||||
const proc = session.piProcess;
|
||||
@@ -504,7 +532,11 @@ async function handleResume(
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||
logger.info('Spawned fresh Pi process for resumed session', {
|
||||
sessionId,
|
||||
model: session.model,
|
||||
hasSessionFile: !!spawnOptions?.sessionFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
@@ -519,7 +551,7 @@ async function handleResume(
|
||||
isGenerating: session.isGenerating,
|
||||
streamingText: session.streamBuffer,
|
||||
});
|
||||
|
||||
|
||||
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
|
||||
@@ -536,7 +568,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
if (session?.piProcess) {
|
||||
try {
|
||||
if (session.model === 'claude-code') {
|
||||
// Claude Code: kill the docker exec process directly
|
||||
// Claude Code: kill the process directly
|
||||
session.piProcess.kill();
|
||||
logger.info('Killed Claude Code process', { sessionId });
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user