@inixiative/agent-session 0.1.0 → 0.2.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,5 @@
1
+ import { TurnState } from "./turn-state";
2
+ import { retainEvidence } from "./retained-evidence";
1
3
  // ---------------------------------------------------------------------------
2
4
  // ClaudeCodeSession — long-lived Claude Code process with full event capture
3
5
  // ---------------------------------------------------------------------------
@@ -39,8 +41,12 @@ import type {
39
41
  SessionEventHandler,
40
42
  SessionResult,
41
43
  SessionArtifact,
44
+ SessionSendOptions,
45
+ SessionTokens,
42
46
  } from "./harness-session";
43
47
 
48
+ import { parseClaudeUsage } from "./claude-usage";
49
+
44
50
  // Re-export types so existing import paths keep working
45
51
  export type {
46
52
  SessionEvent,
@@ -66,7 +72,7 @@ export interface ClaudeCodeSessionConfig {
66
72
  /** Working directory for the session. */
67
73
  cwd?: string;
68
74
  /** Max agentic turns per message. Defaults to 25. */
69
- maxTurns?: number;
75
+ maxTurns?: number | null;
70
76
  /** Permission mode. Defaults to "bypassPermissions". */
71
77
  permissionMode?: string;
72
78
  /** Default per-send timeout in ms. Defaults to 600000 (10 min). */
@@ -104,6 +110,8 @@ export interface ClaudeCodeSessionConfig {
104
110
  // ---------------------------------------------------------------------------
105
111
 
106
112
  interface QueuedTurn {
113
+ onAdmission?: SessionSendOptions["onAdmission"];
114
+ evidence: TurnState;
107
115
  message: string;
108
116
  timeout: number;
109
117
  resolve: (result: SessionResult) => void;
@@ -123,17 +131,59 @@ export interface PipedSubprocess {
123
131
  kill(): void;
124
132
  }
125
133
 
134
+ // ---------------------------------------------------------------------------
135
+ // Public tool-result content contract (stream-json `user` tool_result blocks).
136
+ //
137
+ // A string content is the public output verbatim. An array keeps the text
138
+ // blocks (joined with one newline, as before) and, separately, the exact
139
+ // `tool_name` strings of `tool_reference` blocks (Anthropic tool search returns
140
+ // discovered tools as references, not text). Every other block, and any
141
+ // malformed text/reference block, is omitted explicitly: `toolOutputOmitted` is
142
+ // set and `toolOutputOmittedTypes` lists known public type labels or
143
+ // "unsupported". Nothing else from a block is copied, so private or unexpected
144
+ // fields never enter the public event. Reference-only output is NOT a success
145
+ // claim; the consuming guard judges ownership and ordering.
146
+ // ---------------------------------------------------------------------------
147
+ const KNOWN_NON_TEXT_RESULT_TYPES = new Set(["image", "audio", "document", "resource", "resource_link"]);
148
+
149
+ function publicToolResultContent(content: unknown): Pick<SessionEvent, "toolOutput" | "toolReferences" | "toolOutputOmitted" | "toolOutputOmittedTypes"> {
150
+ if (typeof content === "string") return { toolOutput: content };
151
+ if (content === undefined || content === null) return {};
152
+ const omittedTypes: string[] = [];
153
+ const omit = (type: unknown) => {
154
+ const label = typeof type === "string" && KNOWN_NON_TEXT_RESULT_TYPES.has(type) ? type : "unsupported";
155
+ if (!omittedTypes.includes(label)) omittedTypes.push(label);
156
+ };
157
+ if (!Array.isArray(content)) { omit(undefined); return { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) }; }
158
+ const texts: string[] = [], references: string[] = [];
159
+ for (const block of content) {
160
+ const b = block !== null && typeof block === "object" && !Array.isArray(block) ? block as Record<string, unknown> : undefined;
161
+ if (b?.type === "text" && typeof b.text === "string") texts.push(b.text);
162
+ else if (b?.type === "tool_reference" && typeof b.tool_name === "string" && b.tool_name.length > 0) references.push(b.tool_name);
163
+ else omit(b?.type);
164
+ }
165
+ return {
166
+ toolOutput: texts.join("\n"),
167
+ ...(references.length ? { toolReferences: Object.freeze([...references]) } : {}),
168
+ ...(omittedTypes.length ? { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) } : {}),
169
+ };
170
+ }
171
+
126
172
  export class ClaudeCodeSession implements HarnessSession {
127
173
  // -- Config --
128
174
  private _bin: string;
129
175
  private _model: string;
130
176
  private _effort?: string;
131
177
  private _cwd: string;
132
- private _maxTurns: number;
178
+ readonly admissionProtocol = "prewrite-v1" as const;
179
+ readonly turnBudgetProtocol = "optional-max-turns-v1" as const;
180
+ inspectAttempt(admissionId: string) { return this._attempts.find(attempt => attempt.admissionId === admissionId)?.snapshot(); }
181
+ private _maxTurns: number | null;
133
182
  private _permissionMode: string;
134
183
  private _defaultTimeout: number;
135
184
  private _baseContext?: string;
136
185
  private _forking = false;
186
+ private _awaitingForkIdentity = false;
137
187
  private _spawn?: ClaudeCodeSessionConfig["spawn"];
138
188
 
139
189
  // -- Process --
@@ -151,23 +201,28 @@ export class ClaudeCodeSession implements HarnessSession {
151
201
  */
152
202
  private _externalSessionId?: string;
153
203
  private _alive = false;
204
+ private _endEmitted = false;
154
205
  private _eventLog: SessionEvent[] = [];
155
206
  private _handlers: SessionEventHandler[] = [];
207
+ private _observerFailures = { synchronous: 0, asynchronous: 0 };
156
208
  private _beforeSendHooks: BeforeSendHook[] = [];
157
209
  private _turns = 0;
158
- private _totalTokens = { input: 0, output: 0 };
210
+ private _totalTokens: SessionTokens = { input: 0, output: 0 };
159
211
  private _startedAt: number;
160
212
  /**
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.
213
+ * Native uuids of explicit compaction boundaries already emitted. Claude emits a
214
+ * `system/init` per admission on one binding, so a repeated init is configuration
215
+ * evidence, never proof of compaction or restart. Only the explicit
216
+ * `system/compact_boundary` envelope is a compaction, and a repeat is a duplicate
217
+ * only when it carries the same native uuid; text-equal boundaries without one
218
+ * are distinct compactions.
166
219
  */
167
- private _sawInit = false;
220
+ private _seenBoundaries = new Set<string>();
168
221
 
169
222
  // -- Turn queue --
170
223
  private _queue: QueuedTurn[] = [];
224
+ private _attempts: TurnState[] = [];
225
+ private _seenTerminals = new Set<string>();
171
226
  private _inflight: QueuedTurn | null = null;
172
227
  private _turnEvents: SessionEvent[] = [];
173
228
  private _resultText = "";
@@ -182,7 +237,11 @@ export class ClaudeCodeSession implements HarnessSession {
182
237
  this._model = config?.model ?? "sonnet";
183
238
  this._effort = config?.effort;
184
239
  this._cwd = config?.cwd ?? process.cwd();
185
- this._maxTurns = config?.maxTurns ?? 25;
240
+ // Undefined retains standalone legacy behavior; null explicitly omits the CLI cap.
241
+ if (config?.maxTurns !== undefined && config.maxTurns !== null && (!Number.isSafeInteger(config.maxTurns) || config.maxTurns < 1)) {
242
+ throw new Error("maxTurns must be a positive safe integer or null (unbounded)");
243
+ }
244
+ this._maxTurns = config?.maxTurns === undefined ? 25 : config.maxTurns;
186
245
  this._permissionMode = config?.permissionMode ?? "bypassPermissions";
187
246
  this._defaultTimeout = config?.timeout ?? 600_000;
188
247
  this._baseContext = config?.baseContext;
@@ -195,11 +254,15 @@ export class ClaudeCodeSession implements HarnessSession {
195
254
  // Accessors
196
255
  // ---------------------------------------------------------------------------
197
256
 
257
+ get accounting() { return "observed-only" as const; }
258
+ get attempts() { return this._attempts.map(a => a.snapshot()); }
259
+ get diagnostics() { return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) }); }
260
+
198
261
  get alive(): boolean { return this._alive; }
199
262
  get externalSessionId(): string | undefined { return this._externalSessionId; }
200
- get events(): readonly SessionEvent[] { return this._eventLog; }
263
+ get events(): readonly SessionEvent[] { return Object.freeze([...this._eventLog]); }
201
264
  get turns(): number { return this._turns; }
202
- get totalTokens(): Readonly<{ input: number; output: number }> {
265
+ get totalTokens(): Readonly<SessionTokens> {
203
266
  return { ...this._totalTokens };
204
267
  }
205
268
 
@@ -246,6 +309,7 @@ export class ClaudeCodeSession implements HarnessSession {
246
309
  // ---------------------------------------------------------------------------
247
310
 
248
311
  async start(): Promise<void> {
312
+ if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown")) throw new Error("Native outcome unresolved; automatic resume is blocked");
249
313
  if (this._proc) throw new Error("Session already started");
250
314
 
251
315
  const args = this._buildSpawnArgs();
@@ -271,22 +335,25 @@ export class ClaudeCodeSession implements HarnessSession {
271
335
  }
272
336
 
273
337
  this._alive = true;
338
+ this._endEmitted = false;
274
339
  this._emit({ kind: "session_start", timestamp: Date.now() });
275
340
 
276
341
  // Background readers — run for session lifetime (don't await)
277
- this._readStdout();
342
+ const proc = this._proc;
343
+ const stdoutDone = this._readStdout(proc);
278
344
  this._readStderr();
279
345
 
280
346
  // Monitor process exit for cleanup
281
- this._proc.exited.then((code) => {
282
- if (!this._alive) return;
347
+ proc.exited.then(async (code) => {
348
+ if (this._proc !== proc || !this._alive) return;
283
349
  this._alive = false;
350
+ await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
284
351
  const errMsg = this._stderr.trim()
285
352
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
286
353
  : `Process exited with code ${code}`;
287
354
  this._rejectInflight(new Error(errMsg));
288
355
  this._rejectQueue(new Error("Session ended"));
289
- this._emit({ kind: "session_end", timestamp: Date.now() });
356
+ this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
290
357
  });
291
358
  }
292
359
 
@@ -296,11 +363,15 @@ export class ClaudeCodeSession implements HarnessSession {
296
363
 
297
364
  async send(
298
365
  message: string,
299
- opts?: { timeout?: number },
366
+ opts?: SessionSendOptions,
300
367
  ): Promise<SessionResult> {
301
368
  // Auto-restart after interrupt / process death (crash recovery via --resume).
302
369
  // _externalSessionId persists across process lifetimes, so start() will
303
370
  // include --resume <id> in spawn args.
371
+ if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown" && (!this._alive || a.localOutcome !== "pending"))) {
372
+ const blocked = new TurnState(); this._attempts.push(blocked);
373
+ throw blocked.fail(new Error("Native outcome unresolved; send not dispatched"), "blocked");
374
+ }
304
375
  if (!this._proc && this._externalSessionId) {
305
376
  await this.start();
306
377
  }
@@ -319,7 +390,11 @@ export class ClaudeCodeSession implements HarnessSession {
319
390
  }
320
391
 
321
392
  return new Promise<SessionResult>((resolve, reject) => {
322
- const turn: QueuedTurn = { message: transformed, timeout, resolve, reject };
393
+ const evidence = new TurnState(); this._attempts.push(evidence);
394
+ const turn: QueuedTurn = { evidence, message: transformed, timeout, resolve, reject, onAdmission: opts?.onAdmission };
395
+ if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== "pending")) {
396
+ reject(evidence.fail(new Error("Previous native work unresolved; send not dispatched"), "blocked")); return;
397
+ }
323
398
 
324
399
  if (!this._inflight) {
325
400
  this._dispatchTurn(turn);
@@ -334,6 +409,7 @@ export class ClaudeCodeSession implements HarnessSession {
334
409
  // ---------------------------------------------------------------------------
335
410
 
336
411
  fork(opts?: { cwd?: string; baseContext?: string }): ClaudeCodeSession {
412
+ if (this._inflight) throw new Error("Cannot fork while native ownership is unresolved");
337
413
  if (!this._externalSessionId) {
338
414
  throw new Error(
339
415
  "Cannot fork — no external session ID yet (send at least one message first)",
@@ -357,16 +433,15 @@ export class ClaudeCodeSession implements HarnessSession {
357
433
  }
358
434
 
359
435
  // ---------------------------------------------------------------------------
360
- // interrupt() — cancel the in-flight turn (best-effort)
436
+ // interrupt() — reject the local waiter; native cancellation is unacknowledged
361
437
  // ---------------------------------------------------------------------------
362
438
 
363
439
  interrupt(): void {
364
440
  if (!this._inflight) return;
365
441
 
366
- this._rejectInflight(new Error("Turn interrupted"));
442
+ this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
367
443
 
368
- // Process continues running. When it emits the orphaned result,
369
- // _processLine dispatches the next queued turn (if any).
444
+ // Native ownership is retained. Waiting sends are rejected without dispatch.
370
445
  }
371
446
 
372
447
  // ---------------------------------------------------------------------------
@@ -382,7 +457,7 @@ export class ClaudeCodeSession implements HarnessSession {
382
457
  this._turnTimer = null;
383
458
  }
384
459
 
385
- this._rejectInflight(new Error("Session killed"));
460
+ this._rejectInflight(new Error("Session killed; native outcome may remain unknown"), "killed");
386
461
  this._rejectQueue(new Error("Session killed"));
387
462
 
388
463
  try { this._proc.stdin.end(); } catch { /* already closed */ }
@@ -399,6 +474,7 @@ export class ClaudeCodeSession implements HarnessSession {
399
474
  artifact(): SessionArtifact {
400
475
  return {
401
476
  externalSessionId: this._externalSessionId,
477
+ attempts: this.attempts, accounting: "observed-only", diagnostics: this.diagnostics,
402
478
  events: [...this._eventLog],
403
479
  startedAt: this._startedAt,
404
480
  endedAt: this._alive ? undefined : Date.now(),
@@ -416,7 +492,22 @@ export class ClaudeCodeSession implements HarnessSession {
416
492
 
417
493
  private _dispatchTurn(turn: QueuedTurn): void {
418
494
  this._inflight = turn;
419
- this._turnEvents = [];
495
+ if (turn.onAdmission) {
496
+ Promise.resolve().then(() => turn.onAdmission!(turn.evidence.snapshot())).then(() => {
497
+ if (this._inflight === turn && this._alive && turn.evidence.localOutcome === "pending") this._writeTurn(turn);
498
+ else if (this._inflight === turn && turn.evidence.dispatch === "not-dispatched") { this._inflight = null; this._processNextTurn(); }
499
+ }).catch(error => {
500
+ turn.reject(turn.evidence.fail(error instanceof Error ? error : new Error(String(error)), "registration"));
501
+ if (this._inflight === turn) { this._inflight = null; this._processNextTurn(); }
502
+ });
503
+ return;
504
+ }
505
+ this._writeTurn(turn);
506
+ }
507
+
508
+ private _writeTurn(turn: QueuedTurn): void {
509
+ turn.evidence.dispatch = "attempted";
510
+ this._turnEvents = turn.evidence.events;
420
511
  this._resultText = "";
421
512
 
422
513
  // Wire format validated empirically against claude 2.1.114:
@@ -429,16 +520,16 @@ export class ClaudeCodeSession implements HarnessSession {
429
520
  content: [{ type: "text", text: turn.message }],
430
521
  },
431
522
  }) + "\n";
432
- this._proc!.stdin.write(payload);
433
- this._proc!.stdin.flush();
523
+ try { this._proc!.stdin.write(payload); this._proc!.stdin.flush(); }
524
+ catch (err) { this._rejectInflight(err as Error); return; }
434
525
 
435
526
  // Timeout guard
436
527
  if (turn.timeout > 0) {
437
528
  this._turnTimer = setTimeout(() => {
438
529
  this._turnTimer = null;
439
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
530
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), "timeout");
440
531
  // Don't dispatch next — process may still be working on this turn.
441
- // Next queued turn dispatches when the orphaned result arrives.
532
+ // A late terminal updates this admission; waiting sends are not replayed.
442
533
  }, turn.timeout);
443
534
  }
444
535
  }
@@ -451,41 +542,44 @@ export class ClaudeCodeSession implements HarnessSession {
451
542
  this._turnTimer = null;
452
543
  }
453
544
 
545
+ const a = this._inflight.evidence;
546
+ if (a.nativeOutcome === "unknown") return;
454
547
  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);
548
+ if (a.localOutcome === "pending") {
549
+ a.localOutcome = "resolved";
550
+ this._inflight.resolve(a.result(this._externalSessionId));
551
+ }
462
552
  this._inflight = null;
463
553
 
464
554
  this._processNextTurn();
465
555
  }
466
556
 
467
557
  private _processNextTurn(): void {
468
- if (this._queue.length > 0 && this._alive) {
558
+ if (!this._inflight && this._queue.length > 0 && this._alive) {
469
559
  const next = this._queue.shift()!;
470
560
  this._dispatchTurn(next);
471
561
  }
472
562
  }
473
563
 
474
- private _rejectInflight(err: Error): void {
564
+ private _rejectInflight(err: Error, reason: import("./harness-session").SessionAttempt["localFailure"] = "transport"): void {
475
565
  if (!this._inflight) return;
566
+ if (this._turnTimer) { clearTimeout(this._turnTimer); this._turnTimer = null; }
567
+ const a = this._inflight.evidence;
568
+ if ((reason === "transport" || reason === "killed") && a.transportOutcome === "open") a.transportOutcome = reason === "killed" ? "closed" : "failed";
569
+ if (a.localOutcome === "pending") this._inflight.reject(a.fail(err, reason));
570
+ this._rejectQueue(reason === "killed" ? err : new Error("Previous native work unresolved; queued send not dispatched"));
571
+ }
476
572
 
477
- if (this._turnTimer) {
478
- clearTimeout(this._turnTimer);
479
- this._turnTimer = null;
480
- }
481
-
482
- this._inflight.reject(err);
483
- this._inflight = null;
573
+ private _transportFailure(err: Error): void {
574
+ this._alive = false;
575
+ this._rejectInflight(err);
576
+ this._rejectQueue(err);
577
+ this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
484
578
  }
485
579
 
486
580
  private _rejectQueue(err: Error): void {
487
581
  for (const turn of this._queue) {
488
- turn.reject(err);
582
+ turn.reject(turn.evidence.fail(err, "blocked"));
489
583
  }
490
584
  this._queue = [];
491
585
  }
@@ -494,8 +588,8 @@ export class ClaudeCodeSession implements HarnessSession {
494
588
  // Private — stdout reader (background, runs for session lifetime)
495
589
  // ---------------------------------------------------------------------------
496
590
 
497
- private async _readStdout(): Promise<void> {
498
- const reader = this._proc!.stdout.getReader();
591
+ private async _readStdout(proc: PipedSubprocess): Promise<void> {
592
+ const reader = proc.stdout.getReader();
499
593
  const decoder = new TextDecoder();
500
594
  let buffer = "";
501
595
 
@@ -510,16 +604,17 @@ export class ClaudeCodeSession implements HarnessSession {
510
604
 
511
605
  for (const line of lines) {
512
606
  if (!line.trim()) continue;
513
- this._processLine(line);
607
+ if (this._proc === proc || !this._proc) this._processLine(line);
514
608
  }
515
609
  }
516
610
 
517
611
  // Flush remaining buffer
518
- if (buffer.trim()) {
612
+ if (buffer.trim() && (this._proc === proc || !this._proc)) {
519
613
  this._processLine(buffer);
520
614
  }
615
+ if (this._proc === proc && this._alive) this._transportFailure(new Error(this._stderr.trim() ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}` : "Native stdout closed"));
521
616
  } catch (err) {
522
- this._rejectInflight(err as Error);
617
+ if (this._proc === proc) this._transportFailure(err as Error);
523
618
  }
524
619
  }
525
620
 
@@ -553,6 +648,16 @@ export class ClaudeCodeSession implements HarnessSession {
553
648
 
554
649
  const raw = msg as Record<string, unknown>;
555
650
 
651
+ if (this._awaitingForkIdentity && raw.type === "system" && raw.subtype === "init" && typeof raw.session_id === "string") {
652
+ this._externalSessionId = raw.session_id;
653
+ this._awaitingForkIdentity = false;
654
+ }
655
+ // A foreign session or repeated result UUID cannot own the current admission.
656
+ if (typeof raw.session_id === "string" && this._externalSessionId && raw.session_id !== this._externalSessionId) {
657
+ this._unattributed(raw, "foreign-session"); return;
658
+ }
659
+ const terminalKey = raw.type === "result" && typeof raw.uuid === "string" ? raw.uuid : undefined;
660
+ if (terminalKey && this._seenTerminals.has(terminalKey)) { this._unattributed(raw, "duplicate-terminal"); return; }
556
661
  // Capture the runtime's native session ID the first time we see it.
557
662
  // Subsequent messages echo the same ID; we keep our initial capture
558
663
  // (for resumed sessions, the config-supplied ID should match).
@@ -560,55 +665,74 @@ export class ClaudeCodeSession implements HarnessSession {
560
665
  this._externalSessionId = raw.session_id;
561
666
  }
562
667
 
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;
668
+ // Lifecycle/configuration envelopes belong to the session, not to whichever
669
+ // admission is in flight. Every init is preserved once with its binding; it is
670
+ // never attributed to a turn and never compared against an earlier init to infer
671
+ // compaction or restart. Only an explicit compact_boundary is a compaction, and
672
+ // it is emitted exactly once per native uuid with this session's provenance.
673
+ // Foreign bindings were already refused above and cannot invalidate this owner.
674
+ if (raw.type === "system" && raw.subtype === "init") {
675
+ this._unattributed(raw, "session-configuration"); return;
676
+ }
677
+ if (raw.type === "system" && raw.subtype === "compact_boundary") {
678
+ const boundaryKey = typeof raw.uuid === "string" ? raw.uuid : undefined;
679
+ if (boundaryKey && this._seenBoundaries.has(boundaryKey)) { this._unattributed(raw, "duplicate-boundary"); return; }
680
+ if (boundaryKey) this._seenBoundaries.add(boundaryKey);
681
+ this._emit({
682
+ kind: "session_compact",
683
+ timestamp: Date.now(),
684
+ correlation: "unknown",
685
+ nativeSessionId: typeof raw.session_id === "string" ? raw.session_id : this._externalSessionId,
686
+ externalSessionId: this._externalSessionId,
687
+ compactionSource: "claude-code",
688
+ raw,
689
+ });
690
+ return;
587
691
  }
588
692
 
693
+ const turn = this._inflight;
694
+ if (!turn) {
695
+ if (terminalKey) this._seenTerminals.add(terminalKey);
696
+ this._unattributed(raw, "no-admission"); return;
697
+ }
698
+ const a = turn.evidence;
699
+ a.identity = { ...a.identity, nativeSessionId: typeof raw.session_id === "string" ? raw.session_id : a.identity.nativeSessionId,
700
+ correlation: "ordered-stream" };
701
+ const terminal = raw.type === "result";
702
+ if (terminal) {
703
+ if (terminalKey) this._seenTerminals.add(terminalKey);
704
+ const subtype = typeof raw.subtype === "string" ? raw.subtype : undefined;
705
+ const reason = typeof raw.terminal_reason === "string" ? raw.terminal_reason : undefined;
706
+ const status = typeof raw.api_error_status === "number" && Number.isFinite(raw.api_error_status) ? raw.api_error_status : undefined;
707
+ // Match Foundry's provider precedence. An optimistic subtype cannot erase
708
+ // an explicit native error; local transport/RPC errors do not enter here.
709
+ const failed = raw.is_error === true || subtype?.startsWith("error_")
710
+ || (status !== undefined && status >= 400) || reason === "api_error";
711
+ a.nativeOutcome = failed ? "failed" : subtype === "success" ? "completed" : "unknown";
712
+ a.terminal = { type: "result", eventId: terminalKey, subtype, reason, apiErrorStatus: status };
713
+ }
714
+ const message = raw.message as Record<string, unknown> | undefined;
589
715
  const classified = this._classify(raw);
716
+ if (!classified.length) this._unattributed(raw, "unrecognized-event");
590
717
  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;
718
+ const e = a.record({ ...event, messageId: typeof message?.id === "string" ? message.id : undefined,
719
+ ...(terminal ? { nativeOutcome: a.nativeOutcome, terminal: a.terminal } : {}) });
720
+ this._resultText = a.content;
721
+ // Native result usage is authoritative for the whole turn. Request
722
+ // snapshots can repeat across content blocks and must not be added again.
723
+ if (e.kind === "result" && e.tokens) {
724
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "cacheWrite5m", "cacheWrite1h", "thinking"] as const) {
725
+ const value = e.tokens[key];
726
+ if (value !== undefined) this._totalTokens[key] = (this._totalTokens[key] ?? 0) + value;
727
+ }
600
728
  }
729
+ this._emit(e);
601
730
  }
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
- }
731
+ if (terminal) {
732
+ if (a.nativeOutcome !== "unknown") this._resolveTurn();
733
+ else this._rejectInflight(new Error("Native result has no recognized terminal status"), "unrecognized-terminal");
611
734
  }
735
+
612
736
  }
613
737
 
614
738
  // ---------------------------------------------------------------------------
@@ -625,7 +749,7 @@ export class ClaudeCodeSession implements HarnessSession {
625
749
  "--output-format", "stream-json",
626
750
  "--model", this._model,
627
751
  ...(this._effort ? ["--effort", this._effort] : []),
628
- "--max-turns", String(this._maxTurns),
752
+ ...(this._maxTurns === null ? [] : ["--max-turns", String(this._maxTurns)]),
629
753
  "--permission-mode", this._permissionMode,
630
754
  "--include-hook-events",
631
755
  ];
@@ -637,6 +761,7 @@ export class ClaudeCodeSession implements HarnessSession {
637
761
  args.push("--resume", this._externalSessionId);
638
762
  if (this._forking) {
639
763
  args.push("--fork-session");
764
+ this._awaitingForkIdentity = true;
640
765
  this._forking = false;
641
766
  }
642
767
  }
@@ -661,6 +786,12 @@ export class ClaudeCodeSession implements HarnessSession {
661
786
 
662
787
  if (type === "assistant") {
663
788
  const message = msg.message as Record<string, unknown> | undefined;
789
+ const tokens = parseClaudeUsage(message?.usage);
790
+ if (tokens) {
791
+ // Preserve the envelope (message/request IDs, model and usage tags),
792
+ // including when a process dies before the terminal result arrives.
793
+ events.push({ kind: "usage", timestamp: ts, tokens, raw: msg });
794
+ }
664
795
  const content = message?.content;
665
796
  if (!Array.isArray(content)) return events;
666
797
 
@@ -678,6 +809,8 @@ export class ClaudeCodeSession implements HarnessSession {
678
809
  events.push({
679
810
  kind: "tool_use",
680
811
  timestamp: ts,
812
+ callId: typeof block.id === "string" ? block.id : undefined,
813
+ itemId: typeof block.id === "string" ? block.id : undefined,
681
814
  toolName: (block as Record<string, unknown>).name as string,
682
815
  toolInput: (block as Record<string, unknown>).input as Record<
683
816
  string,
@@ -695,6 +828,13 @@ export class ClaudeCodeSession implements HarnessSession {
695
828
  });
696
829
  }
697
830
  }
831
+ } else if (type === "user") {
832
+ const message = msg.message as Record<string, unknown> | undefined;
833
+ if (Array.isArray(message?.content)) for (const block of message.content) {
834
+ if (block?.type !== "tool_result") continue;
835
+ events.push({ kind: "tool_result", timestamp: ts, callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
836
+ ...publicToolResultContent(block.content), toolError: block.is_error === true, raw: block });
837
+ }
698
838
  } else if (type === "tool") {
699
839
  // Tool result — what the tool returned
700
840
  const content = msg.content;
@@ -724,20 +864,12 @@ export class ClaudeCodeSession implements HarnessSession {
724
864
  });
725
865
  }
726
866
  } else if (type === "result") {
727
- const usage = msg.usage as
728
- | Record<string, number>
729
- | undefined;
730
867
  events.push({
731
868
  kind: "result",
732
869
  timestamp: ts,
733
870
  text: (msg.result as string) ?? "",
734
871
  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,
872
+ tokens: parseClaudeUsage(msg.usage),
741
873
  raw: msg,
742
874
  });
743
875
  } else if (type === "error") {
@@ -763,17 +895,28 @@ export class ClaudeCodeSession implements HarnessSession {
763
895
  // Private — emit
764
896
  // ---------------------------------------------------------------------------
765
897
 
898
+ private _unattributed(raw: Record<string, unknown>, reason: NonNullable<SessionEvent["unattributedReason"]>): void {
899
+ const message = raw.message as Record<string, unknown> | undefined;
900
+ // Preserve the original envelope once, without synthesizing admission/native ownership.
901
+ this._emit({ kind: "native_status", timestamp: Date.now(), correlation: "unknown", unattributedReason: reason,
902
+ nativeSessionId: typeof raw.session_id === "string" ? raw.session_id : undefined,
903
+ messageId: typeof message?.id === "string" ? message.id : undefined, raw });
904
+ }
905
+
766
906
  private _emit(event: SessionEvent): void {
907
+ event = retainEvidence(event);
908
+ if (event.kind === "session_end") {
909
+ if (this._endEmitted) return;
910
+ this._endEmitted = true;
911
+ }
767
912
  this._eventLog.push(event);
768
913
  for (const handler of this._handlers) {
769
914
  try {
770
- handler(event);
771
- } catch (err) {
772
- console.warn(
773
- "[ClaudeCodeSession] handler error:",
774
- (err as Error).message,
775
- );
776
- }
915
+ const observation: unknown = handler(event);
916
+ if (observation && typeof (observation as PromiseLike<unknown>).then === "function") {
917
+ void Promise.resolve(observation).catch(() => { this._observerFailures.asynchronous++; });
918
+ }
919
+ } catch { this._observerFailures.synchronous++; }
777
920
  }
778
921
  }
779
922
  }