@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 +45 -13
- package/package.json +3 -3
- package/src/claude-agent-sdk-session.ts +136 -3
- package/src/claude-code-session.ts +275 -14
- package/src/claude-primed.ts +153 -35
- package/src/codex-session.ts +339 -607
- package/src/harness-session.ts +52 -2
- package/src/index.ts +6 -4
- package/src/pool.ts +1 -1
- package/src/primed.ts +1 -1
- package/src/tool-gate.ts +61 -0
- package/src/transport.ts +11 -38
- package/src/transports.ts +1 -4
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 (
|
|
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
|
|
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-
|
|
73
|
-
|
|
74
|
-
| Status | ✅ live | ✅ live | ✅ live |
|
|
75
|
-
| Resume / fork | native / native |
|
|
76
|
-
| Interrupt | acknowledged |
|
|
77
|
-
| Push | – |
|
|
78
|
-
| Approvals |
|
|
79
|
-
| Limits stream / poll | ✅ / ✅ |
|
|
80
|
-
| Primed reset | 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,
|
|
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.
|
|
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.
|
|
39
|
+
"@inixiative/json-rules": "^3.5.4"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "2.5.2",
|
|
43
|
-
"@inixiative/config": "^0.5.
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
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',
|