pi-harness-delegate 0.6.2 → 0.7.1
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 +65 -9
- package/extensions/acp-runner.ts +158 -35
- package/extensions/activity.ts +91 -1
- package/extensions/command.ts +170 -13
- package/extensions/concurrency.ts +41 -5
- package/extensions/engine.ts +604 -0
- package/extensions/fanout.ts +512 -0
- package/extensions/harnesses/amp.ts +7 -6
- package/extensions/harnesses/claude.ts +2 -0
- package/extensions/harnesses/codex.ts +15 -7
- package/extensions/harnesses/opencode.ts +14 -3
- package/extensions/harnesses/registry.ts +16 -0
- package/extensions/harnesses/types.ts +8 -8
- package/extensions/history.ts +170 -0
- package/extensions/index.ts +120 -1419
- package/extensions/progress.ts +9 -3
- package/extensions/run-registry.ts +36 -3
- package/extensions/runner.ts +75 -30
- package/extensions/stream-caps.ts +46 -0
- package/extensions/subcommands.ts +227 -0
- package/extensions/templates.ts +33 -2
- package/extensions/validate.ts +214 -0
- package/package.json +4 -2
- package/extensions/run-claude.ts +0 -61
- package/extensions/stream-parse.ts +0 -37
package/extensions/command.ts
CHANGED
|
@@ -18,36 +18,176 @@ export interface DelegateCommandArgs {
|
|
|
18
18
|
pr?: string;
|
|
19
19
|
/** Host-run verification command override (--verify=); takes precedence over the template's. */
|
|
20
20
|
verify?: string;
|
|
21
|
+
/** Extra directories the harness may access (--add-dir=, repeatable). */
|
|
22
|
+
addDirs?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* `--allow-dangerous` (or `--allow-dangerous=true`): a human asking to run this one invocation
|
|
25
|
+
* with danger permission. Only ever honored after an interactive confirm in the command handler
|
|
26
|
+
* — never inherited from config, never applied headless. Absent unless explicitly true.
|
|
27
|
+
*/
|
|
28
|
+
allowDangerous?: boolean;
|
|
29
|
+
/** Flag values that were given but are unusable (e.g. `--budget=0`) — the handler reports these
|
|
30
|
+
* and runs nothing, rather than silently dropping the flag. Absent when there are none. */
|
|
31
|
+
errors?: string[];
|
|
32
|
+
/** Non-fatal heads-ups the handler shows before running — e.g. a recognized `--flag=` that sat
|
|
33
|
+
* inside a double-quoted span and so was kept as prompt text, not applied. Absent when none. */
|
|
34
|
+
notices?: string[];
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
export type ClaudeCommandArgs = DelegateCommandArgs;
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The flag set every `/delegate`-family command accepts after the harness — the single source for
|
|
41
|
+
* the `/delegate` description, its usage warning, and every alias command's (`/claude`, `/omp`, …)
|
|
42
|
+
* description, so the hints can't drift from what `parseDelegateCommand` actually parses.
|
|
43
|
+
*/
|
|
44
|
+
export const COMMAND_FLAGS_HINT =
|
|
45
|
+
'[--mode=review|plan|implement|security-audit|docs|general] [--model=…] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--add-dir=<path>] [--allow-dangerous] <prompt>';
|
|
46
|
+
|
|
47
|
+
/** Usage line for `/delegate` itself. */
|
|
48
|
+
export function delegateUsage(): string {
|
|
49
|
+
return `/delegate [--harness=claude|codex|opencode|amp|devin|all|<a,b>] ${COMMAND_FLAGS_HINT}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Usage line for an alias command (`/claude`, `/omp`, …) — same flags, harness fixed. */
|
|
53
|
+
export function aliasUsage(command: string): string {
|
|
54
|
+
return `/${command} ${COMMAND_FLAGS_HINT}`;
|
|
55
|
+
}
|
|
26
56
|
|
|
27
|
-
|
|
57
|
+
const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp', 'devin']);
|
|
58
|
+
const HARNESS_ALIASES: Readonly<Record<string, string>> = { omp: 'amp' };
|
|
59
|
+
|
|
60
|
+
/** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them
|
|
61
|
+
* (a stray empty element, e.g. the trailing comma in `claude,`, is ignored). */
|
|
28
62
|
function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
|
|
29
63
|
const lower = word.toLowerCase();
|
|
30
64
|
if (lower === 'all' || knownHarnesses.has(lower)) return true;
|
|
65
|
+
if (!lower.includes(',')) return false;
|
|
31
66
|
const parts = lower.split(',').filter(Boolean);
|
|
32
|
-
return parts.length >
|
|
67
|
+
return parts.length > 0 && parts.every(p => knownHarnesses.has(p));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a harness spec the same way whether it came from `--harness=` or the first word:
|
|
72
|
+
* lowercased, empty list elements dropped (`claude,` -> `claude`, `,` -> none), and a single name
|
|
73
|
+
* alias-normalized (`omp` -> `amp`). A real list / `all` is left for `resolveHarnessList`.
|
|
74
|
+
*/
|
|
75
|
+
function normalizeHarnessSpec(spec: string): string | undefined {
|
|
76
|
+
const parts = spec
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.split(',')
|
|
79
|
+
.map(p => p.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
if (parts.length === 0) return undefined;
|
|
82
|
+
if (parts.length === 1) return HARNESS_ALIASES[parts[0]] ?? parts[0];
|
|
83
|
+
return parts.join(',');
|
|
33
84
|
}
|
|
34
85
|
|
|
86
|
+
/**
|
|
87
|
+
* One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
|
|
88
|
+
* `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
|
|
89
|
+
* `--key=value` (value bare, "double" or 'single' quoted, or empty) or bare `--allow-dangerous` /
|
|
90
|
+
* `--budget` token that starts a word is a flag. Every other `--word` stays in the text untouched.
|
|
91
|
+
*/
|
|
92
|
+
const FLAG_OR_PROSE = /`[^`]*`|"[^"]*"|(^|\s)--([a-zA-Z][a-zA-Z-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S*))|(?=\s|$))/g;
|
|
93
|
+
|
|
94
|
+
/** Every flag `parseDelegateCommand` acts on — used to notice one stranded inside quoted prose. */
|
|
95
|
+
const RECOGNIZED_FLAGS = new Set([
|
|
96
|
+
'harness',
|
|
97
|
+
'mode',
|
|
98
|
+
'model',
|
|
99
|
+
'scope',
|
|
100
|
+
'budget',
|
|
101
|
+
'resume',
|
|
102
|
+
'pr',
|
|
103
|
+
'verify',
|
|
104
|
+
'add-dir',
|
|
105
|
+
'allow-dangerous',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** A recognized flag token starting a word inside a prose span (quote/backtick counts as a word start). */
|
|
109
|
+
const FLAG_IN_PROSE = /(?:^|[\s"`])--([a-zA-Z][a-zA-Z-]*)(?==|[\s"`]|$)/g;
|
|
110
|
+
|
|
111
|
+
/** Prose spans as FLAG_OR_PROSE pairs them; whatever delimiter is left over afterwards is unmatched. */
|
|
112
|
+
const PROSE_SPAN = /`[^`]*`|"[^"]*"/g;
|
|
113
|
+
|
|
35
114
|
export function parseDelegateCommand(
|
|
36
115
|
raw: string,
|
|
37
116
|
knownModes: ReadonlySet<string>,
|
|
38
117
|
knownHarnesses: ReadonlySet<string> = KNOWN_HARNESSES,
|
|
39
118
|
): DelegateCommandArgs {
|
|
40
119
|
const flags: Record<string, string> = {};
|
|
41
|
-
|
|
120
|
+
const addDirs: string[] = [];
|
|
121
|
+
const errors: string[] = [];
|
|
122
|
+
const notices: string[] = [];
|
|
123
|
+
let allowDangerousBare = false;
|
|
124
|
+
let budgetWithoutValue = false;
|
|
42
125
|
const rest = raw.replace(
|
|
43
|
-
|
|
44
|
-
(
|
|
45
|
-
|
|
46
|
-
|
|
126
|
+
FLAG_OR_PROSE,
|
|
127
|
+
(
|
|
128
|
+
m: string,
|
|
129
|
+
lead: string | undefined,
|
|
130
|
+
k: string | undefined,
|
|
131
|
+
dq: string | undefined,
|
|
132
|
+
sq: string | undefined,
|
|
133
|
+
bare: string | undefined,
|
|
134
|
+
) => {
|
|
135
|
+
if (k === undefined) {
|
|
136
|
+
// quoted/backticked prose — left alone. A double-quoted span holding a recognized flag is
|
|
137
|
+
// most likely an accident (`fix "bug --budget=5 and "more`): say so instead of dropping it
|
|
138
|
+
// silently. Backticks are the deliberate "this is literal" marker, so they stay quiet.
|
|
139
|
+
if (m.startsWith('"')) {
|
|
140
|
+
for (const [, name] of m.matchAll(FLAG_IN_PROSE)) {
|
|
141
|
+
if (!RECOGNIZED_FLAGS.has(name)) continue;
|
|
142
|
+
notices.push(
|
|
143
|
+
`--${name} inside double quotes was kept as prompt text, not applied — move it outside the quotes to use it as a flag`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return m;
|
|
148
|
+
}
|
|
149
|
+
const hasValue = dq !== undefined || sq !== undefined || bare !== undefined;
|
|
150
|
+
if (!hasValue) {
|
|
151
|
+
// bare boolean flag: only `--allow-dangerous`. A bare `--budget` (e.g. the space form
|
|
152
|
+
// `--budget 5`) is an explicit cap we can't honor — an error, never silently "no cap".
|
|
153
|
+
// Any other bare `--word` is prose.
|
|
154
|
+
if (k === 'budget') {
|
|
155
|
+
budgetWithoutValue = true;
|
|
156
|
+
return m;
|
|
157
|
+
}
|
|
158
|
+
if (k !== 'allow-dangerous') return m;
|
|
159
|
+
allowDangerousBare = true;
|
|
160
|
+
return lead ?? '';
|
|
161
|
+
}
|
|
162
|
+
const value = dq ?? sq ?? bare ?? '';
|
|
163
|
+
// --add-dir is the one repeatable flag — every occurrence is kept, in order
|
|
164
|
+
if (k === 'add-dir') {
|
|
165
|
+
if (value) addDirs.push(value);
|
|
166
|
+
} else flags[k] = value;
|
|
167
|
+
return lead ?? '';
|
|
47
168
|
},
|
|
48
169
|
);
|
|
49
170
|
|
|
50
|
-
|
|
171
|
+
// An unmatched `"` or backtick makes the pairing above a guess: in `fix "x --verify="echo ok"` the
|
|
172
|
+
// stray quote pairs with the flag's own opening quote, so the real flag is eaten as prose and
|
|
173
|
+
// whatever followed it is parsed as flags instead. Text before the first delimiter is outside any
|
|
174
|
+
// span however the quotes pair, so only a flag-shaped token at or after it is in play; when one
|
|
175
|
+
// is, refuse rather than guess — the result must not depend on quote parity.
|
|
176
|
+
const strayDelimiter = /["`]/.test(rest.replace(PROSE_SPAN, ''));
|
|
177
|
+
const firstDelimiter = raw.search(/["`]/);
|
|
178
|
+
const ambiguous = strayDelimiter && firstDelimiter >= 0 && /--[a-zA-Z]/.test(raw.slice(firstDelimiter));
|
|
179
|
+
if (ambiguous) {
|
|
180
|
+
errors.push(
|
|
181
|
+
'unbalanced " or ` in the command — flags around it can\'t be told apart from prompt text; close the quote (or remove it) and try again',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (budgetWithoutValue && flags.budget === undefined) {
|
|
185
|
+
errors.push(
|
|
186
|
+
'--budget needs a value: use --budget=<usd> (or wrap the text in backticks if it is part of the prompt)',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
|
|
51
191
|
let mode = flags.mode;
|
|
52
192
|
let task = rest.trim();
|
|
53
193
|
|
|
@@ -55,9 +195,7 @@ export function parseDelegateCommand(
|
|
|
55
195
|
const words = task.split(/\s+/).filter(Boolean);
|
|
56
196
|
let idx = 0;
|
|
57
197
|
if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
|
|
58
|
-
harness = words[idx]
|
|
59
|
-
// single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
|
|
60
|
-
if (harness === 'omp') harness = 'amp';
|
|
198
|
+
harness = normalizeHarnessSpec(words[idx]);
|
|
61
199
|
idx++;
|
|
62
200
|
}
|
|
63
201
|
if (!mode && words[idx] && knownModes.has(words[idx])) {
|
|
@@ -73,11 +211,20 @@ export function parseDelegateCommand(
|
|
|
73
211
|
if (flags.scope) out.scope = flags.scope;
|
|
74
212
|
if (flags.budget !== undefined) {
|
|
75
213
|
const budget = Number(flags.budget);
|
|
76
|
-
|
|
214
|
+
// an explicit spend cap that can't be honored is an error, never silently "no cap"
|
|
215
|
+
if (flags.budget.trim() !== '' && Number.isFinite(budget) && budget > 0) out.budget = budget;
|
|
216
|
+
else errors.push(`--budget must be a positive number of USD (got "${flags.budget}")`);
|
|
77
217
|
}
|
|
78
218
|
if (flags.resume) out.sessionId = flags.resume;
|
|
79
219
|
if (flags.pr) out.pr = flags.pr;
|
|
80
220
|
if (flags.verify) out.verify = flags.verify;
|
|
221
|
+
if (addDirs.length > 0) out.addDirs = addDirs;
|
|
222
|
+
// `=true` is tolerated; any other explicit value (`=false`, `=yes`, …) means off — fail closed
|
|
223
|
+
// never grant danger off an ambiguous parse — the error above stops the run anyway, but fail closed
|
|
224
|
+
if (!ambiguous && (allowDangerousBare || flags['allow-dangerous']?.toLowerCase() === 'true'))
|
|
225
|
+
out.allowDangerous = true;
|
|
226
|
+
if (errors.length > 0) out.errors = errors;
|
|
227
|
+
if (notices.length > 0) out.notices = [...new Set(notices)];
|
|
81
228
|
return out;
|
|
82
229
|
}
|
|
83
230
|
|
|
@@ -92,6 +239,16 @@ export function isFanoutSpec(harness: string | undefined): boolean {
|
|
|
92
239
|
return lower === 'all' || lower.includes(',');
|
|
93
240
|
}
|
|
94
241
|
|
|
242
|
+
/**
|
|
243
|
+
* A session id belongs to exactly one harness's session store, so resuming "it" across a fan-out
|
|
244
|
+
* is meaningless — every other harness would either error out or silently start fresh under a
|
|
245
|
+
* foreign id. Returns the rejection message (null when fine); shared by the tool and command paths.
|
|
246
|
+
*/
|
|
247
|
+
export function fanoutResumeError(harnessSpec: string | undefined, sessionId: string | undefined): string | null {
|
|
248
|
+
if (!sessionId || !isFanoutSpec(harnessSpec)) return null;
|
|
249
|
+
return `cannot resume session "${sessionId}" across a fan-out (harness "${harnessSpec}") — a session id belongs to one harness; resume it with that single harness instead (e.g. /delegate --harness=<name> --resume=${sessionId} …)`;
|
|
250
|
+
}
|
|
251
|
+
|
|
95
252
|
export type HarnessFilterResolution =
|
|
96
253
|
| { kind: 'none' } // no filter word given
|
|
97
254
|
| { kind: 'known'; harness: string } // resolved to its canonical name (aliases/case normalized)
|
|
@@ -39,6 +39,29 @@ export interface AcquireSlotOptions {
|
|
|
39
39
|
/** Aborts a `wait: true` poll early. */
|
|
40
40
|
signal?: AbortSignal;
|
|
41
41
|
pollIntervalMs?: number;
|
|
42
|
+
/** Source of the poll jitter (see `pollDelayMs`) — injectable so tests stay deterministic. */
|
|
43
|
+
random?: () => number;
|
|
44
|
+
/** How a poll waits (default: an abortable `setTimeout`) — injectable so tests can observe the
|
|
45
|
+
* actual delay each poll is given. */
|
|
46
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Max extra delay added to each poll, as a fraction of `pollIntervalMs`. */
|
|
50
|
+
export const POLL_JITTER_FRACTION = 0.25;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One poll's wait: `pollIntervalMs` plus up to `POLL_JITTER_FRACTION` of it, at random. Without the
|
|
54
|
+
* jitter, every waiter of a fan-out (all launched in the same tick) re-checks in lockstep, so they
|
|
55
|
+
* keep hitting `acquireRunWithinLimits`'s write-then-recheck together — exactly the tight multi-way
|
|
56
|
+
* race that makes contenders all back off at once (a transient under-admission). Spreading the
|
|
57
|
+
* re-checks out breaks the lockstep. Pure given `random`.
|
|
58
|
+
*/
|
|
59
|
+
export function pollDelayMs(pollIntervalMs: number, random: () => number = Math.random): number {
|
|
60
|
+
// a non-finite draw (NaN would otherwise survive the clamp and make setTimeout fire at 0ms, i.e.
|
|
61
|
+
// a hot poll loop) gets no jitter
|
|
62
|
+
const raw = random();
|
|
63
|
+
const r = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0;
|
|
64
|
+
return pollIntervalMs + Math.floor(pollIntervalMs * POLL_JITTER_FRACTION * r);
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function abortError(): Error {
|
|
@@ -77,21 +100,31 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
77
100
|
* that race is handled exactly like losing the check above: throw (wait:false) or poll (wait:true).
|
|
78
101
|
*/
|
|
79
102
|
export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
|
|
80
|
-
const {
|
|
103
|
+
const {
|
|
104
|
+
harness,
|
|
105
|
+
mode,
|
|
106
|
+
config,
|
|
107
|
+
wait,
|
|
108
|
+
signal,
|
|
109
|
+
pollIntervalMs = 200,
|
|
110
|
+
random = Math.random,
|
|
111
|
+
sleep: sleepFn = sleep,
|
|
112
|
+
} = opts;
|
|
113
|
+
const pause = () => sleepFn(pollDelayMs(pollIntervalMs, random), signal);
|
|
81
114
|
for (;;) {
|
|
82
115
|
if (signal?.aborted) throw abortError();
|
|
83
116
|
const maxGlobal = getMaxConcurrent(config);
|
|
84
117
|
const globalCount = activeCount();
|
|
85
118
|
if (maxGlobal > 0 && globalCount >= maxGlobal) {
|
|
86
119
|
if (!wait) throw new ConcurrencyLimitError('another delegate run is already in progress (global limit)');
|
|
87
|
-
await
|
|
120
|
+
await pause();
|
|
88
121
|
continue;
|
|
89
122
|
}
|
|
90
123
|
const perHarnessLimit = getMaxConcurrent(config, harness);
|
|
91
124
|
const perHarnessCount = activeCount(harness);
|
|
92
125
|
if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) {
|
|
93
126
|
if (!wait) throw new ConcurrencyLimitError(`another ${harness} run is already in progress`);
|
|
94
|
-
await
|
|
127
|
+
await pause();
|
|
95
128
|
continue;
|
|
96
129
|
}
|
|
97
130
|
|
|
@@ -100,11 +133,14 @@ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void>
|
|
|
100
133
|
// the check above passed, but another racer's write landed first and used up the slot —
|
|
101
134
|
// handled exactly like hitting the cap on the check itself.
|
|
102
135
|
if (!wait) throw new ConcurrencyLimitError(`another delegate run claimed the last available slot for ${harness}`);
|
|
103
|
-
await
|
|
136
|
+
await pause();
|
|
104
137
|
continue;
|
|
105
138
|
}
|
|
106
139
|
|
|
107
|
-
|
|
140
|
+
// In-process counters count only this process's own runs — never seed them from
|
|
141
|
+
// `activeCount()` (which already folds in other processes' registry entries), or a foreign
|
|
142
|
+
// run gets baked into our local count and outlives that run's own release.
|
|
143
|
+
activeRuns.set(harness, (activeRuns.get(harness) ?? 0) + 1);
|
|
108
144
|
globalActiveRuns++;
|
|
109
145
|
const runHandle = claim.status === 'acquired' ? claim.handle : null;
|
|
110
146
|
let released = false;
|