@inixiative/agent-session 0.6.0 → 0.6.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Drive coding-agent CLIs as **persistent, streaming, event-captured sessions** —
4
4
  one interface, any agent, over interchangeable **transports**. Claude Code
5
- (CLI or Agent SDK) and Codex (MCP or app-server) today; Gemini and Grok planned.
5
+ (CLI or Agent SDK) and Codex (app-server) today; Gemini and Grok planned.
6
6
  Also: warm **primed decision sessions**, **subscription pools** with
7
7
  deterministic routing, **limit polling** and **session continuity**.
8
8
 
@@ -64,20 +64,23 @@ arrive as `rate_limit` events.
64
64
  ```ts
65
65
  import { createSession, TRANSPORTS } from "@inixiative/agent-session";
66
66
 
67
- const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk, codex-mcp
67
+ const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk
68
68
  if (TRANSPORTS[kind].capabilities.interrupt === "acknowledged") { /* … */ }
69
69
  const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOME: profile } });
70
70
  ```
71
71
 
72
- | | claude-cli | codex-mcp | codex-app-server | claude-agent-sdk | acp | api |
73
- |---|---|---|---|---|---|---|
74
- | Status | ✅ live | ✅ live | ✅ live | ✅ live | stub | stub |
75
- | Resume / fork | native / native | loaded-only / same-thread | native / – | native / native | – | – |
76
- | Interrupt | acknowledged | local-only | acknowledged | acknowledged | – | – |
77
- | Push | – | – | steer | – | – | – |
78
- | Approvals | callback | mode | callback | callback | – | – |
79
- | Limits stream / poll | ✅ / ✅ | – / – | ✅ / ✅ | ✅ / – | – | – |
80
- | Primed reset | fork | – | fork | – | – | – |
72
+ | | claude-cli | codex-app-server | claude-agent-sdk | acp | api |
73
+ |---|---|---|---|---|---|
74
+ | Status | ✅ live | ✅ live | ✅ live | stub | stub |
75
+ | Resume / fork | native / native | native / – | native / native | – | – |
76
+ | Interrupt | acknowledged | acknowledged | acknowledged | – | – |
77
+ | Push | – | steer | – | – | – |
78
+ | Approvals | callback | callback | callback | – | – |
79
+ | Limits stream / poll | ✅ / ✅ | ✅ / ✅ | ✅ / – | – | – |
80
+ | Primed reset | fork | fork | – | – | – |
81
+
82
+ Codex runs only over `codex app-server --listen stdio://` (`CodexAppServerSession`);
83
+ Codex CLI 0.155 has no `mcp-server` subcommand.
81
84
 
82
85
  Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
83
86
 
@@ -174,7 +177,6 @@ snapshots in the artifact but do not fabricate a completed-turn total.
174
177
  | Runtime | Status |
175
178
  |---|---|
176
179
  | Claude Code | ✅ shipped |
177
- | Codex CLI (`codex mcp-server`, JSON-RPC) | ✅ shipped (`CodexSession`) |
178
180
  | Codex app-server | ✅ shipped (`CodexAppServerSession`, `CodexPrimedSessions`) |
179
181
  | Claude Agent SDK | ✅ shipped (`ClaudeAgentSdkSession`; caller supplies `query`) |
180
182
  | ACP agents / direct API | typed stubs |
@@ -188,7 +190,7 @@ Opaque runtimes degrade gracefully — fewer event types, never a hard failure.
188
190
  ## Provenance
189
191
 
190
192
  Extracted from [`inixiative/foundry`](https://github.com/inixiative/foundry),
191
- where this drives the Artificer session. Consumed by foundry, foundry-oracle,
193
+ where this drives the Artificer session. Consumed by foundry, oracle,
192
194
  and the inixiative bench.
193
195
 
194
196
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
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",
@@ -36,11 +36,11 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
- "@inixiative/json-rules": "^3.5.1"
39
+ "@inixiative/json-rules": "^3.5.4"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@biomejs/biome": "2.5.2",
43
- "@inixiative/config": "^0.5.67",
43
+ "@inixiative/config": "^0.5.78",
44
44
  "@types/bun": "1.4.2",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  // ---------------------------------------------------------------------------
2
3
  // ClaudeAgentSdkSession — Claude through @anthropic-ai/claude-agent-sdk
3
4
  // ---------------------------------------------------------------------------
@@ -90,6 +91,13 @@ export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unkn
90
91
  case '--append-system-prompt':
91
92
  options.systemPrompt = { type: 'preset', preset: 'claude_code', append: value() };
92
93
  break;
94
+ case '--append-system-prompt-file':
95
+ options.systemPrompt = {
96
+ type: 'preset',
97
+ preset: 'claude_code',
98
+ append: readFileSync(value(), 'utf8'),
99
+ };
100
+ break;
93
101
  case '--no-session-persistence':
94
102
  options.persistSession = false;
95
103
  break;
@@ -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,
@@ -92,7 +95,8 @@ export interface ClaudeCodeSessionConfig {
92
95
  /**
93
96
  * Base context to pre-load at session startup.
94
97
  *
95
- * 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
96
100
  * entire session lifetime. Include all stable context here (system,
97
101
  * conventions, memory, architecture) so per-message delta is minimal.
98
102
  */
@@ -258,6 +262,8 @@ export class ClaudeCodeSession implements HarnessSession {
258
262
  private _permissionMode: string;
259
263
  private _defaultTimeout: number;
260
264
  private _baseContext?: string;
265
+ /** The private directory holding the base context file while a process runs. */
266
+ private _baseContextDir?: string;
261
267
  private _forking = false;
262
268
  private _awaitingForkIdentity = false;
263
269
  private _spawn?: ClaudeCodeSessionConfig['spawn'];
@@ -448,16 +454,21 @@ export class ClaudeCodeSession implements HarnessSession {
448
454
  delete env.ANTHROPIC_AUTH_TOKEN;
449
455
  for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
450
456
 
451
- if (this._spawn) {
452
- this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
453
- } else {
454
- this._proc = Bun.spawn([this._bin, ...args], {
455
- cwd: this._cwd,
456
- stdin: 'pipe',
457
- stdout: 'pipe',
458
- stderr: 'pipe',
459
- env,
460
- }) 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;
461
472
  }
462
473
 
463
474
  this._alive = true;
@@ -470,7 +481,10 @@ export class ClaudeCodeSession implements HarnessSession {
470
481
  this._readStderr();
471
482
 
472
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;
473
486
  proc.exited.then(async (code) => {
487
+ this._removeBaseContext(baseDir);
474
488
  if (this._proc !== proc || !this._alive) return;
475
489
  this._alive = false;
476
490
  await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
@@ -734,6 +748,7 @@ export class ClaudeCodeSession implements HarnessSession {
734
748
  /* already dead */
735
749
  }
736
750
  this._proc = null;
751
+ this._removeBaseContext();
737
752
 
738
753
  this._emit({ kind: 'session_end', timestamp: Date.now() });
739
754
  }
@@ -1328,12 +1343,32 @@ export class ClaudeCodeSession implements HarnessSession {
1328
1343
 
1329
1344
  // Stable base context injected once at process startup
1330
1345
  if (this._baseContext) {
1331
- args.push('--append-system-prompt', this._baseContext);
1346
+ args.push('--append-system-prompt-file', this._writeBaseContext(this._baseContext));
1332
1347
  }
1333
1348
 
1334
1349
  return args;
1335
1350
  }
1336
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
+
1337
1372
  // ---------------------------------------------------------------------------
1338
1373
  // Private — event classification
1339
1374
  // ---------------------------------------------------------------------------
@@ -1491,3 +1526,35 @@ export class ClaudeCodeSession implements HarnessSession {
1491
1526
  }
1492
1527
  }
1493
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
+ }
@@ -13,6 +13,12 @@
13
13
  // decision pays no process start. Forks send the primed prefix
14
14
  // byte-for-byte, so it is read from the prompt cache.
15
15
  //
16
+ // A key's decisions run concurrently, like Codex forks: priming is
17
+ // single-flight per key, the first decision takes the spare, and an
18
+ // overlapping one starts its own fork (process start, no re-prime). One key
19
+ // therefore serves every phase of a role without one phase queueing behind
20
+ // another, and still holds a single idle spare.
21
+ //
16
22
  // Verified live with claude 2.1.281: forks read the full primed prefix from
17
23
  // cache (cache_read equal to the primer's cache write) and write nothing to
18
24
  // history.
@@ -87,7 +93,10 @@ interface Primed {
87
93
  cleanup?: Promise<void>;
88
94
  model?: string;
89
95
  lastUsed: number;
90
- busy: boolean;
96
+ /** Decisions currently forked from this session. */
97
+ inUse: number;
98
+ /** Replaced or evicted while in use: its transcript is deleted when the last decision finishes. */
99
+ stale?: 'idle' | 'capacity' | 'reprime' | 'requested' | 'close';
91
100
  }
92
101
 
93
102
  export class ClaudePrimedSessions implements PrimedSessions {
@@ -104,6 +113,8 @@ export class ClaudePrimedSessions implements PrimedSessions {
104
113
  private _idleTimer?: ReturnType<typeof setInterval>;
105
114
  /** Branches and primers with a live process, so close() can stop them. */
106
115
  private _live = new Set<Owned>();
116
+ /** Sessions dropped while decisions still fork from them: retired by the last one, or by close(). */
117
+ private _stale = new Set<Primed>();
107
118
  private _swept = false;
108
119
  private _lastPoll = 0;
109
120
 
@@ -168,14 +179,14 @@ export class ClaudePrimedSessions implements PrimedSessions {
168
179
  Math.min(this._config.idleMs, 60_000),
169
180
  );
170
181
  (this._idleTimer as { unref?: () => void }).unref?.();
171
- return this._queue.run(spec.key, async () => {
182
+ return (async () => {
172
183
  const release = await this._slots.acquire(deadline, request.signal);
173
184
  try {
174
185
  return await this._decide(spec, request, started, deadline);
175
186
  } finally {
176
187
  release();
177
188
  }
178
- });
189
+ })();
179
190
  }
180
191
 
181
192
  async evict(key: string): Promise<void> {
@@ -195,6 +206,8 @@ export class ClaudePrimedSessions implements PrimedSessions {
195
206
  // In-flight branches and primers are stopped, not awaited to completion.
196
207
  await Promise.all([...this._live].map((owned) => this._dispose(owned)));
197
208
  await Promise.all([...this._sessions.values()].map((s) => this._drop(s, 'close')));
209
+ // Their forks are stopped, so no decision still reads a stale transcript.
210
+ for (const session of [...this._stale]) this._retire(session, session.stale ?? 'close');
198
211
  }
199
212
 
200
213
  // -------------------------------------------------------------------------
@@ -358,7 +371,7 @@ export class ClaudePrimedSessions implements PrimedSessions {
358
371
  hash,
359
372
  instructions: spec.instructions,
360
373
  lastUsed: Date.now(),
361
- busy: false,
374
+ inUse: 0,
362
375
  };
363
376
  this._sessions.set(spec.key, primed);
364
377
  this._emit({ type: 'primed', key: spec.key, hash, ms: 0 });
@@ -406,7 +419,7 @@ export class ClaudePrimedSessions implements PrimedSessions {
406
419
  base: base.session,
407
420
  model: observedModel(result),
408
421
  lastUsed: Date.now(),
409
- busy: false,
422
+ inUse: 0,
410
423
  };
411
424
  this._sessions.set(spec.key, primed);
412
425
  this._emit({ type: 'primed', key: spec.key, hash, ms: Math.round(performance.now() - t0) });
@@ -422,14 +435,78 @@ export class ClaudePrimedSessions implements PrimedSessions {
422
435
  const spare = session.spare;
423
436
  session.spare = undefined;
424
437
  await this._dispose(spare);
438
+ // Decisions still forking from it keep its transcript until the last one finishes; on close
439
+ // their processes are already stopped.
440
+ if (session.inUse > 0 && reason !== 'close') {
441
+ session.stale ??= reason;
442
+ this._stale.add(session);
443
+ } else this._retire(session, reason);
444
+ }
445
+
446
+ private _retire(session: Primed, reason: NonNullable<Primed['stale']>): void {
447
+ this._stale.delete(session);
448
+ session.stale = undefined;
425
449
  this._deleteTranscript(session.base?.externalSessionId);
426
450
  this._emit({ type: 'evicted', key: session.key, reason });
427
451
  }
428
452
 
453
+ /** A decision's reference is returned; the last one out retires a stale session. */
454
+ private _release(session: Primed): void {
455
+ session.inUse--;
456
+ session.lastUsed = Date.now();
457
+ if (session.stale && !session.inUse) this._retire(session, session.stale);
458
+ }
459
+
460
+ /** Wait for `task` until the deadline or the signal; neither cancels it. Nothing is dispatched on expiry. */
461
+ private _bounded<T>(task: Promise<T>, deadline: number, signal?: AbortSignal): Promise<T> {
462
+ return new Promise<T>((resolve, reject) => {
463
+ const fail = (error: DecisionError) => {
464
+ clearTimeout(timer);
465
+ signal?.removeEventListener('abort', onAbort);
466
+ reject(error);
467
+ };
468
+ const onAbort = () =>
469
+ fail(
470
+ new DecisionError(
471
+ this._closed ? 'Primed sessions closed' : 'Decision aborted before dispatch',
472
+ this._closed ? 'closed' : 'aborted',
473
+ 'not-dispatched',
474
+ true,
475
+ ),
476
+ );
477
+ const timer = setTimeout(
478
+ () =>
479
+ fail(
480
+ new DecisionError(
481
+ 'Decision deadline passed waiting for its primed session',
482
+ 'timeout',
483
+ 'not-dispatched',
484
+ true,
485
+ ),
486
+ ),
487
+ Math.max(0, deadline - Date.now()),
488
+ );
489
+ if (signal?.aborted) return onAbort();
490
+ signal?.addEventListener('abort', onAbort, { once: true });
491
+ task.then(
492
+ (value) => {
493
+ clearTimeout(timer);
494
+ signal?.removeEventListener('abort', onAbort);
495
+ resolve(value);
496
+ },
497
+ (error) => {
498
+ clearTimeout(timer);
499
+ signal?.removeEventListener('abort', onAbort);
500
+ reject(error);
501
+ },
502
+ );
503
+ });
504
+ }
505
+
429
506
  private async _evictIdle(): Promise<void> {
430
507
  const cutoff = Date.now() - this._config.idleMs;
431
508
  for (const session of [...this._sessions.values()])
432
- if (!session.busy && session.lastUsed < cutoff) await this._drop(session, 'idle');
509
+ if (!session.inUse && session.lastUsed < cutoff) await this._drop(session, 'idle');
433
510
  }
434
511
 
435
512
  private async _decide(
@@ -455,36 +532,72 @@ export class ClaudePrimedSessions implements PrimedSessions {
455
532
  await this._checkLimits();
456
533
  this._sweepTranscripts();
457
534
  const hash = primeHash(spec);
458
- let session = this._sessions.get(spec.key),
459
- prime: 'warm' | 'cold' = 'warm',
535
+ let prime: 'warm' | 'cold' = 'warm',
460
536
  primeMs = 0;
461
- if (session && session.hash !== hash) {
462
- await this._drop(session, 'reprime');
463
- session = undefined;
464
- }
465
- if (!session) {
466
- while (this._sessions.size >= this._config.maxSessions) {
467
- const idle = [...this._sessions.values()]
468
- .filter((s) => !s.busy)
469
- .sort((a, b) => a.lastUsed - b.lastUsed)[0];
470
- if (!idle) break;
471
- await this._drop(idle, 'capacity');
537
+ // Only finding or priming the key's session is serialized: priming is single-flight per key,
538
+ // and the decision itself runs beside any other decision on the key. The wait is bounded by
539
+ // this decision's deadline and signal; a prime it started runs on (host timeout) for the next.
540
+ let abandoned = false,
541
+ taken = false;
542
+ const lookup = this._queue.run(spec.key, async () => {
543
+ if (this._closed || abandoned) throw closed();
544
+ let found = this._sessions.get(spec.key);
545
+ if (found && found.hash !== hash) {
546
+ await this._drop(found, 'reprime');
547
+ found = undefined;
472
548
  }
473
- if (this._closed || signal?.aborted) throw closed();
474
- const t = performance.now();
475
- session = await this._prime(spec, hash, deadline);
476
- primeMs = Math.round(performance.now() - t);
477
- prime = 'cold';
478
- }
479
- await session.cleanup;
480
- if (this._closed || signal?.aborted) throw closed();
481
- session.busy = true;
482
- session.lastUsed = Date.now();
483
- const t1 = performance.now();
484
- let branch = session.spare;
485
- session.spare = undefined;
549
+ if (!found) {
550
+ while (this._sessions.size >= this._config.maxSessions) {
551
+ const idle = [...this._sessions.values()]
552
+ .filter((s) => !s.inUse)
553
+ .sort((a, b) => a.lastUsed - b.lastUsed)[0];
554
+ if (!idle) break;
555
+ await this._drop(idle, 'capacity');
556
+ }
557
+ if (this._closed || abandoned) throw closed();
558
+ const t = performance.now();
559
+ found = await this._prime(spec, hash, Date.now() + this._config.timeoutMs);
560
+ primeMs = Math.round(performance.now() - t);
561
+ prime = 'cold';
562
+ // Closed while priming: the drop above has run, so this session is dropped here.
563
+ if (this._closed) {
564
+ await this._drop(found, 'close');
565
+ throw closed();
566
+ }
567
+ }
568
+ if (abandoned) return found;
569
+ found.inUse++;
570
+ taken = true;
571
+ return found;
572
+ });
573
+ lookup.catch(() => undefined);
574
+ const session = await this._bounded(lookup, deadline, signal).catch((error: unknown) => {
575
+ abandoned = true;
576
+ // Granted between the deadline firing and this handler: hand the reference back.
577
+ void lookup.then(
578
+ (found) => {
579
+ if (taken) this._release(found);
580
+ },
581
+ () => undefined,
582
+ );
583
+ throw error;
584
+ });
585
+ let branch: Owned | undefined;
486
586
  let admissionId: string | undefined;
587
+ const t1 = performance.now();
487
588
  try {
589
+ await this._bounded(session.cleanup ?? Promise.resolve(), deadline, signal);
590
+ if (this._closed || signal?.aborted) throw closed();
591
+ if (Date.now() >= deadline)
592
+ throw new DecisionError(
593
+ 'Decision deadline passed before dispatch',
594
+ 'timeout',
595
+ 'not-dispatched',
596
+ true,
597
+ );
598
+ session.lastUsed = Date.now();
599
+ branch = session.spare;
600
+ session.spare = undefined;
488
601
  if (!branch?.session.alive) {
489
602
  await this._dispose(branch);
490
603
  branch = this._branch(session);
@@ -627,13 +740,18 @@ export class ClaudePrimedSessions implements PrimedSessions {
627
740
  timing: { waitMs, primeMs, branchMs, turnMs },
628
741
  };
629
742
  } finally {
630
- session.busy = false;
631
- session.lastUsed = Date.now();
743
+ this._release(session);
632
744
  // The decision settled on its native terminal; process exit and the next spare are off the caller's path.
633
745
  const done = branch;
634
746
  session.cleanup = (async () => {
635
747
  await this._dispose(done);
636
- if (this._config.spare && !this._closed && this._sessions.get(spec.key) === session) {
748
+ // One spare per key: an overlapping decision that already refilled it wins.
749
+ if (
750
+ this._config.spare &&
751
+ !this._closed &&
752
+ !session.spare &&
753
+ this._sessions.get(spec.key) === session
754
+ ) {
637
755
  const spare = this._branch(session);
638
756
  session.spare = spare;
639
757
  await spare.session.start().catch(() => {