@agentex/agent 0.0.23 → 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 (177) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -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 +7 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -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/execute.d.ts.map +1 -1
  25. package/dist/providers/claude/execute.js +17 -2
  26. package/dist/providers/claude/execute.js.map +1 -1
  27. package/dist/providers/claude/goal-capability.d.ts +15 -0
  28. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  29. package/dist/providers/claude/goal-capability.js +20 -0
  30. package/dist/providers/claude/goal-capability.js.map +1 -0
  31. package/dist/providers/claude/index.d.ts.map +1 -1
  32. package/dist/providers/claude/index.js +8 -4
  33. package/dist/providers/claude/index.js.map +1 -1
  34. package/dist/providers/claude/session.d.ts +11 -9
  35. package/dist/providers/claude/session.d.ts.map +1 -1
  36. package/dist/providers/claude/session.js +36 -14
  37. package/dist/providers/claude/session.js.map +1 -1
  38. package/dist/providers/codex/attach.d.ts +9 -0
  39. package/dist/providers/codex/attach.d.ts.map +1 -0
  40. package/dist/providers/codex/attach.js +93 -0
  41. package/dist/providers/codex/attach.js.map +1 -0
  42. package/dist/providers/codex/execute.d.ts.map +1 -1
  43. package/dist/providers/codex/execute.js +17 -3
  44. package/dist/providers/codex/execute.js.map +1 -1
  45. package/dist/providers/codex/goal-capability.d.ts +13 -0
  46. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  47. package/dist/providers/codex/goal-capability.js +18 -0
  48. package/dist/providers/codex/goal-capability.js.map +1 -0
  49. package/dist/providers/codex/index.d.ts +1 -0
  50. package/dist/providers/codex/index.d.ts.map +1 -1
  51. package/dist/providers/codex/index.js +9 -6
  52. package/dist/providers/codex/index.js.map +1 -1
  53. package/dist/providers/codex/session.d.ts +11 -7
  54. package/dist/providers/codex/session.d.ts.map +1 -1
  55. package/dist/providers/codex/session.js +37 -12
  56. package/dist/providers/codex/session.js.map +1 -1
  57. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  58. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  59. package/dist/providers/codex/transcript-normalize.js +191 -0
  60. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  61. package/dist/providers/cursor/index.d.ts.map +1 -1
  62. package/dist/providers/cursor/index.js +2 -2
  63. package/dist/providers/cursor/index.js.map +1 -1
  64. package/dist/providers/openclaw/index.d.ts.map +1 -1
  65. package/dist/providers/openclaw/index.js +2 -2
  66. package/dist/providers/openclaw/index.js.map +1 -1
  67. package/dist/providers/opencode/index.d.ts.map +1 -1
  68. package/dist/providers/opencode/index.js +3 -5
  69. package/dist/providers/opencode/index.js.map +1 -1
  70. package/dist/providers/pi/index.d.ts.map +1 -1
  71. package/dist/providers/pi/index.js +3 -5
  72. package/dist/providers/pi/index.js.map +1 -1
  73. package/dist/providers/process/index.d.ts.map +1 -1
  74. package/dist/providers/process/index.js +2 -2
  75. package/dist/providers/process/index.js.map +1 -1
  76. package/dist/registry.d.ts +0 -1
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +0 -4
  79. package/dist/registry.js.map +1 -1
  80. package/dist/sessions/index.d.ts +3 -0
  81. package/dist/sessions/index.d.ts.map +1 -0
  82. package/dist/sessions/index.js +2 -0
  83. package/dist/sessions/index.js.map +1 -0
  84. package/dist/sessions/record.d.ts +43 -0
  85. package/dist/sessions/record.d.ts.map +1 -0
  86. package/dist/sessions/record.js +85 -0
  87. package/dist/sessions/record.js.map +1 -0
  88. package/dist/types.d.ts +176 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/dist/utils/endpoint.d.ts +38 -0
  92. package/dist/utils/endpoint.d.ts.map +1 -0
  93. package/dist/utils/endpoint.js +151 -0
  94. package/dist/utils/endpoint.js.map +1 -0
  95. package/dist/utils/env.d.ts.map +1 -1
  96. package/dist/utils/env.js +5 -1
  97. package/dist/utils/env.js.map +1 -1
  98. package/dist/utils/uuid.d.ts +7 -1
  99. package/dist/utils/uuid.d.ts.map +1 -1
  100. package/dist/utils/uuid.js +21 -1
  101. package/dist/utils/uuid.js.map +1 -1
  102. package/package.json +64 -7
  103. package/src/derived.ts +311 -0
  104. package/src/goals/controller.ts +442 -0
  105. package/src/goals/index.ts +21 -0
  106. package/src/goals/normalize.ts +173 -0
  107. package/src/goals/sentinel.ts +90 -0
  108. package/src/index.ts +270 -0
  109. package/src/providers/_shared/http-agent.ts +304 -0
  110. package/src/providers/acp/index.ts +103 -0
  111. package/src/providers/acp/parse.ts +131 -0
  112. package/src/providers/acp/session.ts +744 -0
  113. package/src/providers/claude/attach.ts +147 -0
  114. package/src/providers/claude/codec.ts +43 -0
  115. package/src/providers/claude/execute.ts +300 -0
  116. package/src/providers/claude/goal-capability.ts +21 -0
  117. package/src/providers/claude/index.ts +72 -0
  118. package/src/providers/claude/mcp.ts +82 -0
  119. package/src/providers/claude/parse.ts +824 -0
  120. package/src/providers/claude/session.ts +1192 -0
  121. package/src/providers/claude/transcript.ts +555 -0
  122. package/src/providers/codex/attach.ts +123 -0
  123. package/src/providers/codex/codec.ts +50 -0
  124. package/src/providers/codex/execute.ts +337 -0
  125. package/src/providers/codex/goal-capability.ts +19 -0
  126. package/src/providers/codex/index.ts +57 -0
  127. package/src/providers/codex/modes.ts +159 -0
  128. package/src/providers/codex/parse.ts +691 -0
  129. package/src/providers/codex/plan-mode.ts +49 -0
  130. package/src/providers/codex/session.ts +1287 -0
  131. package/src/providers/codex/transcript-normalize.ts +197 -0
  132. package/src/providers/codex/transcript.ts +487 -0
  133. package/src/providers/codex/usage-scanner.ts +178 -0
  134. package/src/providers/copilot/index.ts +19 -0
  135. package/src/providers/cursor/codec.ts +44 -0
  136. package/src/providers/cursor/execute.ts +271 -0
  137. package/src/providers/cursor/index.ts +25 -0
  138. package/src/providers/cursor/parse.ts +288 -0
  139. package/src/providers/gemini/index.ts +21 -0
  140. package/src/providers/openclaw/codec.ts +40 -0
  141. package/src/providers/openclaw/execute.ts +19 -0
  142. package/src/providers/openclaw/index.ts +29 -0
  143. package/src/providers/opencode/codec.ts +50 -0
  144. package/src/providers/opencode/event-parse.ts +141 -0
  145. package/src/providers/opencode/execute.ts +251 -0
  146. package/src/providers/opencode/http-session.ts +427 -0
  147. package/src/providers/opencode/index.ts +30 -0
  148. package/src/providers/opencode/parse.ts +203 -0
  149. package/src/providers/opencode/server.ts +0 -0
  150. package/src/providers/pi/codec.ts +44 -0
  151. package/src/providers/pi/execute.ts +297 -0
  152. package/src/providers/pi/index.ts +30 -0
  153. package/src/providers/pi/parse.ts +231 -0
  154. package/src/providers/pi/session.ts +381 -0
  155. package/src/providers/process/execute.ts +148 -0
  156. package/src/providers/process/index.ts +52 -0
  157. package/src/registry.ts +40 -0
  158. package/src/sessions/index.ts +8 -0
  159. package/src/sessions/record.ts +108 -0
  160. package/src/types.ts +1638 -0
  161. package/src/utils/ask-user-question.ts +57 -0
  162. package/src/utils/auth.ts +661 -0
  163. package/src/utils/binary.ts +179 -0
  164. package/src/utils/endpoint.ts +172 -0
  165. package/src/utils/env.ts +63 -0
  166. package/src/utils/execute-all.ts +68 -0
  167. package/src/utils/exit-plan-mode.ts +40 -0
  168. package/src/utils/instructions.ts +427 -0
  169. package/src/utils/process.ts +223 -0
  170. package/src/utils/runtime-config.ts +100 -0
  171. package/src/utils/runtime-homes.ts +49 -0
  172. package/src/utils/skill-commands.ts +493 -0
  173. package/src/utils/skills.ts +500 -0
  174. package/src/utils/template.ts +16 -0
  175. package/src/utils/tool-names.ts +51 -0
  176. package/src/utils/uuid.ts +21 -0
  177. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,1192 @@
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
+ SendHandle,
11
+ SendOptions,
12
+ SessionContext,
13
+ SessionRecord,
14
+ SessionState,
15
+ SetGoalResult,
16
+ StopTaskResult,
17
+ StreamEvent,
18
+ TurnResult,
19
+ UserInputResponse,
20
+ } from "../../types.js";
21
+ import { GoalController, latestGoalFromEvents, isTerminalGoalStatus } from "../../goals/index.js";
22
+ import { claudeGoalCapability } from "./goal-capability.js";
23
+ import { claudeSessionCodec } from "./codec.js";
24
+ import { createSessionRecord } from "../../sessions/record.js";
25
+ import { claudeTranscriptOps } from "./transcript.js";
26
+ import { findBinary } from "../../utils/binary.js";
27
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
28
+ import { translateEndpoint } from "../../utils/endpoint.js";
29
+ import { buildSkillsDir, cleanupSkillsDir } from "../../utils/skills.js";
30
+ import { claudeFeatureArgs, cleanupMcpConfig, stageMcpConfig } from "./mcp.js";
31
+ import { createToolNameTracker } from "../../utils/tool-names.js";
32
+ import { parseStreamLine, classifyClaudeAuthFromResult, CLAUDE_LOGIN_COMMAND, type PartialStreamContext } from "./parse.js";
33
+
34
+ /** A pending `send()` whose `result` Promise hasn't settled yet. */
35
+ interface PendingResult {
36
+ resolve: (result: TurnResult) => void;
37
+ reject: (err: Error) => void;
38
+ /** Set once the entry has been settled (by result, timeout, abort, or
39
+ * reject) so the other paths skip it — prevents double-handling. */
40
+ settled?: boolean;
41
+ /** Tear down this send's timeout timer / abort listener. */
42
+ cleanup?: () => void;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // ndjson helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function ndjsonLine(obj: Record<string, unknown>): string {
50
+ return JSON.stringify(obj) + "\n";
51
+ }
52
+
53
+ function parseJson(line: string): Record<string, unknown> | null {
54
+ try {
55
+ const parsed = JSON.parse(line);
56
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
57
+ return parsed as Record<string, unknown>;
58
+ }
59
+ } catch { /* skip */ }
60
+ return null;
61
+ }
62
+
63
+ function str(obj: Record<string, unknown>, key: string): string {
64
+ const v = obj[key];
65
+ return typeof v === "string" ? v : "";
66
+ }
67
+
68
+ function obj(parent: Record<string, unknown>, key: string): Record<string, unknown> {
69
+ const v = parent[key];
70
+ return typeof v === "object" && v !== null && !Array.isArray(v)
71
+ ? v as Record<string, unknown>
72
+ : {};
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Permission response shaping
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Build the wire-shape control_response for a `can_use_tool` request.
81
+ *
82
+ * The CLI's `PermissionResultAllow` schema requires `updatedInput` on every
83
+ * allow response — it carries the (possibly host-modified) tool input back
84
+ * into the agent. If the host doesn't supply one, we echo the original input.
85
+ * `PermissionResultDeny` only requires `behavior` and `message`.
86
+ *
87
+ * Exported for unit testing — not part of the public API.
88
+ *
89
+ * @internal
90
+ */
91
+ export function buildPermissionResponse(
92
+ toolUseId: string,
93
+ input: Record<string, unknown>,
94
+ resp: UserInputResponse | null,
95
+ ): Record<string, unknown> {
96
+ // Auto-allow when no host callback is registered.
97
+ if (resp === null) {
98
+ return { behavior: "allow", toolUseID: toolUseId, updatedInput: input };
99
+ }
100
+ if (resp.allow) {
101
+ const out: Record<string, unknown> = {
102
+ behavior: "allow",
103
+ toolUseID: toolUseId,
104
+ updatedInput: resp.updatedInput ?? input,
105
+ };
106
+ if (resp.message) out["message"] = resp.message;
107
+ return out;
108
+ }
109
+ const out: Record<string, unknown> = {
110
+ behavior: "deny",
111
+ toolUseID: toolUseId,
112
+ };
113
+ if (resp.message) out["message"] = resp.message;
114
+ return out;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // ClaudeSession — persistent multi-turn process
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Creates and returns a ClaudeSession that manages a persistent Claude CLI
123
+ * process using the bidirectional stream-json protocol.
124
+ *
125
+ * The CLI is spawned with `--input-format stream-json --output-format stream-json`
126
+ * which keeps stdin open for multi-turn messages instead of reading a single prompt.
127
+ */
128
+ export async function createClaudeSession(ctx: SessionContext): Promise<AgentSession> {
129
+ const cwd = ctx.cwd ?? process.cwd();
130
+ const config = ctx.config ?? {};
131
+
132
+ // Resolve binary
133
+ const resolved = await findBinary("claude", config.command);
134
+
135
+ // Build env
136
+ const env = buildEnv(ctx.env);
137
+ ensurePathInEnv(env);
138
+ // Custom endpoint (BYOK / gateway / alt model) — env-only for claude. `unset`
139
+ // clears ambient Anthropic creds that would otherwise leak to a custom baseUrl.
140
+ const endpointTx = translateEndpoint("claude", config.endpoint);
141
+ Object.assign(env, endpointTx.env);
142
+ for (const key of endpointTx.unset) delete env[key];
143
+
144
+ // Build skills dir (if any)
145
+ let skillsDir: string | null = null;
146
+ if (config.skillDirs && config.skillDirs.length > 0) {
147
+ try {
148
+ skillsDir = await buildSkillsDir(config.skillDirs, "claude");
149
+ } catch { /* non-fatal */ }
150
+ }
151
+
152
+ // Stage MCP config (if any) — attached via `--mcp-config <file>` (mode 0600),
153
+ // never argv: http server headers can carry bearer tokens and argv is
154
+ // world-readable via `ps`. Cleaned up in close().
155
+ let mcpConfigPath: string | null = null;
156
+ if (config.mcpServers && config.mcpServers.length > 0) {
157
+ mcpConfigPath = await stageMcpConfig(config.mcpServers);
158
+ }
159
+
160
+ // Build CLI args for SDK/headless mode
161
+ const args = [
162
+ ...resolved.prefixArgs,
163
+ "--print", "-",
164
+ "--input-format", "stream-json",
165
+ "--output-format", "stream-json",
166
+ "--verbose",
167
+ ];
168
+
169
+ // Resume existing session
170
+ const sessionParams = ctx.sessionParams ?? null;
171
+ let resumeId: string | null = null;
172
+ if (sessionParams) {
173
+ const id = (sessionParams["sessionId"] as string) ?? (sessionParams["session_id"] as string);
174
+ if (id && typeof id === "string") {
175
+ args.push("--resume", id);
176
+ resumeId = id;
177
+ }
178
+ }
179
+
180
+ // planMode and skipPermissions are mutually exclusive — planMode wins.
181
+ // In plan mode the agent can't actually perform mutations anyway, but we
182
+ // still need stdio permission protocol so the host can inspect the
183
+ // ExitPlanMode permission request and capture the proposed plan.
184
+ if (config.planMode) {
185
+ args.push("--permission-mode", "plan");
186
+ args.push("--permission-prompt-tool", "stdio");
187
+ } else if (config.skipPermissions) {
188
+ args.push("--dangerously-skip-permissions");
189
+ } else {
190
+ // Enable bidirectional permission protocol via control_request/control_response.
191
+ // Without this flag, Claude Code handles permissions internally via its TUI,
192
+ // which silently fails in headless/SDK mode.
193
+ args.push("--permission-prompt-tool", "stdio");
194
+ }
195
+ if (config.model) args.push("--model", config.model);
196
+ if (config.effort) args.push("--effort", config.effort);
197
+ if (config.maxTurns && config.maxTurns > 0) args.push("--max-turns", String(config.maxTurns));
198
+ if (config.instructionsFile) args.push("--append-system-prompt-file", config.instructionsFile);
199
+ if (skillsDir) args.push("--add-dir", skillsDir);
200
+ args.push(...claudeFeatureArgs(config, mcpConfigPath));
201
+ // extraArgs stay LAST so hosts can override any generated flag.
202
+ if (config.extraArgs) args.push(...config.extraArgs);
203
+
204
+ // Spawn persistent process
205
+ const proc: ChildProcess = spawn(resolved.bin, args, {
206
+ cwd,
207
+ env,
208
+ stdio: ["pipe", "pipe", "pipe"],
209
+ });
210
+
211
+ if (!proc.stdin || !proc.stdout || !proc.stderr) {
212
+ // Don't leak staged dirs/files when the spawn fails before the session
213
+ // object (whose close() owns cleanup) exists.
214
+ if (skillsDir) await cleanupSkillsDir(skillsDir);
215
+ await cleanupMcpConfig(mcpConfigPath);
216
+ throw new Error("Failed to open stdio on Claude process");
217
+ }
218
+
219
+ const session = new ClaudeSessionImpl(proc, ctx, skillsDir, mcpConfigPath);
220
+
221
+ // On resume, restore an unmet native goal from the transcript (best-effort,
222
+ // non-blocking so session creation isn't delayed by a transcript read).
223
+ if (resumeId) void session.hydrateGoalFromTranscript(resumeId);
224
+
225
+ // Wire up AbortSignal to close the session
226
+ if (ctx.signal) {
227
+ if (ctx.signal.aborted) {
228
+ void session.close();
229
+ } else {
230
+ ctx.signal.addEventListener("abort", () => void session.close(), { once: true });
231
+ }
232
+ }
233
+
234
+ return session;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Implementation
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * @internal Re-exported for existing import sites; the const now lives in the
243
+ * leaf `goal-capability.ts` so `index.ts` can read it without loading this
244
+ * heavy session module (spec §5.1).
245
+ */
246
+ export { claudeGoalCapability } from "./goal-capability.js";
247
+
248
+ export class ClaudeSessionImpl implements AgentSession {
249
+ private _state: SessionState = "idle";
250
+ private _sessionId: string | null = null;
251
+ private _lineBuffer = "";
252
+ private _stderrBuffer = "";
253
+
254
+ // Pending result-resolvers. With concurrent send, multiple in-flight send()
255
+ // Promises may share a single result event (when the CLI coalesces them
256
+ // into one turn) or get distinct results across turns. On each `result`
257
+ // event we drain the entire list — every pending Promise resolves with the
258
+ // same TurnResult. Subsequent sends queue against a fresh list.
259
+ private _pendingResults: PendingResult[] = [];
260
+
261
+ /** Result Promises for sends that haven't settled, tracked so `drain()` can
262
+ * await the in-flight turn(s) before closing. */
263
+ private _inFlight = new Set<Promise<TurnResult>>();
264
+
265
+ /** Set by `drain()`: new `send()` calls are refused while true. */
266
+ private _draining = false;
267
+ /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
268
+ private _drainPromise: Promise<void> | null = null;
269
+
270
+ /** Tracks the owning message id across --include-partial-messages lines. */
271
+ private readonly _partialCtx: PartialStreamContext = { messageId: null };
272
+
273
+ /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
274
+ private readonly _trackToolName = createToolNameTracker();
275
+
276
+ /**
277
+ * Tracks request_ids for async callbacks (permission, elicitation, hooks)
278
+ * that are still in-flight. If the CLI sends a control_cancel_request for
279
+ * one of these, we remove it so the stale response is never sent back.
280
+ */
281
+ private _pendingCallbacks = new Set<string>();
282
+
283
+ /**
284
+ * Outgoing control_requests we sent to the CLI and are awaiting a
285
+ * control_response for, keyed by request_id. Currently only used by
286
+ * `cancel(uuid)` (interrupt remains fire-and-forget).
287
+ */
288
+ private _pendingControlResponses = new Map<string, {
289
+ resolve: (response: Record<string, unknown>) => void;
290
+ reject: (err: Error) => void;
291
+ }>();
292
+
293
+ /**
294
+ * Serial dispatch chain for `onEvent`. Each dispatched event appends a
295
+ * handler invocation; the chain enforces in-order delivery and lets
296
+ * `send()` await all handlers for the turn before resolving. Control
297
+ * requests stay synchronous and are not gated on this chain.
298
+ */
299
+ private _eventChain: Promise<void> = Promise.resolve();
300
+
301
+ /** Session-scoped goal engine (native `/goal` passthrough + emulation fallback). */
302
+ private readonly _goals: GoalController;
303
+
304
+ /** Resolved transcript path (shared by the goal poller + sentinel context). */
305
+ private _transcriptPath: string | null = null;
306
+ private _transcriptResolving = false;
307
+ /** Tail state for observing native goal_status (transcript-only, not on stdout). */
308
+ private _goalScanOffset = 0;
309
+ private _goalPoll: ReturnType<typeof setInterval> | null = null;
310
+
311
+ constructor(
312
+ private readonly proc: ChildProcess,
313
+ private readonly ctx: SessionContext,
314
+ private readonly skillsDir: string | null,
315
+ private readonly mcpConfigPath: string | null = null,
316
+ ) {
317
+ this._goals = new GoalController({
318
+ providerType: "claude",
319
+ capability: claudeGoalCapability,
320
+ getSessionId: () => this._sessionId,
321
+ send: (m) => this.send(m),
322
+ dispatch: (event) => this.dispatchEvent(event),
323
+ // Best-effort transcript path for custom sentinels that read history.
324
+ // Triggers a lazy async resolve; returns null until it's cached.
325
+ getTranscriptPath: () => this.peekTranscriptPath(),
326
+ // Native arm: `/goal <objective>` arms the CLI's Stop-hook sentinel.
327
+ // Verified against CC 2.1.191: this DOES arm over stream-json stdin, the
328
+ // Haiku sentinel runs, and goal_status attachments are written — but only
329
+ // to the on-disk transcript, NOT the live stdout stream. So we tail the
330
+ // transcript to observe the transitions (startGoalObservation). The
331
+ // objective is whitespace-collapsed for the single-line slash command;
332
+ // the controller keeps the original for state.
333
+ armNative: async (objective) => {
334
+ await this.send(`/goal ${objective.replace(/\s+/g, " ").trim()}`);
335
+ this.startGoalObservation();
336
+ return true;
337
+ },
338
+ clearNative: async () => {
339
+ await this.send("/goal clear");
340
+ },
341
+ });
342
+ // Wire up stdout line-by-line parsing
343
+ proc.stdout!.setEncoding("utf-8");
344
+ proc.stdout!.on("data", (chunk: string) => this.handleStdout(chunk));
345
+
346
+ proc.stderr!.setEncoding("utf-8");
347
+ proc.stderr!.on("data", (chunk: string) => {
348
+ this._stderrBuffer += chunk;
349
+ if (this.ctx.onOutput) {
350
+ try { void this.ctx.onOutput("stderr", chunk); } catch { /* swallow */ }
351
+ }
352
+ });
353
+
354
+ proc.on("exit", (code, signal) => {
355
+ if (this._state !== "closed") {
356
+ this._state = "closed";
357
+ const err = new Error(
358
+ `Claude process exited unexpectedly (code=${code}, signal=${signal})`
359
+ );
360
+ this.rejectAllPending(err);
361
+ }
362
+ });
363
+
364
+ proc.on("error", (err) => {
365
+ if (this._state !== "closed") {
366
+ this._state = "closed";
367
+ this.rejectAllPending(err);
368
+ }
369
+ });
370
+ }
371
+
372
+ /** Reject every pending send() Promise and outgoing control_response. */
373
+ private rejectAllPending(err: Error): void {
374
+ const pending = this._pendingResults.splice(0);
375
+ for (const p of pending) {
376
+ if (p.settled) continue;
377
+ p.settled = true;
378
+ p.cleanup?.();
379
+ p.reject(err);
380
+ }
381
+ for (const [, p] of this._pendingControlResponses) p.reject(err);
382
+ this._pendingControlResponses.clear();
383
+ }
384
+
385
+ get sessionId(): string | null { return this._sessionId; }
386
+ get state(): SessionState { return this._state; }
387
+
388
+ /**
389
+ * Durable identity for persistence + later `attachSession`. Null until Claude
390
+ * has assigned a session id (the first `system`/init event); serializes
391
+ * `{sessionId, cwd?}` through the codec so it round-trips back into resume.
392
+ */
393
+ describe(): SessionRecord | null {
394
+ if (!this._sessionId) return null;
395
+ const cwd = this.ctx.cwd ?? null;
396
+ const params = claudeSessionCodec.serialize({
397
+ sessionId: this._sessionId,
398
+ ...(cwd ? { cwd } : {}),
399
+ });
400
+ if (!params) return null;
401
+ return createSessionRecord({
402
+ providerType: "claude",
403
+ params,
404
+ cwd,
405
+ displayId: claudeSessionCodec.getDisplayId?.(params) ?? null,
406
+ });
407
+ }
408
+
409
+ // -------------------------------------------------------------------------
410
+ // Public API
411
+ // -------------------------------------------------------------------------
412
+
413
+ async send(message: string, options?: SendOptions): Promise<SendHandle> {
414
+ if (this._state === "closed") throw new Error("Session is closed");
415
+ if (this._draining) throw new Error("Session is draining — no new sends accepted");
416
+
417
+ // No guard on _state — Claude's CLI accepts user messages mid-turn and
418
+ // queues them internally (drain via `cancel_async_message` if needed).
419
+ // Set state for observability if currently idle; mid-turn the active
420
+ // turn's state machine keeps driving it.
421
+ if (this._state === "idle") this._state = "thinking";
422
+
423
+ const uuid = randomUUID();
424
+
425
+ // Write user message in stream-json format. `uuid` becomes the queue
426
+ // key the CLI uses for `cancel_async_message`.
427
+ const userMsg = ndjsonLine({
428
+ type: "user",
429
+ session_id: this._sessionId ?? "",
430
+ message: { role: "user", content: message },
431
+ parent_tool_use_id: null,
432
+ uuid,
433
+ });
434
+
435
+ let resolveFn!: (r: TurnResult) => void;
436
+ let rejectFn!: (e: Error) => void;
437
+ const result = new Promise<TurnResult>((resolve, reject) => {
438
+ resolveFn = resolve;
439
+ rejectFn = reject;
440
+ });
441
+
442
+ const entry: PendingResult = { resolve: resolveFn, reject: rejectFn };
443
+ this._pendingResults.push(entry);
444
+
445
+ // Track the in-flight turn so drain() can await it; drop it on settle.
446
+ this._inFlight.add(result);
447
+ void result.catch(() => {}).finally(() => this._inFlight.delete(result));
448
+
449
+ // Per-send timeout / abort, falling back to the session-level
450
+ // ProviderConfig.timeoutSec default when no per-call timeout is given.
451
+ this.armSendDeadline(entry, options);
452
+
453
+ this.proc.stdin!.write(userMsg);
454
+
455
+ return { uuid, result };
456
+ }
457
+
458
+ /**
459
+ * Wire up this send's timeout and/or abort signal. On fire, the active turn
460
+ * is interrupted and the send settles with `timeout` / `aborted`. No-op when
461
+ * neither a timeout nor a signal applies.
462
+ */
463
+ private armSendDeadline(entry: PendingResult, options?: SendOptions): void {
464
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
465
+ const signal = options?.signal;
466
+ const hasTimeout = typeof timeoutSec === "number" && timeoutSec > 0;
467
+ if (!hasTimeout && !signal) return;
468
+
469
+ if (signal?.aborted) {
470
+ // Already aborted before the write — settle on the next tick so the
471
+ // caller still receives its SendHandle first.
472
+ queueMicrotask(() => this.settleEarly(entry, "aborted"));
473
+ return;
474
+ }
475
+
476
+ let timer: ReturnType<typeof setTimeout> | undefined;
477
+ const onAbort = () => this.settleEarly(entry, "aborted");
478
+ if (hasTimeout) {
479
+ timer = setTimeout(() => this.settleEarly(entry, "timeout"), timeoutSec! * 1000);
480
+ }
481
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
482
+
483
+ entry.cleanup = () => {
484
+ if (timer) clearTimeout(timer);
485
+ if (signal) signal.removeEventListener("abort", onAbort);
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Settle a still-pending send early (timeout or abort). Interrupts the active
491
+ * turn best-effort and resolves the send's `result` with a synthetic
492
+ * TurnResult. A no-op if the entry already settled (the real result raced
493
+ * ahead). The late real `result` event later finds the entry already gone.
494
+ */
495
+ private settleEarly(entry: PendingResult, kind: "timeout" | "aborted"): void {
496
+ if (entry.settled) return;
497
+ entry.settled = true;
498
+ entry.cleanup?.();
499
+
500
+ const idx = this._pendingResults.indexOf(entry);
501
+ if (idx >= 0) this._pendingResults.splice(idx, 1);
502
+
503
+ // Best-effort interrupt of the active turn. With concurrent sends this ends
504
+ // the single shared turn for all of them — see SendOptions JSDoc.
505
+ void this.interrupt();
506
+
507
+ entry.resolve({
508
+ summary: null,
509
+ usage: undefined,
510
+ costUsd: null,
511
+ status: kind,
512
+ errorCode: kind,
513
+ errorMessage: kind === "timeout"
514
+ ? "Turn exceeded its timeout and was interrupted"
515
+ : "Turn was aborted",
516
+ });
517
+ }
518
+
519
+ async cancel(uuid: string): Promise<CancelResult> {
520
+ if (this._state === "closed") return { cancelled: false };
521
+
522
+ const requestId = randomUUID();
523
+ const responsePromise = new Promise<Record<string, unknown>>((resolve, reject) => {
524
+ this._pendingControlResponses.set(requestId, { resolve, reject });
525
+ });
526
+
527
+ const cancelMsg = ndjsonLine({
528
+ type: "control_request",
529
+ request_id: requestId,
530
+ request: {
531
+ subtype: "cancel_async_message",
532
+ message_uuid: uuid,
533
+ },
534
+ });
535
+
536
+ this.proc.stdin!.write(cancelMsg);
537
+
538
+ try {
539
+ const response = await responsePromise;
540
+ return { cancelled: response["cancelled"] === true };
541
+ } catch {
542
+ // Process exited / error before response — treat as "not cancelled."
543
+ return { cancelled: false };
544
+ }
545
+ }
546
+
547
+ async stopTask(taskId: string): Promise<StopTaskResult> {
548
+ if (this._state === "closed") return { stopped: false };
549
+
550
+ const requestId = randomUUID();
551
+ const responsePromise = new Promise<Record<string, unknown>>((resolve, reject) => {
552
+ this._pendingControlResponses.set(requestId, { resolve, reject });
553
+ });
554
+
555
+ const stopMsg = ndjsonLine({
556
+ type: "control_request",
557
+ request_id: requestId,
558
+ request: {
559
+ subtype: "stop_task",
560
+ task_id: taskId,
561
+ },
562
+ });
563
+
564
+ this.proc.stdin!.write(stopMsg);
565
+
566
+ try {
567
+ // The CLI acknowledges a stop with an EMPTY success control_response (no
568
+ // payload), so success alone means "accepted". An unknown / already-ended
569
+ // task_id — or a CLI build without `stop_task` — comes back as an error
570
+ // control_response, which rejects here. Either way we settle to a boolean;
571
+ // the task's terminal status arrives later as a task_updated/notification.
572
+ await responsePromise;
573
+ return { stopped: true };
574
+ } catch {
575
+ return { stopped: false };
576
+ }
577
+ }
578
+
579
+ setGoal(objective: string, options?: GoalOptions): Promise<SetGoalResult> {
580
+ return this._goals.setGoal(objective, options);
581
+ }
582
+
583
+ clearGoal(options?: { reason?: "cleared" | "blocked" }): Promise<ClearGoalResult> {
584
+ return this._goals.clearGoal(options);
585
+ }
586
+
587
+ getGoal(): GoalState | null {
588
+ return this._goals.getGoal();
589
+ }
590
+
591
+ async interrupt(): Promise<void> {
592
+ if (this._state === "idle" || this._state === "closed") return;
593
+ this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
594
+
595
+ // Send interrupt control request
596
+ const requestId = randomUUID();
597
+ const interruptMsg = ndjsonLine({
598
+ type: "control_request",
599
+ request_id: requestId,
600
+ request: { subtype: "interrupt" },
601
+ });
602
+
603
+ this.proc.stdin!.write(interruptMsg);
604
+ // The result event from the interrupted turn will resolve the pending send()
605
+ }
606
+
607
+ async drain(): Promise<void> {
608
+ if (this._state === "closed") return;
609
+ // Coalesce concurrent / repeated drains onto one promise.
610
+ if (this._drainPromise) return this._drainPromise;
611
+ this._draining = true;
612
+ this._drainPromise = (async () => {
613
+ // Let every in-flight turn settle (resolve or reject) before closing, so
614
+ // a running tool finishes rather than being killed mid-flight.
615
+ await Promise.allSettled([...this._inFlight]);
616
+ await this.close();
617
+ })();
618
+ return this._drainPromise;
619
+ }
620
+
621
+ async close(): Promise<void> {
622
+ if (this._state === "closed") return;
623
+ this._state = "closed";
624
+ // Final transcript scan so a goal_status (e.g. `met`) written since the last
625
+ // ~800ms poll isn't lost on a fast close. No-op when no goal is observed.
626
+ await this.scanGoalTranscript().catch(() => { /* best effort */ });
627
+ this.stopGoalObservation();
628
+
629
+ // Close stdin to signal the process to exit
630
+ this.proc.stdin!.end();
631
+
632
+ // Give it a moment to exit gracefully, then force kill. The grace window is
633
+ // configurable via ProviderConfig.graceSec for sessions running long tools.
634
+ const graceSec = this.ctx.config?.graceSec ?? 5;
635
+ await new Promise<void>((resolve) => {
636
+ const timeout = setTimeout(() => {
637
+ this.proc.kill("SIGKILL");
638
+ resolve();
639
+ }, graceSec * 1000);
640
+
641
+ this.proc.on("exit", () => {
642
+ clearTimeout(timeout);
643
+ resolve();
644
+ });
645
+
646
+ this.proc.kill("SIGTERM");
647
+ });
648
+
649
+ // Clean up staged dirs/files
650
+ if (this.skillsDir) {
651
+ await cleanupSkillsDir(this.skillsDir);
652
+ }
653
+ await cleanupMcpConfig(this.mcpConfigPath);
654
+ }
655
+
656
+ // -------------------------------------------------------------------------
657
+ // Stdout parsing
658
+ // -------------------------------------------------------------------------
659
+
660
+ private handleStdout(chunk: string): void {
661
+ // Forward raw output
662
+ if (this.ctx.onOutput) {
663
+ try { void this.ctx.onOutput("stdout", chunk); } catch { /* swallow */ }
664
+ }
665
+
666
+ this._lineBuffer += chunk;
667
+ const lines = this._lineBuffer.split("\n");
668
+ this._lineBuffer = lines.pop() ?? "";
669
+
670
+ for (const line of lines) {
671
+ const trimmed = line.trim();
672
+ if (!trimmed) continue;
673
+ this.handleLine(trimmed);
674
+ }
675
+ }
676
+
677
+ private handleLine(line: string): void {
678
+ const msg = parseJson(line);
679
+ if (!msg) return;
680
+
681
+ const type = str(msg, "type");
682
+
683
+ // Control requests from CLI (can_use_tool, elicitation, initialize, etc.)
684
+ if (type === "control_request") {
685
+ this.handleControlRequest(msg);
686
+ return;
687
+ }
688
+
689
+ // Control cancel — CLI is aborting a pending request (e.g., hook won the
690
+ // race against the SDK permission prompt).
691
+ if (type === "control_cancel_request") {
692
+ const cancelId = str(msg, "request_id");
693
+ if (cancelId) this._pendingCallbacks.delete(cancelId);
694
+ return;
695
+ }
696
+
697
+ // Control response — CLI is responding to a control_request we sent
698
+ // (currently only `cancel_async_message`; `interrupt` is fire-and-forget).
699
+ if (type === "control_response") {
700
+ this.handleControlResponse(msg);
701
+ return;
702
+ }
703
+
704
+ // Result event — turn is complete. Forward to onEvent first (via
705
+ // handleStreamMessage / parseStreamLine) so the wire event flows through
706
+ // the same path as every other line; then resolve the TurnResult. The
707
+ // await inside handleResult drains the chain so handlers settle before
708
+ // the awaiting send() returns.
709
+ if (type === "result") {
710
+ this.handleStreamMessage(msg, line);
711
+ void this.handleResult(msg);
712
+ return;
713
+ }
714
+
715
+ // Stream events — forward via onEvent and parse for agentex StreamEvent
716
+ this.handleStreamMessage(msg, line);
717
+ }
718
+
719
+ // -------------------------------------------------------------------------
720
+ // Control request dispatch
721
+ // -------------------------------------------------------------------------
722
+
723
+ private handleControlRequest(msg: Record<string, unknown>): void {
724
+ const requestId = str(msg, "request_id");
725
+ const request = obj(msg, "request");
726
+ const subtype = str(request, "subtype");
727
+
728
+ switch (subtype) {
729
+ case "initialize":
730
+ this.sendControlResponse(requestId, {});
731
+ break;
732
+
733
+ case "can_use_tool":
734
+ this._state = "waiting_for_approval";
735
+ this.handlePermissionRequest(requestId, request);
736
+ break;
737
+
738
+ case "elicitation":
739
+ this._state = "waiting_for_input";
740
+ this.handleElicitationRequest(requestId, request);
741
+ break;
742
+
743
+ case "hook_callback":
744
+ this.handleHookCallback(requestId, request);
745
+ break;
746
+
747
+ default:
748
+ // Unknown control request — respond with empty success to unblock the
749
+ // CLI. This covers subtypes like set_permission_mode, mcp_status,
750
+ // get_context_usage, etc., that don't require host action.
751
+ this.sendControlResponse(requestId, {});
752
+ break;
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Handle a `control_response` from the CLI — a reply to an outgoing
758
+ * `control_request` we sent (currently only `cancel_async_message`).
759
+ *
760
+ * Wire shape:
761
+ * {type:"control_response", response:{request_id, subtype:"success"|"error", response:{...} | error}}
762
+ */
763
+ private handleControlResponse(msg: Record<string, unknown>): void {
764
+ const response = obj(msg, "response");
765
+ const requestId = str(response, "request_id");
766
+ if (!requestId) return;
767
+ const pending = this._pendingControlResponses.get(requestId);
768
+ if (!pending) return;
769
+ this._pendingControlResponses.delete(requestId);
770
+
771
+ const subtype = str(response, "subtype");
772
+ if (subtype === "error") {
773
+ const errMsg = str(response, "error") || "control_response error";
774
+ pending.reject(new Error(errMsg));
775
+ return;
776
+ }
777
+ pending.resolve(obj(response, "response"));
778
+ }
779
+
780
+ // -------------------------------------------------------------------------
781
+ // can_use_tool — permission requests
782
+ // -------------------------------------------------------------------------
783
+
784
+ private async handlePermissionRequest(
785
+ requestId: string,
786
+ request: Record<string, unknown>,
787
+ ): Promise<void> {
788
+ const toolName = str(request, "tool_name");
789
+ const input = obj(request, "input");
790
+ const toolUseId = str(request, "tool_use_id");
791
+
792
+ // If no permission callback, auto-allow
793
+ if (!this.ctx.onUserInputRequest) {
794
+ this.sendControlResponse(
795
+ requestId,
796
+ buildPermissionResponse(toolUseId, input, null),
797
+ );
798
+ return;
799
+ }
800
+
801
+ this._pendingCallbacks.add(requestId);
802
+
803
+ try {
804
+ const resp = await this.ctx.onUserInputRequest({
805
+ toolName,
806
+ input,
807
+ toolUseId,
808
+ title: str(request, "title") || undefined,
809
+ displayName: str(request, "display_name") || undefined,
810
+ description: str(request, "description") || undefined,
811
+ agentId: str(request, "agent_id") || undefined,
812
+ });
813
+
814
+ // If the request was cancelled while we were waiting, don't respond
815
+ if (!this._pendingCallbacks.delete(requestId)) return;
816
+
817
+ this.sendControlResponse(
818
+ requestId,
819
+ buildPermissionResponse(toolUseId, input, resp),
820
+ );
821
+ if (this._state === "waiting_for_approval") this._state = "thinking";
822
+ } catch {
823
+ if (!this._pendingCallbacks.delete(requestId)) return;
824
+ this.sendControlResponse(requestId, {
825
+ behavior: "deny",
826
+ toolUseID: toolUseId,
827
+ message: "Permission callback threw an error",
828
+ });
829
+ if (this._state === "waiting_for_approval") this._state = "thinking";
830
+ }
831
+ }
832
+
833
+ // -------------------------------------------------------------------------
834
+ // elicitation — MCP servers requesting user input
835
+ // -------------------------------------------------------------------------
836
+
837
+ private async handleElicitationRequest(
838
+ requestId: string,
839
+ request: Record<string, unknown>,
840
+ ): Promise<void> {
841
+ // If no elicitation callback, decline
842
+ if (!this.ctx.onElicitation) {
843
+ this.sendControlResponse(requestId, { action: "decline" });
844
+ return;
845
+ }
846
+
847
+ this._pendingCallbacks.add(requestId);
848
+
849
+ try {
850
+ const resp = await this.ctx.onElicitation({
851
+ mcpServerName: str(request, "mcp_server_name"),
852
+ message: str(request, "message"),
853
+ mode: (str(request, "mode") as "form" | "url") || undefined,
854
+ url: str(request, "url") || undefined,
855
+ elicitationId: str(request, "elicitation_id") || undefined,
856
+ requestedSchema: typeof request["requested_schema"] === "object" && request["requested_schema"] !== null
857
+ ? request["requested_schema"] as Record<string, unknown>
858
+ : undefined,
859
+ });
860
+
861
+ if (!this._pendingCallbacks.delete(requestId)) return;
862
+
863
+ const response: Record<string, unknown> = { action: resp.action };
864
+ if (resp.action === "accept" && resp.content) {
865
+ response["content"] = resp.content;
866
+ }
867
+
868
+ this.sendControlResponse(requestId, response);
869
+ if (this._state === "waiting_for_input") this._state = "thinking";
870
+ } catch {
871
+ if (!this._pendingCallbacks.delete(requestId)) return;
872
+ this.sendControlResponse(requestId, { action: "cancel" });
873
+ if (this._state === "waiting_for_input") this._state = "thinking";
874
+ }
875
+ }
876
+
877
+ // -------------------------------------------------------------------------
878
+ // hook_callback — CLI requesting the host to execute a hook
879
+ // -------------------------------------------------------------------------
880
+
881
+ private async handleHookCallback(
882
+ requestId: string,
883
+ request: Record<string, unknown>,
884
+ ): Promise<void> {
885
+ // If no hook callback, return empty result
886
+ if (!this.ctx.onHookCallback) {
887
+ this.sendControlResponse(requestId, {});
888
+ return;
889
+ }
890
+
891
+ this._pendingCallbacks.add(requestId);
892
+
893
+ try {
894
+ const resp = await this.ctx.onHookCallback({
895
+ callbackId: str(request, "callback_id"),
896
+ input: obj(request, "input"),
897
+ toolUseId: str(request, "tool_use_id") || undefined,
898
+ });
899
+
900
+ if (!this._pendingCallbacks.delete(requestId)) return;
901
+ this.sendControlResponse(requestId, resp.result ?? {});
902
+ } catch {
903
+ if (!this._pendingCallbacks.delete(requestId)) return;
904
+ this.sendControlErrorResponse(requestId, "Hook callback threw an error");
905
+ }
906
+ }
907
+
908
+ // -------------------------------------------------------------------------
909
+ // Response helpers
910
+ // -------------------------------------------------------------------------
911
+
912
+ private sendControlResponse(requestId: string, response: Record<string, unknown>): void {
913
+ const msg = ndjsonLine({
914
+ type: "control_response",
915
+ response: {
916
+ request_id: requestId,
917
+ subtype: "success",
918
+ response,
919
+ },
920
+ });
921
+ this.proc.stdin!.write(msg);
922
+ }
923
+
924
+ private sendControlErrorResponse(requestId: string, error: string): void {
925
+ const msg = ndjsonLine({
926
+ type: "control_response",
927
+ response: {
928
+ request_id: requestId,
929
+ subtype: "error",
930
+ error,
931
+ },
932
+ });
933
+ this.proc.stdin!.write(msg);
934
+ }
935
+
936
+ // -------------------------------------------------------------------------
937
+ // Result handling
938
+ // -------------------------------------------------------------------------
939
+
940
+ private async handleResult(msg: Record<string, unknown>): Promise<void> {
941
+ const summary = typeof msg["result"] === "string" ? msg["result"] : null;
942
+ const isError = msg["is_error"] === true;
943
+ const costUsd = typeof msg["total_cost_usd"] === "number" ? msg["total_cost_usd"] : null;
944
+ const stopReason = str(msg, "stop_reason") || null;
945
+ const modelName = str(msg, "model") || null;
946
+
947
+ const usageObj = typeof msg["usage"] === "object" && msg["usage"] !== null
948
+ ? msg["usage"] as Record<string, unknown>
949
+ : null;
950
+
951
+ const usageData = usageObj ? {
952
+ inputTokens: typeof usageObj["input_tokens"] === "number" ? usageObj["input_tokens"] : 0,
953
+ outputTokens: typeof usageObj["output_tokens"] === "number" ? usageObj["output_tokens"] : 0,
954
+ cachedInputTokens: typeof usageObj["cache_read_input_tokens"] === "number"
955
+ ? usageObj["cache_read_input_tokens"] : undefined,
956
+ } : undefined;
957
+
958
+ // Key usage by model name
959
+ const usage = usageData && modelName
960
+ ? { [modelName]: usageData }
961
+ : undefined;
962
+
963
+ // Extract session ID
964
+ if (typeof msg["session_id"] === "string" && msg["session_id"]) {
965
+ this._sessionId = msg["session_id"];
966
+ }
967
+
968
+ // Detect error codes and derive status. Run the auth classifier
969
+ // before the generic `isError` branch so auth failures get the
970
+ // specific `auth_required` code and a recovery message instead of
971
+ // a vague `execution_error`.
972
+ let errorCode: string | null = null;
973
+ const subtype = str(msg, "subtype");
974
+ let status: TurnResult["status"] = "completed";
975
+ const authClassification = classifyClaudeAuthFromResult(msg);
976
+
977
+ if (subtype === "error_max_turns" || stopReason === "max_turns") {
978
+ errorCode = "max_turns";
979
+ status = "max_turns";
980
+ } else if (subtype === "error_max_budget_usd") {
981
+ errorCode = "max_budget";
982
+ status = "max_budget";
983
+ } else if (authClassification) {
984
+ errorCode = "auth_required";
985
+ status = "failed";
986
+ } else if (subtype === "error_during_execution" || isError) {
987
+ errorCode = errorCode ?? "execution_error";
988
+ status = "failed";
989
+ }
990
+
991
+ const errorMessage = (() => {
992
+ if (authClassification) {
993
+ return summary
994
+ ? `${summary} (run \`${CLAUDE_LOGIN_COMMAND}\`)`
995
+ : `Claude requires authentication. Run \`${CLAUDE_LOGIN_COMMAND}\`.`;
996
+ }
997
+ return isError ? summary : null;
998
+ })();
999
+
1000
+ const result: TurnResult = {
1001
+ summary,
1002
+ usage,
1003
+ costUsd,
1004
+ status,
1005
+ errorCode,
1006
+ errorMessage,
1007
+ };
1008
+
1009
+ // Drain pending onEvent handlers so callers awaiting send() see a
1010
+ // settled DB / log / UI state by the time TurnResult resolves. The
1011
+ // chain snapshot here covers every event queued up to and including
1012
+ // the result event; later events extend the chain but aren't awaited.
1013
+ await this._eventChain;
1014
+
1015
+ // The await above yields the event loop; the process may have exited
1016
+ // (or the session closed) during that window, in which case the exit
1017
+ // handler already rejected the turn and set state to "closed". Don't
1018
+ // overwrite that with "idle" — it would falsely advertise a usable
1019
+ // session whose stdin is dead.
1020
+ if (this._state === "closed") return;
1021
+
1022
+ this._state = "idle";
1023
+
1024
+ // Drain ALL pending send() resolvers with this turn's result. Multiple
1025
+ // concurrent sends coalesced by the CLI into one turn share the same
1026
+ // TurnResult — documented in SendHandle JSDoc. Splice empties the list
1027
+ // so subsequent sends queue against a fresh list for the next turn.
1028
+ const pending = this._pendingResults.splice(0);
1029
+ for (const p of pending) {
1030
+ // Skip sends already settled early by timeout / abort.
1031
+ if (p.settled) continue;
1032
+ p.settled = true;
1033
+ p.cleanup?.();
1034
+ p.resolve(result);
1035
+ }
1036
+
1037
+ // Advance any emulated goal loop now that the turn has fully settled.
1038
+ void this._goals.onTurnSettled(result);
1039
+ }
1040
+
1041
+ // -------------------------------------------------------------------------
1042
+ // Stream event forwarding
1043
+ // -------------------------------------------------------------------------
1044
+
1045
+ private handleStreamMessage(msg: Record<string, unknown>, rawLine: string): void {
1046
+ // Extract session ID from any message that has one
1047
+ if (typeof msg["session_id"] === "string" && msg["session_id"]) {
1048
+ this._sessionId = msg["session_id"];
1049
+ }
1050
+
1051
+ // Update session state based on message type
1052
+ const type = str(msg, "type");
1053
+ if (type === "assistant" || type === "thinking") {
1054
+ this._state = "thinking";
1055
+ } else if (type === "tool_use") {
1056
+ this._state = "tool_executing";
1057
+ } else if (type === "tool_result") {
1058
+ this._state = "thinking";
1059
+ }
1060
+
1061
+ // Parse + dispatch when there's an onEvent subscriber OR an active goal to
1062
+ // observe, so native goal_status transitions update getGoal() even with no
1063
+ // handler. dispatchEvent observes unconditionally and gates delivery on cb.
1064
+ if (this.ctx.onEvent || this._goals.isTracking()) {
1065
+ for (const event of parseStreamLine(rawLine, this._partialCtx)) {
1066
+ this.dispatchEvent(event);
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Queue an event for in-order delivery to `onEvent`. Each call appends a
1073
+ * `.then` to `_eventChain` so handler N+1 only starts after handler N's
1074
+ * returned promise settles. Errors are swallowed inside the chain so a
1075
+ * throwing handler does not break delivery of subsequent events.
1076
+ */
1077
+ private dispatchEvent(event: StreamEvent): void {
1078
+ // Let the goal engine track native goal_status transitions even when no
1079
+ // onEvent handler is attached (keeps getGoal() accurate).
1080
+ this._goals.observe(event);
1081
+ const cb = this.ctx.onEvent;
1082
+ if (!cb) return;
1083
+ // Enrich synchronously (in stream order) so tool_result events carry the
1084
+ // name of the tool_call they answer.
1085
+ const enriched = this._trackToolName(event);
1086
+ this._eventChain = this._eventChain.then(async () => {
1087
+ try { await cb(enriched); } catch { /* swallow */ }
1088
+ });
1089
+ }
1090
+
1091
+ // -------------------------------------------------------------------------
1092
+ // Native goal observation
1093
+ //
1094
+ // Claude's `/goal` writes `goal_status` attachments to the on-disk transcript
1095
+ // but NOT to the live stdout stream we parse, so the controller can't observe
1096
+ // native transitions from events. While a native goal is active we tail the
1097
+ // transcript and feed any `goal_status` lines through the normal dispatch
1098
+ // (which runs `_goals.observe` + delivers to onEvent). Self-stops once the
1099
+ // goal reaches a terminal state; also stopped on close().
1100
+ // -------------------------------------------------------------------------
1101
+
1102
+ private startGoalObservation(): void {
1103
+ if (this._goalPoll) return;
1104
+ // NB: do NOT reset _goalScanOffset here. It persists across goals so a second
1105
+ // goal in the same session doesn't replay the first goal's historical
1106
+ // goal_status lines. It starts at 0 (fresh session) and advances as we read.
1107
+ const tick = (): void => { void this.scanGoalTranscript().catch(() => { /* best effort */ }); };
1108
+ this._goalPoll = setInterval(tick, 800);
1109
+ if (typeof this._goalPoll.unref === "function") this._goalPoll.unref();
1110
+ // Defer the first scan: `armNative` calls this BEFORE the controller has
1111
+ // recorded its optimistic `active` state, so a synchronous scan would see
1112
+ // `isTracking() === false` and immediately stop the poller.
1113
+ setTimeout(tick, 0);
1114
+ }
1115
+
1116
+ private stopGoalObservation(): void {
1117
+ if (this._goalPoll) {
1118
+ clearInterval(this._goalPoll);
1119
+ this._goalPoll = null;
1120
+ }
1121
+ }
1122
+
1123
+ private async scanGoalTranscript(): Promise<void> {
1124
+ if (!this._goalPoll) return; // observation already stopped
1125
+ // Stop once the goal is terminal (or was never really native).
1126
+ if (!this._goals.isTracking()) { this.stopGoalObservation(); return; }
1127
+ const filePath = await this.resolveTranscriptPath();
1128
+ if (!filePath) return;
1129
+ for await (const { event, offset } of claudeTranscriptOps.read({
1130
+ filePath,
1131
+ fromOffset: this._goalScanOffset,
1132
+ })) {
1133
+ this._goalScanOffset = offset;
1134
+ if (event.type === "goal_status") this.dispatchEvent(event);
1135
+ }
1136
+ }
1137
+
1138
+ /** Resolve + cache the on-disk transcript path for this session. */
1139
+ private async resolveTranscriptPath(): Promise<string | null> {
1140
+ if (this._transcriptPath) return this._transcriptPath;
1141
+ const sessionId = this._sessionId;
1142
+ if (!sessionId || this._transcriptResolving) return null;
1143
+ this._transcriptResolving = true;
1144
+ try {
1145
+ const found = await claudeTranscriptOps.find({
1146
+ sessionId,
1147
+ cwd: this.ctx.cwd ?? process.cwd(),
1148
+ });
1149
+ if (found) this._transcriptPath = found.filePath;
1150
+ } catch { /* best effort */ } finally {
1151
+ this._transcriptResolving = false;
1152
+ }
1153
+ return this._transcriptPath;
1154
+ }
1155
+
1156
+ /** Sync accessor for the sentinel context; kicks off a lazy resolve. */
1157
+ private peekTranscriptPath(): string | null {
1158
+ if (!this._transcriptPath && this._sessionId) void this.resolveTranscriptPath();
1159
+ return this._transcriptPath;
1160
+ }
1161
+
1162
+ /**
1163
+ * On resume, restore an unmet native goal from the transcript so getGoal()
1164
+ * reflects it and observation continues (Claude persists an unmet goal across
1165
+ * --resume; an achieved/cleared one is not restored).
1166
+ */
1167
+ async hydrateGoalFromTranscript(sessionId: string): Promise<void> {
1168
+ try {
1169
+ const found = await claudeTranscriptOps.find({
1170
+ sessionId,
1171
+ cwd: this.ctx.cwd ?? process.cwd(),
1172
+ });
1173
+ if (!found) return;
1174
+ this._transcriptPath = found.filePath;
1175
+ const events: StreamEvent[] = [];
1176
+ let endOffset = 0;
1177
+ for await (const { event, offset } of claudeTranscriptOps.read({ filePath: found.filePath })) {
1178
+ endOffset = offset;
1179
+ if (event.type === "goal_status") events.push(event);
1180
+ }
1181
+ const last = latestGoalFromEvents(events);
1182
+ if (last && !isTerminalGoalStatus(last.status)) {
1183
+ // Start observing from the END of the historical transcript so the poller
1184
+ // surfaces only post-resume transitions (we already hydrated the state).
1185
+ this._goalScanOffset = endOffset;
1186
+ this._goals.hydrate(last);
1187
+ this.startGoalObservation();
1188
+ }
1189
+ } catch { /* best effort */ }
1190
+ }
1191
+
1192
+ }