@inixiative/agent-session 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,6 @@
1
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import {
2
5
  claudeRateLimitSnapshot,
3
6
  claudeUsageSnapshot,
@@ -5,6 +8,7 @@ import {
5
8
  mergeLimits,
6
9
  } from './limits';
7
10
  import { retainEvidence } from './retained-evidence';
11
+ import { mcpToolParts, ToolGates } from './tool-gate';
8
12
  import { TRANSPORTS } from './transport';
9
13
  import { TurnState } from './turn-state';
10
14
 
@@ -53,6 +57,9 @@ import type {
53
57
  SessionResult,
54
58
  SessionSendOptions,
55
59
  SessionTokens,
60
+ ToolDecision,
61
+ ToolGate,
62
+ ToolRequest,
56
63
  } from './harness-session';
57
64
 
58
65
  // Re-export types so existing import paths keep working
@@ -88,7 +95,8 @@ export interface ClaudeCodeSessionConfig {
88
95
  /**
89
96
  * Base context to pre-load at session startup.
90
97
  *
91
- * Injected via --append-system-prompt on process spawn. Persists for the
98
+ * Injected via --append-system-prompt-file on process spawn (a private 0600 file, removed when the
99
+ * process ends), so a base past Linux's 128 KiB per-argument limit still starts. Persists for the
92
100
  * entire session lifetime. Include all stable context here (system,
93
101
  * conventions, memory, architecture) so per-message delta is minimal.
94
102
  */
@@ -113,6 +121,14 @@ export interface ClaudeCodeSessionConfig {
113
121
  textOnly?: boolean;
114
122
  /** False adds `--no-session-persistence` (nothing written to history; cannot be resumed). Default true. */
115
123
  persistSession?: boolean;
124
+ /**
125
+ * Launch for per-call tool decisions: `--permission-prompt-tool stdio` plus a PreToolUse
126
+ * callback registered over the control protocol at start(). Gates added with
127
+ * onToolRequest() decide (a `permission` ask with no gate is denied). A callback or ask that
128
+ * cannot be read as a tool request is denied (fail closed). A turn timeout leaves a pending
129
+ * ask open; interruptNative() withdraws it. Not with textOnly.
130
+ */
131
+ toolGate?: boolean;
116
132
  /**
117
133
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a
118
134
  * fake subprocess that emulates the claude CLI's stream-json protocol.
@@ -246,12 +262,19 @@ export class ClaudeCodeSession implements HarnessSession {
246
262
  private _permissionMode: string;
247
263
  private _defaultTimeout: number;
248
264
  private _baseContext?: string;
265
+ /** The private directory holding the base context file while a process runs. */
266
+ private _baseContextDir?: string;
249
267
  private _forking = false;
250
268
  private _awaitingForkIdentity = false;
251
269
  private _spawn?: ClaudeCodeSessionConfig['spawn'];
252
270
  private _env?: ClaudeCodeSessionConfig['env'];
253
271
  private _textOnly: boolean;
254
272
  private _persistSession: boolean;
273
+ private _toolGate: boolean;
274
+ private _gates = new ToolGates();
275
+ /** Inbound control requests awaiting a gate decision, by request id. */
276
+ private _inbound = new Map<string, AbortController>();
277
+ private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
255
278
  private _limits?: LimitSnapshot;
256
279
  private _controlSeq = 0;
257
280
  private _control = new Map<
@@ -325,6 +348,9 @@ export class ClaudeCodeSession implements HarnessSession {
325
348
  this._env = config?.env;
326
349
  this._textOnly = config?.textOnly ?? false;
327
350
  this._persistSession = config?.persistSession ?? true;
351
+ this._toolGate = config?.toolGate ?? false;
352
+ if (this._toolGate && this._textOnly)
353
+ throw new Error('toolGate requires a session with tools; textOnly sessions have none');
328
354
  this._startedAt = Date.now();
329
355
  }
330
356
 
@@ -375,6 +401,12 @@ export class ClaudeCodeSession implements HarnessSession {
375
401
  };
376
402
  }
377
403
 
404
+ /** Register a tool gate. A fork gets a snapshot of the gates registered at fork() time. */
405
+ onToolRequest(gate: ToolGate): () => void {
406
+ if (!this._toolGate) throw new Error('Tool gates need a session launched with toolGate: true');
407
+ return this._gates.add(gate);
408
+ }
409
+
378
410
  onBeforeSend(hook: BeforeSendHook): () => void {
379
411
  this._beforeSendHooks.push(hook);
380
412
  return () => {
@@ -422,16 +454,21 @@ export class ClaudeCodeSession implements HarnessSession {
422
454
  delete env.ANTHROPIC_AUTH_TOKEN;
423
455
  for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
424
456
 
425
- if (this._spawn) {
426
- this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
427
- } else {
428
- this._proc = Bun.spawn([this._bin, ...args], {
429
- cwd: this._cwd,
430
- stdin: 'pipe',
431
- stdout: 'pipe',
432
- stderr: 'pipe',
433
- env,
434
- }) as unknown as PipedSubprocess;
457
+ try {
458
+ if (this._spawn) {
459
+ this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
460
+ } else {
461
+ this._proc = Bun.spawn([this._bin, ...args], {
462
+ cwd: this._cwd,
463
+ stdin: 'pipe',
464
+ stdout: 'pipe',
465
+ stderr: 'pipe',
466
+ env,
467
+ }) as unknown as PipedSubprocess;
468
+ }
469
+ } catch (error) {
470
+ this._removeBaseContext();
471
+ throw error;
435
472
  }
436
473
 
437
474
  this._alive = true;
@@ -444,7 +481,10 @@ export class ClaudeCodeSession implements HarnessSession {
444
481
  this._readStderr();
445
482
 
446
483
  // Monitor process exit for cleanup
484
+ // The process read its base context file at startup; it goes when the process does.
485
+ const baseDir = this._baseContextDir;
447
486
  proc.exited.then(async (code) => {
487
+ this._removeBaseContext(baseDir);
448
488
  if (this._proc !== proc || !this._alive) return;
449
489
  this._alive = false;
450
490
  await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
@@ -456,11 +496,30 @@ export class ClaudeCodeSession implements HarnessSession {
456
496
  this._rejectControl(new Error('Session ended'));
457
497
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
458
498
  });
499
+
500
+ // The PreToolUse callback is registered before any turn is written, so it covers the first call.
501
+ if (this._toolGate) {
502
+ try {
503
+ await this._controlRequest(
504
+ {
505
+ subtype: 'initialize',
506
+ hooks: { PreToolUse: [{ matcher: '.*', hookCallbackIds: [this._hookCallbackId] }] },
507
+ },
508
+ 30_000,
509
+ );
510
+ } catch (error) {
511
+ this.kill();
512
+ throw new Error(`Tool gate registration failed: ${(error as Error).message}`);
513
+ }
514
+ }
459
515
  }
460
516
 
461
517
  private _rejectControl(error: Error): void {
462
518
  for (const pending of this._control.values()) pending.reject(error);
463
519
  this._control.clear();
520
+ // Decisions still pending have nowhere to go: withdraw them.
521
+ for (const inbound of this._inbound.values()) inbound.abort();
522
+ this._inbound.clear();
464
523
  }
465
524
 
466
525
  // ---------------------------------------------------------------------------
@@ -530,7 +589,7 @@ export class ClaudeCodeSession implements HarnessSession {
530
589
  }
531
590
 
532
591
  // ---------------------------------------------------------------------------
533
- // fork() — branch from current conversation state
592
+ // fork() — branch from current conversation state (and a snapshot of the tool gates)
534
593
  // ---------------------------------------------------------------------------
535
594
 
536
595
  fork(opts?: {
@@ -558,7 +617,9 @@ export class ClaudeCodeSession implements HarnessSession {
558
617
  env: this._env,
559
618
  textOnly: this._textOnly,
560
619
  persistSession: opts?.persistSession ?? this._persistSession,
620
+ toolGate: this._toolGate,
561
621
  });
622
+ for (const gate of this._gates.list()) forked._gates.add(gate);
562
623
  forked._forking = true;
563
624
  return forked;
564
625
  }
@@ -687,6 +748,7 @@ export class ClaudeCodeSession implements HarnessSession {
687
748
  /* already dead */
688
749
  }
689
750
  this._proc = null;
751
+ this._removeBaseContext();
690
752
 
691
753
  this._emit({ kind: 'session_end', timestamp: Date.now() });
692
754
  }
@@ -930,9 +992,32 @@ export class ClaudeCodeSession implements HarnessSession {
930
992
  }
931
993
  return;
932
994
  }
995
+ if (raw.type === 'control_cancel_request') {
996
+ const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
997
+ const inbound = id ? this._inbound.get(id) : undefined;
998
+ if (inbound && id) {
999
+ this._inbound.delete(id);
1000
+ inbound.abort();
1001
+ }
1002
+ return;
1003
+ }
933
1004
  if (raw.type === 'control_request') {
934
- // No client-side handlers (can_use_tool, hooks) are registered on this transport.
935
1005
  const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
1006
+ const request = raw.request as Record<string, unknown> | undefined;
1007
+ const toolRequest =
1008
+ id && this._toolGate && this._proc ? this._toolRequestFrom(request) : undefined;
1009
+ if (id && toolRequest) {
1010
+ this._answerToolRequest(this._proc!, id, toolRequest);
1011
+ return;
1012
+ }
1013
+ // A gated ask or our own callback that cannot be read is denied: an error reply allows.
1014
+ const unreadable = id && this._toolGate && this._proc ? this._unreadable(request) : undefined;
1015
+ if (id && unreadable) {
1016
+ this._writeControlResponse(this._proc!, id, unreadable);
1017
+ this._unattributed(raw, 'unrecognized-event');
1018
+ return;
1019
+ }
1020
+ // No other client-side handlers are registered on this transport.
936
1021
  if (id && this._proc) {
937
1022
  try {
938
1023
  this._proc.stdin.write(
@@ -1094,6 +1179,129 @@ export class ClaudeCodeSession implements HarnessSession {
1094
1179
  }
1095
1180
  }
1096
1181
 
1182
+ // ---------------------------------------------------------------------------
1183
+ // Private — tool gate (control protocol: hook_callback, can_use_tool)
1184
+ // ---------------------------------------------------------------------------
1185
+
1186
+ private _toolRequestFrom(
1187
+ request: Record<string, unknown> | undefined,
1188
+ ): Omit<ToolRequest, 'signal'> | undefined {
1189
+ const record = (value: unknown) =>
1190
+ value && typeof value === 'object' && !Array.isArray(value)
1191
+ ? (value as Record<string, unknown>)
1192
+ : undefined;
1193
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1194
+ let stage: ToolRequest['stage'];
1195
+ let source: Record<string, unknown> | undefined;
1196
+ let toolName: string | undefined;
1197
+ let toolInput: Record<string, unknown> | undefined;
1198
+ if (request && this._ownHookCallback(request)) {
1199
+ source = record(request.input);
1200
+ if (source?.hook_event_name !== 'PreToolUse') return undefined;
1201
+ stage = 'pre-use';
1202
+ toolName = text(source.tool_name);
1203
+ toolInput = record(source.tool_input);
1204
+ } else if (request?.subtype === 'can_use_tool') {
1205
+ source = request;
1206
+ stage = 'permission';
1207
+ toolName = text(request.tool_name);
1208
+ toolInput = record(request.input);
1209
+ } else return undefined;
1210
+ if (!toolName) return undefined;
1211
+ const callId = text(source?.tool_use_id) ?? text(request.tool_use_id);
1212
+ const agentId = text(source?.agent_id) ?? text(request.agent_id);
1213
+ const turn = this._inflight;
1214
+ return {
1215
+ stage,
1216
+ toolName,
1217
+ ...mcpToolParts(toolName),
1218
+ toolInput: Object.freeze(structuredClone(toolInput ?? {})),
1219
+ ...(callId ? { callId } : {}),
1220
+ ...(turn?.evidence.dispatch === 'attempted' && turn.evidence.nativeOutcome === 'unknown'
1221
+ ? { admissionId: turn.evidence.admissionId }
1222
+ : {}),
1223
+ ...(text(request.description) ? { description: text(request.description) } : {}),
1224
+ ...(text(request.decision_reason) ? { decisionReason: text(request.decision_reason) } : {}),
1225
+ ...(text(request.blocked_path) ? { blockedPath: text(request.blocked_path) } : {}),
1226
+ ...(agentId ? { agentId } : {}),
1227
+ };
1228
+ }
1229
+
1230
+ private _ownHookCallback(request: Record<string, unknown> | undefined): boolean {
1231
+ return request?.subtype === 'hook_callback' && request.callback_id === this._hookCallbackId;
1232
+ }
1233
+
1234
+ /** The deny for a gated request _toolRequestFrom could not read; undefined when not ours. */
1235
+ private _unreadable(
1236
+ request: Record<string, unknown> | undefined,
1237
+ ): Record<string, unknown> | undefined {
1238
+ if (this._ownHookCallback(request))
1239
+ return this._toolResponse(
1240
+ { stage: 'pre-use', toolName: '', toolInput: {} },
1241
+ { behavior: 'deny', message: 'Tool gate: unreadable PreToolUse callback' },
1242
+ );
1243
+ if (request?.subtype === 'can_use_tool')
1244
+ return { behavior: 'deny', message: 'Tool gate: unreadable permission request' };
1245
+ return undefined;
1246
+ }
1247
+
1248
+ private _answerToolRequest(
1249
+ proc: PipedSubprocess,
1250
+ requestId: string,
1251
+ request: Omit<ToolRequest, 'signal'>,
1252
+ ): void {
1253
+ const controller = new AbortController();
1254
+ this._inbound.set(requestId, controller);
1255
+ void this._gates
1256
+ .decide({ ...request, signal: controller.signal })
1257
+ .then((decision) => {
1258
+ // Withdrawn by the runtime, or the process is gone: nothing may be written for it.
1259
+ if (controller.signal.aborted || this._proc !== proc || !this._alive) return;
1260
+ this._inbound.delete(requestId);
1261
+ this._writeControlResponse(proc, requestId, this._toolResponse(request, decision));
1262
+ })
1263
+ .finally(() => {
1264
+ if (this._inbound.get(requestId) === controller) this._inbound.delete(requestId);
1265
+ });
1266
+ }
1267
+
1268
+ private _toolResponse(
1269
+ request: Omit<ToolRequest, 'signal'>,
1270
+ decision: ToolDecision | undefined,
1271
+ ): Record<string, unknown> {
1272
+ if (request.stage === 'pre-use')
1273
+ return decision?.behavior === 'deny'
1274
+ ? {
1275
+ hookSpecificOutput: {
1276
+ hookEventName: 'PreToolUse',
1277
+ permissionDecision: 'deny',
1278
+ permissionDecisionReason: decision.message,
1279
+ },
1280
+ }
1281
+ : {};
1282
+ return decision?.behavior === 'allow'
1283
+ ? { behavior: 'allow', updatedInput: structuredClone(request.toolInput) }
1284
+ : { behavior: 'deny', message: decision?.message ?? 'Denied' };
1285
+ }
1286
+
1287
+ private _writeControlResponse(
1288
+ proc: PipedSubprocess,
1289
+ requestId: string,
1290
+ response: Record<string, unknown>,
1291
+ ): void {
1292
+ try {
1293
+ proc.stdin.write(
1294
+ `${JSON.stringify({
1295
+ type: 'control_response',
1296
+ response: { subtype: 'success', request_id: requestId, response },
1297
+ })}\n`,
1298
+ );
1299
+ proc.stdin.flush();
1300
+ } catch {
1301
+ /* transport failure surfaces on the stream */
1302
+ }
1303
+ }
1304
+
1097
1305
  // ---------------------------------------------------------------------------
1098
1306
  // Private — spawn args (called once at start())
1099
1307
  // ---------------------------------------------------------------------------
@@ -1115,6 +1323,7 @@ export class ClaudeCodeSession implements HarnessSession {
1115
1323
  '--permission-mode',
1116
1324
  this._permissionMode,
1117
1325
  '--include-hook-events',
1326
+ ...(this._toolGate ? ['--permission-prompt-tool', 'stdio'] : []),
1118
1327
  ];
1119
1328
 
1120
1329
  // Resume for fork or crash recovery. _externalSessionId is set from
@@ -1134,12 +1343,32 @@ export class ClaudeCodeSession implements HarnessSession {
1134
1343
 
1135
1344
  // Stable base context injected once at process startup
1136
1345
  if (this._baseContext) {
1137
- args.push('--append-system-prompt', this._baseContext);
1346
+ args.push('--append-system-prompt-file', this._writeBaseContext(this._baseContext));
1138
1347
  }
1139
1348
 
1140
1349
  return args;
1141
1350
  }
1142
1351
 
1352
+ /** The base context as a private file: argv carries its path, never its text. */
1353
+ private _writeBaseContext(text: string): string {
1354
+ this._removeBaseContext();
1355
+ if (!swept) {
1356
+ swept = true;
1357
+ sweepOrphanedBaseContexts();
1358
+ }
1359
+ this._baseContextDir = mkdtempSync(join(tmpdir(), `${BASE_CONTEXT_PREFIX}${process.pid}-`));
1360
+ const file = join(this._baseContextDir, 'append-system-prompt.md');
1361
+ writeFileSync(file, text, { mode: 0o600 });
1362
+ return file;
1363
+ }
1364
+
1365
+ /** Remove a spawn's base context directory: the current one by default. */
1366
+ private _removeBaseContext(dir = this._baseContextDir): void {
1367
+ if (!dir) return;
1368
+ rmSync(dir, { recursive: true, force: true });
1369
+ if (dir === this._baseContextDir) this._baseContextDir = undefined;
1370
+ }
1371
+
1143
1372
  // ---------------------------------------------------------------------------
1144
1373
  // Private — event classification
1145
1374
  // ---------------------------------------------------------------------------
@@ -1297,3 +1526,35 @@ export class ClaudeCodeSession implements HarnessSession {
1297
1526
  }
1298
1527
  }
1299
1528
  }
1529
+
1530
+ const BASE_CONTEXT_PREFIX = 'agent-session-base-';
1531
+ let swept = false;
1532
+
1533
+ /**
1534
+ * Remove base context files a crashed or killed process left behind (each holds a whole composed
1535
+ * context). Runs once per process; a directory belongs to the pid in its name and goes only when
1536
+ * that process is gone.
1537
+ */
1538
+ export function sweepOrphanedBaseContexts(dir = tmpdir()): void {
1539
+ let entries: string[];
1540
+ try {
1541
+ entries = readdirSync(dir);
1542
+ } catch {
1543
+ return;
1544
+ }
1545
+ for (const entry of entries) {
1546
+ const pid = Number(new RegExp(`^${BASE_CONTEXT_PREFIX}(\\d+)-`).exec(entry)?.[1]);
1547
+ if (!pid || pid === process.pid || processAlive(pid)) continue;
1548
+ rmSync(join(dir, entry), { recursive: true, force: true });
1549
+ }
1550
+ }
1551
+
1552
+ function processAlive(pid: number): boolean {
1553
+ try {
1554
+ process.kill(pid, 0);
1555
+ return true;
1556
+ } catch (error) {
1557
+ // EPERM: it exists under another user.
1558
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
1559
+ }
1560
+ }