pi-harness-delegate 0.4.0 → 0.5.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 +9 -3
- package/extensions/acp-runner.ts +310 -0
- package/extensions/harnesses/devin.ts +206 -0
- package/extensions/harnesses/registry.ts +29 -0
- package/extensions/harnesses/types.ts +9 -0
- package/extensions/index.ts +54 -18
- package/extensions/progress-multi.ts +48 -10
- package/extensions/progress.ts +16 -2
- package/extensions/templates.ts +20 -2
- 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.
|
|
@@ -68,7 +73,7 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
|
68
73
|
│ esc cancel all · m minimize │
|
|
69
74
|
╰────────────────────────────────────────────────────────────╯
|
|
70
75
|
```
|
|
71
|
-
Double-ESC cancels every in-flight (and still-queued) run at once; `m` minimizes; the status bar chip shows aggregate state across every status (e.g. `● 1✓ 1✗ 1▶ 1
|
|
76
|
+
Double-ESC cancels every in-flight (and still-queued) run at once; `m` minimizes; the status bar chip shows aggregate state across every status plus elapsed and spend so far (e.g. `● 1✓ 1✗ 1▶ 1… · ⏱ 0:42 · $0.175` — done, failed, running, queued; zero status counts are omitted, so it reads `● 4▶ · ⏱ 0:05` while all four are in flight; the spend segment itself only appears once a run has actually reported a cost). A harness that fails keeps its failure reason on its row rather than blanking, so the overlay still says *why*. Once every row is done or failed, the overlay lingers ~3s on the finished board before closing (Esc or `m` dismisses it immediately) so glancing back after a fan-out still shows the final state instead of an empty screen. Single-harness runs keep the original one-run overlay unchanged, including its live activity feed showing a `+N earlier` marker instead of silently dropping older entries once the feed outgrows the visible window.
|
|
72
77
|
|
|
73
78
|
## Harnesses
|
|
74
79
|
|
|
@@ -78,6 +83,7 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
|
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
84
|
| `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json`. Schema-verified against opencode 1.18.16. |
|
|
80
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). |
|
|
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
|
|
|
@@ -0,0 +1,310 @@
|
|
|
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 `harness.transport` and everything downstream (transcripts,
|
|
12
|
+
* `ToolCallIndex`, progress overlays, fan-out, spend rollup) is unchanged.
|
|
13
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { createInterface } from 'node:readline';
|
|
19
|
+
import { DEFAULT_TIMEOUT_MS, type ParseState, type StreamedResult } from './harnesses/types.ts';
|
|
20
|
+
import type { HarnessResult, RunHarnessOptions } from './runner.ts';
|
|
21
|
+
|
|
22
|
+
/** Bound on the initial handshake (initialize / session/new / session/set_mode) so a hung agent
|
|
23
|
+
* doesn't wedge the whole `timeoutMs` budget before `session/prompt` — the actual work — even starts. */
|
|
24
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
29
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PendingRequest {
|
|
33
|
+
resolve: (result: unknown) => void;
|
|
34
|
+
reject: (err: Error) => void;
|
|
35
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const args = opts.harness.buildArgs({
|
|
41
|
+
prompt: opts.prompt,
|
|
42
|
+
cwd: opts.cwd,
|
|
43
|
+
permission: opts.permission,
|
|
44
|
+
nativePermission: opts.nativePermission,
|
|
45
|
+
model: opts.model,
|
|
46
|
+
maxBudgetUsd: opts.maxBudgetUsd,
|
|
47
|
+
addDirs: opts.addDirs,
|
|
48
|
+
resumeSessionId: opts.resumeSessionId,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
52
|
+
|
|
53
|
+
const state: ParseState = { streamedText: '', activities: [], result: null, _harness: {} };
|
|
54
|
+
let stderr = '';
|
|
55
|
+
let settled = false;
|
|
56
|
+
let firstTokenAt: number | null = null;
|
|
57
|
+
// Resumed sessions replay every prior turn as session/update notifications before the new
|
|
58
|
+
// prompt's — set once session/prompt is actually sent, so replayed text/activities (and a
|
|
59
|
+
// replay-skewed TTFT) never reach the caller. See the handshake IIFE below.
|
|
60
|
+
let promptSent = false;
|
|
61
|
+
const startAt = Date.now();
|
|
62
|
+
const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
|
|
63
|
+
const MAX_ACTIVITIES = 5000;
|
|
64
|
+
|
|
65
|
+
let nextId = 1;
|
|
66
|
+
const pending = new Map<number, PendingRequest>();
|
|
67
|
+
|
|
68
|
+
const finish = (r: StreamedResult) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
rejectAllPending(new Error('session ended'));
|
|
73
|
+
const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
|
|
74
|
+
resolve({ ...r, ttftMs: ttft, streamedText: state.streamedText, harness: opts.harness.name });
|
|
75
|
+
};
|
|
76
|
+
const fail = (err: Error) => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
rejectAllPending(err);
|
|
81
|
+
reject(err);
|
|
82
|
+
};
|
|
83
|
+
function rejectAllPending(err: Error): void {
|
|
84
|
+
for (const p of pending.values()) {
|
|
85
|
+
clearTimeout(p.timer);
|
|
86
|
+
p.reject(err);
|
|
87
|
+
}
|
|
88
|
+
pending.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const writeLine = (msg: Record<string, unknown>): void => {
|
|
92
|
+
try {
|
|
93
|
+
proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
94
|
+
} catch {
|
|
95
|
+
// stdin already closed (process exiting) — the pending request(s) time out/reject normally.
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Send a JSON-RPC request and await its response. `timeoutMs` bounds only this request —
|
|
100
|
+
* distinct from the overall run timeout — so a hung handshake step fails fast and clearly.
|
|
101
|
+
* Omitted for `session/prompt`: that's the actual work, already bounded by the overall
|
|
102
|
+
* `timer` below, which kills the process and rejects every pending request on fire. */
|
|
103
|
+
const sendRequest = (method: string, params: unknown, timeoutMs?: number): Promise<unknown> => {
|
|
104
|
+
const id = nextId++;
|
|
105
|
+
return new Promise((res, rej) => {
|
|
106
|
+
const entry: PendingRequest = { resolve: res, reject: rej };
|
|
107
|
+
if (timeoutMs !== undefined) {
|
|
108
|
+
entry.timer = setTimeout(() => {
|
|
109
|
+
pending.delete(id);
|
|
110
|
+
rej(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
111
|
+
}, timeoutMs);
|
|
112
|
+
}
|
|
113
|
+
pending.set(id, entry);
|
|
114
|
+
writeLine({ jsonrpc: '2.0', id, method, params });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Respond to a request the agent sent to us. Every request needs a reply or the agent's
|
|
119
|
+
* session hangs waiting for it. */
|
|
120
|
+
const respond = (id: unknown, result: unknown): void => writeLine({ jsonrpc: '2.0', id, result });
|
|
121
|
+
const respondError = (id: unknown, message: string): void =>
|
|
122
|
+
writeLine({ jsonrpc: '2.0', id, error: { code: -32601, message } });
|
|
123
|
+
|
|
124
|
+
/** Handle a request FROM the agent (has both `method` and `id`). We run non-interactively
|
|
125
|
+
* with a permission mode already negotiated, so the safe default is to decline anything
|
|
126
|
+
* not already covered by that mode rather than auto-approve — never observed in the captured
|
|
127
|
+
* fixture this harness was built from, but handled defensively since the spec allows it. */
|
|
128
|
+
const handleServerRequest = (msg: Record<string, unknown>): void => {
|
|
129
|
+
const { id, method, params } = msg;
|
|
130
|
+
if (method === 'session/request_permission' && isRecord(params) && Array.isArray(params.options)) {
|
|
131
|
+
const options = params.options as Array<{ optionId?: unknown; kind?: unknown }>;
|
|
132
|
+
const reject =
|
|
133
|
+
options.find(o => o.kind === 'reject_once') ??
|
|
134
|
+
options.find(o => o.kind === 'reject_always') ??
|
|
135
|
+
options.find(o => typeof o.kind === 'string' && o.kind.startsWith('reject'));
|
|
136
|
+
if (reject && typeof reject.optionId === 'string') {
|
|
137
|
+
respond(id, { outcome: { outcome: 'selected', optionId: reject.optionId } });
|
|
138
|
+
} else {
|
|
139
|
+
respond(id, { outcome: { outcome: 'cancelled' } });
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// fs/read_text_file, fs/write_text_file, terminal/* etc. — we declare no client capabilities
|
|
144
|
+
// for these in `initialize`, so a well-behaved agent shouldn't ask; decline defensively if one does.
|
|
145
|
+
respondError(id, `${String(method)} not supported by this client`);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const rl = createInterface({ input: proc.stdout });
|
|
149
|
+
rl.on('line', line => {
|
|
150
|
+
let msg: unknown;
|
|
151
|
+
try {
|
|
152
|
+
msg = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!isRecord(msg)) return;
|
|
157
|
+
|
|
158
|
+
if (typeof msg.method === 'string' && msg.id !== undefined) {
|
|
159
|
+
handleServerRequest(msg);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (msg.id !== undefined && 'result' in msg) {
|
|
163
|
+
const entry = pending.get(msg.id as number);
|
|
164
|
+
if (entry) {
|
|
165
|
+
pending.delete(msg.id as number);
|
|
166
|
+
clearTimeout(entry.timer);
|
|
167
|
+
entry.resolve(msg.result);
|
|
168
|
+
}
|
|
169
|
+
// still fall through: `harness.parseLine` may also want to extract activity/result data.
|
|
170
|
+
} else if (msg.id !== undefined && 'error' in msg) {
|
|
171
|
+
const entry = pending.get(msg.id as number);
|
|
172
|
+
if (entry) {
|
|
173
|
+
pending.delete(msg.id as number);
|
|
174
|
+
clearTimeout(entry.timer);
|
|
175
|
+
const err = isRecord(msg.error) ? msg.error : {};
|
|
176
|
+
entry.reject(new Error(typeof err.message === 'string' ? err.message : `${msg.id} failed`));
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const outcome = opts.harness.parseLine(line, state);
|
|
182
|
+
// Discard streamed text/activities from anything that arrives before the new session/prompt
|
|
183
|
+
// is sent — on a resume that's the replayed prior conversation, not the new turn's own output.
|
|
184
|
+
if (promptSent && outcome.streamedText) {
|
|
185
|
+
if (firstTokenAt === null) firstTokenAt = Date.now();
|
|
186
|
+
if (state.streamedText.length < MAX_STREAMED) {
|
|
187
|
+
const remaining = MAX_STREAMED - state.streamedText.length;
|
|
188
|
+
const chunk =
|
|
189
|
+
outcome.streamedText.length > remaining
|
|
190
|
+
? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
|
|
191
|
+
: outcome.streamedText;
|
|
192
|
+
state.streamedText += chunk;
|
|
193
|
+
opts.onStream?.(chunk);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (promptSent && outcome.activities) {
|
|
197
|
+
for (const a of outcome.activities) {
|
|
198
|
+
if (state.activities.length < MAX_ACTIVITIES) {
|
|
199
|
+
state.activities.push(a);
|
|
200
|
+
opts.onActivity?.(a);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (outcome.result) {
|
|
205
|
+
if (!outcome.result.result) outcome.result.result = state.streamedText;
|
|
206
|
+
state.result = outcome.result;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
|
|
211
|
+
proc.on('close', code => {
|
|
212
|
+
rejectAllPending(new Error(`${opts.harness.binary} exited`));
|
|
213
|
+
if (code !== 0 && !state.result) {
|
|
214
|
+
fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const final = state.result ?? opts.harness.extractResult(state);
|
|
218
|
+
if (final) {
|
|
219
|
+
if (!final.result) final.result = state.streamedText;
|
|
220
|
+
finish(final);
|
|
221
|
+
} else if (code !== 0) {
|
|
222
|
+
fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
|
|
223
|
+
} else {
|
|
224
|
+
fail(new Error(`${opts.harness.binary} finished without emitting a result`));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
proc.on('error', err => {
|
|
228
|
+
fail(new Error(`failed to start ${opts.harness.binary}: ${err.message}`));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
proc.kill('SIGKILL');
|
|
233
|
+
fail(new Error(`${opts.harness.binary} timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
|
|
234
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
235
|
+
timer.unref?.();
|
|
236
|
+
|
|
237
|
+
opts.signal?.addEventListener(
|
|
238
|
+
'abort',
|
|
239
|
+
() => {
|
|
240
|
+
proc.kill('SIGKILL');
|
|
241
|
+
fail(new Error('cancelled'));
|
|
242
|
+
},
|
|
243
|
+
{ once: true },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Drive the handshake. `session/prompt` has no separate timeout — it's the actual work,
|
|
247
|
+
// bounded by the overall `timer` above like everything else.
|
|
248
|
+
(async () => {
|
|
249
|
+
const modeId = opts.nativePermission ?? opts.harness.permissionMap?.[opts.permission]?.[0] ?? opts.permission;
|
|
250
|
+
await sendRequest(
|
|
251
|
+
'initialize',
|
|
252
|
+
{
|
|
253
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
254
|
+
clientCapabilities: {}, // no fs/terminal proxying — decline those requests if asked (see handleServerRequest)
|
|
255
|
+
},
|
|
256
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
257
|
+
);
|
|
258
|
+
if (settled) return;
|
|
259
|
+
const sessionParams = {
|
|
260
|
+
cwd: opts.cwd,
|
|
261
|
+
mcpServers: [],
|
|
262
|
+
...(opts.addDirs && opts.addDirs.length > 0 ? { additionalDirectories: opts.addDirs } : {}),
|
|
263
|
+
};
|
|
264
|
+
// `session/load` resumes a prior session by id (its response carries no sessionId of its
|
|
265
|
+
// own — the client already has it) and replays prior turns as session/update notifications
|
|
266
|
+
// before the new prompt's; `session/new` mints a fresh one. Verified live: loadSession is
|
|
267
|
+
// advertised in agentCapabilities and a real session/load + follow-up prompt round-trips
|
|
268
|
+
// cleanly, replaying history and continuing the same token-usage accounting.
|
|
269
|
+
let sessionId: string | null;
|
|
270
|
+
if (opts.resumeSessionId) {
|
|
271
|
+
await sendRequest('session/load', { sessionId: opts.resumeSessionId, ...sessionParams }, HANDSHAKE_TIMEOUT_MS);
|
|
272
|
+
sessionId = opts.resumeSessionId;
|
|
273
|
+
} else {
|
|
274
|
+
const newSession = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
|
|
275
|
+
sessionId = isRecord(newSession) && typeof newSession.sessionId === 'string' ? newSession.sessionId : null;
|
|
276
|
+
}
|
|
277
|
+
if (settled) return;
|
|
278
|
+
if (!sessionId) throw new Error('session/new did not return a sessionId');
|
|
279
|
+
// session/load's response carries no sessionId of its own (unlike session/new's) — stash it
|
|
280
|
+
// so the harness's parseLine can still report the real session id on the final result.
|
|
281
|
+
if (opts.resumeSessionId) {
|
|
282
|
+
state._harness ??= {};
|
|
283
|
+
state._harness.sessionId = sessionId;
|
|
284
|
+
}
|
|
285
|
+
await sendRequest('session/set_mode', { sessionId, modeId }, HANDSHAKE_TIMEOUT_MS);
|
|
286
|
+
if (settled) return;
|
|
287
|
+
promptSent = true;
|
|
288
|
+
await sendRequest('session/prompt', { sessionId, prompt: [{ type: 'text', text: opts.prompt }] });
|
|
289
|
+
if (settled) return;
|
|
290
|
+
// The agent doesn't exit on its own once the turn is done — an ACP session can outlive a
|
|
291
|
+
// single prompt (resume, follow-up turns). `delegate()` is one-shot per process, so finish
|
|
292
|
+
// as soon as the prompt response resolves (parseLine already turned it into state.result,
|
|
293
|
+
// synchronously, before this await's continuation runs) and tear the process down ourselves.
|
|
294
|
+
const final = state.result ?? opts.harness.extractResult(state);
|
|
295
|
+
if (final) {
|
|
296
|
+
if (!final.result) final.result = state.streamedText;
|
|
297
|
+
finish(final);
|
|
298
|
+
} else {
|
|
299
|
+
fail(new Error(`${opts.harness.binary} session/prompt completed without emitting a result`));
|
|
300
|
+
}
|
|
301
|
+
proc.kill('SIGKILL');
|
|
302
|
+
})().catch(err => {
|
|
303
|
+
// Every other exit path (timeout, abort, success) kills the child — a rejected handshake
|
|
304
|
+
// step (bad modeId, a JSON-RPC error, a HANDSHAKE_TIMEOUT_MS expiry) must too, or the
|
|
305
|
+
// process leaks: ACP agents only exit on stdin EOF, which nothing else here sends.
|
|
306
|
+
proc.kill('SIGKILL');
|
|
307
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devin — runs over the Agent Client Protocol (`devin acp`), not stdout JSONL. `transport: 'acp'`
|
|
3
|
+
* routes it through extensions/acp-runner.ts instead of runner.ts; `buildArgs` here only needs to
|
|
4
|
+
* spawn the ACP server (`devin acp`) — the prompt, permission mode, and session lifecycle are all
|
|
5
|
+
* negotiated over the wire by acp-runner.ts, not passed as CLI flags.
|
|
6
|
+
*
|
|
7
|
+
* `parseLine`/`extractResult` translate raw ACP JSON-RPC lines (one per line, same as any other
|
|
8
|
+
* harness's JSONL) into `ParseOutcome`/`StreamedResult` — this is what makes the JSON-RPC plumbing
|
|
9
|
+
* testable via the same fixture-replay pattern as the stdout harnesses (tests/fixtures.test.ts),
|
|
10
|
+
* without spawning a process: `tests/fixtures/devin-acp.jsonl` is a real captured session.
|
|
11
|
+
*
|
|
12
|
+
* Schema verified against `devin 3000.6.7 (260a97c8)` — see docs/devin-acp-harness-design.md.
|
|
13
|
+
*
|
|
14
|
+
* Workspace trust: the design note this was built from flagged `devin`'s interactive workspace-trust
|
|
15
|
+
* gate as a hazard needing a `detect()` hint. Live verification found that gate applies to `devin -p`
|
|
16
|
+
* / interactive `devin`, but NOT to `devin acp` — confirmed by running the raw `initialize`/`session/new`
|
|
17
|
+
* handshake against a directory never seen by devin before (no `--config` bypass), which succeeded with
|
|
18
|
+
* no refusal, while `devin -p` in the same directory refused. So this harness's actual code path was
|
|
19
|
+
* never gated in the first place; no hint or bypass is needed. If a future devin version starts
|
|
20
|
+
* enforcing trust over ACP too, the generic non-zero-exit path in acp-runner.ts already surfaces
|
|
21
|
+
* whatever refusal message devin prints, same as any other process failure.
|
|
22
|
+
*/
|
|
23
|
+
import { execFile } from 'node:child_process';
|
|
24
|
+
import { promisify } from 'node:util';
|
|
25
|
+
import type {
|
|
26
|
+
ActivityEvent,
|
|
27
|
+
BuildArgsOpts,
|
|
28
|
+
Harness,
|
|
29
|
+
NormalizedPermission,
|
|
30
|
+
ParseOutcome,
|
|
31
|
+
ParseState,
|
|
32
|
+
StreamedResult,
|
|
33
|
+
} from './types.ts';
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
|
|
37
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
38
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Exact structural match to Claude's tiers (session/new's captured `availableModes`: plan, accept-edits,
|
|
42
|
+
* smart, ask, bypass). `smart`/`ask` stay reachable via the existing `nativePermission` escape hatch. */
|
|
43
|
+
const PERMISSION_MAP: Record<NormalizedPermission, string> = {
|
|
44
|
+
readonly: 'plan',
|
|
45
|
+
edit: 'accept-edits',
|
|
46
|
+
danger: 'bypass',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Translate one `session/update` notification's `params.update` payload into ParseOutcome deltas. */
|
|
50
|
+
function translateUpdate(update: Record<string, unknown>, state: ParseState): ParseOutcome {
|
|
51
|
+
const activities: ActivityEvent[] = [];
|
|
52
|
+
let streamedText: string | undefined;
|
|
53
|
+
const content = isRecord(update.content) ? update.content : undefined;
|
|
54
|
+
const text = content?.type === 'text' && typeof content.text === 'string' ? content.text : undefined;
|
|
55
|
+
|
|
56
|
+
switch (update.sessionUpdate) {
|
|
57
|
+
case 'agent_message_chunk':
|
|
58
|
+
if (text !== undefined) streamedText = text;
|
|
59
|
+
break;
|
|
60
|
+
case 'agent_thought_chunk':
|
|
61
|
+
if (text !== undefined) activities.push({ kind: 'thinking', chars: text.length });
|
|
62
|
+
break;
|
|
63
|
+
case 'tool_call': {
|
|
64
|
+
if (typeof update.toolCallId !== 'string') break;
|
|
65
|
+
const meta = isRecord(update._meta) ? update._meta : {};
|
|
66
|
+
const inferenceName = meta['cognition.ai/inferenceToolName'];
|
|
67
|
+
const name =
|
|
68
|
+
typeof inferenceName === 'string' ? inferenceName : typeof update.kind === 'string' ? update.kind : 'tool';
|
|
69
|
+
activities.push({
|
|
70
|
+
kind: 'tool_input',
|
|
71
|
+
name,
|
|
72
|
+
input: isRecord(update.rawInput) ? update.rawInput : {},
|
|
73
|
+
id: update.toolCallId,
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'tool_call_update': {
|
|
78
|
+
// Only a terminal status produces a tool_result. A call fires multiple `in_progress`
|
|
79
|
+
// updates for the same toolCallId before its `completed`/`failed` (real fixture: 2-3 per
|
|
80
|
+
// id) — ToolCallIndex.resolve() consumes the pending entry on first match, so an earlier
|
|
81
|
+
// in_progress "result" would eat the id and strand the real completion unattributed.
|
|
82
|
+
if (typeof update.toolCallId !== 'string') break;
|
|
83
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
84
|
+
activities.push({ kind: 'tool_result', isError: update.status === 'failed', id: update.toolCallId });
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'usage_update':
|
|
89
|
+
state._harness ??= {};
|
|
90
|
+
if (typeof update.size === 'number') state._harness.contextWindow = update.size;
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return { streamedText, activities };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parseDevinLine(line: string, state: ParseState): ParseOutcome {
|
|
99
|
+
let o: unknown;
|
|
100
|
+
try {
|
|
101
|
+
o = JSON.parse(line);
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(o)) return {};
|
|
106
|
+
|
|
107
|
+
if (o.method === 'session/update' && isRecord(o.params) && isRecord(o.params.update)) {
|
|
108
|
+
return translateUpdate(o.params.update, state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (o.method === '_cognition.ai/agent_stopped' && isRecord(o.params) && isRecord(o.params.stats)) {
|
|
112
|
+
// The real model Devin ran, independent of whatever --model was requested — the honest
|
|
113
|
+
// value to report, since `--model` accepts fuzzy names and enterprise config can override it.
|
|
114
|
+
const label = o.params.stats.modelLabel;
|
|
115
|
+
if (typeof label === 'string') {
|
|
116
|
+
state._harness ??= {};
|
|
117
|
+
state._harness.model = label;
|
|
118
|
+
}
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isRecord(o.result)) {
|
|
123
|
+
const r = o.result;
|
|
124
|
+
if (typeof r.stopReason === 'string') {
|
|
125
|
+
// session/prompt response — the turn is over, build the final result.
|
|
126
|
+
const u = isRecord(r.usage) ? r.usage : null;
|
|
127
|
+
const harnessState = isRecord(state._harness) ? state._harness : {};
|
|
128
|
+
// Devin's inputTokens already includes cachedReadTokens as a subset (fixture: on every
|
|
129
|
+
// usage_update/prompt result, used === inputTokens + outputTokens exactly, and
|
|
130
|
+
// cachedReadTokens < inputTokens). StreamedUsage follows Claude's convention where
|
|
131
|
+
// inputTokens EXCLUDES cache reads (index.ts sums inputTokens + cacheReadInputTokens into
|
|
132
|
+
// promptTokens) — so subtract the cache-read subset back out here, or promptTokens/context%
|
|
133
|
+
// double-counts it. Math.max guards against a negative if that invariant ever breaks.
|
|
134
|
+
const cacheReadInputTokens = typeof u?.cachedReadTokens === 'number' ? u.cachedReadTokens : 0;
|
|
135
|
+
const rawInputTokens = typeof u?.inputTokens === 'number' ? u.inputTokens : 0;
|
|
136
|
+
const result: StreamedResult = {
|
|
137
|
+
result: state.streamedText,
|
|
138
|
+
// Devin's ACP prompt response carries no error flag of its own — a genuine failure
|
|
139
|
+
// (JSON-RPC `error`, non-zero exit, workspace-trust refusal) surfaces via acp-runner.ts's
|
|
140
|
+
// process-level fail() path instead, same as every other harness's non-zero-exit case.
|
|
141
|
+
isError: false,
|
|
142
|
+
numTurns: null, // not reported for a single prompt turn
|
|
143
|
+
totalCostUsd: null, // Devin reports no $ cost over ACP — honest-metrics convention (#11)
|
|
144
|
+
sessionId: typeof harnessState.sessionId === 'string' ? harnessState.sessionId : null,
|
|
145
|
+
stopReason: r.stopReason,
|
|
146
|
+
permissionDenials: [],
|
|
147
|
+
durationMs: null,
|
|
148
|
+
durationApiMs: null,
|
|
149
|
+
ttftMs: null,
|
|
150
|
+
model: typeof harnessState.model === 'string' ? harnessState.model : null,
|
|
151
|
+
contextWindow: typeof harnessState.contextWindow === 'number' ? harnessState.contextWindow : null,
|
|
152
|
+
maxOutputTokens: null,
|
|
153
|
+
usage: u
|
|
154
|
+
? {
|
|
155
|
+
inputTokens: Math.max(0, rawInputTokens - cacheReadInputTokens),
|
|
156
|
+
outputTokens: typeof u.outputTokens === 'number' ? u.outputTokens : 0,
|
|
157
|
+
cacheCreationInputTokens: 0, // not reported over ACP
|
|
158
|
+
cacheReadInputTokens,
|
|
159
|
+
}
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
return { result };
|
|
163
|
+
}
|
|
164
|
+
if (typeof r.sessionId === 'string') {
|
|
165
|
+
// session/new response — stash the id; the prompt response above has no sessionId of its own.
|
|
166
|
+
state._harness ??= {};
|
|
167
|
+
state._harness.sessionId = r.sessionId;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const devinHarness: Harness = {
|
|
174
|
+
name: 'devin',
|
|
175
|
+
displayName: 'Devin',
|
|
176
|
+
binary: 'devin',
|
|
177
|
+
transport: 'acp',
|
|
178
|
+
async detect() {
|
|
179
|
+
try {
|
|
180
|
+
const { stdout } = await execFileAsync('devin', ['--version'], { timeout: 5000 });
|
|
181
|
+
return { ok: true, version: stdout.trim() };
|
|
182
|
+
} catch {
|
|
183
|
+
return { ok: false, hint: 'Install the Devin CLI: https://docs.devin.ai/' };
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
buildArgs(opts: BuildArgsOpts): string[] {
|
|
187
|
+
// The prompt, permission mode, and session lifecycle are all negotiated over the ACP wire
|
|
188
|
+
// (see acp-runner.ts) — this just launches the ACP server. `--model` is the one real CLI flag
|
|
189
|
+
// `devin acp` accepts (verified: `devin acp --help`); it sets the default model for every new
|
|
190
|
+
// ACP session on this server, and accepts fuzzy names (family slug, alias, or partial name).
|
|
191
|
+
const args = ['acp'];
|
|
192
|
+
if (opts.model) args.push('--model', opts.model);
|
|
193
|
+
return args;
|
|
194
|
+
},
|
|
195
|
+
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
196
|
+
return parseDevinLine(line, state);
|
|
197
|
+
},
|
|
198
|
+
extractResult(state: ParseState): StreamedResult | null {
|
|
199
|
+
return state.result;
|
|
200
|
+
},
|
|
201
|
+
permissionMap: {
|
|
202
|
+
readonly: [PERMISSION_MAP.readonly],
|
|
203
|
+
edit: [PERMISSION_MAP.edit],
|
|
204
|
+
danger: [PERMISSION_MAP.danger],
|
|
205
|
+
},
|
|
206
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ampHarness } from './amp.ts';
|
|
2
2
|
import { claudeHarness } from './claude.ts';
|
|
3
3
|
import { codexHarness } from './codex.ts';
|
|
4
|
+
import { devinHarness } from './devin.ts';
|
|
4
5
|
import { opencodeHarness } from './opencode.ts';
|
|
5
6
|
import type { Harness } from './types.ts';
|
|
6
7
|
|
|
@@ -9,6 +10,7 @@ export const HARNESSES: Record<string, Harness> = {
|
|
|
9
10
|
codex: codexHarness,
|
|
10
11
|
opencode: opencodeHarness,
|
|
11
12
|
amp: ampHarness,
|
|
13
|
+
devin: devinHarness,
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export const ALIASES: Record<string, string> = {
|
|
@@ -49,3 +51,30 @@ export function isKnownHarness(name: string): boolean {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export const normalizeHarnessName = resolveHarnessName;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Legacy danger spellings, kept for templates written before harnesses were partitioned.
|
|
57
|
+
*/
|
|
58
|
+
const LEGACY_DANGER_TOKENS = new Set(['bypassPermissions', 'danger-full-access', 'danger']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Does this native permission string mean "unrestricted" for this harness?
|
|
62
|
+
*
|
|
63
|
+
* A template can declare any native mode via the escape hatch (`permission: <native>`), and
|
|
64
|
+
* `normalizePermission` files anything unrecognised under `nativePermission` with a normalized
|
|
65
|
+
* tier of `edit`. Without this check, a template declaring `yolo` (amp) or `bypass` (devin) would
|
|
66
|
+
* skip the `allowDangerous` gate entirely and run the harness unsandboxed while `delegate()`
|
|
67
|
+
* recorded the run as `edit` — breaking the invariant that danger is only ever reachable through
|
|
68
|
+
* an explicit per-call `allowDangerous: true`.
|
|
69
|
+
*
|
|
70
|
+
* Matches the harness's own `permissionMap.danger`, joined, so a multi-token danger mode is
|
|
71
|
+
* compared as a whole: opencode's danger is `['build', '--auto']`, and bare `build` is its *edit*
|
|
72
|
+
* token — treating each token separately would wrongly gate legitimate `edit` templates.
|
|
73
|
+
*/
|
|
74
|
+
export function isNativeDangerPermission(harness: Harness | undefined, nativePermission: string | undefined): boolean {
|
|
75
|
+
if (!nativePermission) return false;
|
|
76
|
+
const native = nativePermission.trim();
|
|
77
|
+
if (LEGACY_DANGER_TOKENS.has(native)) return true;
|
|
78
|
+
const danger = harness?.permissionMap?.danger;
|
|
79
|
+
return Array.isArray(danger) && danger.length > 0 && native === danger.join(' ');
|
|
80
|
+
}
|
|
@@ -74,6 +74,11 @@ export interface DetectResult {
|
|
|
74
74
|
hint?: string;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Which runner drives a harness. Defaults to 'stdout' (extensions/runner.ts) when omitted.
|
|
78
|
+
* 'acp' (extensions/acp-runner.ts) is for Agent Client Protocol agents — a bidirectional
|
|
79
|
+
* JSON-RPC session over stdio rather than a one-way JSONL stream. See AGENTS.md. */
|
|
80
|
+
export type Transport = 'stdout' | 'acp';
|
|
81
|
+
|
|
77
82
|
export interface Harness {
|
|
78
83
|
name: string;
|
|
79
84
|
displayName: string;
|
|
@@ -90,6 +95,10 @@ export interface Harness {
|
|
|
90
95
|
/** Normalized -> native arg fragments. */
|
|
91
96
|
permissionMap?: Record<NormalizedPermission, string[]>;
|
|
92
97
|
permissionHint?: (permission: NormalizedPermission) => string[];
|
|
98
|
+
/** Runner selection. Omitted/'stdout' -> runner.ts (the four existing harnesses); 'acp' -> acp-runner.ts.
|
|
99
|
+
* For 'acp', `permissionMap`'s first element per tier is the ACP session mode id (session/set_mode) —
|
|
100
|
+
* the same field the stdout harnesses use for CLI arg fragments, reused rather than duplicated. */
|
|
101
|
+
transport?: Transport;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
export const DEFAULT_TIMEOUT_MS = 600_000;
|
package/extensions/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
5
|
* - `delegate` tool (primary) + `claude_delegate` alias
|
|
6
|
-
* - `/delegate` command (primary) + `/claude`, `/codex`, `/opencode`, `/amp`, `/omp` aliases
|
|
6
|
+
* - `/delegate` command (primary) + `/claude`, `/codex`, `/opencode`, `/amp`, `/omp`, `/devin` aliases
|
|
7
7
|
*
|
|
8
8
|
* Templates ship in ../templates/shared + ../templates/<harness>; users add custom ones in
|
|
9
9
|
* ~/.pi/agent/delegate/templates/<harness>/ (global)
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
truncateToWidth,
|
|
30
30
|
} from '@earendil-works/pi-tui';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
|
+
import { runAcpHarness } from './acp-runner.ts';
|
|
32
33
|
import {
|
|
33
34
|
aggregateSpend,
|
|
34
35
|
buildFanoutReport,
|
|
@@ -64,16 +65,16 @@ import {
|
|
|
64
65
|
getHarness,
|
|
65
66
|
HARNESS_NAMES,
|
|
66
67
|
isKnownHarness,
|
|
68
|
+
isNativeDangerPermission,
|
|
67
69
|
resolveHarnessName,
|
|
68
70
|
} from './harnesses/registry.ts';
|
|
69
71
|
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
70
|
-
|
|
71
72
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
72
73
|
import { NotifyBatcher } from './notify.ts';
|
|
73
74
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
74
75
|
import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
|
|
75
76
|
import { runHarness } from './runner.ts';
|
|
76
|
-
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
77
|
+
import { type DelegateTemplate, loadTemplates, resolveNativePermission } from './templates.ts';
|
|
77
78
|
import { mapClaudeUsage } from './usage.ts';
|
|
78
79
|
|
|
79
80
|
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
@@ -110,6 +111,9 @@ interface DelegateOptions {
|
|
|
110
111
|
|
|
111
112
|
/** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
|
|
112
113
|
const VERIFY_TIMEOUT_MS = 5 * 60_000;
|
|
114
|
+
/** How long the fan-out overlay lingers on the finished board after the last run resolves, so a
|
|
115
|
+
* user who looked away still catches the final state instead of it clearing instantly. */
|
|
116
|
+
const FANOUT_LINGER_MS = 3000;
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
119
|
* Run a verify command in-process on the host (never delegated to the harness). Report-only —
|
|
@@ -548,7 +552,7 @@ async function delegate(
|
|
|
548
552
|
// permission: normalized, danger requires explicit per-call allowDangerous:true
|
|
549
553
|
let permission: NormalizedPermission = template.permission;
|
|
550
554
|
const nativePerm = template.nativePermission;
|
|
551
|
-
const isNativeDanger =
|
|
555
|
+
const isNativeDanger = isNativeDangerPermission(harness, nativePerm);
|
|
552
556
|
if (template.permission === 'danger' || isNativeDanger) {
|
|
553
557
|
if (opts.allowDangerous !== true) {
|
|
554
558
|
throw new Error(
|
|
@@ -561,6 +565,9 @@ async function delegate(
|
|
|
561
565
|
permission = 'danger';
|
|
562
566
|
}
|
|
563
567
|
const permissionForDisplay = nativePerm ?? permission;
|
|
568
|
+
// Dropped when an explicit escalation moved us off the template's own tier — see
|
|
569
|
+
// resolveNativePermission(). Applies to both transports.
|
|
570
|
+
const nativePermissionForRun = resolveNativePermission(template.permission, permission, nativePerm);
|
|
564
571
|
|
|
565
572
|
const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
|
|
566
573
|
const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
|
|
@@ -569,7 +576,7 @@ async function delegate(
|
|
|
569
576
|
let streamedFull = '';
|
|
570
577
|
let result: import('./runner.ts').HarnessResult;
|
|
571
578
|
try {
|
|
572
|
-
|
|
579
|
+
const baseRunOpts = {
|
|
573
580
|
harness,
|
|
574
581
|
prompt,
|
|
575
582
|
cwd: ctx.cwd,
|
|
@@ -583,15 +590,17 @@ async function delegate(
|
|
|
583
590
|
signal: opts.signal,
|
|
584
591
|
timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
|
|
585
592
|
resumeSessionId: opts.sessionId,
|
|
586
|
-
onStream: t => {
|
|
593
|
+
onStream: (t: string) => {
|
|
587
594
|
streamedFull += t;
|
|
588
595
|
opts.onStream?.(t);
|
|
589
596
|
},
|
|
590
|
-
onActivity: ev => {
|
|
597
|
+
onActivity: (ev: ActivityEvent) => {
|
|
591
598
|
activityEvents.push(ev);
|
|
592
599
|
opts.onActivity?.(ev);
|
|
593
600
|
},
|
|
594
|
-
|
|
601
|
+
nativePermission: nativePermissionForRun,
|
|
602
|
+
};
|
|
603
|
+
result = harness.transport === 'acp' ? await runAcpHarness(baseRunOpts) : await runHarness(baseRunOpts);
|
|
595
604
|
} catch (err) {
|
|
596
605
|
release();
|
|
597
606
|
if (streamedFull.length > 0) {
|
|
@@ -957,11 +966,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
957
966
|
name: 'delegate',
|
|
958
967
|
label: 'Delegate',
|
|
959
968
|
description:
|
|
960
|
-
'Delegate a task to any harness (claude, codex, opencode, amp) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
|
|
969
|
+
'Delegate a task to any harness (claude, codex, opencode, amp, devin) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
|
|
961
970
|
promptSnippet: 'Delegate a subtask to a harness and return its report',
|
|
962
971
|
promptGuidelines: [
|
|
963
972
|
'delegate runs a harness headless in the working directory and returns a streamed report with cost, token usage, and a session id for follow-ups.',
|
|
964
|
-
'Pass harness (claude|codex|opencode|amp) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
|
|
973
|
+
'Pass harness (claude|codex|opencode|amp|devin) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
|
|
965
974
|
'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work. Some templates verify their own work (e.g. running tests) automatically after the harness finishes — that is not something you configure here.',
|
|
966
975
|
'harness: "all" or a comma list (e.g. "codex,opencode") fans the same task out to each detected harness and returns one synthesized comparison report — costs multiply, so only use it when the user actually wants a multi-harness comparison.',
|
|
967
976
|
'sessionId resumes a previous delegated session instead of starting fresh.',
|
|
@@ -971,7 +980,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
971
980
|
harness: Type.Optional(
|
|
972
981
|
Type.String({
|
|
973
982
|
description:
|
|
974
|
-
'Harness to use: claude, codex, opencode, amp (aliases: omp). "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
|
|
983
|
+
'Harness to use: claude, codex, opencode, amp (aliases: omp), devin. "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
|
|
975
984
|
}),
|
|
976
985
|
),
|
|
977
986
|
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
@@ -1339,6 +1348,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1339
1348
|
startedAt: null,
|
|
1340
1349
|
status: 'queued',
|
|
1341
1350
|
activity: '',
|
|
1351
|
+
costUsd: null,
|
|
1342
1352
|
}));
|
|
1343
1353
|
let requestRender: (() => void) | null = null;
|
|
1344
1354
|
|
|
@@ -1349,7 +1359,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1349
1359
|
if (now - chipLastPush < 500) return;
|
|
1350
1360
|
chipLastPush = now;
|
|
1351
1361
|
const theme = ctx.ui.theme;
|
|
1352
|
-
ctx.ui.setStatus(
|
|
1362
|
+
ctx.ui.setStatus(
|
|
1363
|
+
'delegate',
|
|
1364
|
+
theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows, now - overallStart)}`),
|
|
1365
|
+
);
|
|
1353
1366
|
};
|
|
1354
1367
|
|
|
1355
1368
|
const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
|
|
@@ -1398,6 +1411,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1398
1411
|
setRow({
|
|
1399
1412
|
status: failed ? 'failed' : 'done',
|
|
1400
1413
|
activity: failed ? reason || rows[idx].activity : '',
|
|
1414
|
+
costUsd: !failed && result ? result.result.totalCostUsd : null,
|
|
1401
1415
|
});
|
|
1402
1416
|
return {
|
|
1403
1417
|
harnessName: spec.harnessName,
|
|
@@ -1413,6 +1427,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1413
1427
|
let outcomes: FanoutOutcome[];
|
|
1414
1428
|
if (ctx.hasUI) {
|
|
1415
1429
|
let overlayHandle: OverlayHandle | null = null;
|
|
1430
|
+
let resolveDismiss = (): void => {};
|
|
1431
|
+
const dismissed = new Promise<void>(resolve => {
|
|
1432
|
+
resolveDismiss = () => resolve();
|
|
1433
|
+
});
|
|
1416
1434
|
const uiPromise = ctx.ui
|
|
1417
1435
|
.custom(
|
|
1418
1436
|
(tui, theme, _kb, done) => {
|
|
@@ -1431,6 +1449,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1431
1449
|
overlayHandle?.setHidden(true);
|
|
1432
1450
|
overlayHandle?.unfocus();
|
|
1433
1451
|
},
|
|
1452
|
+
onDismiss: () => resolveDismiss(),
|
|
1434
1453
|
});
|
|
1435
1454
|
},
|
|
1436
1455
|
{
|
|
@@ -1445,13 +1464,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1445
1464
|
)
|
|
1446
1465
|
.catch(() => {});
|
|
1447
1466
|
outcomes = await allSettled;
|
|
1448
|
-
|
|
1449
|
-
|
|
1467
|
+
// Cancelling already means "I'm done watching" — skip the linger so the overlay closes
|
|
1468
|
+
// right away instead of sitting on a cancelled board for FANOUT_LINGER_MS.
|
|
1469
|
+
if (cancelledAll) resolveDismiss();
|
|
1470
|
+
// Tear the overlay down after a short linger (or immediately on Esc/m/cancel) — in the
|
|
1471
|
+
// background, so this doesn't delay the outcomes we're about to return (and thus the
|
|
1472
|
+
// injected report). `activeOverlay` stays valid for `/delegate watch` until this settles.
|
|
1473
|
+
void (async () => {
|
|
1474
|
+
const timer = setTimeout(() => resolveDismiss(), FANOUT_LINGER_MS);
|
|
1475
|
+
timer.unref?.();
|
|
1476
|
+
await dismissed;
|
|
1477
|
+
clearTimeout(timer);
|
|
1478
|
+
await closeWhenMounted(() => closeWindow, 2000);
|
|
1479
|
+
await uiPromise;
|
|
1480
|
+
clearActive();
|
|
1481
|
+
ctx.ui.setStatus('delegate', undefined);
|
|
1482
|
+
})();
|
|
1450
1483
|
} else {
|
|
1451
1484
|
outcomes = await allSettled;
|
|
1485
|
+
clearActive();
|
|
1452
1486
|
}
|
|
1453
|
-
clearActive();
|
|
1454
|
-
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1455
1487
|
return outcomes;
|
|
1456
1488
|
};
|
|
1457
1489
|
|
|
@@ -1656,7 +1688,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1656
1688
|
);
|
|
1657
1689
|
else
|
|
1658
1690
|
ctx.ui.notify?.(
|
|
1659
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
1691
|
+
'Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
1660
1692
|
'warning',
|
|
1661
1693
|
);
|
|
1662
1694
|
return;
|
|
@@ -1726,7 +1758,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1726
1758
|
|
|
1727
1759
|
pi.registerCommand('delegate', {
|
|
1728
1760
|
description:
|
|
1729
|
-
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--verify=<cmd>] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report.',
|
|
1761
|
+
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--verify=<cmd>] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report.',
|
|
1730
1762
|
handler: makeHandler(),
|
|
1731
1763
|
});
|
|
1732
1764
|
pi.registerCommand('claude', {
|
|
@@ -1749,6 +1781,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1749
1781
|
description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
|
|
1750
1782
|
handler: makeHandler('amp'),
|
|
1751
1783
|
});
|
|
1784
|
+
pi.registerCommand('devin', {
|
|
1785
|
+
description: 'Alias for /delegate --harness=devin. Usage: /devin [--mode=...] <prompt>',
|
|
1786
|
+
handler: makeHandler('devin'),
|
|
1787
|
+
});
|
|
1752
1788
|
|
|
1753
1789
|
pi.on('input', async (event, _ctx) => {
|
|
1754
1790
|
if (event.source === 'extension') return { action: 'continue' };
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* N row summaries).
|
|
10
10
|
*
|
|
11
11
|
* Controls: same as progressWindow — ESC twice to cancel (aborts every in-flight run), `m` to
|
|
12
|
-
* minimize.
|
|
12
|
+
* minimize. Once every row is terminal, a single Esc or `m` instead dismisses immediately (see
|
|
13
|
+
* `isFanoutComplete`) rather than arming/minimizing, since there's nothing left to cancel.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
@@ -32,6 +33,9 @@ export interface RunRow {
|
|
|
32
33
|
* still says *why* rather than going blank at the moment that matters most.
|
|
33
34
|
*/
|
|
34
35
|
activity: string;
|
|
36
|
+
/** Cost reported once the run completes successfully; null while queued/running/failed, or when
|
|
37
|
+
* the harness didn't report one. Feeds the chip's aggregate spend figure. */
|
|
38
|
+
costUsd: number | null;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface MultiProgressWindowOptions {
|
|
@@ -47,6 +51,19 @@ export interface MultiProgressWindowOptions {
|
|
|
47
51
|
onCancel: () => void;
|
|
48
52
|
/** Called when the user presses `m` (minimize — runs continue in the background). */
|
|
49
53
|
onMinimize: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* Called when the user presses Esc or `m` while every row is already terminal — i.e. during the
|
|
56
|
+
* post-completion linger, before the caller tears the overlay down on its own timer. Lets a user
|
|
57
|
+
* who's still watching dismiss the finished board immediately instead of waiting it out. Optional
|
|
58
|
+
* so existing callers/tests that don't care about the linger keep working.
|
|
59
|
+
*/
|
|
60
|
+
onDismiss?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True once every row has reached a terminal state — the fan-out is fully done. Pure — testable
|
|
64
|
+
* without a TUI. Used to gate the post-completion dismiss-on-any-key behavior. */
|
|
65
|
+
export function isFanoutComplete(rows: RunRow[]): boolean {
|
|
66
|
+
return rows.length > 0 && rows.every(r => r.status === 'done' || r.status === 'failed');
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/** Per-status glyphs, matching the row markers in the overlay so the chip and the window read alike. */
|
|
@@ -58,17 +75,26 @@ const CHIP_GLYPHS: ReadonlyArray<readonly [RunStatus, string]> = [
|
|
|
58
75
|
];
|
|
59
76
|
|
|
60
77
|
/**
|
|
61
|
-
* Compact fan-out status-bar summary, e.g. `1✓ 1✗ 1▶ 1
|
|
62
|
-
* cases stay short (`4▶`, then `4✓`)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
78
|
+
* Compact fan-out status-bar summary, e.g. `1✓ 1✗ 1▶ 1… · ⏱ 0:42 · $0.123`. Zero status counts are
|
|
79
|
+
* omitted, so the common cases stay short (`4▶`, then `4✓`); the aggregate spend segment is
|
|
80
|
+
* likewise omitted until at least one run has actually reported a cost. Counting only `running` —
|
|
81
|
+
* as the first cut did — renders `0/4 running`, which reads as idle when runs have actually failed
|
|
82
|
+
* or are queued behind the cap. `elapsedMs` is passed in (rather than read via `Date.now()`
|
|
83
|
+
* internally) so this stays pure and testable without a TUI.
|
|
65
84
|
*/
|
|
66
|
-
export function formatFanoutChip(rows: RunRow[]): string {
|
|
85
|
+
export function formatFanoutChip(rows: RunRow[], elapsedMs: number): string {
|
|
67
86
|
const parts = CHIP_GLYPHS.map(([status, glyph]) => {
|
|
68
87
|
const n = rows.filter(r => r.status === status).length;
|
|
69
88
|
return n > 0 ? `${n}${glyph}` : null;
|
|
70
89
|
}).filter((s): s is string => s !== null);
|
|
71
|
-
|
|
90
|
+
const statusText = parts.length > 0 ? parts.join(' ') : `${rows.length}…`;
|
|
91
|
+
|
|
92
|
+
const costs = rows.map(r => r.costUsd).filter((c): c is number => typeof c === 'number');
|
|
93
|
+
const totalCost = costs.length > 0 ? costs.reduce((sum, c) => sum + c, 0) : null;
|
|
94
|
+
|
|
95
|
+
const segments = [statusText, `⏱ ${fmtElapsed(elapsedMs)}`];
|
|
96
|
+
if (totalCost !== null) segments.push(`$${totalCost.toFixed(3)}`);
|
|
97
|
+
return segments.join(' · ');
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
/** One row's marker + label, e.g. "✓ claude" / "✗ codex" / "⠋ opencode" / "… amp". Pure — testable
|
|
@@ -140,15 +166,27 @@ export function multiProgressWindow(
|
|
|
140
166
|
out.push(`│ ${padTo(truncateToWidth(line, inner), inner)} │`);
|
|
141
167
|
}
|
|
142
168
|
|
|
143
|
-
const hint =
|
|
144
|
-
? theme.fg('
|
|
145
|
-
:
|
|
169
|
+
const hint = isFanoutComplete(rows)
|
|
170
|
+
? theme.fg('dim', 'esc/m dismiss')
|
|
171
|
+
: armed
|
|
172
|
+
? theme.fg('warning', 'press esc again to cancel all') + theme.fg('dim', ' · m minimize')
|
|
173
|
+
: theme.fg('dim', 'esc cancel all') + theme.fg('dim', ' · m minimize');
|
|
146
174
|
out.push(`│ ${padTo(hint, inner)} │`);
|
|
147
175
|
|
|
148
176
|
out.push(theme.fg('accent', `╰${'─'.repeat(Math.max(1, width - 2))}╯`));
|
|
149
177
|
return out;
|
|
150
178
|
},
|
|
151
179
|
handleInput(data: string): void {
|
|
180
|
+
// Once every run has reached a terminal state, the overlay is just lingering on the
|
|
181
|
+
// finished board before the caller closes it on a timer — any Esc/m here dismisses it right
|
|
182
|
+
// away instead of making a user who's still watching wait out the linger.
|
|
183
|
+
if (isFanoutComplete(opts.getRows()) && opts.onDismiss) {
|
|
184
|
+
if (matchesKey(data, Key.escape) || data === 'm') {
|
|
185
|
+
disarm();
|
|
186
|
+
opts.onDismiss();
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
152
190
|
if (matchesKey(data, Key.escape)) {
|
|
153
191
|
if (armed) {
|
|
154
192
|
disarm();
|
package/extensions/progress.ts
CHANGED
|
@@ -46,6 +46,15 @@ export function fmtElapsed(ms: number): string {
|
|
|
46
46
|
return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
|
|
51
|
+
* show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
|
|
52
|
+
* Pure — testable without a TUI.
|
|
53
|
+
*/
|
|
54
|
+
export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
|
|
55
|
+
return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
/** Style one feed entry; the returned string may contain ANSI colors. */
|
|
50
59
|
export function renderEntry(entry: FeedEntry, theme: Theme): string {
|
|
51
60
|
switch (entry.kind) {
|
|
@@ -98,8 +107,13 @@ export function progressWindow(tui: TUI, theme: Theme, opts: ProgressWindowOptio
|
|
|
98
107
|
out.push(`│ ${padTo(banner, inner)} │`);
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
// feed
|
|
102
|
-
|
|
110
|
+
// feed — "+N earlier" marker when older entries were dropped, instead of truncating silently
|
|
111
|
+
const { visible, hiddenCount } = truncateFeed(opts.getEntries(), MAX_VISIBLE_ENTRIES);
|
|
112
|
+
if (hiddenCount > 0) {
|
|
113
|
+
const marker = theme.fg('dim', `+${hiddenCount} earlier`);
|
|
114
|
+
out.push(`│ ${padTo(truncateToWidth(marker, inner), inner)} │`);
|
|
115
|
+
}
|
|
116
|
+
for (const entry of visible) {
|
|
103
117
|
out.push(`│ ${padTo(truncateToWidth(renderEntry(entry, theme), inner), inner)} │`);
|
|
104
118
|
}
|
|
105
119
|
|
package/extensions/templates.ts
CHANGED
|
@@ -43,11 +43,11 @@ export function normalizePermission(
|
|
|
43
43
|
const lower = raw.trim().toLowerCase();
|
|
44
44
|
if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
|
|
45
45
|
return { permission: 'readonly', permissionMode: 'plan' };
|
|
46
|
-
if (lower === 'edit' || lower === '
|
|
46
|
+
if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
|
|
47
47
|
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
48
48
|
if (
|
|
49
49
|
lower === 'danger' ||
|
|
50
|
-
lower === '
|
|
50
|
+
lower === 'bypasspermissions' ||
|
|
51
51
|
lower === 'danger-full-access' ||
|
|
52
52
|
lower === 'danger_full_access'
|
|
53
53
|
)
|
|
@@ -184,3 +184,21 @@ export function loadTemplates(cwd: string, harnessName?: string): Map<string, De
|
|
|
184
184
|
export function loadAllTemplates(cwd: string): Map<string, DelegateTemplate> {
|
|
185
185
|
return loadTemplates(cwd);
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Which native permission string (if any) to hand the harness for this run.
|
|
190
|
+
*
|
|
191
|
+
* A template's native escape hatch (`permissionMode`/`sandbox`) applies only while the effective
|
|
192
|
+
* permission is still the template's own. Every harness's `buildArgs` prefers `nativePermission`
|
|
193
|
+
* over the normalized map, so passing it unconditionally would let a template's native mode
|
|
194
|
+
* silently override an explicit `allowDangerous` escalation — a per-call escalation would be
|
|
195
|
+
* quietly downgraded back to whatever the template declared.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveNativePermission(
|
|
198
|
+
templatePermission: NormalizedPermission,
|
|
199
|
+
effectivePermission: NormalizedPermission,
|
|
200
|
+
nativePermission: string | undefined,
|
|
201
|
+
): string | undefined {
|
|
202
|
+
if (!nativePermission) return undefined;
|
|
203
|
+
return effectivePermission === templatePermission ? nativePermission : undefined;
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: docs
|
|
3
|
+
description: Generate or update documentation. Writes files.
|
|
4
|
+
permission: edit
|
|
5
|
+
---
|
|
6
|
+
You are a technical writer delegated by the pi coding agent.
|
|
7
|
+
|
|
8
|
+
Write clear, accurate documentation matching the project's existing doc style
|
|
9
|
+
(check for README/ADRs/docs conventions first). Cover usage, gotchas, and
|
|
10
|
+
worked examples. Report which files you created or changed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: general
|
|
3
|
+
description: General delegation — any task. Bounded to file edits unless allowDangerous is set.
|
|
4
|
+
permission: edit
|
|
5
|
+
---
|
|
6
|
+
You are a capable engineer delegated by the pi coding agent to handle the
|
|
7
|
+
following task. Work in the current repository. Follow its conventions, run
|
|
8
|
+
available checks, and report what you did and why.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: implement
|
|
3
|
+
description: Implement a task with file edits (auto-accepted). Runs checks.
|
|
4
|
+
permission: edit
|
|
5
|
+
---
|
|
6
|
+
You are a senior engineer delegated by the pi coding agent to implement a
|
|
7
|
+
task.
|
|
8
|
+
|
|
9
|
+
Implement the task described below. Follow the repo's existing conventions.
|
|
10
|
+
Run the relevant checks when present (tests, typecheck, lint) and fix what
|
|
11
|
+
breaks. Keep changes minimal and focused on the task. Report what you changed
|
|
12
|
+
and the verification you ran.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan
|
|
3
|
+
description: Produce a detailed implementation plan from an intent. Read-only.
|
|
4
|
+
permission: readonly
|
|
5
|
+
---
|
|
6
|
+
You are a staff engineer delegated by the pi coding agent to produce a
|
|
7
|
+
detailed implementation plan.
|
|
8
|
+
|
|
9
|
+
Understand the current codebase first (read the relevant files). Then produce:
|
|
10
|
+
1. Goal and non-goals
|
|
11
|
+
2. Proposed approach, with alternatives considered and why rejected
|
|
12
|
+
3. Step-by-step implementation plan: ordered steps, each naming the files to
|
|
13
|
+
touch and what changes in them
|
|
14
|
+
4. Risks, edge cases, and testing strategy
|
|
15
|
+
|
|
16
|
+
Be concrete and reference actual files/functions in the repo. Do not edit
|
|
17
|
+
files — this is a plan.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review
|
|
3
|
+
description: Code review of a scope (git diff, files, or the whole repo). Read-only.
|
|
4
|
+
permission: readonly
|
|
5
|
+
defaultTask: Review the current git diff (staged + unstaged)
|
|
6
|
+
defaultScope: diff
|
|
7
|
+
---
|
|
8
|
+
You are a senior code reviewer delegated by the pi coding agent.
|
|
9
|
+
|
|
10
|
+
Review the provided scope for:
|
|
11
|
+
- Correctness bugs and edge cases
|
|
12
|
+
- Security issues (injection, auth, secrets, unsafe deserialization)
|
|
13
|
+
- Performance problems
|
|
14
|
+
- Code style and maintainability
|
|
15
|
+
|
|
16
|
+
Be specific: cite `file:line` for every finding. Classify each finding as
|
|
17
|
+
Critical / Major / Minor / Nit. End with a prioritized list of the top
|
|
18
|
+
actions. Do not edit files — this is a review.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-audit
|
|
3
|
+
description: Security audit of a scope. Read-only.
|
|
4
|
+
permission: readonly
|
|
5
|
+
defaultTask: Audit this repository for security vulnerabilities
|
|
6
|
+
---
|
|
7
|
+
You are a security auditor delegated by the pi coding agent.
|
|
8
|
+
|
|
9
|
+
Audit the provided scope for:
|
|
10
|
+
- Injection (SQL, command, path, template)
|
|
11
|
+
- Authentication / authorization gaps
|
|
12
|
+
- Secrets and credential handling
|
|
13
|
+
- Insecure defaults, unsafe deserialization, XXE, SSRF
|
|
14
|
+
- Dependency and supply-chain risks
|
|
15
|
+
|
|
16
|
+
Cite `file:line` for every finding. Rank findings by severity and
|
|
17
|
+
exploitability. Do not edit files — this is an audit.
|