@inixiative/agent-session 0.2.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,5 +1,13 @@
1
- import { TurnState } from "./turn-state";
2
- import { retainEvidence } from "./retained-evidence";
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
+
3
11
  // ---------------------------------------------------------------------------
4
12
  // ClaudeCodeSession — long-lived Claude Code process with full event capture
5
13
  // ---------------------------------------------------------------------------
@@ -34,26 +42,26 @@ import { retainEvidence } from "./retained-evidence";
34
42
  // transport. Normal messages go through stdin.
35
43
  // ---------------------------------------------------------------------------
36
44
 
45
+ import { parseClaudeUsage } from './claude-usage';
37
46
  import type {
38
47
  BeforeSendHook,
39
48
  HarnessSession,
49
+ NativeInterruptOutcome,
50
+ SessionArtifact,
40
51
  SessionEvent,
41
52
  SessionEventHandler,
42
53
  SessionResult,
43
- SessionArtifact,
44
54
  SessionSendOptions,
45
55
  SessionTokens,
46
- } from "./harness-session";
47
-
48
- import { parseClaudeUsage } from "./claude-usage";
56
+ } from './harness-session';
49
57
 
50
58
  // Re-export types so existing import paths keep working
51
59
  export type {
60
+ SessionArtifact,
52
61
  SessionEvent,
53
62
  SessionEventKind,
54
63
  SessionResult,
55
- SessionArtifact,
56
- } from "./harness-session";
64
+ } from './harness-session';
57
65
 
58
66
  // ---------------------------------------------------------------------------
59
67
  // Configuration
@@ -92,6 +100,19 @@ export interface ClaudeCodeSessionConfig {
92
100
  * Foundry thread that was previously mapped to this external ID.
93
101
  */
94
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;
95
116
  /**
96
117
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a
97
118
  * fake subprocess that emulates the claude CLI's stream-json protocol.
@@ -110,7 +131,7 @@ export interface ClaudeCodeSessionConfig {
110
131
  // ---------------------------------------------------------------------------
111
132
 
112
133
  interface QueuedTurn {
113
- onAdmission?: SessionSendOptions["onAdmission"];
134
+ onAdmission?: SessionSendOptions['onAdmission'];
114
135
  evidence: TurnState;
115
136
  message: string;
116
137
  timeout: number;
@@ -144,53 +165,105 @@ export interface PipedSubprocess {
144
165
  // fields never enter the public event. Reference-only output is NOT a success
145
166
  // claim; the consuming guard judges ownership and ordering.
146
167
  // ---------------------------------------------------------------------------
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 };
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 };
151
183
  if (content === undefined || content === null) return {};
152
184
  const omittedTypes: string[] = [];
153
185
  const omit = (type: unknown) => {
154
- const label = typeof type === "string" && KNOWN_NON_TEXT_RESULT_TYPES.has(type) ? type : "unsupported";
186
+ const label =
187
+ typeof type === 'string' && KNOWN_NON_TEXT_RESULT_TYPES.has(type) ? type : 'unsupported';
155
188
  if (!omittedTypes.includes(label)) omittedTypes.push(label);
156
189
  };
157
- if (!Array.isArray(content)) { omit(undefined); return { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) }; }
158
- const texts: string[] = [], references: string[] = [];
190
+ if (!Array.isArray(content)) {
191
+ omit(undefined);
192
+ return { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) };
193
+ }
194
+ const texts: string[] = [],
195
+ references: string[] = [];
159
196
  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);
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);
163
208
  else omit(b?.type);
164
209
  }
165
210
  return {
166
- toolOutput: texts.join("\n"),
211
+ toolOutput: texts.join('\n'),
167
212
  ...(references.length ? { toolReferences: Object.freeze([...references]) } : {}),
168
- ...(omittedTypes.length ? { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) } : {}),
213
+ ...(omittedTypes.length
214
+ ? { toolOutputOmitted: true, toolOutputOmittedTypes: Object.freeze(omittedTypes) }
215
+ : {}),
169
216
  };
170
217
  }
171
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
+
172
231
  export class ClaudeCodeSession implements HarnessSession {
232
+ get transport() {
233
+ return TRANSPORTS['claude-cli'];
234
+ }
173
235
  // -- Config --
174
236
  private _bin: string;
175
237
  private _model: string;
176
238
  private _effort?: string;
177
239
  private _cwd: string;
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(); }
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
+ }
181
245
  private _maxTurns: number | null;
182
246
  private _permissionMode: string;
183
247
  private _defaultTimeout: number;
184
248
  private _baseContext?: string;
185
249
  private _forking = false;
186
250
  private _awaitingForkIdentity = false;
187
- private _spawn?: ClaudeCodeSessionConfig["spawn"];
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
+ >();
188
261
 
189
262
  // -- Process --
190
263
  // Bun.spawn's return type is a union; we always use stdin:"pipe"/stdout:"pipe"/stderr:"pipe"
191
264
  // so we know the concrete types at runtime.
192
265
  private _proc: PipedSubprocess | null = null;
193
- private _stderr = "";
266
+ private _stderr = '';
194
267
 
195
268
  // -- Session state --
196
269
  /**
@@ -224,44 +297,68 @@ export class ClaudeCodeSession implements HarnessSession {
224
297
  private _attempts: TurnState[] = [];
225
298
  private _seenTerminals = new Set<string>();
226
299
  private _inflight: QueuedTurn | null = null;
227
- private _turnEvents: SessionEvent[] = [];
228
- private _resultText = "";
229
300
  private _turnTimer: ReturnType<typeof setTimeout> | null = null;
230
301
 
231
302
  constructor(config?: ClaudeCodeSessionConfig) {
232
- const bin = config?.bin ?? "claude";
233
- if (!/^[a-zA-Z0-9_.\/\\-]+$/.test(bin)) {
303
+ const bin = config?.bin ?? 'claude';
304
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
234
305
  throw new Error(`Invalid claude CLI binary path: "${bin}"`);
235
306
  }
236
307
  this._bin = bin;
237
- this._model = config?.model ?? "sonnet";
308
+ this._model = config?.model ?? 'sonnet';
238
309
  this._effort = config?.effort;
239
310
  this._cwd = config?.cwd ?? process.cwd();
240
311
  // 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)");
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)');
243
318
  }
244
319
  this._maxTurns = config?.maxTurns === undefined ? 25 : config.maxTurns;
245
- this._permissionMode = config?.permissionMode ?? "bypassPermissions";
320
+ this._permissionMode = config?.permissionMode ?? 'bypassPermissions';
246
321
  this._defaultTimeout = config?.timeout ?? 600_000;
247
322
  this._baseContext = config?.baseContext;
248
323
  this._externalSessionId = config?.externalSessionId;
249
324
  this._spawn = config?.spawn;
325
+ this._env = config?.env;
326
+ this._textOnly = config?.textOnly ?? false;
327
+ this._persistSession = config?.persistSession ?? true;
250
328
  this._startedAt = Date.now();
251
329
  }
252
330
 
331
+ /** Latest account limits observed on this session (stream or poll). */
332
+ get limits(): LimitSnapshot | undefined {
333
+ return this._limits;
334
+ }
335
+
253
336
  // ---------------------------------------------------------------------------
254
337
  // Accessors
255
338
  // ---------------------------------------------------------------------------
256
339
 
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 }) }); }
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
+ }
260
349
 
261
- get alive(): boolean { return this._alive; }
262
- get externalSessionId(): string | undefined { return this._externalSessionId; }
263
- get events(): readonly SessionEvent[] { return Object.freeze([...this._eventLog]); }
264
- get turns(): number { return this._turns; }
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
+ }
265
362
  get totalTokens(): Readonly<SessionTokens> {
266
363
  return { ...this._totalTokens };
267
364
  }
@@ -297,7 +394,7 @@ export class ClaudeCodeSession implements HarnessSession {
297
394
  */
298
395
  async push(payload: { kind: string; text: string }): Promise<void> {
299
396
  this._emit({
300
- kind: "error",
397
+ kind: 'error',
301
398
  timestamp: Date.now(),
302
399
  text: `push_ignored: kind=${payload.kind} — stream-json stdin has no OOB channel`,
303
400
  raw: payload,
@@ -309,34 +406,37 @@ export class ClaudeCodeSession implements HarnessSession {
309
406
  // ---------------------------------------------------------------------------
310
407
 
311
408
  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");
313
- 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');
314
412
 
315
413
  const args = this._buildSpawnArgs();
316
414
 
317
415
  // Strip API key env vars — CLI uses subscription auth
318
416
  const env: Record<string, string | undefined> = {
319
417
  ...process.env,
320
- DISABLE_AUTOUPDATER: "1",
418
+ ...this._env,
419
+ DISABLE_AUTOUPDATER: '1',
321
420
  };
322
421
  delete env.ANTHROPIC_API_KEY;
323
422
  delete env.ANTHROPIC_AUTH_TOKEN;
423
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
324
424
 
325
425
  if (this._spawn) {
326
426
  this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
327
427
  } else {
328
428
  this._proc = Bun.spawn([this._bin, ...args], {
329
429
  cwd: this._cwd,
330
- stdin: "pipe",
331
- stdout: "pipe",
332
- stderr: "pipe",
430
+ stdin: 'pipe',
431
+ stdout: 'pipe',
432
+ stderr: 'pipe',
333
433
  env,
334
434
  }) as unknown as PipedSubprocess;
335
435
  }
336
436
 
337
437
  this._alive = true;
338
438
  this._endEmitted = false;
339
- this._emit({ kind: "session_start", timestamp: Date.now() });
439
+ this._emit({ kind: 'session_start', timestamp: Date.now() });
340
440
 
341
441
  // Background readers — run for session lifetime (don't await)
342
442
  const proc = this._proc;
@@ -352,32 +452,43 @@ export class ClaudeCodeSession implements HarnessSession {
352
452
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
353
453
  : `Process exited with code ${code}`;
354
454
  this._rejectInflight(new Error(errMsg));
355
- this._rejectQueue(new Error("Session ended"));
356
- this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
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' });
357
458
  });
358
459
  }
359
460
 
461
+ private _rejectControl(error: Error): void {
462
+ for (const pending of this._control.values()) pending.reject(error);
463
+ this._control.clear();
464
+ }
465
+
360
466
  // ---------------------------------------------------------------------------
361
467
  // send() — write message to stdin, resolve on result event
362
468
  // ---------------------------------------------------------------------------
363
469
 
364
- async send(
365
- message: string,
366
- opts?: SessionSendOptions,
367
- ): Promise<SessionResult> {
470
+ async send(message: string, opts?: SessionSendOptions): Promise<SessionResult> {
368
471
  // Auto-restart after interrupt / process death (crash recovery via --resume).
369
472
  // _externalSessionId persists across process lifetimes, so start() will
370
473
  // 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");
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');
374
485
  }
375
486
  if (!this._proc && this._externalSessionId) {
376
487
  await this.start();
377
488
  }
378
489
 
379
- if (!this._proc) throw new Error("Session not started — call start() first");
380
- 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');
381
492
 
382
493
  const timeout = opts?.timeout ?? this._defaultTimeout;
383
494
 
@@ -390,10 +501,24 @@ export class ClaudeCodeSession implements HarnessSession {
390
501
  }
391
502
 
392
503
  return new Promise<SessionResult>((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;
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;
397
522
  }
398
523
 
399
524
  if (!this._inflight) {
@@ -408,15 +533,18 @@ export class ClaudeCodeSession implements HarnessSession {
408
533
  // fork() — branch from current conversation state
409
534
  // ---------------------------------------------------------------------------
410
535
 
411
- fork(opts?: { cwd?: string; baseContext?: string }): ClaudeCodeSession {
412
- if (this._inflight) throw new Error("Cannot fork while native ownership is unresolved");
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');
413
543
  if (!this._externalSessionId) {
414
- throw new Error(
415
- "Cannot fork — no external session ID yet (send at least one message first)",
416
- );
544
+ throw new Error('Cannot fork — no external session ID yet (send at least one message first)');
417
545
  }
418
546
 
419
- const forked = new ClaudeCodeSession({
547
+ const forked = this._construct({
420
548
  bin: this._bin,
421
549
  model: this._model,
422
550
  effort: this._effort,
@@ -426,12 +554,20 @@ export class ClaudeCodeSession implements HarnessSession {
426
554
  timeout: this._defaultTimeout,
427
555
  baseContext: opts?.baseContext ?? this._baseContext,
428
556
  externalSessionId: this._externalSessionId,
429
- spawn: this._spawn,
557
+ spawn: opts?.spawn ?? this._spawn,
558
+ env: this._env,
559
+ textOnly: this._textOnly,
560
+ persistSession: opts?.persistSession ?? this._persistSession,
430
561
  });
431
562
  forked._forking = true;
432
563
  return forked;
433
564
  }
434
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
+
435
571
  // ---------------------------------------------------------------------------
436
572
  // interrupt() — reject the local waiter; native cancellation is unacknowledged
437
573
  // ---------------------------------------------------------------------------
@@ -439,11 +575,90 @@ export class ClaudeCodeSession implements HarnessSession {
439
575
  interrupt(): void {
440
576
  if (!this._inflight) return;
441
577
 
442
- this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
578
+ this._rejectInflight(
579
+ new Error('Local waiter interrupted; native cancellation unacknowledged'),
580
+ 'interrupt-request',
581
+ );
443
582
 
444
583
  // Native ownership is retained. Waiting sends are rejected without dispatch.
445
584
  }
446
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
+ }
605
+
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
+ });
660
+ }
661
+
447
662
  // ---------------------------------------------------------------------------
448
663
  // kill() — terminate the session
449
664
  // ---------------------------------------------------------------------------
@@ -457,14 +672,23 @@ export class ClaudeCodeSession implements HarnessSession {
457
672
  this._turnTimer = null;
458
673
  }
459
674
 
460
- this._rejectInflight(new Error("Session killed; native outcome may remain unknown"), "killed");
461
- 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'));
462
678
 
463
- try { this._proc.stdin.end(); } catch { /* already closed */ }
464
- 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
+ }
465
689
  this._proc = null;
466
690
 
467
- this._emit({ kind: "session_end", timestamp: Date.now() });
691
+ this._emit({ kind: 'session_end', timestamp: Date.now() });
468
692
  }
469
693
 
470
694
  // ---------------------------------------------------------------------------
@@ -474,15 +698,17 @@ export class ClaudeCodeSession implements HarnessSession {
474
698
  artifact(): SessionArtifact {
475
699
  return {
476
700
  externalSessionId: this._externalSessionId,
477
- attempts: this.attempts, accounting: "observed-only", diagnostics: this.diagnostics,
701
+ attempts: this.attempts,
702
+ accounting: 'observed-only',
703
+ diagnostics: this.diagnostics,
478
704
  events: [...this._eventLog],
479
705
  startedAt: this._startedAt,
480
706
  endedAt: this._alive ? undefined : Date.now(),
481
707
  turns: this._turns,
482
708
  totalTokens: { ...this._totalTokens },
483
- toolCalls: this._eventLog.filter((e) => e.kind === "tool_use").length,
484
- toolResults: this._eventLog.filter((e) => e.kind === "tool_result").length,
485
- 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,
486
712
  };
487
713
  }
488
714
 
@@ -493,41 +719,59 @@ export class ClaudeCodeSession implements HarnessSession {
493
719
  private _dispatchTurn(turn: QueuedTurn): void {
494
720
  this._inflight = turn;
495
721
  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
- });
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
+ });
503
744
  return;
504
745
  }
505
746
  this._writeTurn(turn);
506
747
  }
507
748
 
508
749
  private _writeTurn(turn: QueuedTurn): void {
509
- turn.evidence.dispatch = "attempted";
510
- this._turnEvents = turn.evidence.events;
511
- this._resultText = "";
750
+ turn.evidence.dispatch = 'attempted';
512
751
 
513
752
  // Wire format validated empirically against claude 2.1.114:
514
753
  // {type:"user", message:{role,content:[{type:"text",text}]}}
515
754
  // Alternative shapes ({type:"user_message"}, {role,content}) are silently dropped.
516
- const payload = JSON.stringify({
517
- type: "user",
755
+ const payload = `${JSON.stringify({
756
+ type: 'user',
518
757
  message: {
519
- role: "user",
520
- content: [{ type: "text", text: turn.message }],
758
+ role: 'user',
759
+ content: [{ type: 'text', text: turn.message }],
521
760
  },
522
- }) + "\n";
523
- try { this._proc!.stdin.write(payload); this._proc!.stdin.flush(); }
524
- catch (err) { this._rejectInflight(err as Error); return; }
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
+ }
525
769
 
526
770
  // Timeout guard
527
771
  if (turn.timeout > 0) {
528
772
  this._turnTimer = setTimeout(() => {
529
773
  this._turnTimer = null;
530
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), "timeout");
774
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
531
775
  // Don't dispatch next — process may still be working on this turn.
532
776
  // A late terminal updates this admission; waiting sends are not replayed.
533
777
  }, turn.timeout);
@@ -543,10 +787,10 @@ export class ClaudeCodeSession implements HarnessSession {
543
787
  }
544
788
 
545
789
  const a = this._inflight.evidence;
546
- if (a.nativeOutcome === "unknown") return;
790
+ if (a.nativeOutcome === 'unknown') return;
547
791
  this._turns++;
548
- if (a.localOutcome === "pending") {
549
- a.localOutcome = "resolved";
792
+ if (a.localOutcome === 'pending') {
793
+ a.localOutcome = 'resolved';
550
794
  this._inflight.resolve(a.result(this._externalSessionId));
551
795
  }
552
796
  this._inflight = null;
@@ -561,25 +805,37 @@ export class ClaudeCodeSession implements HarnessSession {
561
805
  }
562
806
  }
563
807
 
564
- private _rejectInflight(err: Error, reason: import("./harness-session").SessionAttempt["localFailure"] = "transport"): void {
808
+ private _rejectInflight(
809
+ err: Error,
810
+ reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
811
+ ): void {
565
812
  if (!this._inflight) return;
566
- if (this._turnTimer) { clearTimeout(this._turnTimer); this._turnTimer = null; }
813
+ if (this._turnTimer) {
814
+ clearTimeout(this._turnTimer);
815
+ this._turnTimer = null;
816
+ }
567
817
  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"));
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
+ );
571
826
  }
572
827
 
573
828
  private _transportFailure(err: Error): void {
574
829
  this._alive = false;
575
830
  this._rejectInflight(err);
576
831
  this._rejectQueue(err);
577
- this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
832
+ this._rejectControl(err);
833
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
578
834
  }
579
835
 
580
836
  private _rejectQueue(err: Error): void {
581
837
  for (const turn of this._queue) {
582
- turn.reject(turn.evidence.fail(err, "blocked"));
838
+ turn.reject(turn.evidence.fail(err, 'blocked'));
583
839
  }
584
840
  this._queue = [];
585
841
  }
@@ -591,7 +847,7 @@ export class ClaudeCodeSession implements HarnessSession {
591
847
  private async _readStdout(proc: PipedSubprocess): Promise<void> {
592
848
  const reader = proc.stdout.getReader();
593
849
  const decoder = new TextDecoder();
594
- let buffer = "";
850
+ let buffer = '';
595
851
 
596
852
  try {
597
853
  while (true) {
@@ -599,7 +855,7 @@ export class ClaudeCodeSession implements HarnessSession {
599
855
  if (done) break;
600
856
 
601
857
  buffer += decoder.decode(value, { stream: true });
602
- const lines = buffer.split("\n");
858
+ const lines = buffer.split('\n');
603
859
  buffer = lines.pop()!; // Keep incomplete line in buffer
604
860
 
605
861
  for (const line of lines) {
@@ -612,7 +868,14 @@ export class ClaudeCodeSession implements HarnessSession {
612
868
  if (buffer.trim() && (this._proc === proc || !this._proc)) {
613
869
  this._processLine(buffer);
614
870
  }
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"));
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
+ );
616
879
  } catch (err) {
617
880
  if (this._proc === proc) this._transportFailure(err as Error);
618
881
  }
@@ -631,7 +894,9 @@ export class ClaudeCodeSession implements HarnessSession {
631
894
  if (done) break;
632
895
  this._stderr += decoder.decode(value, { stream: true });
633
896
  }
634
- } catch { /* ignore */ }
897
+ } catch {
898
+ /* ignore */
899
+ }
635
900
  }
636
901
 
637
902
  // ---------------------------------------------------------------------------
@@ -648,20 +913,71 @@ export class ClaudeCodeSession implements HarnessSession {
648
913
 
649
914
  const raw = msg as Record<string, unknown>;
650
915
 
651
- if (this._awaitingForkIdentity && raw.type === "system" && raw.subtype === "init" && typeof raw.session_id === "string") {
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
+ ) {
652
959
  this._externalSessionId = raw.session_id;
653
960
  this._awaitingForkIdentity = false;
654
961
  }
655
962
  // 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;
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;
658
976
  }
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; }
661
977
  // Capture the runtime's native session ID the first time we see it.
662
978
  // Subsequent messages echo the same ID; we keep our initial capture
663
979
  // (for resumed sessions, the config-supplied ID should match).
664
- if (typeof raw.session_id === "string" && !this._externalSessionId) {
980
+ if (typeof raw.session_id === 'string' && !this._externalSessionId) {
665
981
  this._externalSessionId = raw.session_id;
666
982
  }
667
983
 
@@ -671,57 +987,97 @@ export class ClaudeCodeSession implements HarnessSession {
671
987
  // compaction or restart. Only an explicit compact_boundary is a compaction, and
672
988
  // it is emitted exactly once per native uuid with this session's provenance.
673
989
  // 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;
990
+ if (raw.type === 'system' && raw.subtype === 'init') {
991
+ this._unattributed(raw, 'session-configuration');
992
+ return;
676
993
  }
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; }
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;
999
+ }
680
1000
  if (boundaryKey) this._seenBoundaries.add(boundaryKey);
681
1001
  this._emit({
682
- kind: "session_compact",
1002
+ kind: 'session_compact',
683
1003
  timestamp: Date.now(),
684
- correlation: "unknown",
685
- nativeSessionId: typeof raw.session_id === "string" ? raw.session_id : this._externalSessionId,
1004
+ correlation: 'unknown',
1005
+ nativeSessionId:
1006
+ typeof raw.session_id === 'string' ? raw.session_id : this._externalSessionId,
686
1007
  externalSessionId: this._externalSessionId,
687
- compactionSource: "claude-code",
1008
+ compactionSource: 'claude-code',
688
1009
  raw,
689
1010
  });
690
1011
  return;
691
1012
  }
692
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
+
693
1022
  const turn = this._inflight;
694
1023
  if (!turn) {
695
1024
  if (terminalKey) this._seenTerminals.add(terminalKey);
696
- this._unattributed(raw, "no-admission"); return;
1025
+ this._unattributed(raw, 'no-admission');
1026
+ return;
697
1027
  }
698
1028
  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";
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';
702
1036
  if (terminal) {
703
1037
  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;
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;
707
1044
  // Match Foundry's provider precedence. An optimistic subtype cannot erase
708
1045
  // 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 };
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
+ };
713
1059
  }
714
1060
  const message = raw.message as Record<string, unknown> | undefined;
715
1061
  const classified = this._classify(raw);
716
- if (!classified.length) this._unattributed(raw, "unrecognized-event");
1062
+ if (!classified.length) this._unattributed(raw, 'unrecognized-event');
717
1063
  for (const event of classified) {
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;
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
+ });
721
1069
  // Native result usage is authoritative for the whole turn. Request
722
1070
  // 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) {
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) {
725
1081
  const value = e.tokens[key];
726
1082
  if (value !== undefined) this._totalTokens[key] = (this._totalTokens[key] ?? 0) + value;
727
1083
  }
@@ -729,10 +1085,13 @@ export class ClaudeCodeSession implements HarnessSession {
729
1085
  this._emit(e);
730
1086
  }
731
1087
  if (terminal) {
732
- if (a.nativeOutcome !== "unknown") this._resolveTurn();
733
- else this._rejectInflight(new Error("Native result has no recognized terminal status"), "unrecognized-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
+ );
734
1094
  }
735
-
736
1095
  }
737
1096
 
738
1097
  // ---------------------------------------------------------------------------
@@ -743,24 +1102,31 @@ export class ClaudeCodeSession implements HarnessSession {
743
1102
  // --print + --input-format stream-json = multi-turn stream over stdin
744
1103
  // --output-format stream-json requires --verbose
745
1104
  const args: string[] = [
746
- "--print",
747
- "--verbose",
748
- "--input-format", "stream-json",
749
- "--output-format", "stream-json",
750
- "--model", this._model,
751
- ...(this._effort ? ["--effort", this._effort] : []),
752
- ...(this._maxTurns === null ? [] : ["--max-turns", String(this._maxTurns)]),
753
- "--permission-mode", this._permissionMode,
754
- "--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',
755
1118
  ];
756
1119
 
757
1120
  // Resume for fork or crash recovery. _externalSessionId is set from
758
1121
  // config (crash recovery / fork) or from the stream. Either way, if
759
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
+
760
1126
  if (this._externalSessionId) {
761
- args.push("--resume", this._externalSessionId);
1127
+ args.push('--resume', this._externalSessionId);
762
1128
  if (this._forking) {
763
- args.push("--fork-session");
1129
+ args.push('--fork-session');
764
1130
  this._awaitingForkIdentity = true;
765
1131
  this._forking = false;
766
1132
  }
@@ -768,7 +1134,7 @@ export class ClaudeCodeSession implements HarnessSession {
768
1134
 
769
1135
  // Stable base context injected once at process startup
770
1136
  if (this._baseContext) {
771
- args.push("--append-system-prompt", this._baseContext);
1137
+ args.push('--append-system-prompt', this._baseContext);
772
1138
  }
773
1139
 
774
1140
  return args;
@@ -784,13 +1150,13 @@ export class ClaudeCodeSession implements HarnessSession {
784
1150
 
785
1151
  const type = msg.type as string | undefined;
786
1152
 
787
- if (type === "assistant") {
1153
+ if (type === 'assistant') {
788
1154
  const message = msg.message as Record<string, unknown> | undefined;
789
1155
  const tokens = parseClaudeUsage(message?.usage);
790
1156
  if (tokens) {
791
1157
  // Preserve the envelope (message/request IDs, model and usage tags),
792
1158
  // including when a process dies before the terminal result arrives.
793
- events.push({ kind: "usage", timestamp: ts, tokens, raw: msg });
1159
+ events.push({ kind: 'usage', timestamp: ts, tokens, raw: msg });
794
1160
  }
795
1161
  const content = message?.content;
796
1162
  if (!Array.isArray(content)) return events;
@@ -798,56 +1164,58 @@ export class ClaudeCodeSession implements HarnessSession {
798
1164
  for (const block of content) {
799
1165
  const blockType = (block as Record<string, unknown>).type as string;
800
1166
 
801
- if (blockType === "text") {
802
- const text = (block as Record<string, unknown>).text as
803
- | string
804
- | undefined;
1167
+ if (blockType === 'text') {
1168
+ const text = (block as Record<string, unknown>).text as string | undefined;
805
1169
  if (text) {
806
- events.push({ kind: "text", timestamp: ts, text, raw: block });
1170
+ events.push({ kind: 'text', timestamp: ts, text, raw: block });
807
1171
  }
808
- } else if (blockType === "tool_use") {
1172
+ } else if (blockType === 'tool_use') {
809
1173
  events.push({
810
- kind: "tool_use",
1174
+ kind: 'tool_use',
811
1175
  timestamp: ts,
812
- callId: typeof block.id === "string" ? block.id : undefined,
813
- itemId: typeof block.id === "string" ? block.id : undefined,
1176
+ callId: typeof block.id === 'string' ? block.id : undefined,
1177
+ itemId: typeof block.id === 'string' ? block.id : undefined,
814
1178
  toolName: (block as Record<string, unknown>).name as string,
815
- toolInput: (block as Record<string, unknown>).input as Record<
816
- string,
817
- unknown
818
- >,
1179
+ toolInput: (block as Record<string, unknown>).input as Record<string, unknown>,
819
1180
  raw: block,
820
1181
  });
821
- } else if (blockType === "thinking") {
1182
+ } else if (blockType === 'thinking') {
822
1183
  const b = block as Record<string, unknown>;
823
1184
  events.push({
824
- kind: "thinking",
1185
+ kind: 'thinking',
825
1186
  timestamp: ts,
826
1187
  text: (b.thinking ?? b.text ?? b.content) as string | undefined,
827
1188
  raw: block,
828
1189
  });
829
1190
  }
830
1191
  }
831
- } else if (type === "user") {
1192
+ } else if (type === 'user') {
832
1193
  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
- }
838
- } else if (type === "tool") {
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') {
839
1207
  // Tool result — what the tool returned
840
1208
  const content = msg.content;
841
1209
  if (Array.isArray(content)) {
842
1210
  for (const block of content) {
843
1211
  const b = block as Record<string, unknown>;
844
1212
  events.push({
845
- kind: "tool_result",
1213
+ kind: 'tool_result',
846
1214
  timestamp: ts,
847
1215
  toolOutput:
848
- typeof b.text === "string"
1216
+ typeof b.text === 'string'
849
1217
  ? b.text
850
- : typeof b.content === "string"
1218
+ : typeof b.content === 'string'
851
1219
  ? b.content
852
1220
  : JSON.stringify(block),
853
1221
  toolError: b.is_error === true,
@@ -856,31 +1224,27 @@ export class ClaudeCodeSession implements HarnessSession {
856
1224
  }
857
1225
  } else if (content != null) {
858
1226
  events.push({
859
- kind: "tool_result",
1227
+ kind: 'tool_result',
860
1228
  timestamp: ts,
861
- toolOutput:
862
- typeof content === "string" ? content : JSON.stringify(content),
1229
+ toolOutput: typeof content === 'string' ? content : JSON.stringify(content),
863
1230
  raw: content,
864
1231
  });
865
1232
  }
866
- } else if (type === "result") {
1233
+ } else if (type === 'result') {
867
1234
  events.push({
868
- kind: "result",
1235
+ kind: 'result',
869
1236
  timestamp: ts,
870
- text: (msg.result as string) ?? "",
1237
+ text: (msg.result as string) ?? '',
871
1238
  externalSessionId: msg.session_id as string | undefined,
872
1239
  tokens: parseClaudeUsage(msg.usage),
873
1240
  raw: msg,
874
1241
  });
875
- } else if (type === "error") {
1242
+ } else if (type === 'error') {
876
1243
  const error = msg.error as Record<string, unknown> | undefined;
877
1244
  events.push({
878
- kind: "error",
1245
+ kind: 'error',
879
1246
  timestamp: ts,
880
- text:
881
- (error?.message as string) ??
882
- (msg.message as string) ??
883
- JSON.stringify(msg),
1247
+ text: (error?.message as string) ?? (msg.message as string) ?? JSON.stringify(msg),
884
1248
  raw: msg,
885
1249
  });
886
1250
  }
@@ -895,17 +1259,26 @@ export class ClaudeCodeSession implements HarnessSession {
895
1259
  // Private — emit
896
1260
  // ---------------------------------------------------------------------------
897
1261
 
898
- private _unattributed(raw: Record<string, unknown>, reason: NonNullable<SessionEvent["unattributedReason"]>): void {
1262
+ private _unattributed(
1263
+ raw: Record<string, unknown>,
1264
+ reason: NonNullable<SessionEvent['unattributedReason']>,
1265
+ ): void {
899
1266
  const message = raw.message as Record<string, unknown> | undefined;
900
1267
  // 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 });
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
+ });
904
1277
  }
905
1278
 
906
1279
  private _emit(event: SessionEvent): void {
907
1280
  event = retainEvidence(event);
908
- if (event.kind === "session_end") {
1281
+ if (event.kind === 'session_end') {
909
1282
  if (this._endEmitted) return;
910
1283
  this._endEmitted = true;
911
1284
  }
@@ -913,10 +1286,14 @@ export class ClaudeCodeSession implements HarnessSession {
913
1286
  for (const handler of this._handlers) {
914
1287
  try {
915
1288
  const observation: unknown = handler(event);
916
- if (observation && typeof (observation as PromiseLike<unknown>).then === "function") {
917
- void Promise.resolve(observation).catch(() => { this._observerFailures.asynchronous++; });
1289
+ if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
1290
+ void Promise.resolve(observation).catch(() => {
1291
+ this._observerFailures.asynchronous++;
1292
+ });
918
1293
  }
919
- } catch { this._observerFailures.synchronous++; }
1294
+ } catch {
1295
+ this._observerFailures.synchronous++;
1296
+ }
920
1297
  }
921
1298
  }
922
1299
  }