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