@agentex/agent 0.0.39 → 0.0.42
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/CHANGELOG.md +50 -0
- package/README.md +26 -4
- package/dist/history/fs.d.ts.map +1 -1
- package/dist/history/fs.js +6 -12
- package/dist/history/fs.js.map +1 -1
- package/dist/providers/antigravity/execute.d.ts.map +1 -1
- package/dist/providers/antigravity/execute.js +9 -3
- package/dist/providers/antigravity/execute.js.map +1 -1
- package/dist/providers/antigravity/runtime.d.ts +4 -2
- package/dist/providers/antigravity/runtime.d.ts.map +1 -1
- package/dist/providers/antigravity/runtime.js +7 -4
- package/dist/providers/antigravity/runtime.js.map +1 -1
- package/dist/providers/antigravity/session.d.ts.map +1 -1
- package/dist/providers/antigravity/session.js +59 -17
- package/dist/providers/antigravity/session.js.map +1 -1
- package/dist/providers/claude/attach.d.ts.map +1 -1
- package/dist/providers/claude/attach.js +4 -6
- package/dist/providers/claude/attach.js.map +1 -1
- package/dist/providers/claude/transcript.d.ts.map +1 -1
- package/dist/providers/claude/transcript.js +9 -20
- package/dist/providers/claude/transcript.js.map +1 -1
- package/dist/providers/codex/attach.d.ts.map +1 -1
- package/dist/providers/codex/attach.js +4 -0
- package/dist/providers/codex/attach.js.map +1 -1
- package/dist/providers/codex/history.d.ts.map +1 -1
- package/dist/providers/codex/history.js +11 -0
- package/dist/providers/codex/history.js.map +1 -1
- package/dist/providers/codex/parse.d.ts +7 -0
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +56 -0
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/permissions.d.ts +40 -0
- package/dist/providers/codex/permissions.d.ts.map +1 -0
- package/dist/providers/codex/permissions.js +50 -0
- package/dist/providers/codex/permissions.js.map +1 -0
- package/dist/providers/codex/session.d.ts +34 -0
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +213 -20
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.d.ts +12 -3
- package/dist/providers/codex/transcript-normalize.d.ts.map +1 -1
- package/dist/providers/codex/transcript-normalize.js +87 -10
- package/dist/providers/codex/transcript-normalize.js.map +1 -1
- package/dist/providers/codex/transcript.d.ts.map +1 -1
- package/dist/providers/codex/transcript.js +5 -10
- package/dist/providers/codex/transcript.js.map +1 -1
- package/dist/providers/codex/usage-scanner.d.ts.map +1 -1
- package/dist/providers/codex/usage-scanner.js +4 -6
- package/dist/providers/codex/usage-scanner.js.map +1 -1
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/auth.js +12 -10
- package/dist/utils/auth.js.map +1 -1
- package/dist/utils/jsonl-lines.d.ts +35 -0
- package/dist/utils/jsonl-lines.d.ts.map +1 -0
- package/dist/utils/jsonl-lines.js +61 -0
- package/dist/utils/jsonl-lines.js.map +1 -0
- package/package.json +1 -1
- package/src/history/fs.ts +6 -12
- package/src/providers/antigravity/execute.ts +10 -3
- package/src/providers/antigravity/runtime.ts +7 -4
- package/src/providers/antigravity/session.ts +59 -18
- package/src/providers/claude/attach.ts +4 -6
- package/src/providers/claude/transcript.ts +9 -22
- package/src/providers/codex/attach.ts +4 -0
- package/src/providers/codex/history.ts +11 -0
- package/src/providers/codex/parse.ts +59 -0
- package/src/providers/codex/permissions.ts +61 -0
- package/src/providers/codex/session.ts +238 -19
- package/src/providers/codex/transcript-normalize.ts +85 -10
- package/src/providers/codex/transcript.ts +5 -11
- package/src/providers/codex/usage-scanner.ts +4 -7
- package/src/utils/auth.ts +12 -11
- package/src/utils/jsonl-lines.ts +80 -0
|
@@ -38,7 +38,8 @@ import { AGY_SIGN_IN_MESSAGE, buildAgyArgs, findAgyBinary, readAgyResumeId } fro
|
|
|
38
38
|
interface ActiveTurn {
|
|
39
39
|
turnId: string;
|
|
40
40
|
abort: AbortController;
|
|
41
|
-
|
|
41
|
+
/** Set once the turn starts; a turn_start handler can interrupt before that. */
|
|
42
|
+
done: Promise<TurnResult> | null;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -74,8 +75,20 @@ class AntigravitySession implements AgentSession {
|
|
|
74
75
|
/** Events received from agy processes, to tell whether one saw a message. */
|
|
75
76
|
private connectionEvents = 0;
|
|
76
77
|
private draining = false;
|
|
77
|
-
/**
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* agy has no system-prompt flag, so instructions ride on the first message
|
|
80
|
+
* each process accepts, like a per-process system prompt. They stay pending
|
|
81
|
+
* until agy acknowledges a message that carried them, so a failed start or
|
|
82
|
+
* sign-in never drops them, and a resume agy could not honor (a new
|
|
83
|
+
* conversation) still gets them.
|
|
84
|
+
*/
|
|
85
|
+
private readonly instructions: string | null;
|
|
86
|
+
/** Conversations this session saw agy accept the instructions into. */
|
|
87
|
+
private readonly instructedConversations = new Set<string>();
|
|
88
|
+
/** The current process accepted a message that carried the instructions. */
|
|
89
|
+
private connectionInstructed = false;
|
|
90
|
+
/** The conversation the current process was started to resume, if any. */
|
|
91
|
+
private connectionResumeId: string | null = null;
|
|
79
92
|
private readonly cwd: string;
|
|
80
93
|
private readonly goals: GoalController;
|
|
81
94
|
|
|
@@ -86,7 +99,7 @@ class AntigravitySession implements AgentSession {
|
|
|
86
99
|
) {
|
|
87
100
|
this.cwd = ctx.cwd ?? process.cwd();
|
|
88
101
|
this.conversationId = readAgyResumeId(ctx.sessionParams, this.cwd);
|
|
89
|
-
this.
|
|
102
|
+
this.instructions = instructions;
|
|
90
103
|
this.goals = new GoalController({
|
|
91
104
|
providerType: ANTIGRAVITY_PROVIDER_TYPE,
|
|
92
105
|
capability: EMULATED_GOAL_CAPABILITY,
|
|
@@ -118,12 +131,14 @@ class AntigravitySession implements AgentSession {
|
|
|
118
131
|
const uuid = uuidv7();
|
|
119
132
|
const turnId = uuidv7();
|
|
120
133
|
const abort = new AbortController();
|
|
121
|
-
const text = this.pendingInstructions ? `${this.pendingInstructions}\n\n${message}` : message;
|
|
122
|
-
this.pendingInstructions = null;
|
|
123
134
|
|
|
124
135
|
this._state = "thinking";
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
// Registered before the turn runs, so interrupt() can reach it from inside
|
|
137
|
+
// a turn_start handler.
|
|
138
|
+
const active: ActiveTurn = { turnId, abort, done: null };
|
|
139
|
+
this.active = active;
|
|
140
|
+
const done = this.runTurn(message, turnId, abort, options);
|
|
141
|
+
active.done = done;
|
|
127
142
|
void done.then((result) => this.goals.onTurnSettled(result)).catch(() => undefined);
|
|
128
143
|
return { uuid, result: done };
|
|
129
144
|
}
|
|
@@ -133,6 +148,8 @@ class AntigravitySession implements AgentSession {
|
|
|
133
148
|
const config = this.ctx.config ?? {};
|
|
134
149
|
// A replacement process picks the conversation back up where it stopped.
|
|
135
150
|
this.conversationId = this.sessionId;
|
|
151
|
+
this.connectionResumeId = this.conversationId;
|
|
152
|
+
this.connectionInstructed = false;
|
|
136
153
|
const parser = new AgyStreamParser({
|
|
137
154
|
includePartialMessages: config.includePartialMessages,
|
|
138
155
|
conversationId: this.conversationId,
|
|
@@ -162,18 +179,21 @@ class AntigravitySession implements AgentSession {
|
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
private async runTurn(
|
|
165
|
-
|
|
182
|
+
message: string,
|
|
166
183
|
turnId: string,
|
|
167
184
|
abort: AbortController,
|
|
168
185
|
options?: SendOptions,
|
|
169
186
|
): Promise<TurnResult> {
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
// Arm every stop before the first await: an interrupt that lands while
|
|
188
|
+
// turn_start handlers run must still stop the turn.
|
|
172
189
|
const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
|
|
173
190
|
const graceSec = this.ctx.config?.graceSec;
|
|
174
191
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
175
192
|
const guards: Promise<"timeout" | "aborted">[] = [
|
|
176
|
-
new Promise((resolve) =>
|
|
193
|
+
new Promise((resolve) => {
|
|
194
|
+
if (abort.signal.aborted) resolve("aborted");
|
|
195
|
+
else abort.signal.addEventListener("abort", () => resolve("aborted"), { once: true });
|
|
196
|
+
}),
|
|
177
197
|
];
|
|
178
198
|
if (timeoutSec && timeoutSec > 0) {
|
|
179
199
|
guards.push(new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutSec * 1000); }));
|
|
@@ -184,15 +204,30 @@ class AntigravitySession implements AgentSession {
|
|
|
184
204
|
else options.signal.addEventListener("abort", onSendAbort, { once: true });
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
let connection =
|
|
207
|
+
let connection: AgyConnection | null = null;
|
|
188
208
|
try {
|
|
209
|
+
await this.emitTurnEdge({ type: "turn_start", turnId, trigger: "send" });
|
|
210
|
+
if (abort.signal.aborted) {
|
|
211
|
+
// Stopped before agy saw anything: nothing to interrupt, nothing spawned.
|
|
212
|
+
await this.emitTurnEdge({ type: "turn_end", turnId, trigger: "send", reason: "cancelled" });
|
|
213
|
+
return { summary: null, costUsd: null, status: "aborted", errorCode: "aborted", errorMessage: "Turn aborted" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
connection = this.ensureConnection();
|
|
189
217
|
let result: TurnResult | null = null;
|
|
190
218
|
let reason: "result" | "cancelled" | "session_closed" = "result";
|
|
191
219
|
for (let attempt = 0; result === null; attempt++) {
|
|
192
220
|
const eventsBefore = this.connectionEvents;
|
|
221
|
+
const carriesInstructions = this.needsInstructions();
|
|
193
222
|
const pending = connection.nextTurn();
|
|
194
|
-
connection.write(
|
|
223
|
+
connection.write(carriesInstructions ? `${this.instructions}\n\n${message}` : message);
|
|
195
224
|
const first = await Promise.race([pending, ...guards]);
|
|
225
|
+
// Any event from agy means it accepted the message, instructions included.
|
|
226
|
+
if (carriesInstructions && this.connectionEvents > eventsBefore) {
|
|
227
|
+
this.connectionInstructed = true;
|
|
228
|
+
const conversation = this.parser?.conversationId;
|
|
229
|
+
if (conversation) this.instructedConversations.add(conversation);
|
|
230
|
+
}
|
|
196
231
|
if (first === "timeout" || first === "aborted") {
|
|
197
232
|
const outcome = await interruptAgyTurn(connection, pending, graceSec);
|
|
198
233
|
// agy may report INTERRUPTED and then exit. Learn which before the
|
|
@@ -230,11 +265,17 @@ class AntigravitySession implements AgentSession {
|
|
|
230
265
|
if (timer) clearTimeout(timer);
|
|
231
266
|
options?.signal?.removeEventListener("abort", onSendAbort);
|
|
232
267
|
this.active = null;
|
|
233
|
-
if (!connection.alive && this.connection === connection) this.connection = null;
|
|
268
|
+
if (connection && !connection.alive && this.connection === connection) this.connection = null;
|
|
234
269
|
if (this._state !== "closed") this._state = "idle";
|
|
235
270
|
}
|
|
236
271
|
}
|
|
237
272
|
|
|
273
|
+
/** The next message must carry the instructions (see `instructions`). */
|
|
274
|
+
private needsInstructions(): boolean {
|
|
275
|
+
if (!this.instructions || this.connectionInstructed) return false;
|
|
276
|
+
return !(this.connectionResumeId && this.instructedConversations.has(this.connectionResumeId));
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
private usageOf(outcome: AgyTurnOutcome | null): { usage?: TurnResult["usage"] } {
|
|
239
280
|
const usage = agyUsageRecord(outcome?.result?.turnUsage ?? null, this.parser?.model ?? this.ctx.config?.model ?? null);
|
|
240
281
|
return usage ? { usage } : {};
|
|
@@ -335,12 +376,12 @@ class AntigravitySession implements AgentSession {
|
|
|
335
376
|
const active = this.active;
|
|
336
377
|
if (!active) return;
|
|
337
378
|
active.abort.abort();
|
|
338
|
-
await active.done
|
|
379
|
+
await active.done?.catch(() => undefined);
|
|
339
380
|
}
|
|
340
381
|
|
|
341
382
|
async drain(): Promise<void> {
|
|
342
383
|
this.draining = true;
|
|
343
|
-
|
|
384
|
+
await this.active?.done?.catch(() => undefined);
|
|
344
385
|
await this.close();
|
|
345
386
|
}
|
|
346
387
|
|
|
@@ -350,7 +391,7 @@ class AntigravitySession implements AgentSession {
|
|
|
350
391
|
const active = this.active;
|
|
351
392
|
if (active) {
|
|
352
393
|
active.abort.abort();
|
|
353
|
-
await active.done
|
|
394
|
+
await active.done?.catch(() => undefined);
|
|
354
395
|
}
|
|
355
396
|
const connection = this.connection;
|
|
356
397
|
this.connection = null;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
|
-
import * as readline from "node:readline";
|
|
4
3
|
import type {
|
|
5
4
|
AttachOptions,
|
|
6
5
|
CatchUpOptions,
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
createSessionRecord,
|
|
17
16
|
MalformedSessionRecordError,
|
|
18
17
|
} from "../../sessions/record.js";
|
|
18
|
+
import { readJsonlLines } from "../../utils/jsonl-lines.js";
|
|
19
19
|
import { getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
|
|
20
20
|
import { claudeSessionCodec } from "./codec.js";
|
|
21
21
|
import {
|
|
@@ -86,16 +86,15 @@ const EMPTY: AsyncIterable<CatchUpYield> = {
|
|
|
86
86
|
* preceding completed one.
|
|
87
87
|
*/
|
|
88
88
|
async function classifyLastTurn(filePath: string): Promise<LastTurnStatus> {
|
|
89
|
-
const stream = createReadStream(filePath
|
|
89
|
+
const stream = createReadStream(filePath);
|
|
90
90
|
stream.on("error", () => {});
|
|
91
|
-
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
92
91
|
let lastTurn: LastTurnStatus = "unknown";
|
|
93
92
|
|
|
94
93
|
try {
|
|
95
|
-
for await (const line of
|
|
94
|
+
for await (const line of readJsonlLines(stream)) {
|
|
96
95
|
let raw: unknown;
|
|
97
96
|
try {
|
|
98
|
-
raw = JSON.parse(line);
|
|
97
|
+
raw = JSON.parse(line.text);
|
|
99
98
|
} catch {
|
|
100
99
|
continue;
|
|
101
100
|
}
|
|
@@ -109,7 +108,6 @@ async function classifyLastTurn(filePath: string): Promise<LastTurnStatus> {
|
|
|
109
108
|
if (fsError?.code !== "ENOENT") throw error;
|
|
110
109
|
return "unknown";
|
|
111
110
|
} finally {
|
|
112
|
-
lines.close();
|
|
113
111
|
stream.destroy();
|
|
114
112
|
}
|
|
115
113
|
|
|
@@ -23,8 +23,8 @@ import { createReadStream } from "node:fs";
|
|
|
23
23
|
import { readdir, realpath, stat, open as fsOpen } from "node:fs/promises";
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
-
import * as readline from "node:readline";
|
|
27
26
|
|
|
27
|
+
import { readJsonlLines } from "../../utils/jsonl-lines.js";
|
|
28
28
|
import { getDefaultRuntimeHome, getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
|
|
29
29
|
import type { FoundTranscript, StreamEvent, TranscriptOps } from "../../types.js";
|
|
30
30
|
import { parseStreamLine } from "./parse.js";
|
|
@@ -273,13 +273,12 @@ async function readCwdFromTranscript(filePath: string): Promise<string | null> {
|
|
|
273
273
|
if (!fh) return null;
|
|
274
274
|
|
|
275
275
|
try {
|
|
276
|
-
const stream = fh.createReadStream({
|
|
277
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
276
|
+
const stream = fh.createReadStream({ autoClose: false });
|
|
278
277
|
let count = 0;
|
|
279
278
|
try {
|
|
280
|
-
for await (const
|
|
279
|
+
for await (const line of readJsonlLines(stream)) {
|
|
281
280
|
if (++count > 50) break;
|
|
282
|
-
const trimmed =
|
|
281
|
+
const trimmed = line.text.trim();
|
|
283
282
|
if (!trimmed) continue;
|
|
284
283
|
try {
|
|
285
284
|
const obj = JSON.parse(trimmed) as Record<string, unknown>;
|
|
@@ -300,7 +299,6 @@ async function readCwdFromTranscript(filePath: string): Promise<string | null> {
|
|
|
300
299
|
}
|
|
301
300
|
}
|
|
302
301
|
} finally {
|
|
303
|
-
rl.close();
|
|
304
302
|
stream.destroy();
|
|
305
303
|
}
|
|
306
304
|
} finally {
|
|
@@ -377,26 +375,16 @@ export async function* readClaudeTranscript(
|
|
|
377
375
|
|
|
378
376
|
const stream = createReadStream(filePath, { start: fromOffset, encoding: undefined });
|
|
379
377
|
|
|
380
|
-
// Suppress stray ENOENT (file deleted between stat and open) —
|
|
378
|
+
// Suppress stray ENOENT (file deleted between stat and open) — the stream
|
|
381
379
|
// raises the same condition through its own iterator, which we catch below.
|
|
382
380
|
stream.on("error", () => {});
|
|
383
381
|
|
|
384
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
385
|
-
|
|
386
|
-
let pos = fromOffset;
|
|
387
382
|
let stillSkippingPastSince = !!sinceEventId;
|
|
388
383
|
|
|
389
384
|
try {
|
|
390
|
-
for await (const line of
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
// would yield offsets 1 byte short per line — accepted as a corner
|
|
394
|
-
// case; resume from such an offset would skip one stray `\r`.
|
|
395
|
-
const lineByteLen = Buffer.byteLength(line, "utf8");
|
|
396
|
-
pos += lineByteLen + 1;
|
|
397
|
-
|
|
398
|
-
if (!line) continue;
|
|
399
|
-
const trimmed = line.trim();
|
|
385
|
+
for await (const line of readJsonlLines(stream, fromOffset)) {
|
|
386
|
+
if (!line.text) continue;
|
|
387
|
+
const trimmed = line.text.trim();
|
|
400
388
|
if (!trimmed) continue;
|
|
401
389
|
|
|
402
390
|
if (looksLikeSkippedType(trimmed)) continue;
|
|
@@ -413,7 +401,7 @@ export async function* readClaudeTranscript(
|
|
|
413
401
|
}
|
|
414
402
|
|
|
415
403
|
for (const event of events) {
|
|
416
|
-
yield { event, offset:
|
|
404
|
+
yield { event, offset: line.end };
|
|
417
405
|
}
|
|
418
406
|
}
|
|
419
407
|
} catch (err) {
|
|
@@ -421,7 +409,6 @@ export async function* readClaudeTranscript(
|
|
|
421
409
|
const e = err as NodeJS.ErrnoException;
|
|
422
410
|
if (e?.code !== "ENOENT") throw err;
|
|
423
411
|
} finally {
|
|
424
|
-
rl.close();
|
|
425
412
|
stream.destroy();
|
|
426
413
|
}
|
|
427
414
|
}
|
|
@@ -132,6 +132,10 @@ export async function attachCodexSession(
|
|
|
132
132
|
if (transcript) {
|
|
133
133
|
const lastEvent = await latestTurnBoundary(transcript.filePath, sessionId);
|
|
134
134
|
if (lastEvent === null) lastTurn = "unknown";
|
|
135
|
+
// turn_aborted replays as an interrupted result, but the turn never
|
|
136
|
+
// finished, so it stays "interrupted" for hosts deciding whether to re-send.
|
|
137
|
+
else if (lastEvent.type === "event_msg" && lastEvent.payload?.["type"] === "turn_aborted")
|
|
138
|
+
lastTurn = "interrupted";
|
|
135
139
|
else if (codexLineToStreamEvents(lastEvent, { sessionId }).some((event) => event.type === "result"))
|
|
136
140
|
lastTurn = "completed";
|
|
137
141
|
else lastTurn = "interrupted";
|
|
@@ -57,6 +57,17 @@ function codexUserText(line: CodexTranscriptLine): string | null {
|
|
|
57
57
|
if (line.type === "event_msg" && line.payload?.["type"] === "user_message") {
|
|
58
58
|
return meaningfulHumanText(asString(line.payload["message"]));
|
|
59
59
|
}
|
|
60
|
+
// Paginated rollouts (`session_meta.history_mode: "paginated"`, written by
|
|
61
|
+
// Codex 0.142 and later) never write `user_message`. What the person typed
|
|
62
|
+
// is a completed `UserMessage` item instead, and legacy rollouts never
|
|
63
|
+
// persist that item, so each message is read once. The `response_item` user
|
|
64
|
+
// message beside it also carries injected context, so it is not the one read.
|
|
65
|
+
if (line.type === "event_msg" && line.payload?.["type"] === "item_completed") {
|
|
66
|
+
const item = asRecord(line.payload["item"]);
|
|
67
|
+
if (item?.["type"] === "UserMessage") {
|
|
68
|
+
return meaningfulHumanText(textFromContent(item["content"], new Set(["text", "input_text"])));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
60
71
|
// Older unwrapped rollouts have no event_msg mirror.
|
|
61
72
|
if (line.type === "message" && line.raw["role"] === "user") {
|
|
62
73
|
return meaningfulHumanText(textFromContent(
|
|
@@ -81,6 +81,61 @@ function parseStringArray(value: unknown): string[] {
|
|
|
81
81
|
* tells it to do — rendered no row for completions that reached it by the
|
|
82
82
|
* reconcile path. Mirrors the Claude gate in `claude/parse.ts`.
|
|
83
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* The name an MCP tool call goes by: `mcp__<server>__<tool>`, the name the
|
|
86
|
+
* model calls it by and the shape Claude reports, so hosts render both alike.
|
|
87
|
+
* Codex's `tool` can be dotted (`github.create_branch` on the `codex_apps`
|
|
88
|
+
* server); the model sees `mcp__codex_apps__github_create_branch`.
|
|
89
|
+
*/
|
|
90
|
+
export function codexMcpToolName(server: string, tool: string | null): string {
|
|
91
|
+
const clean = (part: string) => part.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
92
|
+
return tool ? `mcp__${clean(server)}__${clean(tool)}` : `mcp__${clean(server)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isMcpToolCallItem(itemType: string): boolean {
|
|
96
|
+
return itemType === "mcpToolCall" || itemType === "mcp_tool_call";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What an MCP tool call returned: its error, else its text content, else its structured content. */
|
|
100
|
+
function mcpToolCallOutput(item: Record<string, unknown>): string {
|
|
101
|
+
const error = parseObject(item["error"]);
|
|
102
|
+
const message = asString(error["message"], "");
|
|
103
|
+
if (message) return message;
|
|
104
|
+
const result = parseObject(item["result"]);
|
|
105
|
+
const content = Array.isArray(result["content"]) ? result["content"] : [];
|
|
106
|
+
const text = content
|
|
107
|
+
.map((part) => {
|
|
108
|
+
const block = parseObject(part);
|
|
109
|
+
return block["type"] === "text" ? asString(block["text"], "") : JSON.stringify(part);
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join("\n");
|
|
113
|
+
if (text) return text;
|
|
114
|
+
const structured = result["structuredContent"] ?? result["structured_content"];
|
|
115
|
+
return structured === undefined || structured === null ? "" : JSON.stringify(structured);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mcpToolCallEvent(
|
|
119
|
+
phase: "started" | "completed",
|
|
120
|
+
item: Record<string, unknown>,
|
|
121
|
+
itemId: string | null,
|
|
122
|
+
base: BaseStreamEventFields,
|
|
123
|
+
): StreamEvent {
|
|
124
|
+
const name = codexMcpToolName(asString(item["server"], ""), asNullableString(item["tool"]));
|
|
125
|
+
if (phase === "started") {
|
|
126
|
+
return { type: "tool_call", toolCallId: itemId, name, input: item["arguments"] ?? {}, ...base };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
type: "tool_result",
|
|
130
|
+
toolCallId: itemId,
|
|
131
|
+
toolName: name,
|
|
132
|
+
content: mcpToolCallOutput(item),
|
|
133
|
+
isError: item["status"] === "failed" || Object.keys(parseObject(item["error"])).length > 0,
|
|
134
|
+
exitCode: null,
|
|
135
|
+
...base,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
export function codexBackgroundTaskReport(
|
|
85
140
|
phase: "started" | "progress" | "completed",
|
|
86
141
|
status: "pending" | "running" | "paused" | "completed" | "failed" | "stopped" | null,
|
|
@@ -402,6 +457,7 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
|
|
|
402
457
|
...base,
|
|
403
458
|
};
|
|
404
459
|
}
|
|
460
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("started", item, itemId, base);
|
|
405
461
|
// reasoning, agentMessage, userMessage — wait for item/completed.
|
|
406
462
|
return null;
|
|
407
463
|
}
|
|
@@ -432,6 +488,7 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
|
|
|
432
488
|
...base,
|
|
433
489
|
};
|
|
434
490
|
}
|
|
491
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("completed", item, itemId, base);
|
|
435
492
|
if (itemType === "agentMessage") {
|
|
436
493
|
const directText = asString(item["text"], "");
|
|
437
494
|
if (directText || directText === "") {
|
|
@@ -690,6 +747,7 @@ function parseNdjsonEvent(
|
|
|
690
747
|
...base,
|
|
691
748
|
};
|
|
692
749
|
}
|
|
750
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("started", item, itemId, base);
|
|
693
751
|
}
|
|
694
752
|
|
|
695
753
|
if (type === "item.completed") {
|
|
@@ -721,6 +779,7 @@ function parseNdjsonEvent(
|
|
|
721
779
|
...base,
|
|
722
780
|
};
|
|
723
781
|
}
|
|
782
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("completed", item, itemId, base);
|
|
724
783
|
if (itemType === "agent_message") {
|
|
725
784
|
const phase = asMessagePhase(item["phase"]);
|
|
726
785
|
const directText = asString(item["text"], "");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval policy and sandbox a Codex app-server thread runs with.
|
|
3
|
+
*
|
|
4
|
+
* `codex app-server` reads root `-c key=value` overrides but drops the
|
|
5
|
+
* interactive flags (`--sandbox`, `--dangerously-bypass-approvals-and-sandbox`):
|
|
6
|
+
* they belong to the TUI, and the `app-server` subcommand never receives them
|
|
7
|
+
* (codex-rs `cli/src/main.rs`, verified on 0.160). Sessions spawned with those
|
|
8
|
+
* flags ran on Codex's own defaults instead: approvals `on-request` and a
|
|
9
|
+
* `workspace-write` or `read-only` sandbox, whatever the host asked for.
|
|
10
|
+
*
|
|
11
|
+
* So the policy is set twice, through the two channels app-server does read:
|
|
12
|
+
* the `-c` overrides at spawn (every thread the process runs, child agents and
|
|
13
|
+
* resumed threads included) and the `thread/start` / `thread/resume` params
|
|
14
|
+
* (the root thread, explicitly, so a resumed thread created under different
|
|
15
|
+
* settings takes the current ones).
|
|
16
|
+
*
|
|
17
|
+
* It matters beyond the sandbox. Under `never` with full disk access, Codex
|
|
18
|
+
* approves MCP and app tool calls itself (`mcp_permission_prompt_is_auto_approved`
|
|
19
|
+
* in codex-rs `codex-mcp`). Under `on-request` it asks the client first, through
|
|
20
|
+
* `mcpServer/elicitation/request`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ProviderConfig } from "../../types.js";
|
|
24
|
+
|
|
25
|
+
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
26
|
+
|
|
27
|
+
export interface CodexThreadPermissions {
|
|
28
|
+
/** Unset leaves Codex's configured default (`on-request` unless the user changed it). */
|
|
29
|
+
approvalPolicy?: "never";
|
|
30
|
+
/** Unset leaves Codex's configured default, which depends on the folder's trust. */
|
|
31
|
+
sandbox?: CodexSandboxMode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Plan mode wins over `skipPermissions`, as it does for the spawn flags:
|
|
36
|
+
* read-only, with approvals left on so an escalation still reaches the host.
|
|
37
|
+
* Anything else leaves Codex's defaults, which is what no flag ever meant.
|
|
38
|
+
*/
|
|
39
|
+
export function codexThreadPermissions(
|
|
40
|
+
config: Pick<ProviderConfig, "planMode" | "skipPermissions"> | undefined,
|
|
41
|
+
): CodexThreadPermissions {
|
|
42
|
+
if (config?.planMode) return { sandbox: "read-only" };
|
|
43
|
+
if (config?.skipPermissions) return { approvalPolicy: "never", sandbox: "danger-full-access" };
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The same settings as root `-c` overrides, placed before the `app-server` subcommand. */
|
|
48
|
+
export function codexPermissionConfigArgs(permissions: CodexThreadPermissions): string[] {
|
|
49
|
+
const args: string[] = [];
|
|
50
|
+
if (permissions.approvalPolicy) args.push("-c", `approval_policy="${permissions.approvalPolicy}"`);
|
|
51
|
+
if (permissions.sandbox) args.push("-c", `sandbox_mode="${permissions.sandbox}"`);
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The same settings as `thread/start` / `thread/resume` params. */
|
|
56
|
+
export function codexPermissionThreadParams(permissions: CodexThreadPermissions): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
...(permissions.approvalPolicy ? { approvalPolicy: permissions.approvalPolicy } : {}),
|
|
59
|
+
...(permissions.sandbox ? { sandbox: permissions.sandbox } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|