@inixiative/agent-session 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # @inixiative/agent-session
2
2
 
3
3
  Drive coding-agent CLIs as **persistent, streaming, event-captured sessions** —
4
- one interface, any agent. Claude Code and Codex today; Gemini and Grok planned.
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.
6
+ Also: warm **primed decision sessions**, **subscription pools** with
7
+ deterministic routing, **limit polling** and **session continuity**.
5
8
 
6
9
  Most ways to script a coding agent are one-shot (`claude -p "…"`) and lose the
7
10
  process. `agent-session` keeps a single long-lived agent process, streams turns
@@ -43,13 +46,83 @@ session.kill();
43
46
  containers.
44
47
  - **fork / resume / interrupt** and pre-send hooks (rewrite the outgoing
45
48
  message — e.g. inject just-in-time context per turn).
46
- - **Zero dependencies.** Just Bun + the agent CLI you're driving.
49
+ - **Transports with declared capabilities.** How a model is reached is a
50
+ pluggable transport; switching it is a routing change, not a rewrite.
51
+ - **Zero dependencies.** Just Bun + the agent CLI you're driving. The Agent SDK
52
+ transport takes the SDK's `query` from the caller.
47
53
 
48
54
  ## Interface
49
55
 
50
56
  `HarnessSession` is the provider-agnostic contract (`start` / `send` / `kill` /
51
- `fork` / `interrupt` + an event handler). `ClaudeCodeSession` implements it over
52
- `claude --print --input-format stream-json --output-format stream-json`.
57
+ `fork` / `interrupt` + an event handler). Optional members where a transport
58
+ supports them: `transport` (its descriptor), `interruptNative()` (acknowledged
59
+ stop), `readLimits()` (account limits without a model turn). Account limits also
60
+ arrive as `rate_limit` events.
61
+
62
+ ## Transports
63
+
64
+ ```ts
65
+ import { createSession, TRANSPORTS } from "@inixiative/agent-session";
66
+
67
+ const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk, codex-mcp
68
+ if (TRANSPORTS[kind].capabilities.interrupt === "acknowledged") { /* … */ }
69
+ const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOME: profile } });
70
+ ```
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 | mode | mode | refuse | callback | – | – |
79
+ | Limits stream / poll | ✅ / ✅ | – / – | ✅ / ✅ | ✅ / – | – | – |
80
+ | Primed reset | fork | – | fork | – | – | – |
81
+
82
+ Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
83
+
84
+ ## Primed decision sessions
85
+
86
+ One live, warm session per middleware role, primed once with its role
87
+ instructions and stable context, reset to that primed state every cycle:
88
+
89
+ ```ts
90
+ import { CodexPrimedSessions } from "@inixiative/agent-session";
91
+
92
+ const decisions = new CodexPrimedSessions({ model: "gpt-6-luna", effort: "low", cwd: privateDir });
93
+ const result = await decisions.decide(
94
+ { key: `${threadId}:aux:domain:api`, instructions: rolePrompt, context: layerContent, hash: layerHash },
95
+ { input: "## Message\n…", onAdmission: register },
96
+ );
97
+ ```
98
+
99
+ Run Codex decisions on a private `CODEX_HOME` that holds only the login (link
100
+ `auth.json` from the account's home; a copy forks its refresh token): Codex has no
101
+ setting that drops the user's own `AGENTS.md`, and a decision thread that reports
102
+ any instruction source is refused.
103
+
104
+ Codex hosts every key as a thread of one `app-server` process per account and
105
+ forks the primed thread per cycle; Claude forks a persisted primed session with
106
+ a pre-spawned spare. Measured on a ChatGPT login: warm Codex decisions 2.45 s
107
+ median vs 3.9 s for `codex exec` per decision, with 34% fewer input tokens; warm
108
+ Claude (haiku) decisions 1.95 s with the whole primed prefix read from cache.
109
+
110
+ ## Pools, routing and continuity
111
+
112
+ ```ts
113
+ import { SubscriptionPool } from "@inixiative/agent-session";
114
+
115
+ const pool = new SubscriptionPool({ instances: [
116
+ { id: "work", transport: "claude-cli", profileDirectory: "/profiles/work", organizationIds: ["org"] },
117
+ { id: "spare", transport: "claude-cli", profileDirectory: "/profiles/spare", organizationIds: ["org"] },
118
+ ] });
119
+ const { session, lease } = await pool.open({ runtime: "claude", model: "sonnet", organizationId: "org", preferredInstanceId: "work" });
120
+ ```
121
+
122
+ Routing is deterministic (quartile-balanced, owner-first or pinned) over
123
+ observed limits, leases and health; stale or unknown limits exclude an instance
124
+ and every exclusion has a reason. `continueOn` moves a thread only between
125
+ instances that share native history, and says why when it cannot.
53
126
 
54
127
  ## Usage telemetry
55
128
 
@@ -71,13 +144,16 @@ snapshots in the artifact but do not fabricate a completed-turn total.
71
144
  | Runtime | Status |
72
145
  |---|---|
73
146
  | Claude Code | ✅ shipped |
74
- | Codex CLI (`codex mcp-server`, JSON-RPC) | ✅ shipped (`CodexSession`; `CodexAppServerSession` experimental) |
147
+ | Codex CLI (`codex mcp-server`, JSON-RPC) | ✅ shipped (`CodexSession`) |
148
+ | Codex app-server | ✅ shipped (`CodexAppServerSession`, `CodexPrimedSessions`) |
149
+ | Claude Agent SDK | ✅ shipped (`ClaudeAgentSdkSession`; caller supplies `query`) |
150
+ | ACP agents / direct API | typed stubs |
75
151
  | Gemini CLI | planned |
76
152
  | Grok CLI | planned (when its headless/stream mode matures) |
77
153
 
78
154
  Opaque runtimes degrade gracefully — fewer event types, never a hard failure.
79
155
 
80
- Proposed [subscription pooling goals](tickets/README.md) cover distributing session workloads across subscriptions while keeping working context independent of the capacity supplying it.
156
+ [Subscription pooling goals](tickets/README.md) (AS-001…005) are implemented as described in [docs/transports-pools-routing.md](docs/transports-pools-routing.md); each ticket lists what remains.
81
157
 
82
158
  ## Provenance
83
159
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.2.0",
4
- "description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions. Claude Code and Codex today; Gemini, Grok planned.",
3
+ "version": "0.3.1",
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",
7
7
  "types": "src/index.ts",
@@ -14,8 +14,11 @@
14
14
  "scripts": {
15
15
  "typecheck": "tsc --noEmit",
16
16
  "test": "bun test",
17
- "check": "bun run typecheck && bun run test",
18
- "prepublishOnly": "bun run check"
17
+ "check": "bun run typecheck && bun run lint && bun run test",
18
+ "prepublishOnly": "bun run check",
19
+ "measure:primed": "bun scripts/measure-primed.ts",
20
+ "lint": "biome check .",
21
+ "prepare": "lefthook install"
19
22
  },
20
23
  "keywords": [
21
24
  "claude-code",
@@ -32,7 +35,10 @@
32
35
  ],
33
36
  "license": "MIT",
34
37
  "devDependencies": {
38
+ "@biomejs/biome": "2.5.2",
39
+ "@inixiative/config": "^0.5.41",
35
40
  "@types/bun": "1.4.2",
41
+ "lefthook": "2.1.9",
36
42
  "typescript": "6.0.3"
37
43
  },
38
44
  "packageManager": "bun@1.4.2",
@@ -0,0 +1,316 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ClaudeAgentSdkSession — Claude through @anthropic-ai/claude-agent-sdk
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // The Agent SDK drives the same Claude Code runtime and yields the same
6
+ // stream-json messages. This transport feeds SDK `query()` through a
7
+ // process-shaped bridge, so ClaudeCodeSession's classification, admission and
8
+ // ownership evidence apply unchanged. The caller supplies `query` (this
9
+ // package keeps zero dependencies):
10
+ //
11
+ // import { query } from "@anthropic-ai/claude-agent-sdk";
12
+ // const session = new ClaudeAgentSdkSession({ query, model: "sonnet", cwd });
13
+ //
14
+ // Launch options come from the same argv the CLI transport would use, mapped
15
+ // to SDK options; unmapped flags pass through `extraArgs`. Adds per-call
16
+ // approvals (`canUseTool`) and SDK-native interrupt. Limit polling
17
+ // (`get_usage`) is not exposed by the SDK Query API and is refused.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import {
21
+ ClaudeCodeSession,
22
+ type ClaudeCodeSessionConfig,
23
+ type PipedSubprocess,
24
+ } from './claude-code-session';
25
+ import { TRANSPORTS } from './transport';
26
+
27
+ /** The subset of the SDK's Query this transport uses. */
28
+ export interface ClaudeAgentSdkQuery extends AsyncIterable<unknown> {
29
+ interrupt(): Promise<unknown>;
30
+ /** Stops the underlying CLI. The bridge reports exit only after the message stream ends. */
31
+ close(): void;
32
+ }
33
+ export type ClaudeAgentSdkQueryFunction = (params: {
34
+ prompt: AsyncIterable<unknown>;
35
+ options: Record<string, unknown>;
36
+ }) => ClaudeAgentSdkQuery;
37
+ export type ClaudeAgentSdkCanUseTool = (
38
+ toolName: string,
39
+ input: Record<string, unknown>,
40
+ context: unknown,
41
+ ) => Promise<unknown>;
42
+
43
+ export interface ClaudeAgentSdkSessionConfig extends Omit<ClaudeCodeSessionConfig, 'spawn'> {
44
+ /** `query` from @anthropic-ai/claude-agent-sdk. */
45
+ query: ClaudeAgentSdkQueryFunction;
46
+ /** Per-call tool approval. Defaults permissionMode to "default"; combining it with "bypassPermissions" throws. */
47
+ canUseTool?: ClaudeAgentSdkCanUseTool;
48
+ /** Use the SDK's bundled CLI instead of resolving `bin` on PATH. Default false. */
49
+ bundledCli?: boolean;
50
+ /** Additional SDK options, passed verbatim (e.g. hooks). Mapped options take precedence. */
51
+ sdkOptions?: Record<string, unknown>;
52
+ }
53
+
54
+ const SDK_OWNED = new Set(['--print', '--verbose']);
55
+ const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format']);
56
+
57
+ /** Map a ClaudeCodeSession argv (without the binary) to SDK options. Exported for tests. */
58
+ export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unknown> {
59
+ const options: Record<string, unknown> = {};
60
+ const extraArgs: Record<string, string | null> = {};
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const flag = argv[i]!;
63
+ const hasValue = i + 1 < argv.length && !argv[i + 1]!.startsWith('--');
64
+ const value = () => argv[++i]!;
65
+ if (SDK_OWNED.has(flag)) continue;
66
+ if (SDK_OWNED_VALUE.has(flag)) {
67
+ i++;
68
+ continue;
69
+ }
70
+ switch (flag) {
71
+ case '--model':
72
+ options.model = value();
73
+ break;
74
+ case '--max-turns':
75
+ options.maxTurns = Number(value());
76
+ break;
77
+ case '--permission-mode': {
78
+ options.permissionMode = value();
79
+ if (options.permissionMode === 'bypassPermissions')
80
+ options.allowDangerouslySkipPermissions = true;
81
+ break;
82
+ }
83
+ case '--resume':
84
+ options.resume = value();
85
+ break;
86
+ case '--fork-session':
87
+ options.forkSession = true;
88
+ break;
89
+ case '--append-system-prompt':
90
+ options.systemPrompt = { type: 'preset', preset: 'claude_code', append: value() };
91
+ break;
92
+ case '--no-session-persistence':
93
+ options.persistSession = false;
94
+ break;
95
+ case '--strict-mcp-config':
96
+ options.strictMcpConfig = true;
97
+ break;
98
+ case '--mcp-config': {
99
+ const parsed = JSON.parse(value()) as { mcpServers?: Record<string, unknown> };
100
+ options.mcpServers = parsed.mcpServers ?? {};
101
+ break;
102
+ }
103
+ case '--tools': {
104
+ // `--tools ""` (text-only) is an empty list; the SDK spells that `tools: []`.
105
+ const list = argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') ? value() : '';
106
+ options.tools = list ? list.split(',') : [];
107
+ break;
108
+ }
109
+ default:
110
+ extraArgs[flag.replace(/^--/, '')] = hasValue ? value() : null;
111
+ }
112
+ }
113
+ if (Object.keys(extraArgs).length) options.extraArgs = extraArgs;
114
+ return options;
115
+ }
116
+
117
+ function bridge(
118
+ config: ClaudeAgentSdkSessionConfig,
119
+ ): NonNullable<ClaudeCodeSessionConfig['spawn']> {
120
+ return (cmd, { cwd, env }) => {
121
+ const encoder = new TextEncoder();
122
+ let out!: ReadableStreamDefaultController<Uint8Array>,
123
+ err!: ReadableStreamDefaultController<Uint8Array>;
124
+ let finish!: (code: number) => void,
125
+ finished = false;
126
+ const exited = new Promise<number>((resolve) => {
127
+ finish = resolve;
128
+ });
129
+ const stdout = new ReadableStream<Uint8Array>({
130
+ start(c) {
131
+ out = c;
132
+ },
133
+ });
134
+ const stderr = new ReadableStream<Uint8Array>({
135
+ start(c) {
136
+ err = c;
137
+ },
138
+ });
139
+ const end = (code: number) => {
140
+ if (finished) return;
141
+ finished = true;
142
+ try {
143
+ out.close();
144
+ } catch {
145
+ /* closed */
146
+ }
147
+ try {
148
+ err.close();
149
+ } catch {
150
+ /* closed */
151
+ }
152
+ finish(code);
153
+ };
154
+ const emit = (message: unknown) => {
155
+ if (!finished) out.enqueue(encoder.encode(`${JSON.stringify(message)}\n`));
156
+ };
157
+
158
+ const inbox: unknown[] = [];
159
+ let wake: (() => void) | undefined,
160
+ inputEnded = false;
161
+ const prompt: AsyncIterable<unknown> = {
162
+ async *[Symbol.asyncIterator]() {
163
+ while (true) {
164
+ while (inbox.length) yield inbox.shift();
165
+ if (inputEnded) return;
166
+ await new Promise<void>((resolve) => {
167
+ wake = resolve;
168
+ });
169
+ }
170
+ },
171
+ };
172
+
173
+ const stringEnv = Object.fromEntries(
174
+ Object.entries(env).filter(
175
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
176
+ ),
177
+ );
178
+ const options: Record<string, unknown> = {
179
+ ...config.sdkOptions,
180
+ ...sdkOptionsFromArgv(cmd.slice(1)),
181
+ cwd,
182
+ env: stringEnv,
183
+ ...(config.canUseTool ? { canUseTool: config.canUseTool } : {}),
184
+ ...(config.bundledCli ? {} : { pathToClaudeCodeExecutable: Bun.which(cmd[0]!) ?? cmd[0] }),
185
+ };
186
+ let query: ClaudeAgentSdkQuery,
187
+ killed = false;
188
+ try {
189
+ query = config.query({ prompt, options });
190
+ } catch (error) {
191
+ queueMicrotask(() => {
192
+ err.enqueue(encoder.encode(String((error as Error).message ?? error)));
193
+ end(1);
194
+ });
195
+ query = { interrupt: async () => {}, close() {}, async *[Symbol.asyncIterator]() {} };
196
+ }
197
+ // `exited` resolves only when the SDK's message stream has ended, so it is evidence that the CLI stopped.
198
+ void (async () => {
199
+ try {
200
+ for await (const message of query) if (!killed) emit(message);
201
+ end(killed ? 143 : 0);
202
+ } catch (error) {
203
+ if (!finished && !killed)
204
+ err.enqueue(encoder.encode(String((error as Error)?.message ?? error)));
205
+ end(killed ? 143 : 1);
206
+ }
207
+ })();
208
+
209
+ const respond = (requestId: string, error?: string) =>
210
+ emit({
211
+ type: 'control_response',
212
+ response: error
213
+ ? { subtype: 'error', request_id: requestId, error }
214
+ : { subtype: 'success', request_id: requestId, response: {} },
215
+ });
216
+ const process: PipedSubprocess = {
217
+ stdin: {
218
+ write(data: string) {
219
+ for (const line of data.split('\n')) {
220
+ if (!line.trim()) continue;
221
+ const message = JSON.parse(line) as Record<string, unknown>;
222
+ if (message.type === 'user') {
223
+ inbox.push({ ...message, parent_tool_use_id: null, session_id: '' });
224
+ wake?.();
225
+ wake = undefined;
226
+ } else if (message.type === 'control_request') {
227
+ const id = String(message.request_id);
228
+ const subtype = (message.request as Record<string, unknown> | undefined)?.subtype;
229
+ if (subtype === 'interrupt')
230
+ query.interrupt().then(
231
+ () => respond(id),
232
+ (e) => respond(id, String((e as Error)?.message ?? e)),
233
+ );
234
+ else
235
+ queueMicrotask(() =>
236
+ respond(
237
+ id,
238
+ `Control request ${String(subtype)} is not exposed by the Agent SDK transport`,
239
+ ),
240
+ );
241
+ }
242
+ }
243
+ },
244
+ flush() {},
245
+ end() {
246
+ inputEnded = true;
247
+ wake?.();
248
+ wake = undefined;
249
+ },
250
+ },
251
+ stdout,
252
+ stderr,
253
+ exited,
254
+ kill() {
255
+ if (killed) return;
256
+ killed = true;
257
+ inputEnded = true;
258
+ wake?.();
259
+ wake = undefined;
260
+ try {
261
+ query.close();
262
+ } catch {
263
+ /* already closed */
264
+ }
265
+ },
266
+ };
267
+ return process;
268
+ };
269
+ }
270
+
271
+ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
272
+ override get transport() {
273
+ return TRANSPORTS['claude-agent-sdk'];
274
+ }
275
+ private readonly _sdk: ClaudeAgentSdkSessionConfig;
276
+
277
+ constructor(config: ClaudeAgentSdkSessionConfig) {
278
+ if (typeof config?.query !== 'function')
279
+ throw Error('ClaudeAgentSdkSession requires `query` from @anthropic-ai/claude-agent-sdk');
280
+ if (config.canUseTool && config.permissionMode === 'bypassPermissions')
281
+ throw Error(
282
+ 'ClaudeAgentSdkSession: canUseTool is never consulted under permissionMode "bypassPermissions"; pass another mode or drop canUseTool',
283
+ );
284
+ const {
285
+ query: _query,
286
+ canUseTool: _canUseTool,
287
+ bundledCli: _bundled,
288
+ sdkOptions: _options,
289
+ ...session
290
+ } = config;
291
+ super({
292
+ ...session,
293
+ ...(config.canUseTool ? { permissionMode: config.permissionMode ?? 'default' } : {}),
294
+ spawn: bridge(config),
295
+ });
296
+ this._sdk = config;
297
+ }
298
+
299
+ /** get_usage is not reachable through the SDK Query API. */
300
+ override async readLimits(): Promise<never> {
301
+ throw Error(
302
+ 'Limit polling is not exposed by the Agent SDK transport; use the claude-cli transport or rate_limit events',
303
+ );
304
+ }
305
+
306
+ protected override _construct(config: ClaudeCodeSessionConfig): ClaudeCodeSession {
307
+ const { spawn: _spawn, ...rest } = config;
308
+ return new ClaudeAgentSdkSession({
309
+ ...rest,
310
+ query: this._sdk.query,
311
+ canUseTool: this._sdk.canUseTool,
312
+ bundledCli: this._sdk.bundledCli,
313
+ sdkOptions: this._sdk.sdkOptions,
314
+ });
315
+ }
316
+ }