pi-harness-delegate 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -9
- package/extensions/acp-runner.ts +370 -0
- 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 +208 -0
- package/extensions/harnesses/opencode.ts +160 -0
- package/extensions/harnesses/registry.ts +29 -0
- package/extensions/harnesses/types.ts +27 -3
- package/extensions/index.ts +180 -55
- package/extensions/run-registry.ts +50 -5
- package/extensions/templates.ts +34 -17
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
- package/templates/devin/docs.md +10 -0
- package/templates/devin/general.md +8 -0
- package/templates/devin/implement.md +12 -0
- package/templates/devin/plan.md +17 -0
- package/templates/devin/review.md +18 -0
- package/templates/devin/security-audit.md +17 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-harness-delegate) [](https://github.com/yorch/pi-harness-delegate/actions/workflows/ci.yml) [](https://github.com/yorch/pi-harness-delegate/actions/workflows/release.yml) [](https://nodejs.org) [](https://bun.sh) [](https://biomejs.dev) [](LICENSE)
|
|
4
4
|
|
|
5
|
-
Delegate work to **any harness** ([Claude Code](https://github.com/anthropics/claude-code), [Muse](https://github.com/openai/codex), [OpenCode](https://opencode.ai), [Amp](https://ampcode.com)) from the [pi coding agent](https://github.com/badlogic/pi-mono): code reviews, detailed plans, implementation, security audits, docs — or your own custom templates.
|
|
5
|
+
Delegate work to **any harness** ([Claude Code](https://github.com/anthropics/claude-code), [Muse](https://github.com/openai/codex), [OpenCode](https://opencode.ai), [Amp](https://ampcode.com), [Devin](https://devin.ai)) from the [pi coding agent](https://github.com/badlogic/pi-mono): code reviews, detailed plans, implementation, security audits, docs — or your own custom templates.
|
|
6
6
|
|
|
7
7
|
Each harness runs headless in your repo with a normalized permission (`readonly` / `edit` / `danger`). Results stream back live, and token/cost usage feeds into pi's footer stats. Templates are portable — prompt bodies live in `templates/shared/`, harness-specific frontmatter selects the native permission.
|
|
8
8
|
|
|
@@ -16,7 +16,9 @@ pi install npm:pi-harness-delegate
|
|
|
16
16
|
pi install git:github.com/yorch/pi-harness-delegate
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Requires at least one harness binary on PATH (`claude --version`, `codex --version`, `opencode --version`, `amp --version`). Restart pi (or `/reload`) to activate.
|
|
19
|
+
Requires at least one harness binary on PATH (`claude --version`, `codex --version`, `opencode --version`, `amp --version`, `devin --version`). Restart pi (or `/reload`) to activate.
|
|
20
|
+
|
|
21
|
+
**Devin setup note:** `devin` refuses to run interactively (`devin`, `devin -p`) in a directory you haven't trusted yet — but this extension runs Devin over `devin acp` (see below), and live testing found that transport is **not** gated by workspace trust in the tested version (`3000.6.7`): a fresh, never-touched directory worked over ACP with no refusal and no prompt. This extension never sets Devin's `skip_workspace_trust` config key on your behalf either way — that stays a decision you make interactively, if you ever need it for `devin` itself.
|
|
20
22
|
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
@@ -32,6 +34,7 @@ Manual delegation:
|
|
|
32
34
|
/claude --mode=security-audit --scope=auth/ … # alias → delegate --harness=claude
|
|
33
35
|
/opencode plan the cache migration
|
|
34
36
|
/amp implement the caching layer
|
|
37
|
+
/devin review the new auth flow
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
Only the prompt is required. A **harness as first word** and/or **mode as next word** selects them; every `--flag` is optional (harness defaults to `delegate.defaultHarness`, mode to `delegate.defaultMode`, scope to whole repo).
|
|
@@ -53,6 +56,8 @@ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff
|
|
|
53
56
|
delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
- `all` resolves against *detected* harnesses, so Devin joins a fan-out automatically once `devin` is installed — a 5-harness fan-out costs more (and runs one more concurrent process) than the 4-harness one did, budget accordingly.
|
|
60
|
+
|
|
56
61
|
- `all` resolves to whatever's actually installed (`detectAll()`) — an uninstalled harness is skipped and named in the report, it doesn't fail the run. An explicit list is validated the same way; an unknown name is also reported rather than aborting the rest.
|
|
57
62
|
- Each harness's run goes through the same `delegate()` engine as a single-harness call and writes its own transcript to its own `~/.pi/agent/delegate/outputs/<harness>/`. Runs are launched together and execute in parallel, bounded by `maxConcurrent` (default `4`, one slot per supported harness) — a run beyond the cap queues for a free slot instead of failing, and the cap is enforced across pi processes, not just this one. **This means fan-out spend is genuinely simultaneous**: with the default cap, a 4-harness fan-out can bill all four at once instead of one after another — budget accordingly (`maxBudgetUsd` still applies per run).
|
|
58
63
|
- The synthesized report is always ordered by the resolved harness list (e.g. `claude, codex, opencode`), regardless of which harness actually finishes first — it groups each harness's metrics + output and a total spend line (unknown-cost runs called out separately, same as `/delegate status`), assembled mechanically, not by asking a model to summarize.
|
|
@@ -76,11 +81,16 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
|
76
81
|
| --- | --- | --- | --- |
|
|
77
82
|
| `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context%. Schema-verified against Claude Code 2.1.247. |
|
|
78
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. |
|
|
79
|
-
| `opencode` | `opencode` | `readonly→
|
|
80
|
-
| `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
|
+
| `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)`. |
|
|
81
87
|
|
|
82
88
|
Detect availability: `delegate` checks `harness --version` at startup; missing harnesses hint install instructions.
|
|
83
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
|
+
|
|
84
94
|
## Modes (templates)
|
|
85
95
|
|
|
86
96
|
| Mode | Permission | Purpose |
|
|
@@ -112,18 +122,20 @@ You are a senior engineer delegated by the pi coding agent.
|
|
|
112
122
|
|
|
113
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.
|
|
114
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.
|
|
115
|
-
- 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.
|
|
116
126
|
|
|
117
127
|
**Template sources (later wins):**
|
|
118
128
|
|
|
119
129
|
- `templates/shared/*.md` — portable prompt bodies
|
|
120
130
|
- `templates/<harness>/*.md` — harness-specific frontmatter (built-ins)
|
|
121
131
|
- `~/.pi/agent/delegate/templates/<harness>/<name>.md` (global)
|
|
122
|
-
- `.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)
|
|
123
133
|
- Legacy `~/.pi/agent/claude-delegate/templates/` and `.pi/claude-delegate/templates/` still loaded for migration
|
|
124
134
|
|
|
125
135
|
Custom templates are just files dropped in the above dirs — any registered name becomes a valid `mode`.
|
|
126
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
|
+
|
|
127
139
|
## How the main session consumes the output
|
|
128
140
|
|
|
129
141
|
- **Agent-driven (`delegate` tool)** — report is the tool result, flows into agent context.
|
|
@@ -164,16 +176,24 @@ In `~/.pi/agent/settings.json`:
|
|
|
164
176
|
"harnesses": {
|
|
165
177
|
"claude": { "model": "sonnet" },
|
|
166
178
|
"codex": { "model": "gpt-5" },
|
|
167
|
-
"opencode": { "model": "opencode-default" }
|
|
179
|
+
"opencode": { "model": "opencode-default", "transport": "acp" }
|
|
168
180
|
}
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
183
|
```
|
|
172
184
|
|
|
173
|
-
|
|
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.
|
|
174
194
|
|
|
175
195
|
- `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
|
|
176
|
-
- `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.
|
|
177
197
|
- `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
|
|
178
198
|
|
|
179
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.
|
|
@@ -184,6 +204,8 @@ Every run records in details + transcript: harness, mode, permission (normalized
|
|
|
184
204
|
|
|
185
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`.
|
|
186
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
|
+
|
|
187
209
|
## Security model
|
|
188
210
|
|
|
189
211
|
- `readonly` — no edits (e.g. Claude `plan`, Codex `read-only`).
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sibling to runner.ts for harnesses whose `transport` is `'acp'` (Agent Client Protocol,
|
|
3
|
+
* https://agentclientprotocol.com — JSON-RPC 2.0, newline-delimited, over stdio). Unlike the
|
|
4
|
+
* stdout harnesses' one-way JSONL stream, ACP is bidirectional and stateful: the runner must
|
|
5
|
+
* drive a handshake (`initialize` -> `session/new` -> `session/set_mode` -> `session/prompt`)
|
|
6
|
+
* and hold stdin open for the session's lifetime — the agent exits on stdin EOF. It must also
|
|
7
|
+
* answer requests the agent sends back to us (permission prompts, fs reads) so the session
|
|
8
|
+
* doesn't hang, since we run non-interactively with a permission mode already negotiated.
|
|
9
|
+
*
|
|
10
|
+
* Exposes the exact `RunHarnessOptions`/`HarnessResult` shape as runner.ts, so `delegate()`
|
|
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.
|
|
14
|
+
*
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
import { DEFAULT_TIMEOUT_MS, type Harness, type ParseState, type StreamedResult } from './harnesses/types.ts';
|
|
24
|
+
import type { HarnessResult, RunHarnessOptions } from './runner.ts';
|
|
25
|
+
|
|
26
|
+
/** Bound on the initial handshake (initialize / session/new / session/set_mode) so a hung agent
|
|
27
|
+
* doesn't wedge the whole `timeoutMs` budget before `session/prompt` — the actual work — even starts. */
|
|
28
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
const PROTOCOL_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
33
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
34
|
+
}
|
|
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
|
+
|
|
63
|
+
interface PendingRequest {
|
|
64
|
+
resolve: (result: unknown) => void;
|
|
65
|
+
reject: (err: Error) => void;
|
|
66
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const args = opts.harness.buildArgs({
|
|
72
|
+
prompt: opts.prompt,
|
|
73
|
+
cwd: opts.cwd,
|
|
74
|
+
permission: opts.permission,
|
|
75
|
+
nativePermission: opts.nativePermission,
|
|
76
|
+
model: opts.model,
|
|
77
|
+
maxBudgetUsd: opts.maxBudgetUsd,
|
|
78
|
+
addDirs: opts.addDirs,
|
|
79
|
+
resumeSessionId: opts.resumeSessionId,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
83
|
+
|
|
84
|
+
const state: ParseState = { streamedText: '', activities: [], result: null, _harness: {} };
|
|
85
|
+
let stderr = '';
|
|
86
|
+
let settled = false;
|
|
87
|
+
let firstTokenAt: number | null = null;
|
|
88
|
+
// Resumed sessions replay every prior turn as session/update notifications before the new
|
|
89
|
+
// prompt's — set once session/prompt is actually sent, so replayed text/activities (and a
|
|
90
|
+
// replay-skewed TTFT) never reach the caller. See the handshake IIFE below.
|
|
91
|
+
let promptSent = false;
|
|
92
|
+
const startAt = Date.now();
|
|
93
|
+
const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
|
|
94
|
+
const MAX_ACTIVITIES = 5000;
|
|
95
|
+
|
|
96
|
+
let nextId = 1;
|
|
97
|
+
const pending = new Map<number, PendingRequest>();
|
|
98
|
+
|
|
99
|
+
const finish = (r: StreamedResult) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
rejectAllPending(new Error('session ended'));
|
|
104
|
+
const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
|
|
105
|
+
resolve({ ...r, ttftMs: ttft, streamedText: state.streamedText, harness: opts.harness.name });
|
|
106
|
+
};
|
|
107
|
+
const fail = (err: Error) => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
rejectAllPending(err);
|
|
112
|
+
reject(err);
|
|
113
|
+
};
|
|
114
|
+
function rejectAllPending(err: Error): void {
|
|
115
|
+
for (const p of pending.values()) {
|
|
116
|
+
clearTimeout(p.timer);
|
|
117
|
+
p.reject(err);
|
|
118
|
+
}
|
|
119
|
+
pending.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const writeLine = (msg: Record<string, unknown>): void => {
|
|
123
|
+
try {
|
|
124
|
+
proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
125
|
+
} catch {
|
|
126
|
+
// stdin already closed (process exiting) — the pending request(s) time out/reject normally.
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Send a JSON-RPC request and await its response. `timeoutMs` bounds only this request —
|
|
131
|
+
* distinct from the overall run timeout — so a hung handshake step fails fast and clearly.
|
|
132
|
+
* Omitted for `session/prompt`: that's the actual work, already bounded by the overall
|
|
133
|
+
* `timer` below, which kills the process and rejects every pending request on fire. */
|
|
134
|
+
const sendRequest = (method: string, params: unknown, timeoutMs?: number): Promise<unknown> => {
|
|
135
|
+
const id = nextId++;
|
|
136
|
+
return new Promise((res, rej) => {
|
|
137
|
+
const entry: PendingRequest = { resolve: res, reject: rej };
|
|
138
|
+
if (timeoutMs !== undefined) {
|
|
139
|
+
entry.timer = setTimeout(() => {
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
rej(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
}
|
|
144
|
+
pending.set(id, entry);
|
|
145
|
+
writeLine({ jsonrpc: '2.0', id, method, params });
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Respond to a request the agent sent to us. Every request needs a reply or the agent's
|
|
150
|
+
* session hangs waiting for it. */
|
|
151
|
+
const respond = (id: unknown, result: unknown): void => writeLine({ jsonrpc: '2.0', id, result });
|
|
152
|
+
const respondError = (id: unknown, message: string): void =>
|
|
153
|
+
writeLine({ jsonrpc: '2.0', id, error: { code: -32601, message } });
|
|
154
|
+
|
|
155
|
+
/** Handle a request FROM the agent (has both `method` and `id`). We run non-interactively
|
|
156
|
+
* with a permission mode already negotiated, so the safe default is to decline anything
|
|
157
|
+
* not already covered by that mode rather than auto-approve — never observed in the captured
|
|
158
|
+
* fixture this harness was built from, but handled defensively since the spec allows it. */
|
|
159
|
+
const handleServerRequest = (msg: Record<string, unknown>): void => {
|
|
160
|
+
const { id, method, params } = msg;
|
|
161
|
+
if (method === 'session/request_permission' && isRecord(params) && Array.isArray(params.options)) {
|
|
162
|
+
const options = params.options as Array<{ optionId?: unknown; kind?: unknown }>;
|
|
163
|
+
const reject =
|
|
164
|
+
options.find(o => o.kind === 'reject_once') ??
|
|
165
|
+
options.find(o => o.kind === 'reject_always') ??
|
|
166
|
+
options.find(o => typeof o.kind === 'string' && o.kind.startsWith('reject'));
|
|
167
|
+
if (reject && typeof reject.optionId === 'string') {
|
|
168
|
+
respond(id, { outcome: { outcome: 'selected', optionId: reject.optionId } });
|
|
169
|
+
} else {
|
|
170
|
+
respond(id, { outcome: { outcome: 'cancelled' } });
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// fs/read_text_file, fs/write_text_file, terminal/* etc. — we declare no client capabilities
|
|
175
|
+
// for these in `initialize`, so a well-behaved agent shouldn't ask; decline defensively if one does.
|
|
176
|
+
respondError(id, `${String(method)} not supported by this client`);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const rl = createInterface({ input: proc.stdout });
|
|
180
|
+
rl.on('line', line => {
|
|
181
|
+
let msg: unknown;
|
|
182
|
+
try {
|
|
183
|
+
msg = JSON.parse(line);
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!isRecord(msg)) return;
|
|
188
|
+
|
|
189
|
+
if (typeof msg.method === 'string' && msg.id !== undefined) {
|
|
190
|
+
handleServerRequest(msg);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (msg.id !== undefined && 'result' in msg) {
|
|
194
|
+
const entry = pending.get(msg.id as number);
|
|
195
|
+
if (entry) {
|
|
196
|
+
pending.delete(msg.id as number);
|
|
197
|
+
clearTimeout(entry.timer);
|
|
198
|
+
entry.resolve(msg.result);
|
|
199
|
+
}
|
|
200
|
+
// still fall through: `harness.parseLine` may also want to extract activity/result data.
|
|
201
|
+
} else if (msg.id !== undefined && 'error' in msg) {
|
|
202
|
+
const entry = pending.get(msg.id as number);
|
|
203
|
+
if (entry) {
|
|
204
|
+
pending.delete(msg.id as number);
|
|
205
|
+
clearTimeout(entry.timer);
|
|
206
|
+
const err = isRecord(msg.error) ? msg.error : {};
|
|
207
|
+
entry.reject(new Error(typeof err.message === 'string' ? err.message : `${msg.id} failed`));
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const outcome = opts.harness.parseLine(line, state);
|
|
213
|
+
// Discard streamed text/activities from anything that arrives before the new session/prompt
|
|
214
|
+
// is sent — on a resume that's the replayed prior conversation, not the new turn's own output.
|
|
215
|
+
if (promptSent && outcome.streamedText) {
|
|
216
|
+
if (firstTokenAt === null) firstTokenAt = Date.now();
|
|
217
|
+
if (state.streamedText.length < MAX_STREAMED) {
|
|
218
|
+
const remaining = MAX_STREAMED - state.streamedText.length;
|
|
219
|
+
const chunk =
|
|
220
|
+
outcome.streamedText.length > remaining
|
|
221
|
+
? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
|
|
222
|
+
: outcome.streamedText;
|
|
223
|
+
state.streamedText += chunk;
|
|
224
|
+
opts.onStream?.(chunk);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (promptSent && outcome.activities) {
|
|
228
|
+
for (const a of outcome.activities) {
|
|
229
|
+
if (state.activities.length < MAX_ACTIVITIES) {
|
|
230
|
+
state.activities.push(a);
|
|
231
|
+
opts.onActivity?.(a);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (outcome.result) {
|
|
236
|
+
if (!outcome.result.result) outcome.result.result = state.streamedText;
|
|
237
|
+
state.result = outcome.result;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
|
|
242
|
+
proc.on('close', code => {
|
|
243
|
+
rejectAllPending(new Error(`${opts.harness.binary} exited`));
|
|
244
|
+
if (code !== 0 && !state.result) {
|
|
245
|
+
fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const final = state.result ?? opts.harness.extractResult(state);
|
|
249
|
+
if (final) {
|
|
250
|
+
if (!final.result) final.result = state.streamedText;
|
|
251
|
+
finish(final);
|
|
252
|
+
} else if (code !== 0) {
|
|
253
|
+
fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
|
|
254
|
+
} else {
|
|
255
|
+
fail(new Error(`${opts.harness.binary} finished without emitting a result`));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
proc.on('error', err => {
|
|
259
|
+
fail(new Error(`failed to start ${opts.harness.binary}: ${err.message}`));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
proc.kill('SIGKILL');
|
|
264
|
+
fail(new Error(`${opts.harness.binary} timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
|
|
265
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
266
|
+
timer.unref?.();
|
|
267
|
+
|
|
268
|
+
opts.signal?.addEventListener(
|
|
269
|
+
'abort',
|
|
270
|
+
() => {
|
|
271
|
+
proc.kill('SIGKILL');
|
|
272
|
+
fail(new Error('cancelled'));
|
|
273
|
+
},
|
|
274
|
+
{ once: true },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Drive the handshake. `session/prompt` has no separate timeout — it's the actual work,
|
|
278
|
+
// bounded by the overall `timer` above like everything else.
|
|
279
|
+
(async () => {
|
|
280
|
+
const modeId = opts.nativePermission ?? opts.harness.permissionMap?.[opts.permission]?.[0] ?? opts.permission;
|
|
281
|
+
const initResult = await sendRequest(
|
|
282
|
+
'initialize',
|
|
283
|
+
{
|
|
284
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
285
|
+
clientCapabilities: {}, // no fs/terminal proxying — decline those requests if asked (see handleServerRequest)
|
|
286
|
+
},
|
|
287
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
288
|
+
);
|
|
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
|
+
}
|
|
299
|
+
const sessionParams = {
|
|
300
|
+
cwd: opts.cwd,
|
|
301
|
+
mcpServers: [],
|
|
302
|
+
...(opts.addDirs && opts.addDirs.length > 0 ? { additionalDirectories: opts.addDirs } : {}),
|
|
303
|
+
};
|
|
304
|
+
// `session/load` resumes a prior session by id (its response carries no sessionId of its
|
|
305
|
+
// own — the client already has it) and replays prior turns as session/update notifications
|
|
306
|
+
// before the new prompt's; `session/new` mints a fresh one. Verified live: loadSession is
|
|
307
|
+
// advertised in agentCapabilities and a real session/load + follow-up prompt round-trips
|
|
308
|
+
// cleanly, replaying history and continuing the same token-usage accounting.
|
|
309
|
+
let sessionId: string | null;
|
|
310
|
+
let sessionResult: unknown;
|
|
311
|
+
if (opts.resumeSessionId) {
|
|
312
|
+
sessionResult = await sendRequest(
|
|
313
|
+
'session/load',
|
|
314
|
+
{ sessionId: opts.resumeSessionId, ...sessionParams },
|
|
315
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
316
|
+
);
|
|
317
|
+
sessionId = opts.resumeSessionId;
|
|
318
|
+
} else {
|
|
319
|
+
sessionResult = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
|
|
320
|
+
sessionId =
|
|
321
|
+
isRecord(sessionResult) && typeof sessionResult.sessionId === 'string' ? sessionResult.sessionId : null;
|
|
322
|
+
}
|
|
323
|
+
if (settled) return;
|
|
324
|
+
if (!sessionId) throw new Error('session/new did not return a sessionId');
|
|
325
|
+
// session/load's response carries no sessionId of its own (unlike session/new's) — stash it
|
|
326
|
+
// so the harness's parseLine can still report the real session id on the final result.
|
|
327
|
+
if (opts.resumeSessionId) {
|
|
328
|
+
state._harness ??= {};
|
|
329
|
+
state._harness.sessionId = sessionId;
|
|
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
|
+
}
|
|
345
|
+
await sendRequest('session/set_mode', { sessionId, modeId }, HANDSHAKE_TIMEOUT_MS);
|
|
346
|
+
if (settled) return;
|
|
347
|
+
promptSent = true;
|
|
348
|
+
await sendRequest('session/prompt', { sessionId, prompt: [{ type: 'text', text: opts.prompt }] });
|
|
349
|
+
if (settled) return;
|
|
350
|
+
// The agent doesn't exit on its own once the turn is done — an ACP session can outlive a
|
|
351
|
+
// single prompt (resume, follow-up turns). `delegate()` is one-shot per process, so finish
|
|
352
|
+
// as soon as the prompt response resolves (parseLine already turned it into state.result,
|
|
353
|
+
// synchronously, before this await's continuation runs) and tear the process down ourselves.
|
|
354
|
+
const final = state.result ?? opts.harness.extractResult(state);
|
|
355
|
+
if (final) {
|
|
356
|
+
if (!final.result) final.result = state.streamedText;
|
|
357
|
+
finish(final);
|
|
358
|
+
} else {
|
|
359
|
+
fail(new Error(`${opts.harness.binary} session/prompt completed without emitting a result`));
|
|
360
|
+
}
|
|
361
|
+
proc.kill('SIGKILL');
|
|
362
|
+
})().catch(err => {
|
|
363
|
+
// Every other exit path (timeout, abort, success) kills the child — a rejected handshake
|
|
364
|
+
// step (bad modeId, a JSON-RPC error, a HANDSHAKE_TIMEOUT_MS expiry) must too, or the
|
|
365
|
+
// process leaks: ACP agents only exit on stdin EOF, which nothing else here sends.
|
|
366
|
+
proc.kill('SIGKILL');
|
|
367
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
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;
|