@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.
- package/README.md +77 -6
- package/package.json +10 -4
- package/src/claude-agent-sdk-session.ts +316 -0
- package/src/claude-code-session.ts +584 -207
- package/src/claude-primed.ts +655 -0
- package/src/claude-usage.ts +3 -4
- package/src/codex-primed.ts +1482 -0
- package/src/codex-session.ts +1080 -395
- package/src/harness-session.ts +82 -35
- package/src/index.ts +106 -12
- package/src/json-rpc.ts +240 -0
- package/src/limits-probe.ts +78 -0
- package/src/limits.ts +216 -0
- package/src/pool.ts +652 -0
- package/src/primed.ts +284 -0
- package/src/retained-evidence.ts +2 -2
- package/src/routing.ts +273 -0
- package/src/transport.ts +215 -0
- package/src/transports.ts +97 -0
- package/src/turn-state.ts +55 -25
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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([
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 =
|
|
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)) {
|
|
158
|
-
|
|
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 =
|
|
161
|
-
|
|
162
|
-
|
|
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(
|
|
211
|
+
toolOutput: texts.join('\n'),
|
|
167
212
|
...(references.length ? { toolReferences: Object.freeze([...references]) } : {}),
|
|
168
|
-
...(omittedTypes.length
|
|
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 =
|
|
179
|
-
readonly turnBudgetProtocol =
|
|
180
|
-
inspectAttempt(admissionId: string) {
|
|
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[
|
|
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 ??
|
|
233
|
-
if (!/^[a-zA-Z0-9_
|
|
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 ??
|
|
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 (
|
|
242
|
-
|
|
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 ??
|
|
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() {
|
|
258
|
-
|
|
259
|
-
|
|
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 {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
get
|
|
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:
|
|
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 ===
|
|
313
|
-
|
|
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
|
-
|
|
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:
|
|
331
|
-
stdout:
|
|
332
|
-
stderr:
|
|
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:
|
|
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(
|
|
356
|
-
this.
|
|
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 (
|
|
372
|
-
|
|
373
|
-
|
|
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(
|
|
380
|
-
if (!this._alive) throw new Error(
|
|
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();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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?: {
|
|
412
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
461
|
-
this._rejectQueue(new Error(
|
|
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 {
|
|
464
|
-
|
|
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:
|
|
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,
|
|
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 ===
|
|
484
|
-
toolResults: this._eventLog.filter((e) => e.kind ===
|
|
485
|
-
errors: this._eventLog.filter((e) => e.kind ===
|
|
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()
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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 =
|
|
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:
|
|
755
|
+
const payload = `${JSON.stringify({
|
|
756
|
+
type: 'user',
|
|
518
757
|
message: {
|
|
519
|
-
role:
|
|
520
|
-
content: [{ type:
|
|
758
|
+
role: 'user',
|
|
759
|
+
content: [{ type: 'text', text: turn.message }],
|
|
521
760
|
},
|
|
522
|
-
})
|
|
523
|
-
try {
|
|
524
|
-
|
|
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`),
|
|
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 ===
|
|
790
|
+
if (a.nativeOutcome === 'unknown') return;
|
|
547
791
|
this._turns++;
|
|
548
|
-
if (a.localOutcome ===
|
|
549
|
-
a.localOutcome =
|
|
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(
|
|
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) {
|
|
813
|
+
if (this._turnTimer) {
|
|
814
|
+
clearTimeout(this._turnTimer);
|
|
815
|
+
this._turnTimer = null;
|
|
816
|
+
}
|
|
567
817
|
const a = this._inflight.evidence;
|
|
568
|
-
if ((reason ===
|
|
569
|
-
|
|
570
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
657
|
-
|
|
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 ===
|
|
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 ===
|
|
675
|
-
this._unattributed(raw,
|
|
990
|
+
if (raw.type === 'system' && raw.subtype === 'init') {
|
|
991
|
+
this._unattributed(raw, 'session-configuration');
|
|
992
|
+
return;
|
|
676
993
|
}
|
|
677
|
-
if (raw.type ===
|
|
678
|
-
const boundaryKey = typeof raw.uuid ===
|
|
679
|
-
if (boundaryKey && this._seenBoundaries.has(boundaryKey)) {
|
|
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:
|
|
1002
|
+
kind: 'session_compact',
|
|
683
1003
|
timestamp: Date.now(),
|
|
684
|
-
correlation:
|
|
685
|
-
nativeSessionId:
|
|
1004
|
+
correlation: 'unknown',
|
|
1005
|
+
nativeSessionId:
|
|
1006
|
+
typeof raw.session_id === 'string' ? raw.session_id : this._externalSessionId,
|
|
686
1007
|
externalSessionId: this._externalSessionId,
|
|
687
|
-
compactionSource:
|
|
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,
|
|
1025
|
+
this._unattributed(raw, 'no-admission');
|
|
1026
|
+
return;
|
|
697
1027
|
}
|
|
698
1028
|
const a = turn.evidence;
|
|
699
|
-
a.identity = {
|
|
700
|
-
|
|
701
|
-
|
|
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 ===
|
|
705
|
-
const reason = typeof raw.terminal_reason ===
|
|
706
|
-
const status =
|
|
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 =
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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,
|
|
1062
|
+
if (!classified.length) this._unattributed(raw, 'unrecognized-event');
|
|
717
1063
|
for (const event of classified) {
|
|
718
|
-
const e = a.record({
|
|
719
|
-
...
|
|
720
|
-
|
|
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 ===
|
|
724
|
-
for (const key of [
|
|
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 !==
|
|
733
|
-
else
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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(
|
|
1127
|
+
args.push('--resume', this._externalSessionId);
|
|
762
1128
|
if (this._forking) {
|
|
763
|
-
args.push(
|
|
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(
|
|
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 ===
|
|
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:
|
|
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 ===
|
|
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:
|
|
1170
|
+
events.push({ kind: 'text', timestamp: ts, text, raw: block });
|
|
807
1171
|
}
|
|
808
|
-
} else if (blockType ===
|
|
1172
|
+
} else if (blockType === 'tool_use') {
|
|
809
1173
|
events.push({
|
|
810
|
-
kind:
|
|
1174
|
+
kind: 'tool_use',
|
|
811
1175
|
timestamp: ts,
|
|
812
|
-
callId: typeof block.id ===
|
|
813
|
-
itemId: typeof block.id ===
|
|
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 ===
|
|
1182
|
+
} else if (blockType === 'thinking') {
|
|
822
1183
|
const b = block as Record<string, unknown>;
|
|
823
1184
|
events.push({
|
|
824
|
-
kind:
|
|
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 ===
|
|
1192
|
+
} else if (type === 'user') {
|
|
832
1193
|
const message = msg.message as Record<string, unknown> | undefined;
|
|
833
|
-
if (Array.isArray(message?.content))
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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:
|
|
1213
|
+
kind: 'tool_result',
|
|
846
1214
|
timestamp: ts,
|
|
847
1215
|
toolOutput:
|
|
848
|
-
typeof b.text ===
|
|
1216
|
+
typeof b.text === 'string'
|
|
849
1217
|
? b.text
|
|
850
|
-
: typeof b.content ===
|
|
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:
|
|
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 ===
|
|
1233
|
+
} else if (type === 'result') {
|
|
867
1234
|
events.push({
|
|
868
|
-
kind:
|
|
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 ===
|
|
1242
|
+
} else if (type === 'error') {
|
|
876
1243
|
const error = msg.error as Record<string, unknown> | undefined;
|
|
877
1244
|
events.push({
|
|
878
|
-
kind:
|
|
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(
|
|
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({
|
|
902
|
-
|
|
903
|
-
|
|
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 ===
|
|
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 ===
|
|
917
|
-
void Promise.resolve(observation).catch(() => {
|
|
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 {
|
|
1294
|
+
} catch {
|
|
1295
|
+
this._observerFailures.synchronous++;
|
|
1296
|
+
}
|
|
920
1297
|
}
|
|
921
1298
|
}
|
|
922
1299
|
}
|