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.
- package/README.md +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/config.ts +12 -8
- package/extensions/harnesses/amp.ts +160 -42
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +139 -77
- package/extensions/harnesses/opencode.ts +105 -45
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +622 -210
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-claude.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,87 +11,205 @@ import type {
|
|
|
9
11
|
StreamedResult,
|
|
10
12
|
} from './types.ts';
|
|
11
13
|
|
|
12
|
-
|
|
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: '
|
|
20
|
-
edit: '
|
|
21
|
-
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
|
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 &&
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
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:
|
|
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:
|
|
65
|
-
totalCostUsd:
|
|
66
|
-
sessionId:
|
|
67
|
-
|
|
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:
|
|
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:
|
|
72
|
-
|
|
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:
|
|
195
|
+
usage: measured
|
|
76
196
|
? {
|
|
77
|
-
inputTokens:
|
|
78
|
-
outputTokens:
|
|
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:
|
|
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
|
|
108
|
-
const args = ['
|
|
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:
|
|
124
|
-
totalCostUsd:
|
|
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({
|
|
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,24 +9,22 @@ import type {
|
|
|
9
9
|
StreamedResult,
|
|
10
10
|
} from './types.ts';
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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 (
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
112
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
145
|
-
?
|
|
146
|
-
:
|
|
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
|
|
149
|
-
?
|
|
150
|
-
:
|
|
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 =
|
|
176
|
-
|
|
177
|
-
|
|
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:
|
|
196
|
-
totalCostUsd:
|
|
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
|
};
|