feat(daemon): close pi adapter parity gaps

Closes pi adapter parity gaps for image paths, extra allowed dirs, error events, and sendAgentEvent routing.
This commit is contained in:
Tom 2026-05-07 19:03:46 +07:00 committed by GitHub
parent 168cb8ab4d
commit 8630fd380a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 646 additions and 4 deletions

View file

@ -686,7 +686,7 @@ export const AGENT_DEFS = [
buildArgs: (
_prompt,
_imagePaths,
_extra,
extraAllowedDirs = [],
options = {},
runtimeContext = {},
) => {
@ -702,11 +702,29 @@ export const AGENT_DEFS = [
// pi supports --append-system-prompt for cwd and extra context.
// For now we rely on the composed prompt containing the cwd hint
// (same pattern as other agents) rather than using system-prompt flags.
//
// extraAllowedDirs carries skill seed and design-system directories
// that live outside the project cwd. pi doesn't have an --add-dir
// sandbox flag (it uses OS cwd), so we use --append-system-prompt to
// hint that these directories exist. The agent can then use its Read
// tool to access files inside them. Without this, pi runs inside the
// project cwd and has no way to discover or reach skill/design-system
// assets that live elsewhere.
const dirs = (extraAllowedDirs || []).filter(
(d) => typeof d === 'string' && path.isAbsolute(d),
);
for (const d of dirs) {
args.push('--append-system-prompt', d);
}
return args;
},
// Prompt is sent via RPC `prompt` command on stdin, not as a CLI arg.
promptViaStdin: true,
streamFormat: 'pi-rpc',
// pi's RPC `prompt` command supports an `images` field for multimodal
// input (base64-encoded). The daemon attaches image paths to the
// session so attachPiRpcSession can read and forward them.
supportsImagePaths: true,
},
{
id: 'kiro',

View file

@ -864,6 +864,8 @@ function attachAgentStreamHandlers(
cwd,
model: model ?? null,
send,
imagePaths: [],
uploadRoot: undefined,
});
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({

View file

@ -17,8 +17,15 @@
* consumed to keep the protocol clean.
*/
import fs from 'node:fs';
import path from 'node:path';
import { createJsonLineStream } from './acp.js';
// Image forwarding budgets to prevent large synchronous base64 work.
const MAX_IMAGE_COUNT = 10;
const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
// sendCommand is scoped inside attachPiRpcSession to avoid sharing
// the RPC id counter across concurrent sessions.
@ -145,6 +152,21 @@ export function mapPiRpcEvent(raw, send, ctx) {
return null;
}
// pi's RPC protocol emits a message_update with error delta when
// the model returns an error (e.g. aborted, context overflow).
// Surface it so sendAgentEvent's error-handling path sets
// agentStreamError and the run flips to `failed` on close.
if (ev.type === 'error') {
const message =
typeof ev.reason === 'string' && ev.reason.length > 0
? ev.reason
: typeof ev.delta === 'string' && ev.delta.length > 0
? ev.delta
: 'Agent error';
send('agent', { type: 'error', message, raw });
return null;
}
return null;
}
@ -184,6 +206,19 @@ export function mapPiRpcEvent(raw, send, ctx) {
return null;
}
// pi's RPC protocol can emit `extension_error` when an extension
// throws during a tool call or event handler. Surface it so the
// daemon's error-handling path (sendAgentEvent → agentStreamError)
// can flip the run to `failed` and forward a visible SSE error.
if (raw.type === 'extension_error') {
const message =
typeof raw.error === 'string' && raw.error.length > 0
? raw.error
: 'Extension error';
send('agent', { type: 'error', message, raw });
return null;
}
if (raw.type === 'compaction_start') {
send('agent', { type: 'status', label: 'compacting' });
return null;
@ -193,6 +228,18 @@ export function mapPiRpcEvent(raw, send, ctx) {
return null;
}
if (raw.type === 'auto_retry_end' && raw.success === false) {
// Auto-retry exhausted — the agent is about to give up. Surface
// the final error so the daemon marks the run as failed rather
// than silently succeeding with empty output.
const message =
typeof raw.finalError === 'string' && raw.finalError.length > 0
? raw.finalError
: 'Auto-retry exhausted';
send('agent', { type: 'error', message, raw });
return null;
}
return null;
}
@ -213,10 +260,12 @@ export function mapPiRpcEvent(raw, send, ctx) {
* @param {string} opts.prompt - composed user message
* @param {string} [opts.cwd] - working directory
* @param {string|null} [opts.model] - model id (null = default)
* @param {string[]} [opts.imagePaths] - absolute paths to image files for multimodal input
* @param {string} [opts.uploadRoot] - root directory that image paths must remain inside after symlink resolution
* @param {function} opts.send - SSE send function
* @returns {{ hasFatalError(): boolean, abort(): void }}
*/
export function attachPiRpcSession({ child, prompt, cwd, model, send }) {
export function attachPiRpcSession({ child, prompt, cwd, model, send, imagePaths, uploadRoot }) {
const runStartedAt = Date.now();
let finished = false;
let fatal = false;
@ -265,7 +314,64 @@ export function attachPiRpcSession({ child, prompt, cwd, model, send }) {
stdinOpen = false;
});
promptRpcId = sendCommand(child.stdin, 'prompt', { message: prompt });
// Build the images array for pi's prompt command. pi's RPC protocol
// accepts `images` as an array of {type, data, mimeType} objects where
// `data` is base64-encoded file contents. The daemon's safeImages guard
// already validated that each path exists under UPLOAD_DIR.
//
// Security: realpath resolves symlinks so we re-check that the resolved
// path is still a regular file (no /proc/self/mem or symlink escape).
// We also enforce a count and total-byte budget to prevent large
// synchronous base64 reads from blocking the event loop.
const images = [];
if (Array.isArray(imagePaths) && imagePaths.length > 0) {
let totalBytes = 0;
for (const imgPath of imagePaths) {
if (images.length >= MAX_IMAGE_COUNT) break;
if (typeof imgPath !== 'string' || !imgPath.length) continue;
try {
// Resolve symlinks and verify it's a regular file.
const realPath = fs.realpathSync(imgPath);
const stat = fs.statSync(realPath);
if (!stat.isFile()) continue;
// Re-verify the resolved path stays inside the upload root.
// Without this, a path that passed server.ts's safeImages prefix
// check (under UPLOAD_DIR) could be a symlink pointing to a file
// outside UPLOAD_DIR, and we'd read/base64-forward it to pi.
if (uploadRoot) {
const resolvedRoot = fs.realpathSync(uploadRoot);
if (realPath !== resolvedRoot && !realPath.startsWith(resolvedRoot + path.sep)) continue;
}
const ext = path.extname(realPath).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) continue;
// Enforce total byte budget.
if (totalBytes + stat.size > MAX_TOTAL_IMAGE_BYTES) continue;
const buf = fs.readFileSync(realPath);
const mimeType =
ext === '.png' ? 'image/png' :
ext === '.gif' ? 'image/gif' :
ext === '.webp' ? 'image/webp' :
'image/jpeg'; // .jpg, .jpeg, and unknown
images.push({
type: 'image',
data: buf.toString('base64'),
mimeType,
});
totalBytes += stat.size;
} catch {
// Skip unreadable images rather than failing the entire run.
}
}
}
promptRpcId = sendCommand(child.stdin, 'prompt', {
message: prompt,
...(images.length > 0 ? { images } : {}),
});
// ---- Inbound: parse stdout events ----
const parser = createJsonLineStream((raw) => {

View file

@ -4310,12 +4310,44 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
child.stdout.on('data', (chunk) => copilot.feed(chunk));
child.on('close', () => copilot.flush());
} else if (def.streamFormat === 'pi-rpc') {
// Route through sendAgentEvent so that pi-rpc's error events
// (extension_error, auto_retry_end with success=false, and the
// message_update error delta) set agentStreamError and flip the
// run to `failed` on close — same path as qoder-stream-json and
// json-event-stream after issue #691. Also enables the
// substantive-output guard (agentProducedOutput) so a pi run
// that exits 0 without producing visible content is caught.
//
// attachPiRpcSession invokes its send callback with the two-arg
// channel/payload shape: send('agent', payload) for normal events
// and send('error', {message}) from fail(). sendAgentEvent
// expects a single event object, so we adapt at the call site:
// - 'agent' channel → relay payload through sendAgentEvent
// - 'error' channel → route through the daemon's error path
// (createSseErrorPayload + send SSE + set agentStreamError)
trackingSubstantiveOutput = true;
acpSession = attachPiRpcSession({
child,
prompt: composed,
cwd: effectiveCwd,
model: safeModel,
send,
send: (channel, payload) => {
if (channel === 'agent') {
sendAgentEvent(payload);
} else if (channel === 'error') {
if (agentStreamError) return;
agentStreamError = String(payload?.message || 'Pi session error');
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
agentStreamError,
{ retryable: false },
));
} else {
send(channel, payload);
}
},
imagePaths: def.supportsImagePaths ? safeImages : [],
uploadRoot: UPLOAD_DIR,
});
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({

View file

@ -460,6 +460,7 @@ test('pi args use rpc mode without --no-session and append model/thinking option
assert.ok(!baseArgs.includes('--no-session'), 'pi must not pass --no-session');
assert.equal(pi.promptViaStdin, true);
assert.equal(pi.streamFormat, 'pi-rpc');
assert.equal(pi.supportsImagePaths, true);
const withModel = pi.buildArgs('', [], [], { model: 'anthropic/claude-sonnet-4-5' }, {});
assert.deepEqual(withModel, [
@ -478,6 +479,66 @@ test('pi args use rpc mode without --no-session and append model/thinking option
]);
});
test('pi args forward extraAllowedDirs as --append-system-prompt flags', () => {
const args = pi.buildArgs(
'',
[],
['/tmp/skills', '/tmp/design-systems'],
{},
{},
);
assert.deepEqual(args, [
'--mode',
'rpc',
'--append-system-prompt',
'/tmp/skills',
'--append-system-prompt',
'/tmp/design-systems',
]);
});
test('pi args filter relative paths from extraAllowedDirs', () => {
const args = pi.buildArgs(
'',
[],
['/tmp/skills', 'relative/path', '/tmp/design-systems'],
{},
{},
);
// Relative paths should be filtered out.
assert.deepEqual(args, [
'--mode',
'rpc',
'--append-system-prompt',
'/tmp/skills',
'--append-system-prompt',
'/tmp/design-systems',
]);
});
test('pi args combine model, thinking, and extraAllowedDirs', () => {
const args = pi.buildArgs(
'',
[],
['/tmp/skills'],
{ model: 'openai/gpt-5', reasoning: 'medium' },
{},
);
assert.deepEqual(args, [
'--mode',
'rpc',
'--model',
'openai/gpt-5',
'--thinking',
'medium',
'--append-system-prompt',
'/tmp/skills',
]);
});
test('gemini args avoid version-fragile trust flags', () => {
const args = gemini.buildArgs('', [], [], {});

View file

@ -1,6 +1,7 @@
// @ts-nocheck
import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { parsePiModels, mapPiRpcEvent, attachPiRpcSession } from '../src/pi-rpc.js';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
@ -582,6 +583,416 @@ test('attachPiRpcSession abort() is idempotent and no-op after stdin close', ()
assert.equal(buffered, null, 'no bytes should be written after abort on closed stdin');
});
// ─── extension_error event handling ─────────────────────────────────────────
test('pi RPC: extension_error maps to error event', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts', event: 'tool_call', error: 'Something broke' },
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Something broke');
});
test('pi RPC: extension_error with non-string error uses fallback', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts', error: { message: 'nested' } },
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Extension error');
});
test('pi RPC: extension_error with missing error uses fallback', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts' },
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Extension error');
});
// ─── message_update error delta handling ────────────────────────────────────
test('pi RPC: message_update with error delta maps to error event', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error', reason: 'aborted' },
},
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'aborted');
});
test('pi RPC: message_update error delta falls back to delta text', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error', delta: 'Connection reset' },
},
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Connection reset');
});
test('pi RPC: message_update error delta with no reason or delta uses fallback', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error' },
},
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Agent error');
});
test('pi RPC: message_update error after partial output still emits error', () => {
// Even after text deltas have been emitted (agentProducedOutput = true
// on the server side), a subsequent error delta should still surface
// so the run flips to failed rather than succeeding with a partial
// response.
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Partial output' },
},
{
type: 'message_update',
assistantMessageEvent: { type: 'error', reason: 'context_overflow' },
},
]);
// status:working, status:thinking, status:streaming, text_delta, error
assert.equal(events.length, 5);
const errorEvent = events.find((e) => e.type === 'error');
assert.ok(errorEvent, 'should emit an error event after partial output');
assert.equal(errorEvent.message, 'context_overflow');
});
// ─── auto_retry_end failure event handling ────────────────────────────────────
test('pi RPC: auto_retry_end with success=false maps to error event', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 1000, errorMessage: 'overloaded' },
{ type: 'auto_retry_end', success: false, attempt: 3, finalError: '529 overloaded_error: Overloaded' },
]);
// auto_retry_start → status:retrying, auto_retry_end → error
assert.equal(events.length, 2);
assert.equal(events[0].type, 'status');
assert.equal(events[0].label, 'retrying');
assert.equal(events[1].type, 'error');
assert.equal(events[1].message, '529 overloaded_error: Overloaded');
});
test('pi RPC: auto_retry_end with success=true does not emit error', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 1000, errorMessage: 'overloaded' },
{ type: 'auto_retry_end', success: true, attempt: 2 },
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'status');
assert.equal(events[0].label, 'retrying');
});
test('pi RPC: auto_retry_end failure with missing finalError uses fallback', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_end', success: false, attempt: 3 },
]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'error');
assert.equal(events[0].message, 'Auto-retry exhausted');
});
// ─── imagePaths forwarding in attachPiRpcSession ─────────────────────────────
test('attachPiRpcSession sends prompt with images when imagePaths provided', async () => {
const { child } = createSession();
// Create a small test image file.
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const tmpFile = path.join(tmpDir, `pi-rpc-test-${Date.now()}.png`);
await import('node:fs/promises').then((fsp) =>
fsp.writeFile(tmpFile, Buffer.from('iVBORw0KGgo=', 'base64')),
);
try {
const events2 = [];
const send2 = (channel, payload) => events2.push({ channel, ...payload });
const child2 = createMockChild();
attachPiRpcSession({
child: child2,
prompt: 'describe this image',
cwd: '/tmp',
model: null,
send: send2,
imagePaths: [tmpFile],
});
// Read the stdin data to find the prompt command.
const chunks = [];
child2.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child2.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
assert.ok(parsed.images, 'prompt should include images array');
assert.equal(parsed.images.length, 1);
assert.equal(parsed.images[0].type, 'image');
assert.equal(parsed.images[0].mimeType, 'image/png');
assert.ok(typeof parsed.images[0].data === 'string' && parsed.images[0].data.length > 0);
} finally {
await import('node:fs/promises').then((fsp) => fsp.unlink(tmpFile).catch(() => {}));
}
});
test('attachPiRpcSession sends prompt without images when imagePaths is empty', () => {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'hello',
cwd: '/tmp',
model: null,
send,
imagePaths: [],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images when none provided');
});
test('attachPiRpcSession skips unreadable image paths gracefully', () => {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: ['/nonexistent/path/fake-image.png'],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = JSON.parse(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images for unreadable paths');
});
test('attachPiRpcSession rejects non-file image paths (directories)', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const dirPath = path.join(tmpDir, `pi-rpc-test-dir-${Date.now()}`);
await fsp.mkdir(dirPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'check this dir',
cwd: '/tmp',
model: null,
send,
imagePaths: [dirPath],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
assert.equal(parsed.images, undefined, 'directories should not be forwarded as images');
} finally {
await fsp.rmdir(dirPath);
}
});
test('attachPiRpcSession rejects disallowed image extensions', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const tmpFile = path.join(tmpDir, `pi-rpc-test-${Date.now()}.txt`);
await fsp.writeFile(tmpFile, 'not an image');
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'what is this',
cwd: '/tmp',
model: null,
send,
imagePaths: [tmpFile],
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
assert.equal(parsed.images, undefined, '.txt files should not be forwarded as images');
} finally {
await fsp.unlink(tmpFile);
}
});
test('attachPiRpcSession rejects symlink escape outside uploadRoot', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
// Create a real image file outside the upload root.
const outsideDir = path.join(tmpDir, `pi-rpc-test-outside-${Date.now()}`);
await fsp.mkdir(outsideDir);
const outsideFile = path.join(outsideDir, 'real.jpg');
await fsp.writeFile(outsideFile, Buffer.from('fake-jpg-content'));
// Create the upload root and a symlink inside it pointing outside.
const uploadRoot = path.join(tmpDir, `pi-rpc-test-uploads-${Date.now()}`);
await fsp.mkdir(uploadRoot);
const symlinkPath = path.join(uploadRoot, 'escape.jpg');
await fsp.symlink(outsideFile, symlinkPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: [symlinkPath],
uploadRoot,
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
assert.equal(parsed.images, undefined, 'symlinks resolving outside uploadRoot should not be forwarded as images');
} finally {
await fsp.unlink(symlinkPath);
await fsp.rmdir(uploadRoot);
await fsp.unlink(outsideFile);
await fsp.rmdir(outsideDir);
}
});
test('attachPiRpcSession allows symlink inside uploadRoot', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
// Create a real image file inside the upload root.
const uploadRoot = path.join(tmpDir, `pi-rpc-test-uploads-in-${Date.now()}`);
await fsp.mkdir(uploadRoot);
const realFile = path.join(uploadRoot, 'real.png');
// Minimal valid PNG header + IHDR so the content isn't empty.
await fsp.writeFile(realFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
// Create a symlink inside the same root pointing to the real file.
const symlinkPath = path.join(uploadRoot, 'link.png');
await fsp.symlink(realFile, symlinkPath);
try {
const events = [];
const send = (channel, payload) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: [symlinkPath],
uploadRoot,
});
const chunks = [];
child.stdin.on('data', (chunk) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return JSON.parse(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = JSON.parse(promptLine);
assert.ok(Array.isArray(parsed.images), 'symlink inside uploadRoot should be forwarded as image');
assert.equal(parsed.images.length, 1);
assert.equal(parsed.images[0].type, 'image');
assert.equal(parsed.images[0].mimeType, 'image/png');
} finally {
await fsp.unlink(symlinkPath);
await fsp.unlink(realFile);
await fsp.rmdir(uploadRoot);
}
});
// ─── original test continues ────────────────────────────────────────────────
test('attachPiRpcSession: no agent events emitted after abort()', () => {
const { child, events, session } = createSession();

View file

@ -97,6 +97,7 @@ If both signals agree, detection is confident. If only one signal fires, we mark
| **vibe** | `vibe-acp` | `~/.vibe/` | ❌ | ✅ | ✅ (`acp-json-rpc`) | P2 |
| **deepseek** | `deepseek` | `~/.deepseek/` | `~/.deepseek/skills/` | ❌ (prompt-injected) | ✅ | ✅ (plain text) | P2 |
| **qoder** | `qodercli` | Qoder CLI config | Qoder CLI managed | ❌ (prompt-injected) | ✅ | ✅ (`stream-json`) | P2 |
| **pi** | `pi` | `~/.pi/agent/` | `~/.pi/agent/skills/` | ❌ (prompt-injected) | ✅ | ✅ (`pi-rpc` JSON-RPC) | P2 |
"P0/P1/P2" correspond to the roadmap phases in [`roadmap.md`](roadmap.md).
@ -208,6 +209,17 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin
- Permission: `--permission-mode bypass_permissions` avoids headless approval prompts in the web UI. Users should treat this as the same trust posture as running Qoder directly with that flag in the selected project directory.
- **Gotcha:** Detection only proves `qodercli --version` can run. Qoder authentication and account scope remain owned by Qoder CLI, with credentials read from Qoder's `~/.qoder/config.json`; the daemon surfaces stderr/stdout failures from the spawned run instead of running login or editing Qoder config.
### 5.11 Pi
- Invocation: `pi --mode rpc [--model <id>] [--thinking <level>] [--append-system-prompt <dir> …]`, with the composed prompt delivered over stdin via JSON-RPC. The daemon sends a `prompt` command (optionally with `images` for multimodal input) and pi streams back typed events until `agent_end`. Pi's RPC process stays alive after `agent_end` (designed for multi-prompt sessions); the daemon closes stdin and SIGTERMs after a grace period since `/api/chat` is single-shot.
- Streaming: `pi-rpc` JSON-RPC over stdio. Events include `agent_start`, `turn_start/end`, `message_update` (text deltas, thinking deltas, tool calls), `tool_execution_start/end`, `compaction_start`, `auto_retry_start/end`, `extension_error`. `apps/daemon/src/pi-rpc.ts` maps these onto the same UI event set as `claude-stream.js` / `copilot-stream.js` / `acp.js`. Error events from `extension_error` and exhausted `auto_retry_end` are routed through `sendAgentEvent` so the daemon's empty-output guard and `agentStreamError` flag apply (same path as qoder-stream-json and json-event-stream after issue #691).
- Models: dynamic — `pi --list-models` prints a TSV table to stderr that the daemon parses into provider/model picker entries. Fallback hints for the most common providers/models are shipped for when the list command times out.
- Images: pi's RPC `prompt` command supports an `images` field (base64-encoded `ImageContent` objects). The daemon reads validated `imagePaths` at session attach time and includes them in the prompt command. Unreadable images are skipped rather than failing the run.
- Skills: prompt injection in v1. `extraAllowedDirs` (skill seed and design-system directories) are forwarded as `--append-system-prompt` repeatable flags so the agent knows these directories exist and can Read files inside them. pi doesn't have an `--add-dir` sandbox flag — it uses OS cwd — so system-prompt hints are the only available mechanism. **Important:** `--append-system-prompt` only hints paths in the system prompt; it does not grant sandbox or filesystem access. pi's Read tool can normally open absolute paths outside cwd, but when absolute reads fail (sandboxed environments, restricted permissions), the reliable fallback is to stage copies of the needed files into the project cwd before the run. No stronger pi flag exists for this purpose today.
- Thinking: the daemon exposes pi's `--thinking` levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`) in the Settings model picker.
- Extension UI: auto-resolved. pi's RPC protocol can request user dialogs (`select`, `confirm`, `input`, `editor`) and fire-and-forget notifications (`setStatus`, `setWidget`, `notify`, `setTitle`, `set_editor_text`). Dialog methods are auto-approved (confirm → true, select → first option) and fire-and-forget methods are silently consumed because the web UI has no surface for them.
- **Gotcha:** pi's RPC `prompt` response is asynchronous — `success: true` only means the prompt was accepted, not that the agent finished. Agent failures after acceptance surface through the normal event stream (`extension_error`, `auto_retry_end` with `success: false`) and the empty-output guard.
### 5.10 DeepSeek TUI
- Invocation: `deepseek exec --auto [--model <id>] "<prompt>"`. The `deepseek` dispatcher owns the `exec` / `--auto` subcommands and delegates to a sibling `deepseek-tui` runtime binary at exec time; upstream documents both binaries as required (the npm and cargo paths install them together). We only probe the dispatcher — `deepseek-tui` on its own doesn't accept this argv shape, so advertising it as a fallback would surface the agent as available but fail on the first chat run. A future revision could teach resolution + buildArgs which binary was selected and emit a verified `deepseek-tui` invocation, with a regression test exercising that path.