@inixiative/agent-session 0.1.0
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/README.md +73 -0
- package/package.json +24 -0
- package/src/claude-code-session.ts +779 -0
- package/src/codex-session.ts +947 -0
- package/src/harness-session.ts +155 -0
- package/src/index.ts +21 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ClaudeCodeSession — long-lived Claude Code process with full event capture
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Implements HarnessSession by spawning a persistent `claude` CLI process
|
|
6
|
+
// with --input-format stream-json --output-format stream-json.
|
|
7
|
+
//
|
|
8
|
+
// Architecture:
|
|
9
|
+
// start() → spawns one process, starts background stdout read loop
|
|
10
|
+
// send() → writes JSON to stdin, returns promise resolved on "result" event
|
|
11
|
+
// fork() → creates new session with --resume <id> --fork-session
|
|
12
|
+
// kill() → closes stdin, kills process
|
|
13
|
+
//
|
|
14
|
+
// Lifecycle:
|
|
15
|
+
// const session = new ClaudeCodeSession({ baseContext, cwd });
|
|
16
|
+
// await session.start(); // one startup
|
|
17
|
+
// const r1 = await session.send("Fix the bug"); // instant, no CLI restart
|
|
18
|
+
// const r2 = await session.send("Now add tests"); // reuses same process
|
|
19
|
+
// session.kill();
|
|
20
|
+
//
|
|
21
|
+
// Performance:
|
|
22
|
+
// CLI startup cost is paid ONCE. Each send() is just a JSON line on stdin.
|
|
23
|
+
// Base context (project identity, conventions, repo map) is injected at
|
|
24
|
+
// startup via --append-system-prompt. Per-message delta is minimal.
|
|
25
|
+
//
|
|
26
|
+
// Fork:
|
|
27
|
+
// const forked = session.fork({ cwd: otherWorktree });
|
|
28
|
+
// await forked.start(); // new process with --resume <id> --fork-session
|
|
29
|
+
// await forked.send("Continue from here");
|
|
30
|
+
//
|
|
31
|
+
// --resume is used ONLY for fork and crash recovery, not as the normal
|
|
32
|
+
// transport. Normal messages go through stdin.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
BeforeSendHook,
|
|
37
|
+
HarnessSession,
|
|
38
|
+
SessionEvent,
|
|
39
|
+
SessionEventHandler,
|
|
40
|
+
SessionResult,
|
|
41
|
+
SessionArtifact,
|
|
42
|
+
} from "./harness-session";
|
|
43
|
+
|
|
44
|
+
// Re-export types so existing import paths keep working
|
|
45
|
+
export type {
|
|
46
|
+
SessionEvent,
|
|
47
|
+
SessionEventKind,
|
|
48
|
+
SessionResult,
|
|
49
|
+
SessionArtifact,
|
|
50
|
+
} from "./harness-session";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Configuration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface ClaudeCodeSessionConfig {
|
|
57
|
+
/** Path to claude CLI binary. Defaults to "claude". */
|
|
58
|
+
bin?: string;
|
|
59
|
+
/** Model. Defaults to "sonnet". */
|
|
60
|
+
model?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Reasoning-effort level (`--effort`). One of low|medium|high|xhigh|max.
|
|
63
|
+
* Omitted → the CLI's default effort. Recorded per run for comparability.
|
|
64
|
+
*/
|
|
65
|
+
effort?: string;
|
|
66
|
+
/** Working directory for the session. */
|
|
67
|
+
cwd?: string;
|
|
68
|
+
/** Max agentic turns per message. Defaults to 25. */
|
|
69
|
+
maxTurns?: number;
|
|
70
|
+
/** Permission mode. Defaults to "bypassPermissions". */
|
|
71
|
+
permissionMode?: string;
|
|
72
|
+
/** Default per-send timeout in ms. Defaults to 600000 (10 min). */
|
|
73
|
+
timeout?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Base context to pre-load at session startup.
|
|
76
|
+
*
|
|
77
|
+
* Injected via --append-system-prompt on process spawn. Persists for the
|
|
78
|
+
* entire session lifetime. Include all stable context here (system,
|
|
79
|
+
* conventions, memory, architecture) so per-message delta is minimal.
|
|
80
|
+
*/
|
|
81
|
+
baseContext?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Native Claude Code session ID (the UUID under ~/.claude/projects/).
|
|
84
|
+
* When set, the process is spawned with `--resume <id>` — used for fork
|
|
85
|
+
* and crash recovery. Also set by a SessionAdapter when resuming a
|
|
86
|
+
* Foundry thread that was previously mapped to this external ID.
|
|
87
|
+
*/
|
|
88
|
+
externalSessionId?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Override for the process spawner. Defaults to Bun.spawn. Tests inject a
|
|
91
|
+
* fake subprocess that emulates the claude CLI's stream-json protocol.
|
|
92
|
+
*/
|
|
93
|
+
spawn?: (
|
|
94
|
+
cmd: string[],
|
|
95
|
+
opts: {
|
|
96
|
+
cwd: string;
|
|
97
|
+
env: Record<string, string | undefined>;
|
|
98
|
+
},
|
|
99
|
+
) => PipedSubprocess;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internal turn queue entry
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
interface QueuedTurn {
|
|
107
|
+
message: string;
|
|
108
|
+
timeout: number;
|
|
109
|
+
resolve: (result: SessionResult) => void;
|
|
110
|
+
reject: (error: Error) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Session
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Concrete types for Bun.spawn with all pipes — also the shape tests mock. */
|
|
118
|
+
export interface PipedSubprocess {
|
|
119
|
+
stdin: { write(data: string): void; flush(): void; end(): void };
|
|
120
|
+
stdout: ReadableStream<Uint8Array>;
|
|
121
|
+
stderr: ReadableStream<Uint8Array>;
|
|
122
|
+
exited: Promise<number>;
|
|
123
|
+
kill(): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class ClaudeCodeSession implements HarnessSession {
|
|
127
|
+
// -- Config --
|
|
128
|
+
private _bin: string;
|
|
129
|
+
private _model: string;
|
|
130
|
+
private _effort?: string;
|
|
131
|
+
private _cwd: string;
|
|
132
|
+
private _maxTurns: number;
|
|
133
|
+
private _permissionMode: string;
|
|
134
|
+
private _defaultTimeout: number;
|
|
135
|
+
private _baseContext?: string;
|
|
136
|
+
private _forking = false;
|
|
137
|
+
private _spawn?: ClaudeCodeSessionConfig["spawn"];
|
|
138
|
+
|
|
139
|
+
// -- Process --
|
|
140
|
+
// Bun.spawn's return type is a union; we always use stdin:"pipe"/stdout:"pipe"/stderr:"pipe"
|
|
141
|
+
// so we know the concrete types at runtime.
|
|
142
|
+
private _proc: PipedSubprocess | null = null;
|
|
143
|
+
private _stderr = "";
|
|
144
|
+
|
|
145
|
+
// -- Session state --
|
|
146
|
+
/**
|
|
147
|
+
* The Claude Code runtime's native session ID. Set from config (for fork /
|
|
148
|
+
* crash recovery) or learned from the stream's system_init event. When
|
|
149
|
+
* set, _buildSpawnArgs() includes --resume <id>, so subsequent start()
|
|
150
|
+
* calls resume rather than create a new native session.
|
|
151
|
+
*/
|
|
152
|
+
private _externalSessionId?: string;
|
|
153
|
+
private _alive = false;
|
|
154
|
+
private _eventLog: SessionEvent[] = [];
|
|
155
|
+
private _handlers: SessionEventHandler[] = [];
|
|
156
|
+
private _beforeSendHooks: BeforeSendHook[] = [];
|
|
157
|
+
private _turns = 0;
|
|
158
|
+
private _totalTokens = { input: 0, output: 0 };
|
|
159
|
+
private _startedAt: number;
|
|
160
|
+
/**
|
|
161
|
+
* Whether we've already seen a system/init event on the stream. If a second
|
|
162
|
+
* one arrives it signals a mid-session restart — Claude Code's auto-compact
|
|
163
|
+
* kills and resumes the session, which reproduces the init event. We use
|
|
164
|
+
* this to emit `session_compact` so the FlowOrchestrator can invalidate
|
|
165
|
+
* the Librarian's injection ledger.
|
|
166
|
+
*/
|
|
167
|
+
private _sawInit = false;
|
|
168
|
+
|
|
169
|
+
// -- Turn queue --
|
|
170
|
+
private _queue: QueuedTurn[] = [];
|
|
171
|
+
private _inflight: QueuedTurn | null = null;
|
|
172
|
+
private _turnEvents: SessionEvent[] = [];
|
|
173
|
+
private _resultText = "";
|
|
174
|
+
private _turnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
175
|
+
|
|
176
|
+
constructor(config?: ClaudeCodeSessionConfig) {
|
|
177
|
+
const bin = config?.bin ?? "claude";
|
|
178
|
+
if (!/^[a-zA-Z0-9_.\/\\-]+$/.test(bin)) {
|
|
179
|
+
throw new Error(`Invalid claude CLI binary path: "${bin}"`);
|
|
180
|
+
}
|
|
181
|
+
this._bin = bin;
|
|
182
|
+
this._model = config?.model ?? "sonnet";
|
|
183
|
+
this._effort = config?.effort;
|
|
184
|
+
this._cwd = config?.cwd ?? process.cwd();
|
|
185
|
+
this._maxTurns = config?.maxTurns ?? 25;
|
|
186
|
+
this._permissionMode = config?.permissionMode ?? "bypassPermissions";
|
|
187
|
+
this._defaultTimeout = config?.timeout ?? 600_000;
|
|
188
|
+
this._baseContext = config?.baseContext;
|
|
189
|
+
this._externalSessionId = config?.externalSessionId;
|
|
190
|
+
this._spawn = config?.spawn;
|
|
191
|
+
this._startedAt = Date.now();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Accessors
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
get alive(): boolean { return this._alive; }
|
|
199
|
+
get externalSessionId(): string | undefined { return this._externalSessionId; }
|
|
200
|
+
get events(): readonly SessionEvent[] { return this._eventLog; }
|
|
201
|
+
get turns(): number { return this._turns; }
|
|
202
|
+
get totalTokens(): Readonly<{ input: number; output: number }> {
|
|
203
|
+
return { ...this._totalTokens };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Event subscription
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
onEvent(handler: SessionEventHandler): () => void {
|
|
211
|
+
this._handlers.push(handler);
|
|
212
|
+
return () => {
|
|
213
|
+
const idx = this._handlers.indexOf(handler);
|
|
214
|
+
if (idx !== -1) this._handlers.splice(idx, 1);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
onBeforeSend(hook: BeforeSendHook): () => void {
|
|
219
|
+
this._beforeSendHooks.push(hook);
|
|
220
|
+
return () => {
|
|
221
|
+
const idx = this._beforeSendHooks.indexOf(hook);
|
|
222
|
+
if (idx !== -1) this._beforeSendHooks.splice(idx, 1);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Mid-turn push. Claude Code's stream-json stdin accepts user messages
|
|
228
|
+
* only; there is no dedicated out-of-band signal channel. So the current
|
|
229
|
+
* behavior is to emit a "push_ignored" error event — callers observe it
|
|
230
|
+
* but the model does not see the payload until the next turn.
|
|
231
|
+
*
|
|
232
|
+
* A future improvement: push via the MCP bridge (FLOW.md Loop 4) so the
|
|
233
|
+
* signal reaches the in-flight turn as a tool result the model must read.
|
|
234
|
+
*/
|
|
235
|
+
async push(payload: { kind: string; text: string }): Promise<void> {
|
|
236
|
+
this._emit({
|
|
237
|
+
kind: "error",
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
text: `push_ignored: kind=${payload.kind} — stream-json stdin has no OOB channel`,
|
|
240
|
+
raw: payload,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// start() — spawn the persistent process
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async start(): Promise<void> {
|
|
249
|
+
if (this._proc) throw new Error("Session already started");
|
|
250
|
+
|
|
251
|
+
const args = this._buildSpawnArgs();
|
|
252
|
+
|
|
253
|
+
// Strip API key env vars — CLI uses subscription auth
|
|
254
|
+
const env: Record<string, string | undefined> = {
|
|
255
|
+
...process.env,
|
|
256
|
+
DISABLE_AUTOUPDATER: "1",
|
|
257
|
+
};
|
|
258
|
+
delete env.ANTHROPIC_API_KEY;
|
|
259
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
260
|
+
|
|
261
|
+
if (this._spawn) {
|
|
262
|
+
this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
|
|
263
|
+
} else {
|
|
264
|
+
this._proc = Bun.spawn([this._bin, ...args], {
|
|
265
|
+
cwd: this._cwd,
|
|
266
|
+
stdin: "pipe",
|
|
267
|
+
stdout: "pipe",
|
|
268
|
+
stderr: "pipe",
|
|
269
|
+
env,
|
|
270
|
+
}) as unknown as PipedSubprocess;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this._alive = true;
|
|
274
|
+
this._emit({ kind: "session_start", timestamp: Date.now() });
|
|
275
|
+
|
|
276
|
+
// Background readers — run for session lifetime (don't await)
|
|
277
|
+
this._readStdout();
|
|
278
|
+
this._readStderr();
|
|
279
|
+
|
|
280
|
+
// Monitor process exit for cleanup
|
|
281
|
+
this._proc.exited.then((code) => {
|
|
282
|
+
if (!this._alive) return;
|
|
283
|
+
this._alive = false;
|
|
284
|
+
const errMsg = this._stderr.trim()
|
|
285
|
+
? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
|
|
286
|
+
: `Process exited with code ${code}`;
|
|
287
|
+
this._rejectInflight(new Error(errMsg));
|
|
288
|
+
this._rejectQueue(new Error("Session ended"));
|
|
289
|
+
this._emit({ kind: "session_end", timestamp: Date.now() });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// send() — write message to stdin, resolve on result event
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
async send(
|
|
298
|
+
message: string,
|
|
299
|
+
opts?: { timeout?: number },
|
|
300
|
+
): Promise<SessionResult> {
|
|
301
|
+
// Auto-restart after interrupt / process death (crash recovery via --resume).
|
|
302
|
+
// _externalSessionId persists across process lifetimes, so start() will
|
|
303
|
+
// include --resume <id> in spawn args.
|
|
304
|
+
if (!this._proc && this._externalSessionId) {
|
|
305
|
+
await this.start();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!this._proc) throw new Error("Session not started — call start() first");
|
|
309
|
+
if (!this._alive) throw new Error("Session ended");
|
|
310
|
+
|
|
311
|
+
const timeout = opts?.timeout ?? this._defaultTimeout;
|
|
312
|
+
|
|
313
|
+
// Compose pre-send hooks in registration order. Each sees the previous
|
|
314
|
+
// hook's output. Errors in a hook reject the send() — callers should
|
|
315
|
+
// unregister problematic hooks or catch.
|
|
316
|
+
let transformed = message;
|
|
317
|
+
for (const hook of this._beforeSendHooks) {
|
|
318
|
+
transformed = await hook(transformed);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return new Promise<SessionResult>((resolve, reject) => {
|
|
322
|
+
const turn: QueuedTurn = { message: transformed, timeout, resolve, reject };
|
|
323
|
+
|
|
324
|
+
if (!this._inflight) {
|
|
325
|
+
this._dispatchTurn(turn);
|
|
326
|
+
} else {
|
|
327
|
+
this._queue.push(turn);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// fork() — branch from current conversation state
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
fork(opts?: { cwd?: string; baseContext?: string }): ClaudeCodeSession {
|
|
337
|
+
if (!this._externalSessionId) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
"Cannot fork — no external session ID yet (send at least one message first)",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const forked = new ClaudeCodeSession({
|
|
344
|
+
bin: this._bin,
|
|
345
|
+
model: this._model,
|
|
346
|
+
effort: this._effort,
|
|
347
|
+
cwd: opts?.cwd ?? this._cwd,
|
|
348
|
+
maxTurns: this._maxTurns,
|
|
349
|
+
permissionMode: this._permissionMode,
|
|
350
|
+
timeout: this._defaultTimeout,
|
|
351
|
+
baseContext: opts?.baseContext ?? this._baseContext,
|
|
352
|
+
externalSessionId: this._externalSessionId,
|
|
353
|
+
spawn: this._spawn,
|
|
354
|
+
});
|
|
355
|
+
forked._forking = true;
|
|
356
|
+
return forked;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// interrupt() — cancel the in-flight turn (best-effort)
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
interrupt(): void {
|
|
364
|
+
if (!this._inflight) return;
|
|
365
|
+
|
|
366
|
+
this._rejectInflight(new Error("Turn interrupted"));
|
|
367
|
+
|
|
368
|
+
// Process continues running. When it emits the orphaned result,
|
|
369
|
+
// _processLine dispatches the next queued turn (if any).
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// kill() — terminate the session
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
kill(): void {
|
|
377
|
+
if (!this._proc) return;
|
|
378
|
+
this._alive = false;
|
|
379
|
+
|
|
380
|
+
if (this._turnTimer) {
|
|
381
|
+
clearTimeout(this._turnTimer);
|
|
382
|
+
this._turnTimer = null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this._rejectInflight(new Error("Session killed"));
|
|
386
|
+
this._rejectQueue(new Error("Session killed"));
|
|
387
|
+
|
|
388
|
+
try { this._proc.stdin.end(); } catch { /* already closed */ }
|
|
389
|
+
try { this._proc.kill(); } catch { /* already dead */ }
|
|
390
|
+
this._proc = null;
|
|
391
|
+
|
|
392
|
+
this._emit({ kind: "session_end", timestamp: Date.now() });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// artifact() — full session record for Oracle
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
artifact(): SessionArtifact {
|
|
400
|
+
return {
|
|
401
|
+
externalSessionId: this._externalSessionId,
|
|
402
|
+
events: [...this._eventLog],
|
|
403
|
+
startedAt: this._startedAt,
|
|
404
|
+
endedAt: this._alive ? undefined : Date.now(),
|
|
405
|
+
turns: this._turns,
|
|
406
|
+
totalTokens: { ...this._totalTokens },
|
|
407
|
+
toolCalls: this._eventLog.filter((e) => e.kind === "tool_use").length,
|
|
408
|
+
toolResults: this._eventLog.filter((e) => e.kind === "tool_result").length,
|
|
409
|
+
errors: this._eventLog.filter((e) => e.kind === "error").length,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Private — turn dispatch + queue
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
private _dispatchTurn(turn: QueuedTurn): void {
|
|
418
|
+
this._inflight = turn;
|
|
419
|
+
this._turnEvents = [];
|
|
420
|
+
this._resultText = "";
|
|
421
|
+
|
|
422
|
+
// Wire format validated empirically against claude 2.1.114:
|
|
423
|
+
// {type:"user", message:{role,content:[{type:"text",text}]}}
|
|
424
|
+
// Alternative shapes ({type:"user_message"}, {role,content}) are silently dropped.
|
|
425
|
+
const payload = JSON.stringify({
|
|
426
|
+
type: "user",
|
|
427
|
+
message: {
|
|
428
|
+
role: "user",
|
|
429
|
+
content: [{ type: "text", text: turn.message }],
|
|
430
|
+
},
|
|
431
|
+
}) + "\n";
|
|
432
|
+
this._proc!.stdin.write(payload);
|
|
433
|
+
this._proc!.stdin.flush();
|
|
434
|
+
|
|
435
|
+
// Timeout guard
|
|
436
|
+
if (turn.timeout > 0) {
|
|
437
|
+
this._turnTimer = setTimeout(() => {
|
|
438
|
+
this._turnTimer = null;
|
|
439
|
+
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
|
|
440
|
+
// Don't dispatch next — process may still be working on this turn.
|
|
441
|
+
// Next queued turn dispatches when the orphaned result arrives.
|
|
442
|
+
}, turn.timeout);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private _resolveTurn(): void {
|
|
447
|
+
if (!this._inflight) return;
|
|
448
|
+
|
|
449
|
+
if (this._turnTimer) {
|
|
450
|
+
clearTimeout(this._turnTimer);
|
|
451
|
+
this._turnTimer = null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
this._turns++;
|
|
455
|
+
const result: SessionResult = {
|
|
456
|
+
content: this._resultText,
|
|
457
|
+
events: [...this._turnEvents],
|
|
458
|
+
tokens: this._turnEvents.find((e) => e.tokens)?.tokens,
|
|
459
|
+
externalSessionId: this._externalSessionId,
|
|
460
|
+
};
|
|
461
|
+
this._inflight.resolve(result);
|
|
462
|
+
this._inflight = null;
|
|
463
|
+
|
|
464
|
+
this._processNextTurn();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private _processNextTurn(): void {
|
|
468
|
+
if (this._queue.length > 0 && this._alive) {
|
|
469
|
+
const next = this._queue.shift()!;
|
|
470
|
+
this._dispatchTurn(next);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private _rejectInflight(err: Error): void {
|
|
475
|
+
if (!this._inflight) return;
|
|
476
|
+
|
|
477
|
+
if (this._turnTimer) {
|
|
478
|
+
clearTimeout(this._turnTimer);
|
|
479
|
+
this._turnTimer = null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this._inflight.reject(err);
|
|
483
|
+
this._inflight = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private _rejectQueue(err: Error): void {
|
|
487
|
+
for (const turn of this._queue) {
|
|
488
|
+
turn.reject(err);
|
|
489
|
+
}
|
|
490
|
+
this._queue = [];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Private — stdout reader (background, runs for session lifetime)
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
private async _readStdout(): Promise<void> {
|
|
498
|
+
const reader = this._proc!.stdout.getReader();
|
|
499
|
+
const decoder = new TextDecoder();
|
|
500
|
+
let buffer = "";
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
while (true) {
|
|
504
|
+
const { done, value } = await reader.read();
|
|
505
|
+
if (done) break;
|
|
506
|
+
|
|
507
|
+
buffer += decoder.decode(value, { stream: true });
|
|
508
|
+
const lines = buffer.split("\n");
|
|
509
|
+
buffer = lines.pop()!; // Keep incomplete line in buffer
|
|
510
|
+
|
|
511
|
+
for (const line of lines) {
|
|
512
|
+
if (!line.trim()) continue;
|
|
513
|
+
this._processLine(line);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Flush remaining buffer
|
|
518
|
+
if (buffer.trim()) {
|
|
519
|
+
this._processLine(buffer);
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
this._rejectInflight(err as Error);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Private — stderr reader (accumulates for error context)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
private async _readStderr(): Promise<void> {
|
|
531
|
+
const reader = this._proc!.stderr.getReader();
|
|
532
|
+
const decoder = new TextDecoder();
|
|
533
|
+
try {
|
|
534
|
+
while (true) {
|
|
535
|
+
const { done, value } = await reader.read();
|
|
536
|
+
if (done) break;
|
|
537
|
+
this._stderr += decoder.decode(value, { stream: true });
|
|
538
|
+
}
|
|
539
|
+
} catch { /* ignore */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Private — JSON line processor
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
private _processLine(line: string): void {
|
|
547
|
+
let msg: unknown;
|
|
548
|
+
try {
|
|
549
|
+
msg = JSON.parse(line);
|
|
550
|
+
} catch {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const raw = msg as Record<string, unknown>;
|
|
555
|
+
|
|
556
|
+
// Capture the runtime's native session ID the first time we see it.
|
|
557
|
+
// Subsequent messages echo the same ID; we keep our initial capture
|
|
558
|
+
// (for resumed sessions, the config-supplied ID should match).
|
|
559
|
+
if (typeof raw.session_id === "string" && !this._externalSessionId) {
|
|
560
|
+
this._externalSessionId = raw.session_id;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Compaction detection: a system/init message arriving after we've
|
|
564
|
+
// already seen one means the Claude Code session was restarted mid-run.
|
|
565
|
+
// Auto-compact works by killing and resuming the session, which fires
|
|
566
|
+
// a new init event. This tells the FlowOrchestrator that the session's
|
|
567
|
+
// in-memory state was summarized — the Librarian ledger is stale.
|
|
568
|
+
if (raw.type === "system" && (raw.subtype === "init" || raw.subtype === "compact_boundary")) {
|
|
569
|
+
if (this._sawInit && raw.subtype === "init") {
|
|
570
|
+
this._emit({
|
|
571
|
+
kind: "session_compact",
|
|
572
|
+
timestamp: Date.now(),
|
|
573
|
+
externalSessionId: this._externalSessionId,
|
|
574
|
+
compactionSource: "claude-code",
|
|
575
|
+
raw,
|
|
576
|
+
});
|
|
577
|
+
} else if (raw.subtype === "compact_boundary") {
|
|
578
|
+
this._emit({
|
|
579
|
+
kind: "session_compact",
|
|
580
|
+
timestamp: Date.now(),
|
|
581
|
+
externalSessionId: this._externalSessionId,
|
|
582
|
+
compactionSource: "claude-code",
|
|
583
|
+
raw,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
this._sawInit = true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const classified = this._classify(raw);
|
|
590
|
+
for (const event of classified) {
|
|
591
|
+
this._emit(event);
|
|
592
|
+
this._turnEvents.push(event);
|
|
593
|
+
|
|
594
|
+
if (event.kind === "result") {
|
|
595
|
+
this._resultText = event.text ?? "";
|
|
596
|
+
}
|
|
597
|
+
if (event.tokens) {
|
|
598
|
+
this._totalTokens.input += event.tokens.input;
|
|
599
|
+
this._totalTokens.output += event.tokens.output;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Result event resolves the pending turn (or dispatches next if orphaned)
|
|
604
|
+
if (raw.type === "result") {
|
|
605
|
+
if (this._inflight) {
|
|
606
|
+
this._resolveTurn();
|
|
607
|
+
} else {
|
|
608
|
+
// Orphaned result from interrupted/timed-out turn — dispatch next
|
|
609
|
+
this._processNextTurn();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Private — spawn args (called once at start())
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
private _buildSpawnArgs(): string[] {
|
|
619
|
+
// --print + --input-format stream-json = multi-turn stream over stdin
|
|
620
|
+
// --output-format stream-json requires --verbose
|
|
621
|
+
const args: string[] = [
|
|
622
|
+
"--print",
|
|
623
|
+
"--verbose",
|
|
624
|
+
"--input-format", "stream-json",
|
|
625
|
+
"--output-format", "stream-json",
|
|
626
|
+
"--model", this._model,
|
|
627
|
+
...(this._effort ? ["--effort", this._effort] : []),
|
|
628
|
+
"--max-turns", String(this._maxTurns),
|
|
629
|
+
"--permission-mode", this._permissionMode,
|
|
630
|
+
"--include-hook-events",
|
|
631
|
+
];
|
|
632
|
+
|
|
633
|
+
// Resume for fork or crash recovery. _externalSessionId is set from
|
|
634
|
+
// config (crash recovery / fork) or from the stream. Either way, if
|
|
635
|
+
// it's present at spawn time, we --resume.
|
|
636
|
+
if (this._externalSessionId) {
|
|
637
|
+
args.push("--resume", this._externalSessionId);
|
|
638
|
+
if (this._forking) {
|
|
639
|
+
args.push("--fork-session");
|
|
640
|
+
this._forking = false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Stable base context injected once at process startup
|
|
645
|
+
if (this._baseContext) {
|
|
646
|
+
args.push("--append-system-prompt", this._baseContext);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return args;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Private — event classification
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
private _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
657
|
+
const events: SessionEvent[] = [];
|
|
658
|
+
const ts = Date.now();
|
|
659
|
+
|
|
660
|
+
const type = msg.type as string | undefined;
|
|
661
|
+
|
|
662
|
+
if (type === "assistant") {
|
|
663
|
+
const message = msg.message as Record<string, unknown> | undefined;
|
|
664
|
+
const content = message?.content;
|
|
665
|
+
if (!Array.isArray(content)) return events;
|
|
666
|
+
|
|
667
|
+
for (const block of content) {
|
|
668
|
+
const blockType = (block as Record<string, unknown>).type as string;
|
|
669
|
+
|
|
670
|
+
if (blockType === "text") {
|
|
671
|
+
const text = (block as Record<string, unknown>).text as
|
|
672
|
+
| string
|
|
673
|
+
| undefined;
|
|
674
|
+
if (text) {
|
|
675
|
+
events.push({ kind: "text", timestamp: ts, text, raw: block });
|
|
676
|
+
}
|
|
677
|
+
} else if (blockType === "tool_use") {
|
|
678
|
+
events.push({
|
|
679
|
+
kind: "tool_use",
|
|
680
|
+
timestamp: ts,
|
|
681
|
+
toolName: (block as Record<string, unknown>).name as string,
|
|
682
|
+
toolInput: (block as Record<string, unknown>).input as Record<
|
|
683
|
+
string,
|
|
684
|
+
unknown
|
|
685
|
+
>,
|
|
686
|
+
raw: block,
|
|
687
|
+
});
|
|
688
|
+
} else if (blockType === "thinking") {
|
|
689
|
+
const b = block as Record<string, unknown>;
|
|
690
|
+
events.push({
|
|
691
|
+
kind: "thinking",
|
|
692
|
+
timestamp: ts,
|
|
693
|
+
text: (b.thinking ?? b.text ?? b.content) as string | undefined,
|
|
694
|
+
raw: block,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
} else if (type === "tool") {
|
|
699
|
+
// Tool result — what the tool returned
|
|
700
|
+
const content = msg.content;
|
|
701
|
+
if (Array.isArray(content)) {
|
|
702
|
+
for (const block of content) {
|
|
703
|
+
const b = block as Record<string, unknown>;
|
|
704
|
+
events.push({
|
|
705
|
+
kind: "tool_result",
|
|
706
|
+
timestamp: ts,
|
|
707
|
+
toolOutput:
|
|
708
|
+
typeof b.text === "string"
|
|
709
|
+
? b.text
|
|
710
|
+
: typeof b.content === "string"
|
|
711
|
+
? b.content
|
|
712
|
+
: JSON.stringify(block),
|
|
713
|
+
toolError: b.is_error === true,
|
|
714
|
+
raw: block,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
} else if (content != null) {
|
|
718
|
+
events.push({
|
|
719
|
+
kind: "tool_result",
|
|
720
|
+
timestamp: ts,
|
|
721
|
+
toolOutput:
|
|
722
|
+
typeof content === "string" ? content : JSON.stringify(content),
|
|
723
|
+
raw: content,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
} else if (type === "result") {
|
|
727
|
+
const usage = msg.usage as
|
|
728
|
+
| Record<string, number>
|
|
729
|
+
| undefined;
|
|
730
|
+
events.push({
|
|
731
|
+
kind: "result",
|
|
732
|
+
timestamp: ts,
|
|
733
|
+
text: (msg.result as string) ?? "",
|
|
734
|
+
externalSessionId: msg.session_id as string | undefined,
|
|
735
|
+
tokens: usage
|
|
736
|
+
? {
|
|
737
|
+
input: usage.input_tokens ?? 0,
|
|
738
|
+
output: usage.output_tokens ?? 0,
|
|
739
|
+
}
|
|
740
|
+
: undefined,
|
|
741
|
+
raw: msg,
|
|
742
|
+
});
|
|
743
|
+
} else if (type === "error") {
|
|
744
|
+
const error = msg.error as Record<string, unknown> | undefined;
|
|
745
|
+
events.push({
|
|
746
|
+
kind: "error",
|
|
747
|
+
timestamp: ts,
|
|
748
|
+
text:
|
|
749
|
+
(error?.message as string) ??
|
|
750
|
+
(msg.message as string) ??
|
|
751
|
+
JSON.stringify(msg),
|
|
752
|
+
raw: msg,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
// Hook events, system events, etc. are captured via the `raw` field
|
|
756
|
+
// on classified events. Unclassified event types are preserved in
|
|
757
|
+
// the raw stream for Oracle introspection.
|
|
758
|
+
|
|
759
|
+
return events;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// Private — emit
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
private _emit(event: SessionEvent): void {
|
|
767
|
+
this._eventLog.push(event);
|
|
768
|
+
for (const handler of this._handlers) {
|
|
769
|
+
try {
|
|
770
|
+
handler(event);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
console.warn(
|
|
773
|
+
"[ClaudeCodeSession] handler error:",
|
|
774
|
+
(err as Error).message,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|