@agentex/agent 0.0.24 → 0.0.26

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 (164) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/goal-capability.d.ts +15 -0
  25. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  26. package/dist/providers/claude/goal-capability.js +20 -0
  27. package/dist/providers/claude/goal-capability.js.map +1 -0
  28. package/dist/providers/claude/index.d.ts.map +1 -1
  29. package/dist/providers/claude/index.js +8 -4
  30. package/dist/providers/claude/index.js.map +1 -1
  31. package/dist/providers/claude/session.d.ts +11 -9
  32. package/dist/providers/claude/session.d.ts.map +1 -1
  33. package/dist/providers/claude/session.js +29 -14
  34. package/dist/providers/claude/session.js.map +1 -1
  35. package/dist/providers/codex/attach.d.ts +9 -0
  36. package/dist/providers/codex/attach.d.ts.map +1 -0
  37. package/dist/providers/codex/attach.js +93 -0
  38. package/dist/providers/codex/attach.js.map +1 -0
  39. package/dist/providers/codex/goal-capability.d.ts +13 -0
  40. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  41. package/dist/providers/codex/goal-capability.js +18 -0
  42. package/dist/providers/codex/goal-capability.js.map +1 -0
  43. package/dist/providers/codex/index.d.ts +1 -0
  44. package/dist/providers/codex/index.d.ts.map +1 -1
  45. package/dist/providers/codex/index.js +9 -6
  46. package/dist/providers/codex/index.js.map +1 -1
  47. package/dist/providers/codex/session.d.ts +11 -7
  48. package/dist/providers/codex/session.d.ts.map +1 -1
  49. package/dist/providers/codex/session.js +24 -12
  50. package/dist/providers/codex/session.js.map +1 -1
  51. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  52. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  53. package/dist/providers/codex/transcript-normalize.js +191 -0
  54. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  55. package/dist/providers/cursor/index.d.ts.map +1 -1
  56. package/dist/providers/cursor/index.js +2 -2
  57. package/dist/providers/cursor/index.js.map +1 -1
  58. package/dist/providers/openclaw/index.d.ts.map +1 -1
  59. package/dist/providers/openclaw/index.js +2 -2
  60. package/dist/providers/openclaw/index.js.map +1 -1
  61. package/dist/providers/opencode/index.d.ts.map +1 -1
  62. package/dist/providers/opencode/index.js +3 -5
  63. package/dist/providers/opencode/index.js.map +1 -1
  64. package/dist/providers/pi/index.d.ts.map +1 -1
  65. package/dist/providers/pi/index.js +3 -5
  66. package/dist/providers/pi/index.js.map +1 -1
  67. package/dist/providers/process/index.d.ts.map +1 -1
  68. package/dist/providers/process/index.js +2 -2
  69. package/dist/providers/process/index.js.map +1 -1
  70. package/dist/registry.d.ts +0 -1
  71. package/dist/registry.d.ts.map +1 -1
  72. package/dist/registry.js +0 -4
  73. package/dist/registry.js.map +1 -1
  74. package/dist/sessions/index.d.ts +3 -0
  75. package/dist/sessions/index.d.ts.map +1 -0
  76. package/dist/sessions/index.js +2 -0
  77. package/dist/sessions/index.js.map +1 -0
  78. package/dist/sessions/record.d.ts +43 -0
  79. package/dist/sessions/record.d.ts.map +1 -0
  80. package/dist/sessions/record.js +85 -0
  81. package/dist/sessions/record.js.map +1 -0
  82. package/dist/types.d.ts +119 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/dist/utils/uuid.d.ts +7 -1
  86. package/dist/utils/uuid.d.ts.map +1 -1
  87. package/dist/utils/uuid.js +21 -1
  88. package/dist/utils/uuid.js.map +1 -1
  89. package/package.json +64 -7
  90. package/src/derived.ts +311 -0
  91. package/src/goals/controller.ts +442 -0
  92. package/src/goals/index.ts +21 -0
  93. package/src/goals/normalize.ts +173 -0
  94. package/src/goals/sentinel.ts +90 -0
  95. package/src/index.ts +270 -0
  96. package/src/providers/_shared/http-agent.ts +304 -0
  97. package/src/providers/acp/index.ts +103 -0
  98. package/src/providers/acp/parse.ts +131 -0
  99. package/src/providers/acp/session.ts +744 -0
  100. package/src/providers/claude/attach.ts +147 -0
  101. package/src/providers/claude/codec.ts +43 -0
  102. package/src/providers/claude/execute.ts +300 -0
  103. package/src/providers/claude/goal-capability.ts +21 -0
  104. package/src/providers/claude/index.ts +72 -0
  105. package/src/providers/claude/mcp.ts +82 -0
  106. package/src/providers/claude/parse.ts +824 -0
  107. package/src/providers/claude/session.ts +1192 -0
  108. package/src/providers/claude/transcript.ts +555 -0
  109. package/src/providers/codex/attach.ts +123 -0
  110. package/src/providers/codex/codec.ts +50 -0
  111. package/src/providers/codex/execute.ts +337 -0
  112. package/src/providers/codex/goal-capability.ts +19 -0
  113. package/src/providers/codex/index.ts +57 -0
  114. package/src/providers/codex/modes.ts +159 -0
  115. package/src/providers/codex/parse.ts +691 -0
  116. package/src/providers/codex/plan-mode.ts +49 -0
  117. package/src/providers/codex/session.ts +1287 -0
  118. package/src/providers/codex/transcript-normalize.ts +197 -0
  119. package/src/providers/codex/transcript.ts +487 -0
  120. package/src/providers/codex/usage-scanner.ts +178 -0
  121. package/src/providers/copilot/index.ts +19 -0
  122. package/src/providers/cursor/codec.ts +44 -0
  123. package/src/providers/cursor/execute.ts +271 -0
  124. package/src/providers/cursor/index.ts +25 -0
  125. package/src/providers/cursor/parse.ts +288 -0
  126. package/src/providers/gemini/index.ts +21 -0
  127. package/src/providers/openclaw/codec.ts +40 -0
  128. package/src/providers/openclaw/execute.ts +19 -0
  129. package/src/providers/openclaw/index.ts +29 -0
  130. package/src/providers/opencode/codec.ts +50 -0
  131. package/src/providers/opencode/event-parse.ts +141 -0
  132. package/src/providers/opencode/execute.ts +251 -0
  133. package/src/providers/opencode/http-session.ts +427 -0
  134. package/src/providers/opencode/index.ts +30 -0
  135. package/src/providers/opencode/parse.ts +203 -0
  136. package/src/providers/opencode/server.ts +0 -0
  137. package/src/providers/pi/codec.ts +44 -0
  138. package/src/providers/pi/execute.ts +297 -0
  139. package/src/providers/pi/index.ts +30 -0
  140. package/src/providers/pi/parse.ts +231 -0
  141. package/src/providers/pi/session.ts +381 -0
  142. package/src/providers/process/execute.ts +148 -0
  143. package/src/providers/process/index.ts +52 -0
  144. package/src/registry.ts +40 -0
  145. package/src/sessions/index.ts +8 -0
  146. package/src/sessions/record.ts +108 -0
  147. package/src/types.ts +1638 -0
  148. package/src/utils/ask-user-question.ts +57 -0
  149. package/src/utils/auth.ts +661 -0
  150. package/src/utils/binary.ts +179 -0
  151. package/src/utils/endpoint.ts +172 -0
  152. package/src/utils/env.ts +63 -0
  153. package/src/utils/execute-all.ts +68 -0
  154. package/src/utils/exit-plan-mode.ts +40 -0
  155. package/src/utils/instructions.ts +427 -0
  156. package/src/utils/process.ts +223 -0
  157. package/src/utils/runtime-config.ts +100 -0
  158. package/src/utils/runtime-homes.ts +49 -0
  159. package/src/utils/skill-commands.ts +493 -0
  160. package/src/utils/skills.ts +500 -0
  161. package/src/utils/template.ts +16 -0
  162. package/src/utils/tool-names.ts +51 -0
  163. package/src/utils/uuid.ts +21 -0
  164. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,1287 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import type {
5
+ AgentSession,
6
+ CancelResult,
7
+ ClearGoalResult,
8
+ GoalOptions,
9
+ GoalState,
10
+ StopTaskResult,
11
+ SendHandle,
12
+ SendOptions,
13
+ SessionContext,
14
+ SessionRecord,
15
+ SessionState,
16
+ SetGoalResult,
17
+ StreamEvent,
18
+ TurnResult,
19
+ UserInputResponse,
20
+ } from "../../types.js";
21
+ import { GoalController, normalizeCodexGoalRecord, isTerminalGoalStatus } from "../../goals/index.js";
22
+ import { codexGoalCapability } from "./goal-capability.js";
23
+ import { createSessionRecord } from "../../sessions/record.js";
24
+ import { findBinary } from "../../utils/binary.js";
25
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
26
+ import { translateEndpoint } from "../../utils/endpoint.js";
27
+ import { injectWorkspaceSkills } from "../../utils/skills.js";
28
+ import { resolveInstructions } from "../../utils/instructions.js";
29
+ import { createToolNameTracker } from "../../utils/tool-names.js";
30
+ import { parseCodexStreamLine } from "./parse.js";
31
+ import { withPlanModePreamble } from "./plan-mode.js";
32
+ import { scanCodexSessionUsage } from "./usage-scanner.js";
33
+ import { codexSessionCodec } from "./codec.js";
34
+ import { parseCollaborationModes, resolveCollaborationModeParam } from "./modes.js";
35
+
36
+ /**
37
+ * Extract a resume thread id from session params (reusing the codec's
38
+ * sessionId / session_id / thread_id alias handling), or null to start fresh.
39
+ */
40
+ function readCodexResumeId(
41
+ sessionParams: Record<string, unknown> | null | undefined,
42
+ ): string | null {
43
+ const decoded = codexSessionCodec.deserialize(sessionParams ?? null);
44
+ const id = decoded?.["sessionId"];
45
+ return typeof id === "string" && id.length > 0 ? id : null;
46
+ }
47
+
48
+ /** One structured question Codex asks via `requestUserInput`, normalized to the
49
+ * cross-provider AskUserQuestion shape so callers can reuse parseAskUserQuestion. */
50
+ interface CodexQuestion {
51
+ id: string;
52
+ header: string;
53
+ question: string;
54
+ options: { label: string; description?: string }[];
55
+ multiSelect?: boolean;
56
+ }
57
+
58
+ /** Pull and normalize the question list out of a `requestUserInput` params blob.
59
+ * Tolerant of missing/extra fields — drops anything without an id and at least
60
+ * a question or a header (Codex sometimes sends header-only prompts). */
61
+ function parseCodexQuestions(params: Record<string, unknown>): CodexQuestion[] {
62
+ const raw = Array.isArray(params["questions"]) ? params["questions"] : [];
63
+ const out: CodexQuestion[] = [];
64
+ for (const item of raw) {
65
+ if (typeof item !== "object" || item === null) continue;
66
+ const q = item as Record<string, unknown>;
67
+ const id = typeof q["id"] === "string" ? q["id"] : "";
68
+ const questionText = typeof q["question"] === "string" ? q["question"] : "";
69
+ const header = typeof q["header"] === "string" ? q["header"] : "";
70
+ if (!id || (!questionText && !header)) continue;
71
+ // Fall back to the header as the prompt text so the bridged AskUserQuestion is
72
+ // never empty and the host can key its answer off the same `question` value.
73
+ const question = questionText || header;
74
+ const options = Array.isArray(q["options"])
75
+ ? q["options"]
76
+ .filter((o): o is Record<string, unknown> => typeof o === "object" && o !== null)
77
+ .map((o) => ({
78
+ label: typeof o["label"] === "string" ? o["label"] : "",
79
+ ...(typeof o["description"] === "string" && o["description"]
80
+ ? { description: o["description"] as string }
81
+ : {}),
82
+ }))
83
+ .filter((o) => o.label.length > 0)
84
+ : [];
85
+ out.push({
86
+ id,
87
+ header,
88
+ question,
89
+ options,
90
+ ...(q["multiSelect"] === true ? { multiSelect: true } : {}),
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function normalizeAnswerValues(raw: unknown): string[] {
97
+ if (typeof raw === "string") return raw.length > 0 ? [raw] : [];
98
+ if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === "string" && v.length > 0);
99
+ return [];
100
+ }
101
+
102
+ /** Translate a host AskUserQuestion answer (keyed by question text or header)
103
+ * into the Codex `requestUserInput` response shape:
104
+ * `{ [questionId]: { answers: string[] } }`. A denied response yields {}. */
105
+ function buildCodexUserInputAnswers(
106
+ questions: CodexQuestion[],
107
+ resp: UserInputResponse,
108
+ ): Record<string, { answers: string[] }> {
109
+ const out: Record<string, { answers: string[] }> = {};
110
+ if (!resp.allow) return out;
111
+ const updated =
112
+ resp.updatedInput && typeof resp.updatedInput["answers"] === "object"
113
+ ? (resp.updatedInput["answers"] as Record<string, unknown>)
114
+ : null;
115
+ if (!updated) return out;
116
+ for (const q of questions) {
117
+ const raw = updated[q.question] ?? updated[q.header];
118
+ const values = normalizeAnswerValues(raw);
119
+ if (values.length > 0) out[q.id] = { answers: values };
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** A pending `send()` whose `result` Promise hasn't settled yet. */
125
+ interface PendingResult {
126
+ resolve: (result: TurnResult) => void;
127
+ reject: (err: Error) => void;
128
+ /** Set once the entry has been settled (by result, timeout, abort, or
129
+ * reject) so the other paths skip it — prevents double-handling. */
130
+ settled?: boolean;
131
+ /** Tear down this send's timeout timer / abort listener. */
132
+ cleanup?: () => void;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // JSON-RPC 2.0 helpers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function parseJson(line: string): Record<string, unknown> | null {
140
+ try {
141
+ const parsed = JSON.parse(line);
142
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
143
+ return parsed as Record<string, unknown>;
144
+ }
145
+ } catch { /* skip */ }
146
+ return null;
147
+ }
148
+
149
+ function str(obj: Record<string, unknown>, key: string): string {
150
+ const v = obj[key];
151
+ return typeof v === "string" ? v : "";
152
+ }
153
+
154
+ function num(obj: Record<string, unknown>, key: string): number {
155
+ const v = obj[key];
156
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
157
+ }
158
+
159
+ function asObj(parent: Record<string, unknown>, key: string): Record<string, unknown> {
160
+ const v = parent[key];
161
+ return typeof v === "object" && v !== null && !Array.isArray(v)
162
+ ? (v as Record<string, unknown>)
163
+ : {};
164
+ }
165
+
166
+ /** Discriminated incoming message from the Codex CLI. */
167
+ type IncomingMessage =
168
+ | { kind: "response"; id: number; result?: Record<string, unknown>; error?: { code: number; message: string } }
169
+ | { kind: "request"; id: number; method: string; params: Record<string, unknown> }
170
+ | { kind: "notification"; method: string; params: Record<string, unknown> }
171
+ | { kind: "legacy_event"; event: Record<string, unknown> };
172
+
173
+ function classifyMessage(msg: Record<string, unknown>): IncomingMessage | null {
174
+ // Detect JSON-RPC by *structure*, not by the `jsonrpc:"2.0"` discriminator.
175
+ // codex-cli 0.130.0's `app-server` emits responses without the `jsonrpc`
176
+ // field (technically non-compliant with the spec, but it's what ships).
177
+ // Heuristic: a message is JSON-RPC if it has any of (jsonrpc, id, method).
178
+ const hasJsonRpc = msg["jsonrpc"] === "2.0";
179
+ const hasId = "id" in msg && (typeof msg["id"] === "number" || typeof msg["id"] === "string");
180
+ const hasMethod = "method" in msg && typeof msg["method"] === "string";
181
+ const hasResult = "result" in msg;
182
+ const hasError = "error" in msg;
183
+
184
+ if (hasJsonRpc || hasId || hasMethod) {
185
+ const id = hasId
186
+ ? (typeof msg["id"] === "number" ? msg["id"] : parseInt(String(msg["id"]), 10))
187
+ : null;
188
+
189
+ if (hasId && hasMethod) {
190
+ return { kind: "request", id: id!, method: msg["method"] as string, params: asObj(msg, "params") };
191
+ }
192
+ if (hasId && (hasResult || hasError)) {
193
+ const errRaw = msg["error"];
194
+ const error = typeof errRaw === "object" && errRaw !== null
195
+ ? { code: num(errRaw as Record<string, unknown>, "code"), message: str(errRaw as Record<string, unknown>, "message") }
196
+ : undefined;
197
+ const result = typeof msg["result"] === "object" && msg["result"] !== null
198
+ ? (msg["result"] as Record<string, unknown>)
199
+ : undefined;
200
+ return { kind: "response", id: id!, result, error };
201
+ }
202
+ if (hasMethod) {
203
+ return { kind: "notification", method: msg["method"] as string, params: asObj(msg, "params") };
204
+ }
205
+ }
206
+
207
+ // Legacy NDJSON events (from `codex exec --json` format) — have a `type` field
208
+ if (typeof msg["type"] === "string") {
209
+ return { kind: "legacy_event", event: msg };
210
+ }
211
+
212
+ return null;
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // createCodexSession
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export async function createCodexSession(ctx: SessionContext): Promise<AgentSession> {
220
+ const cwd = ctx.cwd ?? process.cwd();
221
+ const config = ctx.config ?? {};
222
+
223
+ // Resolve binary
224
+ const resolved = await findBinary("codex", config.command);
225
+
226
+ // Build env
227
+ const env = buildEnv(ctx.env);
228
+ ensurePathInEnv(env);
229
+ // Custom endpoint (BYOK / gateway / alt model) — codex needs both a
230
+ // synthesized model_providers block (`-c` args, added below) and the key in env.
231
+ // `unset` is empty for codex (the ambient key never routes to the endpoint).
232
+ const endpointTx = translateEndpoint("codex", config.endpoint);
233
+ Object.assign(env, endpointTx.env);
234
+ for (const key of endpointTx.unset) delete env[key];
235
+
236
+ // Inject skills
237
+ if (config.skillDirs && config.skillDirs.length > 0) {
238
+ try {
239
+ await injectWorkspaceSkills(config.skillDirs, cwd);
240
+ } catch { /* non-fatal */ }
241
+ }
242
+
243
+ // Resolve instructions. In plan mode, prepend a preamble so the agent
244
+ // investigates-and-proposes rather than attempting writes that the sandbox
245
+ // will reject. See ./plan-mode.ts for rationale.
246
+ const baseInstructions = await resolveInstructions(config.instructionsFile);
247
+ const instructions = config.planMode
248
+ ? withPlanModePreamble(baseInstructions)
249
+ : baseInstructions;
250
+
251
+ // Spawn Codex in interactive JSON-RPC mode via the `app-server` subcommand
252
+ // (codex-cli 0.130.0+; the old top-level `--json` flag was removed).
253
+ //
254
+ // Args order matters: `--sandbox` and `--dangerously-bypass-approvals-and-sandbox`
255
+ // are TOP-LEVEL options and must come BEFORE the `app-server` subcommand.
256
+ // extraArgs land after the subcommand — semantics depend on the user's intent.
257
+ const args = [...resolved.prefixArgs];
258
+ if (config.planMode) {
259
+ args.push("--sandbox", "read-only");
260
+ } else if (config.skipPermissions) {
261
+ args.push("--dangerously-bypass-approvals-and-sandbox");
262
+ }
263
+ // Custom endpoint model_providers overrides are top-level `-c` options, placed
264
+ // with the other top-level flags before the `app-server` subcommand (the
265
+ // position that is always valid for global options).
266
+ if (endpointTx.args.length > 0) args.push(...endpointTx.args);
267
+ args.push("app-server");
268
+ if (config.extraArgs) args.push(...config.extraArgs);
269
+
270
+ const proc = spawn(resolved.bin, args, {
271
+ cwd,
272
+ env,
273
+ stdio: ["pipe", "pipe", "pipe"],
274
+ });
275
+
276
+ if (!proc.stdin || !proc.stdout || !proc.stderr) {
277
+ throw new Error("Failed to open stdio on Codex process");
278
+ }
279
+
280
+ const session = new CodexSessionImpl(proc, ctx, cwd, config.model ?? null, instructions);
281
+
282
+ // Perform JSON-RPC initialize handshake + thread/start
283
+ await session.handshake();
284
+
285
+ // Wire up AbortSignal
286
+ if (ctx.signal) {
287
+ if (ctx.signal.aborted) {
288
+ void session.close();
289
+ } else {
290
+ ctx.signal.addEventListener("abort", () => void session.close(), { once: true });
291
+ }
292
+ }
293
+
294
+ return session;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Implementation
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * @internal Re-exported for existing import sites; the const now lives in the
303
+ * leaf `goal-capability.ts` so `index.ts` can read it without loading this
304
+ * heavy session module (spec §5.1).
305
+ */
306
+ export { codexGoalCapability } from "./goal-capability.js";
307
+
308
+ export class CodexSessionImpl implements AgentSession {
309
+ private _state: SessionState = "idle";
310
+ private _threadId: string | null = null;
311
+ /** Thread id to resume (from ctx.sessionParams); null starts a fresh thread. */
312
+ private readonly _resumeThreadId: string | null;
313
+ private _lineBuffer = "";
314
+ private _nextId = 1;
315
+
316
+ // Pending outgoing RPC responses (keyed by request id)
317
+ private _pendingRpc = new Map<number, {
318
+ resolve: (result: Record<string, unknown>) => void;
319
+ reject: (err: Error) => void;
320
+ }>();
321
+
322
+ // Pending result-resolvers. With concurrent send, multiple in-flight send()
323
+ // Promises may share a single result event (when the CLI coalesces them
324
+ // into one turn) or get distinct results across turns. On each
325
+ // turn.completed / turn.failed we drain the entire list — every pending
326
+ // Promise resolves with the same TurnResult.
327
+ private _pendingResults: PendingResult[] = [];
328
+
329
+ /** Result Promises for sends that haven't settled, tracked so `drain()` can
330
+ * await the in-flight turn(s) before closing. */
331
+ private _inFlight = new Set<Promise<TurnResult>>();
332
+
333
+ /** Set by `drain()`: new `send()` calls are refused while true. */
334
+ private _draining = false;
335
+ /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
336
+ private _drainPromise: Promise<void> | null = null;
337
+
338
+ /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
339
+ private readonly _trackToolName = createToolNameTracker();
340
+
341
+ // Per-turn accumulators. Cleared after each result delivery so a subsequent
342
+ // turn's events don't inherit stale values.
343
+ private _turnSummary: string | null = null;
344
+ private _turnUsage: { inputTokens: number; outputTokens: number } | null = null;
345
+ private _turnModel: string | null = null;
346
+ private _turnIsError = false;
347
+ private _turnErrorMessage: string | null = null;
348
+ private _turnStartedAt: Date | null = null;
349
+
350
+ /**
351
+ * Serial dispatch chain for `onEvent`. Each dispatched event appends a
352
+ * handler invocation; the chain enforces in-order delivery and lets
353
+ * `send()` await all handlers for the turn before resolving. Approval
354
+ * RPCs stay synchronous and are not gated on this chain.
355
+ */
356
+ private _eventChain: Promise<void> = Promise.resolve();
357
+
358
+ /** Session-scoped goal engine (best-effort native thread goal + emulation). */
359
+ private readonly _goals: GoalController;
360
+
361
+ constructor(
362
+ private readonly proc: ChildProcess,
363
+ private readonly ctx: SessionContext,
364
+ private readonly cwd: string,
365
+ private readonly model: string | null,
366
+ private readonly instructions: string | null,
367
+ ) {
368
+ this._resumeThreadId = readCodexResumeId(ctx.sessionParams);
369
+
370
+ this._goals = new GoalController({
371
+ providerType: "codex",
372
+ capability: codexGoalCapability,
373
+ getSessionId: () => this._threadId,
374
+ send: (m) => this.send(m),
375
+ dispatch: (event) => this.dispatchEvent(event),
376
+ // Best-effort native arm. Goal mode is experimental + feature-gated
377
+ // (`features.goals=true`); on builds without it the RPC errors and the
378
+ // controller falls back to emulation. The method name is unconfirmed
379
+ // (see spec §11) — we try the community spelling.
380
+ armNative: async (objective) => {
381
+ if (!this._threadId) return false;
382
+ try {
383
+ // Verified against codex 0.130.0 app-server: method + flat
384
+ // `{threadId, objective}` params, gated on the `experimentalApi`
385
+ // capability (declared in handshake) AND a build where the
386
+ // `thread_goals` table exists (goals enabled in config.toml). When the
387
+ // table is absent the RPC errors and the controller falls back to
388
+ // emulation.
389
+ await this.goalRpc("thread/goal/set", {
390
+ threadId: this._threadId,
391
+ objective,
392
+ });
393
+ return true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ },
398
+ clearNative: async () => {
399
+ if (!this._threadId) return;
400
+ await this.goalRpc("thread/goal/clear", { threadId: this._threadId }).catch(() => {});
401
+ },
402
+ });
403
+
404
+ proc.stdout!.setEncoding("utf-8");
405
+ proc.stdout!.on("data", (chunk: string) => this.handleStdout(chunk));
406
+
407
+ proc.stderr!.setEncoding("utf-8");
408
+ proc.stderr!.on("data", (chunk: string) => {
409
+ if (this.ctx.onOutput) {
410
+ try { void this.ctx.onOutput("stderr", chunk); } catch { /* swallow */ }
411
+ }
412
+ });
413
+
414
+ proc.on("exit", (code, signal) => {
415
+ if (this._state !== "closed") {
416
+ this._state = "closed";
417
+ const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
418
+ this.rejectAllPending(err);
419
+ }
420
+ });
421
+
422
+ proc.on("error", (err) => {
423
+ if (this._state !== "closed") {
424
+ this._state = "closed";
425
+ this.rejectAllPending(err);
426
+ }
427
+ });
428
+ }
429
+
430
+ /** Reject every pending send() Promise and outgoing JSON-RPC call. */
431
+ private rejectAllPending(err: Error): void {
432
+ const pending = this._pendingResults.splice(0);
433
+ for (const p of pending) {
434
+ if (p.settled) continue;
435
+ p.settled = true;
436
+ p.cleanup?.();
437
+ p.reject(err);
438
+ }
439
+ for (const [, p] of this._pendingRpc) p.reject(err);
440
+ this._pendingRpc.clear();
441
+ }
442
+
443
+ get sessionId(): string | null { return this._threadId; }
444
+ get state(): SessionState { return this._state; }
445
+
446
+ /**
447
+ * Durable identity for persistence + later `attachSession`. Null until Codex
448
+ * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
449
+ * it round-trips back into `thread/resume`.
450
+ */
451
+ describe(): SessionRecord | null {
452
+ if (!this._threadId) return null;
453
+ const params = codexSessionCodec.serialize({ sessionId: this._threadId, cwd: this.cwd });
454
+ if (!params) return null;
455
+ return createSessionRecord({
456
+ providerType: "codex",
457
+ params,
458
+ cwd: this.cwd,
459
+ displayId: codexSessionCodec.getDisplayId?.(params) ?? null,
460
+ });
461
+ }
462
+
463
+ // -------------------------------------------------------------------------
464
+ // JSON-RPC send helpers
465
+ // -------------------------------------------------------------------------
466
+
467
+ private rpcRequest(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
468
+ const id = this._nextId++;
469
+ const msg: Record<string, unknown> = { jsonrpc: "2.0", id, method };
470
+ if (params) msg["params"] = params;
471
+ this.proc.stdin!.write(JSON.stringify(msg) + "\n");
472
+
473
+ return new Promise((resolve, reject) => {
474
+ this._pendingRpc.set(id, { resolve, reject });
475
+ });
476
+ }
477
+
478
+ /**
479
+ * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
480
+ * family). An app-server build that doesn't recognize the method may never
481
+ * reply; without this, `setGoal`/`clearGoal`/resume hydration would hang
482
+ * forever. On timeout we reject (callers treat that as "unsupported" and fall
483
+ * back to emulation / skip). A late reply still resolves the pending entry
484
+ * harmlessly; a never-reply is cleaned up by rejectAllPending on close.
485
+ */
486
+ private goalRpc(method: string, params: Record<string, unknown>, timeoutMs = 5000): Promise<Record<string, unknown>> {
487
+ return Promise.race([
488
+ this.rpcRequest(method, params),
489
+ new Promise<never>((_, reject) => {
490
+ const t = setTimeout(() => reject(new Error(`codex ${method} timed out`)), timeoutMs);
491
+ if (typeof t.unref === "function") t.unref();
492
+ }),
493
+ ]);
494
+ }
495
+
496
+ private rpcResponse(id: number, result: Record<string, unknown>): void {
497
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
498
+ }
499
+
500
+ // -------------------------------------------------------------------------
501
+ // Handshake
502
+ // -------------------------------------------------------------------------
503
+
504
+ async handshake(): Promise<void> {
505
+ // 1. initialize. Declare `experimentalApi` so the app-server exposes its
506
+ // experimental method surface — notably `thread/goal/{set,get,clear}`, which
507
+ // the server rejects with "requires experimentalApi capability" otherwise
508
+ // (verified against codex 0.130.0 app-server). This is the same capability
509
+ // the official VS Code client declares; it gates access to experimental RPC
510
+ // methods, not turn semantics.
511
+ await this.rpcRequest("initialize", {
512
+ clientInfo: { name: "agentex", version: "1.0.0" },
513
+ capabilities: { experimentalApi: true },
514
+ });
515
+
516
+ // 2. Resume an existing thread when the caller supplied sessionParams,
517
+ // otherwise start a fresh one. `thread/resume` continues the SAME thread
518
+ // with its full context retained — distinct from `thread/fork`, which is
519
+ // a divergent rewind copy. The thread keeps its original cwd/model, so we
520
+ // pass only the thread id (+ refreshed developer instructions).
521
+ if (this._resumeThreadId) {
522
+ const resumeParams: Record<string, unknown> = { threadId: this._resumeThreadId };
523
+ if (this.instructions) resumeParams["developerInstructions"] = this.instructions;
524
+ try {
525
+ const res = await this.rpcRequest("thread/resume", resumeParams);
526
+ const thread = asObj(res, "thread");
527
+ // thread/resume may echo the thread back or return {}; fall back to the
528
+ // id we resumed with so `sessionId` is always populated.
529
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || this._resumeThreadId;
530
+ // Rehydrate a durable Codex goal so getGoal() reflects it immediately
531
+ // (goals live in SQLite, not the transcript, so a resumed thread would
532
+ // otherwise report null until the next goal notification).
533
+ await this.hydrateGoalFromThread();
534
+ return;
535
+ } catch (err) {
536
+ // The thread is unknown to this codex install (different machine, pruned
537
+ // history). Don't fail the whole session — fall back to a fresh thread
538
+ // and surface the downgrade on stderr. The new id flows back out via
539
+ // the next sessionParams snapshot so callers see the session changed.
540
+ if (this.ctx.onOutput) {
541
+ const reason = err instanceof Error ? err.message : String(err);
542
+ try {
543
+ void this.ctx.onOutput(
544
+ "stderr",
545
+ `agentex: codex thread/resume failed for ${this._resumeThreadId}, starting a fresh thread: ${reason}\n`,
546
+ );
547
+ } catch { /* swallow */ }
548
+ }
549
+ }
550
+ }
551
+
552
+ // thread/start (fresh)
553
+ const threadParams: Record<string, unknown> = { cwd: this.cwd };
554
+ if (this.model) threadParams["model"] = this.model;
555
+ if (this.instructions) threadParams["developerInstructions"] = this.instructions;
556
+
557
+ // Apply a chosen collaboration mode (config.modeId) by resolving it against
558
+ // the live mode list. Only on fresh threads — a resumed thread keeps the
559
+ // mode it was created with. Mode discovery is advisory: a failure or an
560
+ // unknown id just falls through to the default mode.
561
+ const modeId = this.ctx.config?.modeId;
562
+ if (modeId) {
563
+ try {
564
+ // Bound the discovery RPC: an older app-server that ignores
565
+ // `collaborationMode/list` would otherwise hang the whole handshake.
566
+ const modesResponse = await Promise.race([
567
+ this.rpcRequest("collaborationMode/list", {}),
568
+ new Promise<never>((_, reject) =>
569
+ setTimeout(() => reject(new Error("collaborationMode/list timed out")), 10_000),
570
+ ),
571
+ ]);
572
+ const collaborationMode = resolveCollaborationModeParam(
573
+ parseCollaborationModes(modesResponse),
574
+ modeId,
575
+ );
576
+ if (collaborationMode) {
577
+ // Avoid sending instructions twice: the caller's top-level
578
+ // `developerInstructions` wins, so drop the mode's copy.
579
+ if (this.instructions) delete collaborationMode.settings["developer_instructions"];
580
+ threadParams["collaborationMode"] = collaborationMode;
581
+ }
582
+ } catch { /* modes are advisory — ignore discovery failures */ }
583
+ }
584
+
585
+ const res = await this.rpcRequest("thread/start", threadParams);
586
+ // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... }, model, ... }
587
+ const thread = asObj(res, "thread");
588
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || null;
589
+ }
590
+
591
+ // -------------------------------------------------------------------------
592
+ // Public API
593
+ // -------------------------------------------------------------------------
594
+
595
+ async send(message: string, options?: SendOptions): Promise<SendHandle> {
596
+ if (this._state === "closed") throw new Error("Session is closed");
597
+ if (this._draining) throw new Error("Session is draining — no new sends accepted");
598
+
599
+ // No protocol-level guard — Codex's TUI demonstrates queueing of user
600
+ // messages during an active turn, and our wire test (transcript
601
+ // 019e33c3) confirms two `user_message` events recorded across a
602
+ // long-running turn. We bet on the JSON-RPC layer queueing similarly
603
+ // and pass through. If the second `turn/start` lands during the first
604
+ // turn, the per-turn accumulators continue collecting until the result
605
+ // event fires; the result then drains all pending resolvers.
606
+ if (this._state === "idle") {
607
+ this._state = "thinking";
608
+ this._turnStartedAt = new Date();
609
+ }
610
+
611
+ // UUID is for API parity with Claude — Codex's JSON-RPC doesn't carry it
612
+ // through the wire protocol, so cancel(uuid) is a no-op for Codex.
613
+ const uuid = randomUUID();
614
+
615
+ // Start a turn — the completion comes via notifications, not the RPC response.
616
+ // codex-cli 0.130.0+ expects `input` as a content-block array, not a plain
617
+ // string. The MCP-style shape: [{type:"text", text:"..."}].
618
+ const turnParams: Record<string, unknown> = {
619
+ input: [{ type: "text", text: message }],
620
+ };
621
+ if (this._threadId) turnParams["threadId"] = this._threadId;
622
+
623
+ let resolveFn!: (r: TurnResult) => void;
624
+ let rejectFn!: (e: Error) => void;
625
+ const result = new Promise<TurnResult>((resolve, reject) => {
626
+ resolveFn = resolve;
627
+ rejectFn = reject;
628
+ });
629
+
630
+ const entry: PendingResult = { resolve: resolveFn, reject: rejectFn };
631
+ this._pendingResults.push(entry);
632
+
633
+ // Track the in-flight turn so drain() can await it; drop it on settle.
634
+ this._inFlight.add(result);
635
+ void result.catch(() => {}).finally(() => this._inFlight.delete(result));
636
+
637
+ // Per-send timeout / abort, falling back to the session-level
638
+ // ProviderConfig.timeoutSec default when no per-call timeout is given.
639
+ this.armSendDeadline(entry, options);
640
+
641
+ this.rpcRequest("turn/start", turnParams).catch(() => {
642
+ // Turn-level errors arrive via turn.failed notifications.
643
+ });
644
+
645
+ return { uuid, result };
646
+ }
647
+
648
+ /**
649
+ * Wire up this send's timeout and/or abort signal. On fire, the active turn
650
+ * is cancelled (`turn/cancel`) and the send settles with `timeout` /
651
+ * `aborted`. No-op when neither a timeout nor a signal applies.
652
+ */
653
+ private armSendDeadline(entry: PendingResult, options?: SendOptions): void {
654
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
655
+ const signal = options?.signal;
656
+ const hasTimeout = typeof timeoutSec === "number" && timeoutSec > 0;
657
+ if (!hasTimeout && !signal) return;
658
+
659
+ if (signal?.aborted) {
660
+ queueMicrotask(() => this.settleEarly(entry, "aborted"));
661
+ return;
662
+ }
663
+
664
+ let timer: ReturnType<typeof setTimeout> | undefined;
665
+ const onAbort = () => this.settleEarly(entry, "aborted");
666
+ if (hasTimeout) {
667
+ timer = setTimeout(() => this.settleEarly(entry, "timeout"), timeoutSec! * 1000);
668
+ }
669
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
670
+
671
+ entry.cleanup = () => {
672
+ if (timer) clearTimeout(timer);
673
+ if (signal) signal.removeEventListener("abort", onAbort);
674
+ };
675
+ }
676
+
677
+ /**
678
+ * Settle a still-pending send early (timeout or abort). Cancels the active
679
+ * turn best-effort and resolves the send's `result` with a synthetic
680
+ * TurnResult. A no-op if the entry already settled (the real result raced
681
+ * ahead). The late real turn-completion later finds the entry already gone.
682
+ */
683
+ private settleEarly(entry: PendingResult, kind: "timeout" | "aborted"): void {
684
+ if (entry.settled) return;
685
+ entry.settled = true;
686
+ entry.cleanup?.();
687
+
688
+ const idx = this._pendingResults.indexOf(entry);
689
+ if (idx >= 0) this._pendingResults.splice(idx, 1);
690
+
691
+ // Best-effort cancel of the active turn. With concurrent sends this ends
692
+ // the single shared turn for all of them — see SendOptions JSDoc.
693
+ void this.interrupt();
694
+
695
+ entry.resolve({
696
+ summary: null,
697
+ usage: undefined,
698
+ costUsd: null,
699
+ status: kind,
700
+ errorCode: kind,
701
+ errorMessage: kind === "timeout"
702
+ ? "Turn exceeded its timeout and was interrupted"
703
+ : "Turn was aborted",
704
+ });
705
+ }
706
+
707
+ async cancel(_uuid: string): Promise<CancelResult> {
708
+ // Codex's JSON-RPC protocol exposes no per-message cancel — only
709
+ // turn-wide `turn/cancel` (which is what `interrupt()` calls).
710
+ // capabilities.cancelQueuedMessage is false; this is a documented no-op.
711
+ return { cancelled: false };
712
+ }
713
+
714
+ async stopTask(_taskId: string): Promise<StopTaskResult> {
715
+ // Codex has no per-task stop control; capabilities.stopTask is false.
716
+ return { stopped: false };
717
+ }
718
+
719
+ setGoal(objective: string, options?: GoalOptions): Promise<SetGoalResult> {
720
+ return this._goals.setGoal(objective, options);
721
+ }
722
+
723
+ clearGoal(options?: { reason?: "cleared" | "blocked" }): Promise<ClearGoalResult> {
724
+ return this._goals.clearGoal(options);
725
+ }
726
+
727
+ getGoal(): GoalState | null {
728
+ return this._goals.getGoal();
729
+ }
730
+
731
+ /**
732
+ * Best-effort: read the durable thread goal (`thread/goal/get`) and hydrate the
733
+ * controller so a resumed session reports it. Silently skips when goals are
734
+ * disabled, the table is absent, or there's no active goal.
735
+ */
736
+ private async hydrateGoalFromThread(): Promise<void> {
737
+ if (!this._threadId) return;
738
+ try {
739
+ const res = await this.goalRpc("thread/goal/get", { threadId: this._threadId });
740
+ const goal = asObj(res, "goal");
741
+ if (Object.keys(goal).length === 0) return;
742
+ const fields = normalizeCodexGoalRecord(goal, "model");
743
+ if (!fields || isTerminalGoalStatus(fields.status)) return;
744
+ const state: GoalState = {
745
+ objective: fields.objective,
746
+ status: fields.status,
747
+ met: fields.met,
748
+ enforced: fields.enforced,
749
+ source: fields.source,
750
+ updatedAt: new Date().toISOString(),
751
+ };
752
+ if (fields.tokensUsed !== undefined) state.tokensUsed = fields.tokensUsed;
753
+ if (fields.timeUsedSeconds !== undefined) state.timeUsedSeconds = fields.timeUsedSeconds;
754
+ if (fields.tokenBudget !== undefined) state.tokenBudget = fields.tokenBudget;
755
+ this._goals.hydrate(state);
756
+ } catch {
757
+ /* goals off / unsupported / no goal — leave getGoal() null */
758
+ }
759
+ }
760
+
761
+ async interrupt(): Promise<void> {
762
+ if (this._state === "idle" || this._state === "closed") return;
763
+ this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
764
+ try {
765
+ await this.rpcRequest("turn/cancel", {});
766
+ } catch { /* best effort */ }
767
+ }
768
+
769
+ async drain(): Promise<void> {
770
+ if (this._state === "closed") return;
771
+ // Coalesce concurrent / repeated drains onto one promise.
772
+ if (this._drainPromise) return this._drainPromise;
773
+ this._draining = true;
774
+ this._drainPromise = (async () => {
775
+ // Let every in-flight turn settle (resolve or reject) before closing, so
776
+ // a running tool finishes rather than being killed mid-flight.
777
+ await Promise.allSettled([...this._inFlight]);
778
+ await this.close();
779
+ })();
780
+ return this._drainPromise;
781
+ }
782
+
783
+ async close(): Promise<void> {
784
+ if (this._state === "closed") return;
785
+ this._state = "closed";
786
+
787
+ this.proc.stdin!.end();
788
+
789
+ // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
790
+ // for sessions running long tools.
791
+ const graceSec = this.ctx.config?.graceSec ?? 5;
792
+ await new Promise<void>((resolve) => {
793
+ const timeout = setTimeout(() => {
794
+ this.proc.kill("SIGKILL");
795
+ resolve();
796
+ }, graceSec * 1000);
797
+
798
+ this.proc.on("exit", () => {
799
+ clearTimeout(timeout);
800
+ resolve();
801
+ });
802
+
803
+ this.proc.kill("SIGTERM");
804
+ });
805
+ }
806
+
807
+ // -------------------------------------------------------------------------
808
+ // Stdout parsing
809
+ // -------------------------------------------------------------------------
810
+
811
+ private handleStdout(chunk: string): void {
812
+ if (this.ctx.onOutput) {
813
+ try { void this.ctx.onOutput("stdout", chunk); } catch { /* swallow */ }
814
+ }
815
+
816
+ this._lineBuffer += chunk;
817
+ const lines = this._lineBuffer.split("\n");
818
+ this._lineBuffer = lines.pop() ?? "";
819
+
820
+ for (const line of lines) {
821
+ const trimmed = line.trim();
822
+ if (!trimmed) continue;
823
+ this.handleLine(trimmed);
824
+ }
825
+ }
826
+
827
+ private handleLine(line: string): void {
828
+ const raw = parseJson(line);
829
+ if (!raw) return;
830
+ const msg = classifyMessage(raw);
831
+ if (!msg) return;
832
+
833
+ switch (msg.kind) {
834
+ case "response":
835
+ this.handleRpcResponse(msg);
836
+ break;
837
+ case "request":
838
+ this.handleServerRequest(msg.id, msg.method, msg.params);
839
+ break;
840
+ case "notification":
841
+ this.handleNotification(msg.method, msg.params, line);
842
+ break;
843
+ case "legacy_event":
844
+ this.handleLegacyEvent(msg.event, line);
845
+ break;
846
+ }
847
+ }
848
+
849
+ // -------------------------------------------------------------------------
850
+ // RPC response dispatch
851
+ // -------------------------------------------------------------------------
852
+
853
+ private handleRpcResponse(msg: { id: number; result?: Record<string, unknown>; error?: { code: number; message: string } }): void {
854
+ const pending = this._pendingRpc.get(msg.id);
855
+ if (!pending) return;
856
+ this._pendingRpc.delete(msg.id);
857
+
858
+ if (msg.error) {
859
+ pending.reject(new Error(`JSON-RPC error ${msg.error.code}: ${msg.error.message}`));
860
+ } else {
861
+ pending.resolve(msg.result ?? {});
862
+ }
863
+ }
864
+
865
+ // -------------------------------------------------------------------------
866
+ // Server→client requests (tool approval)
867
+ // -------------------------------------------------------------------------
868
+
869
+ private handleServerRequest(id: number, method: string, params: Record<string, unknown>): void {
870
+ if (
871
+ method === "item/commandExecution/requestApproval" ||
872
+ method === "item/fileChange/requestApproval"
873
+ ) {
874
+ void this.handleApproval(id, method, params);
875
+ } else if (
876
+ method === "item/tool/requestUserInput" ||
877
+ method === "tool/requestUserInput"
878
+ ) {
879
+ // `tool/requestUserInput` is the legacy method name on older codex builds.
880
+ void this.handleUserInputRequest(id, params);
881
+ } else {
882
+ // Unknown server request — ack to unblock the turn.
883
+ this.rpcResponse(id, {});
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Leave a waiting-for-input/approval state correctly. A slow host handler can
889
+ * resolve after the turn already ended (deliverTurnResult → idle), so restore
890
+ * to `thinking` only when a turn is still in flight, else `idle` — never clobber
891
+ * a finished turn back to `thinking`.
892
+ */
893
+ private restoreStateAfter(waitingState: "waiting_for_approval" | "waiting_for_input"): void {
894
+ if (this._state !== waitingState) return;
895
+ this._state = this._pendingResults.length > 0 ? "thinking" : "idle";
896
+ }
897
+
898
+ private async handleApproval(id: number, method: string, params: Record<string, unknown>): Promise<void> {
899
+ this._state = "waiting_for_approval";
900
+
901
+ // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
902
+ // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
903
+ // concept, so allow → accept and deny → decline.
904
+ if (!this.ctx.onUserInputRequest) {
905
+ this.rpcResponse(id, { decision: "accept" });
906
+ this.restoreStateAfter("waiting_for_approval");
907
+ return;
908
+ }
909
+
910
+ const toolName = method === "item/commandExecution/requestApproval"
911
+ ? "command_execution"
912
+ : "file_change";
913
+
914
+ try {
915
+ const resp = await this.ctx.onUserInputRequest({
916
+ toolName,
917
+ input: params,
918
+ toolUseId: str(params, "id"),
919
+ description: str(params, "command") || str(params, "path") || undefined,
920
+ });
921
+ this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
922
+ } catch {
923
+ this.rpcResponse(id, { decision: "decline" });
924
+ }
925
+
926
+ this.restoreStateAfter("waiting_for_approval");
927
+ }
928
+
929
+ /**
930
+ * Handle a Codex `requestUserInput` server→client request: the agent is asking
931
+ * the user one or more structured questions. Maps onto the cross-provider
932
+ * AskUserQuestion shape so callers reuse `parseAskUserQuestion`, then answers
933
+ * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
934
+ */
935
+ private async handleUserInputRequest(id: number, params: Record<string, unknown>): Promise<void> {
936
+ // Questions are user *input*, not a tool-permission gate — distinct state so a
937
+ // host UI can render a question form vs an approval prompt.
938
+ this._state = "waiting_for_input";
939
+
940
+ const questions = parseCodexQuestions(params);
941
+
942
+ // No host handler, or nothing answerable → return empty answers so the
943
+ // agent proceeds without hanging.
944
+ if (!this.ctx.onUserInputRequest || questions.length === 0) {
945
+ this.rpcResponse(id, { answers: {} });
946
+ this.restoreStateAfter("waiting_for_input");
947
+ return;
948
+ }
949
+
950
+ try {
951
+ const resp = await this.ctx.onUserInputRequest({
952
+ toolName: "AskUserQuestion",
953
+ input: { questions },
954
+ toolUseId: str(params, "id") || "codex-user-input",
955
+ });
956
+ this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
957
+ } catch {
958
+ this.rpcResponse(id, { answers: {} });
959
+ }
960
+
961
+ this.restoreStateAfter("waiting_for_input");
962
+ }
963
+
964
+ // -------------------------------------------------------------------------
965
+ // Notification handling (v2 format)
966
+ // -------------------------------------------------------------------------
967
+
968
+ private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
969
+ // codex/event — legacy wrapper
970
+ if (method === "codex/event") {
971
+ const innerMsg = str(params, "msg");
972
+ if (innerMsg) {
973
+ // Try to parse inner message
974
+ const inner = parseJson(innerMsg);
975
+ if (inner) this.handleLegacyEvent(inner, innerMsg);
976
+ }
977
+ return;
978
+ }
979
+
980
+ // Map v2 notification methods to processing
981
+ if (method === "thread/started") {
982
+ // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
983
+ const thread = asObj(params, "thread");
984
+ this._threadId = str(thread, "id") || str(thread, "sessionId") || this._threadId;
985
+ this.emitStreamEvent(rawLine);
986
+ return;
987
+ }
988
+
989
+ if (method === "item/started") {
990
+ this._state = "tool_executing";
991
+ this.emitStreamEvent(rawLine);
992
+ return;
993
+ }
994
+
995
+ if (method === "item/completed") {
996
+ this._state = "thinking";
997
+ this.extractSummaryFromItem(params);
998
+ this.emitStreamEvent(rawLine);
999
+ return;
1000
+ }
1001
+
1002
+ if (method === "turn/completed") {
1003
+ this.handleTurnCompleted(params);
1004
+ return;
1005
+ }
1006
+
1007
+ if (method === "turn/failed") {
1008
+ this._turnIsError = true;
1009
+ this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
1010
+ // Emit before resolve so the result event is queued onto _eventChain
1011
+ // before resolveTurn awaits it.
1012
+ this.emitStreamEvent(rawLine);
1013
+ this.resolveTurn();
1014
+ return;
1015
+ }
1016
+
1017
+ if (method === "error") {
1018
+ // A request/turn error notification (e.g. a 4xx from the model API).
1019
+ // Capture the message so the trailing `turn/completed` (status "failed")
1020
+ // surfaces it. Don't resolve here: `willRetry: true` means the turn
1021
+ // continues, and either way `turn/completed` is the turn terminus.
1022
+ const msg = str(asObj(params, "error"), "message") || str(params, "message");
1023
+ if (msg) this._turnErrorMessage = msg;
1024
+ this.emitStreamEvent(rawLine);
1025
+ return;
1026
+ }
1027
+
1028
+ // Forward unrecognized notifications
1029
+ this.emitStreamEvent(rawLine);
1030
+ }
1031
+
1032
+ // -------------------------------------------------------------------------
1033
+ // Legacy event handling (NDJSON events with `type` field)
1034
+ // -------------------------------------------------------------------------
1035
+
1036
+ private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
1037
+ const type = str(event, "type");
1038
+
1039
+ if (type === "thread.started") {
1040
+ this._threadId = str(event, "thread_id") || this._threadId;
1041
+ this.emitStreamEvent(rawLine);
1042
+ return;
1043
+ }
1044
+
1045
+ if (type === "item.started") {
1046
+ this._state = "tool_executing";
1047
+ this.emitStreamEvent(rawLine);
1048
+ return;
1049
+ }
1050
+
1051
+ if (type === "item.completed") {
1052
+ this._state = "thinking";
1053
+ this.extractSummaryFromItem(event);
1054
+ this.emitStreamEvent(rawLine);
1055
+ return;
1056
+ }
1057
+
1058
+ if (type === "turn.completed") {
1059
+ this.handleTurnCompleted(event);
1060
+ return;
1061
+ }
1062
+
1063
+ if (type === "turn.failed" || type === "error") {
1064
+ this._turnIsError = true;
1065
+ this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
1066
+ // Emit before resolve so the result event is queued onto _eventChain
1067
+ // before resolveTurn awaits it.
1068
+ this.emitStreamEvent(rawLine);
1069
+ this.resolveTurn();
1070
+ return;
1071
+ }
1072
+
1073
+ this.emitStreamEvent(rawLine);
1074
+ }
1075
+
1076
+ // -------------------------------------------------------------------------
1077
+ // Shared helpers
1078
+ // -------------------------------------------------------------------------
1079
+
1080
+ private extractSummaryFromItem(params: Record<string, unknown>): void {
1081
+ const item = typeof params["item"] === "object" && params["item"] !== null
1082
+ ? (params["item"] as Record<string, unknown>)
1083
+ : params;
1084
+
1085
+ // v2 app-server items are `agentMessage` (camelCase); legacy NDJSON is
1086
+ // `agent_message`. Accept both or v2 turns return a null TurnResult.summary.
1087
+ const itemType = str(item, "type");
1088
+ if (itemType !== "agent_message" && itemType !== "agentMessage") return;
1089
+
1090
+ // Direct text (Codex 0.30+)
1091
+ const directText = str(item, "text");
1092
+ if (directText) {
1093
+ this._turnSummary = directText;
1094
+ return;
1095
+ }
1096
+
1097
+ // Fallback: content array
1098
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1099
+ for (const entry of content) {
1100
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
1101
+ const block = entry as Record<string, unknown>;
1102
+ if (str(block, "type") === "output_text") {
1103
+ const text = str(block, "text");
1104
+ if (text) this._turnSummary = text;
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ private handleTurnCompleted(params: Record<string, unknown>): void {
1110
+ const usage = typeof params["usage"] === "object" && params["usage"] !== null
1111
+ ? (params["usage"] as Record<string, unknown>)
1112
+ : null;
1113
+ if (usage) {
1114
+ const inputTokens = num(usage, "input_tokens");
1115
+ const outputTokens = num(usage, "output_tokens");
1116
+ if (inputTokens > 0 || outputTokens > 0) {
1117
+ this._turnUsage = { inputTokens, outputTokens };
1118
+ }
1119
+ }
1120
+ const model = str(params, "model");
1121
+ if (model) this._turnModel = model;
1122
+
1123
+ // codex 0.130 signals turn failure via `turn/completed` with
1124
+ // `turn.status: "failed"` (carrying `turn.error.message`), not always via a
1125
+ // separate `turn/failed`. Detect it so the TurnResult + result event report
1126
+ // the error instead of a false "completed".
1127
+ const turn = asObj(params, "turn");
1128
+ const turnStatus = str(turn, "status");
1129
+ if (turnStatus === "failed" || turnStatus === "cancelled") {
1130
+ this._turnIsError = true;
1131
+ const msg = str(asObj(turn, "error"), "message");
1132
+ this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
1133
+ }
1134
+
1135
+ // Dispatch the synthesized result event BEFORE resolving the turn, so it
1136
+ // is queued onto _eventChain and resolveTurn → deliverTurnResult drains
1137
+ // it before the awaiting send() returns. We synthesize here (rather than
1138
+ // routing the raw turn/completed line through emitStreamEvent) because
1139
+ // parseCodexStreamLine yields `text: ""` for turn.completed — the
1140
+ // accumulated `_turnSummary` from item.completed events is the useful
1141
+ // payload to carry on the result event.
1142
+ this.dispatchEvent({
1143
+ type: "result",
1144
+ text: this._turnIsError ? (this._turnErrorMessage ?? this._turnSummary ?? "") : (this._turnSummary ?? ""),
1145
+ costUsd: null,
1146
+ isError: this._turnIsError,
1147
+ stopReason: null,
1148
+ terminalReason: turnStatus || null,
1149
+ numTurns: null,
1150
+ durationMs: null,
1151
+ timestamp: new Date().toISOString(),
1152
+ providerType: "codex",
1153
+ sessionId: this._threadId,
1154
+ messageId: null,
1155
+ eventId: null,
1156
+ turnId: null,
1157
+ parentToolCallId: null,
1158
+ raw: params,
1159
+ });
1160
+
1161
+ this.resolveTurn();
1162
+ }
1163
+
1164
+ private resolveTurn(): void {
1165
+ const resolvedModel = this._turnModel ?? this.model;
1166
+ let usage = this._turnUsage && resolvedModel
1167
+ ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
1168
+ : undefined;
1169
+
1170
+ // Usage precedence: the `turn.completed` payload is authoritative when
1171
+ // present (captured above into _turnUsage). Only when the stream carried no
1172
+ // usage do we fall back to scanning Codex's on-disk session logs.
1173
+ //
1174
+ // RACINESS: the disk scan is best-effort and inherently racy — the rollout
1175
+ // file may still be flushing when we read it, so a fallback scan can miss the
1176
+ // latest turn or read partially-written totals. We therefore scan ONLY when
1177
+ // there is no in-band usage, and never let a scan failure fail the turn
1178
+ // (usage simply stays undefined). Prefer the in-band payload always.
1179
+ if (!usage && this._turnStartedAt) {
1180
+ const startedAt = this._turnStartedAt;
1181
+ const threadId = this._threadId ?? undefined;
1182
+ // Fire-and-forget: scan logs then deliver result
1183
+ void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
1184
+ usage = scanned;
1185
+ }).catch(() => {
1186
+ // Non-fatal — usage stays undefined
1187
+ }).finally(() => {
1188
+ void this.deliverTurnResult(usage);
1189
+ });
1190
+ return;
1191
+ }
1192
+
1193
+ void this.deliverTurnResult(usage);
1194
+ }
1195
+
1196
+ private async deliverTurnResult(usage: Record<string, import("../../types.js").TokenUsage> | undefined): Promise<void> {
1197
+ const result: TurnResult = {
1198
+ summary: this._turnSummary,
1199
+ usage,
1200
+ costUsd: null,
1201
+ status: this._turnIsError ? "failed" : "completed",
1202
+ errorCode: this._turnIsError ? "execution_error" : null,
1203
+ errorMessage: this._turnErrorMessage,
1204
+ };
1205
+
1206
+ // Drain pending onEvent handlers so callers awaiting send() see a settled
1207
+ // DB / log / UI state by the time TurnResult resolves. The chain snapshot
1208
+ // here covers every event queued up to and including the result event;
1209
+ // later events extend the chain but aren't awaited.
1210
+ await this._eventChain;
1211
+
1212
+ // The await above yields the event loop; the process may have exited
1213
+ // (or the session closed) during that window, in which case the exit
1214
+ // handler already rejected the turn and set state to "closed". Don't
1215
+ // overwrite that with "idle" — it would falsely advertise a usable
1216
+ // session whose stdin is dead.
1217
+ if (this._state === "closed") return;
1218
+
1219
+ this._state = "idle";
1220
+
1221
+ // Drain ALL pending send() resolvers with this turn's result. Multiple
1222
+ // concurrent sends coalesced into one turn share the same TurnResult.
1223
+ const pending = this._pendingResults.splice(0);
1224
+
1225
+ // Clear per-turn accumulators so a subsequent turn doesn't inherit
1226
+ // stale summary / usage / model.
1227
+ this._turnSummary = null;
1228
+ this._turnUsage = null;
1229
+ this._turnModel = null;
1230
+ this._turnIsError = false;
1231
+ this._turnErrorMessage = null;
1232
+ this._turnStartedAt = null;
1233
+
1234
+ for (const p of pending) {
1235
+ // Skip sends already settled early by timeout / abort.
1236
+ if (p.settled) continue;
1237
+ p.settled = true;
1238
+ p.cleanup?.();
1239
+ p.resolve(result);
1240
+ }
1241
+
1242
+ // Advance any emulated goal loop now that the turn has fully settled.
1243
+ void this._goals.onTurnSettled(result);
1244
+ }
1245
+
1246
+ private emitStreamEvent(rawLine: string): void {
1247
+ // Parse when there's an onEvent subscriber OR an active goal to observe, so
1248
+ // native goal_status transitions update getGoal() even with no handler.
1249
+ if (!this.ctx.onEvent && !this._goals.isTracking()) return;
1250
+ // Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
1251
+ // carry sessionId. v2 notifications parse threadId from params directly
1252
+ // and ignore this arg.
1253
+ const event = parseCodexStreamLine(rawLine, this._threadId);
1254
+ if (event) this.dispatchEvent(event);
1255
+ }
1256
+
1257
+ /**
1258
+ * Queue an event for in-order delivery to `onEvent`. Each call appends a
1259
+ * `.then` to `_eventChain` so handler N+1 only starts after handler N's
1260
+ * returned promise settles. Errors are swallowed inside the chain so a
1261
+ * throwing handler does not break delivery of subsequent events.
1262
+ */
1263
+ private dispatchEvent(event: StreamEvent): void {
1264
+ // Track native goal_status transitions (keeps getGoal() accurate).
1265
+ this._goals.observe(event);
1266
+ const cb = this.ctx.onEvent;
1267
+ if (!cb) return;
1268
+ // Codex emits no native per-event uuid. Where the v2 components exist,
1269
+ // synthesize a documented, replay-stable identity so hosts get an
1270
+ // idempotency key for live captures:
1271
+ // codex:<threadId>:<turnId>:<itemId>:<eventType>
1272
+ // This is an UPSERT key, not a uniqueness guarantee — repeated updates to
1273
+ // the same item (e.g. streaming text on one agent_message) intentionally
1274
+ // share an id; the last write wins. It also does NOT match the transcript
1275
+ // reader's `codex:<sessionId>:<offset>` scheme (different wire vocabulary
1276
+ // on disk) — cross-shape dedup remains a host concern.
1277
+ if (!event.eventId && this._threadId && event.turnId && event.messageId) {
1278
+ event.eventId = `codex:${this._threadId}:${event.turnId}:${event.messageId}:${event.type}`;
1279
+ }
1280
+ // Enrich synchronously (in stream order) so tool_result events carry the
1281
+ // name of the tool_call they answer.
1282
+ const enriched = this._trackToolName(event);
1283
+ this._eventChain = this._eventChain.then(async () => {
1284
+ try { await cb(enriched); } catch { /* swallow */ }
1285
+ });
1286
+ }
1287
+ }