@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.
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,23 +64,56 @@ 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 | mode | mode | refuse | 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
 
87
+ ### Tool gate
88
+
89
+ `onToolRequest(gate)` puts a decision on each tool call. Gates compose in
90
+ registration order; the first deny wins and a throwing gate denies.
91
+
92
+ ```ts
93
+ const session = new ClaudeCodeSession({ cwd, permissionMode: hostMode, toolGate: true });
94
+ session.onToolRequest(async (call) => {
95
+ if (call.stage === "pre-use") return structural(call) ?? { behavior: "allow" };
96
+ return askTheHuman(call, call.signal); // the runtime's own mode wanted to ask
97
+ });
98
+ ```
99
+
100
+ - `pre-use` (Claude only) runs before every call in every mode,
101
+ `bypassPermissions` included; only a deny counts. A PreToolUse callback over
102
+ the control protocol; one the session cannot read is denied.
103
+ - `permission` is an ask the runtime's mode raised; the answer is final (no gate:
104
+ denied). Claude: `--permission-prompt-tool stdio`; `decisionReason` says why it
105
+ asked, `agentId` marks a subagent's call. Codex app-server: command and
106
+ file-change `requestApproval`, answered accept/decline, under
107
+ `appServer: codexPermissions(mode)`. File-change asks carry the item's
108
+ `toolInput.changes` (path, kind, bounded diff); command asks carry
109
+ `networkApprovalContext`. Gated threads pin `approvalsReviewer: "user"` and
110
+ are refused if a config routes approvals elsewhere. Under the default `never`
111
+ policy nothing is asked, so `onToolRequest` throws.
112
+ - `call.signal` aborts when the runtime withdraws the request or the session
113
+ ends (kill, exit, transport failure); a decision after that is never written.
114
+ A turn timeout does not withdraw a pending Claude ask; `interruptNative()` does.
115
+ - A fork starts with a snapshot of the parent's gates.
116
+
84
117
  ## Primed decision sessions
85
118
 
86
119
  One live, warm session per middleware role, primed once with its role
@@ -144,7 +177,6 @@ snapshots in the artifact but do not fabricate a completed-turn total.
144
177
  | Runtime | Status |
145
178
  |---|---|
146
179
  | Claude Code | ✅ shipped |
147
- | Codex CLI (`codex mcp-server`, JSON-RPC) | ✅ shipped (`CodexSession`) |
148
180
  | Codex app-server | ✅ shipped (`CodexAppServerSession`, `CodexPrimedSessions`) |
149
181
  | Claude Agent SDK | ✅ shipped (`ClaudeAgentSdkSession`; caller supplies `query`) |
150
182
  | ACP agents / direct API | typed stubs |
@@ -158,7 +190,7 @@ Opaque runtimes degrade gracefully — fewer event types, never a hard failure.
158
190
  ## Provenance
159
191
 
160
192
  Extracted from [`inixiative/foundry`](https://github.com/inixiative/foundry),
161
- where this drives the Artificer session. Consumed by foundry, foundry-oracle,
193
+ where this drives the Artificer session. Consumed by foundry, oracle,
162
194
  and the inixiative bench.
163
195
 
164
196
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
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.0"
39
+ "@inixiative/json-rules": "^3.5.4"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@biomejs/biome": "2.5.2",
43
- "@inixiative/config": "^0.5.64",
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
  // ---------------------------------------------------------------------------
@@ -52,7 +53,8 @@ export interface ClaudeAgentSdkSessionConfig extends Omit<ClaudeCodeSessionConfi
52
53
  }
53
54
 
54
55
  const SDK_OWNED = new Set(['--print', '--verbose']);
55
- const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format']);
56
+ // `--permission-prompt-tool stdio` is the SDK's own canUseTool channel; the bridge wires it.
57
+ const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format', '--permission-prompt-tool']);
56
58
 
57
59
  /** Map a ClaudeCodeSession argv (without the binary) to SDK options. Exported for tests. */
58
60
  export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unknown> {
@@ -89,6 +91,13 @@ export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unkn
89
91
  case '--append-system-prompt':
90
92
  options.systemPrompt = { type: 'preset', preset: 'claude_code', append: value() };
91
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;
92
101
  case '--no-session-persistence':
93
102
  options.persistSession = false;
94
103
  break;
@@ -170,6 +179,94 @@ function bridge(
170
179
  },
171
180
  };
172
181
 
182
+ // Tool gate: the SDK's canUseTool and PreToolUse hook become the CLI's own control requests
183
+ // on the bridged stdout, so ClaudeCodeSession answers them exactly as on the CLI transport.
184
+ const gated = cmd.includes('--permission-prompt-tool');
185
+ let hookCallbackId: string | undefined;
186
+ const awaiting = new Map<string, (response: unknown) => void>();
187
+ const ask = (request: Record<string, unknown>, signal?: AbortSignal) =>
188
+ new Promise<unknown>((resolve) => {
189
+ const id = `sdk-bridge-${crypto.randomUUID()}`;
190
+ awaiting.set(id, resolve);
191
+ signal?.addEventListener(
192
+ 'abort',
193
+ () => {
194
+ if (awaiting.delete(id)) {
195
+ emit({ type: 'control_cancel_request', request_id: id });
196
+ resolve(undefined);
197
+ }
198
+ },
199
+ { once: true },
200
+ );
201
+ emit({ type: 'control_request', request_id: id, request });
202
+ });
203
+ const gate = gated
204
+ ? {
205
+ canUseTool: async (
206
+ toolName: string,
207
+ input: Record<string, unknown>,
208
+ context: {
209
+ signal?: AbortSignal;
210
+ toolUseID?: string;
211
+ blockedPath?: string;
212
+ decisionReason?: string;
213
+ description?: string;
214
+ agentID?: string;
215
+ } = {},
216
+ ) =>
217
+ ((await ask(
218
+ {
219
+ subtype: 'can_use_tool',
220
+ tool_name: toolName,
221
+ input,
222
+ ...(context.toolUseID ? { tool_use_id: context.toolUseID } : {}),
223
+ ...(context.blockedPath ? { blocked_path: context.blockedPath } : {}),
224
+ ...(typeof context.decisionReason === 'string' && context.decisionReason
225
+ ? { decision_reason: context.decisionReason }
226
+ : {}),
227
+ ...(typeof context.description === 'string' && context.description
228
+ ? { description: context.description }
229
+ : {}),
230
+ ...(context.agentID ? { agent_id: context.agentID } : {}),
231
+ },
232
+ context.signal,
233
+ )) as Record<string, unknown> | undefined) ?? {
234
+ behavior: 'deny',
235
+ message: 'Approval withdrawn',
236
+ },
237
+ hooks: {
238
+ PreToolUse: [
239
+ {
240
+ matcher: '.*',
241
+ hooks: [
242
+ async (
243
+ input: Record<string, unknown>,
244
+ toolUseID: string | undefined,
245
+ context: { signal?: AbortSignal } = {},
246
+ ) =>
247
+ ((await ask(
248
+ {
249
+ subtype: 'hook_callback',
250
+ callback_id: hookCallbackId,
251
+ input,
252
+ ...(toolUseID ? { tool_use_id: toolUseID } : {}),
253
+ },
254
+ context.signal,
255
+ )) as Record<string, unknown> | undefined) ?? {
256
+ // No answer (bridge closed, or the session could not read it): fail closed.
257
+ hookSpecificOutput: {
258
+ hookEventName: 'PreToolUse',
259
+ permissionDecision: 'deny',
260
+ permissionDecisionReason: 'Tool gate unavailable',
261
+ },
262
+ },
263
+ ],
264
+ },
265
+ ],
266
+ },
267
+ }
268
+ : undefined;
269
+
173
270
  const stringEnv = Object.fromEntries(
174
271
  Object.entries(env).filter(
175
272
  (entry): entry is [string, string] => typeof entry[1] === 'string',
@@ -181,6 +278,19 @@ function bridge(
181
278
  cwd,
182
279
  env: stringEnv,
183
280
  ...(config.canUseTool ? { canUseTool: config.canUseTool } : {}),
281
+ ...(gate
282
+ ? {
283
+ canUseTool: gate.canUseTool,
284
+ hooks: {
285
+ ...((config.sdkOptions?.hooks as Record<string, unknown> | undefined) ?? {}),
286
+ PreToolUse: [
287
+ ...(((config.sdkOptions?.hooks as Record<string, unknown[]> | undefined)
288
+ ?.PreToolUse ?? []) as unknown[]),
289
+ ...gate.hooks.PreToolUse,
290
+ ],
291
+ },
292
+ }
293
+ : {}),
184
294
  ...(config.bundledCli ? {} : { pathToClaudeCodeExecutable: Bun.which(cmd[0]!) ?? cmd[0] }),
185
295
  };
186
296
  let query: ClaudeAgentSdkQuery,
@@ -223,10 +333,27 @@ function bridge(
223
333
  inbox.push({ ...message, parent_tool_use_id: null, session_id: '' });
224
334
  wake?.();
225
335
  wake = undefined;
336
+ } else if (message.type === 'control_response') {
337
+ const response = message.response as Record<string, unknown> | undefined;
338
+ const id = String(response?.request_id);
339
+ const resolve = awaiting.get(id);
340
+ if (resolve) {
341
+ awaiting.delete(id);
342
+ resolve(response?.subtype === 'success' ? response.response : undefined);
343
+ }
226
344
  } else if (message.type === 'control_request') {
227
345
  const id = String(message.request_id);
228
- const subtype = (message.request as Record<string, unknown> | undefined)?.subtype;
229
- if (subtype === 'interrupt')
346
+ const request = message.request as Record<string, unknown> | undefined;
347
+ const subtype = request?.subtype;
348
+ if (subtype === 'initialize' && gated) {
349
+ // Hooks were configured as SDK options at launch; only the callback id is new.
350
+ const pre = (request?.hooks as Record<string, unknown> | undefined)?.PreToolUse;
351
+ const ids = Array.isArray(pre)
352
+ ? (pre[0] as { hookCallbackIds?: unknown } | undefined)?.hookCallbackIds
353
+ : undefined;
354
+ hookCallbackId = Array.isArray(ids) ? String(ids[0]) : undefined;
355
+ queueMicrotask(() => respond(id));
356
+ } else if (subtype === 'interrupt')
230
357
  query.interrupt().then(
231
358
  () => respond(id),
232
359
  (e) => respond(id, String((e as Error)?.message ?? e)),
@@ -254,6 +381,8 @@ function bridge(
254
381
  kill() {
255
382
  if (killed) return;
256
383
  killed = true;
384
+ for (const resolve of awaiting.values()) resolve(undefined);
385
+ awaiting.clear();
257
386
  inputEnded = true;
258
387
  wake?.();
259
388
  wake = undefined;
@@ -277,6 +406,10 @@ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
277
406
  constructor(config: ClaudeAgentSdkSessionConfig) {
278
407
  if (typeof config?.query !== 'function')
279
408
  throw Error('ClaudeAgentSdkSession requires `query` from @anthropic-ai/claude-agent-sdk');
409
+ if (config.canUseTool && config.toolGate)
410
+ throw Error(
411
+ 'ClaudeAgentSdkSession: pass either canUseTool or toolGate (gates registered through onToolRequest), not both',
412
+ );
280
413
  if (config.canUseTool && config.permissionMode === 'bypassPermissions')
281
414
  throw Error(
282
415
  'ClaudeAgentSdkSession: canUseTool is never consulted under permissionMode "bypassPermissions"; pass another mode or drop canUseTool',