@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/derived.d.ts +5 -3
- package/dist/derived.d.ts.map +1 -1
- package/dist/derived.js +11 -7
- package/dist/derived.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/acp/index.d.ts +1 -1
- package/dist/providers/acp/index.d.ts.map +1 -1
- package/dist/providers/acp/index.js +5 -97
- package/dist/providers/acp/index.js.map +1 -1
- package/dist/providers/acp/session.d.ts +8 -1
- package/dist/providers/acp/session.d.ts.map +1 -1
- package/dist/providers/acp/session.js +94 -0
- package/dist/providers/acp/session.js.map +1 -1
- package/dist/providers/claude/attach.d.ts +8 -0
- package/dist/providers/claude/attach.d.ts.map +1 -0
- package/dist/providers/claude/attach.js +113 -0
- package/dist/providers/claude/attach.js.map +1 -0
- package/dist/providers/claude/goal-capability.d.ts +15 -0
- package/dist/providers/claude/goal-capability.d.ts.map +1 -0
- package/dist/providers/claude/goal-capability.js +20 -0
- package/dist/providers/claude/goal-capability.js.map +1 -0
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +8 -4
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/session.d.ts +11 -9
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +29 -14
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/attach.d.ts +9 -0
- package/dist/providers/codex/attach.d.ts.map +1 -0
- package/dist/providers/codex/attach.js +93 -0
- package/dist/providers/codex/attach.js.map +1 -0
- package/dist/providers/codex/goal-capability.d.ts +13 -0
- package/dist/providers/codex/goal-capability.d.ts.map +1 -0
- package/dist/providers/codex/goal-capability.js +18 -0
- package/dist/providers/codex/goal-capability.js.map +1 -0
- package/dist/providers/codex/index.d.ts +1 -0
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +9 -6
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/session.d.ts +11 -7
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +24 -12
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.d.ts +28 -0
- package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
- package/dist/providers/codex/transcript-normalize.js +191 -0
- package/dist/providers/codex/transcript-normalize.js.map +1 -0
- package/dist/providers/cursor/index.d.ts.map +1 -1
- package/dist/providers/cursor/index.js +2 -2
- package/dist/providers/cursor/index.js.map +1 -1
- package/dist/providers/openclaw/index.d.ts.map +1 -1
- package/dist/providers/openclaw/index.js +2 -2
- package/dist/providers/openclaw/index.js.map +1 -1
- package/dist/providers/opencode/index.d.ts.map +1 -1
- package/dist/providers/opencode/index.js +3 -5
- package/dist/providers/opencode/index.js.map +1 -1
- package/dist/providers/pi/index.d.ts.map +1 -1
- package/dist/providers/pi/index.js +3 -5
- package/dist/providers/pi/index.js.map +1 -1
- package/dist/providers/process/index.d.ts.map +1 -1
- package/dist/providers/process/index.js +2 -2
- package/dist/providers/process/index.js.map +1 -1
- package/dist/registry.d.ts +0 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -4
- package/dist/registry.js.map +1 -1
- package/dist/sessions/index.d.ts +3 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/record.d.ts +43 -0
- package/dist/sessions/record.d.ts.map +1 -0
- package/dist/sessions/record.js +85 -0
- package/dist/sessions/record.js.map +1 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/uuid.d.ts +7 -1
- package/dist/utils/uuid.d.ts.map +1 -1
- package/dist/utils/uuid.js +21 -1
- package/dist/utils/uuid.js.map +1 -1
- package/package.json +64 -7
- package/src/derived.ts +311 -0
- package/src/goals/controller.ts +442 -0
- package/src/goals/index.ts +21 -0
- package/src/goals/normalize.ts +173 -0
- package/src/goals/sentinel.ts +90 -0
- package/src/index.ts +270 -0
- package/src/providers/_shared/http-agent.ts +304 -0
- package/src/providers/acp/index.ts +103 -0
- package/src/providers/acp/parse.ts +131 -0
- package/src/providers/acp/session.ts +744 -0
- package/src/providers/claude/attach.ts +147 -0
- package/src/providers/claude/codec.ts +43 -0
- package/src/providers/claude/execute.ts +300 -0
- package/src/providers/claude/goal-capability.ts +21 -0
- package/src/providers/claude/index.ts +72 -0
- package/src/providers/claude/mcp.ts +82 -0
- package/src/providers/claude/parse.ts +824 -0
- package/src/providers/claude/session.ts +1192 -0
- package/src/providers/claude/transcript.ts +555 -0
- package/src/providers/codex/attach.ts +123 -0
- package/src/providers/codex/codec.ts +50 -0
- package/src/providers/codex/execute.ts +337 -0
- package/src/providers/codex/goal-capability.ts +19 -0
- package/src/providers/codex/index.ts +57 -0
- package/src/providers/codex/modes.ts +159 -0
- package/src/providers/codex/parse.ts +691 -0
- package/src/providers/codex/plan-mode.ts +49 -0
- package/src/providers/codex/session.ts +1287 -0
- package/src/providers/codex/transcript-normalize.ts +197 -0
- package/src/providers/codex/transcript.ts +487 -0
- package/src/providers/codex/usage-scanner.ts +178 -0
- package/src/providers/copilot/index.ts +19 -0
- package/src/providers/cursor/codec.ts +44 -0
- package/src/providers/cursor/execute.ts +271 -0
- package/src/providers/cursor/index.ts +25 -0
- package/src/providers/cursor/parse.ts +288 -0
- package/src/providers/gemini/index.ts +21 -0
- package/src/providers/openclaw/codec.ts +40 -0
- package/src/providers/openclaw/execute.ts +19 -0
- package/src/providers/openclaw/index.ts +29 -0
- package/src/providers/opencode/codec.ts +50 -0
- package/src/providers/opencode/event-parse.ts +141 -0
- package/src/providers/opencode/execute.ts +251 -0
- package/src/providers/opencode/http-session.ts +427 -0
- package/src/providers/opencode/index.ts +30 -0
- package/src/providers/opencode/parse.ts +203 -0
- package/src/providers/opencode/server.ts +0 -0
- package/src/providers/pi/codec.ts +44 -0
- package/src/providers/pi/execute.ts +297 -0
- package/src/providers/pi/index.ts +30 -0
- package/src/providers/pi/parse.ts +231 -0
- package/src/providers/pi/session.ts +381 -0
- package/src/providers/process/execute.ts +148 -0
- package/src/providers/process/index.ts +52 -0
- package/src/registry.ts +40 -0
- package/src/sessions/index.ts +8 -0
- package/src/sessions/record.ts +108 -0
- package/src/types.ts +1638 -0
- package/src/utils/ask-user-question.ts +57 -0
- package/src/utils/auth.ts +661 -0
- package/src/utils/binary.ts +179 -0
- package/src/utils/endpoint.ts +172 -0
- package/src/utils/env.ts +63 -0
- package/src/utils/execute-all.ts +68 -0
- package/src/utils/exit-plan-mode.ts +40 -0
- package/src/utils/instructions.ts +427 -0
- package/src/utils/process.ts +223 -0
- package/src/utils/runtime-config.ts +100 -0
- package/src/utils/runtime-homes.ts +49 -0
- package/src/utils/skill-commands.ts +493 -0
- package/src/utils/skills.ts +500 -0
- package/src/utils/template.ts +16 -0
- package/src/utils/tool-names.ts +51 -0
- package/src/utils/uuid.ts +21 -0
- 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
|
+
}
|