pi-harness-delegate 0.4.1 → 0.6.0

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.
@@ -1,13 +1,20 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
3
+ import { dirname, join } from 'node:path';
4
+ import { DEFAULT_TIMEOUT_MS, type Harness, type Transport } from './harnesses/types.ts';
5
5
 
6
6
  export interface HarnessConfig {
7
7
  model?: string;
8
8
  timeoutMs?: number;
9
9
  allowDangerous?: boolean;
10
10
  maxBudgetUsd?: number;
11
+ /** Overrides the harness's default transport ('stdout' unless the harness itself defaults to
12
+ * 'acp', e.g. Devin). A malformed value (not 'stdout'/'acp') is dropped at load time, same as
13
+ * every other per-harness field's defensive parsing below; a well-formed but unsupported value
14
+ * (e.g. 'acp' for a harness with no ACP surface) is *not* dropped here — it's validated against
15
+ * `Harness.supportsTransports` by `resolveTransport()` instead, so misconfiguring it fails the
16
+ * run with a clear message rather than being silently ignored. */
17
+ transport?: Transport;
11
18
  }
12
19
 
13
20
  export interface DelegateConfig {
@@ -25,6 +32,36 @@ export interface DelegateConfig {
25
32
  harnesses: Record<string, HarnessConfig>;
26
33
  }
27
34
 
35
+ /**
36
+ * Provenance for how `loadConfig()` actually resolved its settings — the bit a bare `try/catch {}`
37
+ * used to erase entirely, making "no file", "file, no relevant key", and "file, unparseable" all
38
+ * look identical (empty defaults, no signal anywhere). `usedKey` is which key ended up populating
39
+ * `cfg`: `'delegate'` and `'claudeDelegate'` are mutually exclusive (the legacy branch only runs
40
+ * when `delegate` is *absent*), so `'claudeDelegate'` here means the legacy-only case that costs
41
+ * the user every setting under `delegate` — not "legacy key present at all" (see
42
+ * `legacyKeyPresent` for that). `raw` is the literal value of whichever key was used, exactly as
43
+ * read from the file — no defaults merged in, no per-field sanitizing — so `/delegate config` can
44
+ * show what was actually written next to what it resolved to.
45
+ */
46
+ export interface ConfigSource {
47
+ file: string;
48
+ fileExists: boolean;
49
+ /** Set when the file exists but `JSON.parse` failed, or the parsed value isn't a JSON object
50
+ * (e.g. an array or a bare string) — either way `cfg` fell back to defaults. */
51
+ parseError?: string;
52
+ usedKey: 'delegate' | 'claudeDelegate' | 'none';
53
+ /** Whether a `claudeDelegate` key exists in the file at all, independent of `usedKey` — true
54
+ * even when `delegate` won and `claudeDelegate` was only partially merged (the `model`
55
+ * fallback below). */
56
+ legacyKeyPresent: boolean;
57
+ raw?: unknown;
58
+ }
59
+
60
+ export interface ConfigLoadResult {
61
+ config: DelegateConfig;
62
+ source: ConfigSource;
63
+ }
64
+
28
65
  export function agentDir(): string {
29
66
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
30
67
  }
@@ -38,7 +75,24 @@ export function legacyOutputsDir(): string {
38
75
  return join(agentDir(), 'claude-delegate', 'outputs');
39
76
  }
40
77
 
41
- export function loadConfig(): DelegateConfig {
78
+ /** Drops a malformed `transport` (anything but 'stdout'/'acp', including absent) before it's
79
+ * stored — the rest of `HarnessConfig`'s fields are spread through as-is by `loadConfig()`. */
80
+ function sanitizeHarnessConfig(v: HarnessConfig): HarnessConfig {
81
+ const out = { ...v };
82
+ if (out.transport !== 'stdout' && out.transport !== 'acp') delete out.transport;
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Loads `delegate` config from `~/.pi/agent/settings.json` alongside `ConfigSource`, describing
88
+ * how that happened (file present? which key won? did it fail to parse?) — see `ConfigSource`'s
89
+ * doc comment. `loadConfig()` below is a thin wrapper for the existing call sites that only want
90
+ * the resolved values; this is the one place that actually reads/parses the file, so a caller
91
+ * needing both never pays for a second parse. Never throws — any failure (missing file, bad JSON,
92
+ * a non-object root) is recorded on `source` and falls back to the same defaults `loadConfig()`
93
+ * has always returned.
94
+ */
95
+ export function loadConfigWithSource(): ConfigLoadResult {
42
96
  const cfg: DelegateConfig = {
43
97
  timeoutMs: DEFAULT_TIMEOUT_MS,
44
98
  defaultMode: 'general',
@@ -51,15 +105,31 @@ export function loadConfig(): DelegateConfig {
51
105
  maxTranscripts: 100,
52
106
  harnesses: {},
53
107
  };
108
+ const file = join(agentDir(), 'settings.json');
109
+ const source: ConfigSource = { file, fileExists: false, usedKey: 'none', legacyKeyPresent: false };
54
110
  try {
55
- const file = join(agentDir(), 'settings.json');
56
- if (!existsSync(file)) return cfg;
57
- const settings = JSON.parse(readFileSync(file, 'utf8')) as {
111
+ if (!existsSync(file)) return { config: cfg, source };
112
+ source.fileExists = true;
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
116
+ } catch (err) {
117
+ source.parseError = err instanceof Error ? err.message : String(err);
118
+ return { config: cfg, source };
119
+ }
120
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
121
+ source.parseError = 'settings.json root is not a JSON object';
122
+ return { config: cfg, source };
123
+ }
124
+ const settings = parsed as {
58
125
  delegate?: Partial<DelegateConfig & { harnesses: Record<string, HarnessConfig> }>;
59
126
  claudeDelegate?: Partial<DelegateConfig & { model?: string }>;
60
127
  };
128
+ source.legacyKeyPresent = Boolean(settings.claudeDelegate);
61
129
  // Legacy claudeDelegate -> delegate.harnesses.claude migration
62
130
  if (settings.claudeDelegate && !settings.delegate) {
131
+ source.usedKey = 'claudeDelegate';
132
+ source.raw = settings.claudeDelegate;
63
133
  const c = settings.claudeDelegate as Partial<DelegateConfig>;
64
134
  if (typeof c.model === 'string') cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
65
135
  if (typeof c.timeoutMs === 'number' && c.timeoutMs > 0) cfg.timeoutMs = c.timeoutMs;
@@ -92,12 +162,15 @@ export function loadConfig(): DelegateConfig {
92
162
  if (typeof c.maxTranscripts === 'number' && c.maxTranscripts >= 0) cfg.maxTranscripts = c.maxTranscripts;
93
163
  if (c.harnesses && typeof c.harnesses === 'object') {
94
164
  for (const [k, v] of Object.entries(c.harnesses)) {
95
- if (v && typeof v === 'object') cfg.harnesses[k] = { ...(cfg.harnesses[k] ?? {}), ...(v as HarnessConfig) };
165
+ if (v && typeof v === 'object')
166
+ cfg.harnesses[k] = { ...(cfg.harnesses[k] ?? {}), ...sanitizeHarnessConfig(v as HarnessConfig) };
96
167
  }
97
168
  }
98
169
  // also map harnesses.claude if any
99
- return cfg;
170
+ return { config: cfg, source };
100
171
  }
172
+ source.usedKey = settings.delegate ? 'delegate' : 'none';
173
+ source.raw = settings.delegate;
101
174
  const d = settings.delegate ?? {};
102
175
  if (typeof d.defaultHarness === 'string' && d.defaultHarness) cfg.defaultHarness = d.defaultHarness;
103
176
  if (typeof d.defaultMode === 'string') cfg.defaultMode = d.defaultMode;
@@ -129,7 +202,7 @@ export function loadConfig(): DelegateConfig {
129
202
  if (typeof d.maxTranscripts === 'number' && d.maxTranscripts >= 0) cfg.maxTranscripts = d.maxTranscripts;
130
203
  if (d.harnesses && typeof d.harnesses === 'object') {
131
204
  for (const [k, v] of Object.entries(d.harnesses)) {
132
- if (v && typeof v === 'object') cfg.harnesses[k] = { ...(v as HarnessConfig) };
205
+ if (v && typeof v === 'object') cfg.harnesses[k] = sanitizeHarnessConfig(v as HarnessConfig);
133
206
  }
134
207
  }
135
208
  // also support legacy claudeDelegate merged when delegate also present (delegate wins)
@@ -139,10 +212,121 @@ export function loadConfig(): DelegateConfig {
139
212
  cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
140
213
  }
141
214
  }
142
- } catch {
143
- // invalid settings fall back to defaults
215
+ } catch (err) {
216
+ // Anything unexpected (e.g. a read error after existsSync's check raced a delete) — still
217
+ // never throw; record it as a parse error so it's not silently indistinguishable from "no
218
+ // config" if it wasn't already caught (and thus reported) above.
219
+ if (!source.parseError) source.parseError = err instanceof Error ? err.message : String(err);
220
+ }
221
+ return { config: cfg, source };
222
+ }
223
+
224
+ export function loadConfig(): DelegateConfig {
225
+ return loadConfigWithSource().config;
226
+ }
227
+
228
+ /**
229
+ * Human-readable lines describing how `loadConfig()` actually resolved its settings — the
230
+ * provenance report used by both `/delegate status` and `/delegate config`. Pure: takes the
231
+ * `ConfigSource` companion to `loadConfigWithSource()`'s result, no I/O, so it's testable without
232
+ * touching the filesystem.
233
+ */
234
+ export function describeConfigSource(source: ConfigSource): string[] {
235
+ if (source.parseError) {
236
+ return [
237
+ `⚠ ${source.file} exists but failed to parse: ${source.parseError}`,
238
+ ' using defaults until this is fixed',
239
+ ];
240
+ }
241
+ if (!source.fileExists) {
242
+ return [`${source.file} not found — using defaults`];
243
+ }
244
+ if (source.usedKey === 'none') {
245
+ return [`${source.file} has no "delegate" key — using defaults`];
246
+ }
247
+ if (source.usedKey === 'claudeDelegate') {
248
+ return [
249
+ `⚠ using legacy "claudeDelegate" key in ${source.file}`,
250
+ ' two settings can never be reached this way: "defaultHarness" stays pinned to "claude", and there\'s no',
251
+ ' top-level default "model" (only claudeDelegate.model -> harnesses.claude.model migrates) — everything',
252
+ ' else (including per-harness settings like harnesses.<name>.transport) migrates fine',
253
+ ' rename "claudeDelegate" to "delegate" to unlock those two, or run `/delegate config init` to write an',
254
+ ' explicit "delegate" key for you (claudeDelegate itself is left untouched either way)',
255
+ ];
256
+ }
257
+ const lines = [`"delegate" key in ${source.file}`];
258
+ if (source.legacyKeyPresent) {
259
+ lines.push(' legacy "claudeDelegate" key is also present — ignored except claudeDelegate.model as a fallback');
260
+ }
261
+ return lines;
262
+ }
263
+
264
+ /**
265
+ * Full `/delegate config` report: provenance (`describeConfigSource`), the raw `delegate`/
266
+ * `claudeDelegate` value exactly as written in the file, and the effective config with defaults
267
+ * merged in — so a user can see both what they wrote and what it resolved to, and has a
268
+ * paste-ready starting point either way. Pure — takes an already-loaded `ConfigLoadResult`.
269
+ */
270
+ export function buildConfigReport(result: ConfigLoadResult): string[] {
271
+ const lines = [...describeConfigSource(result.source)];
272
+ lines.push('');
273
+ lines.push('from file (as written, before defaults are applied):');
274
+ lines.push(JSON.stringify(result.source.raw ?? {}, null, 2));
275
+ lines.push('');
276
+ lines.push('effective config (file merged with defaults) — paste under "delegate" in settings.json,');
277
+ lines.push('or run `/delegate config init` to write it there directly:');
278
+ lines.push(JSON.stringify({ delegate: result.config }, null, 2));
279
+ return lines;
280
+ }
281
+
282
+ export interface WriteConfigResult {
283
+ ok: boolean;
284
+ file: string;
285
+ message: string;
286
+ }
287
+
288
+ /**
289
+ * Writes `delegateSubtree` into `settings.json` under the `delegate` key — replacing only that
290
+ * key and preserving every other top-level key verbatim (including a leftover `claudeDelegate`,
291
+ * which is never touched or removed here; that's the user's call, made by editing the file
292
+ * themselves). Read-modify-write, atomic: writes to `<file>.<pid>.tmp` in the same directory then
293
+ * `renameSync`s over the target, so a process death mid-write can never leave a torn file. Refuses
294
+ * to write (returns `ok: false`, file untouched) when the existing file is present but fails to
295
+ * parse or isn't a JSON object — overwriting an already-broken file would destroy whatever the
296
+ * user has in it; the caller should fall back to `buildConfigReport`'s paste-ready block instead.
297
+ * Never throws. Only ever called from an explicit user action (`/delegate config init`) — never
298
+ * on the strength of a read like `loadConfig()`/`showStatus`.
299
+ */
300
+ export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult {
301
+ const file = join(agentDir(), 'settings.json');
302
+ try {
303
+ let root: Record<string, unknown> = {};
304
+ if (existsSync(file)) {
305
+ let parsed: unknown;
306
+ try {
307
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
308
+ } catch (err) {
309
+ return {
310
+ ok: false,
311
+ file,
312
+ message: `refusing to write: ${file} exists but failed to parse (${err instanceof Error ? err.message : String(err)}) — fix or remove it first`,
313
+ };
314
+ }
315
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
316
+ return { ok: false, file, message: `refusing to write: ${file} exists but its root isn't a JSON object` };
317
+ }
318
+ root = parsed as Record<string, unknown>;
319
+ }
320
+ root.delegate = delegateSubtree;
321
+ const dir = dirname(file);
322
+ mkdirSync(dir, { recursive: true });
323
+ const tmp = join(dir, `settings.json.${process.pid}.tmp`);
324
+ writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, 'utf8');
325
+ renameSync(tmp, file);
326
+ return { ok: true, file, message: `wrote "delegate" key to ${file}` };
327
+ } catch (err) {
328
+ return { ok: false, file, message: `failed to write ${file}: ${err instanceof Error ? err.message : String(err)}` };
144
329
  }
145
- return cfg;
146
330
  }
147
331
 
148
332
  export function resolveModelForHarness(
@@ -155,6 +339,25 @@ export function resolveModelForHarness(
155
339
  return resolve(model) ?? resolve(templateModel) ?? resolve(cfg.harnesses[harness]?.model) ?? resolve(cfg.model);
156
340
  }
157
341
 
342
+ /**
343
+ * Which transport a run should actually use: config override, falling back to the harness's own
344
+ * default (`harness.transport`, e.g. Devin's static 'acp'), falling back to 'stdout'. Validated
345
+ * against `harness.supportsTransports` — the ceiling of what the binary can actually do, distinct
346
+ * from what's configured — *before* the caller acquires a run slot or spawns anything, so
347
+ * misconfiguring e.g. `transport: 'acp'` for `claude` fails fast with a clear message instead of a
348
+ * cryptic "unknown subcommand" from the spawned process. See docs/acp-harness-assessment.md §5/§6.
349
+ */
350
+ export function resolveTransport(cfg: DelegateConfig, harnessName: string, harness: Harness): Transport {
351
+ const transport = cfg.harnesses[harnessName]?.transport ?? harness.transport ?? 'stdout';
352
+ const allowed = harness.supportsTransports ?? [harness.transport ?? 'stdout'];
353
+ if (!allowed.includes(transport)) {
354
+ throw new Error(
355
+ `delegate.harnesses.${harnessName}.transport is "${transport}", but ${harnessName} only supports: ${allowed.join(', ')}`,
356
+ );
357
+ }
358
+ return transport;
359
+ }
360
+
158
361
  export function getMaxConcurrent(cfg: DelegateConfig, harness?: string): number {
159
362
  if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
160
363
  // if object shape {global, perHarness}
@@ -159,14 +159,22 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
159
159
  ? (o.messages[o.messages.length - 1] as Record<string, unknown>)
160
160
  : null;
161
161
  const measured = (hs.turnCount ?? 0) > 0;
162
+ // real error shape: message.stopReason === 'error' + message.errorMessage — observed live on a
163
+ // 429 quota rejection, where message.content is an empty array, so the text-block extraction
164
+ // above never sets streamedText and this would otherwise silently report success with empty
165
+ // text (tests/fixtures/amp-error.jsonl).
166
+ const errorMessage = typeof msg?.errorMessage === 'string' ? (msg.errorMessage as string) : undefined;
167
+ const isErrorTurn = msg?.stopReason === 'error' || errorMessage !== undefined;
162
168
  const result: StreamedResult = {
163
169
  result:
164
170
  typeof o.result === 'string'
165
171
  ? o.result
166
- : streamedText
167
- ? state.streamedText + streamedText
168
- : state.streamedText || (text ?? ''),
169
- isError: o.is_error === true,
172
+ : errorMessage
173
+ ? errorMessage
174
+ : streamedText
175
+ ? state.streamedText + streamedText
176
+ : state.streamedText || (text ?? ''),
177
+ isError: o.is_error === true || isErrorTurn,
170
178
  numTurns: measured ? (hs.turnCount as number) : null,
171
179
  totalCostUsd: measured ? (hs.costAccum as number) : null,
172
180
  sessionId:
@@ -228,6 +236,9 @@ export const ampHarness: Harness = {
228
236
  const args = ['-p', '--mode', 'json', '--approval-mode', approvalMode];
229
237
  if (opts.model) args.push('--model', opts.model);
230
238
  if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
239
+ // `--add-dir=<value>` is real and repeatable per `omp --help` — confirmed live: a run with
240
+ // --add-dir echoed the directory back in the session line's `additionalDirectories`. (Earlier
241
+ // research had flagged this as possibly absent; that was wrong for omp 17.2.9.)
231
242
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
232
243
  args.push(opts.prompt);
233
244
  return args;
@@ -259,4 +270,13 @@ export const ampHarness: Harness = {
259
270
  return null;
260
271
  },
261
272
  permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
273
+ // `omp acp` is real and live-verified (docs/acp-harness-assessment.md §2/§4) — but deliberately
274
+ // NOT offered as a config value yet: its ACP mode surface only has 2 tiers (`default`/`plan`),
275
+ // while the stdout `--approval-mode` above has 3 genuine ones. Adding 'acp' here would let a
276
+ // user configure `edit`, expecting "ask before every write", and silently collapse it onto the
277
+ // same `default` mode as `danger` — a real permission-tier regression, not a cosmetic one. Only
278
+ // revisit if a future omp ACP version exposes a third tier (e.g. an `approval-mode`-shaped
279
+ // `configOptions` category, the same slot `thinking` already occupies today) — redo the §4-style
280
+ // live permission-tier analysis before changing this, don't just add 'acp' to the list.
281
+ supportsTransports: ['stdout'],
262
282
  };
@@ -156,4 +156,7 @@ export const claudeHarness: Harness = {
156
156
  edit: ['acceptEdits'],
157
157
  danger: ['bypassPermissions'],
158
158
  },
159
+ // No `acp` subcommand exists (docs/acp-harness-assessment.md §2) — confirmed against the full
160
+ // `claude --help` output, not just an earlier probe.
161
+ supportsTransports: ['stdout'],
159
162
  };
@@ -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
  };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Devin — runs over the Agent Client Protocol (`devin acp`), not stdout JSONL. `transport: 'acp'`
3
+ * routes it through extensions/acp-runner.ts instead of runner.ts; `buildArgs` here only needs to
4
+ * spawn the ACP server (`devin acp`) — the prompt, permission mode, and session lifecycle are all
5
+ * negotiated over the wire by acp-runner.ts, not passed as CLI flags.
6
+ *
7
+ * `parseLine`/`extractResult` translate raw ACP JSON-RPC lines (one per line, same as any other
8
+ * harness's JSONL) into `ParseOutcome`/`StreamedResult` — this is what makes the JSON-RPC plumbing
9
+ * testable via the same fixture-replay pattern as the stdout harnesses (tests/fixtures.test.ts),
10
+ * without spawning a process: `tests/fixtures/devin-acp.jsonl` is a real captured session.
11
+ *
12
+ * Schema verified against `devin 3000.6.7 (260a97c8)` — see docs/devin-acp-harness-design.md.
13
+ *
14
+ * Workspace trust: the design note this was built from flagged `devin`'s interactive workspace-trust
15
+ * gate as a hazard needing a `detect()` hint. Live verification found that gate applies to `devin -p`
16
+ * / interactive `devin`, but NOT to `devin acp` — confirmed by running the raw `initialize`/`session/new`
17
+ * handshake against a directory never seen by devin before (no `--config` bypass), which succeeded with
18
+ * no refusal, while `devin -p` in the same directory refused. So this harness's actual code path was
19
+ * never gated in the first place; no hint or bypass is needed. If a future devin version starts
20
+ * enforcing trust over ACP too, the generic non-zero-exit path in acp-runner.ts already surfaces
21
+ * whatever refusal message devin prints, same as any other process failure.
22
+ */
23
+ import { execFile } from 'node:child_process';
24
+ import { promisify } from 'node:util';
25
+ import type {
26
+ ActivityEvent,
27
+ BuildArgsOpts,
28
+ Harness,
29
+ NormalizedPermission,
30
+ ParseOutcome,
31
+ ParseState,
32
+ StreamedResult,
33
+ } from './types.ts';
34
+
35
+ const execFileAsync = promisify(execFile);
36
+
37
+ function isRecord(v: unknown): v is Record<string, unknown> {
38
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
39
+ }
40
+
41
+ /** Exact structural match to Claude's tiers (session/new's captured `availableModes`: plan, accept-edits,
42
+ * smart, ask, bypass). `smart`/`ask` stay reachable via the existing `nativePermission` escape hatch. */
43
+ const PERMISSION_MAP: Record<NormalizedPermission, string> = {
44
+ readonly: 'plan',
45
+ edit: 'accept-edits',
46
+ danger: 'bypass',
47
+ };
48
+
49
+ /** Translate one `session/update` notification's `params.update` payload into ParseOutcome deltas. */
50
+ function translateUpdate(update: Record<string, unknown>, state: ParseState): ParseOutcome {
51
+ const activities: ActivityEvent[] = [];
52
+ let streamedText: string | undefined;
53
+ const content = isRecord(update.content) ? update.content : undefined;
54
+ const text = content?.type === 'text' && typeof content.text === 'string' ? content.text : undefined;
55
+
56
+ switch (update.sessionUpdate) {
57
+ case 'agent_message_chunk':
58
+ if (text !== undefined) streamedText = text;
59
+ break;
60
+ case 'agent_thought_chunk':
61
+ if (text !== undefined) activities.push({ kind: 'thinking', chars: text.length });
62
+ break;
63
+ case 'tool_call': {
64
+ if (typeof update.toolCallId !== 'string') break;
65
+ const meta = isRecord(update._meta) ? update._meta : {};
66
+ const inferenceName = meta['cognition.ai/inferenceToolName'];
67
+ const name =
68
+ typeof inferenceName === 'string' ? inferenceName : typeof update.kind === 'string' ? update.kind : 'tool';
69
+ activities.push({
70
+ kind: 'tool_input',
71
+ name,
72
+ input: isRecord(update.rawInput) ? update.rawInput : {},
73
+ id: update.toolCallId,
74
+ });
75
+ break;
76
+ }
77
+ case 'tool_call_update': {
78
+ // Only a terminal status produces a tool_result. A call fires multiple `in_progress`
79
+ // updates for the same toolCallId before its `completed`/`failed` (real fixture: 2-3 per
80
+ // id) — ToolCallIndex.resolve() consumes the pending entry on first match, so an earlier
81
+ // in_progress "result" would eat the id and strand the real completion unattributed.
82
+ if (typeof update.toolCallId !== 'string') break;
83
+ if (update.status === 'completed' || update.status === 'failed') {
84
+ activities.push({ kind: 'tool_result', isError: update.status === 'failed', id: update.toolCallId });
85
+ }
86
+ break;
87
+ }
88
+ case 'usage_update':
89
+ state._harness ??= {};
90
+ if (typeof update.size === 'number') state._harness.contextWindow = update.size;
91
+ break;
92
+ default:
93
+ break;
94
+ }
95
+ return { streamedText, activities };
96
+ }
97
+
98
+ export function parseDevinLine(line: string, state: ParseState): ParseOutcome {
99
+ let o: unknown;
100
+ try {
101
+ o = JSON.parse(line);
102
+ } catch {
103
+ return {};
104
+ }
105
+ if (!isRecord(o)) return {};
106
+
107
+ if (o.method === 'session/update' && isRecord(o.params) && isRecord(o.params.update)) {
108
+ return translateUpdate(o.params.update, state);
109
+ }
110
+
111
+ if (o.method === '_cognition.ai/agent_stopped' && isRecord(o.params) && isRecord(o.params.stats)) {
112
+ // The real model Devin ran, independent of whatever --model was requested — the honest
113
+ // value to report, since `--model` accepts fuzzy names and enterprise config can override it.
114
+ const label = o.params.stats.modelLabel;
115
+ if (typeof label === 'string') {
116
+ state._harness ??= {};
117
+ state._harness.model = label;
118
+ }
119
+ return {};
120
+ }
121
+
122
+ if (isRecord(o.result)) {
123
+ const r = o.result;
124
+ if (typeof r.stopReason === 'string') {
125
+ // session/prompt response — the turn is over, build the final result.
126
+ const u = isRecord(r.usage) ? r.usage : null;
127
+ const harnessState = isRecord(state._harness) ? state._harness : {};
128
+ // Devin's inputTokens already includes cachedReadTokens as a subset (fixture: on every
129
+ // usage_update/prompt result, used === inputTokens + outputTokens exactly, and
130
+ // cachedReadTokens < inputTokens). StreamedUsage follows Claude's convention where
131
+ // inputTokens EXCLUDES cache reads (index.ts sums inputTokens + cacheReadInputTokens into
132
+ // promptTokens) — so subtract the cache-read subset back out here, or promptTokens/context%
133
+ // double-counts it. Math.max guards against a negative if that invariant ever breaks.
134
+ const cacheReadInputTokens = typeof u?.cachedReadTokens === 'number' ? u.cachedReadTokens : 0;
135
+ const rawInputTokens = typeof u?.inputTokens === 'number' ? u.inputTokens : 0;
136
+ const result: StreamedResult = {
137
+ result: state.streamedText,
138
+ // Devin's ACP prompt response carries no error flag of its own — a genuine failure
139
+ // (JSON-RPC `error`, non-zero exit, workspace-trust refusal) surfaces via acp-runner.ts's
140
+ // process-level fail() path instead, same as every other harness's non-zero-exit case.
141
+ isError: false,
142
+ numTurns: null, // not reported for a single prompt turn
143
+ totalCostUsd: null, // Devin reports no $ cost over ACP — honest-metrics convention (#11)
144
+ sessionId: typeof harnessState.sessionId === 'string' ? harnessState.sessionId : null,
145
+ stopReason: r.stopReason,
146
+ permissionDenials: [],
147
+ durationMs: null,
148
+ durationApiMs: null,
149
+ ttftMs: null,
150
+ model: typeof harnessState.model === 'string' ? harnessState.model : null,
151
+ contextWindow: typeof harnessState.contextWindow === 'number' ? harnessState.contextWindow : null,
152
+ maxOutputTokens: null,
153
+ usage: u
154
+ ? {
155
+ inputTokens: Math.max(0, rawInputTokens - cacheReadInputTokens),
156
+ outputTokens: typeof u.outputTokens === 'number' ? u.outputTokens : 0,
157
+ cacheCreationInputTokens: 0, // not reported over ACP
158
+ cacheReadInputTokens,
159
+ }
160
+ : null,
161
+ };
162
+ return { result };
163
+ }
164
+ if (typeof r.sessionId === 'string') {
165
+ // session/new response — stash the id; the prompt response above has no sessionId of its own.
166
+ state._harness ??= {};
167
+ state._harness.sessionId = r.sessionId;
168
+ }
169
+ }
170
+ return {};
171
+ }
172
+
173
+ export const devinHarness: Harness = {
174
+ name: 'devin',
175
+ displayName: 'Devin',
176
+ binary: 'devin',
177
+ transport: 'acp',
178
+ async detect() {
179
+ try {
180
+ const { stdout } = await execFileAsync('devin', ['--version'], { timeout: 5000 });
181
+ return { ok: true, version: stdout.trim() };
182
+ } catch {
183
+ return { ok: false, hint: 'Install the Devin CLI: https://docs.devin.ai/' };
184
+ }
185
+ },
186
+ buildArgs(opts: BuildArgsOpts): string[] {
187
+ // The prompt, permission mode, and session lifecycle are all negotiated over the ACP wire
188
+ // (see acp-runner.ts) — this just launches the ACP server. `--model` is the one real CLI flag
189
+ // `devin acp` accepts (verified: `devin acp --help`); it sets the default model for every new
190
+ // ACP session on this server, and accepts fuzzy names (family slug, alias, or partial name).
191
+ const args = ['acp'];
192
+ if (opts.model) args.push('--model', opts.model);
193
+ return args;
194
+ },
195
+ parseLine(line: string, state: ParseState): ParseOutcome {
196
+ return parseDevinLine(line, state);
197
+ },
198
+ extractResult(state: ParseState): StreamedResult | null {
199
+ return state.result;
200
+ },
201
+ permissionMap: {
202
+ readonly: [PERMISSION_MAP.readonly],
203
+ edit: [PERMISSION_MAP.edit],
204
+ danger: [PERMISSION_MAP.danger],
205
+ },
206
+ // ACP-only — no stdout mode exists to select between, so there's nothing to configure.
207
+ supportsTransports: ['acp'],
208
+ };