@inixiative/agent-session 0.6.5 → 0.6.6

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 CHANGED
@@ -164,6 +164,47 @@ a pre-spawned spare. Measured on a ChatGPT login: warm Codex decisions 2.45 s
164
164
  median vs 3.9 s for `codex exec` per decision, with 34% fewer input tokens; warm
165
165
  Claude (haiku) decisions 1.95 s with the whole primed prefix read from cache.
166
166
 
167
+ ### In the main session's own process (Claude)
168
+
169
+ `ClaudeInSessionDecisions` holds decision roles as subagents of a main
170
+ `ClaudeCodeSession`, so a thread and all its decision roles are one process:
171
+
172
+ ```ts
173
+ const decisions = new ClaudeInSessionDecisions({ model: "claude-haiku-5-5", effort: "low" });
174
+ const session = new ClaudeCodeSession({ model: "claude-opus-5-5", cwd, toolGate: true });
175
+ session.extend(decisions); // before start(): agents, an SDK MCP server, a PreToolUse claim
176
+ await session.start(); // one internal setup turn starts the root agent
177
+ decisions.prestart(threadId, roles.map((spec) => ({ spec, standby })));
178
+ await decisions.ready();
179
+ const result = await decisions.decide(spec, { input, standby });
180
+ ```
181
+
182
+ - **Shape.** The setup turn asks the main model to start `foundry-root` in the
183
+ background. The root never ends: it starts dispatcher generations
184
+ (`foundry-dispatch`), which start members (`foundry-warden`), all in the
185
+ background, through a `next` tool the host holds open. A member holds the
186
+ host's `moment` tool until a decision answers it, and answers through
187
+ `answer`. After setup, no member activity needs a main-model turn.
188
+ - **Pinned input.** Every spawn passes the session's PreToolUse callback,
189
+ which replaces its prompt with the composed text, so agent definitions are
190
+ generic and an edited role reaches the next spare without a relaunch.
191
+ Spawns of these agent types from anywhere else are denied; the main agent's
192
+ own subagents go to the tool gate untouched.
193
+ - **Fresh per call.** Each member serves one decision. A used one is replaced
194
+ at once; a role with no held spare starts a member with its input inline.
195
+ - **Recycle.** A dispatcher's context grows ~700 tokens per member started;
196
+ past `recycleTokens` (100k) a new generation takes over and the old one
197
+ ends into the root once empty.
198
+ - **Orphans.** A nested agent whose parent ended reports its own end to the
199
+ main thread, which wakes the main model (Claude Code 2.1.300). Members of a
200
+ dispatcher that ended unasked are therefore never retired or stopped, and an
201
+ orphan's `answer` call is held for the session's life. Past `maxOrphans`
202
+ (40) the host stops serving and its caller falls back.
203
+ - **Limits.** Claude Code runs 20 subagents at once by default; the launch
204
+ raises `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` (`maxSubagents`, 96). The
205
+ session declares `perTaskStopAffordance`, so the user's Stop spares
206
+ background agents.
207
+
167
208
  ## Pools, routing and continuity
168
209
 
169
210
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@biomejs/biome": "2.5.2",
43
- "@inixiative/config": "^0.5.83",
43
+ "@inixiative/config": "^0.5.88",
44
44
  "@types/bun": "1.4.2",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
@@ -143,6 +143,66 @@ export interface ClaudeCodeSessionConfig {
143
143
  ) => PipedSubprocess;
144
144
  }
145
145
 
146
+ // ---------------------------------------------------------------------------
147
+ // Extension: work a host runs inside this session's own process (ClaudeInSessionDecisions)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** A custom agent definition (`--agents`), as Claude Code reads it. */
151
+ export interface ClaudeAgentDefinition {
152
+ readonly description: string;
153
+ readonly prompt: string;
154
+ readonly tools: readonly string[];
155
+ readonly model?: string;
156
+ readonly effort?: string;
157
+ readonly permissionMode?: string;
158
+ readonly omitClaudeMd?: boolean;
159
+ readonly background?: boolean;
160
+ }
161
+
162
+ /** What the session offers an extension once its process is up. */
163
+ export interface ClaudeSessionHost {
164
+ /** A control request to the CLI (e.g. `stop_task`). */
165
+ control(request: Record<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>>;
166
+ /** Answer an MCP message the extension was handed. */
167
+ mcpReply(requestId: string, message: unknown): void;
168
+ /**
169
+ * A turn of the extension's own, never a caller's: queued like any send, and nothing of it is
170
+ * attributed to a caller's admission. No deadline: its native outcome must settle.
171
+ */
172
+ sendInternal(message: string): Promise<SessionResult>;
173
+ alive(): boolean;
174
+ }
175
+
176
+ /**
177
+ * Work that runs inside this session's process (agents, an SDK MCP server, a PreToolUse claim on
178
+ * its own tool calls), never through vendor hook files. Attached before `start()`.
179
+ */
180
+ export interface ClaudeSessionExtension {
181
+ /** Agent definitions added to the launch. */
182
+ readonly agents: Readonly<Record<string, ClaudeAgentDefinition>>;
183
+ /** SDK MCP servers it answers (declared in `initialize`). */
184
+ readonly mcpServers: readonly string[];
185
+ /** Environment its agents need on the launch (merged over the session's own). */
186
+ readonly env?: Readonly<Record<string, string>>;
187
+ /** The process runs (before `initialize`): it may answer MCP messages from now on. */
188
+ attach(host: ClaudeSessionHost): void;
189
+ /** `initialize` was answered: it may write turns and control requests. */
190
+ started(): void;
191
+ /** An MCP message for one of its servers; answered through `host.mcpReply`. */
192
+ mcp(server: string, requestId: string, message: Record<string, unknown>): void;
193
+ /**
194
+ * A PreToolUse callback: the hook output when the call is its own (its agents' calls, a spawn of
195
+ * one of its agent types), else undefined and the tool gate decides.
196
+ */
197
+ preToolUse(input: Record<string, unknown>): Record<string, unknown> | undefined;
198
+ /** A permission ask (`can_use_tool`): its answer when the asking agent is its own, else undefined. */
199
+ permission(request: Record<string, unknown>): Record<string, unknown> | undefined;
200
+ /** A native frame: true when it is its own (never emitted, never part of an admission). */
201
+ frame(raw: Record<string, unknown>): boolean;
202
+ /** The process ended or was killed. */
203
+ detach(reason: string): void;
204
+ }
205
+
146
206
  // ---------------------------------------------------------------------------
147
207
  // Internal turn queue entry
148
208
  // ---------------------------------------------------------------------------
@@ -273,6 +333,9 @@ export class ClaudeCodeSession implements HarnessSession {
273
333
  private _persistSession: boolean;
274
334
  private _toolGate: boolean;
275
335
  private _gates = new ToolGates();
336
+ private _extension?: ClaudeSessionExtension;
337
+ /** The private directory holding the extension's agent definitions while a process runs. */
338
+ private _agentsDir?: string;
276
339
  /** Inbound control requests awaiting a gate decision, by request id. */
277
340
  private _inbound = new Map<string, AbortController>();
278
341
  private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
@@ -410,6 +473,17 @@ export class ClaudeCodeSession implements HarnessSession {
410
473
  return this._gates.add(gate);
411
474
  }
412
475
 
476
+ /**
477
+ * Run an extension inside this session's process (ClaudeInSessionDecisions). Before `start()`
478
+ * only, once, and never on a text-only session (it needs tools and an MCP server).
479
+ */
480
+ extend(extension: ClaudeSessionExtension): void {
481
+ if (this._proc) throw new Error('Extensions attach before start()');
482
+ if (this._extension) throw new Error('Session already has an extension');
483
+ if (this._textOnly) throw new Error('A text-only session cannot host an extension');
484
+ this._extension = extension;
485
+ }
486
+
413
487
  onBeforeSend(hook: BeforeSendHook): () => void {
414
488
  this._beforeSendHooks.push(hook);
415
489
  return () => {
@@ -451,6 +525,7 @@ export class ClaudeCodeSession implements HarnessSession {
451
525
  const env: Record<string, string | undefined> = {
452
526
  ...process.env,
453
527
  ...this._env,
528
+ ...this._extension?.env,
454
529
  DISABLE_AUTOUPDATER: '1',
455
530
  };
456
531
  delete env.ANTHROPIC_API_KEY;
@@ -471,6 +546,7 @@ export class ClaudeCodeSession implements HarnessSession {
471
546
  }
472
547
  } catch (error) {
473
548
  this._removeBaseContext();
549
+ this._removeAgents();
474
550
  throw error;
475
551
  }
476
552
 
@@ -486,9 +562,13 @@ export class ClaudeCodeSession implements HarnessSession {
486
562
  // Monitor process exit for cleanup
487
563
  // The process read its base context file at startup; it goes when the process does.
488
564
  const baseDir = this._baseContextDir;
565
+ const agentsDir = this._agentsDir;
489
566
  proc.exited.then(async (code) => {
490
567
  this._removeBaseContext(baseDir);
491
- if (this._proc !== proc || !this._alive) return;
568
+ this._removeAgents(agentsDir);
569
+ if (this._proc !== proc) return;
570
+ this._detachExtension('process exited');
571
+ if (!this._alive) return;
492
572
  this._alive = false;
493
573
  await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
494
574
  const errMsg = this._stderr.trim()
@@ -501,20 +581,58 @@ export class ClaudeCodeSession implements HarnessSession {
501
581
  });
502
582
 
503
583
  // The PreToolUse callback is registered before any turn is written, so it covers the first call.
504
- if (this._toolGate) {
584
+ // An extension's agents run their own tools: it claims them through the same callback.
585
+ const extension = this._extension;
586
+ // The extension can answer from the moment the process runs: the CLI connects its SDK MCP
587
+ // server while `initialize` is still in flight, and an unanswered connect costs a 30 s retry.
588
+ if (extension)
589
+ extension.attach({
590
+ control: (request, timeoutMs) => this._controlRequest(request, timeoutMs),
591
+ mcpReply: (requestId, message) => {
592
+ if (this._proc === proc && this._alive)
593
+ this._writeControlResponse(proc, requestId, { mcp_response: message });
594
+ },
595
+ sendInternal: (message) => this.send(message, { timeout: 0 }),
596
+ alive: () => this._proc === proc && this._alive,
597
+ });
598
+ if (this._toolGate || extension) {
505
599
  try {
506
600
  await this._controlRequest(
507
601
  {
508
602
  subtype: 'initialize',
509
603
  hooks: { PreToolUse: [{ matcher: '.*', hookCallbackIds: [this._hookCallbackId] }] },
604
+ ...(extension
605
+ ? {
606
+ sdkMcpServers: [...extension.mcpServers],
607
+ // The user's Stop aborts the turn only; background agents (the extension's) survive.
608
+ perTaskStopAffordance: true,
609
+ }
610
+ : {}),
510
611
  },
511
612
  30_000,
512
613
  );
513
614
  } catch (error) {
514
615
  this.kill();
515
- throw new Error(`Tool gate registration failed: ${(error as Error).message}`);
616
+ throw new Error(
617
+ `${this._toolGate ? 'Tool gate' : 'Extension'} registration failed: ${(error as Error).message}`,
618
+ );
516
619
  }
517
620
  }
621
+ if (extension && this._proc === proc && this._alive) {
622
+ try {
623
+ extension.started();
624
+ } catch {
625
+ /* an extension's failure never touches the session */
626
+ }
627
+ }
628
+ }
629
+
630
+ private _detachExtension(reason: string): void {
631
+ try {
632
+ this._extension?.detach(reason);
633
+ } catch {
634
+ /* an extension's failure never touches the session */
635
+ }
518
636
  }
519
637
 
520
638
  private _rejectControl(error: Error): void {
@@ -752,6 +870,8 @@ export class ClaudeCodeSession implements HarnessSession {
752
870
  }
753
871
  this._proc = null;
754
872
  this._removeBaseContext();
873
+ this._removeAgents();
874
+ this._detachExtension('killed');
755
875
 
756
876
  this._emit({ kind: 'session_end', timestamp: Date.now() });
757
877
  }
@@ -1007,6 +1127,8 @@ export class ClaudeCodeSession implements HarnessSession {
1007
1127
  if (raw.type === 'control_request') {
1008
1128
  const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
1009
1129
  const request = raw.request as Record<string, unknown> | undefined;
1130
+ if (id && this._proc && this._extension && this._extensionRequest(this._proc, id, request))
1131
+ return;
1010
1132
  const toolRequest =
1011
1133
  id && this._toolGate && this._proc ? this._toolRequestFrom(request) : undefined;
1012
1134
  if (id && toolRequest) {
@@ -1107,6 +1229,17 @@ export class ClaudeCodeSession implements HarnessSession {
1107
1229
  return;
1108
1230
  }
1109
1231
 
1232
+ // An extension's own agents (their frames and task lifecycle) are its alone.
1233
+ if (this._extension) {
1234
+ let own = false;
1235
+ try {
1236
+ own = this._extension.frame(raw);
1237
+ } catch {
1238
+ /* an extension's failure never touches the session */
1239
+ }
1240
+ if (own) return;
1241
+ }
1242
+
1110
1243
  // A subagent's frames and lifecycle are never part of an admission, whichever turn is in flight
1111
1244
  // (a background subagent outlives the turn that started it): they reach handlers tagged
1112
1245
  // `subagent`, with the spawning call in `parentToolUseId`.
@@ -1258,6 +1391,52 @@ export class ClaudeCodeSession implements HarnessSession {
1258
1391
  };
1259
1392
  }
1260
1393
 
1394
+ /**
1395
+ * A control request the extension answers: an MCP message for its servers, or a PreToolUse
1396
+ * callback / permission ask that belongs to its agents or spawns one of its agent types. With no
1397
+ * tool gate, our callback's other calls get no opinion (the session's permission mode decides).
1398
+ */
1399
+ private _extensionRequest(
1400
+ proc: PipedSubprocess,
1401
+ id: string,
1402
+ request: Record<string, unknown> | undefined,
1403
+ ): boolean {
1404
+ const extension = this._extension!;
1405
+ try {
1406
+ if (
1407
+ request?.subtype === 'mcp_message' &&
1408
+ typeof request.server_name === 'string' &&
1409
+ extension.mcpServers.includes(request.server_name)
1410
+ ) {
1411
+ extension.mcp(request.server_name, id, (request.message ?? {}) as Record<string, unknown>);
1412
+ return true;
1413
+ }
1414
+ if (this._ownHookCallback(request)) {
1415
+ const input = request!.input as Record<string, unknown> | undefined;
1416
+ const output = input ? extension.preToolUse(input) : undefined;
1417
+ if (output) {
1418
+ this._writeControlResponse(proc, id, { hookSpecificOutput: output });
1419
+ return true;
1420
+ }
1421
+ if (!this._toolGate) {
1422
+ this._writeControlResponse(proc, id, {});
1423
+ return true;
1424
+ }
1425
+ return false;
1426
+ }
1427
+ if (request?.subtype === 'can_use_tool') {
1428
+ const answer = extension.permission(request);
1429
+ if (answer) {
1430
+ this._writeControlResponse(proc, id, answer);
1431
+ return true;
1432
+ }
1433
+ }
1434
+ } catch {
1435
+ /* an extension's failure falls through to the session's own handling */
1436
+ }
1437
+ return false;
1438
+ }
1439
+
1261
1440
  private _ownHookCallback(request: Record<string, unknown> | undefined): boolean {
1262
1441
  return request?.subtype === 'hook_callback' && request.callback_id === this._hookCallbackId;
1263
1442
  }
@@ -1376,10 +1555,26 @@ export class ClaudeCodeSession implements HarnessSession {
1376
1555
  if (this._baseContext) {
1377
1556
  args.push('--append-system-prompt-file', this._writeBaseContext(this._baseContext));
1378
1557
  }
1558
+ // An extension's agents, from a private file; its MCP servers are declared in `initialize`.
1559
+ if (this._extension) args.push('--agents', this._writeAgents(this._extension.agents));
1379
1560
 
1380
1561
  return args;
1381
1562
  }
1382
1563
 
1564
+ private _writeAgents(agents: ClaudeSessionExtension['agents']): string {
1565
+ this._removeAgents();
1566
+ this._agentsDir = mkdtempSync(join(tmpdir(), `${BASE_CONTEXT_PREFIX}${process.pid}-agents-`));
1567
+ const file = join(this._agentsDir, 'agents.json');
1568
+ writeFileSync(file, JSON.stringify(agents), { mode: 0o600 });
1569
+ return file;
1570
+ }
1571
+
1572
+ private _removeAgents(dir = this._agentsDir): void {
1573
+ if (!dir) return;
1574
+ rmSync(dir, { recursive: true, force: true });
1575
+ if (dir === this._agentsDir) this._agentsDir = undefined;
1576
+ }
1577
+
1383
1578
  /** The base context as a private file: argv carries its path, never its text. */
1384
1579
  private _writeBaseContext(text: string): string {
1385
1580
  this._removeBaseContext();