@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
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import type {
5
5
  BackgroundTaskReport,
6
6
  AgentSession,
7
+ ElicitationResponse,
7
8
  CancelResult,
8
9
  ClearGoalResult,
9
10
  GoalOptions,
@@ -28,11 +29,16 @@ import { translateEndpoint } from "../../utils/endpoint.js";
28
29
  import { injectWorkspaceSkills } from "../../utils/skills.js";
29
30
  import { resolveInstructions } from "../../utils/instructions.js";
30
31
  import { createToolNameTracker } from "../../utils/tool-names.js";
31
- import { parseCodexStreamLines, codexBackgroundTaskReport } from "./parse.js";
32
+ import { parseCodexStreamLines, codexBackgroundTaskReport, codexMcpToolName } from "./parse.js";
32
33
  import { withPlanModePreamble } from "./plan-mode.js";
33
34
  import { scanCodexSessionUsage } from "./usage-scanner.js";
34
35
  import { codexSessionCodec } from "./codec.js";
35
36
  import { parseCollaborationModes, resolveCollaborationModeParam } from "./modes.js";
37
+ import {
38
+ codexPermissionConfigArgs,
39
+ codexPermissionThreadParams,
40
+ codexThreadPermissions,
41
+ } from "./permissions.js";
36
42
 
37
43
  /**
38
44
  * Extract a resume thread id from session params (reusing the codec's
@@ -122,6 +128,52 @@ function buildCodexUserInputAnswers(
122
128
  return out;
123
129
  }
124
130
 
131
+ /** An MCP tool call Codex has started and not finished (`item/started`, `mcpToolCall`). */
132
+ interface InFlightMcpCall {
133
+ itemId: string;
134
+ threadId: string | null;
135
+ server: string;
136
+ tool: string;
137
+ arguments: unknown;
138
+ }
139
+
140
+ /**
141
+ * One line for an MCP tool approval from Codex's `tool_params_display`
142
+ * (`[{ name, display_name, value }]`), e.g.
143
+ * `Repository: acme/web · Branch name: fix/login · Base ref: main`.
144
+ */
145
+ function describeMcpToolParams(display: unknown): string | undefined {
146
+ if (!Array.isArray(display)) return undefined;
147
+ const parts: string[] = [];
148
+ for (const entry of display) {
149
+ if (typeof entry !== "object" || entry === null) continue;
150
+ const param = entry as Record<string, unknown>;
151
+ const label = str(param, "display_name") || str(param, "name");
152
+ if (!label) continue;
153
+ const raw = param["value"];
154
+ const value = typeof raw === "string" ? raw : JSON.stringify(raw);
155
+ if (value === undefined) continue;
156
+ parts.push(`${label}: ${value.length > 120 ? `${value.slice(0, 117)}...` : value}`);
157
+ }
158
+ return parts.length > 0 ? parts.join(" · ") : undefined;
159
+ }
160
+
161
+ /** The elicitation reply Codex expects: `action`, plus `content` only on an accept. */
162
+ function elicitationReply(resp: ElicitationResponse): Record<string, unknown> {
163
+ return {
164
+ action: resp.action,
165
+ ...(resp.action === "accept" && resp.content ? { content: resp.content } : {}),
166
+ };
167
+ }
168
+
169
+ /** JSON with object keys sorted, so two argument objects compare by value. */
170
+ function stableJson(value: unknown): string {
171
+ return JSON.stringify(value, (_key, v: unknown) =>
172
+ v && typeof v === "object" && !Array.isArray(v)
173
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
174
+ : v) ?? "";
175
+ }
176
+
125
177
  /** A pending `send()` whose `result` Promise hasn't settled yet. */
126
178
  interface PendingResult {
127
179
  resolve: (result: TurnResult) => void;
@@ -328,15 +380,13 @@ export async function createCodexSession(ctx: SessionContext): Promise<AgentSess
328
380
  // Spawn Codex in interactive JSON-RPC mode via the `app-server` subcommand
329
381
  // (codex-cli 0.130.0+; the old top-level `--json` flag was removed).
330
382
  //
331
- // Args order matters: `--sandbox` and `--dangerously-bypass-approvals-and-sandbox`
332
- // are TOP-LEVEL options and must come BEFORE the `app-server` subcommand.
383
+ // Approval policy and sandbox go in as root `-c` overrides, BEFORE the
384
+ // subcommand: app-server ignores `--sandbox` and
385
+ // `--dangerously-bypass-approvals-and-sandbox` (see ./permissions.ts). The
386
+ // handshake repeats them on the thread itself.
333
387
  // extraArgs land after the subcommand — semantics depend on the user's intent.
334
388
  const args = [...resolved.prefixArgs];
335
- if (config.planMode) {
336
- args.push("--sandbox", "read-only");
337
- } else if (config.skipPermissions) {
338
- args.push("--dangerously-bypass-approvals-and-sandbox");
339
- }
389
+ args.push(...codexPermissionConfigArgs(codexThreadPermissions(config)));
340
390
  // Custom endpoint model_providers overrides are top-level `-c` options, placed
341
391
  // with the other top-level flags before the `app-server` subcommand (the
342
392
  // position that is always valid for global options).
@@ -437,6 +487,9 @@ export class CodexSessionImpl implements AgentSession {
437
487
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
438
488
  private readonly _trackToolName = createToolNameTracker();
439
489
 
490
+ /** MCP tool calls started and not yet completed, by item id, for pairing approvals. */
491
+ private readonly _mcpCallsInFlight = new Map<string, InFlightMcpCall>();
492
+
440
493
  // Per-turn accumulators. Cleared after each result delivery so a subsequent
441
494
  // turn's events don't inherit stale values.
442
495
  private _turnSummary: string | null = null;
@@ -771,6 +824,10 @@ export class CodexSessionImpl implements AgentSession {
771
824
  this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
772
825
  }
773
826
 
827
+ private rpcError(id: number, code: number, message: string): void {
828
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
829
+ }
830
+
774
831
  // -------------------------------------------------------------------------
775
832
  // Handshake
776
833
  // -------------------------------------------------------------------------
@@ -791,9 +848,12 @@ export class CodexSessionImpl implements AgentSession {
791
848
  // otherwise start a fresh one. `thread/resume` continues the SAME thread
792
849
  // with its full context retained — distinct from `thread/fork`, which is
793
850
  // a divergent rewind copy. The thread keeps its original cwd/model, so we
794
- // pass only the thread id (+ refreshed developer instructions).
851
+ // pass only the thread id (+ refreshed developer instructions, and the
852
+ // approval policy and sandbox this session runs with, which may differ
853
+ // from the ones the thread was created under).
854
+ const permissionParams = codexPermissionThreadParams(codexThreadPermissions(this.ctx.config));
795
855
  if (this._resumeThreadId) {
796
- const resumeParams: Record<string, unknown> = { threadId: this._resumeThreadId };
856
+ const resumeParams: Record<string, unknown> = { threadId: this._resumeThreadId, ...permissionParams };
797
857
  if (this.instructions) resumeParams["developerInstructions"] = this.instructions;
798
858
  try {
799
859
  const res = await this.rpcRequest("thread/resume", resumeParams);
@@ -830,7 +890,7 @@ export class CodexSessionImpl implements AgentSession {
830
890
  }
831
891
 
832
892
  // thread/start (fresh)
833
- const threadParams: Record<string, unknown> = { cwd: this.cwd };
893
+ const threadParams: Record<string, unknown> = { cwd: this.cwd, ...permissionParams };
834
894
  if (this.model) threadParams["model"] = this.model;
835
895
  if (this.instructions) threadParams["developerInstructions"] = this.instructions;
836
896
 
@@ -1236,16 +1296,174 @@ export class CodexSessionImpl implements AgentSession {
1236
1296
  ) {
1237
1297
  // `tool/requestUserInput` is the legacy method name on older codex builds.
1238
1298
  void this.handleUserInputRequest(id, params);
1299
+ } else if (method === "mcpServer/elicitation/request") {
1300
+ void this.handleMcpElicitation(id, params);
1301
+ } else {
1302
+ // A request this file does not handle. Every server request the protocol
1303
+ // defines declares required response fields, so the `{}` this used to
1304
+ // send was schema-invalid, and Codex read it as a refusal: that is how
1305
+ // MCP tool approvals were silently declined before they were handled
1306
+ // here. "Method not found" is the honest answer and fails visibly.
1307
+ // What's left is opt-in (`attestation/generate`), never sent to a client
1308
+ // that registers no dynamic tools and no external auth (`item/tool/call`,
1309
+ // `account/chatgptAuthTokens/refresh`), or v1-only (`applyPatchApproval`,
1310
+ // `execCommandApproval`).
1311
+ this.rpcError(id, -32601, `agentex does not handle the ${method} request`);
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Handle `mcpServer/elicitation/request`. Codex sends two kinds.
1317
+ *
1318
+ * With `_meta.codex_approval_kind: "mcp_tool_call"` it is a tool permission:
1319
+ * an MCP tool (a ChatGPT app such as GitHub, or a server in the user's
1320
+ * config.toml) that needs a yes before it runs. Codex asks only while the
1321
+ * thread's approval policy is on; under `never` with full access it approves
1322
+ * these itself. It goes to `onUserInputRequest` like every other approval.
1323
+ *
1324
+ * Anything else is an MCP elicitation proper, a form or a URL an MCP server
1325
+ * asks the user to fill in or open, and goes to `onElicitation`.
1326
+ *
1327
+ * The reply must carry `action`. Before this was handled, the generic `{}`
1328
+ * reply was read as a decline, so every app write failed as "user rejected
1329
+ * MCP tool call" with nobody asked.
1330
+ */
1331
+ private async handleMcpElicitation(id: number, params: Record<string, unknown>): Promise<void> {
1332
+ const meta = asObj(params, "_meta");
1333
+ if (str(meta, "codex_approval_kind") === "mcp_tool_call") {
1334
+ await this.handleMcpToolApproval(id, params, meta);
1239
1335
  } else {
1240
- // Unknown server request. `{}` is a best-effort reply, NOT a guarantee
1241
- // that the turn proceeds: every server request the protocol defines but
1242
- // this file does not handle declares required response fields, so an
1243
- // empty result is schema-invalid for all of them. A JSON-RPC error reply
1244
- // would be the honest answer, but `rpcResponse` has no error path and
1245
- // introducing one changes behavior (an error can abort a turn where this
1246
- // limps along), so that is a deliberate follow-up rather than a fix here.
1247
- this.rpcResponse(id, {});
1336
+ await this.handleElicitation(id, params);
1337
+ }
1338
+ }
1339
+
1340
+ private async handleMcpToolApproval(
1341
+ id: number,
1342
+ params: Record<string, unknown>,
1343
+ meta: Record<string, unknown>,
1344
+ ): Promise<void> {
1345
+ const requestThreadId = str(params, "threadId") || null;
1346
+ const foreign = this.isForeignThread(requestThreadId);
1347
+ if (!foreign) this._state = "waiting_for_approval";
1348
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
1349
+
1350
+ // No host handler: allowed, like the command and file-change approvals.
1351
+ if (!this.ctx.onUserInputRequest) {
1352
+ this.rpcResponse(id, { action: "accept" });
1353
+ restore();
1354
+ return;
1355
+ }
1356
+
1357
+ // The request names neither the tool nor the call it gates. The call has
1358
+ // already started (Codex reports `item/started` before it asks), so pair
1359
+ // the two through the in-flight MCP calls.
1360
+ const server = str(params, "serverName");
1361
+ const toolParams = meta["tool_params"];
1362
+ const call = this.findMcpCallForApproval(requestThreadId, server, toolParams);
1363
+ const input = call?.arguments ?? toolParams;
1364
+
1365
+ try {
1366
+ const resp = await this.ctx.onUserInputRequest({
1367
+ toolName: codexMcpToolName(call?.server ?? server, call?.tool ?? null),
1368
+ input: input && typeof input === "object" && !Array.isArray(input)
1369
+ ? input as Record<string, unknown>
1370
+ : {},
1371
+ // The call's item id lines the approval up with its tool_call event,
1372
+ // as `itemId` does for command approvals. Without a match, a fresh id:
1373
+ // hosts key pending approvals on this, so it must never repeat.
1374
+ toolUseId: call?.itemId ?? `codex-mcp-approval-${randomUUID()}`,
1375
+ // Codex's own question, e.g. `Allow GitHub to run tool "create_branch"?`.
1376
+ title: str(params, "message") || undefined,
1377
+ displayName: str(meta, "tool_title") || str(meta, "connector_name") || undefined,
1378
+ description: describeMcpToolParams(meta["tool_params_display"]),
1379
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
1380
+ });
1381
+ // A plain accept runs this one call. Codex also offers "for this
1382
+ // session" and "always" (`_meta.persist`), which agentex's yes/no
1383
+ // permission has no way to express, so the host is asked each time.
1384
+ this.rpcResponse(id, { action: resp.allow ? "accept" : "decline" });
1385
+ } catch {
1386
+ this.rpcResponse(id, { action: "decline" });
1387
+ }
1388
+
1389
+ restore();
1390
+ }
1391
+
1392
+ /** An MCP server's form or URL request, for the host's `onElicitation`. Declined without one. */
1393
+ private async handleElicitation(id: number, params: Record<string, unknown>): Promise<void> {
1394
+ if (!this.ctx.onElicitation) {
1395
+ this.rpcResponse(id, { action: "decline" });
1396
+ return;
1397
+ }
1398
+
1399
+ const requestThreadId = str(params, "threadId") || null;
1400
+ const foreign = this.isForeignThread(requestThreadId);
1401
+ if (!foreign) this._state = "waiting_for_input";
1402
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_input"); };
1403
+
1404
+ const url = str(params, "url");
1405
+ const elicitationId = str(params, "elicitationId");
1406
+ const schema = params["requestedSchema"];
1407
+ try {
1408
+ const resp = await this.ctx.onElicitation({
1409
+ mcpServerName: str(params, "serverName"),
1410
+ message: str(params, "message"),
1411
+ mode: str(params, "mode") === "url" ? "url" : "form",
1412
+ ...(url ? { url } : {}),
1413
+ ...(elicitationId ? { elicitationId } : {}),
1414
+ ...(schema && typeof schema === "object" && !Array.isArray(schema)
1415
+ ? { requestedSchema: schema as Record<string, unknown> }
1416
+ : {}),
1417
+ });
1418
+ this.rpcResponse(id, elicitationReply(resp));
1419
+ } catch {
1420
+ this.rpcResponse(id, { action: "cancel" });
1421
+ }
1422
+
1423
+ restore();
1424
+ }
1425
+
1426
+ /**
1427
+ * Remember the MCP tool calls Codex has started and not finished, on every
1428
+ * thread, so an approval request can be paired with the call it gates.
1429
+ */
1430
+ private trackMcpToolCall(method: string, params: Record<string, unknown>): void {
1431
+ if (method !== "item/started" && method !== "item/completed") return;
1432
+ const item = asObj(params, "item");
1433
+ if (str(item, "type") !== "mcpToolCall") return;
1434
+ const itemId = str(item, "id");
1435
+ if (!itemId) return;
1436
+ if (method === "item/completed") {
1437
+ this._mcpCallsInFlight.delete(itemId);
1438
+ return;
1439
+ }
1440
+ this._mcpCallsInFlight.set(itemId, {
1441
+ itemId,
1442
+ threadId: str(params, "threadId") || null,
1443
+ server: str(item, "server"),
1444
+ tool: str(item, "tool"),
1445
+ arguments: item["arguments"],
1446
+ });
1447
+ }
1448
+
1449
+ /**
1450
+ * The in-flight call an approval is about: on the asking thread and server,
1451
+ * the one whose arguments match the approval's `tool_params`, else the most
1452
+ * recently started (Codex asks right after it starts a call).
1453
+ */
1454
+ private findMcpCallForApproval(
1455
+ threadId: string | null,
1456
+ server: string,
1457
+ toolParams: unknown,
1458
+ ): InFlightMcpCall | null {
1459
+ const candidates = [...this._mcpCallsInFlight.values()].filter((call) =>
1460
+ call.server === server && (!threadId || !call.threadId || call.threadId === threadId));
1461
+ if (toolParams !== undefined) {
1462
+ const wanted = stableJson(toolParams);
1463
+ const exact = candidates.find((call) => stableJson(call.arguments) === wanted);
1464
+ if (exact) return exact;
1248
1465
  }
1466
+ return candidates.at(-1) ?? null;
1249
1467
  }
1250
1468
 
1251
1469
  /**
@@ -1965,6 +2183,7 @@ export class CodexSessionImpl implements AgentSession {
1965
2183
 
1966
2184
  private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
1967
2185
  if (this._state === "closed") return;
2186
+ this.trackMcpToolCall(method, params);
1968
2187
  // codex/event — legacy wrapper
1969
2188
  if (method === "codex/event") {
1970
2189
  const innerMsg = str(params, "msg");
@@ -12,11 +12,20 @@ import type { CodexTranscriptLine } from "./transcript.js";
12
12
  * response_item / reasoning → thinking
13
13
  * response_item / function_call → tool_call
14
14
  * response_item / function_call_output → tool_result
15
- * event_msg / task_complete → result (completed)
15
+ * response_item / custom_tool_call → tool_call
16
+ * response_item / custom_tool_call_output → tool_result
17
+ * event_msg / task_complete → result (completed, or failed with `error`)
18
+ * event_msg / turn_aborted → result (interrupted)
19
+ *
20
+ * Current Codex runs shell work through freeform tools: `exec` takes a script
21
+ * and `apply_patch` takes a patch, so most tool activity in a current rollout
22
+ * is a `custom_tool_call`. Its `input` is the raw string the model wrote.
16
23
  *
17
24
  * Everything else — `session_meta`, `turn_context`, `task_started`,
18
- * `token_count`, `agent_message`/`agent_reasoning` duplicates, user/developer
19
- * messages, unwrapped legacy lines, unknown types — yields `[]`.
25
+ * `token_count`, `agent_message`/`agent_reasoning` duplicates, the
26
+ * `item_completed` mirrors of response items, inter-agent `agent_message`
27
+ * items, user/developer messages, unwrapped legacy lines, unknown types —
28
+ * yields `[]`.
20
29
  *
21
30
  * The on-disk vocabulary is Codex-internal and version-shifting, so every field
22
31
  * is read defensively and a weird line NEVER throws — it returns `[]`. Codex
@@ -89,7 +98,19 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
89
98
  ];
90
99
  }
91
100
 
92
- if (innerType === "function_call_output") {
101
+ if (innerType === "custom_tool_call") {
102
+ return [
103
+ {
104
+ type: "tool_call",
105
+ toolCallId: str(payload["call_id"]) ?? str(payload["id"]),
106
+ name: str(payload["name"]) ?? "custom_tool_call",
107
+ input: typeof payload["input"] === "string" ? payload["input"] : null,
108
+ ...base,
109
+ },
110
+ ];
111
+ }
112
+
113
+ if (innerType === "function_call_output" || innerType === "custom_tool_call_output") {
93
114
  return [
94
115
  {
95
116
  type: "tool_result",
@@ -110,16 +131,36 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
110
131
 
111
132
  if (line.type === "event_msg") {
112
133
  if (innerType === "task_complete") {
134
+ // A turn that failed (for example a model the account cannot use) still
135
+ // ends in task_complete, with `error` set and no agent message.
136
+ const error = turnErrorMessage(payload["error"]);
137
+ return [
138
+ {
139
+ type: "result",
140
+ text: error ?? str(payload["last_agent_message"]) ?? "",
141
+ costUsd: null,
142
+ isError: error !== null,
143
+ stopReason: null,
144
+ terminalReason: error !== null ? "failed" : "completed",
145
+ numTurns: null,
146
+ durationMs: num(payload["duration_ms"]),
147
+ ...base,
148
+ },
149
+ ];
150
+ }
151
+
152
+ if (innerType === "turn_aborted") {
153
+ // Same shape as a live `turn/completed` with status "interrupted".
113
154
  return [
114
155
  {
115
156
  type: "result",
116
- text: str(payload["last_agent_message"]) ?? "",
157
+ text: "",
117
158
  costUsd: null,
118
159
  isError: false,
119
160
  stopReason: null,
120
- terminalReason: "completed",
161
+ terminalReason: str(payload["reason"]) ?? "interrupted",
121
162
  numTurns: null,
122
- durationMs: null,
163
+ durationMs: num(payload["duration_ms"]),
123
164
  ...base,
124
165
  },
125
166
  ];
@@ -135,6 +176,28 @@ function str(v: unknown): string | null {
135
176
  return typeof v === "string" && v.length > 0 ? v : null;
136
177
  }
137
178
 
179
+ /** Finite number or null. */
180
+ function num(v: unknown): number | null {
181
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
182
+ }
183
+
184
+ /**
185
+ * `task_complete.error` is `{ message, codex_error_info }`. The message is
186
+ * often the API's JSON error body, so prefer its inner `error.message`.
187
+ */
188
+ function turnErrorMessage(error: unknown): string | null {
189
+ if (typeof error === "string") return str(error);
190
+ if (typeof error !== "object" || error === null) return null;
191
+ const message = str((error as Record<string, unknown>)["message"]);
192
+ if (!message) return "Turn failed";
193
+ try {
194
+ const body = JSON.parse(message) as { error?: { message?: unknown }; message?: unknown };
195
+ return str(body?.error?.message) ?? str(body?.message) ?? message;
196
+ } catch {
197
+ return message;
198
+ }
199
+ }
200
+
138
201
  function messagePhase(v: unknown): "commentary" | "final_answer" | undefined {
139
202
  return v === "commentary" || v === "final_answer" ? v : undefined;
140
203
  }
@@ -171,13 +234,25 @@ function extractReasoningSummary(summary: unknown): string | null {
171
234
  }
172
235
 
173
236
  /**
174
- * `function_call_output.output` is usually a string, but some versions wrap it
175
- * as `{ output: "...", metadata: {...} }`. Extract the readable text; fall back
176
- * to a JSON dump so nothing is silently lost. Returns "" when unreadable
237
+ * Tool output is usually a string. Some versions wrap it as
238
+ * `{ output: "...", metadata: {...} }`, and Codex 0.142+ also writes a list of
239
+ * content parts (`input_text`, `input_image`). Extract the readable text, one
240
+ * part per line, with images left in `raw`. Fall back to a JSON dump
241
+ * so nothing is silently lost. Returns "" when unreadable
177
242
  * (`tool_result.content` is a required string).
178
243
  */
179
244
  function extractOutputText(output: unknown): string {
180
245
  if (typeof output === "string") return output;
246
+ if (Array.isArray(output)) {
247
+ let text = "";
248
+ for (const part of output) {
249
+ if (typeof part !== "object" || part === null) continue;
250
+ const partText = (part as { text?: unknown }).text;
251
+ if (typeof partText !== "string" || partText.length === 0) continue;
252
+ text += text && !text.endsWith("\n") ? `\n${partText}` : partText;
253
+ }
254
+ return text;
255
+ }
181
256
  if (output && typeof output === "object" && !Array.isArray(output)) {
182
257
  const inner = (output as Record<string, unknown>)["output"];
183
258
  if (typeof inner === "string") return inner;
@@ -28,10 +28,10 @@ import { createReadStream } from "node:fs";
28
28
  import { readdir, stat, open as fsOpen } from "node:fs/promises";
29
29
  import * as os from "node:os";
30
30
  import * as path from "node:path";
31
- import * as readline from "node:readline";
32
31
 
33
32
  import { getDefaultRuntimeHome, getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
34
33
  import type { FoundTranscript, TranscriptOps } from "../../types.js";
34
+ import { readJsonlLines } from "../../utils/jsonl-lines.js";
35
35
 
36
36
  /** Bytes scanned from the tail of the file in {@link peekCodexTranscript}. */
37
37
  const PEEK_TAIL_BYTES = 16 * 1024;
@@ -316,16 +316,11 @@ export async function* readCodexTranscript(
316
316
 
317
317
  const stream = createReadStream(filePath, { start: fromOffset, encoding: undefined });
318
318
  stream.on("error", () => {});
319
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
320
319
 
321
320
  const fileIdentity = rolloutIdentityFromPath(filePath);
322
- let pos = fromOffset;
323
321
  try {
324
- for await (const line of rl) {
325
- const lineStart = pos;
326
- pos += Buffer.byteLength(line, "utf8") + 1;
327
-
328
- const trimmed = line.trim();
322
+ for await (const line of readJsonlLines(stream, fromOffset)) {
323
+ const trimmed = line.text.trim();
329
324
  if (!trimmed) continue;
330
325
 
331
326
  const parsed = parseCodexLine(trimmed);
@@ -334,15 +329,14 @@ export async function* readCodexTranscript(
334
329
  // Replay-stable synthetic identity: (rollout identity, line start offset).
335
330
  // Codex emits no native per-event uuid, so this is the idempotency key
336
331
  // hosts use to dedup transcript replays. Deterministic across reads.
337
- parsed.eventId = `codex:${fileIdentity}:${lineStart}`;
332
+ parsed.eventId = `codex:${fileIdentity}:${line.start}`;
338
333
 
339
- yield { event: parsed, offset: pos };
334
+ yield { event: parsed, offset: line.end };
340
335
  }
341
336
  } catch (err) {
342
337
  const e = err as NodeJS.ErrnoException;
343
338
  if (e?.code !== "ENOENT") throw err;
344
339
  } finally {
345
- rl.close();
346
340
  stream.destroy();
347
341
  }
348
342
  }
@@ -1,9 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
- import * as readline from "node:readline";
5
4
  import { createReadStream } from "node:fs";
6
5
  import type { TokenUsage } from "../../types.js";
6
+ import { readJsonlLines } from "../../utils/jsonl-lines.js";
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Log path resolution
@@ -104,16 +104,14 @@ async function scanFile(
104
104
  ): Promise<void> {
105
105
  let stream: ReturnType<typeof createReadStream>;
106
106
  try {
107
- stream = createReadStream(filePath, { encoding: "utf-8" });
107
+ stream = createReadStream(filePath);
108
108
  } catch {
109
109
  return;
110
110
  }
111
111
 
112
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
113
-
114
112
  try {
115
- for await (const line of rl) {
116
- const trimmed = line.trim();
113
+ for await (const line of readJsonlLines(stream)) {
114
+ const trimmed = line.text.trim();
117
115
  if (!trimmed) continue;
118
116
 
119
117
  let parsed: Record<string, unknown>;
@@ -172,7 +170,6 @@ async function scanFile(
172
170
  usageByModel.set(model, existing);
173
171
  }
174
172
  } finally {
175
- rl.close();
176
173
  stream.destroy();
177
174
  }
178
175
  }
package/src/utils/auth.ts CHANGED
@@ -96,7 +96,7 @@ export function detectAuth(providerType: string, env: Record<string, string>): R
96
96
  return { method: "subscription", billingType: "subscription" };
97
97
  }
98
98
  case "antigravity": {
99
- if (hasEnv(env, "GEMINI_API_KEY") && readAgyModelProvider() === "gemini") {
99
+ if (hasEnv(env, "GEMINI_API_KEY") && readAgyModelProvider(env) === "gemini") {
100
100
  return { method: "api_key", billingType: "api" };
101
101
  }
102
102
  return { method: "subscription", billingType: "subscription" };
@@ -448,19 +448,20 @@ async function resolveGeminiAuth(ctx?: AuthResolveContext): Promise<AuthReport>
448
448
  // API (`"modelProvider": "gemini"`); the CLI ignores the variable otherwise.
449
449
  // ---------------------------------------------------------------------------
450
450
 
451
- /** Home directory as agy (a Go binary) resolves it: os.UserHomeDir semantics. */
452
- function agyHomeDir(): string {
453
- const fromEnv = process.platform === "win32" ? process.env["USERPROFILE"] : process.env["HOME"];
451
+ /**
452
+ * Home directory as the spawned agy (a Go binary) resolves it: os.UserHomeDir
453
+ * semantics against the child's environment, so a host that gives agy its own
454
+ * HOME reads that home's settings.
455
+ */
456
+ function agyHomeDir(env: Record<string, string | undefined>): string {
457
+ const fromEnv = process.platform === "win32" ? env["USERPROFILE"] : env["HOME"];
454
458
  return fromEnv && fromEnv.trim() ? fromEnv : os.homedir();
455
459
  }
456
460
 
457
- function agySettingsPath(): string {
458
- return path.join(agyHomeDir(), ".gemini", "antigravity-cli", "settings.json");
459
- }
460
-
461
- function readAgyModelProvider(): string | null {
461
+ function readAgyModelProvider(env: Record<string, string | undefined>): string | null {
462
462
  try {
463
- const settings = JSON.parse(readFileSync(agySettingsPath(), "utf-8")) as Record<string, unknown>;
463
+ const settingsPath = path.join(agyHomeDir(env), ".gemini", "antigravity-cli", "settings.json");
464
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
464
465
  return typeof settings["modelProvider"] === "string" ? settings["modelProvider"] : null;
465
466
  } catch {
466
467
  return null;
@@ -470,7 +471,7 @@ function readAgyModelProvider(): string | null {
470
471
  async function resolveAntigravityAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
471
472
  const env = buildEnv(ctx?.env);
472
473
  const binary = await checkBinary("agy", ctx?.command, env);
473
- const apiKeyProvider = readAgyModelProvider() === "gemini";
474
+ const apiKeyProvider = readAgyModelProvider(env) === "gemini";
474
475
  let signedIn = false;
475
476
  let usedCli = false;
476
477
  if (binary.installed && binary.resolvedPath) {