@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.
- package/LICENSE +21 -0
- package/README.md +22 -1
- package/package.json +32 -7
- package/src/claude-code-session.ts +252 -109
- package/src/claude-usage.ts +30 -0
- package/src/codex-session.ts +521 -130
- package/src/harness-session.ts +121 -10
- package/src/index.ts +2 -0
- package/src/retained-evidence.ts +20 -0
- package/src/turn-state.ts +42 -0
|
@@ -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
|
-
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* the
|
|
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
|
|
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
|
-
|
|
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<
|
|
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.
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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() —
|
|
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("
|
|
442
|
+
this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
|
|
367
443
|
|
|
368
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
}
|