@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +26 -4
  3. package/dist/history/fs.d.ts.map +1 -1
  4. package/dist/history/fs.js +6 -12
  5. package/dist/history/fs.js.map +1 -1
  6. package/dist/providers/antigravity/execute.d.ts.map +1 -1
  7. package/dist/providers/antigravity/execute.js +9 -3
  8. package/dist/providers/antigravity/execute.js.map +1 -1
  9. package/dist/providers/antigravity/runtime.d.ts +4 -2
  10. package/dist/providers/antigravity/runtime.d.ts.map +1 -1
  11. package/dist/providers/antigravity/runtime.js +7 -4
  12. package/dist/providers/antigravity/runtime.js.map +1 -1
  13. package/dist/providers/antigravity/session.d.ts.map +1 -1
  14. package/dist/providers/antigravity/session.js +59 -17
  15. package/dist/providers/antigravity/session.js.map +1 -1
  16. package/dist/providers/claude/attach.d.ts.map +1 -1
  17. package/dist/providers/claude/attach.js +4 -6
  18. package/dist/providers/claude/attach.js.map +1 -1
  19. package/dist/providers/claude/transcript.d.ts.map +1 -1
  20. package/dist/providers/claude/transcript.js +9 -20
  21. package/dist/providers/claude/transcript.js.map +1 -1
  22. package/dist/providers/codex/attach.d.ts.map +1 -1
  23. package/dist/providers/codex/attach.js +4 -0
  24. package/dist/providers/codex/attach.js.map +1 -1
  25. package/dist/providers/codex/history.d.ts.map +1 -1
  26. package/dist/providers/codex/history.js +11 -0
  27. package/dist/providers/codex/history.js.map +1 -1
  28. package/dist/providers/codex/parse.d.ts +7 -0
  29. package/dist/providers/codex/parse.d.ts.map +1 -1
  30. package/dist/providers/codex/parse.js +56 -0
  31. package/dist/providers/codex/parse.js.map +1 -1
  32. package/dist/providers/codex/permissions.d.ts +40 -0
  33. package/dist/providers/codex/permissions.d.ts.map +1 -0
  34. package/dist/providers/codex/permissions.js +50 -0
  35. package/dist/providers/codex/permissions.js.map +1 -0
  36. package/dist/providers/codex/session.d.ts +34 -0
  37. package/dist/providers/codex/session.d.ts.map +1 -1
  38. package/dist/providers/codex/session.js +213 -20
  39. package/dist/providers/codex/session.js.map +1 -1
  40. package/dist/providers/codex/transcript-normalize.d.ts +12 -3
  41. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -1
  42. package/dist/providers/codex/transcript-normalize.js +87 -10
  43. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  44. package/dist/providers/codex/transcript.d.ts.map +1 -1
  45. package/dist/providers/codex/transcript.js +5 -10
  46. package/dist/providers/codex/transcript.js.map +1 -1
  47. package/dist/providers/codex/usage-scanner.d.ts.map +1 -1
  48. package/dist/providers/codex/usage-scanner.js +4 -6
  49. package/dist/providers/codex/usage-scanner.js.map +1 -1
  50. package/dist/utils/auth.d.ts.map +1 -1
  51. package/dist/utils/auth.js +12 -10
  52. package/dist/utils/auth.js.map +1 -1
  53. package/dist/utils/jsonl-lines.d.ts +35 -0
  54. package/dist/utils/jsonl-lines.d.ts.map +1 -0
  55. package/dist/utils/jsonl-lines.js +61 -0
  56. package/dist/utils/jsonl-lines.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/history/fs.ts +6 -12
  59. package/src/providers/antigravity/execute.ts +10 -3
  60. package/src/providers/antigravity/runtime.ts +7 -4
  61. package/src/providers/antigravity/session.ts +59 -18
  62. package/src/providers/claude/attach.ts +4 -6
  63. package/src/providers/claude/transcript.ts +9 -22
  64. package/src/providers/codex/attach.ts +4 -0
  65. package/src/providers/codex/history.ts +11 -0
  66. package/src/providers/codex/parse.ts +59 -0
  67. package/src/providers/codex/permissions.ts +61 -0
  68. package/src/providers/codex/session.ts +238 -19
  69. package/src/providers/codex/transcript-normalize.ts +85 -10
  70. package/src/providers/codex/transcript.ts +5 -11
  71. package/src/providers/codex/usage-scanner.ts +4 -7
  72. package/src/utils/auth.ts +12 -11
  73. 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
- done: Promise<TurnResult>;
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
- /** Instructions go into the first message of a conversation this session starts. */
78
- private pendingInstructions: string | null;
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.pendingInstructions = this.conversationId ? null : instructions;
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
- const done = this.runTurn(text, turnId, abort, options);
126
- this.active = { turnId, abort, done };
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
- text: string,
182
+ message: string,
166
183
  turnId: string,
167
184
  abort: AbortController,
168
185
  options?: SendOptions,
169
186
  ): Promise<TurnResult> {
170
- await this.emitTurnEdge({ type: "turn_start", turnId, trigger: "send" });
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) => abort.signal.addEventListener("abort", () => resolve("aborted"), { once: true })),
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 = this.ensureConnection();
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(text);
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.catch(() => undefined);
379
+ await active.done?.catch(() => undefined);
339
380
  }
340
381
 
341
382
  async drain(): Promise<void> {
342
383
  this.draining = true;
343
- if (this.active) await this.active.done.catch(() => undefined);
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.catch(() => undefined);
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, { encoding: "utf8" });
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 lines) {
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({ encoding: "utf8", autoClose: false });
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 raw of rl) {
279
+ for await (const line of readJsonlLines(stream)) {
281
280
  if (++count > 50) break;
282
- const trimmed = raw.trim();
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) — readline
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 rl) {
391
- // readline strips the trailing `\n` (and the `\r` from `\r\n`). Claude
392
- // writes Unix line endings, so we account for `\n` only. A `\r\n` file
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: pos };
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
+ }