@sellable/install 0.1.795-wip.runs-api.20261004.1 → 0.1.796

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.
@@ -3,12 +3,12 @@ FROM ${HERMES_IMAGE}
3
3
 
4
4
  USER root
5
5
 
6
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.795-wip.runs-api.20261004.1
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.796
7
7
  ARG MCP_PACKAGE=@sellable/mcp@0.1.957
8
8
  ARG INSTALLER_INTEGRITY
9
9
  ARG MCP_INTEGRITY=sha512-tIZuO5IEanT/afamDIWdorUNMdqZA6xnjL9jGAmGqYIlIqkXdHB9slWiwfLfmM44iEp7OT98IBHJlvbpMThk6A==
10
10
 
11
- RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.795-wip.runs-api.20261004.1" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.796" \
12
12
  && test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.957" \
13
13
  && test -n "${INSTALLER_INTEGRITY}" \
14
14
  && test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
@@ -23,9 +23,9 @@ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.795-wip.runs-api.202610
23
23
  && mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
24
24
  && npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
25
25
  && npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
26
- && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.795-wip.runs-api.20261004.1' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.957') throw new Error('package identity rejected');" \
26
+ && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.796' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.957') throw new Error('package identity rejected');" \
27
27
  && node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
28
- && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.795-wip.runs-api.20261004.1', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.957', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
28
+ && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.796', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.957', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
29
29
  && npm cache clean --force
30
30
 
31
31
  COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
@@ -3,7 +3,7 @@
3
3
  This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
4
4
 
5
5
  Build from this directory after
6
- `@sellable/install@0.1.795-wip.runs-api.20261004.1` is
6
+ `@sellable/install@0.1.796` is
7
7
  published:
8
8
 
9
9
  ```sh
@@ -169,7 +169,7 @@ COPY fly-admin-image/start-admin-runtime /usr/local/lib/sellable-agent/admin-run
169
169
  COPY fly-customer-image/customer-runtime.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime.mjs
170
170
  COPY hermes-first-welcome.mjs /usr/local/lib/sellable-agent/hermes-first-welcome.mjs
171
171
  COPY fly-customer-image/hermes-runs-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs
172
- COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/claude-code-runs-api.mjs /usr/local/lib/sellable-agent/fly-customer-image/
172
+ COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/claude-code-runs-api.mjs fly-customer-image/claude-code-mcp-session.mjs /usr/local/lib/sellable-agent/fly-customer-image/
173
173
  COPY fly-customer-image/opener-stage-execution.mjs /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs
174
174
  COPY fly-customer-image/endpoint-uat-completion-gate.mjs /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs
175
175
  COPY fly-customer-image/customer-runtime-phase-telemetry.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs
@@ -276,6 +276,7 @@ RUN set -eux; \
276
276
  /usr/local/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs \
277
277
  /usr/local/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs \
278
278
  /usr/local/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs \
279
+ /usr/local/lib/sellable-agent/fly-customer-image/claude-code-mcp-session.mjs \
279
280
  /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs \
280
281
  /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs \
281
282
  /usr/local/lib/sellable-agent/admin-native-gateway-supervisor.mjs \
@@ -901,6 +901,32 @@ function installAdminMcpRuntimeBinding(
901
901
  );
902
902
  }
903
903
 
904
+ // The profile's `sellable` MCP server exactly as Hermes runs it, so a Claude
905
+ // Code tool-lane run sees the same tools, through the same context proxy.
906
+ function profileSellableMcpServer(profileRoot) {
907
+ const path = join(profileRoot, "config.yaml");
908
+ const stat = lstatSync(path);
909
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
910
+ const document = parseDocument(readFileSync(path, "utf8"));
911
+ if (document.errors.length > 0) return null;
912
+ const server = document.toJS().mcp_servers?.sellable;
913
+ if (
914
+ server?.command !== NODE ||
915
+ JSON.stringify(server.args) !== JSON.stringify([MCP_CONTEXT_PROXY]) ||
916
+ !server.env ||
917
+ typeof server.env !== "object"
918
+ ) {
919
+ return null;
920
+ }
921
+ return {
922
+ command: NODE,
923
+ args: [MCP_CONTEXT_PROXY],
924
+ env: Object.fromEntries(
925
+ Object.entries(server.env).map(([key, value]) => [key, String(value)])
926
+ ),
927
+ };
928
+ }
929
+
904
930
  function installAdminModelConfig(profileRoot, modelConfig) {
905
931
  const path = join(profileRoot, "config.yaml");
906
932
  const stat = lstatSync(path);
@@ -3822,6 +3848,13 @@ async function runAdminProduction() {
3822
3848
  try {
3823
3849
  customRunsServices = await startConfiguredClaudeRunsApis({
3824
3850
  diagnostic: value => process.stderr.write(`${JSON.stringify(value)}\n`),
3851
+ toolLanes: {
3852
+ turnDescriptorPath: join(MCP_TURN_CONTEXT_ROOT, "turn-context.json"),
3853
+ server: () =>
3854
+ activeLaunch
3855
+ ? profileSellableMcpServer(activeLaunch.profile.profileHome)
3856
+ : null,
3857
+ },
3825
3858
  });
3826
3859
  } catch {
3827
3860
  process.stderr.write(`${JSON.stringify({ ok: false, code: "custom_runs_api_start_failed" })}\n`);
@@ -8,7 +8,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
8
8
 
9
9
  ARG MCP_PACKAGE=@sellable/mcp@0.1.957
10
10
  ARG MCP_INTEGRITY=sha512-tIZuO5IEanT/afamDIWdorUNMdqZA6xnjL9jGAmGqYIlIqkXdHB9slWiwfLfmM44iEp7OT98IBHJlvbpMThk6A==
11
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.795-wip.runs-api.20261004.1
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.796
12
12
  ARG ADMIN_MCP_PACKAGE=@sellable/admin-mcp@0.1.202
13
13
  ARG ADMIN_MCP_INTEGRITY=sha512-ym3aye/0vQ8tfULJlKz9JFOxvIQsl1G+n3np+TTgK/tecwNblBo/j3AkTnRqH9iomKzPbZzP+n4tEQsvsU8ByA==
14
14
  ARG TUS_JS_CLIENT_VERSION=4.3.1
@@ -0,0 +1 @@
1
+ export function tagEndpointSession(line: string, sessionId: string): string;
@@ -0,0 +1,53 @@
1
+ // Claude Code's only MCP server for a tool-lane run. It starts the profile's
2
+ // own `sellable` server (the context proxy, with exactly its configured
3
+ // environment) and adds this run's endpoint session to every tools/list and
4
+ // tools/call. The proxy then loads the run's signed turn descriptor, lists
5
+ // only the run's allowed tools and stamps the run identity on each call; the
6
+ // backend checks it again. Nothing in this file grants anything.
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { createInterface } from "node:readline";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ export function tagEndpointSession(line, sessionId) {
13
+ let message;
14
+ try {
15
+ message = JSON.parse(line);
16
+ } catch {
17
+ return line;
18
+ }
19
+ if (
20
+ !message ||
21
+ typeof message !== "object" ||
22
+ !["tools/list", "tools/call"].includes(message.method)
23
+ )
24
+ return line;
25
+ const params =
26
+ message.params && typeof message.params === "object" ? message.params : {};
27
+ // The proxy accepts exactly this shape and nothing else in _meta.
28
+ return JSON.stringify({
29
+ ...message,
30
+ params: { ...params, _meta: { sellableEndpoint: { sessionId } } },
31
+ });
32
+ }
33
+
34
+ function main(specPath) {
35
+ const spec = JSON.parse(readFileSync(specPath, "utf8"));
36
+ if (!/^sellable_endpoint_[a-f0-9]{32}$/.test(spec.sessionId ?? ""))
37
+ throw new Error("claude_runs_mcp_session_rejected");
38
+ const child = spawn(spec.command, spec.args, {
39
+ stdio: ["pipe", "inherit", "ignore"],
40
+ env: spec.env,
41
+ });
42
+ child.once("exit", (code) => process.exit(code ?? 1));
43
+ child.stdin.on("error", () => {});
44
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
45
+ lines.on("line", (line) =>
46
+ child.stdin.write(`${tagEndpointSession(line, spec.sessionId)}\n`)
47
+ );
48
+ lines.on("close", () => child.stdin.end());
49
+ }
50
+
51
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
52
+ main(process.argv[2]);
53
+ }
@@ -8,6 +8,18 @@ export type ClaudeRunsRequest = {
8
8
  schema: Record<string, unknown> | null;
9
9
  model: string;
10
10
  effort: string;
11
+ sessionId: string;
12
+ tools: ClaudeRunsTools | null;
13
+ };
14
+ export type ClaudeRunsToolServer = {
15
+ command: string;
16
+ args: string[];
17
+ env: Record<string, string>;
18
+ };
19
+ export type ClaudeRunsTools = { names: string[]; server: ClaudeRunsToolServer };
20
+ export type ClaudeRunsToolLanes = {
21
+ turnDescriptorPath: string;
22
+ server(): ClaudeRunsToolServer | null;
11
23
  };
12
24
  export type ClaudeRunsResult = {
13
25
  output: string;
@@ -24,19 +36,35 @@ export type ClaudeRunsApiOptions = {
24
36
  };
25
37
  audit?: (entry: Record<string, unknown>) => void;
26
38
  maxConcurrency?: number;
39
+ maxToolConcurrency?: number;
40
+ toolLanes?: ClaudeRunsToolLanes | null;
27
41
  };
28
42
  export function validateClaudeRunsRequest(
29
43
  body: unknown,
30
44
  models: RunsApiConnection["models"]
31
45
  ): ClaudeRunsRequest;
46
+ export function endpointToolNames(options: {
47
+ turnDescriptorPath: string;
48
+ sessionId: string;
49
+ toolEndpointIds: string[];
50
+ }): string[] | null;
32
51
  export function claudeInvocation(
33
52
  request: ClaudeRunsRequest,
34
- root: string
53
+ root: string,
54
+ mcpSpecPath?: string | null
35
55
  ): { command: string; args: string[]; options: SpawnOptions };
36
56
  export function parseClaudeResult(
37
57
  stdout: string,
38
58
  request: ClaudeRunsRequest
39
59
  ): ClaudeRunsResult;
60
+ export function startClaudeRun(
61
+ request: ClaudeRunsRequest,
62
+ options: {
63
+ root: string;
64
+ spawnImpl?: (...args: any[]) => any;
65
+ timeoutMs?: number;
66
+ }
67
+ ): { promise: Promise<ClaudeRunsResult>; stop(code?: string): void };
40
68
  export function createClaudeRunsApi(
41
69
  options: ClaudeRunsApiOptions
42
70
  ): ClaudeRunsServer;
@@ -45,4 +73,5 @@ export function startConfiguredClaudeRunsApis(options?: {
45
73
  diagnostic?: (entry: Record<string, unknown>) => void;
46
74
  createApi?: typeof createClaudeRunsApi;
47
75
  readToken?: (path: string) => string;
76
+ toolLanes?: ClaudeRunsToolLanes | null;
48
77
  }): Promise<{ stop(): Promise<void> }>;
@@ -1,7 +1,19 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
- import { appendFileSync, readFileSync } from "node:fs";
3
+ import {
4
+ appendFileSync,
5
+ lstatSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
4
10
  import { createServer } from "node:http";
11
+ import { join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import {
14
+ endpointHermesSessionId,
15
+ endpointMcpDescriptorPath,
16
+ } from "../mcp-context-proxy.mjs";
5
17
  import { validateOpenerOutputContract } from "./hermes-runs-client.mjs";
6
18
  import {
7
19
  parseRunsApiConnections,
@@ -11,6 +23,9 @@ import {
11
23
  const MAX_BODY = 320 * 1024;
12
24
  const MAX_OUTPUT = 48 * 1024;
13
25
  const MAX_CLI_OUTPUT = 512 * 1024;
26
+ const MCP_SESSION = fileURLToPath(
27
+ new URL("./claude-code-mcp-session.mjs", import.meta.url)
28
+ );
14
29
  const hash = (s) => createHash("sha256").update(s).digest("hex");
15
30
  const record = (v) => v && typeof v === "object" && !Array.isArray(v);
16
31
 
@@ -74,10 +89,47 @@ export function validateClaudeRunsRequest(body, models) {
74
89
  schema,
75
90
  model: body.model,
76
91
  effort: body.model_options.reasoning_effort,
92
+ sessionId: body.session_id,
93
+ tools: null,
77
94
  };
78
95
  }
79
96
 
80
- export function claudeInvocation(request, root) {
97
+ /**
98
+ * A tool-lane run's own allowed tools, read from the signed turn descriptor
99
+ * the runtime installed for its session before submitting; null when the run
100
+ * is not on a tool endpoint. This only narrows: the context proxy verifies the
101
+ * descriptor itself before it lists or calls any tool.
102
+ */
103
+ export function endpointToolNames({
104
+ turnDescriptorPath,
105
+ sessionId,
106
+ toolEndpointIds,
107
+ }) {
108
+ const path = endpointMcpDescriptorPath(turnDescriptorPath, sessionId);
109
+ if (!path) return null;
110
+ try {
111
+ if (!lstatSync(path).isFile())
112
+ throw new Error("claude_runs_tool_descriptor_rejected");
113
+ } catch (error) {
114
+ if (error.code === "ENOENT") return null;
115
+ throw error;
116
+ }
117
+ const body = JSON.parse(readFileSync(path, "utf8"))?.body;
118
+ if (!toolEndpointIds.includes(body?.endpointId)) return null;
119
+ const names = body.allowedToolNames;
120
+ if (
121
+ endpointHermesSessionId(body) !== sessionId ||
122
+ !Array.isArray(names) ||
123
+ !names.length ||
124
+ names.some(
125
+ (name) => typeof name !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(name)
126
+ )
127
+ )
128
+ throw new Error("claude_runs_tool_descriptor_rejected");
129
+ return [...names];
130
+ }
131
+
132
+ export function claudeInvocation(request, root, mcpSpecPath = null) {
81
133
  return {
82
134
  command: `${root}/cli/node_modules/.bin/claude`,
83
135
  args: [
@@ -97,7 +149,24 @@ export function claudeInvocation(request, root) {
97
149
  "dontAsk",
98
150
  "--strict-mcp-config",
99
151
  "--mcp-config",
100
- '{"mcpServers":{}}',
152
+ // A tool lane's only MCP server is the run's own session wrapper; its
153
+ // allowed tools are exactly the descriptor's. Every other run has none.
154
+ ...(request.tools
155
+ ? [
156
+ JSON.stringify({
157
+ mcpServers: {
158
+ sellable: {
159
+ command: process.execPath,
160
+ args: [MCP_SESSION, mcpSpecPath],
161
+ },
162
+ },
163
+ }),
164
+ "--allowedTools",
165
+ request.tools.names
166
+ .map((name) => `mcp__sellable__${name}`)
167
+ .join(","),
168
+ ]
169
+ : ['{"mcpServers":{}}']),
101
170
  "--setting-sources",
102
171
  "",
103
172
  "--settings",
@@ -105,7 +174,7 @@ export function claudeInvocation(request, root) {
105
174
  "--disable-slash-commands",
106
175
  "--no-session-persistence",
107
176
  "--max-turns",
108
- "3",
177
+ request.tools ? "16" : "3",
109
178
  "--system-prompt",
110
179
  request.instructions,
111
180
  ],
@@ -166,12 +235,28 @@ export function startClaudeRun(
166
235
  request,
167
236
  { root, spawnImpl = spawn, timeoutMs = 750_000 } = {}
168
237
  ) {
169
- const invocation = claudeInvocation(request, root);
170
- const child = spawnImpl(
171
- invocation.command,
172
- invocation.args,
173
- invocation.options
174
- );
238
+ // The tool server's environment travels in a private per-run file, never
239
+ // in argv, and is removed when the run ends.
240
+ const mcpSpecPath = request.tools
241
+ ? join(root, "work", `mcp-session-${randomBytes(12).toString("hex")}.json`)
242
+ : null;
243
+ if (mcpSpecPath)
244
+ writeFileSync(
245
+ mcpSpecPath,
246
+ JSON.stringify({ sessionId: request.sessionId, ...request.tools.server }),
247
+ { mode: 0o600, flag: "wx" }
248
+ );
249
+ const removeSpec = () => {
250
+ if (mcpSpecPath) rmSync(mcpSpecPath, { force: true });
251
+ };
252
+ const invocation = claudeInvocation(request, root, mcpSpecPath);
253
+ let child;
254
+ try {
255
+ child = spawnImpl(invocation.command, invocation.args, invocation.options);
256
+ } catch (error) {
257
+ removeSpec();
258
+ throw error;
259
+ }
175
260
  let reason = null;
176
261
  let killTimer;
177
262
  const stop = (code = "claude_runs_cancelled") => {
@@ -203,11 +288,13 @@ export function startClaudeRun(
203
288
  child.stderr.on("data", () => {});
204
289
  child.once("error", () => {
205
290
  clearTimeout(timer);
291
+ removeSpec();
206
292
  reject(new Error("claude_runs_cli_unavailable"));
207
293
  });
208
294
  child.once("close", (code) => {
209
295
  clearTimeout(timer);
210
296
  clearTimeout(killTimer);
297
+ removeSpec();
211
298
  if (reason || code !== 0)
212
299
  return reject(new Error(reason ?? "claude_runs_cli_failed"));
213
300
  try {
@@ -231,9 +318,33 @@ export function createClaudeRunsApi({
231
318
  startClaudeRun(request, { root: connection.localRoot }),
232
319
  audit = () => {},
233
320
  maxConcurrency = 2,
321
+ // Tool lanes (replies, follow-ups) have their own slots so a batch of
322
+ // openers can never crowd them out.
323
+ maxToolConcurrency = 2,
324
+ toolLanes = null,
234
325
  } = {}) {
235
326
  if (!/^[a-f0-9]{64}$/.test(token ?? ""))
236
327
  throw new Error("claude_runs_key_rejected");
328
+ // The run's own tools and the profile's MCP server, or null for a tool-free run.
329
+ const resolveTools = (request) => {
330
+ if (!connection.toolEndpointIds || !toolLanes) return null;
331
+ const names = endpointToolNames({
332
+ turnDescriptorPath: toolLanes.turnDescriptorPath,
333
+ sessionId: request.sessionId,
334
+ toolEndpointIds: connection.toolEndpointIds,
335
+ });
336
+ if (!names) return null;
337
+ const server = toolLanes.server();
338
+ if (!server) throw new Error("claude_runs_tool_server_unavailable");
339
+ return {
340
+ names,
341
+ server: {
342
+ command: server.command,
343
+ args: [...server.args],
344
+ env: { PATH: "/usr/local/bin:/usr/bin:/bin", ...server.env },
345
+ },
346
+ };
347
+ };
237
348
  const recordAudit = (entry) => {
238
349
  try {
239
350
  audit(entry);
@@ -242,7 +353,9 @@ export function createClaudeRunsApi({
242
353
  }
243
354
  };
244
355
  const runs = new Map();
245
- let active = 0;
356
+ const inFlight = { plain: 0, tools: 0 };
357
+ const limits = { plain: maxConcurrency, tools: maxToolConcurrency };
358
+ const full = (pool) => inFlight[pool] >= limits[pool] || runs.size >= 200;
246
359
  const json = (res, status, body) => {
247
360
  res.writeHead(status, {
248
361
  "content-type": "application/json",
@@ -269,6 +382,7 @@ export function createClaudeRunsApi({
269
382
  recordAudit({
270
383
  runId: run.run_id,
271
384
  status: run.status,
385
+ toolLane: run.pool === "tools",
272
386
  servedRuntime: run.runtime ?? null,
273
387
  usage: run.usage ?? null,
274
388
  durationMs: Date.now() - run.startedAt,
@@ -293,14 +407,15 @@ export function createClaudeRunsApi({
293
407
  return json(res, 200, {
294
408
  ok: true,
295
409
  provider: "claude-code",
296
- active,
410
+ active: inFlight.plain + inFlight.tools,
411
+ toolActive: inFlight.tools,
297
412
  scope: {
298
413
  workspaceId: connection.workspaceId,
299
414
  agentId: connection.agentId,
300
415
  },
301
416
  });
302
417
  if (req.method === "POST" && req.url === "/v1/runs") {
303
- if (active >= maxConcurrency || runs.size >= 200)
418
+ if (full("plain") && (!connection.toolEndpointIds || full("tools")))
304
419
  return json(res, 429, { error: "claude_runs_busy" });
305
420
  let request;
306
421
  try {
@@ -316,6 +431,7 @@ export function createClaudeRunsApi({
316
431
  }
317
432
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
318
433
  request = validateClaudeRunsRequest(body, connection.models);
434
+ request.tools = resolveTools(request);
319
435
  } catch (error) {
320
436
  recordAudit({
321
437
  status: "request-rejected",
@@ -326,16 +442,17 @@ export function createClaudeRunsApi({
326
442
  return json(res, 400, { error: "claude_runs_request_rejected" });
327
443
  }
328
444
  // Check again after reading: concurrent uploads cannot over-admit.
329
- if (active >= maxConcurrency || runs.size >= 200)
330
- return json(res, 429, { error: "claude_runs_busy" });
445
+ const pool = request.tools ? "tools" : "plain";
446
+ if (full(pool)) return json(res, 429, { error: "claude_runs_busy" });
331
447
  const run = {
332
448
  run_id: `run_${randomBytes(16).toString("hex")}`,
333
449
  status: "running",
334
450
  startedAt: Date.now(),
335
451
  listeners: new Set(),
336
452
  consumed: false,
453
+ pool,
337
454
  };
338
- active++;
455
+ inFlight[pool]++;
339
456
  runs.set(run.run_id, run);
340
457
  try {
341
458
  run.execution = execute(request);
@@ -357,9 +474,9 @@ export function createClaudeRunsApi({
357
474
  : { status: "failed", error: "claude_runs_generation_failed" }
358
475
  )
359
476
  )
360
- .finally(() => active--);
477
+ .finally(() => inFlight[pool]--);
361
478
  } catch {
362
- active--;
479
+ inFlight[pool]--;
363
480
  terminal(run, "run.failed", {
364
481
  status: "failed",
365
482
  error: "claude_runs_cli_unavailable",
@@ -438,6 +555,7 @@ export async function startConfiguredClaudeRunsApis({
438
555
  diagnostic = () => {},
439
556
  createApi = createClaudeRunsApi,
440
557
  readToken = (path) => readFileSync(path, "utf8").trim(),
558
+ toolLanes = null,
441
559
  } = {}) {
442
560
  const services = [];
443
561
  for (const connection of parseRunsApiConnections(
@@ -453,6 +571,7 @@ export async function startConfiguredClaudeRunsApis({
453
571
  const server = createApi({
454
572
  token,
455
573
  connection,
574
+ toolLanes,
456
575
  audit: (entry) =>
457
576
  appendFileSync(
458
577
  `${connection.localRoot}/audit.jsonl`,
@@ -126,7 +126,7 @@ const RUNTIME_GENERATION = Number(
126
126
  const BOOT_SESSION_ID = randomUUID();
127
127
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
128
128
  const INSTALLER_PACKAGE =
129
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.795-wip.runs-api.20261004.1";
129
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.796";
130
130
  const MCP_PACKAGE =
131
131
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.957";
132
132
  export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
@@ -13,6 +13,7 @@ export type RunsApiConnection = Readonly<{
13
13
  reasoningEfforts: string[];
14
14
  }>;
15
15
  localRoot?: string;
16
+ toolEndpointIds?: string[];
16
17
  }>;
17
18
  export function parseRunsApiConnections(raw: unknown): RunsApiConnection[];
18
19
  export function runsApiConnectionsForRuntime(
@@ -52,6 +52,7 @@ export function parseRunsApiConnections(raw) {
52
52
  "workKinds",
53
53
  "models",
54
54
  "localRoot",
55
+ "toolEndpointIds",
55
56
  ].includes(key)
56
57
  ) ||
57
58
  ![entry.runtimeId, entry.workspaceId, entry.agentId].every(id) ||
@@ -123,12 +124,28 @@ export function parseRunsApiConnections(raw) {
123
124
  )
124
125
  fail();
125
126
  }
127
+ // Runs on these endpoints get their own allowed tools from the image-owned
128
+ // service; every other run stays tool-free.
129
+ if (
130
+ entry.toolEndpointIds !== undefined &&
131
+ (entry.localRoot === undefined ||
132
+ !Array.isArray(entry.toolEndpointIds) ||
133
+ !entry.toolEndpointIds.length ||
134
+ new Set(entry.toolEndpointIds).size !== entry.toolEndpointIds.length ||
135
+ entry.toolEndpointIds.some(
136
+ (value) => !entry.endpointIds.includes(value)
137
+ ))
138
+ )
139
+ fail();
126
140
  return Object.freeze({
127
141
  ...entry,
128
142
  baseUrl,
129
143
  models,
130
144
  endpointIds: [...entry.endpointIds],
131
145
  workKinds: [...entry.workKinds],
146
+ ...(entry.toolEndpointIds === undefined
147
+ ? {}
148
+ : { toolEndpointIds: [...entry.toolEndpointIds] }),
132
149
  });
133
150
  });
134
151
  }
@@ -38,7 +38,7 @@ import { installAgentHostServices } from "./service-installer.mjs";
38
38
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
39
39
  const VERSION = "sellable-agent-host-bootstrap/v1";
40
40
  const RECEIPT_VERSION = "sellable-agent-host-registration/v1";
41
- const INSTALLER_PACKAGE = "@sellable/install@0.1.795-wip.runs-api.20261004.1";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.796";
42
42
  const MCP_PACKAGE = "@sellable/mcp@0.1.957";
43
43
  const HERMES_VERSION = "0.18.0";
44
44
  const SHA256 = /^[a-f0-9]{64}$/;
@@ -1546,7 +1546,7 @@ export function compileClaimToProfileDesired(activeClaim, config) {
1546
1546
  ]) ||
1547
1547
  pinned.hermesCli !== "hermes" ||
1548
1548
  pinned.hermesVersion !== "0.18.0" ||
1549
- pinned.installerPackage !== "@sellable/install@0.1.795-wip.runs-api.20261004.1" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.796" ||
1550
1550
  pinned.mcpPackage !== "@sellable/mcp@0.1.957" ||
1551
1551
  !Array.isArray(policy.toolInclude) ||
1552
1552
  policy.toolInclude.length === 0 ||
@@ -312,7 +312,7 @@ function validateDesired(desired) {
312
312
  desired.serviceCredentialGeneration < 1 ||
313
313
  desired.hermesCli !== "hermes" ||
314
314
  desired.hermesVersion !== "0.18.0" ||
315
- desired.installerPackage !== "@sellable/install@0.1.795-wip.runs-api.20261004.1" ||
315
+ desired.installerPackage !== "@sellable/install@0.1.796" ||
316
316
  desired.mcpPackage !== "@sellable/mcp@0.1.957" ||
317
317
  !Array.isArray(desired.toolInclude) ||
318
318
  desired.toolInclude.length === 0 ||
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import { deriveContainedProfileId } from "./profile-materializer.mjs";
26
26
 
27
27
  export const PROVISIONING_ACTION = "PROVISION_HERMES_PROFILE";
28
- export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.795-wip.runs-api.20261004.1";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.796";
29
29
  export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.957";
30
30
  const PINNED_INSTALL_VERSION = PINNED_INSTALL_PACKAGE.slice(
31
31
  PINNED_INSTALL_PACKAGE.lastIndexOf("@") + 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.795-wip.runs-api.20261004.1",
3
+ "version": "0.1.796",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {