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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import type {
|
|
4
|
+
ActivityEvent,
|
|
4
5
|
BuildArgsOpts,
|
|
5
6
|
Harness,
|
|
6
7
|
NormalizedPermission,
|
|
@@ -13,6 +14,15 @@ import type {
|
|
|
13
14
|
// `opencode run` in this version has no `--permission` or `--add-dir` flag at all (confirmed via
|
|
14
15
|
// `opencode run --help`) — permission tiers map onto built-in agents instead (`opencode agent
|
|
15
16
|
// list`: "plan" is the read-only-oriented primary agent, "build" is the full-access one).
|
|
17
|
+
//
|
|
18
|
+
// This harness also supports the ACP transport (`opencode acp`) — see the `parseOpencodeAcpLine`/
|
|
19
|
+
// `buildAcpArgs`/`ACP_MODE_MAP` block below and tests/fixtures/opencode-acp*.jsonl. Live-verified
|
|
20
|
+
// (docs/acp-harness-assessment.md §2/§4, closed by a second live run — see
|
|
21
|
+
// tests/fixtures/opencode-acp-build-write.jsonl): a real write prompt under `build` mode produced
|
|
22
|
+
// a `tool_call`/`tool_call_update` pair with `kind: "edit"` and no `session/request_permission`
|
|
23
|
+
// request at all — this project's ACP client auto-declines that request when it arrives (see
|
|
24
|
+
// acp-runner.ts's `handleServerRequest`), so its absence here means `edit`/`danger` genuinely
|
|
25
|
+
// execute over ACP rather than silently no-op.
|
|
16
26
|
|
|
17
27
|
const execFileAsync = promisify(execFile);
|
|
18
28
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
@@ -23,6 +33,14 @@ const AGENT_MAP: Record<NormalizedPermission, string> = {
|
|
|
23
33
|
edit: 'build',
|
|
24
34
|
danger: 'build',
|
|
25
35
|
};
|
|
36
|
+
// A distinct vocabulary from AGENT_MAP even though the values happen to coincide today (`plan`/
|
|
37
|
+
// `build` are both the CLI agent name and the ACP `session/set_mode` modeId) — kept separate on
|
|
38
|
+
// purpose, per docs/acp-harness-assessment.md §6, so the two never silently drift together.
|
|
39
|
+
const ACP_MODE_MAP: Record<NormalizedPermission, string> = {
|
|
40
|
+
readonly: 'plan',
|
|
41
|
+
edit: 'build',
|
|
42
|
+
danger: 'build',
|
|
43
|
+
};
|
|
26
44
|
function extractOpencodeText(o: Record<string, unknown>): string | undefined {
|
|
27
45
|
if (typeof o.text === 'string') return o.text;
|
|
28
46
|
if (typeof o.output === 'string') return o.output;
|
|
@@ -165,6 +183,136 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
|
|
|
165
183
|
}
|
|
166
184
|
return { activities, streamedText };
|
|
167
185
|
}
|
|
186
|
+
|
|
187
|
+
interface OpencodeAcpHarnessState {
|
|
188
|
+
sessionId?: string;
|
|
189
|
+
contextWindow?: number;
|
|
190
|
+
costUsd?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function acpHarnessState(state: ParseState): OpencodeAcpHarnessState {
|
|
194
|
+
const s = (state._harness ?? {}) as OpencodeAcpHarnessState;
|
|
195
|
+
state._harness = s as unknown as Record<string, unknown>;
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Translate one `session/update` notification's `params.update` payload into ParseOutcome deltas.
|
|
200
|
+
* Same wire dialect as devin.ts's `translateUpdate` (both are real ACP `session/update` payloads —
|
|
201
|
+
* see docs/acp-harness-assessment.md §2's evidence that opencode/omp share an ACP implementation),
|
|
202
|
+
* kept as an independent function rather than a shared import so devin.ts needs zero changes here. */
|
|
203
|
+
function translateOpencodeAcpUpdate(update: Record<string, unknown>, state: ParseState): ParseOutcome {
|
|
204
|
+
const activities: ActivityEvent[] = [];
|
|
205
|
+
let streamedText: string | undefined;
|
|
206
|
+
const content = isRecord(update.content) ? update.content : undefined;
|
|
207
|
+
const text = content?.type === 'text' && typeof content.text === 'string' ? content.text : undefined;
|
|
208
|
+
const hs = acpHarnessState(state);
|
|
209
|
+
|
|
210
|
+
switch (update.sessionUpdate) {
|
|
211
|
+
case 'agent_message_chunk':
|
|
212
|
+
if (text !== undefined) streamedText = text;
|
|
213
|
+
break;
|
|
214
|
+
case 'agent_thought_chunk':
|
|
215
|
+
if (text !== undefined) activities.push({ kind: 'thinking', chars: text.length });
|
|
216
|
+
break;
|
|
217
|
+
case 'tool_call': {
|
|
218
|
+
if (typeof update.toolCallId !== 'string') break;
|
|
219
|
+
// real fixture: `title` is the tool name ("read", "glob", "write"), matching the stdout
|
|
220
|
+
// parser's `part.tool` convention — fall back to `kind` (the coarser category) if absent.
|
|
221
|
+
const name =
|
|
222
|
+
typeof update.title === 'string' ? update.title : typeof update.kind === 'string' ? update.kind : 'tool';
|
|
223
|
+
activities.push({
|
|
224
|
+
kind: 'tool_input',
|
|
225
|
+
name,
|
|
226
|
+
input: isRecord(update.rawInput) ? update.rawInput : {},
|
|
227
|
+
id: update.toolCallId,
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'tool_call_update': {
|
|
232
|
+
// Only a terminal status produces a tool_result — same reasoning as devin.ts: a call fires
|
|
233
|
+
// multiple `in_progress` updates before its `completed`/`failed`, and ToolCallIndex.resolve()
|
|
234
|
+
// consumes the pending entry on first match.
|
|
235
|
+
if (typeof update.toolCallId !== 'string') break;
|
|
236
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
237
|
+
activities.push({ kind: 'tool_result', isError: update.status === 'failed', id: update.toolCallId });
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case 'usage_update':
|
|
242
|
+
// Both fields are genuinely real over ACP (unlike stdout's `null`s) — see
|
|
243
|
+
// docs/acp-harness-assessment.md §2 — and, per the same section, this is a running session
|
|
244
|
+
// total already, latched from the *last* usage_update seen, never summed across a series.
|
|
245
|
+
if (typeof update.size === 'number') hs.contextWindow = update.size;
|
|
246
|
+
if (isRecord(update.cost) && typeof update.cost.amount === 'number') hs.costUsd = update.cost.amount;
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
return { streamedText, activities };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function parseOpencodeAcpLine(line: string, state: ParseState): ParseOutcome {
|
|
255
|
+
let o: unknown;
|
|
256
|
+
try {
|
|
257
|
+
o = JSON.parse(line);
|
|
258
|
+
} catch {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
if (!isRecord(o)) return {};
|
|
262
|
+
|
|
263
|
+
if (o.method === 'session/update' && isRecord(o.params) && isRecord(o.params.update)) {
|
|
264
|
+
return translateOpencodeAcpUpdate(o.params.update, state);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (isRecord(o.result)) {
|
|
268
|
+
const r = o.result;
|
|
269
|
+
if (typeof r.stopReason === 'string') {
|
|
270
|
+
// session/prompt response — the turn is over, build the final result.
|
|
271
|
+
const u = isRecord(r.usage) ? r.usage : null;
|
|
272
|
+
const hs = acpHarnessState(state);
|
|
273
|
+
const result: StreamedResult = {
|
|
274
|
+
result: state.streamedText,
|
|
275
|
+
// opencode's ACP prompt response carries no error flag of its own — a genuine failure
|
|
276
|
+
// surfaces via acp-runner.ts's process-level fail() path, same as every other harness.
|
|
277
|
+
isError: false,
|
|
278
|
+
numTurns: null, // never observed populated over ACP (docs/acp-harness-assessment.md §2)
|
|
279
|
+
totalCostUsd: typeof hs.costUsd === 'number' ? hs.costUsd : null,
|
|
280
|
+
sessionId: typeof hs.sessionId === 'string' ? hs.sessionId : null,
|
|
281
|
+
stopReason: r.stopReason,
|
|
282
|
+
permissionDenials: [],
|
|
283
|
+
durationMs: null,
|
|
284
|
+
durationApiMs: null,
|
|
285
|
+
ttftMs: null,
|
|
286
|
+
// Only ever the *requested* configOptions value from session/new, never confirmed back
|
|
287
|
+
// the way Devin's agent_stopped event confirms what actually ran — null is the honest
|
|
288
|
+
// value, not a guess.
|
|
289
|
+
model: null,
|
|
290
|
+
contextWindow: typeof hs.contextWindow === 'number' ? hs.contextWindow : null,
|
|
291
|
+
maxOutputTokens: null,
|
|
292
|
+
usage: u
|
|
293
|
+
? {
|
|
294
|
+
// Unlike Devin's inputTokens (which includes cachedReadTokens as a subset and needs
|
|
295
|
+
// subtracting), opencode's inputTokens already EXCLUDES it — verified against the
|
|
296
|
+
// real capture: inputTokens + cachedReadTokens === totalTokens - outputTokens exactly
|
|
297
|
+
// in both tests/fixtures/opencode-acp.jsonl and opencode-acp-build-write.jsonl.
|
|
298
|
+
inputTokens: typeof u.inputTokens === 'number' ? u.inputTokens : 0,
|
|
299
|
+
outputTokens: typeof u.outputTokens === 'number' ? u.outputTokens : 0,
|
|
300
|
+
cacheCreationInputTokens: 0, // not reported over ACP
|
|
301
|
+
cacheReadInputTokens: typeof u.cachedReadTokens === 'number' ? u.cachedReadTokens : 0,
|
|
302
|
+
}
|
|
303
|
+
: null,
|
|
304
|
+
};
|
|
305
|
+
return { result };
|
|
306
|
+
}
|
|
307
|
+
if (typeof r.sessionId === 'string') {
|
|
308
|
+
// session/new response — stash the id; session/load's response has none of its own
|
|
309
|
+
// (acp-runner.ts stashes it into state._harness.sessionId itself on a resume).
|
|
310
|
+
acpHarnessState(state).sessionId = r.sessionId;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
|
|
168
316
|
export const opencodeHarness: Harness = {
|
|
169
317
|
name: 'opencode',
|
|
170
318
|
displayName: 'OpenCode',
|
|
@@ -212,4 +360,16 @@ export const opencodeHarness: Harness = {
|
|
|
212
360
|
return null;
|
|
213
361
|
},
|
|
214
362
|
permissionMap: { readonly: ['plan'], edit: ['build'], danger: ['build', '--auto'] },
|
|
363
|
+
// ACP path — opt-in only (transport defaults to 'stdout'; see config.ts's resolveTransport).
|
|
364
|
+
// `--model` isn't wired here: `opencode acp --help` has no such flag, unlike `devin acp
|
|
365
|
+
// --model`; the ACP handshake's own `configOptions` "model" category is the real mechanism
|
|
366
|
+
// (session/set_config_option, unverified live — see ROADMAP.md), out of scope for this change.
|
|
367
|
+
buildAcpArgs(): string[] {
|
|
368
|
+
return ['acp'];
|
|
369
|
+
},
|
|
370
|
+
parseAcpLine(line: string, state: ParseState): ParseOutcome {
|
|
371
|
+
return parseOpencodeAcpLine(line, state);
|
|
372
|
+
},
|
|
373
|
+
acpPermissionMap: { readonly: [ACP_MODE_MAP.readonly], edit: [ACP_MODE_MAP.edit], danger: [ACP_MODE_MAP.danger] },
|
|
374
|
+
supportsTransports: ['stdout', 'acp'],
|
|
215
375
|
};
|
|
@@ -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;
|
|
@@ -81,15 +86,34 @@ export interface Harness {
|
|
|
81
86
|
aliases?: string[];
|
|
82
87
|
/** Check if binary is available. */
|
|
83
88
|
detect(): Promise<DetectResult>;
|
|
84
|
-
/** Build CLI args (excluding binary). */
|
|
89
|
+
/** Build CLI args (excluding binary) for the 'stdout' transport. */
|
|
85
90
|
buildArgs(opts: BuildArgsOpts): string[];
|
|
86
|
-
/** Parse a single stdout line. State is mutated by runner; return deltas. */
|
|
91
|
+
/** Parse a single stdout-transport line. State is mutated by runner; return deltas. */
|
|
87
92
|
parseLine(line: string, state: ParseState): ParseOutcome;
|
|
88
93
|
/** Extract final result after process exit (state.result may already be set). */
|
|
89
94
|
extractResult(state: ParseState): StreamedResult | null;
|
|
90
|
-
/** Normalized -> native arg fragments. */
|
|
95
|
+
/** Normalized -> native CLI arg fragments, used by the 'stdout' transport's buildArgs/nativePermission. */
|
|
91
96
|
permissionMap?: Record<NormalizedPermission, string[]>;
|
|
92
97
|
permissionHint?: (permission: NormalizedPermission) => string[];
|
|
98
|
+
/** Which transports this harness's binary actually supports — the ceiling `config.harnesses.<name>.transport`
|
|
99
|
+
* is validated against (see config.ts's `resolveTransport`), independent of what a user configures.
|
|
100
|
+
* Omitted -> `[transport ?? 'stdout']` (today's single-transport harnesses). A harness legally offering
|
|
101
|
+
* both 'stdout' and 'acp' must also declare `buildAcpArgs`/`parseAcpLine`/`acpPermissionMap` below. */
|
|
102
|
+
supportsTransports?: Transport[];
|
|
103
|
+
/** Default transport when config doesn't override. Omitted -> 'stdout'. */
|
|
104
|
+
transport?: Transport;
|
|
105
|
+
/** Build args to spawn the ACP server (e.g. ['acp']), for a harness whose `supportsTransports`
|
|
106
|
+
* includes 'acp'. Devin (ACP-only) has no separate stdout buildArgs, so it reuses `buildArgs` for
|
|
107
|
+
* this and doesn't need to declare `buildAcpArgs` — acp-runner.ts's caller falls back to `buildArgs`
|
|
108
|
+
* when `buildAcpArgs` is absent (see acp-runner.ts's `acpView`). */
|
|
109
|
+
buildAcpArgs?(opts: BuildArgsOpts): string[];
|
|
110
|
+
/** Parse a single ACP JSON-RPC line (see acp-runner.ts). Falls back to `parseLine` when absent, same
|
|
111
|
+
* reasoning as `buildAcpArgs`. */
|
|
112
|
+
parseAcpLine?(line: string, state: ParseState): ParseOutcome;
|
|
113
|
+
/** Normalized -> ACP session mode id (`session/set_mode`'s `modeId`) — a distinct vocabulary from
|
|
114
|
+
* `permissionMap`'s CLI arg fragments even when a value happens to coincide (e.g. opencode's `build`
|
|
115
|
+
* is both). Falls back to `permissionMap` when absent, same reasoning as `buildAcpArgs`. */
|
|
116
|
+
acpPermissionMap?: Record<NormalizedPermission, string[]>;
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
export const DEFAULT_TIMEOUT_MS = 600_000;
|