@agentex/agent 0.0.40 → 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 (35) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +24 -4
  3. package/dist/providers/antigravity/execute.d.ts.map +1 -1
  4. package/dist/providers/antigravity/execute.js +9 -3
  5. package/dist/providers/antigravity/execute.js.map +1 -1
  6. package/dist/providers/antigravity/runtime.d.ts +4 -2
  7. package/dist/providers/antigravity/runtime.d.ts.map +1 -1
  8. package/dist/providers/antigravity/runtime.js +7 -4
  9. package/dist/providers/antigravity/runtime.js.map +1 -1
  10. package/dist/providers/antigravity/session.d.ts.map +1 -1
  11. package/dist/providers/antigravity/session.js +59 -17
  12. package/dist/providers/antigravity/session.js.map +1 -1
  13. package/dist/providers/codex/parse.d.ts +7 -0
  14. package/dist/providers/codex/parse.d.ts.map +1 -1
  15. package/dist/providers/codex/parse.js +56 -0
  16. package/dist/providers/codex/parse.js.map +1 -1
  17. package/dist/providers/codex/permissions.d.ts +40 -0
  18. package/dist/providers/codex/permissions.d.ts.map +1 -0
  19. package/dist/providers/codex/permissions.js +50 -0
  20. package/dist/providers/codex/permissions.js.map +1 -0
  21. package/dist/providers/codex/session.d.ts +34 -0
  22. package/dist/providers/codex/session.d.ts.map +1 -1
  23. package/dist/providers/codex/session.js +213 -20
  24. package/dist/providers/codex/session.js.map +1 -1
  25. package/dist/utils/auth.d.ts.map +1 -1
  26. package/dist/utils/auth.js +12 -10
  27. package/dist/utils/auth.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/providers/antigravity/execute.ts +10 -3
  30. package/src/providers/antigravity/runtime.ts +7 -4
  31. package/src/providers/antigravity/session.ts +59 -18
  32. package/src/providers/codex/parse.ts +59 -0
  33. package/src/providers/codex/permissions.ts +61 -0
  34. package/src/providers/codex/session.ts +238 -19
  35. package/src/utils/auth.ts +12 -11
@@ -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;
@@ -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
+ }
@@ -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");