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.
- package/README.md +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/harnesses/amp.ts +122 -99
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +92 -45
- package/extensions/harnesses/opencode.ts +86 -74
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +617 -206
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-registry.ts +90 -0
- package/extensions/templates.ts +3 -0
- package/extensions/usage.ts +8 -4
- package/package.json +1 -1
|
@@ -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: '
|
|
18
|
-
edit: '
|
|
19
|
-
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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)
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 &&
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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:
|
|
145
|
-
totalCostUsd:
|
|
146
|
-
sessionId:
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
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
|
|
205
|
-
const args = ['
|
|
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 = (
|
|
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:
|
|
223
|
-
totalCostUsd:
|
|
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: ['
|
|
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({
|
|
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 :
|
|
86
|
-
totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd :
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
76
|
-
totalCostUsd:
|
|
77
|
-
sessionId: typeof o.thread_id === 'string' ? o.thread_id : (
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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:
|
|
134
|
-
|
|
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
|
-
: (
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
167
|
-
?
|
|
168
|
-
:
|
|
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
|
|
171
|
-
?
|
|
172
|
-
:
|
|
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 =
|
|
195
|
-
|
|
196
|
-
|
|
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 = (
|
|
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:
|
|
215
|
-
totalCostUsd:
|
|
261
|
+
numTurns: null,
|
|
262
|
+
totalCostUsd: null,
|
|
216
263
|
sessionId: latched ?? null,
|
|
217
264
|
stopReason: null,
|
|
218
265
|
permissionDenials: [],
|