pi-harness-delegate 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,11 @@ import type {
14
14
  // non-interactive; sandbox alone governs what's allowed) and resume is a subcommand
15
15
  // (`exec resume <id> <prompt>`), not a `--thread-id` flag — both confirmed via `codex exec
16
16
  // --help` / `codex exec resume --help`, not just the JSONL capture.
17
+ //
18
+ // Resume itself live-verified end-to-end against codex-cli 0.150.1 — see
19
+ // tests/fixtures/codex-resume.jsonl: a first `codex exec` turn was taught a fact, then a second,
20
+ // independent process ran `codex exec resume <id> <prompt>` and correctly recalled it, proving
21
+ // resume genuinely restores prior context rather than just replaying/echoing the session id.
17
22
 
18
23
  const execFileAsync = promisify(execFile);
19
24
  function isRecord(v: unknown): v is Record<string, unknown> {
@@ -245,7 +250,10 @@ export const codexHarness: Harness = {
245
250
  ? ['exec', 'resume', opts.resumeSessionId, opts.prompt, '--json']
246
251
  : ['exec', '--json', opts.prompt, '--sandbox', sandbox];
247
252
  if (opts.model) args.push('--model', opts.model);
248
- for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
253
+ // `codex exec resume --help` has no `--sandbox`/`--add-dir` at all (confirmed live: passing
254
+ // --add-dir to resume is a hard CLI error, "unexpected argument '--add-dir' found") — only the
255
+ // fresh-turn branch above supports either flag.
256
+ if (!opts.resumeSessionId) for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
249
257
  return args;
250
258
  },
251
259
  parseLine(line: string, state: ParseState): ParseOutcome {
@@ -275,4 +283,8 @@ export const codexHarness: Harness = {
275
283
  return null;
276
284
  },
277
285
  permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
286
+ // No `acp` subcommand exists; `app-server` is a different, codex-proprietary JSON-RPC protocol,
287
+ // not ACP (docs/acp-harness-assessment.md §2) — confirmed against the full `--help` output of
288
+ // `codex`, `codex mcp-server`, `codex app-server`, and `codex exec`.
289
+ supportsTransports: ['stdout'],
278
290
  };
@@ -203,4 +203,6 @@ export const devinHarness: Harness = {
203
203
  edit: [PERMISSION_MAP.edit],
204
204
  danger: [PERMISSION_MAP.danger],
205
205
  },
206
+ // ACP-only — no stdout mode exists to select between, so there's nothing to configure.
207
+ supportsTransports: ['acp'],
206
208
  };
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import type {
4
+ ActivityEvent,
4
5
  BuildArgsOpts,
5
6
  Harness,
6
7
  NormalizedPermission,
@@ -13,6 +14,15 @@ import type {
13
14
  // `opencode run` in this version has no `--permission` or `--add-dir` flag at all (confirmed via
14
15
  // `opencode run --help`) — permission tiers map onto built-in agents instead (`opencode agent
15
16
  // list`: "plan" is the read-only-oriented primary agent, "build" is the full-access one).
17
+ //
18
+ // This harness also supports the ACP transport (`opencode acp`) — see the `parseOpencodeAcpLine`/
19
+ // `buildAcpArgs`/`ACP_MODE_MAP` block below and tests/fixtures/opencode-acp*.jsonl. Live-verified
20
+ // (docs/acp-harness-assessment.md §2/§4, closed by a second live run — see
21
+ // tests/fixtures/opencode-acp-build-write.jsonl): a real write prompt under `build` mode produced
22
+ // a `tool_call`/`tool_call_update` pair with `kind: "edit"` and no `session/request_permission`
23
+ // request at all — this project's ACP client auto-declines that request when it arrives (see
24
+ // acp-runner.ts's `handleServerRequest`), so its absence here means `edit`/`danger` genuinely
25
+ // execute over ACP rather than silently no-op.
16
26
 
17
27
  const execFileAsync = promisify(execFile);
18
28
  function isRecord(v: unknown): v is Record<string, unknown> {
@@ -23,6 +33,14 @@ const AGENT_MAP: Record<NormalizedPermission, string> = {
23
33
  edit: 'build',
24
34
  danger: 'build',
25
35
  };
36
+ // A distinct vocabulary from AGENT_MAP even though the values happen to coincide today (`plan`/
37
+ // `build` are both the CLI agent name and the ACP `session/set_mode` modeId) — kept separate on
38
+ // purpose, per docs/acp-harness-assessment.md §6, so the two never silently drift together.
39
+ const ACP_MODE_MAP: Record<NormalizedPermission, string> = {
40
+ readonly: 'plan',
41
+ edit: 'build',
42
+ danger: 'build',
43
+ };
26
44
  function extractOpencodeText(o: Record<string, unknown>): string | undefined {
27
45
  if (typeof o.text === 'string') return o.text;
28
46
  if (typeof o.output === 'string') return o.output;
@@ -165,6 +183,136 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
165
183
  }
166
184
  return { activities, streamedText };
167
185
  }
186
+
187
+ interface OpencodeAcpHarnessState {
188
+ sessionId?: string;
189
+ contextWindow?: number;
190
+ costUsd?: number;
191
+ }
192
+
193
+ function acpHarnessState(state: ParseState): OpencodeAcpHarnessState {
194
+ const s = (state._harness ?? {}) as OpencodeAcpHarnessState;
195
+ state._harness = s as unknown as Record<string, unknown>;
196
+ return s;
197
+ }
198
+
199
+ /** Translate one `session/update` notification's `params.update` payload into ParseOutcome deltas.
200
+ * Same wire dialect as devin.ts's `translateUpdate` (both are real ACP `session/update` payloads —
201
+ * see docs/acp-harness-assessment.md §2's evidence that opencode/omp share an ACP implementation),
202
+ * kept as an independent function rather than a shared import so devin.ts needs zero changes here. */
203
+ function translateOpencodeAcpUpdate(update: Record<string, unknown>, state: ParseState): ParseOutcome {
204
+ const activities: ActivityEvent[] = [];
205
+ let streamedText: string | undefined;
206
+ const content = isRecord(update.content) ? update.content : undefined;
207
+ const text = content?.type === 'text' && typeof content.text === 'string' ? content.text : undefined;
208
+ const hs = acpHarnessState(state);
209
+
210
+ switch (update.sessionUpdate) {
211
+ case 'agent_message_chunk':
212
+ if (text !== undefined) streamedText = text;
213
+ break;
214
+ case 'agent_thought_chunk':
215
+ if (text !== undefined) activities.push({ kind: 'thinking', chars: text.length });
216
+ break;
217
+ case 'tool_call': {
218
+ if (typeof update.toolCallId !== 'string') break;
219
+ // real fixture: `title` is the tool name ("read", "glob", "write"), matching the stdout
220
+ // parser's `part.tool` convention — fall back to `kind` (the coarser category) if absent.
221
+ const name =
222
+ typeof update.title === 'string' ? update.title : typeof update.kind === 'string' ? update.kind : 'tool';
223
+ activities.push({
224
+ kind: 'tool_input',
225
+ name,
226
+ input: isRecord(update.rawInput) ? update.rawInput : {},
227
+ id: update.toolCallId,
228
+ });
229
+ break;
230
+ }
231
+ case 'tool_call_update': {
232
+ // Only a terminal status produces a tool_result — same reasoning as devin.ts: a call fires
233
+ // multiple `in_progress` updates before its `completed`/`failed`, and ToolCallIndex.resolve()
234
+ // consumes the pending entry on first match.
235
+ if (typeof update.toolCallId !== 'string') break;
236
+ if (update.status === 'completed' || update.status === 'failed') {
237
+ activities.push({ kind: 'tool_result', isError: update.status === 'failed', id: update.toolCallId });
238
+ }
239
+ break;
240
+ }
241
+ case 'usage_update':
242
+ // Both fields are genuinely real over ACP (unlike stdout's `null`s) — see
243
+ // docs/acp-harness-assessment.md §2 — and, per the same section, this is a running session
244
+ // total already, latched from the *last* usage_update seen, never summed across a series.
245
+ if (typeof update.size === 'number') hs.contextWindow = update.size;
246
+ if (isRecord(update.cost) && typeof update.cost.amount === 'number') hs.costUsd = update.cost.amount;
247
+ break;
248
+ default:
249
+ break;
250
+ }
251
+ return { streamedText, activities };
252
+ }
253
+
254
+ export function parseOpencodeAcpLine(line: string, state: ParseState): ParseOutcome {
255
+ let o: unknown;
256
+ try {
257
+ o = JSON.parse(line);
258
+ } catch {
259
+ return {};
260
+ }
261
+ if (!isRecord(o)) return {};
262
+
263
+ if (o.method === 'session/update' && isRecord(o.params) && isRecord(o.params.update)) {
264
+ return translateOpencodeAcpUpdate(o.params.update, state);
265
+ }
266
+
267
+ if (isRecord(o.result)) {
268
+ const r = o.result;
269
+ if (typeof r.stopReason === 'string') {
270
+ // session/prompt response — the turn is over, build the final result.
271
+ const u = isRecord(r.usage) ? r.usage : null;
272
+ const hs = acpHarnessState(state);
273
+ const result: StreamedResult = {
274
+ result: state.streamedText,
275
+ // opencode's ACP prompt response carries no error flag of its own — a genuine failure
276
+ // surfaces via acp-runner.ts's process-level fail() path, same as every other harness.
277
+ isError: false,
278
+ numTurns: null, // never observed populated over ACP (docs/acp-harness-assessment.md §2)
279
+ totalCostUsd: typeof hs.costUsd === 'number' ? hs.costUsd : null,
280
+ sessionId: typeof hs.sessionId === 'string' ? hs.sessionId : null,
281
+ stopReason: r.stopReason,
282
+ permissionDenials: [],
283
+ durationMs: null,
284
+ durationApiMs: null,
285
+ ttftMs: null,
286
+ // Only ever the *requested* configOptions value from session/new, never confirmed back
287
+ // the way Devin's agent_stopped event confirms what actually ran — null is the honest
288
+ // value, not a guess.
289
+ model: null,
290
+ contextWindow: typeof hs.contextWindow === 'number' ? hs.contextWindow : null,
291
+ maxOutputTokens: null,
292
+ usage: u
293
+ ? {
294
+ // Unlike Devin's inputTokens (which includes cachedReadTokens as a subset and needs
295
+ // subtracting), opencode's inputTokens already EXCLUDES it — verified against the
296
+ // real capture: inputTokens + cachedReadTokens === totalTokens - outputTokens exactly
297
+ // in both tests/fixtures/opencode-acp.jsonl and opencode-acp-build-write.jsonl.
298
+ inputTokens: typeof u.inputTokens === 'number' ? u.inputTokens : 0,
299
+ outputTokens: typeof u.outputTokens === 'number' ? u.outputTokens : 0,
300
+ cacheCreationInputTokens: 0, // not reported over ACP
301
+ cacheReadInputTokens: typeof u.cachedReadTokens === 'number' ? u.cachedReadTokens : 0,
302
+ }
303
+ : null,
304
+ };
305
+ return { result };
306
+ }
307
+ if (typeof r.sessionId === 'string') {
308
+ // session/new response — stash the id; session/load's response has none of its own
309
+ // (acp-runner.ts stashes it into state._harness.sessionId itself on a resume).
310
+ acpHarnessState(state).sessionId = r.sessionId;
311
+ }
312
+ }
313
+ return {};
314
+ }
315
+
168
316
  export const opencodeHarness: Harness = {
169
317
  name: 'opencode',
170
318
  displayName: 'OpenCode',
@@ -212,4 +360,16 @@ export const opencodeHarness: Harness = {
212
360
  return null;
213
361
  },
214
362
  permissionMap: { readonly: ['plan'], edit: ['build'], danger: ['build', '--auto'] },
363
+ // ACP path — opt-in only (transport defaults to 'stdout'; see config.ts's resolveTransport).
364
+ // `--model` isn't wired here: `opencode acp --help` has no such flag, unlike `devin acp
365
+ // --model`; the ACP handshake's own `configOptions` "model" category is the real mechanism
366
+ // (session/set_config_option, unverified live — see ROADMAP.md), out of scope for this change.
367
+ buildAcpArgs(): string[] {
368
+ return ['acp'];
369
+ },
370
+ parseAcpLine(line: string, state: ParseState): ParseOutcome {
371
+ return parseOpencodeAcpLine(line, state);
372
+ },
373
+ acpPermissionMap: { readonly: [ACP_MODE_MAP.readonly], edit: [ACP_MODE_MAP.edit], danger: [ACP_MODE_MAP.danger] },
374
+ supportsTransports: ['stdout', 'acp'],
215
375
  };
@@ -86,19 +86,34 @@ export interface Harness {
86
86
  aliases?: string[];
87
87
  /** Check if binary is available. */
88
88
  detect(): Promise<DetectResult>;
89
- /** Build CLI args (excluding binary). */
89
+ /** Build CLI args (excluding binary) for the 'stdout' transport. */
90
90
  buildArgs(opts: BuildArgsOpts): string[];
91
- /** Parse a single stdout line. State is mutated by runner; return deltas. */
91
+ /** Parse a single stdout-transport line. State is mutated by runner; return deltas. */
92
92
  parseLine(line: string, state: ParseState): ParseOutcome;
93
93
  /** Extract final result after process exit (state.result may already be set). */
94
94
  extractResult(state: ParseState): StreamedResult | null;
95
- /** Normalized -> native arg fragments. */
95
+ /** Normalized -> native CLI arg fragments, used by the 'stdout' transport's buildArgs/nativePermission. */
96
96
  permissionMap?: Record<NormalizedPermission, string[]>;
97
97
  permissionHint?: (permission: NormalizedPermission) => string[];
98
- /** Runner selection. Omitted/'stdout' -> runner.ts (the four existing harnesses); 'acp' -> acp-runner.ts.
99
- * For 'acp', `permissionMap`'s first element per tier is the ACP session mode id (session/set_mode) —
100
- * the same field the stdout harnesses use for CLI arg fragments, reused rather than duplicated. */
98
+ /** Which transports this harness's binary actually supports — the ceiling `config.harnesses.<name>.transport`
99
+ * is validated against (see config.ts's `resolveTransport`), independent of what a user configures.
100
+ * Omitted -> `[transport ?? 'stdout']` (today's single-transport harnesses). A harness legally offering
101
+ * both 'stdout' and 'acp' must also declare `buildAcpArgs`/`parseAcpLine`/`acpPermissionMap` below. */
102
+ supportsTransports?: Transport[];
103
+ /** Default transport when config doesn't override. Omitted -> 'stdout'. */
101
104
  transport?: Transport;
105
+ /** Build args to spawn the ACP server (e.g. ['acp']), for a harness whose `supportsTransports`
106
+ * includes 'acp'. Devin (ACP-only) has no separate stdout buildArgs, so it reuses `buildArgs` for
107
+ * this and doesn't need to declare `buildAcpArgs` — acp-runner.ts's caller falls back to `buildArgs`
108
+ * when `buildAcpArgs` is absent (see acp-runner.ts's `acpView`). */
109
+ buildAcpArgs?(opts: BuildArgsOpts): string[];
110
+ /** Parse a single ACP JSON-RPC line (see acp-runner.ts). Falls back to `parseLine` when absent, same
111
+ * reasoning as `buildAcpArgs`. */
112
+ parseAcpLine?(line: string, state: ParseState): ParseOutcome;
113
+ /** Normalized -> ACP session mode id (`session/set_mode`'s `modeId`) — a distinct vocabulary from
114
+ * `permissionMap`'s CLI arg fragments even when a value happens to coincide (e.g. opencode's `build`
115
+ * is both). Falls back to `permissionMap` when absent, same reasoning as `buildAcpArgs`. */
116
+ acpPermissionMap?: Record<NormalizedPermission, string[]>;
102
117
  }
103
118
 
104
119
  export const DEFAULT_TIMEOUT_MS = 600_000;