pi-harness-delegate 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/extensions/acp-runner.ts +69 -9
- package/extensions/command.ts +21 -0
- package/extensions/concurrency.ts +16 -3
- package/extensions/config.ts +216 -13
- package/extensions/harnesses/amp.ts +24 -4
- package/extensions/harnesses/claude.ts +3 -0
- package/extensions/harnesses/codex.ts +13 -1
- package/extensions/harnesses/devin.ts +2 -0
- package/extensions/harnesses/opencode.ts +160 -0
- package/extensions/harnesses/types.ts +21 -6
- package/extensions/index.ts +159 -44
- package/extensions/run-registry.ts +50 -5
- package/extensions/templates.ts +14 -15
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
|
@@ -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
|
};
|
|
@@ -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
|
};
|
|
@@ -86,19 +86,34 @@ export interface Harness {
|
|
|
86
86
|
aliases?: string[];
|
|
87
87
|
/** Check if binary is available. */
|
|
88
88
|
detect(): Promise<DetectResult>;
|
|
89
|
-
/** Build CLI args (excluding binary). */
|
|
89
|
+
/** Build CLI args (excluding binary) for the 'stdout' transport. */
|
|
90
90
|
buildArgs(opts: BuildArgsOpts): string[];
|
|
91
|
-
/** 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. */
|
|
92
92
|
parseLine(line: string, state: ParseState): ParseOutcome;
|
|
93
93
|
/** Extract final result after process exit (state.result may already be set). */
|
|
94
94
|
extractResult(state: ParseState): StreamedResult | null;
|
|
95
|
-
/** Normalized -> native arg fragments. */
|
|
95
|
+
/** Normalized -> native CLI arg fragments, used by the 'stdout' transport's buildArgs/nativePermission. */
|
|
96
96
|
permissionMap?: Record<NormalizedPermission, string[]>;
|
|
97
97
|
permissionHint?: (permission: NormalizedPermission) => string[];
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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'. */
|
|
101
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[]>;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
export const DEFAULT_TIMEOUT_MS = 600_000;
|
package/extensions/index.ts
CHANGED
|
@@ -29,7 +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
|
+
import { acpView, runAcpHarness } from './acp-runner.ts';
|
|
33
33
|
import {
|
|
34
34
|
aggregateSpend,
|
|
35
35
|
buildFanoutReport,
|
|
@@ -50,14 +50,26 @@ import {
|
|
|
50
50
|
ToolCallIndex,
|
|
51
51
|
type VerifyResult,
|
|
52
52
|
} from './activity.ts';
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
isFanoutSpec,
|
|
55
|
+
parseDelegateCommand,
|
|
56
|
+
resolveDefaults,
|
|
57
|
+
resolveHarnessFilter,
|
|
58
|
+
resolveHarnessList,
|
|
59
|
+
} from './command.ts';
|
|
54
60
|
import { acquireSlot, activeCount } from './concurrency.ts';
|
|
55
61
|
import {
|
|
62
|
+
buildConfigReport,
|
|
56
63
|
type DelegateConfig,
|
|
64
|
+
describeConfigSource,
|
|
65
|
+
getMaxConcurrent,
|
|
57
66
|
outputsDir as getOutputsDir,
|
|
58
67
|
legacyOutputsDir,
|
|
59
68
|
loadConfig,
|
|
69
|
+
loadConfigWithSource,
|
|
60
70
|
resolveModelForHarness,
|
|
71
|
+
resolveTransport,
|
|
72
|
+
writeDelegateConfig,
|
|
61
73
|
} from './config.ts';
|
|
62
74
|
import {
|
|
63
75
|
ALIASES,
|
|
@@ -120,8 +132,9 @@ const FANOUT_LINGER_MS = 3000;
|
|
|
120
132
|
* callers must not let this flip a run's `isError`.
|
|
121
133
|
*
|
|
122
134
|
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
123
|
-
* (project-local templates are already
|
|
124
|
-
*
|
|
135
|
+
* (project-local templates are already gated by `isProjectTrusted(ctx)` — pi's own trust store,
|
|
136
|
+
* never anything inside the project itself) or a human typing `/delegate --verify=<cmd>` at the
|
|
137
|
+
* CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
125
138
|
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
126
139
|
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
127
140
|
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
@@ -169,6 +182,20 @@ function outputsDirFor(harness: string): string {
|
|
|
169
182
|
return getOutputsDir(harness);
|
|
170
183
|
}
|
|
171
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Whether pi's own trust store (`ctx.isProjectTrusted()`, backed by `~/.pi/agent/trust.json`,
|
|
187
|
+
* outside any project) considers `ctx.cwd` trusted. This is the sole source of truth for whether
|
|
188
|
+
* project-local delegate templates load — see the trust-tier comment on `loadTemplates`. Fails
|
|
189
|
+
* closed (untrusted) if the host is old enough not to expose the method, or if it throws.
|
|
190
|
+
*/
|
|
191
|
+
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
192
|
+
try {
|
|
193
|
+
return typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
function formatTemplateRow(t: DelegateTemplate): string {
|
|
173
200
|
const parts = [
|
|
174
201
|
t.name,
|
|
@@ -182,15 +209,16 @@ function formatTemplateRow(t: DelegateTemplate): string {
|
|
|
182
209
|
|
|
183
210
|
async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
184
211
|
const all = new Map<string, DelegateTemplate>();
|
|
212
|
+
const trusted = isProjectTrusted(ctx);
|
|
185
213
|
// collect from all harnesses if no filter
|
|
186
214
|
if (harnessFilter) {
|
|
187
|
-
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter)) all.set(k, v);
|
|
215
|
+
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
|
|
188
216
|
} else {
|
|
189
217
|
for (const h of [...HARNESS_NAMES, 'shared']) {
|
|
190
|
-
for (const [k, v] of loadTemplates(ctx.cwd, h)) if (!all.has(k)) all.set(k, v);
|
|
218
|
+
for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
|
|
191
219
|
}
|
|
192
220
|
// also load without harness param
|
|
193
|
-
for (const [k, v] of loadTemplates(ctx.cwd)) if (!all.has(k)) all.set(k, v);
|
|
221
|
+
for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
|
|
194
222
|
}
|
|
195
223
|
const rows = [...all.values()].map(formatTemplateRow);
|
|
196
224
|
if (!ctx.hasUI) {
|
|
@@ -357,6 +385,7 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
357
385
|
return;
|
|
358
386
|
}
|
|
359
387
|
if (!ctx.hasUI) {
|
|
388
|
+
if (harnessFilter) process.stdout.write(`delegate — history (${harnessFilter})\n`);
|
|
360
389
|
for (const e of entries)
|
|
361
390
|
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
362
391
|
return;
|
|
@@ -377,7 +406,10 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
377
406
|
list.onSelect = item => done(item.value);
|
|
378
407
|
list.onCancel = () => done(undefined);
|
|
379
408
|
return {
|
|
380
|
-
render: (w: number) =>
|
|
409
|
+
render: (w: number) => {
|
|
410
|
+
const rows = list.render(w);
|
|
411
|
+
return harnessFilter ? [theme.fg('accent', `delegate — history (${harnessFilter})`), ...rows] : rows;
|
|
412
|
+
},
|
|
381
413
|
invalidate: () => list.invalidate(),
|
|
382
414
|
handleInput: (data: string) => {
|
|
383
415
|
list.handleInput(data);
|
|
@@ -392,15 +424,22 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
392
424
|
}
|
|
393
425
|
|
|
394
426
|
async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
395
|
-
const cfg =
|
|
427
|
+
const { config: cfg, source } = loadConfigWithSource();
|
|
396
428
|
const detection = await detectAll();
|
|
429
|
+
const trusted = isProjectTrusted(ctx);
|
|
397
430
|
const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
|
|
398
431
|
const lines: string[] = [];
|
|
399
432
|
lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
|
|
433
|
+
lines.push(...describeConfigSource(source));
|
|
400
434
|
lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
|
|
401
435
|
lines.push(
|
|
402
436
|
`maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
|
|
403
437
|
);
|
|
438
|
+
lines.push(
|
|
439
|
+
trusted
|
|
440
|
+
? 'project trust: trusted — project-local templates (.pi/delegate/templates/) are loaded'
|
|
441
|
+
: "project trust: untrusted — project-local templates skipped (trust this project via pi's trust prompt, or set defaultProjectTrust, to load them)",
|
|
442
|
+
);
|
|
404
443
|
lines.push('');
|
|
405
444
|
lines.push('harness binary ok version outputs templates active');
|
|
406
445
|
lines.push('─'.repeat(78));
|
|
@@ -416,13 +455,15 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
416
455
|
} catch {}
|
|
417
456
|
let templates = 0;
|
|
418
457
|
try {
|
|
419
|
-
templates = loadTemplates(ctx.cwd, h).size;
|
|
458
|
+
templates = loadTemplates(ctx.cwd, h, trusted).size;
|
|
420
459
|
} catch {}
|
|
421
460
|
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
422
461
|
const active = activeCount(h);
|
|
462
|
+
const cap = getMaxConcurrent(cfg, h);
|
|
463
|
+
const activeCol = `${active}/${cap > 0 ? cap : '∞'}`;
|
|
423
464
|
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
424
465
|
lines.push(
|
|
425
|
-
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${
|
|
466
|
+
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${activeCol}${hint}`,
|
|
426
467
|
);
|
|
427
468
|
}
|
|
428
469
|
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
@@ -435,9 +476,10 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
435
476
|
}
|
|
436
477
|
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
437
478
|
if (!harnessFilter) {
|
|
479
|
+
const globalCap = getMaxConcurrent(cfg);
|
|
438
480
|
lines.push('');
|
|
439
481
|
lines.push(
|
|
440
|
-
`global active: ${activeCount()} · aliases: ${
|
|
482
|
+
`global active: ${activeCount()}/${globalCap > 0 ? globalCap : '∞'} · aliases: ${
|
|
441
483
|
Object.entries(ALIASES)
|
|
442
484
|
.map(([k, v]) => `${k}→${v}`)
|
|
443
485
|
.join(', ') || '—'
|
|
@@ -475,6 +517,61 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
475
517
|
});
|
|
476
518
|
}
|
|
477
519
|
|
|
520
|
+
/**
|
|
521
|
+
* `/delegate config` — the discoverability gap `/delegate status`'s provenance line only hints at:
|
|
522
|
+
* shows exactly what was read from `settings.json` (or why it wasn't) plus the effective config
|
|
523
|
+
* with defaults filled in, formatted as a paste-ready JSON block under the `delegate` key. Print-
|
|
524
|
+
* only — writing is a separate, explicit action (`/delegate config init`, below), never triggered
|
|
525
|
+
* from this default view.
|
|
526
|
+
*/
|
|
527
|
+
async function showConfig(ctx: ExtensionContext): Promise<void> {
|
|
528
|
+
const result = loadConfigWithSource();
|
|
529
|
+
const lines = ['delegate — config', '', ...buildConfigReport(result)];
|
|
530
|
+
if (!ctx.hasUI) {
|
|
531
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
535
|
+
let offset = 0;
|
|
536
|
+
const height = 20;
|
|
537
|
+
return {
|
|
538
|
+
render(width: number): string[] {
|
|
539
|
+
const header = theme.fg('accent', `delegate config — ${result.source.file} (↑↓ scroll · any key to close)`);
|
|
540
|
+
const visible = lines.slice(offset, offset + height);
|
|
541
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
542
|
+
},
|
|
543
|
+
handleInput(data: string): void {
|
|
544
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
545
|
+
offset--;
|
|
546
|
+
tui.requestRender();
|
|
547
|
+
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
548
|
+
offset++;
|
|
549
|
+
tui.requestRender();
|
|
550
|
+
} else done(undefined);
|
|
551
|
+
},
|
|
552
|
+
invalidate() {},
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
|
|
559
|
+
* because a human explicitly typed this subcommand. Writes the current effective config (defaults
|
|
560
|
+
* merged with whatever was already on disk) into the `delegate` key via `writeDelegateConfig()`
|
|
561
|
+
* (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
|
|
562
|
+
* also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
|
|
563
|
+
* about: writing an explicit `delegate` key (with the correctly-resolved values already folded
|
|
564
|
+
* in — the legacy migration already ran before this point) makes it win from then on, without
|
|
565
|
+
* this command ever touching or deleting the old `claudeDelegate` key itself.
|
|
566
|
+
*/
|
|
567
|
+
async function initConfig(ctx: ExtensionContext): Promise<void> {
|
|
568
|
+
const result = loadConfigWithSource();
|
|
569
|
+
const write = writeDelegateConfig(result.config);
|
|
570
|
+
const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
|
|
571
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
572
|
+
else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
|
|
573
|
+
}
|
|
574
|
+
|
|
478
575
|
function buildPrompt(
|
|
479
576
|
template: DelegateTemplate,
|
|
480
577
|
task: string,
|
|
@@ -514,7 +611,7 @@ async function delegate(
|
|
|
514
611
|
throw new Error(
|
|
515
612
|
`unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
|
|
516
613
|
);
|
|
517
|
-
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
614
|
+
const templates = loadTemplates(ctx.cwd, harnessName, isProjectTrusted(ctx));
|
|
518
615
|
const mode = opts.mode || config.defaultMode;
|
|
519
616
|
const template = templates.get(mode);
|
|
520
617
|
if (!template)
|
|
@@ -524,6 +621,11 @@ async function delegate(
|
|
|
524
621
|
const task = opts.task || template.defaultTask;
|
|
525
622
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
526
623
|
|
|
624
|
+
// Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
|
|
625
|
+
// ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
|
|
626
|
+
// not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
|
|
627
|
+
const transport = resolveTransport(config, harnessName, harness);
|
|
628
|
+
|
|
527
629
|
// concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
|
|
528
630
|
// exactly as before; fan-out passes waitForSlot:true to queue instead.
|
|
529
631
|
const release = await acquireSlot({
|
|
@@ -600,7 +702,10 @@ async function delegate(
|
|
|
600
702
|
},
|
|
601
703
|
nativePermission: nativePermissionForRun,
|
|
602
704
|
};
|
|
603
|
-
result =
|
|
705
|
+
result =
|
|
706
|
+
transport === 'acp'
|
|
707
|
+
? await runAcpHarness({ ...baseRunOpts, harness: acpView(harness) })
|
|
708
|
+
: await runHarness(baseRunOpts);
|
|
604
709
|
} catch (err) {
|
|
605
710
|
release();
|
|
606
711
|
if (streamedFull.length > 0) {
|
|
@@ -1518,8 +1623,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1518
1623
|
// occupying a concurrency slot.
|
|
1519
1624
|
const specs: FanoutSpec[] = [];
|
|
1520
1625
|
const immediateFailures: FanoutRunSummary[] = [];
|
|
1626
|
+
const trusted = isProjectTrusted(ctx);
|
|
1521
1627
|
for (const h of resolved) {
|
|
1522
|
-
const templates = loadTemplates(ctx.cwd, h);
|
|
1628
|
+
const templates = loadTemplates(ctx.cwd, h, trusted);
|
|
1523
1629
|
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1524
1630
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1525
1631
|
if (!resolvedTaskScope) {
|
|
@@ -1609,8 +1715,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1609
1715
|
await showStatus(ctx, h);
|
|
1610
1716
|
return;
|
|
1611
1717
|
}
|
|
1718
|
+
if (subLower === 'config init') {
|
|
1719
|
+
await initConfig(ctx);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (subLower === 'config') {
|
|
1723
|
+
await showConfig(ctx);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1612
1726
|
// extract --harness flag for list/history subcommands
|
|
1613
|
-
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1]
|
|
1727
|
+
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1];
|
|
1614
1728
|
if (sub === 'watch' || sub === 'show') {
|
|
1615
1729
|
if (activeOverlay) {
|
|
1616
1730
|
activeOverlay.show();
|
|
@@ -1620,44 +1734,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1620
1734
|
}
|
|
1621
1735
|
return;
|
|
1622
1736
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
if (
|
|
1633
|
-
|
|
1634
|
-
|
|
1737
|
+
// Shared by list/history: resolve their (optional) harness filter to a canonical name via the
|
|
1738
|
+
// same alias/case rules (`omp` -> `amp`, any case), and reject a word that matches nothing —
|
|
1739
|
+
// rather than each falling back to silently showing an unfiltered or empty result.
|
|
1740
|
+
const filterHarness = (bareWord: string | undefined): string | undefined | 'unknown' => {
|
|
1741
|
+
if (forcedHarness) return forcedHarness;
|
|
1742
|
+
const resolution = resolveHarnessFilter(harnessFlag ?? bareWord, {
|
|
1743
|
+
isKnown: isKnownHarness,
|
|
1744
|
+
aliasOf: resolveHarnessName,
|
|
1745
|
+
});
|
|
1746
|
+
if (resolution.kind === 'unknown') {
|
|
1747
|
+
const msg = `unknown harness "${resolution.requested}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`;
|
|
1748
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
1749
|
+
else ctx.ui.notify?.(msg, 'warning');
|
|
1750
|
+
return 'unknown';
|
|
1635
1751
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1752
|
+
return resolution.kind === 'known' ? resolution.harness : undefined;
|
|
1753
|
+
};
|
|
1754
|
+
if (sub === 'list' || subLower.startsWith('list ')) {
|
|
1755
|
+
const h = filterHarness(subLower.startsWith('list ') ? sub.split(/\s+/)[1] : undefined);
|
|
1756
|
+
if (h === 'unknown') return;
|
|
1757
|
+
await showModes(ctx, h);
|
|
1638
1758
|
return;
|
|
1639
1759
|
}
|
|
1640
1760
|
if (sub === 'history' || sub === 'logs' || subLower.startsWith('history ') || subLower.startsWith('logs ')) {
|
|
1641
|
-
const h =
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
: undefined);
|
|
1647
|
-
if (h && isKnownHarness(h)) {
|
|
1648
|
-
await showHistory(ctx, h);
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
await showHistory(ctx, forcedHarness);
|
|
1761
|
+
const h = filterHarness(
|
|
1762
|
+
subLower.startsWith('history ') || subLower.startsWith('logs ') ? sub.split(/\s+/)[1] : undefined,
|
|
1763
|
+
);
|
|
1764
|
+
if (h === 'unknown') return;
|
|
1765
|
+
await showHistory(ctx, h);
|
|
1652
1766
|
return;
|
|
1653
1767
|
}
|
|
1654
1768
|
|
|
1655
1769
|
// combine forced harness + args for parsing
|
|
1656
1770
|
const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
|
|
1657
1771
|
// gather known modes across all harnesses for parsing
|
|
1772
|
+
const trusted = isProjectTrusted(ctx);
|
|
1658
1773
|
const allModes = new Set<string>();
|
|
1659
|
-
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
|
|
1660
|
-
for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
|
|
1774
|
+
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h, trusted).keys()) allModes.add(k);
|
|
1775
|
+
for (const k of loadTemplates(ctx.cwd, undefined, trusted).keys()) allModes.add(k);
|
|
1661
1776
|
const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
|
|
1662
1777
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
1663
1778
|
// if forcedHarness provided, it wins
|
|
@@ -1671,7 +1786,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1671
1786
|
}
|
|
1672
1787
|
|
|
1673
1788
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
1674
|
-
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
1789
|
+
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
1675
1790
|
const resolved = resolveDefaults(parsed, templates);
|
|
1676
1791
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1677
1792
|
const isDanger =
|