pi-harness-delegate 0.2.2 → 0.4.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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The `delegate()` concurrency guard, factored out of index.ts so it's usable — and testable —
3
+ * independent of the TUI.
4
+ *
5
+ * Combines the file-based cross-process registry (`run-registry.ts`) with an in-process counter
6
+ * fallback (registry I/O failures never block a delegation). `acquireSlot()` is the single choke
7
+ * point: `wait: false` preserves the original fail-fast behavior for ad-hoc single-harness runs
8
+ * (throws immediately at capacity); `wait: true` polls until a slot frees, which is what turns a
9
+ * fan-out into a bounded pool without a separate worker-pool abstraction — callers just kick off
10
+ * all the harnesses at once and let `acquireSlot` serialize the ones that don't fit yet.
11
+ */
12
+
13
+ import { type DelegateConfig, getMaxConcurrent } from './config.ts';
14
+ import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
15
+
16
+ const activeRuns = new Map<string, number>();
17
+ let globalActiveRuns = 0;
18
+
19
+ /** In-process active-run count (optionally filtered to one harness). Exposed for `/delegate status`. */
20
+ export function inProcessActiveCount(harness?: string): number {
21
+ return harness ? (activeRuns.get(harness) ?? 0) : globalActiveRuns;
22
+ }
23
+
24
+ /** Active-run count combining the in-process counter with the cross-process registry (the max of
25
+ * the two — registry I/O failures fall back to the in-process view). */
26
+ export function activeCount(harness?: string): number {
27
+ return Math.max(inProcessActiveCount(harness), countActiveRuns(harness));
28
+ }
29
+
30
+ /** Thrown by `acquireSlot({wait: false})` when at capacity. */
31
+ export class ConcurrencyLimitError extends Error {}
32
+
33
+ export interface AcquireSlotOptions {
34
+ harness: string;
35
+ mode: string;
36
+ config: DelegateConfig;
37
+ /** false (default): throw immediately at capacity. true: poll until a slot frees. */
38
+ wait: boolean;
39
+ /** Aborts a `wait: true` poll early. */
40
+ signal?: AbortSignal;
41
+ pollIntervalMs?: number;
42
+ }
43
+
44
+ function abortError(): Error {
45
+ const err = new Error('aborted');
46
+ err.name = 'AbortError';
47
+ return err;
48
+ }
49
+
50
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
51
+ return new Promise((resolve, reject) => {
52
+ if (signal?.aborted) {
53
+ reject(abortError());
54
+ return;
55
+ }
56
+ const onAbort = () => {
57
+ clearTimeout(timer);
58
+ reject(abortError());
59
+ };
60
+ const timer = setTimeout(() => {
61
+ signal?.removeEventListener('abort', onAbort);
62
+ resolve();
63
+ }, ms);
64
+ signal?.addEventListener('abort', onAbort, { once: true });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Acquire a concurrency slot for one delegate() run. Resolves with a release function (idempotent,
70
+ * never throws) once a slot is held; the caller must call it exactly once when the run finishes.
71
+ *
72
+ * Checks the global limit before the per-harness limit — same precedence and error text as the
73
+ * original inline guard, so single-run (`wait: false`) callers see unchanged behavior.
74
+ */
75
+ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
76
+ const { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
77
+ for (;;) {
78
+ if (signal?.aborted) throw abortError();
79
+ const maxGlobal = getMaxConcurrent(config);
80
+ const globalCount = activeCount();
81
+ if (maxGlobal > 0 && globalCount >= maxGlobal) {
82
+ if (!wait) throw new ConcurrencyLimitError('another delegate run is already in progress (global limit)');
83
+ await sleep(pollIntervalMs, signal);
84
+ continue;
85
+ }
86
+ const perHarnessLimit = getMaxConcurrent(config, harness);
87
+ const perHarnessCount = activeCount(harness);
88
+ if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) {
89
+ if (!wait) throw new ConcurrencyLimitError(`another ${harness} run is already in progress`);
90
+ await sleep(pollIntervalMs, signal);
91
+ continue;
92
+ }
93
+
94
+ activeRuns.set(harness, perHarnessCount + 1);
95
+ globalActiveRuns++;
96
+ const runHandle = acquireRun(harness, mode);
97
+ let released = false;
98
+ return () => {
99
+ if (released) return;
100
+ released = true;
101
+ activeRuns.set(harness, Math.max(0, (activeRuns.get(harness) ?? 1) - 1));
102
+ globalActiveRuns = Math.max(0, globalActiveRuns - 1);
103
+ releaseRun(runHandle);
104
+ };
105
+ }
106
+ }
@@ -47,7 +47,7 @@ export function loadConfig(): DelegateConfig {
47
47
  inspectThinking: false,
48
48
  autoDelegateHints: false,
49
49
  modelAliases: { economy: 'haiku', balanced: 'sonnet', max: 'opus' },
50
- maxConcurrent: 1,
50
+ maxConcurrent: 4,
51
51
  maxTranscripts: 100,
52
52
  harnesses: {},
53
53
  };
@@ -1,4 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
2
4
  import { promisify } from 'node:util';
3
5
  import type {
4
6
  BuildArgsOpts,
@@ -9,14 +11,47 @@ import type {
9
11
  StreamedResult,
10
12
  } from './types.ts';
11
13
 
14
+ // Schema verified against omp/17.2.9 (the only binary of this harness installed on the capture
15
+ // machine — see tests/fixtures/amp.jsonl and AGENTS.md for what "amp" vs "omp" means here).
16
+
12
17
  const execFileAsync = promisify(execFile);
13
18
  function isRecord(v: unknown): v is Record<string, unknown> {
14
19
  return typeof v === 'object' && v !== null && !Array.isArray(v);
15
20
  }
21
+
22
+ /**
23
+ * `amp` isn't on PATH on machines that only have the `omp` alias binary installed — resolve
24
+ * which one actually exists once at module load, so the spawned binary matches what `detect()`
25
+ * already tolerates. Falls back to 'amp' (the documented default) when neither is found, so
26
+ * error messages still name the expected tool.
27
+ */
28
+ export function resolveAmpBinary(pathEnv: string | undefined, exists: (p: string) => boolean = pathExists): string {
29
+ const dirs = (pathEnv ?? '').split(delimiter).filter(Boolean);
30
+ for (const dir of dirs) {
31
+ if (exists(join(dir, 'amp'))) return 'amp';
32
+ }
33
+ for (const dir of dirs) {
34
+ if (exists(join(dir, 'omp'))) return 'omp';
35
+ }
36
+ return 'amp';
37
+ }
38
+
39
+ function pathExists(p: string): boolean {
40
+ try {
41
+ accessSync(p, constants.X_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ const RESOLVED_BINARY = resolveAmpBinary(process.env.PATH);
49
+
50
+ // approval-mode maps cleanly onto the normalized 3-tier permission model.
16
51
  const PERMISSION_MAP: Record<NormalizedPermission, string> = {
17
- readonly: 'read-only',
18
- edit: 'workspace',
19
- danger: 'danger',
52
+ readonly: 'always-ask',
53
+ edit: 'write',
54
+ danger: 'yolo',
20
55
  };
21
56
  function extractAmpText(o: Record<string, unknown>): string | undefined {
22
57
  if (typeof o.text === 'string') return o.text;
@@ -26,6 +61,23 @@ function extractAmpText(o: Record<string, unknown>): string | undefined {
26
61
  if (isRecord(o.part) && typeof o.part.text === 'string') return o.part.text;
27
62
  return undefined;
28
63
  }
64
+
65
+ interface AmpHarnessState {
66
+ sessionId?: string;
67
+ costAccum?: number;
68
+ inputAccum?: number;
69
+ outputAccum?: number;
70
+ cacheReadAccum?: number;
71
+ cacheWriteAccum?: number;
72
+ turnCount?: number;
73
+ }
74
+
75
+ function harnessState(state: ParseState): AmpHarnessState {
76
+ const s = (state._harness ?? {}) as AmpHarnessState;
77
+ state._harness = s as unknown as Record<string, unknown>;
78
+ return s;
79
+ }
80
+
29
81
  export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
30
82
  let o: unknown;
31
83
  try {
@@ -38,33 +90,30 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
38
90
  const activities: ParseOutcome['activities'] = [];
39
91
  let streamedText: string | undefined;
40
92
  const typeStr = typeof o.type === 'string' ? o.type : '';
93
+ const hs = harnessState(state);
41
94
  // latch session id
42
- if (typeStr === 'session' && typeof o.id === 'string') {
43
- (state as unknown as Record<string, unknown>)._harness = {
44
- ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
45
- sessionId: o.id,
46
- };
47
- }
48
- if (isRecord(o.part) && typeof (o.part as Record<string, unknown>).sessionID === 'string') {
49
- (state as unknown as Record<string, unknown>)._harness = {
50
- ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
51
- sessionId: (o.part as Record<string, unknown>).sessionID as string,
52
- };
53
- }
54
- if (typeStr === 'session' && typeof (o as Record<string, unknown>).sessionID === 'string') {
55
- (state as unknown as Record<string, unknown>)._harness = {
56
- ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
57
- sessionId: (o as Record<string, unknown>).sessionID as string,
58
- };
59
- }
60
- if (typeStr.includes('tool') || o.type === 'tool_use') {
61
- const name = typeof o.name === 'string' ? o.name : 'tool';
62
- if (typeStr.includes('start') || o.type === 'tool_use') {
95
+ if (typeStr === 'session' && typeof o.id === 'string') hs.sessionId = o.id;
96
+ if (isRecord(o.part) && typeof (o.part as Record<string, unknown>).sessionID === 'string')
97
+ hs.sessionId = (o.part as Record<string, unknown>).sessionID as string;
98
+ if (typeStr === 'session' && typeof (o as Record<string, unknown>).sessionID === 'string')
99
+ hs.sessionId = (o as Record<string, unknown>).sessionID as string;
100
+
101
+ // real tool schema: top-level tool_execution_start/tool_execution_end, correlated by toolCallId
102
+ if (typeStr === 'tool_execution_start' || typeStr === 'tool_execution_end') {
103
+ const name = typeof o.toolName === 'string' ? o.toolName : 'tool';
104
+ const id = typeof o.toolCallId === 'string' ? o.toolCallId : undefined;
105
+ if (typeStr === 'tool_execution_start') {
63
106
  activities.push({ kind: 'tool_start', name });
64
- if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
65
- } else activities.push({ kind: 'tool_result', isError: o.is_error === true });
107
+ activities.push({
108
+ kind: 'tool_input',
109
+ name,
110
+ input: isRecord(o.args) ? (o.args as Record<string, unknown>) : {},
111
+ id,
112
+ });
113
+ } else {
114
+ activities.push({ kind: 'tool_result', isError: o.isError === true, id });
115
+ }
66
116
  }
67
- if (typeStr.includes('thinking')) activities.push({ kind: 'thinking', chars: 10 });
68
117
  if (typeStr === 'message_update' && isRecord(o.assistantMessageEvent)) {
69
118
  const ev = o.assistantMessageEvent as Record<string, unknown>;
70
119
  if (ev.type === 'thinking_delta' && typeof ev.delta === 'string')
@@ -73,7 +122,11 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
73
122
  else if (ev.type === 'thinking_start') activities.push({ kind: 'thinking', chars: 5 });
74
123
  }
75
124
  if (typeStr === 'turn_end' || typeStr === 'agent_end') {
76
- const msg = isRecord(o.message) ? o.message : null;
125
+ const msg = isRecord(o.message)
126
+ ? (o.message as Record<string, unknown>)
127
+ : Array.isArray(o.messages) && isRecord(o.messages[o.messages.length - 1])
128
+ ? (o.messages[o.messages.length - 1] as Record<string, unknown>)
129
+ : null;
77
130
  if (msg && Array.isArray(msg.content)) {
78
131
  for (const block of msg.content as unknown[]) {
79
132
  if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') streamedText = block.text;
@@ -81,58 +134,31 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
81
134
  activities.push({ kind: 'thinking', chars: (block.thinking as string).length });
82
135
  }
83
136
  }
84
- if (Array.isArray(o.messages)) {
85
- const last = o.messages[o.messages.length - 1] as unknown;
86
- if (isRecord(last) && Array.isArray(last.content)) {
87
- for (const block of last.content as unknown[]) {
88
- if (isRecord(block) && block.type === 'text' && typeof block.text === 'string' && !streamedText)
89
- streamedText = block.text as string;
90
- }
91
- }
137
+ // real usage lives at message.usage on turn_end (per-turn, not cumulative — see accumulation below)
138
+ if (typeStr === 'turn_end' && isRecord(msg?.usage)) {
139
+ const u = msg.usage as Record<string, unknown>;
140
+ const cost =
141
+ isRecord(u.cost) && typeof (u.cost as Record<string, unknown>).total === 'number'
142
+ ? ((u.cost as Record<string, unknown>).total as number)
143
+ : 0;
144
+ hs.costAccum = (hs.costAccum ?? 0) + cost;
145
+ hs.inputAccum = (hs.inputAccum ?? 0) + (typeof u.input === 'number' ? u.input : 0);
146
+ hs.outputAccum = (hs.outputAccum ?? 0) + (typeof u.output === 'number' ? u.output : 0);
147
+ hs.cacheReadAccum = (hs.cacheReadAccum ?? 0) + (typeof u.cacheRead === 'number' ? u.cacheRead : 0);
148
+ hs.cacheWriteAccum = (hs.cacheWriteAccum ?? 0) + (typeof u.cacheWrite === 'number' ? u.cacheWrite : 0);
149
+ hs.turnCount = (hs.turnCount ?? 0) + 1;
92
150
  }
93
151
  }
94
152
  const text = extractAmpText(o);
95
- if (text && !typeStr.includes('tool') && !streamedText) streamedText = text;
96
- if (
97
- o.type === 'result' ||
98
- o.type === 'done' ||
99
- o.type === 'completed' ||
100
- typeStr === 'turn_end' ||
101
- typeStr === 'agent_end'
102
- ) {
103
- const usage = isRecord(o.usage)
104
- ? o.usage
105
- : isRecord(o.message) && isRecord((o.message as Record<string, unknown>).usage)
106
- ? ((o.message as Record<string, unknown>).usage as Record<string, unknown>)
107
- : null;
108
- const actualUsage = isRecord(usage)
109
- ? usage
110
- : isRecord(o.message) && isRecord((o.message as Record<string, unknown>).usage)
111
- ? ((o.message as Record<string, unknown>).usage as Record<string, unknown>)
153
+ if (text && typeStr !== 'tool_execution_start' && typeStr !== 'tool_execution_end' && !streamedText)
154
+ streamedText = text;
155
+ if (typeStr === 'turn_end' || typeStr === 'agent_end') {
156
+ const msg = isRecord(o.message)
157
+ ? (o.message as Record<string, unknown>)
158
+ : Array.isArray(o.messages) && isRecord(o.messages[o.messages.length - 1])
159
+ ? (o.messages[o.messages.length - 1] as Record<string, unknown>)
112
160
  : null;
113
- const msg = isRecord(o.message) ? o.message : null;
114
- const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
115
- ?.sessionId as string | undefined;
116
- const cost =
117
- isRecord(actualUsage) && typeof (actualUsage as Record<string, unknown>).total === 'number'
118
- ? ((actualUsage as Record<string, unknown>).total as number)
119
- : typeof o.total_cost_usd === 'number'
120
- ? o.total_cost_usd
121
- : 0;
122
- const inputTokens = isRecord(actualUsage)
123
- ? typeof (actualUsage as Record<string, unknown>).input === 'number'
124
- ? ((actualUsage as Record<string, unknown>).input as number)
125
- : typeof (actualUsage as Record<string, unknown>).input_tokens === 'number'
126
- ? ((actualUsage as Record<string, unknown>).input_tokens as number)
127
- : 0
128
- : 0;
129
- const outputTokens = isRecord(actualUsage)
130
- ? typeof (actualUsage as Record<string, unknown>).output === 'number'
131
- ? ((actualUsage as Record<string, unknown>).output as number)
132
- : typeof (actualUsage as Record<string, unknown>).output_tokens === 'number'
133
- ? ((actualUsage as Record<string, unknown>).output_tokens as number)
134
- : 0
135
- : 0;
161
+ const measured = (hs.turnCount ?? 0) > 0;
136
162
  const result: StreamedResult = {
137
163
  result:
138
164
  typeof o.result === 'string'
@@ -141,9 +167,10 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
141
167
  ? state.streamedText + streamedText
142
168
  : state.streamedText || (text ?? ''),
143
169
  isError: o.is_error === true,
144
- numTurns: 1,
145
- totalCostUsd: typeof cost === 'number' ? cost : 0,
146
- sessionId: typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : (latched ?? null),
170
+ numTurns: measured ? (hs.turnCount as number) : null,
171
+ totalCostUsd: measured ? (hs.costAccum as number) : null,
172
+ sessionId:
173
+ typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : (hs.sessionId ?? null),
147
174
  stopReason:
148
175
  typeof o.stop_reason === 'string'
149
176
  ? o.stop_reason
@@ -165,18 +192,14 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
165
192
  model: typeof o.model === 'string' ? o.model : typeof msg?.model === 'string' ? (msg.model as string) : null,
166
193
  contextWindow: null,
167
194
  maxOutputTokens: null,
168
- usage:
169
- actualUsage && (inputTokens || outputTokens)
170
- ? {
171
- inputTokens,
172
- outputTokens,
173
- cacheCreationInputTokens: 0,
174
- cacheReadInputTokens:
175
- typeof (actualUsage as Record<string, unknown>).cacheRead === 'number'
176
- ? ((actualUsage as Record<string, unknown>).cacheRead as number)
177
- : 0,
178
- }
179
- : null,
195
+ usage: measured
196
+ ? {
197
+ inputTokens: hs.inputAccum ?? 0,
198
+ outputTokens: hs.outputAccum ?? 0,
199
+ cacheCreationInputTokens: hs.cacheWriteAccum ?? 0,
200
+ cacheReadInputTokens: hs.cacheReadAccum ?? 0,
201
+ }
202
+ : null,
180
203
  };
181
204
  if (!result.result) result.result = state.streamedText + (streamedText ?? '');
182
205
  return { activities, streamedText, result };
@@ -186,7 +209,7 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
186
209
  export const ampHarness: Harness = {
187
210
  name: 'amp',
188
211
  displayName: 'Amp',
189
- binary: 'amp',
212
+ binary: RESOLVED_BINARY,
190
213
  async detect() {
191
214
  try {
192
215
  const { stdout } = await execFileAsync('amp', ['--version'], { timeout: 5000 });
@@ -201,11 +224,12 @@ export const ampHarness: Harness = {
201
224
  }
202
225
  },
203
226
  buildArgs(opts: BuildArgsOpts): string[] {
204
- const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'workspace';
205
- const args = ['--output', 'jsonl', opts.prompt, '--permission', perm];
227
+ const approvalMode = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'always-ask';
228
+ const args = ['-p', '--mode', 'json', '--approval-mode', approvalMode];
206
229
  if (opts.model) args.push('--model', opts.model);
207
230
  if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
208
231
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
232
+ args.push(opts.prompt);
209
233
  return args;
210
234
  },
211
235
  parseLine(line: string, state: ParseState): ParseOutcome {
@@ -214,13 +238,12 @@ export const ampHarness: Harness = {
214
238
  extractResult(state: ParseState): StreamedResult | null {
215
239
  if (state.result) return state.result;
216
240
  if (state.streamedText.trim().length > 0) {
217
- const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
218
- ?.sessionId as string | undefined;
241
+ const latched = (state._harness as AmpHarnessState | undefined)?.sessionId;
219
242
  return {
220
243
  result: state.streamedText,
221
244
  isError: false,
222
- numTurns: 1,
223
- totalCostUsd: 0,
245
+ numTurns: null,
246
+ totalCostUsd: null,
224
247
  sessionId: latched ?? null,
225
248
  stopReason: null,
226
249
  permissionDenials: [],
@@ -235,5 +258,5 @@ export const ampHarness: Harness = {
235
258
  }
236
259
  return null;
237
260
  },
238
- permissionMap: { readonly: ['read-only'], edit: ['workspace'], danger: ['danger'] },
261
+ permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
239
262
  };
@@ -56,13 +56,18 @@ export function parseClaudeLine(line: string, state: ParseState): ParseOutcome {
56
56
  kind: 'tool_input',
57
57
  name: block.name,
58
58
  input: isRecord(block.input) ? block.input : {},
59
+ id: typeof block.id === 'string' ? block.id : undefined,
59
60
  });
60
61
  }
61
62
  }
62
63
  } else if (o.type === 'user' && isRecord(o.message)) {
63
64
  for (const block of Array.isArray(o.message.content) ? o.message.content : []) {
64
65
  if (isRecord(block) && block.type === 'tool_result') {
65
- activities.push({ kind: 'tool_result', isError: block.is_error === true });
66
+ activities.push({
67
+ kind: 'tool_result',
68
+ isError: block.is_error === true,
69
+ id: typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined,
70
+ });
66
71
  }
67
72
  }
68
73
  } else if (o.type === 'result') {
@@ -82,8 +87,8 @@ export function parseClaudeLine(line: string, state: ParseState): ParseOutcome {
82
87
  const result: StreamedResult = {
83
88
  result: typeof o.result === 'string' ? o.result : state.streamedText,
84
89
  isError: o.is_error === true,
85
- numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
86
- totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
90
+ numTurns: typeof o.num_turns === 'number' ? o.num_turns : null,
91
+ totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : null,
87
92
  sessionId: typeof o.session_id === 'string' ? o.session_id : null,
88
93
  stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
89
94
  permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],