@agentex/agent 0.0.16 → 0.0.19

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 (165) hide show
  1. package/README.md +158 -18
  2. package/dist/derived.d.ts +69 -0
  3. package/dist/derived.d.ts.map +1 -0
  4. package/dist/derived.js +218 -0
  5. package/dist/derived.js.map +1 -0
  6. package/dist/index.d.ts +8 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/providers/_shared/http-agent.d.ts +39 -0
  11. package/dist/providers/_shared/http-agent.d.ts.map +1 -0
  12. package/dist/providers/_shared/http-agent.js +265 -0
  13. package/dist/providers/_shared/http-agent.js.map +1 -0
  14. package/dist/providers/acp/index.d.ts +29 -0
  15. package/dist/providers/acp/index.d.ts.map +1 -0
  16. package/dist/providers/acp/index.js +153 -0
  17. package/dist/providers/acp/index.js.map +1 -0
  18. package/dist/providers/acp/parse.d.ts +22 -0
  19. package/dist/providers/acp/parse.d.ts.map +1 -0
  20. package/dist/providers/acp/parse.js +122 -0
  21. package/dist/providers/acp/parse.js.map +1 -0
  22. package/dist/providers/acp/session.d.ts +36 -0
  23. package/dist/providers/acp/session.d.ts.map +1 -0
  24. package/dist/providers/acp/session.js +487 -0
  25. package/dist/providers/acp/session.js.map +1 -0
  26. package/dist/providers/claude/execute.d.ts.map +1 -1
  27. package/dist/providers/claude/execute.js +6 -2
  28. package/dist/providers/claude/execute.js.map +1 -1
  29. package/dist/providers/claude/index.d.ts.map +1 -1
  30. package/dist/providers/claude/index.js +3 -0
  31. package/dist/providers/claude/index.js.map +1 -1
  32. package/dist/providers/claude/parse.d.ts.map +1 -1
  33. package/dist/providers/claude/parse.js +8 -0
  34. package/dist/providers/claude/parse.js.map +1 -1
  35. package/dist/providers/claude/session.d.ts +43 -4
  36. package/dist/providers/claude/session.d.ts.map +1 -1
  37. package/dist/providers/claude/session.js +215 -30
  38. package/dist/providers/claude/session.js.map +1 -1
  39. package/dist/providers/codex/execute.d.ts.map +1 -1
  40. package/dist/providers/codex/execute.js +5 -1
  41. package/dist/providers/codex/execute.js.map +1 -1
  42. package/dist/providers/codex/index.d.ts.map +1 -1
  43. package/dist/providers/codex/index.js +5 -0
  44. package/dist/providers/codex/index.js.map +1 -1
  45. package/dist/providers/codex/modes.d.ts +35 -0
  46. package/dist/providers/codex/modes.d.ts.map +1 -0
  47. package/dist/providers/codex/modes.js +148 -0
  48. package/dist/providers/codex/modes.js.map +1 -0
  49. package/dist/providers/codex/parse.d.ts.map +1 -1
  50. package/dist/providers/codex/parse.js +4 -0
  51. package/dist/providers/codex/parse.js.map +1 -1
  52. package/dist/providers/codex/session.d.ts +45 -4
  53. package/dist/providers/codex/session.d.ts.map +1 -1
  54. package/dist/providers/codex/session.js +408 -66
  55. package/dist/providers/codex/session.js.map +1 -1
  56. package/dist/providers/copilot/index.d.ts +15 -0
  57. package/dist/providers/copilot/index.d.ts.map +1 -0
  58. package/dist/providers/copilot/index.js +19 -0
  59. package/dist/providers/copilot/index.js.map +1 -0
  60. package/dist/providers/cursor/index.d.ts.map +1 -1
  61. package/dist/providers/cursor/index.js +3 -0
  62. package/dist/providers/cursor/index.js.map +1 -1
  63. package/dist/providers/cursor/parse.d.ts.map +1 -1
  64. package/dist/providers/cursor/parse.js +1 -0
  65. package/dist/providers/cursor/parse.js.map +1 -1
  66. package/dist/providers/gemini/index.d.ts.map +1 -1
  67. package/dist/providers/gemini/index.js +16 -18
  68. package/dist/providers/gemini/index.js.map +1 -1
  69. package/dist/providers/openclaw/execute.d.ts +5 -0
  70. package/dist/providers/openclaw/execute.d.ts.map +1 -1
  71. package/dist/providers/openclaw/execute.js +13 -173
  72. package/dist/providers/openclaw/execute.js.map +1 -1
  73. package/dist/providers/openclaw/index.d.ts.map +1 -1
  74. package/dist/providers/openclaw/index.js +3 -0
  75. package/dist/providers/openclaw/index.js.map +1 -1
  76. package/dist/providers/opencode/event-parse.d.ts +23 -0
  77. package/dist/providers/opencode/event-parse.d.ts.map +1 -0
  78. package/dist/providers/opencode/event-parse.js +128 -0
  79. package/dist/providers/opencode/event-parse.js.map +1 -0
  80. package/dist/providers/opencode/http-session.d.ts +4 -0
  81. package/dist/providers/opencode/http-session.d.ts.map +1 -0
  82. package/dist/providers/opencode/http-session.js +376 -0
  83. package/dist/providers/opencode/http-session.js.map +1 -0
  84. package/dist/providers/opencode/index.d.ts.map +1 -1
  85. package/dist/providers/opencode/index.js +8 -1
  86. package/dist/providers/opencode/index.js.map +1 -1
  87. package/dist/providers/opencode/parse.d.ts.map +1 -1
  88. package/dist/providers/opencode/parse.js +1 -0
  89. package/dist/providers/opencode/parse.js.map +1 -1
  90. package/dist/providers/opencode/server.d.ts +8 -0
  91. package/dist/providers/opencode/server.d.ts.map +1 -0
  92. package/dist/providers/opencode/server.js +0 -0
  93. package/dist/providers/opencode/server.js.map +1 -0
  94. package/dist/providers/pi/index.d.ts.map +1 -1
  95. package/dist/providers/pi/index.js +8 -1
  96. package/dist/providers/pi/index.js.map +1 -1
  97. package/dist/providers/pi/parse.d.ts.map +1 -1
  98. package/dist/providers/pi/parse.js +1 -0
  99. package/dist/providers/pi/parse.js.map +1 -1
  100. package/dist/providers/pi/session.d.ts +40 -0
  101. package/dist/providers/pi/session.d.ts.map +1 -0
  102. package/dist/providers/pi/session.js +328 -0
  103. package/dist/providers/pi/session.js.map +1 -0
  104. package/dist/providers/process/index.d.ts.map +1 -1
  105. package/dist/providers/process/index.js +3 -0
  106. package/dist/providers/process/index.js.map +1 -1
  107. package/dist/registry.d.ts +1 -0
  108. package/dist/registry.d.ts.map +1 -1
  109. package/dist/registry.js +6 -0
  110. package/dist/registry.js.map +1 -1
  111. package/dist/types.d.ts +192 -3
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/types.js.map +1 -1
  114. package/dist/utils/skill-commands.d.ts.map +1 -1
  115. package/dist/utils/skill-commands.js +4 -2
  116. package/dist/utils/skill-commands.js.map +1 -1
  117. package/dist/utils/tool-names.d.ts +24 -0
  118. package/dist/utils/tool-names.d.ts.map +1 -0
  119. package/dist/utils/tool-names.js +50 -0
  120. package/dist/utils/tool-names.js.map +1 -0
  121. package/package.json +22 -12
  122. package/dist/providers/claude/test.d.ts +0 -3
  123. package/dist/providers/claude/test.d.ts.map +0 -1
  124. package/dist/providers/claude/test.js +0 -167
  125. package/dist/providers/claude/test.js.map +0 -1
  126. package/dist/providers/codex/test.d.ts +0 -3
  127. package/dist/providers/codex/test.d.ts.map +0 -1
  128. package/dist/providers/codex/test.js +0 -74
  129. package/dist/providers/codex/test.js.map +0 -1
  130. package/dist/providers/cursor/test.d.ts +0 -3
  131. package/dist/providers/cursor/test.d.ts.map +0 -1
  132. package/dist/providers/cursor/test.js +0 -58
  133. package/dist/providers/cursor/test.js.map +0 -1
  134. package/dist/providers/gemini/codec.d.ts +0 -3
  135. package/dist/providers/gemini/codec.d.ts.map +0 -1
  136. package/dist/providers/gemini/codec.js +0 -47
  137. package/dist/providers/gemini/codec.js.map +0 -1
  138. package/dist/providers/gemini/execute.d.ts +0 -3
  139. package/dist/providers/gemini/execute.d.ts.map +0 -1
  140. package/dist/providers/gemini/execute.js +0 -256
  141. package/dist/providers/gemini/execute.js.map +0 -1
  142. package/dist/providers/gemini/parse.d.ts +0 -20
  143. package/dist/providers/gemini/parse.d.ts.map +0 -1
  144. package/dist/providers/gemini/parse.js +0 -235
  145. package/dist/providers/gemini/parse.js.map +0 -1
  146. package/dist/providers/gemini/test.d.ts +0 -3
  147. package/dist/providers/gemini/test.d.ts.map +0 -1
  148. package/dist/providers/gemini/test.js +0 -67
  149. package/dist/providers/gemini/test.js.map +0 -1
  150. package/dist/providers/openclaw/test.d.ts +0 -3
  151. package/dist/providers/openclaw/test.d.ts.map +0 -1
  152. package/dist/providers/openclaw/test.js +0 -54
  153. package/dist/providers/openclaw/test.js.map +0 -1
  154. package/dist/providers/opencode/test.d.ts +0 -3
  155. package/dist/providers/opencode/test.d.ts.map +0 -1
  156. package/dist/providers/opencode/test.js +0 -60
  157. package/dist/providers/opencode/test.js.map +0 -1
  158. package/dist/providers/pi/test.d.ts +0 -3
  159. package/dist/providers/pi/test.d.ts.map +0 -1
  160. package/dist/providers/pi/test.js +0 -60
  161. package/dist/providers/pi/test.js.map +0 -1
  162. package/dist/utils/model-cache.d.ts +0 -11
  163. package/dist/utils/model-cache.d.ts.map +0 -1
  164. package/dist/utils/model-cache.js +0 -17
  165. package/dist/utils/model-cache.js.map +0 -1
@@ -1,11 +1,90 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { findBinary } from "../../utils/binary.js";
3
4
  import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
4
5
  import { injectWorkspaceSkills } from "../../utils/skills.js";
5
6
  import { resolveInstructions } from "../../utils/instructions.js";
7
+ import { createToolNameTracker } from "../../utils/tool-names.js";
6
8
  import { parseCodexStreamLine } from "./parse.js";
7
9
  import { withPlanModePreamble } from "./plan-mode.js";
8
10
  import { scanCodexSessionUsage } from "./usage-scanner.js";
11
+ import { codexSessionCodec } from "./codec.js";
12
+ import { parseCollaborationModes, resolveCollaborationModeParam } from "./modes.js";
13
+ /**
14
+ * Extract a resume thread id from session params (reusing the codec's
15
+ * sessionId / session_id / thread_id alias handling), or null to start fresh.
16
+ */
17
+ function readCodexResumeId(sessionParams) {
18
+ const decoded = codexSessionCodec.deserialize(sessionParams ?? null);
19
+ const id = decoded?.["sessionId"];
20
+ return typeof id === "string" && id.length > 0 ? id : null;
21
+ }
22
+ /** Pull and normalize the question list out of a `requestUserInput` params blob.
23
+ * Tolerant of missing/extra fields — drops anything without an id and at least
24
+ * a question or a header (Codex sometimes sends header-only prompts). */
25
+ function parseCodexQuestions(params) {
26
+ const raw = Array.isArray(params["questions"]) ? params["questions"] : [];
27
+ const out = [];
28
+ for (const item of raw) {
29
+ if (typeof item !== "object" || item === null)
30
+ continue;
31
+ const q = item;
32
+ const id = typeof q["id"] === "string" ? q["id"] : "";
33
+ const questionText = typeof q["question"] === "string" ? q["question"] : "";
34
+ const header = typeof q["header"] === "string" ? q["header"] : "";
35
+ if (!id || (!questionText && !header))
36
+ continue;
37
+ // Fall back to the header as the prompt text so the bridged AskUserQuestion is
38
+ // never empty and the host can key its answer off the same `question` value.
39
+ const question = questionText || header;
40
+ const options = Array.isArray(q["options"])
41
+ ? q["options"]
42
+ .filter((o) => typeof o === "object" && o !== null)
43
+ .map((o) => ({
44
+ label: typeof o["label"] === "string" ? o["label"] : "",
45
+ ...(typeof o["description"] === "string" && o["description"]
46
+ ? { description: o["description"] }
47
+ : {}),
48
+ }))
49
+ .filter((o) => o.label.length > 0)
50
+ : [];
51
+ out.push({
52
+ id,
53
+ header,
54
+ question,
55
+ options,
56
+ ...(q["multiSelect"] === true ? { multiSelect: true } : {}),
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ function normalizeAnswerValues(raw) {
62
+ if (typeof raw === "string")
63
+ return raw.length > 0 ? [raw] : [];
64
+ if (Array.isArray(raw))
65
+ return raw.filter((v) => typeof v === "string" && v.length > 0);
66
+ return [];
67
+ }
68
+ /** Translate a host AskUserQuestion answer (keyed by question text or header)
69
+ * into the Codex `requestUserInput` response shape:
70
+ * `{ [questionId]: { answers: string[] } }`. A denied response yields {}. */
71
+ function buildCodexUserInputAnswers(questions, resp) {
72
+ const out = {};
73
+ if (!resp.allow)
74
+ return out;
75
+ const updated = resp.updatedInput && typeof resp.updatedInput["answers"] === "object"
76
+ ? resp.updatedInput["answers"]
77
+ : null;
78
+ if (!updated)
79
+ return out;
80
+ for (const q of questions) {
81
+ const raw = updated[q.question] ?? updated[q.header];
82
+ const values = normalizeAnswerValues(raw);
83
+ if (values.length > 0)
84
+ out[q.id] = { answers: values };
85
+ }
86
+ return out;
87
+ }
9
88
  // ---------------------------------------------------------------------------
10
89
  // JSON-RPC 2.0 helpers
11
90
  // ---------------------------------------------------------------------------
@@ -34,15 +113,23 @@ function asObj(parent, key) {
34
113
  : {};
35
114
  }
36
115
  function classifyMessage(msg) {
37
- const isRpc = msg["jsonrpc"] === "2.0";
38
- if (isRpc) {
39
- const hasId = "id" in msg && (typeof msg["id"] === "number" || typeof msg["id"] === "string");
40
- const hasMethod = "method" in msg && typeof msg["method"] === "string";
41
- const id = typeof msg["id"] === "number" ? msg["id"] : parseInt(String(msg["id"]), 10);
116
+ // Detect JSON-RPC by *structure*, not by the `jsonrpc:"2.0"` discriminator.
117
+ // codex-cli 0.130.0's `app-server` emits responses without the `jsonrpc`
118
+ // field (technically non-compliant with the spec, but it's what ships).
119
+ // Heuristic: a message is JSON-RPC if it has any of (jsonrpc, id, method).
120
+ const hasJsonRpc = msg["jsonrpc"] === "2.0";
121
+ const hasId = "id" in msg && (typeof msg["id"] === "number" || typeof msg["id"] === "string");
122
+ const hasMethod = "method" in msg && typeof msg["method"] === "string";
123
+ const hasResult = "result" in msg;
124
+ const hasError = "error" in msg;
125
+ if (hasJsonRpc || hasId || hasMethod) {
126
+ const id = hasId
127
+ ? (typeof msg["id"] === "number" ? msg["id"] : parseInt(String(msg["id"]), 10))
128
+ : null;
42
129
  if (hasId && hasMethod) {
43
- return { kind: "request", id, method: msg["method"], params: asObj(msg, "params") };
130
+ return { kind: "request", id: id, method: msg["method"], params: asObj(msg, "params") };
44
131
  }
45
- if (hasId && !hasMethod) {
132
+ if (hasId && (hasResult || hasError)) {
46
133
  const errRaw = msg["error"];
47
134
  const error = typeof errRaw === "object" && errRaw !== null
48
135
  ? { code: num(errRaw, "code"), message: str(errRaw, "message") }
@@ -50,12 +137,11 @@ function classifyMessage(msg) {
50
137
  const result = typeof msg["result"] === "object" && msg["result"] !== null
51
138
  ? msg["result"]
52
139
  : undefined;
53
- return { kind: "response", id, result, error };
140
+ return { kind: "response", id: id, result, error };
54
141
  }
55
142
  if (hasMethod) {
56
143
  return { kind: "notification", method: msg["method"], params: asObj(msg, "params") };
57
144
  }
58
- return null;
59
145
  }
60
146
  // Legacy NDJSON events (from `codex exec --json` format) — have a `type` field
61
147
  if (typeof msg["type"] === "string") {
@@ -88,15 +174,20 @@ export async function createCodexSession(ctx) {
88
174
  const instructions = config.planMode
89
175
  ? withPlanModePreamble(baseInstructions)
90
176
  : baseInstructions;
91
- // Spawn Codex in interactive JSON-RPC mode.
92
- // planMode and skipPermissions are mutually exclusive planMode wins.
93
- const args = [...resolved.prefixArgs, "--json"];
177
+ // Spawn Codex in interactive JSON-RPC mode via the `app-server` subcommand
178
+ // (codex-cli 0.130.0+; the old top-level `--json` flag was removed).
179
+ //
180
+ // Args order matters: `--sandbox` and `--dangerously-bypass-approvals-and-sandbox`
181
+ // are TOP-LEVEL options and must come BEFORE the `app-server` subcommand.
182
+ // extraArgs land after the subcommand — semantics depend on the user's intent.
183
+ const args = [...resolved.prefixArgs];
94
184
  if (config.planMode) {
95
185
  args.push("--sandbox", "read-only");
96
186
  }
97
187
  else if (config.skipPermissions) {
98
188
  args.push("--dangerously-bypass-approvals-and-sandbox");
99
189
  }
190
+ args.push("app-server");
100
191
  if (config.extraArgs)
101
192
  args.push(...config.extraArgs);
102
193
  const proc = spawn(resolved.bin, args, {
@@ -133,13 +224,29 @@ export class CodexSessionImpl {
133
224
  instructions;
134
225
  _state = "idle";
135
226
  _threadId = null;
227
+ /** Thread id to resume (from ctx.sessionParams); null starts a fresh thread. */
228
+ _resumeThreadId;
136
229
  _lineBuffer = "";
137
230
  _nextId = 1;
138
231
  // Pending outgoing RPC responses (keyed by request id)
139
232
  _pendingRpc = new Map();
140
- // Active turn state
141
- _turnResolve = null;
142
- _turnReject = null;
233
+ // Pending result-resolvers. With concurrent send, multiple in-flight send()
234
+ // Promises may share a single result event (when the CLI coalesces them
235
+ // into one turn) or get distinct results across turns. On each
236
+ // turn.completed / turn.failed we drain the entire list — every pending
237
+ // Promise resolves with the same TurnResult.
238
+ _pendingResults = [];
239
+ /** Result Promises for sends that haven't settled, tracked so `drain()` can
240
+ * await the in-flight turn(s) before closing. */
241
+ _inFlight = new Set();
242
+ /** Set by `drain()`: new `send()` calls are refused while true. */
243
+ _draining = false;
244
+ /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
245
+ _drainPromise = null;
246
+ /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
247
+ _trackToolName = createToolNameTracker();
248
+ // Per-turn accumulators. Cleared after each result delivery so a subsequent
249
+ // turn's events don't inherit stale values.
143
250
  _turnSummary = null;
144
251
  _turnUsage = null;
145
252
  _turnModel = null;
@@ -159,6 +266,7 @@ export class CodexSessionImpl {
159
266
  this.cwd = cwd;
160
267
  this.model = model;
161
268
  this.instructions = instructions;
269
+ this._resumeThreadId = readCodexResumeId(ctx.sessionParams);
162
270
  proc.stdout.setEncoding("utf-8");
163
271
  proc.stdout.on("data", (chunk) => this.handleStdout(chunk));
164
272
  proc.stderr.setEncoding("utf-8");
@@ -174,30 +282,30 @@ export class CodexSessionImpl {
174
282
  if (this._state !== "closed") {
175
283
  this._state = "closed";
176
284
  const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
177
- for (const [, pending] of this._pendingRpc)
178
- pending.reject(err);
179
- this._pendingRpc.clear();
180
- if (this._turnReject) {
181
- this._turnReject(err);
182
- this._turnResolve = null;
183
- this._turnReject = null;
184
- }
285
+ this.rejectAllPending(err);
185
286
  }
186
287
  });
187
288
  proc.on("error", (err) => {
188
289
  if (this._state !== "closed") {
189
290
  this._state = "closed";
190
- for (const [, pending] of this._pendingRpc)
191
- pending.reject(err);
192
- this._pendingRpc.clear();
193
- if (this._turnReject) {
194
- this._turnReject(err);
195
- this._turnResolve = null;
196
- this._turnReject = null;
197
- }
291
+ this.rejectAllPending(err);
198
292
  }
199
293
  });
200
294
  }
295
+ /** Reject every pending send() Promise and outgoing JSON-RPC call. */
296
+ rejectAllPending(err) {
297
+ const pending = this._pendingResults.splice(0);
298
+ for (const p of pending) {
299
+ if (p.settled)
300
+ continue;
301
+ p.settled = true;
302
+ p.cleanup?.();
303
+ p.reject(err);
304
+ }
305
+ for (const [, p] of this._pendingRpc)
306
+ p.reject(err);
307
+ this._pendingRpc.clear();
308
+ }
201
309
  get sessionId() { return this._threadId; }
202
310
  get state() { return this._state; }
203
311
  // -------------------------------------------------------------------------
@@ -225,42 +333,184 @@ export class CodexSessionImpl {
225
333
  clientInfo: { name: "agentex", version: "1.0.0" },
226
334
  capabilities: {},
227
335
  });
228
- // 2. thread/start
336
+ // 2. Resume an existing thread when the caller supplied sessionParams,
337
+ // otherwise start a fresh one. `thread/resume` continues the SAME thread
338
+ // with its full context retained — distinct from `thread/fork`, which is
339
+ // a divergent rewind copy. The thread keeps its original cwd/model, so we
340
+ // pass only the thread id (+ refreshed developer instructions).
341
+ if (this._resumeThreadId) {
342
+ const resumeParams = { threadId: this._resumeThreadId };
343
+ if (this.instructions)
344
+ resumeParams["developerInstructions"] = this.instructions;
345
+ try {
346
+ const res = await this.rpcRequest("thread/resume", resumeParams);
347
+ const thread = asObj(res, "thread");
348
+ // thread/resume may echo the thread back or return {}; fall back to the
349
+ // id we resumed with so `sessionId` is always populated.
350
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || this._resumeThreadId;
351
+ return;
352
+ }
353
+ catch (err) {
354
+ // The thread is unknown to this codex install (different machine, pruned
355
+ // history). Don't fail the whole session — fall back to a fresh thread
356
+ // and surface the downgrade on stderr. The new id flows back out via
357
+ // the next sessionParams snapshot so callers see the session changed.
358
+ if (this.ctx.onOutput) {
359
+ const reason = err instanceof Error ? err.message : String(err);
360
+ try {
361
+ void this.ctx.onOutput("stderr", `agentex: codex thread/resume failed for ${this._resumeThreadId}, starting a fresh thread: ${reason}\n`);
362
+ }
363
+ catch { /* swallow */ }
364
+ }
365
+ }
366
+ }
367
+ // thread/start (fresh)
229
368
  const threadParams = { cwd: this.cwd };
230
369
  if (this.model)
231
370
  threadParams["model"] = this.model;
232
371
  if (this.instructions)
233
372
  threadParams["developerInstructions"] = this.instructions;
373
+ // Apply a chosen collaboration mode (config.modeId) by resolving it against
374
+ // the live mode list. Only on fresh threads — a resumed thread keeps the
375
+ // mode it was created with. Mode discovery is advisory: a failure or an
376
+ // unknown id just falls through to the default mode.
377
+ const modeId = this.ctx.config?.modeId;
378
+ if (modeId) {
379
+ try {
380
+ // Bound the discovery RPC: an older app-server that ignores
381
+ // `collaborationMode/list` would otherwise hang the whole handshake.
382
+ const modesResponse = await Promise.race([
383
+ this.rpcRequest("collaborationMode/list", {}),
384
+ new Promise((_, reject) => setTimeout(() => reject(new Error("collaborationMode/list timed out")), 10_000)),
385
+ ]);
386
+ const collaborationMode = resolveCollaborationModeParam(parseCollaborationModes(modesResponse), modeId);
387
+ if (collaborationMode) {
388
+ // Avoid sending instructions twice: the caller's top-level
389
+ // `developerInstructions` wins, so drop the mode's copy.
390
+ if (this.instructions)
391
+ delete collaborationMode.settings["developer_instructions"];
392
+ threadParams["collaborationMode"] = collaborationMode;
393
+ }
394
+ }
395
+ catch { /* modes are advisory — ignore discovery failures */ }
396
+ }
234
397
  const res = await this.rpcRequest("thread/start", threadParams);
235
- this._threadId = str(res, "threadId") || str(res, "thread_id") || null;
398
+ // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... }, model, ... }
399
+ const thread = asObj(res, "thread");
400
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || null;
236
401
  }
237
402
  // -------------------------------------------------------------------------
238
403
  // Public API
239
404
  // -------------------------------------------------------------------------
240
- async send(message) {
405
+ async send(message, options) {
241
406
  if (this._state === "closed")
242
407
  throw new Error("Session is closed");
243
- if (this._state !== "idle")
244
- throw new Error("A turn is already in progress");
245
- this._state = "thinking";
246
- this._turnSummary = null;
247
- this._turnUsage = null;
248
- this._turnModel = null;
249
- this._turnIsError = false;
250
- this._turnErrorMessage = null;
251
- this._turnStartedAt = new Date();
252
- // Start a turn — the completion comes via notifications, not the RPC response
253
- const turnParams = { input: message };
408
+ if (this._draining)
409
+ throw new Error("Session is draining no new sends accepted");
410
+ // No protocol-level guard — Codex's TUI demonstrates queueing of user
411
+ // messages during an active turn, and our wire test (transcript
412
+ // 019e33c3) confirms two `user_message` events recorded across a
413
+ // long-running turn. We bet on the JSON-RPC layer queueing similarly
414
+ // and pass through. If the second `turn/start` lands during the first
415
+ // turn, the per-turn accumulators continue collecting until the result
416
+ // event fires; the result then drains all pending resolvers.
417
+ if (this._state === "idle") {
418
+ this._state = "thinking";
419
+ this._turnStartedAt = new Date();
420
+ }
421
+ // UUID is for API parity with Claude — Codex's JSON-RPC doesn't carry it
422
+ // through the wire protocol, so cancel(uuid) is a no-op for Codex.
423
+ const uuid = randomUUID();
424
+ // Start a turn — the completion comes via notifications, not the RPC response.
425
+ // codex-cli 0.130.0+ expects `input` as a content-block array, not a plain
426
+ // string. The MCP-style shape: [{type:"text", text:"..."}].
427
+ const turnParams = {
428
+ input: [{ type: "text", text: message }],
429
+ };
254
430
  if (this._threadId)
255
431
  turnParams["threadId"] = this._threadId;
432
+ let resolveFn;
433
+ let rejectFn;
434
+ const result = new Promise((resolve, reject) => {
435
+ resolveFn = resolve;
436
+ rejectFn = reject;
437
+ });
438
+ const entry = { resolve: resolveFn, reject: rejectFn };
439
+ this._pendingResults.push(entry);
440
+ // Track the in-flight turn so drain() can await it; drop it on settle.
441
+ this._inFlight.add(result);
442
+ void result.catch(() => { }).finally(() => this._inFlight.delete(result));
443
+ // Per-send timeout / abort, falling back to the session-level
444
+ // ProviderConfig.timeoutSec default when no per-call timeout is given.
445
+ this.armSendDeadline(entry, options);
256
446
  this.rpcRequest("turn/start", turnParams).catch(() => {
257
- // Turn-level errors arrive via turn.failed notifications
447
+ // Turn-level errors arrive via turn.failed notifications.
258
448
  });
259
- return new Promise((resolve, reject) => {
260
- this._turnResolve = resolve;
261
- this._turnReject = reject;
449
+ return { uuid, result };
450
+ }
451
+ /**
452
+ * Wire up this send's timeout and/or abort signal. On fire, the active turn
453
+ * is cancelled (`turn/cancel`) and the send settles with `timeout` /
454
+ * `aborted`. No-op when neither a timeout nor a signal applies.
455
+ */
456
+ armSendDeadline(entry, options) {
457
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
458
+ const signal = options?.signal;
459
+ const hasTimeout = typeof timeoutSec === "number" && timeoutSec > 0;
460
+ if (!hasTimeout && !signal)
461
+ return;
462
+ if (signal?.aborted) {
463
+ queueMicrotask(() => this.settleEarly(entry, "aborted"));
464
+ return;
465
+ }
466
+ let timer;
467
+ const onAbort = () => this.settleEarly(entry, "aborted");
468
+ if (hasTimeout) {
469
+ timer = setTimeout(() => this.settleEarly(entry, "timeout"), timeoutSec * 1000);
470
+ }
471
+ if (signal)
472
+ signal.addEventListener("abort", onAbort, { once: true });
473
+ entry.cleanup = () => {
474
+ if (timer)
475
+ clearTimeout(timer);
476
+ if (signal)
477
+ signal.removeEventListener("abort", onAbort);
478
+ };
479
+ }
480
+ /**
481
+ * Settle a still-pending send early (timeout or abort). Cancels the active
482
+ * turn best-effort and resolves the send's `result` with a synthetic
483
+ * TurnResult. A no-op if the entry already settled (the real result raced
484
+ * ahead). The late real turn-completion later finds the entry already gone.
485
+ */
486
+ settleEarly(entry, kind) {
487
+ if (entry.settled)
488
+ return;
489
+ entry.settled = true;
490
+ entry.cleanup?.();
491
+ const idx = this._pendingResults.indexOf(entry);
492
+ if (idx >= 0)
493
+ this._pendingResults.splice(idx, 1);
494
+ // Best-effort cancel of the active turn. With concurrent sends this ends
495
+ // the single shared turn for all of them — see SendOptions JSDoc.
496
+ void this.interrupt();
497
+ entry.resolve({
498
+ summary: null,
499
+ usage: undefined,
500
+ costUsd: null,
501
+ status: kind,
502
+ errorCode: kind,
503
+ errorMessage: kind === "timeout"
504
+ ? "Turn exceeded its timeout and was interrupted"
505
+ : "Turn was aborted",
262
506
  });
263
507
  }
508
+ async cancel(_uuid) {
509
+ // Codex's JSON-RPC protocol exposes no per-message cancel — only
510
+ // turn-wide `turn/cancel` (which is what `interrupt()` calls).
511
+ // capabilities.cancelQueuedMessage is false; this is a documented no-op.
512
+ return { cancelled: false };
513
+ }
264
514
  async interrupt() {
265
515
  if (this._state === "idle" || this._state === "closed")
266
516
  return;
@@ -269,16 +519,34 @@ export class CodexSessionImpl {
269
519
  }
270
520
  catch { /* best effort */ }
271
521
  }
522
+ async drain() {
523
+ if (this._state === "closed")
524
+ return;
525
+ // Coalesce concurrent / repeated drains onto one promise.
526
+ if (this._drainPromise)
527
+ return this._drainPromise;
528
+ this._draining = true;
529
+ this._drainPromise = (async () => {
530
+ // Let every in-flight turn settle (resolve or reject) before closing, so
531
+ // a running tool finishes rather than being killed mid-flight.
532
+ await Promise.allSettled([...this._inFlight]);
533
+ await this.close();
534
+ })();
535
+ return this._drainPromise;
536
+ }
272
537
  async close() {
273
538
  if (this._state === "closed")
274
539
  return;
275
540
  this._state = "closed";
276
541
  this.proc.stdin.end();
542
+ // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
543
+ // for sessions running long tools.
544
+ const graceSec = this.ctx.config?.graceSec ?? 5;
277
545
  await new Promise((resolve) => {
278
546
  const timeout = setTimeout(() => {
279
547
  this.proc.kill("SIGKILL");
280
548
  resolve();
281
- }, 5000);
549
+ }, graceSec * 1000);
282
550
  this.proc.on("exit", () => {
283
551
  clearTimeout(timeout);
284
552
  resolve();
@@ -351,17 +619,35 @@ export class CodexSessionImpl {
351
619
  method === "item/fileChange/requestApproval") {
352
620
  void this.handleApproval(id, method, params);
353
621
  }
622
+ else if (method === "item/tool/requestUserInput" ||
623
+ method === "tool/requestUserInput") {
624
+ // `tool/requestUserInput` is the legacy method name on older codex builds.
625
+ void this.handleUserInputRequest(id, params);
626
+ }
354
627
  else {
355
- // Unknown server request — ack to unblock
628
+ // Unknown server request — ack to unblock the turn.
356
629
  this.rpcResponse(id, {});
357
630
  }
358
631
  }
632
+ /**
633
+ * Leave a waiting-for-input/approval state correctly. A slow host handler can
634
+ * resolve after the turn already ended (deliverTurnResult → idle), so restore
635
+ * to `thinking` only when a turn is still in flight, else `idle` — never clobber
636
+ * a finished turn back to `thinking`.
637
+ */
638
+ restoreStateAfter(waitingState) {
639
+ if (this._state !== waitingState)
640
+ return;
641
+ this._state = this._pendingResults.length > 0 ? "thinking" : "idle";
642
+ }
359
643
  async handleApproval(id, method, params) {
360
644
  this._state = "waiting_for_approval";
645
+ // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
646
+ // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
647
+ // concept, so allow → accept and deny → decline.
361
648
  if (!this.ctx.onUserInputRequest) {
362
- this.rpcResponse(id, { approved: true });
363
- if (this._state === "waiting_for_approval")
364
- this._state = "thinking";
649
+ this.rpcResponse(id, { decision: "accept" });
650
+ this.restoreStateAfter("waiting_for_approval");
365
651
  return;
366
652
  }
367
653
  const toolName = method === "item/commandExecution/requestApproval"
@@ -374,13 +660,43 @@ export class CodexSessionImpl {
374
660
  toolUseId: str(params, "id"),
375
661
  description: str(params, "command") || str(params, "path") || undefined,
376
662
  });
377
- this.rpcResponse(id, { approved: resp.allow });
663
+ this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
378
664
  }
379
665
  catch {
380
- this.rpcResponse(id, { approved: false });
666
+ this.rpcResponse(id, { decision: "decline" });
381
667
  }
382
- if (this._state === "waiting_for_approval")
383
- this._state = "thinking";
668
+ this.restoreStateAfter("waiting_for_approval");
669
+ }
670
+ /**
671
+ * Handle a Codex `requestUserInput` server→client request: the agent is asking
672
+ * the user one or more structured questions. Maps onto the cross-provider
673
+ * AskUserQuestion shape so callers reuse `parseAskUserQuestion`, then answers
674
+ * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
675
+ */
676
+ async handleUserInputRequest(id, params) {
677
+ // Questions are user *input*, not a tool-permission gate — distinct state so a
678
+ // host UI can render a question form vs an approval prompt.
679
+ this._state = "waiting_for_input";
680
+ const questions = parseCodexQuestions(params);
681
+ // No host handler, or nothing answerable → return empty answers so the
682
+ // agent proceeds without hanging.
683
+ if (!this.ctx.onUserInputRequest || questions.length === 0) {
684
+ this.rpcResponse(id, { answers: {} });
685
+ this.restoreStateAfter("waiting_for_input");
686
+ return;
687
+ }
688
+ try {
689
+ const resp = await this.ctx.onUserInputRequest({
690
+ toolName: "AskUserQuestion",
691
+ input: { questions },
692
+ toolUseId: str(params, "id") || "codex-user-input",
693
+ });
694
+ this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
695
+ }
696
+ catch {
697
+ this.rpcResponse(id, { answers: {} });
698
+ }
699
+ this.restoreStateAfter("waiting_for_input");
384
700
  }
385
701
  // -------------------------------------------------------------------------
386
702
  // Notification handling (v2 format)
@@ -399,7 +715,9 @@ export class CodexSessionImpl {
399
715
  }
400
716
  // Map v2 notification methods to processing
401
717
  if (method === "thread/started") {
402
- this._threadId = str(params, "threadId") || str(params, "thread_id") || this._threadId;
718
+ // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
719
+ const thread = asObj(params, "thread");
720
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || this._threadId;
403
721
  this.emitStreamEvent(rawLine);
404
722
  return;
405
723
  }
@@ -540,7 +858,15 @@ export class CodexSessionImpl {
540
858
  let usage = this._turnUsage && resolvedModel
541
859
  ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
542
860
  : undefined;
543
- // If no usage from the stream, try scanning session logs
861
+ // Usage precedence: the `turn.completed` payload is authoritative when
862
+ // present (captured above into _turnUsage). Only when the stream carried no
863
+ // usage do we fall back to scanning Codex's on-disk session logs.
864
+ //
865
+ // RACINESS: the disk scan is best-effort and inherently racy — the rollout
866
+ // file may still be flushing when we read it, so a fallback scan can miss the
867
+ // latest turn or read partially-written totals. We therefore scan ONLY when
868
+ // there is no in-band usage, and never let a scan failure fail the turn
869
+ // (usage simply stays undefined). Prefer the in-band payload always.
544
870
  if (!usage && this._turnStartedAt) {
545
871
  const startedAt = this._turnStartedAt;
546
872
  const threadId = this._threadId ?? undefined;
@@ -578,11 +904,24 @@ export class CodexSessionImpl {
578
904
  if (this._state === "closed")
579
905
  return;
580
906
  this._state = "idle";
581
- if (this._turnResolve) {
582
- const resolve = this._turnResolve;
583
- this._turnResolve = null;
584
- this._turnReject = null;
585
- resolve(result);
907
+ // Drain ALL pending send() resolvers with this turn's result. Multiple
908
+ // concurrent sends coalesced into one turn share the same TurnResult.
909
+ const pending = this._pendingResults.splice(0);
910
+ // Clear per-turn accumulators so a subsequent turn doesn't inherit
911
+ // stale summary / usage / model.
912
+ this._turnSummary = null;
913
+ this._turnUsage = null;
914
+ this._turnModel = null;
915
+ this._turnIsError = false;
916
+ this._turnErrorMessage = null;
917
+ this._turnStartedAt = null;
918
+ for (const p of pending) {
919
+ // Skip sends already settled early by timeout / abort.
920
+ if (p.settled)
921
+ continue;
922
+ p.settled = true;
923
+ p.cleanup?.();
924
+ p.resolve(result);
586
925
  }
587
926
  }
588
927
  emitStreamEvent(rawLine) {
@@ -605,9 +944,12 @@ export class CodexSessionImpl {
605
944
  const cb = this.ctx.onEvent;
606
945
  if (!cb)
607
946
  return;
947
+ // Enrich synchronously (in stream order) so tool_result events carry the
948
+ // name of the tool_call they answer.
949
+ const enriched = this._trackToolName(event);
608
950
  this._eventChain = this._eventChain.then(async () => {
609
951
  try {
610
- await cb(event);
952
+ await cb(enriched);
611
953
  }
612
954
  catch { /* swallow */ }
613
955
  });