pi-harness-delegate 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,87 +11,205 @@ import type {
9
11
  StreamedResult,
10
12
  } from './types.ts';
11
13
 
12
- const execFileAsync = promisify(execFile);
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).
13
16
 
17
+ const execFileAsync = promisify(execFile);
14
18
  function isRecord(v: unknown): v is Record<string, unknown> {
15
19
  return typeof v === 'object' && v !== null && !Array.isArray(v);
16
20
  }
17
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.
18
51
  const PERMISSION_MAP: Record<NormalizedPermission, string> = {
19
- readonly: 'read-only',
20
- edit: 'workspace',
21
- danger: 'danger',
52
+ readonly: 'always-ask',
53
+ edit: 'write',
54
+ danger: 'yolo',
22
55
  };
23
-
24
56
  function extractAmpText(o: Record<string, unknown>): string | undefined {
25
57
  if (typeof o.text === 'string') return o.text;
26
58
  if (typeof o.output === 'string') return o.output;
27
59
  if (typeof o.delta === 'string') return o.delta;
28
60
  if (typeof o.content === 'string') return o.content;
61
+ if (isRecord(o.part) && typeof o.part.text === 'string') return o.part.text;
29
62
  return undefined;
30
63
  }
31
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
+
32
81
  export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
33
82
  let o: unknown;
34
83
  try {
35
84
  o = JSON.parse(line);
36
85
  } catch {
37
- if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
86
+ if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
38
87
  return { activities: [] };
39
88
  }
40
89
  if (!isRecord(o)) return { activities: [] };
41
90
  const activities: ParseOutcome['activities'] = [];
42
91
  let streamedText: string | undefined;
43
92
  const typeStr = typeof o.type === 'string' ? o.type : '';
93
+ const hs = harnessState(state);
94
+ // latch session id
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;
44
100
 
45
- if (typeStr.includes('tool') || o.type === 'tool_use') {
46
- const name = typeof o.name === 'string' ? o.name : 'tool';
47
- if (typeStr.includes('start') || o.type === 'tool_use') {
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') {
48
106
  activities.push({ kind: 'tool_start', name });
49
- if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
107
+ activities.push({
108
+ kind: 'tool_input',
109
+ name,
110
+ input: isRecord(o.args) ? (o.args as Record<string, unknown>) : {},
111
+ id,
112
+ });
50
113
  } else {
51
- activities.push({ kind: 'tool_result', isError: o.is_error === true });
114
+ activities.push({ kind: 'tool_result', isError: o.isError === true, id });
115
+ }
116
+ }
117
+ if (typeStr === 'message_update' && isRecord(o.assistantMessageEvent)) {
118
+ const ev = o.assistantMessageEvent as Record<string, unknown>;
119
+ if (ev.type === 'thinking_delta' && typeof ev.delta === 'string')
120
+ activities.push({ kind: 'thinking', chars: ev.delta.length });
121
+ else if (ev.type === 'text_delta' && typeof ev.delta === 'string') streamedText = ev.delta;
122
+ else if (ev.type === 'thinking_start') activities.push({ kind: 'thinking', chars: 5 });
123
+ }
124
+ if (typeStr === 'turn_end' || typeStr === 'agent_end') {
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;
130
+ if (msg && Array.isArray(msg.content)) {
131
+ for (const block of msg.content as unknown[]) {
132
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') streamedText = block.text;
133
+ if (isRecord(block) && block.type === 'thinking' && typeof block.thinking === 'string')
134
+ activities.push({ kind: 'thinking', chars: (block.thinking as string).length });
135
+ }
136
+ }
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;
52
150
  }
53
151
  }
54
- if (typeStr.includes('thinking')) activities.push({ kind: 'thinking', chars: 10 });
55
-
56
152
  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;
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>)
160
+ : null;
161
+ const measured = (hs.turnCount ?? 0) > 0;
61
162
  const result: StreamedResult = {
62
- result: typeof o.result === 'string' ? o.result : state.streamedText + (streamedText ?? ''),
163
+ result:
164
+ typeof o.result === 'string'
165
+ ? o.result
166
+ : streamedText
167
+ ? state.streamedText + streamedText
168
+ : state.streamedText || (text ?? ''),
63
169
  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,
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),
174
+ stopReason:
175
+ typeof o.stop_reason === 'string'
176
+ ? o.stop_reason
177
+ : typeof msg?.stopReason === 'string'
178
+ ? (msg.stopReason as string)
179
+ : null,
68
180
  permissionDenials: [],
69
- durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
181
+ durationMs:
182
+ typeof o.duration_ms === 'number'
183
+ ? o.duration_ms
184
+ : typeof (o as Record<string, unknown>).duration === 'number'
185
+ ? ((o as Record<string, unknown>).duration as number)
186
+ : null,
70
187
  durationApiMs: null,
71
- ttftMs: null,
72
- model: typeof o.model === 'string' ? o.model : null,
188
+ ttftMs:
189
+ typeof (o as Record<string, unknown>).ttft === 'number'
190
+ ? ((o as Record<string, unknown>).ttft as number)
191
+ : null,
192
+ model: typeof o.model === 'string' ? o.model : typeof msg?.model === 'string' ? (msg.model as string) : null,
73
193
  contextWindow: null,
74
194
  maxOutputTokens: null,
75
- usage: usage
195
+ usage: measured
76
196
  ? {
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,
197
+ inputTokens: hs.inputAccum ?? 0,
198
+ outputTokens: hs.outputAccum ?? 0,
199
+ cacheCreationInputTokens: hs.cacheWriteAccum ?? 0,
200
+ cacheReadInputTokens: hs.cacheReadAccum ?? 0,
81
201
  }
82
202
  : null,
83
203
  };
204
+ if (!result.result) result.result = state.streamedText + (streamedText ?? '');
84
205
  return { activities, streamedText, result };
85
206
  }
86
207
  return { activities, streamedText };
87
208
  }
88
-
89
209
  export const ampHarness: Harness = {
90
210
  name: 'amp',
91
211
  displayName: 'Amp',
92
- binary: 'amp',
212
+ binary: RESOLVED_BINARY,
93
213
  async detect() {
94
214
  try {
95
215
  const { stdout } = await execFileAsync('amp', ['--version'], { timeout: 5000 });
@@ -104,11 +224,12 @@ export const ampHarness: Harness = {
104
224
  }
105
225
  },
106
226
  buildArgs(opts: BuildArgsOpts): string[] {
107
- const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'workspace';
108
- 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];
109
229
  if (opts.model) args.push('--model', opts.model);
110
230
  if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
111
231
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
232
+ args.push(opts.prompt);
112
233
  return args;
113
234
  },
114
235
  parseLine(line: string, state: ParseState): ParseOutcome {
@@ -117,12 +238,13 @@ export const ampHarness: Harness = {
117
238
  extractResult(state: ParseState): StreamedResult | null {
118
239
  if (state.result) return state.result;
119
240
  if (state.streamedText.trim().length > 0) {
241
+ const latched = (state._harness as AmpHarnessState | undefined)?.sessionId;
120
242
  return {
121
243
  result: state.streamedText,
122
244
  isError: false,
123
- numTurns: 1,
124
- totalCostUsd: 0,
125
- sessionId: null,
245
+ numTurns: null,
246
+ totalCostUsd: null,
247
+ sessionId: latched ?? null,
126
248
  stopReason: null,
127
249
  permissionDenials: [],
128
250
  durationMs: null,
@@ -136,9 +258,5 @@ export const ampHarness: Harness = {
136
258
  }
137
259
  return null;
138
260
  },
139
- permissionMap: {
140
- readonly: ['read-only'],
141
- edit: ['workspace'],
142
- danger: ['danger'],
143
- },
261
+ permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
144
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 : [],
@@ -9,24 +9,22 @@ import type {
9
9
  StreamedResult,
10
10
  } from './types.ts';
11
11
 
12
- const execFileAsync = promisify(execFile);
12
+ // Schema verified against codex-cli 0.149.1 — see tests/fixtures/codex.jsonl.
13
+ // `codex exec` in this version dropped `--ask-for-approval` entirely (exec is inherently
14
+ // non-interactive; sandbox alone governs what's allowed) and resume is a subcommand
15
+ // (`exec resume <id> <prompt>`), not a `--thread-id` flag — both confirmed via `codex exec
16
+ // --help` / `codex exec resume --help`, not just the JSONL capture.
13
17
 
18
+ const execFileAsync = promisify(execFile);
14
19
  function isRecord(v: unknown): v is Record<string, unknown> {
15
20
  return typeof v === 'object' && v !== null && !Array.isArray(v);
16
21
  }
17
-
18
22
  const SANDBOX_MAP: Record<NormalizedPermission, string> = {
19
23
  readonly: 'read-only',
20
24
  edit: 'workspace-write',
21
25
  danger: 'danger-full-access',
22
26
  };
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":"..."}
27
+ function extractTextFromCodexEvent(o: Record<string, unknown>, _state: ParseState): string | undefined {
30
28
  if (typeof o.text === 'string' && o.type !== 'tool_use') return o.text;
31
29
  if (typeof o.output === 'string') return o.output;
32
30
  if (typeof o.delta === 'string') return o.delta;
@@ -35,32 +33,102 @@ function extractTextFromCodexEvent(o: Record<string, unknown>, state: ParseState
35
33
  return (o.event as Record<string, unknown>).delta as string;
36
34
  if (isRecord(o.item) && typeof o.item.text === 'string') return o.item.text;
37
35
  if (isRecord(o.item) && typeof o.item.output === 'string') return o.item.output;
38
- // agent_message
39
36
  if (o.type === 'agent_message' && typeof o.text === 'string') return o.text;
37
+ if (o.type === 'error' && typeof o.message === 'string') return o.message;
38
+ if (typeof o.error === 'string') return o.error;
40
39
  return undefined;
41
40
  }
42
41
 
42
+ interface CodexHarnessState {
43
+ sessionId?: string;
44
+ turnCount?: number;
45
+ }
46
+
47
+ function harnessState(state: ParseState): CodexHarnessState {
48
+ const s = (state._harness ?? {}) as CodexHarnessState;
49
+ state._harness = s as unknown as Record<string, unknown>;
50
+ return s;
51
+ }
52
+
43
53
  export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
44
54
  let o: unknown;
45
55
  try {
46
56
  o = JSON.parse(line);
47
57
  } 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: [] };
58
+ if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
50
59
  return { activities: [] };
51
60
  }
52
61
  if (!isRecord(o)) return { activities: [] };
53
-
54
62
  const activities: ParseOutcome['activities'] = [];
55
63
  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
64
  const typeStr = typeof o.type === 'string' ? o.type : '';
62
-
63
- if (typeStr.includes('tool') || typeStr.includes('item.started') || typeStr.includes('function_call')) {
65
+ const item = isRecord(o.item) ? o.item : null;
66
+ const hs = harnessState(state);
67
+ // latch thread_id from thread.started
68
+ if (typeStr === 'thread.started' && typeof o.thread_id === 'string') {
69
+ hs.sessionId = o.thread_id;
70
+ return { activities, streamedText };
71
+ }
72
+ if (typeStr === 'turn.started') {
73
+ hs.turnCount = (hs.turnCount ?? 0) + 1;
74
+ return { activities, streamedText };
75
+ }
76
+ if (typeStr === 'error' && typeof o.message === 'string') {
77
+ streamedText = o.message;
78
+ }
79
+ if (typeof o.error === 'string' && !streamedText) streamedText = o.error;
80
+ if (typeStr === 'turn.failed') {
81
+ const msg =
82
+ isRecord(o.error) && typeof o.error.message === 'string'
83
+ ? o.error.message
84
+ : typeof o.error === 'string'
85
+ ? o.error
86
+ : typeof o.message === 'string'
87
+ ? o.message
88
+ : state.streamedText + (streamedText ?? '');
89
+ const result: StreamedResult = {
90
+ result: msg,
91
+ isError: true,
92
+ numTurns: null,
93
+ totalCostUsd: null,
94
+ sessionId: typeof o.thread_id === 'string' ? o.thread_id : (hs.sessionId ?? null),
95
+ stopReason: 'error',
96
+ permissionDenials: [],
97
+ durationMs: null,
98
+ durationApiMs: null,
99
+ ttftMs: null,
100
+ model: null,
101
+ contextWindow: null,
102
+ maxOutputTokens: null,
103
+ usage: null,
104
+ };
105
+ return { activities, streamedText, result };
106
+ }
107
+ // real tool schema: item.started/item.completed carry item.id (correlating id) and item.type
108
+ // (no separate "name" field — command_execution is the only item type observed emitting a
109
+ // shell command; other item types like file_change/mcp_tool_call may exist but weren't
110
+ // captured, so the generic name/input guesses below stay as a fallback for those).
111
+ if (
112
+ item &&
113
+ (typeStr === 'item.started' || typeStr === 'item.completed') &&
114
+ item.type !== 'agent_message' &&
115
+ item.type !== 'reasoning'
116
+ ) {
117
+ const id = typeof item.id === 'string' ? item.id : undefined;
118
+ const name = typeof item.name === 'string' ? item.name : typeof item.type === 'string' ? item.type : 'tool';
119
+ if (typeStr === 'item.started') {
120
+ const input =
121
+ typeof item.command === 'string' ? { command: item.command } : isRecord(item.input) ? item.input : {};
122
+ activities.push({ kind: 'tool_start', name });
123
+ activities.push({ kind: 'tool_input', name, input: input as Record<string, unknown>, id });
124
+ } else {
125
+ const isError =
126
+ (typeof item.exit_code === 'number' && item.exit_code !== 0) ||
127
+ item.status === 'failed' ||
128
+ item.is_error === true;
129
+ activities.push({ kind: 'tool_result', isError, id });
130
+ }
131
+ } else if (typeStr.includes('tool') || typeStr.includes('function_call')) {
64
132
  const toolName = (item && typeof item.name === 'string' ? item.name : typeof o.name === 'string' ? o.name : null) as
65
133
  | string
66
134
  | null;
@@ -68,37 +136,37 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
68
136
  if (typeStr.includes('started') || o.type === 'tool_use') {
69
137
  activities.push({ kind: 'tool_start', name: toolName });
70
138
  if (item && isRecord(item.input))
71
- activities.push({
72
- kind: 'tool_input',
73
- name: toolName,
74
- input: item.input as Record<string, unknown>,
75
- });
139
+ activities.push({ kind: 'tool_input', name: toolName, input: item.input as Record<string, unknown> });
76
140
  else if (isRecord(o.input))
77
141
  activities.push({ kind: 'tool_input', name: toolName, input: o.input as Record<string, unknown> });
78
- } else if (typeStr.includes('completed') || typeStr.includes('result')) {
142
+ } else if (typeStr.includes('completed') || typeStr.includes('result'))
79
143
  activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
80
- }
81
144
  }
82
145
  }
83
- if (o.type === 'tool_result' || (typeStr === 'item.completed' && item?.type === 'tool_result')) {
146
+ if (o.type === 'tool_result')
84
147
  activities.push({ kind: 'tool_result', isError: (o as Record<string, unknown>).is_error === true });
85
- }
86
148
  if (typeStr.includes('thinking') || o.type === 'reasoning' || item?.type === 'reasoning') {
87
149
  const thinkingText = extractTextFromCodexEvent(o, state);
88
150
  if (thinkingText) activities.push({ kind: 'thinking', chars: thinkingText.length });
89
151
  else activities.push({ kind: 'thinking', chars: 10 });
90
152
  }
91
-
92
- // Text extraction
93
153
  const text = extractTextFromCodexEvent(o, state);
94
- if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) {
95
- // Avoid double-counting tool inputs as text
154
+ if (
155
+ text &&
156
+ !typeStr.includes('tool') &&
157
+ !typeStr.includes('thinking') &&
158
+ typeStr !== 'error' &&
159
+ typeStr !== 'turn.failed'
160
+ )
96
161
  streamedText = text;
97
- }
98
-
99
- // Result detection: final result often {type:"result", result:"...", total_cost_usd, usage, ...}
100
- // or {type:"thread.completed", ...}
101
- if (o.type === 'result' || o.type === 'thread.completed' || o.type === 'task.completed') {
162
+ // real final-usage event is turn.completed (thread.completed/task.completed/result kept as
163
+ // fallback in case another codex-cli version emits them instead).
164
+ if (
165
+ o.type === 'result' ||
166
+ o.type === 'thread.completed' ||
167
+ o.type === 'task.completed' ||
168
+ typeStr === 'turn.completed'
169
+ ) {
102
170
  const usage = isRecord(o.usage) ? o.usage : null;
103
171
  const result: StreamedResult = {
104
172
  result:
@@ -108,8 +176,16 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
108
176
  ? o.output
109
177
  : state.streamedText + (streamedText ?? ''),
110
178
  isError: o.is_error === true || o.error === true,
111
- numTurns: typeof o.num_turns === 'number' ? o.num_turns : typeof o.turns === 'number' ? o.turns : 0,
112
- totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : typeof o.cost === 'number' ? o.cost : 0,
179
+ numTurns:
180
+ typeof o.num_turns === 'number'
181
+ ? o.num_turns
182
+ : typeof o.turns === 'number'
183
+ ? o.turns
184
+ : hs.turnCount
185
+ ? hs.turnCount
186
+ : null,
187
+ totalCostUsd:
188
+ typeof o.total_cost_usd === 'number' ? o.total_cost_usd : typeof o.cost === 'number' ? o.cost : null,
113
189
  sessionId:
114
190
  typeof o.session_id === 'string'
115
191
  ? o.session_id
@@ -117,7 +193,7 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
117
193
  ? o.thread_id
118
194
  : typeof o.id === 'string'
119
195
  ? o.id
120
- : null,
196
+ : (hs.sessionId ?? null),
121
197
  stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
122
198
  permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],
123
199
  durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
@@ -128,35 +204,29 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
128
204
  maxOutputTokens: null,
129
205
  usage: usage
130
206
  ? {
131
- inputTokens:
132
- typeof usage.input_tokens === 'number'
133
- ? usage.input_tokens
134
- : typeof (usage as Record<string, unknown>).inputTokens === 'number'
135
- ? ((usage as Record<string, unknown>).inputTokens as number)
136
- : 0,
137
- outputTokens:
138
- typeof usage.output_tokens === 'number'
139
- ? usage.output_tokens
140
- : typeof (usage as Record<string, unknown>).outputTokens === 'number'
141
- ? ((usage as Record<string, unknown>).outputTokens as number)
142
- : 0,
207
+ // real fields: input_tokens, cached_input_tokens, cache_write_input_tokens, output_tokens
208
+ // (no total_cost_usd anywhere in this schema — ChatGPT-plan auth doesn't report $ cost).
209
+ inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
210
+ outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
143
211
  cacheCreationInputTokens:
144
- typeof (usage as Record<string, unknown>).cache_creation_input_tokens === 'number'
145
- ? ((usage as Record<string, unknown>).cache_creation_input_tokens as number)
146
- : 0,
212
+ typeof usage.cache_write_input_tokens === 'number'
213
+ ? usage.cache_write_input_tokens
214
+ : typeof (usage as Record<string, unknown>).cache_creation_input_tokens === 'number'
215
+ ? ((usage as Record<string, unknown>).cache_creation_input_tokens as number)
216
+ : 0,
147
217
  cacheReadInputTokens:
148
- typeof (usage as Record<string, unknown>).cache_read_input_tokens === 'number'
149
- ? ((usage as Record<string, unknown>).cache_read_input_tokens as number)
150
- : 0,
218
+ typeof usage.cached_input_tokens === 'number'
219
+ ? usage.cached_input_tokens
220
+ : typeof (usage as Record<string, unknown>).cache_read_input_tokens === 'number'
221
+ ? ((usage as Record<string, unknown>).cache_read_input_tokens as number)
222
+ : 0,
151
223
  }
152
224
  : null,
153
225
  };
154
226
  return { activities, streamedText, result };
155
227
  }
156
-
157
228
  return { activities, streamedText };
158
229
  }
159
-
160
230
  export const codexHarness: Harness = {
161
231
  name: 'codex',
162
232
  displayName: 'Muse',
@@ -170,15 +240,11 @@ export const codexHarness: Harness = {
170
240
  }
171
241
  },
172
242
  buildArgs(opts: BuildArgsOpts): string[] {
173
- // codex exec --json <prompt> --sandbox <level> --ask-for-approval <level>
174
243
  const sandbox = opts.nativePermission ?? SANDBOX_MAP[opts.permission] ?? 'workspace-write';
175
- const args = ['exec', '--json', opts.prompt, '--sandbox', sandbox];
176
- if (opts.permission === 'danger' || sandbox === 'danger-full-access') args.push('--ask-for-approval', 'never');
177
- else if (opts.permission === 'readonly') args.push('--ask-for-approval', 'never');
178
- else args.push('--ask-for-approval', 'on-request');
244
+ const args = opts.resumeSessionId
245
+ ? ['exec', 'resume', opts.resumeSessionId, opts.prompt, '--json']
246
+ : ['exec', '--json', opts.prompt, '--sandbox', sandbox];
179
247
  if (opts.model) args.push('--model', opts.model);
180
- if (opts.resumeSessionId) args.push('--thread-id', opts.resumeSessionId);
181
- // maxBudgetUsd not natively supported; pass as env hint via --config if needed
182
248
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
183
249
  return args;
184
250
  },
@@ -187,14 +253,14 @@ export const codexHarness: Harness = {
187
253
  },
188
254
  extractResult(state: ParseState): StreamedResult | null {
189
255
  if (state.result) return state.result;
190
- // Fallback: if no explicit result, synthesize from streamed text if any
191
256
  if (state.streamedText.trim().length > 0) {
257
+ const latched = (state._harness as CodexHarnessState | undefined)?.sessionId;
192
258
  return {
193
259
  result: state.streamedText,
194
260
  isError: false,
195
- numTurns: 1,
196
- totalCostUsd: 0,
197
- sessionId: null,
261
+ numTurns: null,
262
+ totalCostUsd: null,
263
+ sessionId: latched ?? null,
198
264
  stopReason: null,
199
265
  permissionDenials: [],
200
266
  durationMs: null,
@@ -208,9 +274,5 @@ export const codexHarness: Harness = {
208
274
  }
209
275
  return null;
210
276
  },
211
- permissionMap: {
212
- readonly: ['read-only'],
213
- edit: ['workspace-write'],
214
- danger: ['danger-full-access'],
215
- },
277
+ permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
216
278
  };