pi-harness-delegate 0.2.2 → 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,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 : [],
@@ -9,6 +9,12 @@ import type {
9
9
  StreamedResult,
10
10
  } from './types.ts';
11
11
 
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.
17
+
12
18
  const execFileAsync = promisify(execFile);
13
19
  function isRecord(v: unknown): v is Record<string, unknown> {
14
20
  return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -32,6 +38,18 @@ function extractTextFromCodexEvent(o: Record<string, unknown>, _state: ParseStat
32
38
  if (typeof o.error === 'string') return o.error;
33
39
  return undefined;
34
40
  }
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
+
35
53
  export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
36
54
  let o: unknown;
37
55
  try {
@@ -45,15 +63,16 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
45
63
  let streamedText: string | undefined;
46
64
  const typeStr = typeof o.type === 'string' ? o.type : '';
47
65
  const item = isRecord(o.item) ? o.item : null;
66
+ const hs = harnessState(state);
48
67
  // latch thread_id from thread.started
49
68
  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
- };
69
+ hs.sessionId = o.thread_id;
70
+ return { activities, streamedText };
71
+ }
72
+ if (typeStr === 'turn.started') {
73
+ hs.turnCount = (hs.turnCount ?? 0) + 1;
54
74
  return { activities, streamedText };
55
75
  }
56
- if (typeStr === 'turn.started') return { activities, streamedText };
57
76
  if (typeStr === 'error' && typeof o.message === 'string') {
58
77
  streamedText = o.message;
59
78
  }
@@ -67,14 +86,12 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
67
86
  : typeof o.message === 'string'
68
87
  ? o.message
69
88
  : 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
89
  const result: StreamedResult = {
73
90
  result: msg,
74
91
  isError: true,
75
- numTurns: 1,
76
- totalCostUsd: 0,
77
- sessionId: typeof o.thread_id === 'string' ? o.thread_id : (latched ?? null),
92
+ numTurns: null,
93
+ totalCostUsd: null,
94
+ sessionId: typeof o.thread_id === 'string' ? o.thread_id : (hs.sessionId ?? null),
78
95
  stopReason: 'error',
79
96
  permissionDenials: [],
80
97
  durationMs: null,
@@ -87,7 +104,31 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
87
104
  };
88
105
  return { activities, streamedText, result };
89
106
  }
90
- if (typeStr.includes('tool') || typeStr.includes('item.started') || typeStr.includes('function_call')) {
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')) {
91
132
  const toolName = (item && typeof item.name === 'string' ? item.name : typeof o.name === 'string' ? o.name : null) as
92
133
  | string
93
134
  | null;
@@ -102,7 +143,7 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
102
143
  activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
103
144
  }
104
145
  }
105
- if (o.type === 'tool_result' || (typeStr === 'item.completed' && item?.type === 'tool_result'))
146
+ if (o.type === 'tool_result')
106
147
  activities.push({ kind: 'tool_result', isError: (o as Record<string, unknown>).is_error === true });
107
148
  if (typeStr.includes('thinking') || o.type === 'reasoning' || item?.type === 'reasoning') {
108
149
  const thinkingText = extractTextFromCodexEvent(o, state);
@@ -118,10 +159,15 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
118
159
  typeStr !== 'turn.failed'
119
160
  )
120
161
  streamedText = text;
121
- 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
+ ) {
122
170
  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;
125
171
  const result: StreamedResult = {
126
172
  result:
127
173
  typeof o.result === 'string'
@@ -130,8 +176,16 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
130
176
  ? o.output
131
177
  : state.streamedText + (streamedText ?? ''),
132
178
  isError: o.is_error === true || o.error === true,
133
- numTurns: typeof o.num_turns === 'number' ? o.num_turns : typeof o.turns === 'number' ? o.turns : 0,
134
- 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,
135
189
  sessionId:
136
190
  typeof o.session_id === 'string'
137
191
  ? o.session_id
@@ -139,7 +193,7 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
139
193
  ? o.thread_id
140
194
  : typeof o.id === 'string'
141
195
  ? o.id
142
- : (latched ?? null),
196
+ : (hs.sessionId ?? null),
143
197
  stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
144
198
  permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],
145
199
  durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
@@ -150,26 +204,22 @@ export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
150
204
  maxOutputTokens: null,
151
205
  usage: usage
152
206
  ? {
153
- inputTokens:
154
- typeof usage.input_tokens === 'number'
155
- ? usage.input_tokens
156
- : typeof (usage as Record<string, unknown>).inputTokens === 'number'
157
- ? ((usage as Record<string, unknown>).inputTokens as number)
158
- : 0,
159
- outputTokens:
160
- typeof usage.output_tokens === 'number'
161
- ? usage.output_tokens
162
- : typeof (usage as Record<string, unknown>).outputTokens === 'number'
163
- ? ((usage as Record<string, unknown>).outputTokens as number)
164
- : 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,
165
211
  cacheCreationInputTokens:
166
- typeof (usage as Record<string, unknown>).cache_creation_input_tokens === 'number'
167
- ? ((usage as Record<string, unknown>).cache_creation_input_tokens as number)
168
- : 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,
169
217
  cacheReadInputTokens:
170
- typeof (usage as Record<string, unknown>).cache_read_input_tokens === 'number'
171
- ? ((usage as Record<string, unknown>).cache_read_input_tokens as number)
172
- : 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,
173
223
  }
174
224
  : null,
175
225
  };
@@ -191,12 +241,10 @@ export const codexHarness: Harness = {
191
241
  },
192
242
  buildArgs(opts: BuildArgsOpts): string[] {
193
243
  const sandbox = opts.nativePermission ?? SANDBOX_MAP[opts.permission] ?? 'workspace-write';
194
- const args = ['exec', '--json', opts.prompt, '--sandbox', sandbox];
195
- if (opts.permission === 'danger' || sandbox === 'danger-full-access') args.push('--ask-for-approval', 'never');
196
- else if (opts.permission === 'readonly') args.push('--ask-for-approval', 'never');
197
- 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];
198
247
  if (opts.model) args.push('--model', opts.model);
199
- if (opts.resumeSessionId) args.push('--thread-id', opts.resumeSessionId);
200
248
  for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
201
249
  return args;
202
250
  },
@@ -206,13 +254,12 @@ export const codexHarness: Harness = {
206
254
  extractResult(state: ParseState): StreamedResult | null {
207
255
  if (state.result) return state.result;
208
256
  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;
257
+ const latched = (state._harness as CodexHarnessState | undefined)?.sessionId;
211
258
  return {
212
259
  result: state.streamedText,
213
260
  isError: false,
214
- numTurns: 1,
215
- totalCostUsd: 0,
261
+ numTurns: null,
262
+ totalCostUsd: null,
216
263
  sessionId: latched ?? null,
217
264
  stopReason: null,
218
265
  permissionDenials: [],