@inixiative/agent-session 0.5.0 → 0.6.0

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
@@ -75,12 +75,42 @@ const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOM
75
75
  | Resume / fork | native / native | loaded-only / same-thread | native / – | native / native | – | – |
76
76
  | Interrupt | acknowledged | local-only | acknowledged | acknowledged | – | – |
77
77
  | Push | – | – | steer | – | – | – |
78
- | Approvals | mode | mode | refuse | callback | – | – |
78
+ | Approvals | callback | mode | callback | callback | – | – |
79
79
  | Limits stream / poll | ✅ / ✅ | – / – | ✅ / ✅ | ✅ / – | – | – |
80
80
  | Primed reset | fork | – | fork | – | – | – |
81
81
 
82
82
  Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
83
83
 
84
+ ### Tool gate
85
+
86
+ `onToolRequest(gate)` puts a decision on each tool call. Gates compose in
87
+ registration order; the first deny wins and a throwing gate denies.
88
+
89
+ ```ts
90
+ const session = new ClaudeCodeSession({ cwd, permissionMode: hostMode, toolGate: true });
91
+ session.onToolRequest(async (call) => {
92
+ if (call.stage === "pre-use") return structural(call) ?? { behavior: "allow" };
93
+ return askTheHuman(call, call.signal); // the runtime's own mode wanted to ask
94
+ });
95
+ ```
96
+
97
+ - `pre-use` (Claude only) runs before every call in every mode,
98
+ `bypassPermissions` included; only a deny counts. A PreToolUse callback over
99
+ the control protocol; one the session cannot read is denied.
100
+ - `permission` is an ask the runtime's mode raised; the answer is final (no gate:
101
+ denied). Claude: `--permission-prompt-tool stdio`; `decisionReason` says why it
102
+ asked, `agentId` marks a subagent's call. Codex app-server: command and
103
+ file-change `requestApproval`, answered accept/decline, under
104
+ `appServer: codexPermissions(mode)`. File-change asks carry the item's
105
+ `toolInput.changes` (path, kind, bounded diff); command asks carry
106
+ `networkApprovalContext`. Gated threads pin `approvalsReviewer: "user"` and
107
+ are refused if a config routes approvals elsewhere. Under the default `never`
108
+ policy nothing is asked, so `onToolRequest` throws.
109
+ - `call.signal` aborts when the runtime withdraws the request or the session
110
+ ends (kill, exit, transport failure); a decision after that is never written.
111
+ A turn timeout does not withdraw a pending Claude ask; `interruptNative()` does.
112
+ - A fork starts with a snapshot of the parent's gates.
113
+
84
114
  ## Primed decision sessions
85
115
 
86
116
  One live, warm session per middleware role, primed once with its role
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./routing": "./src/routing.ts"
10
11
  },
11
12
  "files": [
12
13
  "src"
@@ -35,11 +36,11 @@
35
36
  ],
36
37
  "license": "MIT",
37
38
  "dependencies": {
38
- "@inixiative/json-rules": "^3.5.0"
39
+ "@inixiative/json-rules": "^3.5.1"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@biomejs/biome": "2.5.2",
42
- "@inixiative/config": "^0.5.63",
43
+ "@inixiative/config": "^0.5.67",
43
44
  "@types/bun": "1.4.2",
44
45
  "lefthook": "2.1.9",
45
46
  "typescript": "6.0.3"
@@ -52,7 +52,8 @@ export interface ClaudeAgentSdkSessionConfig extends Omit<ClaudeCodeSessionConfi
52
52
  }
53
53
 
54
54
  const SDK_OWNED = new Set(['--print', '--verbose']);
55
- const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format']);
55
+ // `--permission-prompt-tool stdio` is the SDK's own canUseTool channel; the bridge wires it.
56
+ const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format', '--permission-prompt-tool']);
56
57
 
57
58
  /** Map a ClaudeCodeSession argv (without the binary) to SDK options. Exported for tests. */
58
59
  export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unknown> {
@@ -170,6 +171,94 @@ function bridge(
170
171
  },
171
172
  };
172
173
 
174
+ // Tool gate: the SDK's canUseTool and PreToolUse hook become the CLI's own control requests
175
+ // on the bridged stdout, so ClaudeCodeSession answers them exactly as on the CLI transport.
176
+ const gated = cmd.includes('--permission-prompt-tool');
177
+ let hookCallbackId: string | undefined;
178
+ const awaiting = new Map<string, (response: unknown) => void>();
179
+ const ask = (request: Record<string, unknown>, signal?: AbortSignal) =>
180
+ new Promise<unknown>((resolve) => {
181
+ const id = `sdk-bridge-${crypto.randomUUID()}`;
182
+ awaiting.set(id, resolve);
183
+ signal?.addEventListener(
184
+ 'abort',
185
+ () => {
186
+ if (awaiting.delete(id)) {
187
+ emit({ type: 'control_cancel_request', request_id: id });
188
+ resolve(undefined);
189
+ }
190
+ },
191
+ { once: true },
192
+ );
193
+ emit({ type: 'control_request', request_id: id, request });
194
+ });
195
+ const gate = gated
196
+ ? {
197
+ canUseTool: async (
198
+ toolName: string,
199
+ input: Record<string, unknown>,
200
+ context: {
201
+ signal?: AbortSignal;
202
+ toolUseID?: string;
203
+ blockedPath?: string;
204
+ decisionReason?: string;
205
+ description?: string;
206
+ agentID?: string;
207
+ } = {},
208
+ ) =>
209
+ ((await ask(
210
+ {
211
+ subtype: 'can_use_tool',
212
+ tool_name: toolName,
213
+ input,
214
+ ...(context.toolUseID ? { tool_use_id: context.toolUseID } : {}),
215
+ ...(context.blockedPath ? { blocked_path: context.blockedPath } : {}),
216
+ ...(typeof context.decisionReason === 'string' && context.decisionReason
217
+ ? { decision_reason: context.decisionReason }
218
+ : {}),
219
+ ...(typeof context.description === 'string' && context.description
220
+ ? { description: context.description }
221
+ : {}),
222
+ ...(context.agentID ? { agent_id: context.agentID } : {}),
223
+ },
224
+ context.signal,
225
+ )) as Record<string, unknown> | undefined) ?? {
226
+ behavior: 'deny',
227
+ message: 'Approval withdrawn',
228
+ },
229
+ hooks: {
230
+ PreToolUse: [
231
+ {
232
+ matcher: '.*',
233
+ hooks: [
234
+ async (
235
+ input: Record<string, unknown>,
236
+ toolUseID: string | undefined,
237
+ context: { signal?: AbortSignal } = {},
238
+ ) =>
239
+ ((await ask(
240
+ {
241
+ subtype: 'hook_callback',
242
+ callback_id: hookCallbackId,
243
+ input,
244
+ ...(toolUseID ? { tool_use_id: toolUseID } : {}),
245
+ },
246
+ context.signal,
247
+ )) as Record<string, unknown> | undefined) ?? {
248
+ // No answer (bridge closed, or the session could not read it): fail closed.
249
+ hookSpecificOutput: {
250
+ hookEventName: 'PreToolUse',
251
+ permissionDecision: 'deny',
252
+ permissionDecisionReason: 'Tool gate unavailable',
253
+ },
254
+ },
255
+ ],
256
+ },
257
+ ],
258
+ },
259
+ }
260
+ : undefined;
261
+
173
262
  const stringEnv = Object.fromEntries(
174
263
  Object.entries(env).filter(
175
264
  (entry): entry is [string, string] => typeof entry[1] === 'string',
@@ -181,6 +270,19 @@ function bridge(
181
270
  cwd,
182
271
  env: stringEnv,
183
272
  ...(config.canUseTool ? { canUseTool: config.canUseTool } : {}),
273
+ ...(gate
274
+ ? {
275
+ canUseTool: gate.canUseTool,
276
+ hooks: {
277
+ ...((config.sdkOptions?.hooks as Record<string, unknown> | undefined) ?? {}),
278
+ PreToolUse: [
279
+ ...(((config.sdkOptions?.hooks as Record<string, unknown[]> | undefined)
280
+ ?.PreToolUse ?? []) as unknown[]),
281
+ ...gate.hooks.PreToolUse,
282
+ ],
283
+ },
284
+ }
285
+ : {}),
184
286
  ...(config.bundledCli ? {} : { pathToClaudeCodeExecutable: Bun.which(cmd[0]!) ?? cmd[0] }),
185
287
  };
186
288
  let query: ClaudeAgentSdkQuery,
@@ -223,10 +325,27 @@ function bridge(
223
325
  inbox.push({ ...message, parent_tool_use_id: null, session_id: '' });
224
326
  wake?.();
225
327
  wake = undefined;
328
+ } else if (message.type === 'control_response') {
329
+ const response = message.response as Record<string, unknown> | undefined;
330
+ const id = String(response?.request_id);
331
+ const resolve = awaiting.get(id);
332
+ if (resolve) {
333
+ awaiting.delete(id);
334
+ resolve(response?.subtype === 'success' ? response.response : undefined);
335
+ }
226
336
  } else if (message.type === 'control_request') {
227
337
  const id = String(message.request_id);
228
- const subtype = (message.request as Record<string, unknown> | undefined)?.subtype;
229
- if (subtype === 'interrupt')
338
+ const request = message.request as Record<string, unknown> | undefined;
339
+ const subtype = request?.subtype;
340
+ if (subtype === 'initialize' && gated) {
341
+ // Hooks were configured as SDK options at launch; only the callback id is new.
342
+ const pre = (request?.hooks as Record<string, unknown> | undefined)?.PreToolUse;
343
+ const ids = Array.isArray(pre)
344
+ ? (pre[0] as { hookCallbackIds?: unknown } | undefined)?.hookCallbackIds
345
+ : undefined;
346
+ hookCallbackId = Array.isArray(ids) ? String(ids[0]) : undefined;
347
+ queueMicrotask(() => respond(id));
348
+ } else if (subtype === 'interrupt')
230
349
  query.interrupt().then(
231
350
  () => respond(id),
232
351
  (e) => respond(id, String((e as Error)?.message ?? e)),
@@ -254,6 +373,8 @@ function bridge(
254
373
  kill() {
255
374
  if (killed) return;
256
375
  killed = true;
376
+ for (const resolve of awaiting.values()) resolve(undefined);
377
+ awaiting.clear();
257
378
  inputEnded = true;
258
379
  wake?.();
259
380
  wake = undefined;
@@ -277,6 +398,10 @@ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
277
398
  constructor(config: ClaudeAgentSdkSessionConfig) {
278
399
  if (typeof config?.query !== 'function')
279
400
  throw Error('ClaudeAgentSdkSession requires `query` from @anthropic-ai/claude-agent-sdk');
401
+ if (config.canUseTool && config.toolGate)
402
+ throw Error(
403
+ 'ClaudeAgentSdkSession: pass either canUseTool or toolGate (gates registered through onToolRequest), not both',
404
+ );
280
405
  if (config.canUseTool && config.permissionMode === 'bypassPermissions')
281
406
  throw Error(
282
407
  'ClaudeAgentSdkSession: canUseTool is never consulted under permissionMode "bypassPermissions"; pass another mode or drop canUseTool',
@@ -5,6 +5,7 @@ import {
5
5
  mergeLimits,
6
6
  } from './limits';
7
7
  import { retainEvidence } from './retained-evidence';
8
+ import { mcpToolParts, ToolGates } from './tool-gate';
8
9
  import { TRANSPORTS } from './transport';
9
10
  import { TurnState } from './turn-state';
10
11
 
@@ -53,6 +54,9 @@ import type {
53
54
  SessionResult,
54
55
  SessionSendOptions,
55
56
  SessionTokens,
57
+ ToolDecision,
58
+ ToolGate,
59
+ ToolRequest,
56
60
  } from './harness-session';
57
61
 
58
62
  // Re-export types so existing import paths keep working
@@ -113,6 +117,14 @@ export interface ClaudeCodeSessionConfig {
113
117
  textOnly?: boolean;
114
118
  /** False adds `--no-session-persistence` (nothing written to history; cannot be resumed). Default true. */
115
119
  persistSession?: boolean;
120
+ /**
121
+ * Launch for per-call tool decisions: `--permission-prompt-tool stdio` plus a PreToolUse
122
+ * callback registered over the control protocol at start(). Gates added with
123
+ * onToolRequest() decide (a `permission` ask with no gate is denied). A callback or ask that
124
+ * cannot be read as a tool request is denied (fail closed). A turn timeout leaves a pending
125
+ * ask open; interruptNative() withdraws it. Not with textOnly.
126
+ */
127
+ toolGate?: boolean;
116
128
  /**
117
129
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a
118
130
  * fake subprocess that emulates the claude CLI's stream-json protocol.
@@ -252,6 +264,11 @@ export class ClaudeCodeSession implements HarnessSession {
252
264
  private _env?: ClaudeCodeSessionConfig['env'];
253
265
  private _textOnly: boolean;
254
266
  private _persistSession: boolean;
267
+ private _toolGate: boolean;
268
+ private _gates = new ToolGates();
269
+ /** Inbound control requests awaiting a gate decision, by request id. */
270
+ private _inbound = new Map<string, AbortController>();
271
+ private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
255
272
  private _limits?: LimitSnapshot;
256
273
  private _controlSeq = 0;
257
274
  private _control = new Map<
@@ -325,6 +342,9 @@ export class ClaudeCodeSession implements HarnessSession {
325
342
  this._env = config?.env;
326
343
  this._textOnly = config?.textOnly ?? false;
327
344
  this._persistSession = config?.persistSession ?? true;
345
+ this._toolGate = config?.toolGate ?? false;
346
+ if (this._toolGate && this._textOnly)
347
+ throw new Error('toolGate requires a session with tools; textOnly sessions have none');
328
348
  this._startedAt = Date.now();
329
349
  }
330
350
 
@@ -375,6 +395,12 @@ export class ClaudeCodeSession implements HarnessSession {
375
395
  };
376
396
  }
377
397
 
398
+ /** Register a tool gate. A fork gets a snapshot of the gates registered at fork() time. */
399
+ onToolRequest(gate: ToolGate): () => void {
400
+ if (!this._toolGate) throw new Error('Tool gates need a session launched with toolGate: true');
401
+ return this._gates.add(gate);
402
+ }
403
+
378
404
  onBeforeSend(hook: BeforeSendHook): () => void {
379
405
  this._beforeSendHooks.push(hook);
380
406
  return () => {
@@ -456,11 +482,30 @@ export class ClaudeCodeSession implements HarnessSession {
456
482
  this._rejectControl(new Error('Session ended'));
457
483
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
458
484
  });
485
+
486
+ // The PreToolUse callback is registered before any turn is written, so it covers the first call.
487
+ if (this._toolGate) {
488
+ try {
489
+ await this._controlRequest(
490
+ {
491
+ subtype: 'initialize',
492
+ hooks: { PreToolUse: [{ matcher: '.*', hookCallbackIds: [this._hookCallbackId] }] },
493
+ },
494
+ 30_000,
495
+ );
496
+ } catch (error) {
497
+ this.kill();
498
+ throw new Error(`Tool gate registration failed: ${(error as Error).message}`);
499
+ }
500
+ }
459
501
  }
460
502
 
461
503
  private _rejectControl(error: Error): void {
462
504
  for (const pending of this._control.values()) pending.reject(error);
463
505
  this._control.clear();
506
+ // Decisions still pending have nowhere to go: withdraw them.
507
+ for (const inbound of this._inbound.values()) inbound.abort();
508
+ this._inbound.clear();
464
509
  }
465
510
 
466
511
  // ---------------------------------------------------------------------------
@@ -530,7 +575,7 @@ export class ClaudeCodeSession implements HarnessSession {
530
575
  }
531
576
 
532
577
  // ---------------------------------------------------------------------------
533
- // fork() — branch from current conversation state
578
+ // fork() — branch from current conversation state (and a snapshot of the tool gates)
534
579
  // ---------------------------------------------------------------------------
535
580
 
536
581
  fork(opts?: {
@@ -558,7 +603,9 @@ export class ClaudeCodeSession implements HarnessSession {
558
603
  env: this._env,
559
604
  textOnly: this._textOnly,
560
605
  persistSession: opts?.persistSession ?? this._persistSession,
606
+ toolGate: this._toolGate,
561
607
  });
608
+ for (const gate of this._gates.list()) forked._gates.add(gate);
562
609
  forked._forking = true;
563
610
  return forked;
564
611
  }
@@ -930,9 +977,32 @@ export class ClaudeCodeSession implements HarnessSession {
930
977
  }
931
978
  return;
932
979
  }
980
+ if (raw.type === 'control_cancel_request') {
981
+ const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
982
+ const inbound = id ? this._inbound.get(id) : undefined;
983
+ if (inbound && id) {
984
+ this._inbound.delete(id);
985
+ inbound.abort();
986
+ }
987
+ return;
988
+ }
933
989
  if (raw.type === 'control_request') {
934
- // No client-side handlers (can_use_tool, hooks) are registered on this transport.
935
990
  const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
991
+ const request = raw.request as Record<string, unknown> | undefined;
992
+ const toolRequest =
993
+ id && this._toolGate && this._proc ? this._toolRequestFrom(request) : undefined;
994
+ if (id && toolRequest) {
995
+ this._answerToolRequest(this._proc!, id, toolRequest);
996
+ return;
997
+ }
998
+ // A gated ask or our own callback that cannot be read is denied: an error reply allows.
999
+ const unreadable = id && this._toolGate && this._proc ? this._unreadable(request) : undefined;
1000
+ if (id && unreadable) {
1001
+ this._writeControlResponse(this._proc!, id, unreadable);
1002
+ this._unattributed(raw, 'unrecognized-event');
1003
+ return;
1004
+ }
1005
+ // No other client-side handlers are registered on this transport.
936
1006
  if (id && this._proc) {
937
1007
  try {
938
1008
  this._proc.stdin.write(
@@ -1094,6 +1164,129 @@ export class ClaudeCodeSession implements HarnessSession {
1094
1164
  }
1095
1165
  }
1096
1166
 
1167
+ // ---------------------------------------------------------------------------
1168
+ // Private — tool gate (control protocol: hook_callback, can_use_tool)
1169
+ // ---------------------------------------------------------------------------
1170
+
1171
+ private _toolRequestFrom(
1172
+ request: Record<string, unknown> | undefined,
1173
+ ): Omit<ToolRequest, 'signal'> | undefined {
1174
+ const record = (value: unknown) =>
1175
+ value && typeof value === 'object' && !Array.isArray(value)
1176
+ ? (value as Record<string, unknown>)
1177
+ : undefined;
1178
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1179
+ let stage: ToolRequest['stage'];
1180
+ let source: Record<string, unknown> | undefined;
1181
+ let toolName: string | undefined;
1182
+ let toolInput: Record<string, unknown> | undefined;
1183
+ if (request && this._ownHookCallback(request)) {
1184
+ source = record(request.input);
1185
+ if (source?.hook_event_name !== 'PreToolUse') return undefined;
1186
+ stage = 'pre-use';
1187
+ toolName = text(source.tool_name);
1188
+ toolInput = record(source.tool_input);
1189
+ } else if (request?.subtype === 'can_use_tool') {
1190
+ source = request;
1191
+ stage = 'permission';
1192
+ toolName = text(request.tool_name);
1193
+ toolInput = record(request.input);
1194
+ } else return undefined;
1195
+ if (!toolName) return undefined;
1196
+ const callId = text(source?.tool_use_id) ?? text(request.tool_use_id);
1197
+ const agentId = text(source?.agent_id) ?? text(request.agent_id);
1198
+ const turn = this._inflight;
1199
+ return {
1200
+ stage,
1201
+ toolName,
1202
+ ...mcpToolParts(toolName),
1203
+ toolInput: Object.freeze(structuredClone(toolInput ?? {})),
1204
+ ...(callId ? { callId } : {}),
1205
+ ...(turn?.evidence.dispatch === 'attempted' && turn.evidence.nativeOutcome === 'unknown'
1206
+ ? { admissionId: turn.evidence.admissionId }
1207
+ : {}),
1208
+ ...(text(request.description) ? { description: text(request.description) } : {}),
1209
+ ...(text(request.decision_reason) ? { decisionReason: text(request.decision_reason) } : {}),
1210
+ ...(text(request.blocked_path) ? { blockedPath: text(request.blocked_path) } : {}),
1211
+ ...(agentId ? { agentId } : {}),
1212
+ };
1213
+ }
1214
+
1215
+ private _ownHookCallback(request: Record<string, unknown> | undefined): boolean {
1216
+ return request?.subtype === 'hook_callback' && request.callback_id === this._hookCallbackId;
1217
+ }
1218
+
1219
+ /** The deny for a gated request _toolRequestFrom could not read; undefined when not ours. */
1220
+ private _unreadable(
1221
+ request: Record<string, unknown> | undefined,
1222
+ ): Record<string, unknown> | undefined {
1223
+ if (this._ownHookCallback(request))
1224
+ return this._toolResponse(
1225
+ { stage: 'pre-use', toolName: '', toolInput: {} },
1226
+ { behavior: 'deny', message: 'Tool gate: unreadable PreToolUse callback' },
1227
+ );
1228
+ if (request?.subtype === 'can_use_tool')
1229
+ return { behavior: 'deny', message: 'Tool gate: unreadable permission request' };
1230
+ return undefined;
1231
+ }
1232
+
1233
+ private _answerToolRequest(
1234
+ proc: PipedSubprocess,
1235
+ requestId: string,
1236
+ request: Omit<ToolRequest, 'signal'>,
1237
+ ): void {
1238
+ const controller = new AbortController();
1239
+ this._inbound.set(requestId, controller);
1240
+ void this._gates
1241
+ .decide({ ...request, signal: controller.signal })
1242
+ .then((decision) => {
1243
+ // Withdrawn by the runtime, or the process is gone: nothing may be written for it.
1244
+ if (controller.signal.aborted || this._proc !== proc || !this._alive) return;
1245
+ this._inbound.delete(requestId);
1246
+ this._writeControlResponse(proc, requestId, this._toolResponse(request, decision));
1247
+ })
1248
+ .finally(() => {
1249
+ if (this._inbound.get(requestId) === controller) this._inbound.delete(requestId);
1250
+ });
1251
+ }
1252
+
1253
+ private _toolResponse(
1254
+ request: Omit<ToolRequest, 'signal'>,
1255
+ decision: ToolDecision | undefined,
1256
+ ): Record<string, unknown> {
1257
+ if (request.stage === 'pre-use')
1258
+ return decision?.behavior === 'deny'
1259
+ ? {
1260
+ hookSpecificOutput: {
1261
+ hookEventName: 'PreToolUse',
1262
+ permissionDecision: 'deny',
1263
+ permissionDecisionReason: decision.message,
1264
+ },
1265
+ }
1266
+ : {};
1267
+ return decision?.behavior === 'allow'
1268
+ ? { behavior: 'allow', updatedInput: structuredClone(request.toolInput) }
1269
+ : { behavior: 'deny', message: decision?.message ?? 'Denied' };
1270
+ }
1271
+
1272
+ private _writeControlResponse(
1273
+ proc: PipedSubprocess,
1274
+ requestId: string,
1275
+ response: Record<string, unknown>,
1276
+ ): void {
1277
+ try {
1278
+ proc.stdin.write(
1279
+ `${JSON.stringify({
1280
+ type: 'control_response',
1281
+ response: { subtype: 'success', request_id: requestId, response },
1282
+ })}\n`,
1283
+ );
1284
+ proc.stdin.flush();
1285
+ } catch {
1286
+ /* transport failure surfaces on the stream */
1287
+ }
1288
+ }
1289
+
1097
1290
  // ---------------------------------------------------------------------------
1098
1291
  // Private — spawn args (called once at start())
1099
1292
  // ---------------------------------------------------------------------------
@@ -1115,6 +1308,7 @@ export class ClaudeCodeSession implements HarnessSession {
1115
1308
  '--permission-mode',
1116
1309
  this._permissionMode,
1117
1310
  '--include-hook-events',
1311
+ ...(this._toolGate ? ['--permission-prompt-tool', 'stdio'] : []),
1118
1312
  ];
1119
1313
 
1120
1314
  // Resume for fork or crash recovery. _externalSessionId is set from
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
3
3
  import { retainEvidence } from './retained-evidence';
4
+ import { ToolGates } from './tool-gate';
4
5
  import { TRANSPORTS } from './transport';
5
6
  import { TurnState } from './turn-state';
6
7
  // ---------------------------------------------------------------------------
@@ -40,6 +41,7 @@ import type {
40
41
  SessionEvent,
41
42
  SessionEventHandler,
42
43
  SessionResult,
44
+ ToolGate,
43
45
  } from './harness-session';
44
46
 
45
47
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
@@ -63,6 +65,40 @@ export interface PipedSubprocess {
63
65
  kill(): void;
64
66
  }
65
67
 
68
+ export type CodexApprovalPolicy = 'never' | 'on-request' | 'untrusted';
69
+ export type CodexSandbox = 'read-only' | 'workspace-write' | 'danger-full-access';
70
+ export type HostPermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions';
71
+
72
+ /**
73
+ * The app-server approval policy and sandbox for a host permission mode (Claude's mode ids).
74
+ * `acceptEdits` maps like `default`: under workspace-write, edits inside the workspace are never
75
+ * asked about; only escalations (writes outside it, commands needing more access) reach the gates.
76
+ */
77
+ export function codexPermissions(mode: HostPermissionMode): {
78
+ approvalPolicy: CodexApprovalPolicy;
79
+ sandbox: CodexSandbox;
80
+ } {
81
+ switch (mode) {
82
+ case 'default':
83
+ case 'acceptEdits':
84
+ return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
85
+ case 'plan':
86
+ return { approvalPolicy: 'never', sandbox: 'read-only' };
87
+ case 'bypassPermissions':
88
+ return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
89
+ }
90
+ }
91
+
92
+ const SANDBOX_TYPES: Record<CodexSandbox, string> = {
93
+ 'read-only': 'readOnly',
94
+ 'workspace-write': 'workspaceWrite',
95
+ 'danger-full-access': 'dangerFullAccess',
96
+ };
97
+ const APPROVAL_METHODS = new Set([
98
+ 'item/commandExecution/requestApproval',
99
+ 'item/fileChange/requestApproval',
100
+ ]);
101
+
66
102
  export type CodexSpawn = (
67
103
  cmd: string[],
68
104
  opts: { cwd: string; env: Record<string, string | undefined> },
@@ -81,6 +117,17 @@ export interface CodexSessionConfig {
81
117
  requireConfiguration?: boolean;
82
118
  config?: Readonly<Record<string, unknown>>;
83
119
  requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
120
+ /**
121
+ * Native approval policy and sandbox for the thread. Default `never` / `danger-full-access`.
122
+ * With `on-request` or `untrusted`, approval requests go to the gates registered through
123
+ * onToolRequest() (stage `permission` only; Codex has no pre-use stage); with none registered
124
+ * they are declined. The thread is started with `approvalsReviewer: "user"` and refused if the
125
+ * runtime reports another reviewer (a config.toml `approvals_reviewer` would bypass the gates).
126
+ * Under `never` nothing reaches a gate and onToolRequest() throws.
127
+ * `codexPermissions(mode)` maps the host permission modes.
128
+ */
129
+ approvalPolicy?: CodexApprovalPolicy;
130
+ sandbox?: CodexSandbox;
84
131
  };
85
132
  /** Path to codex CLI binary. Defaults to "codex". */
86
133
  bin?: string;
@@ -304,6 +351,7 @@ abstract class BaseCodexSession implements HarnessSession {
304
351
  this._rejectQueue(new Error('Session killed'));
305
352
  for (const p of this._pending.values()) p.reject(new Error('Session killed'));
306
353
  this._pending.clear();
354
+ this._withdrawRequests();
307
355
 
308
356
  try {
309
357
  this._proc.stdin.end();
@@ -388,6 +436,7 @@ abstract class BaseCodexSession implements HarnessSession {
388
436
  this._rejectQueue(new Error('Session ended'));
389
437
  for (const p of this._pending.values()) p.reject(new Error('Session ended'));
390
438
  this._pending.clear();
439
+ this._withdrawRequests();
391
440
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
392
441
  });
393
442
 
@@ -595,6 +644,7 @@ abstract class BaseCodexSession implements HarnessSession {
595
644
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
596
645
  for (const p of this._pending.values()) p.reject(err);
597
646
  this._pending.clear();
647
+ this._withdrawRequests();
598
648
  }
599
649
 
600
650
  protected _rejectQueue(err: Error): void {
@@ -791,6 +841,9 @@ abstract class BaseCodexSession implements HarnessSession {
791
841
 
792
842
  protected _observeRpcResult(_id: number, _result: unknown): void {}
793
843
 
844
+ /** The process is gone (kill, exit, transport failure): withdraw server requests still pending. */
845
+ protected _withdrawRequests(): void {}
846
+
794
847
  /** Shared fork constructor — subclass passes its own ctor. */
795
848
  fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
796
849
  if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
@@ -1237,6 +1290,11 @@ export class CodexAppServerSession extends BaseCodexSession {
1237
1290
  private _nativeSessionId?: string;
1238
1291
  private _appAdmission?: string;
1239
1292
  private _turnDone?: () => void;
1293
+ private _gates = new ToolGates();
1294
+ /** Approval requests awaiting a gate decision, by JSON-RPC request id. */
1295
+ private _approvals = new Map<string, AbortController>();
1296
+ /** The owned turn's in-progress fileChange items' changes, by item id, for their approvals. */
1297
+ private _fileChanges = new Map<string, readonly CodexFileChange[]>();
1240
1298
  private _items = new Map<
1241
1299
  string,
1242
1300
  {
@@ -1284,7 +1342,113 @@ export class CodexAppServerSession extends BaseCodexSession {
1284
1342
  }
1285
1343
  }
1286
1344
 
1345
+ /** Needs an `approvalPolicy` other than `never`. Gates registered here are not copied (no fork). */
1346
+ onToolRequest(gate: ToolGate): () => void {
1347
+ if (this._approvalPolicy === 'never')
1348
+ throw new Error(
1349
+ 'Tool gates need an app-server session launched with approvalPolicy "on-request" or "untrusted"; under "never" no approval reaches them',
1350
+ );
1351
+ return this._gates.add(gate);
1352
+ }
1353
+
1354
+ protected override _withdrawRequests(): void {
1355
+ for (const approval of this._approvals.values()) approval.abort();
1356
+ this._approvals.clear();
1357
+ }
1358
+
1359
+ private get _approvalPolicy(): CodexApprovalPolicy {
1360
+ return this._options.approvalPolicy ?? 'never';
1361
+ }
1362
+
1363
+ /** Approval requests are asked of this client, so they must reach the gates. */
1364
+ private get _gated(): boolean {
1365
+ return this._approvalPolicy !== 'never';
1366
+ }
1367
+
1368
+ private get _sandbox(): CodexSandbox {
1369
+ return this._options.sandbox ?? 'danger-full-access';
1370
+ }
1371
+
1372
+ /** A command or file-change approval for the owned turn, decided by the gates. */
1373
+ private _approval(request: Record<string, unknown>): boolean {
1374
+ const method = request.method as string;
1375
+ const params = object(request.params);
1376
+ const a = this._inflight?.evidence;
1377
+ if (
1378
+ !APPROVAL_METHODS.has(method) ||
1379
+ !a?.identity.turnId ||
1380
+ params?.threadId !== a.identity.threadId ||
1381
+ params?.turnId !== a.identity.turnId ||
1382
+ (typeof request.id !== 'string' && typeof request.id !== 'number')
1383
+ )
1384
+ return false;
1385
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1386
+ const command = method === 'item/commandExecution/requestApproval';
1387
+ const network = object(params.networkApprovalContext);
1388
+ const host = str(network?.host),
1389
+ protocol = str(network?.protocol);
1390
+ const changes = str(params.itemId) ? this._fileChanges.get(params.itemId as string) : undefined;
1391
+ const toolInput: Record<string, unknown> = command
1392
+ ? {
1393
+ ...(str(params.command) ? { command: params.command } : {}),
1394
+ ...(str(params.cwd) ? { cwd: params.cwd } : {}),
1395
+ ...(str(params.kind) ? { kind: params.kind } : {}),
1396
+ ...(host ? { networkApprovalContext: { host, ...(protocol ? { protocol } : {}) } } : {}),
1397
+ }
1398
+ : {
1399
+ ...(str(params.grantRoot) ? { grantRoot: params.grantRoot } : {}),
1400
+ ...(changes ? { changes: structuredClone(changes) } : {}),
1401
+ };
1402
+ const proc = this._proc;
1403
+ const id = request.id;
1404
+ const key = JSON.stringify(id);
1405
+ const controller = new AbortController();
1406
+ this._approvals.get(key)?.abort();
1407
+ this._approvals.set(key, controller);
1408
+ const identity = { ...a.identity, admissionId: a.admissionId };
1409
+ this._emit({
1410
+ ...identity,
1411
+ kind: 'native_status',
1412
+ timestamp: Date.now(),
1413
+ raw: { type: 'approval-request', method },
1414
+ });
1415
+ void this._gates
1416
+ .decide({
1417
+ stage: 'permission',
1418
+ toolName: command ? 'shell' : 'apply_patch',
1419
+ toolInput: Object.freeze(toolInput),
1420
+ ...(str(params.itemId) ? { callId: params.itemId as string } : {}),
1421
+ admissionId: a.admissionId,
1422
+ ...(str(params.reason) ? { description: params.reason as string } : {}),
1423
+ signal: controller.signal,
1424
+ })
1425
+ .then((decision) => {
1426
+ if (controller.signal.aborted || this._proc !== proc || !this._alive || !proc) return;
1427
+ this._approvals.delete(key);
1428
+ const accepted = decision?.behavior === 'allow';
1429
+ try {
1430
+ proc.stdin.write(
1431
+ `${JSON.stringify({ jsonrpc: '2.0', id, result: { decision: accepted ? 'accept' : 'decline' } })}\n`,
1432
+ );
1433
+ proc.stdin.flush();
1434
+ } catch {
1435
+ return;
1436
+ }
1437
+ this._emit({
1438
+ ...identity,
1439
+ kind: 'native_status',
1440
+ timestamp: Date.now(),
1441
+ raw: { type: 'approval-response', method, decision: accepted ? 'accept' : 'decline' },
1442
+ });
1443
+ })
1444
+ .finally(() => {
1445
+ if (this._approvals.get(key) === controller) this._approvals.delete(key);
1446
+ });
1447
+ return true;
1448
+ }
1449
+
1287
1450
  protected override _serverRequest(request: Record<string, unknown>): boolean {
1451
+ if (this._approval(request)) return true;
1288
1452
  // JSON-RPC's explicit method-not-supported refusal also covers unknown future
1289
1453
  // requests. Never approve native work or echo private request parameters.
1290
1454
  const payload = `${JSON.stringify({
@@ -1435,8 +1599,10 @@ export class CodexAppServerSession extends BaseCodexSession {
1435
1599
  ...(binding ? { threadId: binding } : {}),
1436
1600
  model: this._model,
1437
1601
  cwd: this._cwd,
1438
- approvalPolicy: 'never',
1439
- sandbox: 'danger-full-access',
1602
+ approvalPolicy: this._approvalPolicy,
1603
+ // Approvals go to the gates, never to a config-selected reviewer (auto_review, guardian).
1604
+ ...(this._gated ? { approvalsReviewer: 'user' } : {}),
1605
+ sandbox: this._sandbox,
1440
1606
  ...(this._options.config ? { config: this._options.config } : {}),
1441
1607
  }),
1442
1608
  );
@@ -1458,6 +1624,16 @@ export class CodexAppServerSession extends BaseCodexSession {
1458
1624
  ? String(object(thread?.status)?.type)
1459
1625
  : 'unknown',
1460
1626
  );
1627
+ if (
1628
+ this._gated &&
1629
+ (response?.approvalsReviewer !== 'user' || response.approvalPolicy !== this._approvalPolicy)
1630
+ )
1631
+ this._refuse(
1632
+ 'approvals-not-routed-to-user',
1633
+ ['user', 'auto_review', 'guardian_subagent'].includes(String(response?.approvalsReviewer))
1634
+ ? String(response?.approvalsReviewer)
1635
+ : 'unknown',
1636
+ );
1461
1637
  if (
1462
1638
  this._options.requireConfiguration &&
1463
1639
  (typeof response?.model !== 'string' ||
@@ -1465,9 +1641,9 @@ export class CodexAppServerSession extends BaseCodexSession {
1465
1641
  typeof response.modelProvider !== 'string' ||
1466
1642
  !response.modelProvider ||
1467
1643
  response.cwd !== this._cwd ||
1468
- response.approvalPolicy !== 'never' ||
1644
+ response.approvalPolicy !== this._approvalPolicy ||
1469
1645
  response.approvalsReviewer !== 'user' ||
1470
- object(response.sandbox)?.type !== 'dangerFullAccess' ||
1646
+ object(response.sandbox)?.type !== SANDBOX_TYPES[this._sandbox] ||
1471
1647
  !Array.isArray(thread?.turns) ||
1472
1648
  thread.turns.some((value) => !this._turn(value)))
1473
1649
  )
@@ -1587,6 +1763,13 @@ export class CodexAppServerSession extends BaseCodexSession {
1587
1763
  const snapshot = codexLimitSnapshot(msg.params, 'stream');
1588
1764
  return snapshot ? [this._observeLimits(snapshot)] : [];
1589
1765
  }
1766
+ if (method === 'serverRequest/resolved') {
1767
+ // The runtime settled or withdrew an approval itself: a later gate decision is discarded.
1768
+ const requestId = object(msg.params)?.requestId;
1769
+ const key = JSON.stringify(requestId);
1770
+ this._approvals.get(key)?.abort();
1771
+ this._approvals.delete(key);
1772
+ }
1590
1773
  const params = object(msg.params),
1591
1774
  turn = this._turn(params?.turn),
1592
1775
  item = object(params?.item);
@@ -1645,6 +1828,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1645
1828
  if (turn.error != null && typeof error?.message !== 'string')
1646
1829
  return unowned('unrecognized-event');
1647
1830
  this._seenTerminals.add(turnId);
1831
+ this._fileChanges.clear();
1648
1832
  this._turnDone?.();
1649
1833
  this._turnDone = undefined;
1650
1834
  return [
@@ -1703,8 +1887,19 @@ export class CodexAppServerSession extends BaseCodexSession {
1703
1887
  },
1704
1888
  ];
1705
1889
  }
1890
+ if (method === 'item/fileChange/patchUpdated') {
1891
+ // Changes only feed the item's approval request; the event itself stays a diagnostic.
1892
+ if (itemId && this._fileChanges.has(itemId))
1893
+ this._fileChanges.set(itemId, fileChanges(params?.changes));
1894
+ return unowned('unrecognized-event');
1895
+ }
1706
1896
  if (method === 'item/completed' || method === 'item/started') {
1707
1897
  if (!itemId || !item || typeof item.type !== 'string') return unowned('unrecognized-event');
1898
+ if (item.type === 'fileChange') {
1899
+ if (method === 'item/started') this._fileChanges.set(itemId, fileChanges(item.changes));
1900
+ else this._fileChanges.delete(itemId);
1901
+ return unowned('unrecognized-event');
1902
+ }
1708
1903
  if (item.type === 'agentMessage') {
1709
1904
  if (method !== 'item/completed') return status();
1710
1905
  if (this._items.get(itemId)?.completed) return unowned('after-terminal');
@@ -1844,6 +2039,49 @@ const object = (value: unknown): Record<string, unknown> | undefined =>
1844
2039
  ? (value as Record<string, unknown>)
1845
2040
  : undefined;
1846
2041
 
2042
+ /** A fileChange item's change as a file-change gate sees it (`toolInput.changes`). */
2043
+ export interface CodexFileChange {
2044
+ readonly path: string;
2045
+ readonly kind: 'add' | 'delete' | 'update';
2046
+ /** Rename target of an `update`. */
2047
+ readonly movePath?: string;
2048
+ /** Unified diff, present while it fits the bound; otherwise `diffOmitted`. */
2049
+ readonly diff?: string;
2050
+ readonly diffOmitted?: true;
2051
+ }
2052
+ const FILE_CHANGE_DIFF_LIMIT = 16 * 1024;
2053
+ const FILE_CHANGE_DIFF_TOTAL = 64 * 1024;
2054
+
2055
+ /** Native FileUpdateChange[] (codex 0.155.1) → CodexFileChange[]; diffs only within the bound. */
2056
+ function fileChanges(value: unknown): readonly CodexFileChange[] {
2057
+ if (!Array.isArray(value)) return [];
2058
+ let budget = FILE_CHANGE_DIFF_TOTAL;
2059
+ const out: CodexFileChange[] = [];
2060
+ for (const entry of value) {
2061
+ const change = object(entry);
2062
+ const kind = object(change?.kind);
2063
+ if (
2064
+ typeof change?.path !== 'string' ||
2065
+ !change.path ||
2066
+ (kind?.type !== 'add' && kind?.type !== 'delete' && kind?.type !== 'update')
2067
+ )
2068
+ continue;
2069
+ const diff = typeof change.diff === 'string' ? change.diff : undefined;
2070
+ const fits =
2071
+ diff !== undefined && diff.length <= FILE_CHANGE_DIFF_LIMIT && diff.length <= budget;
2072
+ if (fits) budget -= diff.length;
2073
+ out.push({
2074
+ path: change.path,
2075
+ kind: kind.type,
2076
+ ...(kind.type === 'update' && typeof kind.move_path === 'string' && kind.move_path
2077
+ ? { movePath: kind.move_path }
2078
+ : {}),
2079
+ ...(fits ? { diff } : diff ? { diffOmitted: true as const } : {}),
2080
+ });
2081
+ }
2082
+ return out;
2083
+ }
2084
+
1847
2085
  function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
1848
2086
  if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
1849
2087
  let omitted = false;
@@ -232,6 +232,47 @@ export interface SessionSendOptions {
232
232
  onAdmission?: (attempt: SessionAttempt) => void | Promise<void>;
233
233
  }
234
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // Tool gate — per-call decisions from the runtime's native approval channel
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export interface ToolRequest {
240
+ /**
241
+ * `pre-use` (Claude only; Codex has no pre-use stage): before every tool call, in every
242
+ * permission mode (bypass included). Only a deny counts; an allow leaves the runtime's own mode
243
+ * to decide. `permission`: the runtime's own mode would ask the user; the decision is final.
244
+ */
245
+ readonly stage: 'pre-use' | 'permission';
246
+ readonly toolName: string;
247
+ /** `mcp__<server>__<tool>` split, as tool_use events carry it. */
248
+ readonly toolServer?: string;
249
+ readonly toolMethod?: string;
250
+ readonly toolInput: Readonly<Record<string, unknown>>;
251
+ /** Native tool_use id (Claude) or item id (Codex). */
252
+ readonly callId?: string;
253
+ /** The in-flight admission, when one is attributable. */
254
+ readonly admissionId?: string;
255
+ /** The runtime's own description of, or reason for, the call. */
256
+ readonly description?: string;
257
+ /** Claude `can_use_tool` decision_reason: why the runtime's mode escalated to an ask. */
258
+ readonly decisionReason?: string;
259
+ /** Claude `can_use_tool` blocked_path. */
260
+ readonly blockedPath?: string;
261
+ /** Claude subagent id (`agent_id`) when a subagent, not the main thread, makes the call. */
262
+ readonly agentId?: string;
263
+ /**
264
+ * Aborted when the runtime withdraws the request or the session ends; a later decision is
265
+ * discarded. A turn timeout does not withdraw a pending Claude ask; interruptNative() does
266
+ * (the CLI sends control_cancel_request).
267
+ */
268
+ readonly signal?: AbortSignal;
269
+ }
270
+ export type ToolDecision =
271
+ | { readonly behavior: 'allow' }
272
+ | { readonly behavior: 'deny'; readonly message: string };
273
+ /** Gates compose in registration order: the first deny wins; a thrown or rejected gate denies. */
274
+ export type ToolGate = (request: ToolRequest) => ToolDecision | Promise<ToolDecision>;
275
+
235
276
  // ---------------------------------------------------------------------------
236
277
  // HarnessSession interface
237
278
  // ---------------------------------------------------------------------------
@@ -277,7 +318,8 @@ export interface HarnessSession {
277
318
 
278
319
  /**
279
320
  * Fork: create a new (unstarted) session branching from current state.
280
- * Caller must call start() on the forked session.
321
+ * Caller must call start() on the forked session. A fork starts with a snapshot of the
322
+ * parent's tool gates; gates registered or unregistered afterwards on either side stay local.
281
323
  */
282
324
  fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession;
283
325
 
@@ -297,6 +339,14 @@ export interface HarnessSession {
297
339
  */
298
340
  onBeforeSend(hook: BeforeSendHook): () => void;
299
341
 
342
+ /**
343
+ * Register a tool gate. Present where the transport declares `approvals: "callback"`; the
344
+ * session must be launched for callbacks (Claude: `toolGate: true`; Codex app-server: an
345
+ * `approvalPolicy` other than `never`), otherwise this throws. Forks copy the gates registered
346
+ * at fork() time. Returns an unregister function.
347
+ */
348
+ onToolRequest?(gate: ToolGate): () => void;
349
+
300
350
  /**
301
351
  * Mid-turn push: out-of-band signal injected into an in-flight turn.
302
352
  * Used by guards and Herald to deliver urgent feedback. Best-effort —
package/src/index.ts CHANGED
@@ -30,11 +30,16 @@ export {
30
30
  // Codex CLI adapter (persistent JSON-RPC session). CodexSession defaults to the
31
31
  // mcp-server variant; CodexAppServerSession is the app-server one.
32
32
  export {
33
+ type CodexApprovalPolicy,
33
34
  CodexAppServerSession,
35
+ type CodexFileChange,
34
36
  CodexMcpSession,
37
+ type CodexSandbox,
35
38
  CodexSession,
36
39
  type CodexSessionConfig,
37
40
  type CodexSpawn,
41
+ codexPermissions,
42
+ type HostPermissionMode,
38
43
  } from './codex-session';
39
44
  // The provider-agnostic interface + event taxonomy.
40
45
  export * from './harness-session';
package/src/pool.ts CHANGED
@@ -19,12 +19,14 @@ import type { HarnessSession } from './harness-session';
19
19
  import { type LimitSnapshot, mergeLimits } from './limits';
20
20
  import { probeClaudeLimits, probeCodexLimits } from './limits-probe';
21
21
  import {
22
+ type BillingMode,
22
23
  type ExclusionReason,
23
24
  type OrderingSpec,
24
25
  type RankedCandidate,
25
26
  type ResolvedOrdering,
26
27
  rankCandidates,
27
28
  type SubscriptionCandidate,
29
+ type UnknownUtilizationPolicy,
28
30
  } from './routing';
29
31
  import { TRANSPORTS, type TransportKind } from './transport';
30
32
  import { createSession } from './transports';
@@ -66,6 +68,9 @@ export interface PoolRequest {
66
68
  readonly ordering?: OrderingSpec;
67
69
  /** Only the preferred instance may serve. Requires preferredInstanceId. */
68
70
  readonly pinned?: boolean;
71
+ /** Billing modes this request may use. Default: subscription only. */
72
+ readonly allowedBilling?: readonly BillingMode[];
73
+ readonly unknownUtilization?: UnknownUtilizationPolicy;
69
74
  /** Restrict to these transports (e.g. only resumable ones). */
70
75
  readonly transports?: readonly TransportKind[];
71
76
  readonly excludeInstanceIds?: readonly string[];
@@ -228,6 +233,8 @@ export class SubscriptionPool {
228
233
  const limit = config.concurrencyLimit ?? 1;
229
234
  if (!Number.isSafeInteger(limit) || limit < 1)
230
235
  throw Error(`Instance ${config.id}: concurrencyLimit must be a positive integer`);
236
+ if (config.priority !== undefined && !Number.isSafeInteger(config.priority))
237
+ throw Error(`Instance ${config.id}: priority must be a safe integer`);
231
238
  this._instances.set(config.id, {
232
239
  config,
233
240
  runtime,
@@ -332,6 +339,10 @@ export class SubscriptionPool {
332
339
  maximumObservationAgeMs: this._maxAge,
333
340
  ordering: request.ordering,
334
341
  ...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
342
+ ...(request.allowedBilling !== undefined ? { allowedBilling: request.allowedBilling } : {}),
343
+ ...(request.unknownUtilization !== undefined
344
+ ? { unknownUtilization: request.unknownUtilization }
345
+ : {}),
335
346
  organizationId: request.organizationId,
336
347
  preferredId: request.preferredInstanceId,
337
348
  },
package/src/routing.ts CHANGED
@@ -74,9 +74,8 @@ const ROUTING_FIELDS = Object.keys(NATURAL_DIRECTION) as RoutingField[];
74
74
  /** Resolve a strategy name or explicit term list into terms, each with a direction. */
75
75
  export const resolveOrdering = (spec: OrderingSpec = DEFAULT_STRATEGY): ResolvedOrdering => {
76
76
  if (typeof spec === 'string') {
77
- const preset = ROUTING_STRATEGIES[spec];
78
- if (!preset) throw Error(`Unknown routing strategy "${spec}"`);
79
- return resolveOrdering(preset);
77
+ if (!Object.hasOwn(ROUTING_STRATEGIES, spec)) throw Error(`Unknown routing strategy "${spec}"`);
78
+ return resolveOrdering(ROUTING_STRATEGIES[spec]);
80
79
  }
81
80
  if (!spec.length) throw Error('Ordering requires at least one term');
82
81
  return spec.map((term) => {
@@ -228,7 +227,7 @@ export function repositoryIdentity(remote: string): string {
228
227
  return `${url.hostname.toLowerCase()}/${normalized}`;
229
228
  }
230
229
 
231
- /** Why one candidate is ineligible, or undefined with its utilization when eligible. */
230
+ /** Why one candidate is ineligible, or its utilization when eligible. */
232
231
  export function assessCandidate(
233
232
  request: CandidateRequest,
234
233
  account: SubscriptionCandidate,
@@ -257,13 +256,13 @@ export function assessCandidate(
257
256
  return { excluded: 'unavailable' };
258
257
  if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
259
258
  if (account.blocked) return { excluded: 'blocked' };
260
- const unreadable = request.unknownUtilization === 'rank-last' ? null : undefined;
261
- if (account.windows.length === 0) {
262
- if (unreadable === undefined) return { excluded: 'no-observation' };
263
- return { utilizationPercent: null };
264
- }
265
- const fractions: number[] = [];
259
+ const rankLast = request.unknownUtilization === 'rank-last';
260
+ if (account.windows.length === 0)
261
+ return rankLast ? { utilizationPercent: null } : { excluded: 'no-observation' };
262
+ const readable: number[] = [];
263
+ let unreadable = false;
266
264
  for (const window of account.windows) {
265
+ // Corrupt is not unknown: a structurally impossible reading is never routable under any policy.
267
266
  if (
268
267
  window.usedPercent === null ||
269
268
  !Number.isFinite(window.usedPercent) ||
@@ -272,7 +271,7 @@ export function assessCandidate(
272
271
  !Number.isFinite(window.reservedPercent) ||
273
272
  window.reservedPercent < 0
274
273
  )
275
- return unreadable === undefined ? { excluded: 'invalid' } : { utilizationPercent: null };
274
+ return { excluded: 'invalid' };
276
275
  // resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
277
276
  if (
278
277
  !Number.isFinite(window.observedAt) ||
@@ -280,14 +279,17 @@ export function assessCandidate(
280
279
  request.now - window.observedAt > request.maximumObservationAgeMs ||
281
280
  (window.resetsAt !== Number.POSITIVE_INFINITY &&
282
281
  (!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
283
- )
284
- return unreadable === undefined
285
- ? { excluded: 'stale-observation' }
286
- : { utilizationPercent: null };
287
- fractions.push(window.usedPercent + window.reservedPercent);
282
+ ) {
283
+ if (!rankLast) return { excluded: 'stale-observation' };
284
+ unreadable = true;
285
+ continue;
286
+ }
287
+ readable.push(window.usedPercent + window.reservedPercent);
288
288
  }
289
- if (fractions.some((p) => p >= 100)) return { excluded: 'exhausted' };
290
- return { utilizationPercent: Math.max(...fractions) };
289
+ // A window that is readable and full exhausts the instance even when another is not.
290
+ if (readable.some((p) => p >= 100)) return { excluded: 'exhausted' };
291
+ if (unreadable || !readable.length) return { utilizationPercent: null };
292
+ return { utilizationPercent: Math.max(...readable) };
291
293
  }
292
294
 
293
295
  /** Term fields as own properties, so json-rules orders them by name. */
@@ -318,6 +320,8 @@ export function rankCandidates(
318
320
  throw Error('Invalid observation policy');
319
321
  if (request.pinned && !request.preferredId)
320
322
  throw Error('Pinned routing requires a preferred instance');
323
+ if (request.allowedBilling?.length === 0)
324
+ throw Error('Ordering requires at least one billing mode');
321
325
  if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
322
326
  throw Error('Duplicate account identity');
323
327
  const ordering = resolveOrdering(request.ordering);
@@ -0,0 +1,61 @@
1
+ import type { ToolDecision, ToolGate, ToolRequest } from './harness-session';
2
+
3
+ /**
4
+ * A session's registered tool gates. They compose in registration order and the first deny
5
+ * wins; a gate that throws or rejects denies. `pre-use` with no deny is no decision (the
6
+ * runtime's own mode decides); `permission` with no gate registered is denied.
7
+ */
8
+ export class ToolGates {
9
+ private readonly _gates: ToolGate[] = [];
10
+
11
+ get size(): number {
12
+ return this._gates.length;
13
+ }
14
+
15
+ add(gate: ToolGate): () => void {
16
+ this._gates.push(gate);
17
+ return () => {
18
+ const index = this._gates.indexOf(gate);
19
+ if (index !== -1) this._gates.splice(index, 1);
20
+ };
21
+ }
22
+
23
+ /** The gates in order, for a sibling session (fork). */
24
+ list(): readonly ToolGate[] {
25
+ return [...this._gates];
26
+ }
27
+
28
+ /** Never rejects. Undefined means no decision: only a `pre-use` request without a deny. */
29
+ async decide(request: ToolRequest): Promise<ToolDecision | undefined> {
30
+ const gates = [...this._gates];
31
+ if (!gates.length)
32
+ return request.stage === 'permission'
33
+ ? { behavior: 'deny', message: 'No approval handler' }
34
+ : undefined;
35
+ for (const gate of gates) {
36
+ let decision: ToolDecision;
37
+ try {
38
+ decision = await gate(request);
39
+ } catch (error) {
40
+ return {
41
+ behavior: 'deny',
42
+ message: `Tool gate failed: ${error instanceof Error ? error.message : String(error)}`,
43
+ };
44
+ }
45
+ if (decision?.behavior === 'deny')
46
+ return { behavior: 'deny', message: String(decision.message ?? 'Denied') };
47
+ if (decision?.behavior !== 'allow')
48
+ return { behavior: 'deny', message: 'Tool gate failed: malformed decision' };
49
+ }
50
+ return request.stage === 'permission' ? { behavior: 'allow' } : undefined;
51
+ }
52
+ }
53
+
54
+ /** `mcp__<server>__<tool>` split into its server and method. */
55
+ export function mcpToolParts(toolName: string): { toolServer?: string; toolMethod?: string } {
56
+ if (!toolName.startsWith('mcp__')) return {};
57
+ const rest = toolName.slice(5);
58
+ const split = rest.indexOf('__');
59
+ if (split <= 0 || split + 2 >= rest.length) return {};
60
+ return { toolServer: rest.slice(0, split), toolMethod: rest.slice(split + 2) };
61
+ }
package/src/transport.ts CHANGED
@@ -34,7 +34,7 @@ export interface TransportCapabilities {
34
34
  readonly interrupt: 'acknowledged' | 'local-only';
35
35
  /** `steer`: push() adds input to the in-flight turn; `none`: push() records push_ignored. */
36
36
  readonly push: 'steer' | 'none';
37
- /** `callback`: per-call decision hook; `mode`: a fixed launch policy (bypass); `refuse`: runtime requests are refused. */
37
+ /** `callback`: per-call decisions through onToolRequest(); `mode`: a fixed launch policy (bypass); `refuse`: runtime requests are refused. */
38
38
  readonly approvals: 'callback' | 'mode' | 'refuse';
39
39
  /** Token usage reported per turn. */
40
40
  readonly usage: boolean;
@@ -72,7 +72,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
72
72
  rollback: 'none',
73
73
  interrupt: 'acknowledged',
74
74
  push: 'none',
75
- approvals: 'mode',
75
+ approvals: 'callback',
76
76
  usage: true,
77
77
  limits: { stream: true, poll: true },
78
78
  textOnly: true,
@@ -84,7 +84,8 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
84
84
  'textOnly launches with no tools, MCP servers, slash commands or user settings.',
85
85
  'Primed decisions (ClaudePrimedSessions) fork a persisted primed session per cycle with a pre-spawned spare.',
86
86
  "interruptNative() and readLimits() use the CLI's stdio control protocol (`interrupt`, `get_usage`).",
87
- 'Rollback (`--resume-session-at`) and per-call approvals (`--permission-prompt-tool stdio`) exist natively but are not wired here.',
87
+ 'toolGate: true launches with `--permission-prompt-tool stdio` and registers a PreToolUse callback over the control protocol: onToolRequest() gates see every call (`pre-use`, any mode, bypass included; only a deny counts) and the asks the permission mode raises (`permission`, final).',
88
+ 'Rollback (`--resume-session-at`) exists natively but is not wired here.',
88
89
  ],
89
90
  },
90
91
  'codex-mcp': {
@@ -121,7 +122,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
121
122
  rollback: 'none',
122
123
  interrupt: 'acknowledged',
123
124
  push: 'steer',
124
- approvals: 'refuse',
125
+ approvals: 'callback',
125
126
  usage: true,
126
127
  limits: { stream: true, poll: true },
127
128
  textOnly: false,
@@ -132,6 +133,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
132
133
  '`codex app-server --listen stdio://` with thread/start, thread/resume, turn/start.',
133
134
  "CodexAppServerSession runs with the profile's tools; tool-free text sessions are CodexPrimedSessions (primed: fork).",
134
135
  'interruptNative() sends turn/interrupt; push() sends turn/steer against the owned turn.',
136
+ 'appServer.approvalPolicy/sandbox (default never / danger-full-access; `codexPermissions(mode)`) set the thread policy; command and file-change approvals for the owned turn go to onToolRequest() gates (`permission`) and are answered accept/decline. Other server requests are refused (-32601).',
135
137
  'Primed decisions (CodexPrimedSessions) host every key as a thread of one process per account and fork the primed thread per cycle.',
136
138
  'thread/rollback is refused by codex-cli 0.155 for both ephemeral and paginated threads; session fork() is not wired.',
137
139
  ],