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/extensions/config.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { DEFAULT_TIMEOUT_MS, type Harness, type Transport } from './harnesses/types.ts';
|
|
5
5
|
|
|
6
6
|
export interface HarnessConfig {
|
|
7
7
|
model?: string;
|
|
8
8
|
timeoutMs?: number;
|
|
9
9
|
allowDangerous?: boolean;
|
|
10
10
|
maxBudgetUsd?: number;
|
|
11
|
+
/** Overrides the harness's default transport ('stdout' unless the harness itself defaults to
|
|
12
|
+
* 'acp', e.g. Devin). A malformed value (not 'stdout'/'acp') is dropped at load time, same as
|
|
13
|
+
* every other per-harness field's defensive parsing below; a well-formed but unsupported value
|
|
14
|
+
* (e.g. 'acp' for a harness with no ACP surface) is *not* dropped here — it's validated against
|
|
15
|
+
* `Harness.supportsTransports` by `resolveTransport()` instead, so misconfiguring it fails the
|
|
16
|
+
* run with a clear message rather than being silently ignored. */
|
|
17
|
+
transport?: Transport;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export interface DelegateConfig {
|
|
@@ -25,6 +32,36 @@ export interface DelegateConfig {
|
|
|
25
32
|
harnesses: Record<string, HarnessConfig>;
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Provenance for how `loadConfig()` actually resolved its settings — the bit a bare `try/catch {}`
|
|
37
|
+
* used to erase entirely, making "no file", "file, no relevant key", and "file, unparseable" all
|
|
38
|
+
* look identical (empty defaults, no signal anywhere). `usedKey` is which key ended up populating
|
|
39
|
+
* `cfg`: `'delegate'` and `'claudeDelegate'` are mutually exclusive (the legacy branch only runs
|
|
40
|
+
* when `delegate` is *absent*), so `'claudeDelegate'` here means the legacy-only case that costs
|
|
41
|
+
* the user every setting under `delegate` — not "legacy key present at all" (see
|
|
42
|
+
* `legacyKeyPresent` for that). `raw` is the literal value of whichever key was used, exactly as
|
|
43
|
+
* read from the file — no defaults merged in, no per-field sanitizing — so `/delegate config` can
|
|
44
|
+
* show what was actually written next to what it resolved to.
|
|
45
|
+
*/
|
|
46
|
+
export interface ConfigSource {
|
|
47
|
+
file: string;
|
|
48
|
+
fileExists: boolean;
|
|
49
|
+
/** Set when the file exists but `JSON.parse` failed, or the parsed value isn't a JSON object
|
|
50
|
+
* (e.g. an array or a bare string) — either way `cfg` fell back to defaults. */
|
|
51
|
+
parseError?: string;
|
|
52
|
+
usedKey: 'delegate' | 'claudeDelegate' | 'none';
|
|
53
|
+
/** Whether a `claudeDelegate` key exists in the file at all, independent of `usedKey` — true
|
|
54
|
+
* even when `delegate` won and `claudeDelegate` was only partially merged (the `model`
|
|
55
|
+
* fallback below). */
|
|
56
|
+
legacyKeyPresent: boolean;
|
|
57
|
+
raw?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ConfigLoadResult {
|
|
61
|
+
config: DelegateConfig;
|
|
62
|
+
source: ConfigSource;
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
export function agentDir(): string {
|
|
29
66
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
30
67
|
}
|
|
@@ -38,7 +75,24 @@ export function legacyOutputsDir(): string {
|
|
|
38
75
|
return join(agentDir(), 'claude-delegate', 'outputs');
|
|
39
76
|
}
|
|
40
77
|
|
|
41
|
-
|
|
78
|
+
/** Drops a malformed `transport` (anything but 'stdout'/'acp', including absent) before it's
|
|
79
|
+
* stored — the rest of `HarnessConfig`'s fields are spread through as-is by `loadConfig()`. */
|
|
80
|
+
function sanitizeHarnessConfig(v: HarnessConfig): HarnessConfig {
|
|
81
|
+
const out = { ...v };
|
|
82
|
+
if (out.transport !== 'stdout' && out.transport !== 'acp') delete out.transport;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Loads `delegate` config from `~/.pi/agent/settings.json` alongside `ConfigSource`, describing
|
|
88
|
+
* how that happened (file present? which key won? did it fail to parse?) — see `ConfigSource`'s
|
|
89
|
+
* doc comment. `loadConfig()` below is a thin wrapper for the existing call sites that only want
|
|
90
|
+
* the resolved values; this is the one place that actually reads/parses the file, so a caller
|
|
91
|
+
* needing both never pays for a second parse. Never throws — any failure (missing file, bad JSON,
|
|
92
|
+
* a non-object root) is recorded on `source` and falls back to the same defaults `loadConfig()`
|
|
93
|
+
* has always returned.
|
|
94
|
+
*/
|
|
95
|
+
export function loadConfigWithSource(): ConfigLoadResult {
|
|
42
96
|
const cfg: DelegateConfig = {
|
|
43
97
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
44
98
|
defaultMode: 'general',
|
|
@@ -51,15 +105,31 @@ export function loadConfig(): DelegateConfig {
|
|
|
51
105
|
maxTranscripts: 100,
|
|
52
106
|
harnesses: {},
|
|
53
107
|
};
|
|
108
|
+
const file = join(agentDir(), 'settings.json');
|
|
109
|
+
const source: ConfigSource = { file, fileExists: false, usedKey: 'none', legacyKeyPresent: false };
|
|
54
110
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
111
|
+
if (!existsSync(file)) return { config: cfg, source };
|
|
112
|
+
source.fileExists = true;
|
|
113
|
+
let parsed: unknown;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
source.parseError = err instanceof Error ? err.message : String(err);
|
|
118
|
+
return { config: cfg, source };
|
|
119
|
+
}
|
|
120
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
121
|
+
source.parseError = 'settings.json root is not a JSON object';
|
|
122
|
+
return { config: cfg, source };
|
|
123
|
+
}
|
|
124
|
+
const settings = parsed as {
|
|
58
125
|
delegate?: Partial<DelegateConfig & { harnesses: Record<string, HarnessConfig> }>;
|
|
59
126
|
claudeDelegate?: Partial<DelegateConfig & { model?: string }>;
|
|
60
127
|
};
|
|
128
|
+
source.legacyKeyPresent = Boolean(settings.claudeDelegate);
|
|
61
129
|
// Legacy claudeDelegate -> delegate.harnesses.claude migration
|
|
62
130
|
if (settings.claudeDelegate && !settings.delegate) {
|
|
131
|
+
source.usedKey = 'claudeDelegate';
|
|
132
|
+
source.raw = settings.claudeDelegate;
|
|
63
133
|
const c = settings.claudeDelegate as Partial<DelegateConfig>;
|
|
64
134
|
if (typeof c.model === 'string') cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
|
|
65
135
|
if (typeof c.timeoutMs === 'number' && c.timeoutMs > 0) cfg.timeoutMs = c.timeoutMs;
|
|
@@ -92,12 +162,15 @@ export function loadConfig(): DelegateConfig {
|
|
|
92
162
|
if (typeof c.maxTranscripts === 'number' && c.maxTranscripts >= 0) cfg.maxTranscripts = c.maxTranscripts;
|
|
93
163
|
if (c.harnesses && typeof c.harnesses === 'object') {
|
|
94
164
|
for (const [k, v] of Object.entries(c.harnesses)) {
|
|
95
|
-
if (v && typeof v === 'object')
|
|
165
|
+
if (v && typeof v === 'object')
|
|
166
|
+
cfg.harnesses[k] = { ...(cfg.harnesses[k] ?? {}), ...sanitizeHarnessConfig(v as HarnessConfig) };
|
|
96
167
|
}
|
|
97
168
|
}
|
|
98
169
|
// also map harnesses.claude if any
|
|
99
|
-
return cfg;
|
|
170
|
+
return { config: cfg, source };
|
|
100
171
|
}
|
|
172
|
+
source.usedKey = settings.delegate ? 'delegate' : 'none';
|
|
173
|
+
source.raw = settings.delegate;
|
|
101
174
|
const d = settings.delegate ?? {};
|
|
102
175
|
if (typeof d.defaultHarness === 'string' && d.defaultHarness) cfg.defaultHarness = d.defaultHarness;
|
|
103
176
|
if (typeof d.defaultMode === 'string') cfg.defaultMode = d.defaultMode;
|
|
@@ -129,7 +202,7 @@ export function loadConfig(): DelegateConfig {
|
|
|
129
202
|
if (typeof d.maxTranscripts === 'number' && d.maxTranscripts >= 0) cfg.maxTranscripts = d.maxTranscripts;
|
|
130
203
|
if (d.harnesses && typeof d.harnesses === 'object') {
|
|
131
204
|
for (const [k, v] of Object.entries(d.harnesses)) {
|
|
132
|
-
if (v && typeof v === 'object') cfg.harnesses[k] =
|
|
205
|
+
if (v && typeof v === 'object') cfg.harnesses[k] = sanitizeHarnessConfig(v as HarnessConfig);
|
|
133
206
|
}
|
|
134
207
|
}
|
|
135
208
|
// also support legacy claudeDelegate merged when delegate also present (delegate wins)
|
|
@@ -139,10 +212,121 @@ export function loadConfig(): DelegateConfig {
|
|
|
139
212
|
cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
|
|
140
213
|
}
|
|
141
214
|
}
|
|
142
|
-
} catch {
|
|
143
|
-
//
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// Anything unexpected (e.g. a read error after existsSync's check raced a delete) — still
|
|
217
|
+
// never throw; record it as a parse error so it's not silently indistinguishable from "no
|
|
218
|
+
// config" if it wasn't already caught (and thus reported) above.
|
|
219
|
+
if (!source.parseError) source.parseError = err instanceof Error ? err.message : String(err);
|
|
220
|
+
}
|
|
221
|
+
return { config: cfg, source };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function loadConfig(): DelegateConfig {
|
|
225
|
+
return loadConfigWithSource().config;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Human-readable lines describing how `loadConfig()` actually resolved its settings — the
|
|
230
|
+
* provenance report used by both `/delegate status` and `/delegate config`. Pure: takes the
|
|
231
|
+
* `ConfigSource` companion to `loadConfigWithSource()`'s result, no I/O, so it's testable without
|
|
232
|
+
* touching the filesystem.
|
|
233
|
+
*/
|
|
234
|
+
export function describeConfigSource(source: ConfigSource): string[] {
|
|
235
|
+
if (source.parseError) {
|
|
236
|
+
return [
|
|
237
|
+
`⚠ ${source.file} exists but failed to parse: ${source.parseError}`,
|
|
238
|
+
' using defaults until this is fixed',
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
if (!source.fileExists) {
|
|
242
|
+
return [`${source.file} not found — using defaults`];
|
|
243
|
+
}
|
|
244
|
+
if (source.usedKey === 'none') {
|
|
245
|
+
return [`${source.file} has no "delegate" key — using defaults`];
|
|
246
|
+
}
|
|
247
|
+
if (source.usedKey === 'claudeDelegate') {
|
|
248
|
+
return [
|
|
249
|
+
`⚠ using legacy "claudeDelegate" key in ${source.file}`,
|
|
250
|
+
' two settings can never be reached this way: "defaultHarness" stays pinned to "claude", and there\'s no',
|
|
251
|
+
' top-level default "model" (only claudeDelegate.model -> harnesses.claude.model migrates) — everything',
|
|
252
|
+
' else (including per-harness settings like harnesses.<name>.transport) migrates fine',
|
|
253
|
+
' rename "claudeDelegate" to "delegate" to unlock those two, or run `/delegate config init` to write an',
|
|
254
|
+
' explicit "delegate" key for you (claudeDelegate itself is left untouched either way)',
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
const lines = [`"delegate" key in ${source.file}`];
|
|
258
|
+
if (source.legacyKeyPresent) {
|
|
259
|
+
lines.push(' legacy "claudeDelegate" key is also present — ignored except claudeDelegate.model as a fallback');
|
|
260
|
+
}
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Full `/delegate config` report: provenance (`describeConfigSource`), the raw `delegate`/
|
|
266
|
+
* `claudeDelegate` value exactly as written in the file, and the effective config with defaults
|
|
267
|
+
* merged in — so a user can see both what they wrote and what it resolved to, and has a
|
|
268
|
+
* paste-ready starting point either way. Pure — takes an already-loaded `ConfigLoadResult`.
|
|
269
|
+
*/
|
|
270
|
+
export function buildConfigReport(result: ConfigLoadResult): string[] {
|
|
271
|
+
const lines = [...describeConfigSource(result.source)];
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push('from file (as written, before defaults are applied):');
|
|
274
|
+
lines.push(JSON.stringify(result.source.raw ?? {}, null, 2));
|
|
275
|
+
lines.push('');
|
|
276
|
+
lines.push('effective config (file merged with defaults) — paste under "delegate" in settings.json,');
|
|
277
|
+
lines.push('or run `/delegate config init` to write it there directly:');
|
|
278
|
+
lines.push(JSON.stringify({ delegate: result.config }, null, 2));
|
|
279
|
+
return lines;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface WriteConfigResult {
|
|
283
|
+
ok: boolean;
|
|
284
|
+
file: string;
|
|
285
|
+
message: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Writes `delegateSubtree` into `settings.json` under the `delegate` key — replacing only that
|
|
290
|
+
* key and preserving every other top-level key verbatim (including a leftover `claudeDelegate`,
|
|
291
|
+
* which is never touched or removed here; that's the user's call, made by editing the file
|
|
292
|
+
* themselves). Read-modify-write, atomic: writes to `<file>.<pid>.tmp` in the same directory then
|
|
293
|
+
* `renameSync`s over the target, so a process death mid-write can never leave a torn file. Refuses
|
|
294
|
+
* to write (returns `ok: false`, file untouched) when the existing file is present but fails to
|
|
295
|
+
* parse or isn't a JSON object — overwriting an already-broken file would destroy whatever the
|
|
296
|
+
* user has in it; the caller should fall back to `buildConfigReport`'s paste-ready block instead.
|
|
297
|
+
* Never throws. Only ever called from an explicit user action (`/delegate config init`) — never
|
|
298
|
+
* on the strength of a read like `loadConfig()`/`showStatus`.
|
|
299
|
+
*/
|
|
300
|
+
export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult {
|
|
301
|
+
const file = join(agentDir(), 'settings.json');
|
|
302
|
+
try {
|
|
303
|
+
let root: Record<string, unknown> = {};
|
|
304
|
+
if (existsSync(file)) {
|
|
305
|
+
let parsed: unknown;
|
|
306
|
+
try {
|
|
307
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
file,
|
|
312
|
+
message: `refusing to write: ${file} exists but failed to parse (${err instanceof Error ? err.message : String(err)}) — fix or remove it first`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
316
|
+
return { ok: false, file, message: `refusing to write: ${file} exists but its root isn't a JSON object` };
|
|
317
|
+
}
|
|
318
|
+
root = parsed as Record<string, unknown>;
|
|
319
|
+
}
|
|
320
|
+
root.delegate = delegateSubtree;
|
|
321
|
+
const dir = dirname(file);
|
|
322
|
+
mkdirSync(dir, { recursive: true });
|
|
323
|
+
const tmp = join(dir, `settings.json.${process.pid}.tmp`);
|
|
324
|
+
writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, 'utf8');
|
|
325
|
+
renameSync(tmp, file);
|
|
326
|
+
return { ok: true, file, message: `wrote "delegate" key to ${file}` };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return { ok: false, file, message: `failed to write ${file}: ${err instanceof Error ? err.message : String(err)}` };
|
|
144
329
|
}
|
|
145
|
-
return cfg;
|
|
146
330
|
}
|
|
147
331
|
|
|
148
332
|
export function resolveModelForHarness(
|
|
@@ -155,6 +339,25 @@ export function resolveModelForHarness(
|
|
|
155
339
|
return resolve(model) ?? resolve(templateModel) ?? resolve(cfg.harnesses[harness]?.model) ?? resolve(cfg.model);
|
|
156
340
|
}
|
|
157
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Which transport a run should actually use: config override, falling back to the harness's own
|
|
344
|
+
* default (`harness.transport`, e.g. Devin's static 'acp'), falling back to 'stdout'. Validated
|
|
345
|
+
* against `harness.supportsTransports` — the ceiling of what the binary can actually do, distinct
|
|
346
|
+
* from what's configured — *before* the caller acquires a run slot or spawns anything, so
|
|
347
|
+
* misconfiguring e.g. `transport: 'acp'` for `claude` fails fast with a clear message instead of a
|
|
348
|
+
* cryptic "unknown subcommand" from the spawned process. See docs/acp-harness-assessment.md §5/§6.
|
|
349
|
+
*/
|
|
350
|
+
export function resolveTransport(cfg: DelegateConfig, harnessName: string, harness: Harness): Transport {
|
|
351
|
+
const transport = cfg.harnesses[harnessName]?.transport ?? harness.transport ?? 'stdout';
|
|
352
|
+
const allowed = harness.supportsTransports ?? [harness.transport ?? 'stdout'];
|
|
353
|
+
if (!allowed.includes(transport)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`delegate.harnesses.${harnessName}.transport is "${transport}", but ${harnessName} only supports: ${allowed.join(', ')}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return transport;
|
|
359
|
+
}
|
|
360
|
+
|
|
158
361
|
export function getMaxConcurrent(cfg: DelegateConfig, harness?: string): number {
|
|
159
362
|
if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
|
|
160
363
|
// if object shape {global, perHarness}
|
|
@@ -159,14 +159,22 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
|
|
|
159
159
|
? (o.messages[o.messages.length - 1] as Record<string, unknown>)
|
|
160
160
|
: null;
|
|
161
161
|
const measured = (hs.turnCount ?? 0) > 0;
|
|
162
|
+
// real error shape: message.stopReason === 'error' + message.errorMessage — observed live on a
|
|
163
|
+
// 429 quota rejection, where message.content is an empty array, so the text-block extraction
|
|
164
|
+
// above never sets streamedText and this would otherwise silently report success with empty
|
|
165
|
+
// text (tests/fixtures/amp-error.jsonl).
|
|
166
|
+
const errorMessage = typeof msg?.errorMessage === 'string' ? (msg.errorMessage as string) : undefined;
|
|
167
|
+
const isErrorTurn = msg?.stopReason === 'error' || errorMessage !== undefined;
|
|
162
168
|
const result: StreamedResult = {
|
|
163
169
|
result:
|
|
164
170
|
typeof o.result === 'string'
|
|
165
171
|
? o.result
|
|
166
|
-
:
|
|
167
|
-
?
|
|
168
|
-
:
|
|
169
|
-
|
|
172
|
+
: errorMessage
|
|
173
|
+
? errorMessage
|
|
174
|
+
: streamedText
|
|
175
|
+
? state.streamedText + streamedText
|
|
176
|
+
: state.streamedText || (text ?? ''),
|
|
177
|
+
isError: o.is_error === true || isErrorTurn,
|
|
170
178
|
numTurns: measured ? (hs.turnCount as number) : null,
|
|
171
179
|
totalCostUsd: measured ? (hs.costAccum as number) : null,
|
|
172
180
|
sessionId:
|
|
@@ -228,6 +236,9 @@ export const ampHarness: Harness = {
|
|
|
228
236
|
const args = ['-p', '--mode', 'json', '--approval-mode', approvalMode];
|
|
229
237
|
if (opts.model) args.push('--model', opts.model);
|
|
230
238
|
if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
|
|
239
|
+
// `--add-dir=<value>` is real and repeatable per `omp --help` — confirmed live: a run with
|
|
240
|
+
// --add-dir echoed the directory back in the session line's `additionalDirectories`. (Earlier
|
|
241
|
+
// research had flagged this as possibly absent; that was wrong for omp 17.2.9.)
|
|
231
242
|
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
232
243
|
args.push(opts.prompt);
|
|
233
244
|
return args;
|
|
@@ -259,4 +270,13 @@ export const ampHarness: Harness = {
|
|
|
259
270
|
return null;
|
|
260
271
|
},
|
|
261
272
|
permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
|
|
273
|
+
// `omp acp` is real and live-verified (docs/acp-harness-assessment.md §2/§4) — but deliberately
|
|
274
|
+
// NOT offered as a config value yet: its ACP mode surface only has 2 tiers (`default`/`plan`),
|
|
275
|
+
// while the stdout `--approval-mode` above has 3 genuine ones. Adding 'acp' here would let a
|
|
276
|
+
// user configure `edit`, expecting "ask before every write", and silently collapse it onto the
|
|
277
|
+
// same `default` mode as `danger` — a real permission-tier regression, not a cosmetic one. Only
|
|
278
|
+
// revisit if a future omp ACP version exposes a third tier (e.g. an `approval-mode`-shaped
|
|
279
|
+
// `configOptions` category, the same slot `thinking` already occupies today) — redo the §4-style
|
|
280
|
+
// live permission-tier analysis before changing this, don't just add 'acp' to the list.
|
|
281
|
+
supportsTransports: ['stdout'],
|
|
262
282
|
};
|
|
@@ -156,4 +156,7 @@ export const claudeHarness: Harness = {
|
|
|
156
156
|
edit: ['acceptEdits'],
|
|
157
157
|
danger: ['bypassPermissions'],
|
|
158
158
|
},
|
|
159
|
+
// No `acp` subcommand exists (docs/acp-harness-assessment.md §2) — confirmed against the full
|
|
160
|
+
// `claude --help` output, not just an earlier probe.
|
|
161
|
+
supportsTransports: ['stdout'],
|
|
159
162
|
};
|
|
@@ -14,6 +14,11 @@ import type {
|
|
|
14
14
|
// non-interactive; sandbox alone governs what's allowed) and resume is a subcommand
|
|
15
15
|
// (`exec resume <id> <prompt>`), not a `--thread-id` flag — both confirmed via `codex exec
|
|
16
16
|
// --help` / `codex exec resume --help`, not just the JSONL capture.
|
|
17
|
+
//
|
|
18
|
+
// Resume itself live-verified end-to-end against codex-cli 0.150.1 — see
|
|
19
|
+
// tests/fixtures/codex-resume.jsonl: a first `codex exec` turn was taught a fact, then a second,
|
|
20
|
+
// independent process ran `codex exec resume <id> <prompt>` and correctly recalled it, proving
|
|
21
|
+
// resume genuinely restores prior context rather than just replaying/echoing the session id.
|
|
17
22
|
|
|
18
23
|
const execFileAsync = promisify(execFile);
|
|
19
24
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
@@ -245,7 +250,10 @@ export const codexHarness: Harness = {
|
|
|
245
250
|
? ['exec', 'resume', opts.resumeSessionId, opts.prompt, '--json']
|
|
246
251
|
: ['exec', '--json', opts.prompt, '--sandbox', sandbox];
|
|
247
252
|
if (opts.model) args.push('--model', opts.model);
|
|
248
|
-
|
|
253
|
+
// `codex exec resume --help` has no `--sandbox`/`--add-dir` at all (confirmed live: passing
|
|
254
|
+
// --add-dir to resume is a hard CLI error, "unexpected argument '--add-dir' found") — only the
|
|
255
|
+
// fresh-turn branch above supports either flag.
|
|
256
|
+
if (!opts.resumeSessionId) for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
249
257
|
return args;
|
|
250
258
|
},
|
|
251
259
|
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
@@ -275,4 +283,8 @@ export const codexHarness: Harness = {
|
|
|
275
283
|
return null;
|
|
276
284
|
},
|
|
277
285
|
permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
|
|
286
|
+
// No `acp` subcommand exists; `app-server` is a different, codex-proprietary JSON-RPC protocol,
|
|
287
|
+
// not ACP (docs/acp-harness-assessment.md §2) — confirmed against the full `--help` output of
|
|
288
|
+
// `codex`, `codex mcp-server`, `codex app-server`, and `codex exec`.
|
|
289
|
+
supportsTransports: ['stdout'],
|
|
278
290
|
};
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
// ACP-only — no stdout mode exists to select between, so there's nothing to configure.
|
|
207
|
+
supportsTransports: ['acp'],
|
|
208
|
+
};
|