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