@inixiative/agent-session 0.1.0 → 0.3.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.
@@ -1,3 +1,13 @@
1
+ import {
2
+ claudeRateLimitSnapshot,
3
+ claudeUsageSnapshot,
4
+ type LimitSnapshot,
5
+ mergeLimits,
6
+ } from './limits';
7
+ import { retainEvidence } from './retained-evidence';
8
+ import { TRANSPORTS } from './transport';
9
+ import { TurnState } from './turn-state';
10
+
1
11
  // ---------------------------------------------------------------------------
2
12
  // ClaudeCodeSession — long-lived Claude Code process with full event capture
3
13
  // ---------------------------------------------------------------------------
@@ -32,22 +42,26 @@
32
42
  // transport. Normal messages go through stdin.
33
43
  // ---------------------------------------------------------------------------
34
44
 
45
+ import { parseClaudeUsage } from './claude-usage';
35
46
  import type {
36
47
  BeforeSendHook,
37
48
  HarnessSession,
49
+ NativeInterruptOutcome,
50
+ SessionArtifact,
38
51
  SessionEvent,
39
52
  SessionEventHandler,
40
53
  SessionResult,
41
- SessionArtifact,
42
- } from "./harness-session";
54
+ SessionSendOptions,
55
+ SessionTokens,
56
+ } from './harness-session';
43
57
 
44
58
  // Re-export types so existing import paths keep working
45
59
  export type {
60
+ SessionArtifact,
46
61
  SessionEvent,
47
62
  SessionEventKind,
48
63
  SessionResult,
49
- SessionArtifact,
50
- } from "./harness-session";
64
+ } from './harness-session';
51
65
 
52
66
  // ---------------------------------------------------------------------------
53
67
  // Configuration
@@ -66,7 +80,7 @@ export interface ClaudeCodeSessionConfig {
66
80
  /** Working directory for the session. */
67
81
  cwd?: string;
68
82
  /** Max agentic turns per message. Defaults to 25. */
69
- maxTurns?: number;
83
+ maxTurns?: number | null;
70
84
  /** Permission mode. Defaults to "bypassPermissions". */
71
85
  permissionMode?: string;
72
86
  /** Default per-send timeout in ms. Defaults to 600000 (10 min). */
@@ -86,6 +100,19 @@ export interface ClaudeCodeSessionConfig {
86
100
  * Foundry thread that was previously mapped to this external ID.
87
101
  */
88
102
  externalSessionId?: string;
103
+ /**
104
+ * Environment merged over process.env (e.g. CLAUDE_CONFIG_DIR for a profile).
105
+ * ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are always removed: this
106
+ * transport runs on the CLI's subscription login.
107
+ */
108
+ env?: Record<string, string | undefined>;
109
+ /**
110
+ * Launch without callable tools, MCP servers, slash commands or user
111
+ * settings (`--safe-mode --tools "" --strict-mcp-config …`). For decisions.
112
+ */
113
+ textOnly?: boolean;
114
+ /** False adds `--no-session-persistence` (nothing written to history; cannot be resumed). Default true. */
115
+ persistSession?: boolean;
89
116
  /**
90
117
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a
91
118
  * fake subprocess that emulates the claude CLI's stream-json protocol.
@@ -104,6 +131,8 @@ export interface ClaudeCodeSessionConfig {
104
131
  // ---------------------------------------------------------------------------
105
132
 
106
133
  interface QueuedTurn {
134
+ onAdmission?: SessionSendOptions['onAdmission'];
135
+ evidence: TurnState;
107
136
  message: string;
108
137
  timeout: number;
109
138
  resolve: (result: SessionResult) => void;
@@ -123,24 +152,118 @@ export interface PipedSubprocess {
123
152
  kill(): void;
124
153
  }
125
154
 
155
+ // ---------------------------------------------------------------------------
156
+ // Public tool-result content contract (stream-json `user` tool_result blocks).
157
+ //
158
+ // A string content is the public output verbatim. An array keeps the text
159
+ // blocks (joined with one newline, as before) and, separately, the exact
160
+ // `tool_name` strings of `tool_reference` blocks (Anthropic tool search returns
161
+ // discovered tools as references, not text). Every other block, and any
162
+ // malformed text/reference block, is omitted explicitly: `toolOutputOmitted` is
163
+ // set and `toolOutputOmittedTypes` lists known public type labels or
164
+ // "unsupported". Nothing else from a block is copied, so private or unexpected
165
+ // fields never enter the public event. Reference-only output is NOT a success
166
+ // claim; the consuming guard judges ownership and ordering.
167
+ // ---------------------------------------------------------------------------
168
+ const KNOWN_NON_TEXT_RESULT_TYPES = new Set([
169
+ 'image',
170
+ 'audio',
171
+ 'document',
172
+ 'resource',
173
+ 'resource_link',
174
+ ]);
175
+
176
+ function publicToolResultContent(
177
+ content: unknown,
178
+ ): Pick<
179
+ SessionEvent,
180
+ 'toolOutput' | 'toolReferences' | 'toolOutputOmitted' | 'toolOutputOmittedTypes'
181
+ > {
182
+ if (typeof content === 'string') return { toolOutput: content };
183
+ if (content === undefined || content === null) return {};
184
+ const omittedTypes: string[] = [];
185
+ const omit = (type: unknown) => {
186
+ const label =
187
+ typeof type === 'string' && KNOWN_NON_TEXT_RESULT_TYPES.has(type) ? type : 'unsupported';
188
+ if (!omittedTypes.includes(label)) omittedTypes.push(label);
189
+ };
190
+ if (!Array.isArray(content)) {
191
+ omit(undefined);
192
+ return { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) };
193
+ }
194
+ const texts: string[] = [],
195
+ references: string[] = [];
196
+ for (const block of content) {
197
+ const b =
198
+ block !== null && typeof block === 'object' && !Array.isArray(block)
199
+ ? (block as Record<string, unknown>)
200
+ : undefined;
201
+ if (b?.type === 'text' && typeof b.text === 'string') texts.push(b.text);
202
+ else if (
203
+ b?.type === 'tool_reference' &&
204
+ typeof b.tool_name === 'string' &&
205
+ b.tool_name.length > 0
206
+ )
207
+ references.push(b.tool_name);
208
+ else omit(b?.type);
209
+ }
210
+ return {
211
+ toolOutput: texts.join('\n'),
212
+ ...(references.length ? { toolReferences: Object.freeze([...references]) } : {}),
213
+ ...(omittedTypes.length
214
+ ? { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) }
215
+ : {}),
216
+ };
217
+ }
218
+
219
+ /** Claude CLI flags for a tool-free session (the same launch Foundry uses for text-only auxiliaries). */
220
+ export const CLAUDE_TEXT_ONLY_ARGS: readonly string[] = [
221
+ '--safe-mode',
222
+ '--tools',
223
+ '',
224
+ '--strict-mcp-config',
225
+ '--mcp-config',
226
+ '{"mcpServers":{}}',
227
+ '--disable-slash-commands',
228
+ '--no-chrome',
229
+ ];
230
+
126
231
  export class ClaudeCodeSession implements HarnessSession {
232
+ get transport() {
233
+ return TRANSPORTS['claude-cli'];
234
+ }
127
235
  // -- Config --
128
236
  private _bin: string;
129
237
  private _model: string;
130
238
  private _effort?: string;
131
239
  private _cwd: string;
132
- private _maxTurns: number;
240
+ readonly admissionProtocol = 'prewrite-v1' as const;
241
+ readonly turnBudgetProtocol = 'optional-max-turns-v1' as const;
242
+ inspectAttempt(admissionId: string) {
243
+ return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
244
+ }
245
+ private _maxTurns: number | null;
133
246
  private _permissionMode: string;
134
247
  private _defaultTimeout: number;
135
248
  private _baseContext?: string;
136
249
  private _forking = false;
137
- private _spawn?: ClaudeCodeSessionConfig["spawn"];
250
+ private _awaitingForkIdentity = false;
251
+ private _spawn?: ClaudeCodeSessionConfig['spawn'];
252
+ private _env?: ClaudeCodeSessionConfig['env'];
253
+ private _textOnly: boolean;
254
+ private _persistSession: boolean;
255
+ private _limits?: LimitSnapshot;
256
+ private _controlSeq = 0;
257
+ private _control = new Map<
258
+ string,
259
+ { resolve(response: Record<string, unknown>): void; reject(error: Error): void }
260
+ >();
138
261
 
139
262
  // -- Process --
140
263
  // Bun.spawn's return type is a union; we always use stdin:"pipe"/stdout:"pipe"/stderr:"pipe"
141
264
  // so we know the concrete types at runtime.
142
265
  private _proc: PipedSubprocess | null = null;
143
- private _stderr = "";
266
+ private _stderr = '';
144
267
 
145
268
  // -- Session state --
146
269
  /**
@@ -151,55 +274,92 @@ export class ClaudeCodeSession implements HarnessSession {
151
274
  */
152
275
  private _externalSessionId?: string;
153
276
  private _alive = false;
277
+ private _endEmitted = false;
154
278
  private _eventLog: SessionEvent[] = [];
155
279
  private _handlers: SessionEventHandler[] = [];
280
+ private _observerFailures = { synchronous: 0, asynchronous: 0 };
156
281
  private _beforeSendHooks: BeforeSendHook[] = [];
157
282
  private _turns = 0;
158
- private _totalTokens = { input: 0, output: 0 };
283
+ private _totalTokens: SessionTokens = { input: 0, output: 0 };
159
284
  private _startedAt: number;
160
285
  /**
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.
286
+ * Native uuids of explicit compaction boundaries already emitted. Claude emits a
287
+ * `system/init` per admission on one binding, so a repeated init is configuration
288
+ * evidence, never proof of compaction or restart. Only the explicit
289
+ * `system/compact_boundary` envelope is a compaction, and a repeat is a duplicate
290
+ * only when it carries the same native uuid; text-equal boundaries without one
291
+ * are distinct compactions.
166
292
  */
167
- private _sawInit = false;
293
+ private _seenBoundaries = new Set<string>();
168
294
 
169
295
  // -- Turn queue --
170
296
  private _queue: QueuedTurn[] = [];
297
+ private _attempts: TurnState[] = [];
298
+ private _seenTerminals = new Set<string>();
171
299
  private _inflight: QueuedTurn | null = null;
172
- private _turnEvents: SessionEvent[] = [];
173
- private _resultText = "";
174
300
  private _turnTimer: ReturnType<typeof setTimeout> | null = null;
175
301
 
176
302
  constructor(config?: ClaudeCodeSessionConfig) {
177
- const bin = config?.bin ?? "claude";
178
- if (!/^[a-zA-Z0-9_.\/\\-]+$/.test(bin)) {
303
+ const bin = config?.bin ?? 'claude';
304
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
179
305
  throw new Error(`Invalid claude CLI binary path: "${bin}"`);
180
306
  }
181
307
  this._bin = bin;
182
- this._model = config?.model ?? "sonnet";
308
+ this._model = config?.model ?? 'sonnet';
183
309
  this._effort = config?.effort;
184
310
  this._cwd = config?.cwd ?? process.cwd();
185
- this._maxTurns = config?.maxTurns ?? 25;
186
- this._permissionMode = config?.permissionMode ?? "bypassPermissions";
311
+ // Undefined retains standalone legacy behavior; null explicitly omits the CLI cap.
312
+ if (
313
+ config?.maxTurns !== undefined &&
314
+ config.maxTurns !== null &&
315
+ (!Number.isSafeInteger(config.maxTurns) || config.maxTurns < 1)
316
+ ) {
317
+ throw new Error('maxTurns must be a positive safe integer or null (unbounded)');
318
+ }
319
+ this._maxTurns = config?.maxTurns === undefined ? 25 : config.maxTurns;
320
+ this._permissionMode = config?.permissionMode ?? 'bypassPermissions';
187
321
  this._defaultTimeout = config?.timeout ?? 600_000;
188
322
  this._baseContext = config?.baseContext;
189
323
  this._externalSessionId = config?.externalSessionId;
190
324
  this._spawn = config?.spawn;
325
+ this._env = config?.env;
326
+ this._textOnly = config?.textOnly ?? false;
327
+ this._persistSession = config?.persistSession ?? true;
191
328
  this._startedAt = Date.now();
192
329
  }
193
330
 
331
+ /** Latest account limits observed on this session (stream or poll). */
332
+ get limits(): LimitSnapshot | undefined {
333
+ return this._limits;
334
+ }
335
+
194
336
  // ---------------------------------------------------------------------------
195
337
  // Accessors
196
338
  // ---------------------------------------------------------------------------
197
339
 
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 }> {
340
+ get accounting() {
341
+ return 'observed-only' as const;
342
+ }
343
+ get attempts() {
344
+ return this._attempts.map((a) => a.snapshot());
345
+ }
346
+ get diagnostics() {
347
+ return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) });
348
+ }
349
+
350
+ get alive(): boolean {
351
+ return this._alive;
352
+ }
353
+ get externalSessionId(): string | undefined {
354
+ return this._externalSessionId;
355
+ }
356
+ get events(): readonly SessionEvent[] {
357
+ return Object.freeze([...this._eventLog]);
358
+ }
359
+ get turns(): number {
360
+ return this._turns;
361
+ }
362
+ get totalTokens(): Readonly<SessionTokens> {
203
363
  return { ...this._totalTokens };
204
364
  }
205
365
 
@@ -234,7 +394,7 @@ export class ClaudeCodeSession implements HarnessSession {
234
394
  */
235
395
  async push(payload: { kind: string; text: string }): Promise<void> {
236
396
  this._emit({
237
- kind: "error",
397
+ kind: 'error',
238
398
  timestamp: Date.now(),
239
399
  text: `push_ignored: kind=${payload.kind} — stream-json stdin has no OOB channel`,
240
400
  raw: payload,
@@ -246,67 +406,89 @@ export class ClaudeCodeSession implements HarnessSession {
246
406
  // ---------------------------------------------------------------------------
247
407
 
248
408
  async start(): Promise<void> {
249
- if (this._proc) throw new Error("Session already started");
409
+ if (this._attempts.some((a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown'))
410
+ throw new Error('Native outcome unresolved; automatic resume is blocked');
411
+ if (this._proc) throw new Error('Session already started');
250
412
 
251
413
  const args = this._buildSpawnArgs();
252
414
 
253
415
  // Strip API key env vars — CLI uses subscription auth
254
416
  const env: Record<string, string | undefined> = {
255
417
  ...process.env,
256
- DISABLE_AUTOUPDATER: "1",
418
+ ...this._env,
419
+ DISABLE_AUTOUPDATER: '1',
257
420
  };
258
421
  delete env.ANTHROPIC_API_KEY;
259
422
  delete env.ANTHROPIC_AUTH_TOKEN;
423
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
260
424
 
261
425
  if (this._spawn) {
262
426
  this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
263
427
  } else {
264
428
  this._proc = Bun.spawn([this._bin, ...args], {
265
429
  cwd: this._cwd,
266
- stdin: "pipe",
267
- stdout: "pipe",
268
- stderr: "pipe",
430
+ stdin: 'pipe',
431
+ stdout: 'pipe',
432
+ stderr: 'pipe',
269
433
  env,
270
434
  }) as unknown as PipedSubprocess;
271
435
  }
272
436
 
273
437
  this._alive = true;
274
- this._emit({ kind: "session_start", timestamp: Date.now() });
438
+ this._endEmitted = false;
439
+ this._emit({ kind: 'session_start', timestamp: Date.now() });
275
440
 
276
441
  // Background readers — run for session lifetime (don't await)
277
- this._readStdout();
442
+ const proc = this._proc;
443
+ const stdoutDone = this._readStdout(proc);
278
444
  this._readStderr();
279
445
 
280
446
  // Monitor process exit for cleanup
281
- this._proc.exited.then((code) => {
282
- if (!this._alive) return;
447
+ proc.exited.then(async (code) => {
448
+ if (this._proc !== proc || !this._alive) return;
283
449
  this._alive = false;
450
+ await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
284
451
  const errMsg = this._stderr.trim()
285
452
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
286
453
  : `Process exited with code ${code}`;
287
454
  this._rejectInflight(new Error(errMsg));
288
- this._rejectQueue(new Error("Session ended"));
289
- this._emit({ kind: "session_end", timestamp: Date.now() });
455
+ this._rejectQueue(new Error('Session ended'));
456
+ this._rejectControl(new Error('Session ended'));
457
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
290
458
  });
291
459
  }
292
460
 
461
+ private _rejectControl(error: Error): void {
462
+ for (const pending of this._control.values()) pending.reject(error);
463
+ this._control.clear();
464
+ }
465
+
293
466
  // ---------------------------------------------------------------------------
294
467
  // send() — write message to stdin, resolve on result event
295
468
  // ---------------------------------------------------------------------------
296
469
 
297
- async send(
298
- message: string,
299
- opts?: { timeout?: number },
300
- ): Promise<SessionResult> {
470
+ async send(message: string, opts?: SessionSendOptions): Promise<SessionResult> {
301
471
  // Auto-restart after interrupt / process death (crash recovery via --resume).
302
472
  // _externalSessionId persists across process lifetimes, so start() will
303
473
  // include --resume <id> in spawn args.
474
+ if (
475
+ this._attempts.some(
476
+ (a) =>
477
+ a.dispatch === 'attempted' &&
478
+ a.nativeOutcome === 'unknown' &&
479
+ (!this._alive || a.localOutcome !== 'pending'),
480
+ )
481
+ ) {
482
+ const blocked = new TurnState();
483
+ this._attempts.push(blocked);
484
+ throw blocked.fail(new Error('Native outcome unresolved; send not dispatched'), 'blocked');
485
+ }
304
486
  if (!this._proc && this._externalSessionId) {
305
487
  await this.start();
306
488
  }
307
489
 
308
- if (!this._proc) throw new Error("Session not started — call start() first");
309
- if (!this._alive) throw new Error("Session ended");
490
+ if (!this._proc) throw new Error('Session not started — call start() first');
491
+ if (!this._alive) throw new Error('Session ended');
310
492
 
311
493
  const timeout = opts?.timeout ?? this._defaultTimeout;
312
494
 
@@ -319,7 +501,25 @@ export class ClaudeCodeSession implements HarnessSession {
319
501
  }
320
502
 
321
503
  return new Promise<SessionResult>((resolve, reject) => {
322
- const turn: QueuedTurn = { message: transformed, timeout, resolve, reject };
504
+ const evidence = new TurnState();
505
+ this._attempts.push(evidence);
506
+ const turn: QueuedTurn = {
507
+ evidence,
508
+ message: transformed,
509
+ timeout,
510
+ resolve,
511
+ reject,
512
+ onAdmission: opts?.onAdmission,
513
+ };
514
+ if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== 'pending')) {
515
+ reject(
516
+ evidence.fail(
517
+ new Error('Previous native work unresolved; send not dispatched'),
518
+ 'blocked',
519
+ ),
520
+ );
521
+ return;
522
+ }
323
523
 
324
524
  if (!this._inflight) {
325
525
  this._dispatchTurn(turn);
@@ -333,14 +533,18 @@ export class ClaudeCodeSession implements HarnessSession {
333
533
  // fork() — branch from current conversation state
334
534
  // ---------------------------------------------------------------------------
335
535
 
336
- fork(opts?: { cwd?: string; baseContext?: string }): ClaudeCodeSession {
536
+ fork(opts?: {
537
+ cwd?: string;
538
+ baseContext?: string;
539
+ persistSession?: boolean;
540
+ spawn?: ClaudeCodeSessionConfig['spawn'];
541
+ }): ClaudeCodeSession {
542
+ if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
337
543
  if (!this._externalSessionId) {
338
- throw new Error(
339
- "Cannot fork — no external session ID yet (send at least one message first)",
340
- );
544
+ throw new Error('Cannot fork — no external session ID yet (send at least one message first)');
341
545
  }
342
546
 
343
- const forked = new ClaudeCodeSession({
547
+ const forked = this._construct({
344
548
  bin: this._bin,
345
549
  model: this._model,
346
550
  effort: this._effort,
@@ -350,23 +554,109 @@ export class ClaudeCodeSession implements HarnessSession {
350
554
  timeout: this._defaultTimeout,
351
555
  baseContext: opts?.baseContext ?? this._baseContext,
352
556
  externalSessionId: this._externalSessionId,
353
- spawn: this._spawn,
557
+ spawn: opts?.spawn ?? this._spawn,
558
+ env: this._env,
559
+ textOnly: this._textOnly,
560
+ persistSession: opts?.persistSession ?? this._persistSession,
354
561
  });
355
562
  forked._forking = true;
356
563
  return forked;
357
564
  }
358
565
 
566
+ /** Construct a sibling session (fork). Subclasses keep their own transport. */
567
+ protected _construct(config: ClaudeCodeSessionConfig): ClaudeCodeSession {
568
+ return new ClaudeCodeSession(config);
569
+ }
570
+
359
571
  // ---------------------------------------------------------------------------
360
- // interrupt() — cancel the in-flight turn (best-effort)
572
+ // interrupt() — reject the local waiter; native cancellation is unacknowledged
361
573
  // ---------------------------------------------------------------------------
362
574
 
363
575
  interrupt(): void {
364
576
  if (!this._inflight) return;
365
577
 
366
- this._rejectInflight(new Error("Turn interrupted"));
578
+ this._rejectInflight(
579
+ new Error('Local waiter interrupted; native cancellation unacknowledged'),
580
+ 'interrupt-request',
581
+ );
582
+
583
+ // Native ownership is retained. Waiting sends are rejected without dispatch.
584
+ }
585
+
586
+ /**
587
+ * Native interrupt through the CLI control protocol. Resolves "acknowledged"
588
+ * once the CLI confirms and the turn's own terminal arrives (the send()
589
+ * settles through that terminal, normally as a failed/interrupted outcome).
590
+ */
591
+ async interruptNative(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome> {
592
+ const turn = this._inflight;
593
+ if (turn?.evidence.dispatch !== 'attempted' || turn.evidence.nativeOutcome !== 'unknown')
594
+ return 'no-turn';
595
+ const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
596
+ try {
597
+ await this._controlRequest({ subtype: 'interrupt' }, Math.max(1, deadline - Date.now()));
598
+ } catch {
599
+ return 'unacknowledged';
600
+ }
601
+ while (turn.evidence.nativeOutcome === 'unknown' && this._alive && Date.now() < deadline)
602
+ await Bun.sleep(10);
603
+ return turn.evidence.nativeOutcome === 'unknown' ? 'unacknowledged' : 'acknowledged';
604
+ }
367
605
 
368
- // Process continues running. When it emits the orphaned result,
369
- // _processLine dispatches the next queued turn (if any).
606
+ /** Account limits via the CLI control protocol (`get_usage`); no model turn. Requires a started session. */
607
+ async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
608
+ const response = await this._controlRequest(
609
+ { subtype: 'get_usage' },
610
+ opts?.timeoutMs ?? 10_000,
611
+ );
612
+ const snapshot = claudeUsageSnapshot(response.response);
613
+ if (snapshot) this._observeLimits(snapshot);
614
+ return snapshot;
615
+ }
616
+
617
+ private _controlRequest(
618
+ request: Record<string, unknown>,
619
+ timeoutMs: number,
620
+ ): Promise<Record<string, unknown>> {
621
+ if (!this._proc || !this._alive) return Promise.reject(new Error('Session not running'));
622
+ const requestId = `agent-session-${++this._controlSeq}-${crypto.randomUUID()}`;
623
+ return new Promise((resolve, reject) => {
624
+ const timer = setTimeout(() => {
625
+ this._control.delete(requestId);
626
+ reject(new Error(`Control request ${String(request.subtype)} timed out`));
627
+ }, timeoutMs);
628
+ this._control.set(requestId, {
629
+ resolve: (value) => {
630
+ clearTimeout(timer);
631
+ resolve(value);
632
+ },
633
+ reject: (error) => {
634
+ clearTimeout(timer);
635
+ reject(error);
636
+ },
637
+ });
638
+ try {
639
+ this._proc!.stdin.write(
640
+ `${JSON.stringify({ type: 'control_request', request_id: requestId, request })}\n`,
641
+ );
642
+ this._proc!.stdin.flush();
643
+ } catch (error) {
644
+ this._control.delete(requestId);
645
+ clearTimeout(timer);
646
+ reject(error as Error);
647
+ }
648
+ });
649
+ }
650
+
651
+ private _observeLimits(snapshot: LimitSnapshot): void {
652
+ this._limits = mergeLimits(this._limits, snapshot);
653
+ this._emit({
654
+ kind: 'rate_limit',
655
+ timestamp: Date.now(),
656
+ correlation: 'unknown',
657
+ unattributedReason: 'account-status',
658
+ limits: this._limits,
659
+ });
370
660
  }
371
661
 
372
662
  // ---------------------------------------------------------------------------
@@ -382,14 +672,23 @@ export class ClaudeCodeSession implements HarnessSession {
382
672
  this._turnTimer = null;
383
673
  }
384
674
 
385
- this._rejectInflight(new Error("Session killed"));
386
- this._rejectQueue(new Error("Session killed"));
675
+ this._rejectInflight(new Error('Session killed; native outcome may remain unknown'), 'killed');
676
+ this._rejectQueue(new Error('Session killed'));
677
+ this._rejectControl(new Error('Session killed'));
387
678
 
388
- try { this._proc.stdin.end(); } catch { /* already closed */ }
389
- try { this._proc.kill(); } catch { /* already dead */ }
679
+ try {
680
+ this._proc.stdin.end();
681
+ } catch {
682
+ /* already closed */
683
+ }
684
+ try {
685
+ this._proc.kill();
686
+ } catch {
687
+ /* already dead */
688
+ }
390
689
  this._proc = null;
391
690
 
392
- this._emit({ kind: "session_end", timestamp: Date.now() });
691
+ this._emit({ kind: 'session_end', timestamp: Date.now() });
393
692
  }
394
693
 
395
694
  // ---------------------------------------------------------------------------
@@ -399,14 +698,17 @@ export class ClaudeCodeSession implements HarnessSession {
399
698
  artifact(): SessionArtifact {
400
699
  return {
401
700
  externalSessionId: this._externalSessionId,
701
+ attempts: this.attempts,
702
+ accounting: 'observed-only',
703
+ diagnostics: this.diagnostics,
402
704
  events: [...this._eventLog],
403
705
  startedAt: this._startedAt,
404
706
  endedAt: this._alive ? undefined : Date.now(),
405
707
  turns: this._turns,
406
708
  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,
709
+ toolCalls: this._eventLog.filter((e) => e.kind === 'tool_use').length,
710
+ toolResults: this._eventLog.filter((e) => e.kind === 'tool_result').length,
711
+ errors: this._eventLog.filter((e) => e.kind === 'error').length,
410
712
  };
411
713
  }
412
714
 
@@ -416,29 +718,62 @@ export class ClaudeCodeSession implements HarnessSession {
416
718
 
417
719
  private _dispatchTurn(turn: QueuedTurn): void {
418
720
  this._inflight = turn;
419
- this._turnEvents = [];
420
- this._resultText = "";
721
+ if (turn.onAdmission) {
722
+ Promise.resolve()
723
+ .then(() => turn.onAdmission!(turn.evidence.snapshot()))
724
+ .then(() => {
725
+ if (this._inflight === turn && this._alive && turn.evidence.localOutcome === 'pending')
726
+ this._writeTurn(turn);
727
+ else if (this._inflight === turn && turn.evidence.dispatch === 'not-dispatched') {
728
+ this._inflight = null;
729
+ this._processNextTurn();
730
+ }
731
+ })
732
+ .catch((error) => {
733
+ turn.reject(
734
+ turn.evidence.fail(
735
+ error instanceof Error ? error : new Error(String(error)),
736
+ 'registration',
737
+ ),
738
+ );
739
+ if (this._inflight === turn) {
740
+ this._inflight = null;
741
+ this._processNextTurn();
742
+ }
743
+ });
744
+ return;
745
+ }
746
+ this._writeTurn(turn);
747
+ }
748
+
749
+ private _writeTurn(turn: QueuedTurn): void {
750
+ turn.evidence.dispatch = 'attempted';
421
751
 
422
752
  // Wire format validated empirically against claude 2.1.114:
423
753
  // {type:"user", message:{role,content:[{type:"text",text}]}}
424
754
  // Alternative shapes ({type:"user_message"}, {role,content}) are silently dropped.
425
- const payload = JSON.stringify({
426
- type: "user",
755
+ const payload = `${JSON.stringify({
756
+ type: 'user',
427
757
  message: {
428
- role: "user",
429
- content: [{ type: "text", text: turn.message }],
758
+ role: 'user',
759
+ content: [{ type: 'text', text: turn.message }],
430
760
  },
431
- }) + "\n";
432
- this._proc!.stdin.write(payload);
433
- this._proc!.stdin.flush();
761
+ })}\n`;
762
+ try {
763
+ this._proc!.stdin.write(payload);
764
+ this._proc!.stdin.flush();
765
+ } catch (err) {
766
+ this._rejectInflight(err as Error);
767
+ return;
768
+ }
434
769
 
435
770
  // Timeout guard
436
771
  if (turn.timeout > 0) {
437
772
  this._turnTimer = setTimeout(() => {
438
773
  this._turnTimer = null;
439
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
774
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
440
775
  // Don't dispatch next — process may still be working on this turn.
441
- // Next queued turn dispatches when the orphaned result arrives.
776
+ // A late terminal updates this admission; waiting sends are not replayed.
442
777
  }, turn.timeout);
443
778
  }
444
779
  }
@@ -451,41 +786,56 @@ export class ClaudeCodeSession implements HarnessSession {
451
786
  this._turnTimer = null;
452
787
  }
453
788
 
789
+ const a = this._inflight.evidence;
790
+ if (a.nativeOutcome === 'unknown') return;
454
791
  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);
792
+ if (a.localOutcome === 'pending') {
793
+ a.localOutcome = 'resolved';
794
+ this._inflight.resolve(a.result(this._externalSessionId));
795
+ }
462
796
  this._inflight = null;
463
797
 
464
798
  this._processNextTurn();
465
799
  }
466
800
 
467
801
  private _processNextTurn(): void {
468
- if (this._queue.length > 0 && this._alive) {
802
+ if (!this._inflight && this._queue.length > 0 && this._alive) {
469
803
  const next = this._queue.shift()!;
470
804
  this._dispatchTurn(next);
471
805
  }
472
806
  }
473
807
 
474
- private _rejectInflight(err: Error): void {
808
+ private _rejectInflight(
809
+ err: Error,
810
+ reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
811
+ ): void {
475
812
  if (!this._inflight) return;
476
-
477
813
  if (this._turnTimer) {
478
814
  clearTimeout(this._turnTimer);
479
815
  this._turnTimer = null;
480
816
  }
817
+ const a = this._inflight.evidence;
818
+ if ((reason === 'transport' || reason === 'killed') && a.transportOutcome === 'open')
819
+ a.transportOutcome = reason === 'killed' ? 'closed' : 'failed';
820
+ if (a.localOutcome === 'pending') this._inflight.reject(a.fail(err, reason));
821
+ this._rejectQueue(
822
+ reason === 'killed'
823
+ ? err
824
+ : new Error('Previous native work unresolved; queued send not dispatched'),
825
+ );
826
+ }
481
827
 
482
- this._inflight.reject(err);
483
- this._inflight = null;
828
+ private _transportFailure(err: Error): void {
829
+ this._alive = false;
830
+ this._rejectInflight(err);
831
+ this._rejectQueue(err);
832
+ this._rejectControl(err);
833
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
484
834
  }
485
835
 
486
836
  private _rejectQueue(err: Error): void {
487
837
  for (const turn of this._queue) {
488
- turn.reject(err);
838
+ turn.reject(turn.evidence.fail(err, 'blocked'));
489
839
  }
490
840
  this._queue = [];
491
841
  }
@@ -494,10 +844,10 @@ export class ClaudeCodeSession implements HarnessSession {
494
844
  // Private — stdout reader (background, runs for session lifetime)
495
845
  // ---------------------------------------------------------------------------
496
846
 
497
- private async _readStdout(): Promise<void> {
498
- const reader = this._proc!.stdout.getReader();
847
+ private async _readStdout(proc: PipedSubprocess): Promise<void> {
848
+ const reader = proc.stdout.getReader();
499
849
  const decoder = new TextDecoder();
500
- let buffer = "";
850
+ let buffer = '';
501
851
 
502
852
  try {
503
853
  while (true) {
@@ -505,21 +855,29 @@ export class ClaudeCodeSession implements HarnessSession {
505
855
  if (done) break;
506
856
 
507
857
  buffer += decoder.decode(value, { stream: true });
508
- const lines = buffer.split("\n");
858
+ const lines = buffer.split('\n');
509
859
  buffer = lines.pop()!; // Keep incomplete line in buffer
510
860
 
511
861
  for (const line of lines) {
512
862
  if (!line.trim()) continue;
513
- this._processLine(line);
863
+ if (this._proc === proc || !this._proc) this._processLine(line);
514
864
  }
515
865
  }
516
866
 
517
867
  // Flush remaining buffer
518
- if (buffer.trim()) {
868
+ if (buffer.trim() && (this._proc === proc || !this._proc)) {
519
869
  this._processLine(buffer);
520
870
  }
871
+ if (this._proc === proc && this._alive)
872
+ this._transportFailure(
873
+ new Error(
874
+ this._stderr.trim()
875
+ ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
876
+ : 'Native stdout closed',
877
+ ),
878
+ );
521
879
  } catch (err) {
522
- this._rejectInflight(err as Error);
880
+ if (this._proc === proc) this._transportFailure(err as Error);
523
881
  }
524
882
  }
525
883
 
@@ -536,7 +894,9 @@ export class ClaudeCodeSession implements HarnessSession {
536
894
  if (done) break;
537
895
  this._stderr += decoder.decode(value, { stream: true });
538
896
  }
539
- } catch { /* ignore */ }
897
+ } catch {
898
+ /* ignore */
899
+ }
540
900
  }
541
901
 
542
902
  // ---------------------------------------------------------------------------
@@ -553,61 +913,184 @@ export class ClaudeCodeSession implements HarnessSession {
553
913
 
554
914
  const raw = msg as Record<string, unknown>;
555
915
 
916
+ // Control-protocol traffic belongs to the local client, never to an admission.
917
+ if (raw.type === 'control_response') {
918
+ const response = raw.response as Record<string, unknown> | undefined;
919
+ const id = typeof response?.request_id === 'string' ? response.request_id : undefined;
920
+ const pending = id ? this._control.get(id) : undefined;
921
+ if (pending && id) {
922
+ this._control.delete(id);
923
+ if (response?.subtype === 'success') pending.resolve(response);
924
+ else
925
+ pending.reject(
926
+ new Error(
927
+ typeof response?.error === 'string' ? response.error : 'Control request failed',
928
+ ),
929
+ );
930
+ }
931
+ return;
932
+ }
933
+ if (raw.type === 'control_request') {
934
+ // No client-side handlers (can_use_tool, hooks) are registered on this transport.
935
+ const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
936
+ if (id && this._proc) {
937
+ try {
938
+ this._proc.stdin.write(
939
+ `${JSON.stringify({
940
+ type: 'control_response',
941
+ response: { subtype: 'error', request_id: id, error: 'Unsupported by this client' },
942
+ })}\n`,
943
+ );
944
+ this._proc.stdin.flush();
945
+ } catch {
946
+ /* transport failure surfaces on the stream */
947
+ }
948
+ }
949
+ this._unattributed(raw, 'unrecognized-event');
950
+ return;
951
+ }
952
+
953
+ if (
954
+ this._awaitingForkIdentity &&
955
+ raw.type === 'system' &&
956
+ raw.subtype === 'init' &&
957
+ typeof raw.session_id === 'string'
958
+ ) {
959
+ this._externalSessionId = raw.session_id;
960
+ this._awaitingForkIdentity = false;
961
+ }
962
+ // A foreign session or repeated result UUID cannot own the current admission.
963
+ if (
964
+ typeof raw.session_id === 'string' &&
965
+ this._externalSessionId &&
966
+ raw.session_id !== this._externalSessionId
967
+ ) {
968
+ this._unattributed(raw, 'foreign-session');
969
+ return;
970
+ }
971
+ const terminalKey =
972
+ raw.type === 'result' && typeof raw.uuid === 'string' ? raw.uuid : undefined;
973
+ if (terminalKey && this._seenTerminals.has(terminalKey)) {
974
+ this._unattributed(raw, 'duplicate-terminal');
975
+ return;
976
+ }
556
977
  // Capture the runtime's native session ID the first time we see it.
557
978
  // Subsequent messages echo the same ID; we keep our initial capture
558
979
  // (for resumed sessions, the config-supplied ID should match).
559
- if (typeof raw.session_id === "string" && !this._externalSessionId) {
980
+ if (typeof raw.session_id === 'string' && !this._externalSessionId) {
560
981
  this._externalSessionId = raw.session_id;
561
982
  }
562
983
 
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
- });
984
+ // Lifecycle/configuration envelopes belong to the session, not to whichever
985
+ // admission is in flight. Every init is preserved once with its binding; it is
986
+ // never attributed to a turn and never compared against an earlier init to infer
987
+ // compaction or restart. Only an explicit compact_boundary is a compaction, and
988
+ // it is emitted exactly once per native uuid with this session's provenance.
989
+ // Foreign bindings were already refused above and cannot invalidate this owner.
990
+ if (raw.type === 'system' && raw.subtype === 'init') {
991
+ this._unattributed(raw, 'session-configuration');
992
+ return;
993
+ }
994
+ if (raw.type === 'system' && raw.subtype === 'compact_boundary') {
995
+ const boundaryKey = typeof raw.uuid === 'string' ? raw.uuid : undefined;
996
+ if (boundaryKey && this._seenBoundaries.has(boundaryKey)) {
997
+ this._unattributed(raw, 'duplicate-boundary');
998
+ return;
585
999
  }
586
- this._sawInit = true;
1000
+ if (boundaryKey) this._seenBoundaries.add(boundaryKey);
1001
+ this._emit({
1002
+ kind: 'session_compact',
1003
+ timestamp: Date.now(),
1004
+ correlation: 'unknown',
1005
+ nativeSessionId:
1006
+ typeof raw.session_id === 'string' ? raw.session_id : this._externalSessionId,
1007
+ externalSessionId: this._externalSessionId,
1008
+ compactionSource: 'claude-code',
1009
+ raw,
1010
+ });
1011
+ return;
587
1012
  }
588
1013
 
1014
+ // Account limits are account-level evidence, never part of an admission's output.
1015
+ if (raw.type === 'rate_limit_event') {
1016
+ const snapshot = claudeRateLimitSnapshot(raw.rate_limit_info);
1017
+ if (snapshot) this._observeLimits(snapshot);
1018
+ else this._unattributed(raw, 'account-status');
1019
+ return;
1020
+ }
1021
+
1022
+ const turn = this._inflight;
1023
+ if (!turn) {
1024
+ if (terminalKey) this._seenTerminals.add(terminalKey);
1025
+ this._unattributed(raw, 'no-admission');
1026
+ return;
1027
+ }
1028
+ const a = turn.evidence;
1029
+ a.identity = {
1030
+ ...a.identity,
1031
+ nativeSessionId:
1032
+ typeof raw.session_id === 'string' ? raw.session_id : a.identity.nativeSessionId,
1033
+ correlation: 'ordered-stream',
1034
+ };
1035
+ const terminal = raw.type === 'result';
1036
+ if (terminal) {
1037
+ if (terminalKey) this._seenTerminals.add(terminalKey);
1038
+ const subtype = typeof raw.subtype === 'string' ? raw.subtype : undefined;
1039
+ const reason = typeof raw.terminal_reason === 'string' ? raw.terminal_reason : undefined;
1040
+ const status =
1041
+ typeof raw.api_error_status === 'number' && Number.isFinite(raw.api_error_status)
1042
+ ? raw.api_error_status
1043
+ : undefined;
1044
+ // Match Foundry's provider precedence. An optimistic subtype cannot erase
1045
+ // an explicit native error; local transport/RPC errors do not enter here.
1046
+ const failed =
1047
+ raw.is_error === true ||
1048
+ subtype?.startsWith('error_') ||
1049
+ (status !== undefined && status >= 400) ||
1050
+ reason === 'api_error';
1051
+ a.nativeOutcome = failed ? 'failed' : subtype === 'success' ? 'completed' : 'unknown';
1052
+ a.terminal = {
1053
+ type: 'result',
1054
+ eventId: terminalKey,
1055
+ subtype,
1056
+ reason,
1057
+ apiErrorStatus: status,
1058
+ };
1059
+ }
1060
+ const message = raw.message as Record<string, unknown> | undefined;
589
1061
  const classified = this._classify(raw);
1062
+ if (!classified.length) this._unattributed(raw, 'unrecognized-event');
590
1063
  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;
1064
+ const e = a.record({
1065
+ ...event,
1066
+ messageId: typeof message?.id === 'string' ? message.id : undefined,
1067
+ ...(terminal ? { nativeOutcome: a.nativeOutcome, terminal: a.terminal } : {}),
1068
+ });
1069
+ // Native result usage is authoritative for the whole turn. Request
1070
+ // snapshots can repeat across content blocks and must not be added again.
1071
+ if (e.kind === 'result' && e.tokens) {
1072
+ for (const key of [
1073
+ 'input',
1074
+ 'output',
1075
+ 'cacheRead',
1076
+ 'cacheWrite',
1077
+ 'cacheWrite5m',
1078
+ 'cacheWrite1h',
1079
+ 'thinking',
1080
+ ] as const) {
1081
+ const value = e.tokens[key];
1082
+ if (value !== undefined) this._totalTokens[key] = (this._totalTokens[key] ?? 0) + value;
1083
+ }
600
1084
  }
1085
+ this._emit(e);
601
1086
  }
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
- }
1087
+ if (terminal) {
1088
+ if (a.nativeOutcome !== 'unknown') this._resolveTurn();
1089
+ else
1090
+ this._rejectInflight(
1091
+ new Error('Native result has no recognized terminal status'),
1092
+ 'unrecognized-terminal',
1093
+ );
611
1094
  }
612
1095
  }
613
1096
 
@@ -619,31 +1102,39 @@ export class ClaudeCodeSession implements HarnessSession {
619
1102
  // --print + --input-format stream-json = multi-turn stream over stdin
620
1103
  // --output-format stream-json requires --verbose
621
1104
  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",
1105
+ '--print',
1106
+ '--verbose',
1107
+ '--input-format',
1108
+ 'stream-json',
1109
+ '--output-format',
1110
+ 'stream-json',
1111
+ '--model',
1112
+ this._model,
1113
+ ...(this._effort ? ['--effort', this._effort] : []),
1114
+ ...(this._maxTurns === null ? [] : ['--max-turns', String(this._maxTurns)]),
1115
+ '--permission-mode',
1116
+ this._permissionMode,
1117
+ '--include-hook-events',
631
1118
  ];
632
1119
 
633
1120
  // Resume for fork or crash recovery. _externalSessionId is set from
634
1121
  // config (crash recovery / fork) or from the stream. Either way, if
635
1122
  // it's present at spawn time, we --resume.
1123
+ if (this._textOnly) args.push(...CLAUDE_TEXT_ONLY_ARGS);
1124
+ if (!this._persistSession) args.push('--no-session-persistence');
1125
+
636
1126
  if (this._externalSessionId) {
637
- args.push("--resume", this._externalSessionId);
1127
+ args.push('--resume', this._externalSessionId);
638
1128
  if (this._forking) {
639
- args.push("--fork-session");
1129
+ args.push('--fork-session');
1130
+ this._awaitingForkIdentity = true;
640
1131
  this._forking = false;
641
1132
  }
642
1133
  }
643
1134
 
644
1135
  // Stable base context injected once at process startup
645
1136
  if (this._baseContext) {
646
- args.push("--append-system-prompt", this._baseContext);
1137
+ args.push('--append-system-prompt', this._baseContext);
647
1138
  }
648
1139
 
649
1140
  return args;
@@ -659,55 +1150,72 @@ export class ClaudeCodeSession implements HarnessSession {
659
1150
 
660
1151
  const type = msg.type as string | undefined;
661
1152
 
662
- if (type === "assistant") {
1153
+ if (type === 'assistant') {
663
1154
  const message = msg.message as Record<string, unknown> | undefined;
1155
+ const tokens = parseClaudeUsage(message?.usage);
1156
+ if (tokens) {
1157
+ // Preserve the envelope (message/request IDs, model and usage tags),
1158
+ // including when a process dies before the terminal result arrives.
1159
+ events.push({ kind: 'usage', timestamp: ts, tokens, raw: msg });
1160
+ }
664
1161
  const content = message?.content;
665
1162
  if (!Array.isArray(content)) return events;
666
1163
 
667
1164
  for (const block of content) {
668
1165
  const blockType = (block as Record<string, unknown>).type as string;
669
1166
 
670
- if (blockType === "text") {
671
- const text = (block as Record<string, unknown>).text as
672
- | string
673
- | undefined;
1167
+ if (blockType === 'text') {
1168
+ const text = (block as Record<string, unknown>).text as string | undefined;
674
1169
  if (text) {
675
- events.push({ kind: "text", timestamp: ts, text, raw: block });
1170
+ events.push({ kind: 'text', timestamp: ts, text, raw: block });
676
1171
  }
677
- } else if (blockType === "tool_use") {
1172
+ } else if (blockType === 'tool_use') {
678
1173
  events.push({
679
- kind: "tool_use",
1174
+ kind: 'tool_use',
680
1175
  timestamp: ts,
1176
+ callId: typeof block.id === 'string' ? block.id : undefined,
1177
+ itemId: typeof block.id === 'string' ? block.id : undefined,
681
1178
  toolName: (block as Record<string, unknown>).name as string,
682
- toolInput: (block as Record<string, unknown>).input as Record<
683
- string,
684
- unknown
685
- >,
1179
+ toolInput: (block as Record<string, unknown>).input as Record<string, unknown>,
686
1180
  raw: block,
687
1181
  });
688
- } else if (blockType === "thinking") {
1182
+ } else if (blockType === 'thinking') {
689
1183
  const b = block as Record<string, unknown>;
690
1184
  events.push({
691
- kind: "thinking",
1185
+ kind: 'thinking',
692
1186
  timestamp: ts,
693
1187
  text: (b.thinking ?? b.text ?? b.content) as string | undefined,
694
1188
  raw: block,
695
1189
  });
696
1190
  }
697
1191
  }
698
- } else if (type === "tool") {
1192
+ } else if (type === 'user') {
1193
+ const message = msg.message as Record<string, unknown> | undefined;
1194
+ if (Array.isArray(message?.content))
1195
+ for (const block of message.content) {
1196
+ if (block?.type !== 'tool_result') continue;
1197
+ events.push({
1198
+ kind: 'tool_result',
1199
+ timestamp: ts,
1200
+ callId: typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined,
1201
+ ...publicToolResultContent(block.content),
1202
+ toolError: block.is_error === true,
1203
+ raw: block,
1204
+ });
1205
+ }
1206
+ } else if (type === 'tool') {
699
1207
  // Tool result — what the tool returned
700
1208
  const content = msg.content;
701
1209
  if (Array.isArray(content)) {
702
1210
  for (const block of content) {
703
1211
  const b = block as Record<string, unknown>;
704
1212
  events.push({
705
- kind: "tool_result",
1213
+ kind: 'tool_result',
706
1214
  timestamp: ts,
707
1215
  toolOutput:
708
- typeof b.text === "string"
1216
+ typeof b.text === 'string'
709
1217
  ? b.text
710
- : typeof b.content === "string"
1218
+ : typeof b.content === 'string'
711
1219
  ? b.content
712
1220
  : JSON.stringify(block),
713
1221
  toolError: b.is_error === true,
@@ -716,39 +1224,27 @@ export class ClaudeCodeSession implements HarnessSession {
716
1224
  }
717
1225
  } else if (content != null) {
718
1226
  events.push({
719
- kind: "tool_result",
1227
+ kind: 'tool_result',
720
1228
  timestamp: ts,
721
- toolOutput:
722
- typeof content === "string" ? content : JSON.stringify(content),
1229
+ toolOutput: typeof content === 'string' ? content : JSON.stringify(content),
723
1230
  raw: content,
724
1231
  });
725
1232
  }
726
- } else if (type === "result") {
727
- const usage = msg.usage as
728
- | Record<string, number>
729
- | undefined;
1233
+ } else if (type === 'result') {
730
1234
  events.push({
731
- kind: "result",
1235
+ kind: 'result',
732
1236
  timestamp: ts,
733
- text: (msg.result as string) ?? "",
1237
+ text: (msg.result as string) ?? '',
734
1238
  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,
1239
+ tokens: parseClaudeUsage(msg.usage),
741
1240
  raw: msg,
742
1241
  });
743
- } else if (type === "error") {
1242
+ } else if (type === 'error') {
744
1243
  const error = msg.error as Record<string, unknown> | undefined;
745
1244
  events.push({
746
- kind: "error",
1245
+ kind: 'error',
747
1246
  timestamp: ts,
748
- text:
749
- (error?.message as string) ??
750
- (msg.message as string) ??
751
- JSON.stringify(msg),
1247
+ text: (error?.message as string) ?? (msg.message as string) ?? JSON.stringify(msg),
752
1248
  raw: msg,
753
1249
  });
754
1250
  }
@@ -763,16 +1259,40 @@ export class ClaudeCodeSession implements HarnessSession {
763
1259
  // Private — emit
764
1260
  // ---------------------------------------------------------------------------
765
1261
 
1262
+ private _unattributed(
1263
+ raw: Record<string, unknown>,
1264
+ reason: NonNullable<SessionEvent['unattributedReason']>,
1265
+ ): void {
1266
+ const message = raw.message as Record<string, unknown> | undefined;
1267
+ // Preserve the original envelope once, without synthesizing admission/native ownership.
1268
+ this._emit({
1269
+ kind: 'native_status',
1270
+ timestamp: Date.now(),
1271
+ correlation: 'unknown',
1272
+ unattributedReason: reason,
1273
+ nativeSessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined,
1274
+ messageId: typeof message?.id === 'string' ? message.id : undefined,
1275
+ raw,
1276
+ });
1277
+ }
1278
+
766
1279
  private _emit(event: SessionEvent): void {
1280
+ event = retainEvidence(event);
1281
+ if (event.kind === 'session_end') {
1282
+ if (this._endEmitted) return;
1283
+ this._endEmitted = true;
1284
+ }
767
1285
  this._eventLog.push(event);
768
1286
  for (const handler of this._handlers) {
769
1287
  try {
770
- handler(event);
771
- } catch (err) {
772
- console.warn(
773
- "[ClaudeCodeSession] handler error:",
774
- (err as Error).message,
775
- );
1288
+ const observation: unknown = handler(event);
1289
+ if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
1290
+ void Promise.resolve(observation).catch(() => {
1291
+ this._observerFailures.asynchronous++;
1292
+ });
1293
+ }
1294
+ } catch {
1295
+ this._observerFailures.synchronous++;
776
1296
  }
777
1297
  }
778
1298
  }