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.
@@ -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
- const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
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
- /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
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 > 1 && parts.every(p => knownHarnesses.has(p));
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
- // supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
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
- /--([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 '';
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
- let harness = flags.harness?.toLowerCase();
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].toLowerCase();
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
- if (Number.isFinite(budget) && budget > 0) out.budget = budget;
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 { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
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 sleep(pollIntervalMs, signal);
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 sleep(pollIntervalMs, signal);
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 sleep(pollIntervalMs, signal);
136
+ await pause();
104
137
  continue;
105
138
  }
106
139
 
107
- activeRuns.set(harness, perHarnessCount + 1);
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;