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 +23 -7
- package/extensions/acp-runner.ts +69 -9
- package/extensions/command.ts +21 -0
- package/extensions/concurrency.ts +16 -3
- package/extensions/config.ts +216 -13
- package/extensions/harnesses/amp.ts +24 -4
- package/extensions/harnesses/claude.ts +3 -0
- package/extensions/harnesses/codex.ts +13 -1
- package/extensions/harnesses/devin.ts +2 -0
- package/extensions/harnesses/opencode.ts +160 -0
- package/extensions/harnesses/types.ts +21 -6
- package/extensions/index.ts +159 -44
- package/extensions/run-registry.ts +50 -5
- package/extensions/templates.ts +14 -15
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
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→
|
|
85
|
-
| `amp` | `amp` (`omp` alias) | `readonly→
|
|
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
|
|
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
|
-
|
|
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`).
|
package/extensions/acp-runner.ts
CHANGED
|
@@ -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 `
|
|
12
|
-
* `ToolCallIndex`, progress overlays, fan-out, spend rollup)
|
|
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`)
|
|
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(
|
|
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
|
-
|
|
275
|
-
sessionId =
|
|
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;
|
package/extensions/command.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
109
|
+
const runHandle = claim.status === 'acquired' ? claim.handle : null;
|
|
97
110
|
let released = false;
|
|
98
111
|
return () => {
|
|
99
112
|
if (released) return;
|
package/extensions/config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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')
|
|
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] =
|
|
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
|
-
//
|
|
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
|
-
:
|
|
167
|
-
?
|
|
168
|
-
:
|
|
169
|
-
|
|
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
|
};
|