pi-harness-delegate 0.2.2 → 0.3.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 +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/harnesses/amp.ts +122 -99
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +92 -45
- package/extensions/harnesses/opencode.ts +86 -74
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +617 -206
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-registry.ts +90 -0
- package/extensions/templates.ts +3 -0
- package/extensions/usage.ts +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,18 +39,34 @@ Only the prompt is required. A **harness as first word** and/or **mode as next w
|
|
|
39
39
|
Some modes have **default tasks** when the prompt is omitted:
|
|
40
40
|
`/delegate review` reviews the current git diff (`scope: diff`), `/delegate security-audit` audits the repo. Modes without a default (`plan`, `implement`, `docs`, `general`) print a hint asking for a prompt.
|
|
41
41
|
|
|
42
|
-
The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`.
|
|
42
|
+
The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`. (`verify` is deliberately *not* a tool parameter — see below.)
|
|
43
43
|
|
|
44
44
|
`claude_delegate` remains as a deprecated alias for `delegate{harness:claude}`.
|
|
45
45
|
|
|
46
|
+
### Fan out to multiple harnesses
|
|
47
|
+
|
|
48
|
+
`harness` also accepts `all` or a comma-separated list — the same task runs on every harness, sequentially, and comes back as one comparison report instead of one report per harness:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
/delegate all review the auth flow # every *detected* harness
|
|
52
|
+
/delegate claude,codex plan the migration # just these two
|
|
53
|
+
delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `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
|
+
- 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>/`; a fan-out costs roughly N× a single run (respects `maxBudgetUsd` per run) and always runs sequentially, respecting `maxConcurrent`.
|
|
58
|
+
- The synthesized report groups each harness's metrics + output and a total spend line (unknown-cost runs called out separately, same as `/delegate status`) — it's assembled mechanically, not by asking a model to summarize.
|
|
59
|
+
- A single-harness call (`harness: "claude"`, or omitted) behaves exactly as before — fan-out is opt-in by typing `all`/a list.
|
|
60
|
+
- `/delegate all …` batches successful completions into one notification instead of one per harness; a failure is never delayed or folded into the batch — it surfaces immediately.
|
|
61
|
+
|
|
46
62
|
## Harnesses
|
|
47
63
|
|
|
48
64
|
| Harness | Binary | Permission mapping | Notes |
|
|
49
65
|
| --- | --- | --- | --- |
|
|
50
|
-
| `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context
|
|
51
|
-
| `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json
|
|
52
|
-
| `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json
|
|
53
|
-
| `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `amp
|
|
66
|
+
| `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context%. Schema-verified against Claude Code 2.1.247. |
|
|
67
|
+
| `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. |
|
|
68
|
+
| `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json`. Schema-verified against opencode 1.18.16. |
|
|
69
|
+
| `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). |
|
|
54
70
|
|
|
55
71
|
Detect availability: `delegate` checks `harness --version` at startup; missing harnesses hint install instructions.
|
|
56
72
|
|
|
@@ -69,19 +85,24 @@ Each mode is a markdown template with frontmatter:
|
|
|
69
85
|
|
|
70
86
|
```yaml
|
|
71
87
|
---
|
|
72
|
-
name:
|
|
73
|
-
description:
|
|
74
|
-
permission:
|
|
88
|
+
name: implement
|
|
89
|
+
description: Implement a task with file edits. Runs checks.
|
|
90
|
+
permission: edit # normalized: readonly | edit | danger
|
|
75
91
|
model: sonnet # or gpt-5 for codex, etc. Aliases resolved via modelAliases
|
|
76
|
-
|
|
77
|
-
defaultScope: diff
|
|
92
|
+
verify: bun test # optional — host-run check after the harness exits
|
|
78
93
|
---
|
|
79
|
-
You are a senior
|
|
94
|
+
You are a senior engineer delegated by the pi coding agent.
|
|
80
95
|
...
|
|
81
96
|
```
|
|
82
97
|
|
|
83
98
|
**Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, use the native key (`permissionMode: dontAsk`, `sandbox: ...`) — it overrides `permission` for that harness.
|
|
84
99
|
|
|
100
|
+
**Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
|
|
101
|
+
|
|
102
|
+
- **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
|
|
103
|
+
- **Never runs on a `readonly` template.** `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
|
|
104
|
+
- A project-local template's `verify` command is gated by the same trust check (`PI_TRUSTED=1` / `.pi/trusted`) as the rest of the template.
|
|
105
|
+
|
|
85
106
|
**Template sources (later wins):**
|
|
86
107
|
|
|
87
108
|
- `templates/shared/*.md` — portable prompt bodies
|
|
@@ -141,7 +162,7 @@ In `~/.pi/agent/settings.json`:
|
|
|
141
162
|
Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated).
|
|
142
163
|
|
|
143
164
|
- `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
|
|
144
|
-
- `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`).
|
|
165
|
+
- `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs.
|
|
145
166
|
- `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
|
|
146
167
|
|
|
147
168
|
`autoDelegateHints` is off by default — no system-prompt bias. When `true`, explicit markers (`@harness`, `with codex`, `delegate … to claude`) and imperative review/plan phrasing append a hint.
|
|
@@ -150,6 +171,8 @@ Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (depre
|
|
|
150
171
|
|
|
151
172
|
Every run records in details + transcript: harness, mode, permission (normalized + native), cost, tokens (input/output/cache), context% (prompt ÷ window), model, turns, duration, TTFT, stop reason, session id. Token + cost feed pi's `Usage`.
|
|
152
173
|
|
|
174
|
+
Claude reports turns and cost on every run; Codex/OpenCode/Amp don't always. An unmeasured turn count or cost renders as `—`/`n/a` (never `0`/`$0.000`) everywhere it's shown — the transcript header, `formatMetrics`, tool results, and `/delegate history` — so an unmeasured run is never mistaken for a free one. `/delegate status` shows a per-harness spend rollup (e.g. `$1.234 over 12 run(s) (3 unknown)`); runs with unknown cost are counted separately rather than folded into the total as `$0`.
|
|
175
|
+
|
|
153
176
|
## Security model
|
|
154
177
|
|
|
155
178
|
- `readonly` — no edits (e.g. Claude `plan`, Codex `read-only`).
|
package/extensions/activity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import type { ActivityEvent } from './harnesses/types.ts';
|
|
3
|
+
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
4
4
|
|
|
5
5
|
function truncate(s: string, max: number): string {
|
|
6
6
|
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
@@ -13,18 +13,18 @@ export function safeSegmentName(name: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface MetricsInput {
|
|
16
|
-
numTurns: number;
|
|
17
|
-
totalCostUsd: number;
|
|
16
|
+
numTurns: number | null;
|
|
17
|
+
totalCostUsd: number | null;
|
|
18
18
|
promptTokens: number;
|
|
19
19
|
contextPercent: number | null;
|
|
20
20
|
durationMs: number | null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. */
|
|
23
|
+
/** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. Unknown numTurns/cost render as `—`. */
|
|
24
24
|
export function formatMetrics(m: MetricsInput): string {
|
|
25
25
|
const parts: Array<string | null> = [
|
|
26
|
-
`${m.numTurns} turn(s)
|
|
27
|
-
`$${m.totalCostUsd.toFixed(3)}
|
|
26
|
+
m.numTurns !== null ? `${m.numTurns} turn(s)` : '— turn(s)',
|
|
27
|
+
m.totalCostUsd !== null ? `$${m.totalCostUsd.toFixed(3)}` : '$—',
|
|
28
28
|
m.promptTokens > 0 ? `${Math.round(m.promptTokens / 1000)}k tok` : null,
|
|
29
29
|
typeof m.contextPercent === 'number' ? `${m.contextPercent.toFixed(1)}% ctx` : null,
|
|
30
30
|
typeof m.durationMs === 'number' && m.durationMs !== null ? `${(m.durationMs / 1000).toFixed(0)}s` : null,
|
|
@@ -32,15 +32,15 @@ export function formatMetrics(m: MetricsInput): string {
|
|
|
32
32
|
return parts.filter((p): p is string => Boolean(p)).join(' · ');
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Parse the metadata header of a transcript file (without loading the whole body). */
|
|
35
|
+
/** Parse the metadata header of a transcript file (without loading the whole body). `cost` is null when unknown. */
|
|
36
36
|
export function parseTranscriptMeta(head: string): {
|
|
37
37
|
mode: string;
|
|
38
|
-
cost: number;
|
|
38
|
+
cost: number | null;
|
|
39
39
|
sessionId: string | null;
|
|
40
40
|
harness: string | null;
|
|
41
41
|
} {
|
|
42
42
|
let mode = 'delegate';
|
|
43
|
-
let cost =
|
|
43
|
+
let cost: number | null = null;
|
|
44
44
|
let sessionId: string | null = null;
|
|
45
45
|
let harness: string | null = null;
|
|
46
46
|
const mm = /^# Delegated (?:Claude|Harness) run — (.+)$/m.exec(head);
|
|
@@ -58,6 +58,117 @@ export function parseTranscriptMeta(head: string): {
|
|
|
58
58
|
return { mode, cost, sessionId, harness };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** One history entry's harness + cost, for spend aggregation. */
|
|
62
|
+
export interface SpendEntry {
|
|
63
|
+
harness: string;
|
|
64
|
+
cost: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Total cost, run count and unknown-cost run count — either per harness or overall. */
|
|
68
|
+
export interface HarnessSpend {
|
|
69
|
+
totalCostUsd: number;
|
|
70
|
+
runs: number;
|
|
71
|
+
unknownRuns: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Roll up cost across history entries, per harness and overall. Runs with unknown cost are counted
|
|
75
|
+
* separately rather than silently treated as $0. Pure — testable without the TUI. */
|
|
76
|
+
export function aggregateSpend(entries: SpendEntry[]): {
|
|
77
|
+
byHarness: Record<string, HarnessSpend>;
|
|
78
|
+
total: HarnessSpend;
|
|
79
|
+
} {
|
|
80
|
+
const byHarness: Record<string, HarnessSpend> = {};
|
|
81
|
+
const total: HarnessSpend = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (!byHarness[e.harness]) byHarness[e.harness] = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
|
|
84
|
+
const h = byHarness[e.harness];
|
|
85
|
+
h.runs++;
|
|
86
|
+
total.runs++;
|
|
87
|
+
if (e.cost === null) {
|
|
88
|
+
h.unknownRuns++;
|
|
89
|
+
total.unknownRuns++;
|
|
90
|
+
} else {
|
|
91
|
+
h.totalCostUsd += e.cost;
|
|
92
|
+
total.totalCostUsd += e.cost;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { byHarness, total };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Format a `HarnessSpend` as `$1.234 over 12 run(s) (3 unknown)`. */
|
|
99
|
+
export function formatSpend(s: HarnessSpend): string {
|
|
100
|
+
const unknown = s.unknownRuns > 0 ? ` (${s.unknownRuns} unknown)` : '';
|
|
101
|
+
return `$${s.totalCostUsd.toFixed(3)} over ${s.runs} run(s)${unknown}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Host-run verification (e.g. `bun test`) result — evidence only, never flips a run's `isError`.
|
|
106
|
+
* `skipped` is set (with a human-readable reason) when a verify command was configured but
|
|
107
|
+
* deliberately not executed — e.g. a `readonly` permission tier — instead of `exitCode`/`ok`
|
|
108
|
+
* describing a real run. Never silently dropped: a skip is still recorded and reported.
|
|
109
|
+
*/
|
|
110
|
+
export interface VerifyResult {
|
|
111
|
+
command: string;
|
|
112
|
+
/** `null` when the command was never run (see `skipped`). */
|
|
113
|
+
exitCode: number | null;
|
|
114
|
+
ok: boolean;
|
|
115
|
+
/** Combined stdout+stderr, capped to the last `maxTail` chars. Empty when skipped. */
|
|
116
|
+
outputTail: string;
|
|
117
|
+
/** Reason the command was not run, e.g. `"readonly run"`. Absent when it actually ran. */
|
|
118
|
+
skipped?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const VERIFY_TAIL_MAX = 2000;
|
|
122
|
+
|
|
123
|
+
/** Which verify command applies for a run: an explicit per-call override wins over the template's. */
|
|
124
|
+
export function resolveVerifyCommand(
|
|
125
|
+
callOverride: string | undefined,
|
|
126
|
+
templateVerify: string | undefined,
|
|
127
|
+
): string | undefined {
|
|
128
|
+
return callOverride || templateVerify || undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the verify command AND decide whether it may actually run, given the run's effective
|
|
133
|
+
* permission. `readonly` templates guarantee no execution/modification — a verify command must
|
|
134
|
+
* never ride along on one (see AGENTS.md's Conventions), so `skip` is true whenever `permission`
|
|
135
|
+
* is `readonly`, regardless of who configured the command. Pure — testable without `pi.exec`.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveVerifyPlan(
|
|
138
|
+
callOverride: string | undefined,
|
|
139
|
+
templateVerify: string | undefined,
|
|
140
|
+
permission: NormalizedPermission,
|
|
141
|
+
): { command: string; skip: boolean } | undefined {
|
|
142
|
+
const command = resolveVerifyCommand(callOverride, templateVerify);
|
|
143
|
+
if (!command) return undefined;
|
|
144
|
+
return { command, skip: permission === 'readonly' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Build a `VerifyResult` from a raw exit code + combined output, capping the tail. Pure. */
|
|
148
|
+
export function buildVerifyResult(
|
|
149
|
+
command: string,
|
|
150
|
+
exitCode: number,
|
|
151
|
+
output: string,
|
|
152
|
+
maxTail = VERIFY_TAIL_MAX,
|
|
153
|
+
): VerifyResult {
|
|
154
|
+
const outputTail = output.length > maxTail ? output.slice(-maxTail) : output;
|
|
155
|
+
return { command, exitCode, ok: exitCode === 0, outputTail };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A `VerifyResult` recording that a configured verify command was deliberately not run. */
|
|
159
|
+
export function skipVerifyResult(command: string, reason: string): VerifyResult {
|
|
160
|
+
return { command, exitCode: null, ok: false, outputTail: '', skipped: reason };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Markdown section for a verify result — report-only evidence, distinct from the harness's own isError. */
|
|
164
|
+
export function formatVerifySection(v: VerifyResult): string {
|
|
165
|
+
if (v.skipped) return `### Verify: \`${v.command}\`\n⊘ skipped (${v.skipped})`;
|
|
166
|
+
const lines = [`### Verify: \`${v.command}\``, v.ok ? `✓ exit ${v.exitCode}` : `✗ exit ${v.exitCode}`];
|
|
167
|
+
const tail = v.outputTail.trim();
|
|
168
|
+
if (tail) lines.push(...tail.split('\n').map(l => ` ${l}`));
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
61
172
|
/** Build the markdown report content injected into the session on the next turn. */
|
|
62
173
|
export function buildReportContent(opts: {
|
|
63
174
|
harness?: string;
|
|
@@ -66,6 +177,7 @@ export function buildReportContent(opts: {
|
|
|
66
177
|
body: string;
|
|
67
178
|
file?: string;
|
|
68
179
|
sessionId?: string;
|
|
180
|
+
verify?: VerifyResult;
|
|
69
181
|
}): string {
|
|
70
182
|
const harness = opts.harness ?? 'claude';
|
|
71
183
|
const header = `## ${harness} ${opts.mode} (${opts.metrics})`;
|
|
@@ -75,7 +187,50 @@ export function buildReportContent(opts: {
|
|
|
75
187
|
foot.push(
|
|
76
188
|
`resume: \`/delegate --harness=${opts.harness} --resume=${opts.sessionId} <prompt>\` (or /${opts.harness} --resume=${opts.sessionId})`,
|
|
77
189
|
);
|
|
78
|
-
|
|
190
|
+
const verifySection = opts.verify ? `\n${formatVerifySection(opts.verify)}\n` : '';
|
|
191
|
+
return [header, '', opts.body, verifySection, foot.length > 0 ? `\n_${foot.join(' · ')}_` : ''].join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One harness's outcome within a fan-out, for the synthesized comparison report. */
|
|
195
|
+
export interface FanoutRunSummary {
|
|
196
|
+
harness: string;
|
|
197
|
+
ok: boolean;
|
|
198
|
+
/** `formatMetrics` output — omitted (and `error` used instead) when the run failed to complete. */
|
|
199
|
+
metrics?: string;
|
|
200
|
+
cost: number | null;
|
|
201
|
+
body?: string;
|
|
202
|
+
file?: string;
|
|
203
|
+
sessionId?: string;
|
|
204
|
+
error?: string;
|
|
205
|
+
verify?: VerifyResult;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Mechanically assemble one comparison report across all fan-out runs — no second model call.
|
|
210
|
+
* Groups per-harness metrics/output and rolls up total spend via `aggregateSpend`/`formatSpend`.
|
|
211
|
+
* Header-free — callers wrap this body with their own `## <label> (...)` header.
|
|
212
|
+
*/
|
|
213
|
+
export function buildFanoutReport(opts: { runs: FanoutRunSummary[]; skipped: string[]; unknown: string[] }): string {
|
|
214
|
+
const lines: string[] = [];
|
|
215
|
+
if (opts.unknown.length > 0) lines.push(`_unknown harness(es), skipped: ${opts.unknown.join(', ')}_`);
|
|
216
|
+
if (opts.skipped.length > 0) lines.push(`_not installed, skipped: ${opts.skipped.join(', ')}_`);
|
|
217
|
+
if (opts.unknown.length > 0 || opts.skipped.length > 0) lines.push('');
|
|
218
|
+
|
|
219
|
+
const spend = aggregateSpend(opts.runs.map(r => ({ harness: r.harness, cost: r.cost })));
|
|
220
|
+
lines.push(`**Total spend:** ${formatSpend(spend.total)}`, '');
|
|
221
|
+
|
|
222
|
+
for (const r of opts.runs) {
|
|
223
|
+
lines.push(`### ${r.harness}${r.ok ? '' : ' — failed'}`);
|
|
224
|
+
if (r.ok) {
|
|
225
|
+
lines.push(r.metrics ?? '', '', r.body ?? '(empty)');
|
|
226
|
+
} else {
|
|
227
|
+
lines.push(`error: ${r.error ?? 'unknown error'}`);
|
|
228
|
+
}
|
|
229
|
+
if (r.verify) lines.push('', formatVerifySection(r.verify));
|
|
230
|
+
if (r.file) lines.push('', `_transcript: ${r.file}_`);
|
|
231
|
+
lines.push('');
|
|
232
|
+
}
|
|
233
|
+
return lines.join('\n').trimEnd();
|
|
79
234
|
}
|
|
80
235
|
|
|
81
236
|
/** Legacy wrapper for compat */
|
|
@@ -124,16 +279,54 @@ export function formatToolUse(name: string, input: Record<string, unknown>): str
|
|
|
124
279
|
return first ? `${name}: ${truncate(first, 90)}` : name;
|
|
125
280
|
}
|
|
126
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Tracks pending `tool_input` array indices by id, so a later `tool_result` can be matched to
|
|
284
|
+
* the row it actually belongs to instead of always landing on the last row.
|
|
285
|
+
*
|
|
286
|
+
* A harness emits N `tool_input` events followed by N `tool_result` events for a parallel
|
|
287
|
+
* tool-call batch, so "attach the result to the last entry" stamps every mark on the last row.
|
|
288
|
+
* Harnesses that don't carry an id fall back to that last-entry behavior via `resolve`.
|
|
289
|
+
* Shared by the transcript log builder and the live-feed builders (which also splice old
|
|
290
|
+
* entries off the front — `shift` keeps pending indices correct after that).
|
|
291
|
+
*/
|
|
292
|
+
export class ToolCallIndex {
|
|
293
|
+
private pending = new Map<string, number>();
|
|
294
|
+
|
|
295
|
+
set(id: string | undefined, index: number): void {
|
|
296
|
+
if (id) this.pending.set(id, index);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Index to mark, or -1 when an id was given but has no matching pending entry (don't mis-attribute). */
|
|
300
|
+
resolve(id: string | undefined, fallbackIndex: number): number {
|
|
301
|
+
if (id === undefined) return fallbackIndex;
|
|
302
|
+
const idx = this.pending.get(id);
|
|
303
|
+
if (idx === undefined) return -1;
|
|
304
|
+
this.pending.delete(id);
|
|
305
|
+
return idx;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Adjust pending indices after removing `count` entries from the front of the backing array. */
|
|
309
|
+
shift(count: number): void {
|
|
310
|
+
for (const [id, idx] of this.pending) {
|
|
311
|
+
const next = idx - count;
|
|
312
|
+
if (next < 0) this.pending.delete(id);
|
|
313
|
+
else this.pending.set(id, next);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
127
318
|
/** Compact per-line activity log for the transcript (tool_input + results only). */
|
|
128
319
|
export function collectActivityLog(events: ActivityEvent[]): string[] {
|
|
129
320
|
const log: string[] = [];
|
|
321
|
+
const index = new ToolCallIndex();
|
|
130
322
|
for (const ev of events) {
|
|
131
323
|
if (ev.kind === 'tool_input') {
|
|
132
324
|
log.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
325
|
+
index.set(ev.id, log.length - 1);
|
|
133
326
|
} else if (ev.kind === 'tool_result') {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
log[
|
|
327
|
+
const idx = index.resolve(ev.id, log.length - 1);
|
|
328
|
+
if (idx >= 0 && log[idx].startsWith('▶')) {
|
|
329
|
+
log[idx] += ev.isError ? ' ✗ error' : ' ✓';
|
|
137
330
|
}
|
|
138
331
|
}
|
|
139
332
|
}
|
|
@@ -152,8 +345,8 @@ export function buildTranscript(
|
|
|
152
345
|
cwd: string;
|
|
153
346
|
sessionId: string | null;
|
|
154
347
|
resumed: boolean;
|
|
155
|
-
numTurns: number;
|
|
156
|
-
totalCostUsd: number;
|
|
348
|
+
numTurns: number | null;
|
|
349
|
+
totalCostUsd: number | null;
|
|
157
350
|
isError: boolean;
|
|
158
351
|
stopReason: string | null;
|
|
159
352
|
durationMs: number | null;
|
|
@@ -167,6 +360,8 @@ export function buildTranscript(
|
|
|
167
360
|
contextWindow: number | null;
|
|
168
361
|
activityLog: string[];
|
|
169
362
|
output: string;
|
|
363
|
+
/** Host-run verification result, when a `verify` command was configured for this run. */
|
|
364
|
+
verify?: VerifyResult;
|
|
170
365
|
} & Record<string, unknown>,
|
|
171
366
|
): string {
|
|
172
367
|
const harness = (opts.harness as string | undefined) ?? 'claude';
|
|
@@ -215,7 +410,7 @@ export function buildTranscript(
|
|
|
215
410
|
`- model: ${opts.model ?? 'default'}`,
|
|
216
411
|
`- cwd: ${opts.cwd}`,
|
|
217
412
|
`- session: ${opts.sessionId ?? 'n/a'}${opts.resumed ? ' (resumed)' : ''}`,
|
|
218
|
-
`- turns: ${opts.numTurns} · cost:
|
|
413
|
+
`- turns: ${opts.numTurns ?? 'n/a'} · cost: ${opts.totalCostUsd !== null ? `$${opts.totalCostUsd.toFixed(4)}` : 'n/a'} · isError: ${opts.isError}`,
|
|
219
414
|
`- tokens: ${tokens ?? 'n/a'}`,
|
|
220
415
|
`- context: ${context ?? 'n/a'}`,
|
|
221
416
|
`- duration: ${duration ?? 'n/a'}`,
|
|
@@ -227,6 +422,7 @@ export function buildTranscript(
|
|
|
227
422
|
'## Output',
|
|
228
423
|
opts.output || '(empty)',
|
|
229
424
|
'',
|
|
425
|
+
...(opts.verify ? [formatVerifySection(opts.verify), ''] : []),
|
|
230
426
|
].join('\n');
|
|
231
427
|
}
|
|
232
428
|
|
package/extensions/command.ts
CHANGED
|
@@ -16,32 +16,47 @@ export interface DelegateCommandArgs {
|
|
|
16
16
|
sessionId?: string;
|
|
17
17
|
/** GitHub PR number/URL to review (--pr=). */
|
|
18
18
|
pr?: string;
|
|
19
|
+
/** Host-run verification command override (--verify=); takes precedence over the template's. */
|
|
20
|
+
verify?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export type ClaudeCommandArgs = DelegateCommandArgs;
|
|
22
24
|
|
|
23
25
|
const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
|
|
24
26
|
|
|
27
|
+
/** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
|
|
28
|
+
function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
|
|
29
|
+
const lower = word.toLowerCase();
|
|
30
|
+
if (lower === 'all' || knownHarnesses.has(lower)) return true;
|
|
31
|
+
const parts = lower.split(',').filter(Boolean);
|
|
32
|
+
return parts.length > 1 && parts.every(p => knownHarnesses.has(p));
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
export function parseDelegateCommand(
|
|
26
36
|
raw: string,
|
|
27
37
|
knownModes: ReadonlySet<string>,
|
|
28
38
|
knownHarnesses: ReadonlySet<string> = KNOWN_HARNESSES,
|
|
29
39
|
): DelegateCommandArgs {
|
|
30
40
|
const flags: Record<string, string> = {};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
// supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
|
|
42
|
+
const rest = raw.replace(
|
|
43
|
+
/--([a-zA-Z-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
|
|
44
|
+
(_m, k: string, dq: string | undefined, sq: string | undefined, bare: string | undefined) => {
|
|
45
|
+
flags[k] = dq ?? sq ?? bare ?? '';
|
|
46
|
+
return '';
|
|
47
|
+
},
|
|
48
|
+
);
|
|
35
49
|
|
|
36
50
|
let harness = flags.harness?.toLowerCase();
|
|
37
51
|
let mode = flags.mode;
|
|
38
52
|
let task = rest.trim();
|
|
39
53
|
|
|
40
|
-
// First word handling: harness, mode, or both
|
|
54
|
+
// First word handling: harness (single, `all`, or comma list), mode, or both
|
|
41
55
|
const words = task.split(/\s+/).filter(Boolean);
|
|
42
56
|
let idx = 0;
|
|
43
|
-
if (!harness && words[idx] &&
|
|
57
|
+
if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
|
|
44
58
|
harness = words[idx].toLowerCase();
|
|
59
|
+
// single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
|
|
45
60
|
if (harness === 'omp') harness = 'amp';
|
|
46
61
|
idx++;
|
|
47
62
|
}
|
|
@@ -62,6 +77,7 @@ export function parseDelegateCommand(
|
|
|
62
77
|
}
|
|
63
78
|
if (flags.resume) out.sessionId = flags.resume;
|
|
64
79
|
if (flags.pr) out.pr = flags.pr;
|
|
80
|
+
if (flags.verify) out.verify = flags.verify;
|
|
65
81
|
return out;
|
|
66
82
|
}
|
|
67
83
|
|
|
@@ -69,6 +85,67 @@ export function parseClaudeCommand(raw: string, knownModes: ReadonlySet<string>)
|
|
|
69
85
|
return parseDelegateCommand(raw, knownModes);
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/** True when a `harness` field selects more than one harness: `all` or a comma-separated list. */
|
|
89
|
+
export function isFanoutSpec(harness: string | undefined): boolean {
|
|
90
|
+
if (!harness) return false;
|
|
91
|
+
const lower = harness.trim().toLowerCase();
|
|
92
|
+
return lower === 'all' || lower.includes(',');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HarnessListResolution {
|
|
96
|
+
/** Canonical harness names to run, in request order, deduped. */
|
|
97
|
+
resolved: string[];
|
|
98
|
+
/** Requested names that don't match any known harness or alias. */
|
|
99
|
+
unknown: string[];
|
|
100
|
+
/** Known harnesses that were requested/selected by `all` but aren't detected as installed. */
|
|
101
|
+
skipped: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a `harness` field (`all` or a comma-separated list of names/aliases) into the
|
|
106
|
+
* canonical harness names a fan-out should actually run. Pure — detection results and the
|
|
107
|
+
* known-harness/alias lookups are passed in, no I/O happens here.
|
|
108
|
+
*
|
|
109
|
+
* `all` resolves to every *detected* harness (skipping uninstalled ones). An explicit list is
|
|
110
|
+
* validated against `isKnown`/`aliasOf` and also filtered by detection, so a named-but-uninstalled
|
|
111
|
+
* harness is reported (via `skipped`) instead of failing the whole run.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveHarnessList(
|
|
114
|
+
spec: string,
|
|
115
|
+
opts: {
|
|
116
|
+
knownHarnesses: readonly string[];
|
|
117
|
+
aliasOf: (name: string) => string;
|
|
118
|
+
isKnown: (name: string) => boolean;
|
|
119
|
+
detection: Readonly<Record<string, { ok: boolean }>>;
|
|
120
|
+
},
|
|
121
|
+
): HarnessListResolution {
|
|
122
|
+
const lower = spec.trim().toLowerCase();
|
|
123
|
+
const isAll = lower === 'all';
|
|
124
|
+
const requested = isAll
|
|
125
|
+
? opts.knownHarnesses
|
|
126
|
+
: lower
|
|
127
|
+
.split(',')
|
|
128
|
+
.map(s => s.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
|
|
131
|
+
const resolved: string[] = [];
|
|
132
|
+
const unknown: string[] = [];
|
|
133
|
+
const skipped: string[] = [];
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
for (const raw of requested) {
|
|
136
|
+
if (!isAll && !opts.isKnown(raw)) {
|
|
137
|
+
unknown.push(raw);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const canon = opts.aliasOf(raw);
|
|
141
|
+
if (seen.has(canon)) continue;
|
|
142
|
+
seen.add(canon);
|
|
143
|
+
if (opts.detection[canon]?.ok) resolved.push(canon);
|
|
144
|
+
else skipped.push(canon);
|
|
145
|
+
}
|
|
146
|
+
return { resolved, unknown, skipped };
|
|
147
|
+
}
|
|
148
|
+
|
|
72
149
|
/**
|
|
73
150
|
* Apply template defaults when the prompt is empty.
|
|
74
151
|
*/
|