pi-harness-delegate 0.5.0 → 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.
package/README.md CHANGED
@@ -81,12 +81,16 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
81
81
  | --- | --- | --- | --- |
82
82
  | `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context%. Schema-verified against Claude Code 2.1.247. |
83
83
  | `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json`. Schema-verified against codex-cli 0.149.1; cost is always unmeasured (`null`) on ChatGPT-plan auth. |
84
- | `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json`. Schema-verified against opencode 1.18.16. |
85
- | `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `<binary> -p --mode json`, resolves whichever of `amp`/`omp` is actually on `PATH`. Schema-verified against omp 17.2.9 (Sourcegraph's real Amp CLI is unverified). |
84
+ | `opencode` | `opencode` | `readonly→plan`, `edit→build`, `danger→build --auto` | `opencode run --format json` (stdout, default) or `opencode acp` ([ACP](https://agentclientprotocol.com), opt-in via `transport: "acp"` — see Config). Schema-verified against opencode 1.18.16. |
85
+ | `amp` | `amp` (`omp` alias) | `readonly→always-ask`, `edit→write`, `danger→yolo` | `<binary> -p --mode json`, resolves whichever of `amp`/`omp` is actually on `PATH`. Schema-verified against omp 17.2.9 (Sourcegraph's real Amp CLI is unverified). `omp acp` is real but not offered as a `transport` option — its ACP mode surface only has 2 tiers against this CLI's genuine 3. |
86
86
  | `devin` | `devin` | `readonly→plan`, `edit→accept-edits`, `danger→bypass` | Runs `devin acp` — [Agent Client Protocol](https://agentclientprotocol.com) over stdio, not stdout JSONL (see `acp-runner.ts`). Real tool-call ids, a genuine context-window %, and a working `sessionId`/resume via `session/load`. Reports no `$` cost (stays `null`) and no turn count. `model` is wired via `devin acp --model <MODEL>` (fuzzy names, e.g. `opus`); the reported `model` is read back from Devin's own `_cognition.ai/agent_stopped` event rather than echoed from the request, so it reflects what actually ran. Schema-verified against `devin 3000.6.7 (260a97c8)`. |
87
87
 
88
88
  Detect availability: `delegate` checks `harness --version` at startup; missing harnesses hint install instructions.
89
89
 
90
+ ### Transport
91
+
92
+ Every harness runs over its native CLI's stdout (`stdout`, the default and only option for `claude`/`codex`/`amp`). `opencode` and `devin` also speak [ACP](https://agentclientprotocol.com) (Agent Client Protocol — bidirectional JSON-RPC over stdio): Devin ships ACP-only (no stdout mode exists), and `opencode` supports both — `stdout` stays the default, `transport: "acp"` is opt-in per harness in config (see below). ACP gives `opencode` a genuine `cost`/`contextWindow` (both `null` over stdout today) and a resume path independently proven to recall cross-process state; the tradeoff is `model`/`numTurns` staying unmeasured (`null`) either way. `amp`/`omp` has a real `acp` subcommand too, but isn't offered as a `transport` value — its ACP mode surface has only 2 permission tiers against the stdout CLI's genuine 3, a real regression, not just an unverified one. Configuring a transport a harness doesn't support fails immediately with a clear error, before anything spawns.
93
+
90
94
  ## Modes (templates)
91
95
 
92
96
  | Mode | Permission | Purpose |
@@ -118,18 +122,20 @@ You are a senior engineer delegated by the pi coding agent.
118
122
 
119
123
  - **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
120
124
  - **Never runs on a `readonly` template.** `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
121
- - A project-local template's `verify` command is gated by the same trust check (`PI_TRUSTED=1` / `.pi/trusted`) as the rest of the template.
125
+ - A project-local template's `verify` command is gated by the same project-trust check as the rest of the template.
122
126
 
123
127
  **Template sources (later wins):**
124
128
 
125
129
  - `templates/shared/*.md` — portable prompt bodies
126
130
  - `templates/<harness>/*.md` — harness-specific frontmatter (built-ins)
127
131
  - `~/.pi/agent/delegate/templates/<harness>/<name>.md` (global)
128
- - `.pi/delegate/templates/<harness>/<name>.md` (project — when trusted)
132
+ - `.pi/delegate/templates/<harness>/<name>.md` (project — only when the project is trusted, see below)
129
133
  - Legacy `~/.pi/agent/claude-delegate/templates/` and `.pi/claude-delegate/templates/` still loaded for migration
130
134
 
131
135
  Custom templates are just files dropped in the above dirs — any registered name becomes a valid `mode`.
132
136
 
137
+ **Project trust:** project-local templates (`.pi/delegate/templates/`) load only when pi itself considers the current project trusted (`ctx.isProjectTrusted()`, backed by pi's own trust store outside the project — the same trust that gates other project-scoped behavior). Trust it via pi's own trust prompt (shown the first time you open an untrusted directory) or your `defaultProjectTrust` setting; `/delegate status` reports whether the current project is trusted and, if not, that project-local templates are being skipped. There is **no way to grant trust from inside the project** — no `.pi/trusted` file, no environment variable. Earlier versions supported both (`.pi/trusted` containing `1`, or `PI_TRUSTED=1`/`PI_DELEGATE_TRUSTED=1` in the environment); both were removed as a security fix — a repo could commit `.pi/trusted` and declare itself trusted, letting a cloned hostile repo's templates silently override a builtin (e.g. widening `review` from `readonly` to `edit` and attaching a `verify:` command that runs host-side). If you relied on either, switch to pi's trust prompt or `defaultProjectTrust`.
138
+
133
139
  ## How the main session consumes the output
134
140
 
135
141
  - **Agent-driven (`delegate` tool)** — report is the tool result, flows into agent context.
@@ -170,16 +176,24 @@ In `~/.pi/agent/settings.json`:
170
176
  "harnesses": {
171
177
  "claude": { "model": "sonnet" },
172
178
  "codex": { "model": "gpt-5" },
173
- "opencode": { "model": "opencode-default" }
179
+ "opencode": { "model": "opencode-default", "transport": "acp" }
174
180
  }
175
181
  }
176
182
  }
177
183
  ```
178
184
 
179
- Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated).
185
+ Run `/delegate config` to see exactly what was read from `settings.json` (or why nothing was — no file, no `delegate` key, or a parse error), plus the effective config with defaults filled in, as a paste-ready JSON block for the `delegate` key. `/delegate status` shows the same provenance as one summary line.
186
+
187
+ `/delegate config init` writes that effective config into `settings.json` under the `delegate` key — the only thing this extension ever writes there, and only on this explicit command. It reads the whole file, replaces only the `delegate` key, and preserves every other key (pi's `theme`/`defaultProvider`/`packages`/…, and a leftover `claudeDelegate`, verbatim). The write is atomic (temp file + rename in the same directory — no torn file if the process dies mid-write) and refuses outright if the existing file fails to parse, rather than clobbering whatever's actually in it; `/delegate config`'s paste-ready block is the fallback in that case.
188
+
189
+ Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated) — most fields migrate, including per-harness settings like `harnesses.<name>.transport`. Two things never migrate, though, and stay silently unreachable as long as `claudeDelegate` is your *only* key (no `delegate` key at all): `defaultHarness` (stays pinned to `claude`) and a top-level default `model` (only `claudeDelegate.model` → `harnesses.claude.model` migrates — there's no global fallback). Both `/delegate status` and `/delegate config` call this out when it's happening; fix it by renaming `claudeDelegate` to `delegate`, or by running `/delegate config init`, which writes an explicit `delegate` key (with the legacy values already correctly migrated) without touching `claudeDelegate` itself.
190
+
191
+ Config lives as a key inside pi's own `~/.pi/agent/settings.json` rather than a dedicated file — small enough that this fits comfortably, and pi's extension docs don't prescribe a convention either way for global (as opposed to project-local) preferences. If per-project overrides are ever wanted, pi's documented pattern for extension-owned project config is `.pi/<CONFIG_DIR_NAME>/pi-harness-delegate.json` (gated by project trust); not implemented today.
192
+
193
+ - `harnesses.<name>.transport` — `"stdout"` (default for every harness except `devin`, which is ACP-only) or `"acp"`. Only legal where the harness actually supports it — see [Transport](#transport) above; an unsupported value fails the run immediately with a clear message rather than being silently ignored or failing at spawn time.
180
194
 
181
195
  - `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
182
- - `maxConcurrent` — cap overlapping runs (default **`4`**, one slot per supported harness; may be `{global:4, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs, so the slots available to you also depend on any other pi session running `delegate`. This is a **genuinely parallel** spend cap now, not just a "don't overlap" guard: a single-harness `/delegate` call still fails fast (`another delegate run is already in progress`) the moment it's at capacity, but `/delegate all …` fan-out queues for a free slot instead and can run up to `maxConcurrent` harnesses at once — meaning up to that many harnesses billing simultaneously. Lower it if you want fan-out to stay sequential/cheaper (`"maxConcurrent": 1` restores the old one-at-a-time behavior for everything, single runs included).
196
+ - `maxConcurrent` — cap overlapping runs (default **`4`**, one slot per supported harness; may be `{global:4, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs, so the slots available to you also depend on any other pi session running `delegate`. This is a **genuinely parallel** spend cap now, not just a "don't overlap" guard: a single-harness `/delegate` call still fails fast (`another delegate run is already in progress`) the moment it's at capacity, but `/delegate all …` fan-out queues for a free slot instead and can run up to `maxConcurrent` harnesses at once — meaning up to that many harnesses billing simultaneously. Lower it if you want fan-out to stay sequential/cheaper (`"maxConcurrent": 1` restores the old one-at-a-time behavior for everything, single runs included). `/delegate status` shows each harness's `active` count next to the cap that actually applies to it (e.g. `1/2`), so a `{perHarness: {...}}` override is visible per-row, not just the raw config JSON in the header; the summary line at the bottom shows the same for the global cap.
183
197
  - `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
184
198
 
185
199
  `autoDelegateHints` is off by default — no system-prompt bias. When `true`, explicit markers (`@harness`, `with codex`, `delegate … to claude`) and imperative review/plan phrasing append a hint.
@@ -190,6 +204,8 @@ Every run records in details + transcript: harness, mode, permission (normalized
190
204
 
191
205
  Claude reports turns and cost on every run; Codex/OpenCode/Amp don't always. An unmeasured turn count or cost renders as `—`/`n/a` (never `0`/`$0.000`) everywhere it's shown — the transcript header, `formatMetrics`, tool results, and `/delegate history` — so an unmeasured run is never mistaken for a free one. `/delegate status` shows a per-harness spend rollup (e.g. `$1.234 over 12 run(s) (3 unknown)`); runs with unknown cost are counted separately rather than folded into the total as `$0`.
192
206
 
207
+ One deliberate, narrow exception: pi's own `Usage` (the footer/session token+cost stats) has no way to express "cost unknown" — its `cost.total` field is mandatory. Codex and Devin never report a `$` cost, so treating unknown-cost as unknown-usage there would drop those two harnesses' tokens out of pi's session totals entirely. `mapHarnessUsage`/`mapClaudeUsage` (`extensions/usage.ts`) report the real token counts with `cost.total: 0` in that one case — under-reporting spend by a bounded, knowable amount beats losing 40% of token accounting. This does not change anything above: transcripts, `formatMetrics`, and `/delegate status` still render unmeasured cost as `—`/`n/a`, never `$0`.
208
+
193
209
  ## Security model
194
210
 
195
211
  - `readonly` — no edits (e.g. Claude `plan`, Codex `read-only`).
@@ -8,15 +8,19 @@
8
8
  * doesn't hang, since we run non-interactively with a permission mode already negotiated.
9
9
  *
10
10
  * Exposes the exact `RunHarnessOptions`/`HarnessResult` shape as runner.ts, so `delegate()`
11
- * can pick either runner from `harness.transport` and everything downstream (transcripts,
12
- * `ToolCallIndex`, progress overlays, fan-out, spend rollup) is unchanged.
11
+ * can pick either runner from the resolved `transport` (see config.ts's `resolveTransport`) and
12
+ * everything downstream (transcripts, `ToolCallIndex`, progress overlays, fan-out, spend rollup)
13
+ * is unchanged.
13
14
  *
14
- * Deliberately general: an agent's mode ids and result shape live in its `Harness` (`buildArgs`,
15
- * `permissionMap`, `parseLine`, `extractResult`) — this file only knows the ACP wire protocol.
15
+ * Deliberately general: an agent's mode ids and result shape live in its `Harness` (`buildArgs`/
16
+ * `buildAcpArgs`, `permissionMap`/`acpPermissionMap`, `parseLine`/`parseAcpLine`, `extractResult`)
17
+ * — this file only knows the ACP wire protocol. Callers driving a dual-transport harness over ACP
18
+ * pass it through `acpView()` (below) first, so this file always reads the stdout-shaped field
19
+ * names regardless of which harness it's given.
16
20
  */
17
21
  import { spawn } from 'node:child_process';
18
22
  import { createInterface } from 'node:readline';
19
- import { DEFAULT_TIMEOUT_MS, type ParseState, type StreamedResult } from './harnesses/types.ts';
23
+ import { DEFAULT_TIMEOUT_MS, type Harness, type ParseState, type StreamedResult } from './harnesses/types.ts';
20
24
  import type { HarnessResult, RunHarnessOptions } from './runner.ts';
21
25
 
22
26
  /** Bound on the initial handshake (initialize / session/new / session/set_mode) so a hung agent
@@ -29,6 +33,33 @@ function isRecord(v: unknown): v is Record<string, unknown> {
29
33
  return typeof v === 'object' && v !== null && !Array.isArray(v);
30
34
  }
31
35
 
36
+ /** Presents the ACP-shaped view of a dual-transport `Harness` (buildAcpArgs/parseAcpLine/
37
+ * acpPermissionMap) to this file, falling back to the stdout-shaped fields for an ACP-only
38
+ * harness like Devin that never declares the Acp-prefixed ones. Devin needs zero changes for
39
+ * this — every field below already exists on it under the stdout-shaped name. */
40
+ export function acpView(harness: Harness): Harness {
41
+ return {
42
+ ...harness,
43
+ buildArgs: harness.buildAcpArgs ?? harness.buildArgs,
44
+ parseLine: harness.parseAcpLine ?? harness.parseLine,
45
+ permissionMap: harness.acpPermissionMap ?? harness.permissionMap,
46
+ };
47
+ }
48
+
49
+ /** Does this `session/new`/`session/load` result advertise support for switching session modes?
50
+ * Two independently-real dialects, both live-verified (docs/acp-harness-assessment.md §2/§4):
51
+ * the spec-standard `modes` field (Devin, omp), or a `configOptions` entry with `category: "mode"`
52
+ * (opencode, which never populates `modes` at all but implements `session/set_mode` anyway). Either
53
+ * signal is enough to trust the upcoming `session/set_mode` call. */
54
+ function supportsSessionModes(sessionResult: unknown): boolean {
55
+ if (!isRecord(sessionResult)) return false;
56
+ if (isRecord(sessionResult.modes)) return true;
57
+ if (Array.isArray(sessionResult.configOptions)) {
58
+ return sessionResult.configOptions.some(o => isRecord(o) && o.category === 'mode');
59
+ }
60
+ return false;
61
+ }
62
+
32
63
  interface PendingRequest {
33
64
  resolve: (result: unknown) => void;
34
65
  reject: (err: Error) => void;
@@ -247,7 +278,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
247
278
  // bounded by the overall `timer` above like everything else.
248
279
  (async () => {
249
280
  const modeId = opts.nativePermission ?? opts.harness.permissionMap?.[opts.permission]?.[0] ?? opts.permission;
250
- await sendRequest(
281
+ const initResult = await sendRequest(
251
282
  'initialize',
252
283
  {
253
284
  protocolVersion: PROTOCOL_VERSION,
@@ -256,6 +287,15 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
256
287
  HANDSHAKE_TIMEOUT_MS,
257
288
  );
258
289
  if (settled) return;
290
+ // The client "should disconnect" (spec text) if the agent didn't echo back the version we
291
+ // asked for — only `1` has ever shipped, so this is cheap insurance against a future
292
+ // version-mismatched agent producing a confusing mid-handshake failure instead of a clear one.
293
+ const negotiatedVersion = isRecord(initResult) ? initResult.protocolVersion : undefined;
294
+ if (negotiatedVersion !== PROTOCOL_VERSION) {
295
+ throw new Error(
296
+ `${opts.harness.binary} negotiated ACP protocolVersion ${JSON.stringify(negotiatedVersion)}, expected ${PROTOCOL_VERSION}`,
297
+ );
298
+ }
259
299
  const sessionParams = {
260
300
  cwd: opts.cwd,
261
301
  mcpServers: [],
@@ -267,12 +307,18 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
267
307
  // advertised in agentCapabilities and a real session/load + follow-up prompt round-trips
268
308
  // cleanly, replaying history and continuing the same token-usage accounting.
269
309
  let sessionId: string | null;
310
+ let sessionResult: unknown;
270
311
  if (opts.resumeSessionId) {
271
- await sendRequest('session/load', { sessionId: opts.resumeSessionId, ...sessionParams }, HANDSHAKE_TIMEOUT_MS);
312
+ sessionResult = await sendRequest(
313
+ 'session/load',
314
+ { sessionId: opts.resumeSessionId, ...sessionParams },
315
+ HANDSHAKE_TIMEOUT_MS,
316
+ );
272
317
  sessionId = opts.resumeSessionId;
273
318
  } else {
274
- const newSession = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
275
- sessionId = isRecord(newSession) && typeof newSession.sessionId === 'string' ? newSession.sessionId : null;
319
+ sessionResult = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
320
+ sessionId =
321
+ isRecord(sessionResult) && typeof sessionResult.sessionId === 'string' ? sessionResult.sessionId : null;
276
322
  }
277
323
  if (settled) return;
278
324
  if (!sessionId) throw new Error('session/new did not return a sessionId');
@@ -282,6 +328,20 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
282
328
  state._harness ??= {};
283
329
  state._harness.sessionId = sessionId;
284
330
  }
331
+ // `session/set_mode` (and `NewSessionResponse.modes`) are spec-optional — calling it
332
+ // unconditionally against a mode-less agent would fail the whole handshake with a raw
333
+ // "method not found" instead of a clear message. Every agent this project has captured
334
+ // (Devin, opencode, omp) does support it, so this never fires for a real run today — but per
335
+ // the brief, a mode we can't confirm is a hard error, not a silent downgrade to whatever the
336
+ // agent's default permissiveness happens to be: we already promised the caller a specific
337
+ // permission tier.
338
+ if (!supportsSessionModes(sessionResult)) {
339
+ throw new Error(
340
+ `${opts.harness.binary} does not advertise session-mode support (no "modes" field or ` +
341
+ `configOptions "mode" category on session/${opts.resumeSessionId ? 'load' : 'new'}) — ` +
342
+ `cannot verify the "${opts.permission}" permission tier would be honored over ACP`,
343
+ );
344
+ }
285
345
  await sendRequest('session/set_mode', { sessionId, modeId }, HANDSHAKE_TIMEOUT_MS);
286
346
  if (settled) return;
287
347
  promptSent = true;
@@ -92,6 +92,27 @@ export function isFanoutSpec(harness: string | undefined): boolean {
92
92
  return lower === 'all' || lower.includes(',');
93
93
  }
94
94
 
95
+ export type HarnessFilterResolution =
96
+ | { kind: 'none' } // no filter word given
97
+ | { kind: 'known'; harness: string } // resolved to its canonical name (aliases/case normalized)
98
+ | { kind: 'unknown'; requested: string }; // word given, but not a known harness or alias
99
+
100
+ /**
101
+ * Resolve a single optional harness-filter word — as used by `/delegate list`/`history`'s bare
102
+ * word or `--harness=` flag — to its canonical name via `aliasOf`, case-insensitively, so `omp`,
103
+ * `OMP`, and `amp` all filter identically. Pure and shared by both subcommands so they can't drift
104
+ * on alias/case handling the way they once did.
105
+ */
106
+ export function resolveHarnessFilter(
107
+ word: string | undefined,
108
+ opts: { isKnown: (name: string) => boolean; aliasOf: (name: string) => string },
109
+ ): HarnessFilterResolution {
110
+ if (!word) return { kind: 'none' };
111
+ const lower = word.toLowerCase();
112
+ if (!opts.isKnown(lower)) return { kind: 'unknown', requested: word };
113
+ return { kind: 'known', harness: opts.aliasOf(lower) };
114
+ }
115
+
95
116
  export interface HarnessListResolution {
96
117
  /** Canonical harness names to run, in request order, deduped. */
97
118
  resolved: string[];
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { type DelegateConfig, getMaxConcurrent } from './config.ts';
14
- import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
14
+ import { acquireRunWithinLimits, countActiveRuns, releaseRun } from './run-registry.ts';
15
15
 
16
16
  const activeRuns = new Map<string, number>();
17
17
  let globalActiveRuns = 0;
@@ -70,7 +70,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
70
70
  * never throws) once a slot is held; the caller must call it exactly once when the run finishes.
71
71
  *
72
72
  * Checks the global limit before the per-harness limit — same precedence and error text as the
73
- * original inline guard, so single-run (`wait: false`) callers see unchanged behavior.
73
+ * original inline guard, so single-run (`wait: false`) callers see unchanged behavior. Those two
74
+ * checks are still a plain (racy) read, kept as a cheap fail-fast/error-message step; the actual
75
+ * grant is `acquireRunWithinLimits()` (run-registry.ts), which re-verifies after registering so a
76
+ * race lost between the check here and the write there is caught — see its doc comment. Losing
77
+ * that race is handled exactly like losing the check above: throw (wait:false) or poll (wait:true).
74
78
  */
75
79
  export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
76
80
  const { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
@@ -91,9 +95,18 @@ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void>
91
95
  continue;
92
96
  }
93
97
 
98
+ const claim = acquireRunWithinLimits(harness, mode, maxGlobal, perHarnessLimit);
99
+ if (claim.status === 'full') {
100
+ // the check above passed, but another racer's write landed first and used up the slot —
101
+ // handled exactly like hitting the cap on the check itself.
102
+ if (!wait) throw new ConcurrencyLimitError(`another delegate run claimed the last available slot for ${harness}`);
103
+ await sleep(pollIntervalMs, signal);
104
+ continue;
105
+ }
106
+
94
107
  activeRuns.set(harness, perHarnessCount + 1);
95
108
  globalActiveRuns++;
96
- const runHandle = acquireRun(harness, mode);
109
+ const runHandle = claim.status === 'acquired' ? claim.handle : null;
97
110
  let released = false;
98
111
  return () => {
99
112
  if (released) return;
@@ -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
  };