pi-harness-delegate 0.2.0 → 0.2.2

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.
@@ -61,16 +61,17 @@ export function loadConfig(): DelegateConfig {
61
61
  // Legacy claudeDelegate -> delegate.harnesses.claude migration
62
62
  if (settings.claudeDelegate && !settings.delegate) {
63
63
  const c = settings.claudeDelegate as Partial<DelegateConfig>;
64
- if (typeof c.model === 'string') cfg.harnesses['claude'] = { ...(cfg.harnesses['claude'] ?? {}), model: c.model };
64
+ if (typeof c.model === 'string') cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
65
65
  if (typeof c.timeoutMs === 'number' && c.timeoutMs > 0) cfg.timeoutMs = c.timeoutMs;
66
66
  if (typeof c.defaultMode === 'string') cfg.defaultMode = c.defaultMode;
67
67
  if (typeof c.allowDangerous === 'boolean') cfg.allowDangerous = c.allowDangerous;
68
68
  if (typeof c.inspectThinking === 'boolean') cfg.inspectThinking = c.inspectThinking;
69
- if (typeof (c as DelegateConfig).maxBudgetUsd === 'number' && (c as DelegateConfig).maxBudgetUsd! > 0)
70
- cfg.maxBudgetUsd = (c as DelegateConfig).maxBudgetUsd;
69
+ const maxBudgetLegacy = (c as DelegateConfig).maxBudgetUsd;
70
+ if (typeof maxBudgetLegacy === 'number' && maxBudgetLegacy > 0) cfg.maxBudgetUsd = maxBudgetLegacy;
71
71
  if (typeof c.autoDelegateHints === 'boolean') cfg.autoDelegateHints = c.autoDelegateHints;
72
- if ((c as DelegateConfig).modelAliases && typeof (c as DelegateConfig).modelAliases === 'object') {
73
- for (const [k, v] of Object.entries((c as DelegateConfig).modelAliases!)) {
72
+ const legacyAliases = (c as DelegateConfig).modelAliases;
73
+ if (legacyAliases && typeof legacyAliases === 'object') {
74
+ for (const [k, v] of Object.entries(legacyAliases)) {
74
75
  if (typeof v === 'string' && v) cfg.modelAliases[k] = v;
75
76
  }
76
77
  }
@@ -134,8 +135,8 @@ export function loadConfig(): DelegateConfig {
134
135
  // also support legacy claudeDelegate merged when delegate also present (delegate wins)
135
136
  if (settings.claudeDelegate) {
136
137
  const c = settings.claudeDelegate as Partial<DelegateConfig>;
137
- if (typeof c.model === 'string' && !cfg.harnesses['claude']?.model) {
138
- cfg.harnesses['claude'] = { ...(cfg.harnesses['claude'] ?? {}), model: c.model };
138
+ if (typeof c.model === 'string' && !cfg.harnesses.claude?.model) {
139
+ cfg.harnesses.claude = { ...(cfg.harnesses.claude ?? {}), model: c.model };
139
140
  }
140
141
  }
141
142
  } catch {
@@ -158,7 +159,10 @@ export function getMaxConcurrent(cfg: DelegateConfig, harness?: string): number
158
159
  if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
159
160
  // if object shape {global, perHarness}
160
161
  const mc = cfg.maxConcurrent as unknown as { global?: number; perHarness?: Record<string, number> };
161
- if (harness && mc.perHarness && typeof mc.perHarness[harness] === 'number') return mc.perHarness[harness]!;
162
+ if (harness && mc.perHarness && typeof mc.perHarness[harness] === 'number') {
163
+ const v = mc.perHarness[harness];
164
+ if (typeof v === 'number') return v;
165
+ }
162
166
  if (typeof mc.global === 'number') return mc.global;
163
167
  return 1;
164
168
  }
@@ -10,82 +10,179 @@ import type {
10
10
  } from './types.ts';
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
-
14
13
  function isRecord(v: unknown): v is Record<string, unknown> {
15
14
  return typeof v === 'object' && v !== null && !Array.isArray(v);
16
15
  }
17
-
18
16
  const PERMISSION_MAP: Record<NormalizedPermission, string> = {
19
17
  readonly: 'read-only',
20
18
  edit: 'workspace',
21
19
  danger: 'danger',
22
20
  };
23
-
24
21
  function extractAmpText(o: Record<string, unknown>): string | undefined {
25
22
  if (typeof o.text === 'string') return o.text;
26
23
  if (typeof o.output === 'string') return o.output;
27
24
  if (typeof o.delta === 'string') return o.delta;
28
25
  if (typeof o.content === 'string') return o.content;
26
+ if (isRecord(o.part) && typeof o.part.text === 'string') return o.part.text;
29
27
  return undefined;
30
28
  }
31
-
32
29
  export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
33
30
  let o: unknown;
34
31
  try {
35
32
  o = JSON.parse(line);
36
33
  } catch {
37
- if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
34
+ if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
38
35
  return { activities: [] };
39
36
  }
40
37
  if (!isRecord(o)) return { activities: [] };
41
38
  const activities: ParseOutcome['activities'] = [];
42
39
  let streamedText: string | undefined;
43
40
  const typeStr = typeof o.type === 'string' ? o.type : '';
44
-
41
+ // 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
+ }
45
60
  if (typeStr.includes('tool') || o.type === 'tool_use') {
46
61
  const name = typeof o.name === 'string' ? o.name : 'tool';
47
62
  if (typeStr.includes('start') || o.type === 'tool_use') {
48
63
  activities.push({ kind: 'tool_start', name });
49
64
  if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
50
- } else {
51
- activities.push({ kind: 'tool_result', isError: o.is_error === true });
52
- }
65
+ } else activities.push({ kind: 'tool_result', isError: o.is_error === true });
53
66
  }
54
67
  if (typeStr.includes('thinking')) activities.push({ kind: 'thinking', chars: 10 });
55
-
68
+ if (typeStr === 'message_update' && isRecord(o.assistantMessageEvent)) {
69
+ const ev = o.assistantMessageEvent as Record<string, unknown>;
70
+ if (ev.type === 'thinking_delta' && typeof ev.delta === 'string')
71
+ activities.push({ kind: 'thinking', chars: ev.delta.length });
72
+ else if (ev.type === 'text_delta' && typeof ev.delta === 'string') streamedText = ev.delta;
73
+ else if (ev.type === 'thinking_start') activities.push({ kind: 'thinking', chars: 5 });
74
+ }
75
+ if (typeStr === 'turn_end' || typeStr === 'agent_end') {
76
+ const msg = isRecord(o.message) ? o.message : null;
77
+ if (msg && Array.isArray(msg.content)) {
78
+ for (const block of msg.content as unknown[]) {
79
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') streamedText = block.text;
80
+ if (isRecord(block) && block.type === 'thinking' && typeof block.thinking === 'string')
81
+ activities.push({ kind: 'thinking', chars: (block.thinking as string).length });
82
+ }
83
+ }
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
+ }
92
+ }
93
+ }
56
94
  const text = extractAmpText(o);
57
- if (text && !typeStr.includes('tool')) streamedText = text;
58
-
59
- if (o.type === 'result' || o.type === 'done' || o.type === 'completed') {
60
- const usage = isRecord(o.usage) ? o.usage : null;
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>)
112
+ : 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;
61
136
  const result: StreamedResult = {
62
- result: typeof o.result === 'string' ? o.result : state.streamedText + (streamedText ?? ''),
137
+ result:
138
+ typeof o.result === 'string'
139
+ ? o.result
140
+ : streamedText
141
+ ? state.streamedText + streamedText
142
+ : state.streamedText || (text ?? ''),
63
143
  isError: o.is_error === true,
64
- numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
65
- totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
66
- sessionId: typeof o.session_id === 'string' ? o.session_id : null,
67
- stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
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),
147
+ stopReason:
148
+ typeof o.stop_reason === 'string'
149
+ ? o.stop_reason
150
+ : typeof msg?.stopReason === 'string'
151
+ ? (msg.stopReason as string)
152
+ : null,
68
153
  permissionDenials: [],
69
- durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
154
+ durationMs:
155
+ typeof o.duration_ms === 'number'
156
+ ? o.duration_ms
157
+ : typeof (o as Record<string, unknown>).duration === 'number'
158
+ ? ((o as Record<string, unknown>).duration as number)
159
+ : null,
70
160
  durationApiMs: null,
71
- ttftMs: null,
72
- model: typeof o.model === 'string' ? o.model : null,
161
+ ttftMs:
162
+ typeof (o as Record<string, unknown>).ttft === 'number'
163
+ ? ((o as Record<string, unknown>).ttft as number)
164
+ : null,
165
+ model: typeof o.model === 'string' ? o.model : typeof msg?.model === 'string' ? (msg.model as string) : null,
73
166
  contextWindow: null,
74
167
  maxOutputTokens: null,
75
- usage: usage
76
- ? {
77
- inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
78
- outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
79
- cacheCreationInputTokens: 0,
80
- cacheReadInputTokens: 0,
81
- }
82
- : 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,
83
180
  };
181
+ if (!result.result) result.result = state.streamedText + (streamedText ?? '');
84
182
  return { activities, streamedText, result };
85
183
  }
86
184
  return { activities, streamedText };
87
185
  }
88
-
89
186
  export const ampHarness: Harness = {
90
187
  name: 'amp',
91
188
  displayName: 'Amp',
@@ -117,12 +214,14 @@ export const ampHarness: Harness = {
117
214
  extractResult(state: ParseState): StreamedResult | null {
118
215
  if (state.result) return state.result;
119
216
  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;
120
219
  return {
121
220
  result: state.streamedText,
122
221
  isError: false,
123
222
  numTurns: 1,
124
223
  totalCostUsd: 0,
125
- sessionId: null,
224
+ sessionId: latched ?? null,
126
225
  stopReason: null,
127
226
  permissionDenials: [],
128
227
  durationMs: null,
@@ -136,9 +235,5 @@ export const ampHarness: Harness = {
136
235
  }
137
236
  return null;
138
237
  },
139
- permissionMap: {
140
- readonly: ['read-only'],
141
- edit: ['workspace'],
142
- danger: ['danger'],
143
- },
238
+ permissionMap: { readonly: ['read-only'], edit: ['workspace'], danger: ['danger'] },
144
239
  };
@@ -10,23 +10,15 @@ import type {
10
10
  } from './types.ts';
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
-
14
13
  function isRecord(v: unknown): v is Record<string, unknown> {
15
14
  return typeof v === 'object' && v !== null && !Array.isArray(v);
16
15
  }
17
-
18
16
  const SANDBOX_MAP: Record<NormalizedPermission, string> = {
19
17
  readonly: 'read-only',
20
18
  edit: 'workspace-write',
21
19
  danger: 'danger-full-access',
22
20
  };
23
-
24
- function extractTextFromCodexEvent(o: Record<string, unknown>, state: ParseState): string | undefined {
25
- // Codex JSON variants: try common shapes
26
- // 1. {type:"item.completed", item:{type:"agent_message", text:"..."}}
27
- // 2. {type:"thread.item.completed", item:{type:"agent_message", ...}}
28
- // 3. {type:"event", event:{type:"response.output_text.delta", delta:"..."}}
29
- // 4. Plain {"text":"..."} or {"output":"..."}
21
+ function extractTextFromCodexEvent(o: Record<string, unknown>, _state: ParseState): string | undefined {
30
22
  if (typeof o.text === 'string' && o.type !== 'tool_use') return o.text;
31
23
  if (typeof o.output === 'string') return o.output;
32
24
  if (typeof o.delta === 'string') return o.delta;
@@ -35,31 +27,66 @@ function extractTextFromCodexEvent(o: Record<string, unknown>, state: ParseState
35
27
  return (o.event as Record<string, unknown>).delta as string;
36
28
  if (isRecord(o.item) && typeof o.item.text === 'string') return o.item.text;
37
29
  if (isRecord(o.item) && typeof o.item.output === 'string') return o.item.output;
38
- // agent_message
39
30
  if (o.type === 'agent_message' && typeof o.text === 'string') return o.text;
31
+ if (o.type === 'error' && typeof o.message === 'string') return o.message;
32
+ if (typeof o.error === 'string') return o.error;
40
33
  return undefined;
41
34
  }
42
-
43
35
  export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
44
36
  let o: unknown;
45
37
  try {
46
38
  o = JSON.parse(line);
47
39
  } catch {
48
- // Non-JSON line: treat as streamed text (codex may emit plain text)
49
- if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
40
+ if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
50
41
  return { activities: [] };
51
42
  }
52
43
  if (!isRecord(o)) return { activities: [] };
53
-
54
44
  const activities: ParseOutcome['activities'] = [];
55
45
  let streamedText: string | undefined;
56
-
57
- // Tool activity heuristics
58
- // Codex may emit: {type:"item.started", item:{type:"tool_use", name:"...", input:{}}}
59
- // or {type:"tool_use", name:"..."}
60
- const item = isRecord(o.item) ? o.item : null;
61
46
  const typeStr = typeof o.type === 'string' ? o.type : '';
62
-
47
+ const item = isRecord(o.item) ? o.item : null;
48
+ // latch thread_id from thread.started
49
+ if (typeStr === 'thread.started' && typeof o.thread_id === 'string') {
50
+ (state as unknown as Record<string, unknown>)._harness = {
51
+ ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
52
+ sessionId: o.thread_id,
53
+ };
54
+ return { activities, streamedText };
55
+ }
56
+ if (typeStr === 'turn.started') return { activities, streamedText };
57
+ if (typeStr === 'error' && typeof o.message === 'string') {
58
+ streamedText = o.message;
59
+ }
60
+ if (typeof o.error === 'string' && !streamedText) streamedText = o.error;
61
+ if (typeStr === 'turn.failed') {
62
+ const msg =
63
+ isRecord(o.error) && typeof o.error.message === 'string'
64
+ ? o.error.message
65
+ : typeof o.error === 'string'
66
+ ? o.error
67
+ : typeof o.message === 'string'
68
+ ? o.message
69
+ : state.streamedText + (streamedText ?? '');
70
+ const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
71
+ ?.sessionId as string | undefined;
72
+ const result: StreamedResult = {
73
+ result: msg,
74
+ isError: true,
75
+ numTurns: 1,
76
+ totalCostUsd: 0,
77
+ sessionId: typeof o.thread_id === 'string' ? o.thread_id : (latched ?? null),
78
+ stopReason: 'error',
79
+ permissionDenials: [],
80
+ durationMs: null,
81
+ durationApiMs: null,
82
+ ttftMs: null,
83
+ model: null,
84
+ contextWindow: null,
85
+ maxOutputTokens: null,
86
+ usage: null,
87
+ };
88
+ return { activities, streamedText, result };
89
+ }
63
90
  if (typeStr.includes('tool') || typeStr.includes('item.started') || typeStr.includes('function_call')) {
64
91
  const toolName = (item && typeof item.name === 'string' ? item.name : typeof o.name === 'string' ? o.name : null) as
65
92
  | string
@@ -68,38 +95,33 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
68
95
  if (typeStr.includes('started') || o.type === 'tool_use') {
69
96
  activities.push({ kind: 'tool_start', name: toolName });
70
97
  if (item && isRecord(item.input))
71
- activities.push({
72
- kind: 'tool_input',
73
- name: toolName,
74
- input: item.input as Record<string, unknown>,
75
- });
98
+ activities.push({ kind: 'tool_input', name: toolName, input: item.input as Record<string, unknown> });
76
99
  else if (isRecord(o.input))
77
100
  activities.push({ kind: 'tool_input', name: toolName, input: o.input as Record<string, unknown> });
78
- } else if (typeStr.includes('completed') || typeStr.includes('result')) {
101
+ } else if (typeStr.includes('completed') || typeStr.includes('result'))
79
102
  activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
80
- }
81
103
  }
82
104
  }
83
- if (o.type === 'tool_result' || (typeStr === 'item.completed' && item?.type === 'tool_result')) {
105
+ if (o.type === 'tool_result' || (typeStr === 'item.completed' && item?.type === 'tool_result'))
84
106
  activities.push({ kind: 'tool_result', isError: (o as Record<string, unknown>).is_error === true });
85
- }
86
107
  if (typeStr.includes('thinking') || o.type === 'reasoning' || item?.type === 'reasoning') {
87
108
  const thinkingText = extractTextFromCodexEvent(o, state);
88
109
  if (thinkingText) activities.push({ kind: 'thinking', chars: thinkingText.length });
89
110
  else activities.push({ kind: 'thinking', chars: 10 });
90
111
  }
91
-
92
- // Text extraction
93
112
  const text = extractTextFromCodexEvent(o, state);
94
- if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) {
95
- // Avoid double-counting tool inputs as text
113
+ if (
114
+ text &&
115
+ !typeStr.includes('tool') &&
116
+ !typeStr.includes('thinking') &&
117
+ typeStr !== 'error' &&
118
+ typeStr !== 'turn.failed'
119
+ )
96
120
  streamedText = text;
97
- }
98
-
99
- // Result detection: final result often {type:"result", result:"...", total_cost_usd, usage, ...}
100
- // or {type:"thread.completed", ...}
101
121
  if (o.type === 'result' || o.type === 'thread.completed' || o.type === 'task.completed') {
102
122
  const usage = isRecord(o.usage) ? o.usage : null;
123
+ const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
124
+ ?.sessionId as string | undefined;
103
125
  const result: StreamedResult = {
104
126
  result:
105
127
  typeof o.result === 'string'
@@ -117,7 +139,7 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
117
139
  ? o.thread_id
118
140
  : typeof o.id === 'string'
119
141
  ? o.id
120
- : null,
142
+ : (latched ?? null),
121
143
  stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
122
144
  permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],
123
145
  durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
@@ -153,10 +175,8 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
153
175
  };
154
176
  return { activities, streamedText, result };
155
177
  }
156
-
157
178
  return { activities, streamedText };
158
179
  }
159
-
160
180
  export const codexHarness: Harness = {
161
181
  name: 'codex',
162
182
  displayName: 'Muse',
@@ -170,7 +190,6 @@ export const codexHarness: Harness = {
170
190
  }
171
191
  },
172
192
  buildArgs(opts: BuildArgsOpts): string[] {
173
- // codex exec --json <prompt> --sandbox <level> --ask-for-approval <level>
174
193
  const sandbox = opts.nativePermission ?? SANDBOX_MAP[opts.permission] ?? 'workspace-write';
175
194
  const args = ['exec', '--json', opts.prompt, '--sandbox', sandbox];
176
195
  if (opts.permission === 'danger' || sandbox === 'danger-full-access') args.push('--ask-for-approval', 'never');
@@ -178,7 +197,6 @@ export const codexHarness: Harness = {
178
197
  else args.push('--ask-for-approval', 'on-request');
179
198
  if (opts.model) args.push('--model', opts.model);
180
199
  if (opts.resumeSessionId) args.push('--thread-id', opts.resumeSessionId);
181
- // maxBudgetUsd not natively supported; pass as env hint via --config if needed
182
200
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
183
201
  return args;
184
202
  },
@@ -187,14 +205,15 @@ export const codexHarness: Harness = {
187
205
  },
188
206
  extractResult(state: ParseState): StreamedResult | null {
189
207
  if (state.result) return state.result;
190
- // Fallback: if no explicit result, synthesize from streamed text if any
191
208
  if (state.streamedText.trim().length > 0) {
209
+ const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
210
+ ?.sessionId as string | undefined;
192
211
  return {
193
212
  result: state.streamedText,
194
213
  isError: false,
195
214
  numTurns: 1,
196
215
  totalCostUsd: 0,
197
- sessionId: null,
216
+ sessionId: latched ?? null,
198
217
  stopReason: null,
199
218
  permissionDenials: [],
200
219
  durationMs: null,
@@ -208,9 +227,5 @@ export const codexHarness: Harness = {
208
227
  }
209
228
  return null;
210
229
  },
211
- permissionMap: {
212
- readonly: ['read-only'],
213
- edit: ['workspace-write'],
214
- danger: ['danger-full-access'],
215
- },
230
+ permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
216
231
  };
@@ -10,40 +10,53 @@ import type {
10
10
  } from './types.ts';
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
-
14
13
  function isRecord(v: unknown): v is Record<string, unknown> {
15
14
  return typeof v === 'object' && v !== null && !Array.isArray(v);
16
15
  }
17
-
18
16
  const PERMISSION_MAP: Record<NormalizedPermission, string> = {
19
17
  readonly: 'read-only',
20
18
  edit: 'allow-edit',
21
19
  danger: 'danger',
22
20
  };
23
-
24
21
  function extractOpencodeText(o: Record<string, unknown>): string | undefined {
25
22
  if (typeof o.text === 'string') return o.text;
26
23
  if (typeof o.output === 'string') return o.output;
27
24
  if (typeof o.delta === 'string') return o.delta;
28
25
  if (typeof o.content === 'string') return o.content;
26
+ if (isRecord(o.part) && typeof o.part.text === 'string') return o.part.text;
29
27
  if (isRecord(o.event) && typeof o.event.text === 'string') return o.event.text;
30
28
  if (isRecord(o.message) && typeof o.message.text === 'string') return o.message.text;
31
29
  return undefined;
32
30
  }
33
-
34
31
  export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome {
35
32
  let o: unknown;
36
33
  try {
37
34
  o = JSON.parse(line);
38
35
  } catch {
39
- if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
36
+ if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
40
37
  return { activities: [] };
41
38
  }
42
39
  if (!isRecord(o)) return { activities: [] };
43
40
  const activities: ParseOutcome['activities'] = [];
44
41
  let streamedText: string | undefined;
45
42
  const typeStr = typeof o.type === 'string' ? o.type : '';
46
-
43
+ // latch sessionID from step_start
44
+ if (typeStr === 'step_start' && typeof o.sessionID === 'string') {
45
+ (state as unknown as Record<string, unknown>)._harness = {
46
+ ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
47
+ sessionId: o.sessionID,
48
+ };
49
+ }
50
+ if (
51
+ isRecord(o.part) &&
52
+ typeof (o.part as Record<string, unknown>).sessionID === 'string' &&
53
+ !((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)?.sessionId
54
+ ) {
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.part as Record<string, unknown>).sessionID as string,
58
+ };
59
+ }
47
60
  if (typeStr.includes('tool') || o.type === 'tool_use' || o.type === 'tool_result') {
48
61
  const name =
49
62
  typeof o.name === 'string'
@@ -54,19 +67,59 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
54
67
  if (typeStr.includes('start') || o.type === 'tool_use') {
55
68
  activities.push({ kind: 'tool_start', name });
56
69
  if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
57
- } else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result') {
70
+ } else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result')
58
71
  activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
59
- }
60
72
  }
61
- if (typeStr.includes('thinking') || o.type === 'reasoning') {
62
- activities.push({ kind: 'thinking', chars: 10 });
63
- }
64
-
65
- const text = extractOpencodeText(o);
66
- if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) streamedText = text;
67
-
68
- if (o.type === 'result' || o.type === 'completed' || o.type === 'done') {
69
- const usage = isRecord(o.usage) ? o.usage : null;
73
+ if (typeStr.includes('thinking') || o.type === 'reasoning') activities.push({ kind: 'thinking', chars: 10 });
74
+ const directText = extractOpencodeText(o);
75
+ if (directText && !typeStr.includes('tool') && !typeStr.includes('thinking')) streamedText = directText;
76
+ if (o.type === 'text' && isRecord(o.part) && typeof o.part.text === 'string') streamedText = o.part.text;
77
+ if (o.type === 'result' || o.type === 'completed' || o.type === 'done' || o.type === 'step_finish') {
78
+ const part = isRecord(o.part) ? (o.part as Record<string, unknown>) : null;
79
+ const usageRaw = isRecord(o.usage)
80
+ ? o.usage
81
+ : part && isRecord((part as Record<string, unknown>).tokens)
82
+ ? ((part as Record<string, unknown>).tokens as Record<string, unknown>)
83
+ : isRecord(o.part) && isRecord((o.part as Record<string, unknown>).tokens)
84
+ ? ((o.part as Record<string, unknown>).tokens as Record<string, unknown>)
85
+ : null;
86
+ const usage = isRecord(usageRaw) ? usageRaw : null;
87
+ const tokensInput = isRecord(usage)
88
+ ? typeof usage.input === 'number'
89
+ ? usage.input
90
+ : typeof usage.input_tokens === 'number'
91
+ ? usage.input_tokens
92
+ : 0
93
+ : 0;
94
+ const tokensOutput = isRecord(usage)
95
+ ? typeof usage.output === 'number'
96
+ ? usage.output
97
+ : typeof usage.output_tokens === 'number'
98
+ ? usage.output_tokens
99
+ : 0
100
+ : 0;
101
+ const cost =
102
+ typeof o.total_cost_usd === 'number'
103
+ ? o.total_cost_usd
104
+ : part && typeof (part as Record<string, unknown>).cost === 'number'
105
+ ? ((part as Record<string, unknown>).cost as number)
106
+ : typeof o.cost === 'number'
107
+ ? (o.cost as number)
108
+ : 0;
109
+ const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
110
+ ?.sessionId as string | undefined;
111
+ const sessionId =
112
+ typeof o.session_id === 'string'
113
+ ? o.session_id
114
+ : typeof o.sessionID === 'string'
115
+ ? o.sessionID
116
+ : typeof (o as Record<string, unknown>).sessionID === 'string'
117
+ ? ((o as Record<string, unknown>).sessionID as string)
118
+ : part && typeof (part as Record<string, unknown>).sessionID === 'string'
119
+ ? ((part as Record<string, unknown>).sessionID as string)
120
+ : typeof o.id === 'string'
121
+ ? o.id
122
+ : (latched ?? null);
70
123
  const result: StreamedResult = {
71
124
  result:
72
125
  typeof o.result === 'string'
@@ -76,9 +129,14 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
76
129
  : state.streamedText + (streamedText ?? ''),
77
130
  isError: o.is_error === true,
78
131
  numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
79
- totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
80
- sessionId: typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : null,
81
- stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
132
+ totalCostUsd: cost,
133
+ sessionId,
134
+ stopReason:
135
+ typeof o.stop_reason === 'string'
136
+ ? o.stop_reason
137
+ : part && typeof (part as Record<string, unknown>).reason === 'string'
138
+ ? ((part as Record<string, unknown>).reason as string)
139
+ : null,
82
140
  permissionDenials: [],
83
141
  durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
84
142
  durationApiMs: null,
@@ -87,19 +145,13 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
87
145
  contextWindow: null,
88
146
  maxOutputTokens: null,
89
147
  usage: usage
90
- ? {
91
- inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
92
- outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
93
- cacheCreationInputTokens: 0,
94
- cacheReadInputTokens: 0,
95
- }
148
+ ? { inputTokens: tokensInput, outputTokens: tokensOutput, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }
96
149
  : null,
97
150
  };
98
151
  return { activities, streamedText, result };
99
152
  }
100
153
  return { activities, streamedText };
101
154
  }
102
-
103
155
  export const opencodeHarness: Harness = {
104
156
  name: 'opencode',
105
157
  displayName: 'OpenCode',
@@ -113,10 +165,8 @@ export const opencodeHarness: Harness = {
113
165
  }
114
166
  },
115
167
  buildArgs(opts: BuildArgsOpts): string[] {
116
- const args = ['run', '--format', 'json', opts.prompt];
117
- // permission not fully standardized; pass as --permission if supported
118
168
  const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'allow-edit';
119
- args.push('--permission', perm);
169
+ const args = ['run', '--format', 'json', opts.prompt, '--permission', perm];
120
170
  if (opts.model) args.push('--model', opts.model);
121
171
  if (opts.resumeSessionId) args.push('--session', opts.resumeSessionId);
122
172
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
@@ -128,12 +178,14 @@ export const opencodeHarness: Harness = {
128
178
  extractResult(state: ParseState): StreamedResult | null {
129
179
  if (state.result) return state.result;
130
180
  if (state.streamedText.trim().length > 0) {
181
+ const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
182
+ ?.sessionId as string | undefined;
131
183
  return {
132
184
  result: state.streamedText,
133
185
  isError: false,
134
186
  numTurns: 1,
135
187
  totalCostUsd: 0,
136
- sessionId: null,
188
+ sessionId: latched ?? null,
137
189
  stopReason: null,
138
190
  permissionDenials: [],
139
191
  durationMs: null,
@@ -147,9 +199,5 @@ export const opencodeHarness: Harness = {
147
199
  }
148
200
  return null;
149
201
  },
150
- permissionMap: {
151
- readonly: ['read-only'],
152
- edit: ['allow-edit'],
153
- danger: ['danger'],
154
- },
202
+ permissionMap: { readonly: ['read-only'], edit: ['allow-edit'], danger: ['danger'] },
155
203
  };
@@ -451,10 +451,11 @@ async function delegate(
451
451
  throw new Error('another delegate run is already in progress (global limit)');
452
452
  // per-harness limit if configured as object
453
453
  const perHarnessLimit = (() => {
454
- // SAFETY: maxConcurrent shape checked for perHarness record before access
455
- const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> }; // SAFETY: shape checked before access
456
- if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number')
457
- return mc.perHarness[harnessName]!;
454
+ const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> };
455
+ if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number') {
456
+ const v = mc.perHarness[harnessName];
457
+ if (typeof v === 'number') return v;
458
+ }
458
459
  return maxGlobal;
459
460
  })();
460
461
  if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
@@ -1034,7 +1035,7 @@ export default function (pi: ExtensionAPI) {
1034
1035
  } else if (ev.kind === 'tool_result') {
1035
1036
  if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
1036
1037
  const last = feed.length - 1;
1037
- if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: ev.isError ? false : true };
1038
+ if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: !ev.isError };
1038
1039
  } else if (ev.kind === 'thinking') {
1039
1040
  chipActivity = '💭 thinking…';
1040
1041
  thinkingChars += ev.chars;
@@ -32,7 +32,7 @@ function mapPerm(mode: string): 'readonly' | 'edit' | 'danger' {
32
32
 
33
33
  export function runClaude(opts: RunClaudeOptions): Promise<ClaudeResult> {
34
34
  const perm = mapPerm(opts.permissionMode);
35
- const nativePerm =
35
+ const _nativePerm =
36
36
  perm === 'readonly' && opts.permissionMode !== 'plan'
37
37
  ? opts.permissionMode
38
38
  : perm === 'danger' && opts.permissionMode !== 'bypassPermissions'
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.14",
7
7
  "engines": {
8
- "node": "26.x"
8
+ "node": "22 || 24 || 26"
9
9
  },
10
10
  "files": [
11
11
  "extensions",
@@ -58,7 +58,7 @@
58
58
  "@earendil-works/pi-ai": "0.84.3",
59
59
  "@earendil-works/pi-coding-agent": "0.84.3",
60
60
  "@earendil-works/pi-tui": "0.84.3",
61
- "@types/node": "26.3.0",
61
+ "@types/node": "22.15.32",
62
62
  "typebox": "1.3.18",
63
63
  "typescript": "7.0.2"
64
64
  },