@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,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
|
+
}
|