@sellable/install 0.1.801 → 0.1.803

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.801
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.803
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.801" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.803" \
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.801" \
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.801' || 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.803' || 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.801', 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.803', 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.801` is
6
+ `@sellable/install@0.1.803` is
7
7
  published:
8
8
 
9
9
  ```sh
@@ -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.801
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.803
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
@@ -332,6 +332,9 @@ export function claudeUsageLimit(stdout, now = Date.now()) {
332
332
  return { retryAt };
333
333
  }
334
334
 
335
+ // A failure reason the run may report: a fixed code, never raw CLI text.
336
+ const RUN_FAILURE_CODE = /^claude_runs_[a-z_]{1,48}$/;
337
+
335
338
  export function parseClaudeResult(stdout, request) {
336
339
  const result = JSON.parse(stdout);
337
340
  const models = Object.keys(result.modelUsage ?? {});
@@ -341,15 +344,19 @@ export function parseClaudeResult(stdout, request) {
341
344
  result.subtype !== "success" ||
342
345
  (request.schema
343
346
  ? !record(result.structured_output)
344
- : typeof result.result !== "string") ||
345
- (result.permission_denials ?? []).length ||
347
+ : typeof result.result !== "string")
348
+ )
349
+ throw new Error("claude_runs_output_rejected");
350
+ if ((result.permission_denials ?? []).length)
351
+ throw new Error("claude_runs_permission_denied");
352
+ if (
346
353
  !models.length ||
347
354
  models.some(
348
355
  (model) =>
349
356
  model !== request.model && !model.startsWith(`${request.model}-`)
350
357
  )
351
358
  )
352
- throw new Error("claude_runs_output_rejected");
359
+ throw new Error("claude_runs_model_mismatch");
353
360
  const output = request.schema
354
361
  ? JSON.stringify(result.structured_output)
355
362
  : result.result;
@@ -447,8 +454,12 @@ export function startClaudeRun(
447
454
  return reject(new Error(reason ?? "claude_runs_cli_failed"));
448
455
  try {
449
456
  resolve(parseClaudeResult(stdout, request));
450
- } catch {
451
- reject(new Error("claude_runs_output_rejected"));
457
+ } catch (error) {
458
+ reject(
459
+ error instanceof Error && RUN_FAILURE_CODE.test(error.message)
460
+ ? error
461
+ : new Error("claude_runs_output_rejected")
462
+ );
452
463
  }
453
464
  });
454
465
  child.stdin.on("error", () => {});
@@ -728,11 +739,9 @@ export function createClaudeRunsApi({
728
739
  ? { status: "cancelled" }
729
740
  : {
730
741
  status: "failed",
731
- error: [
732
- "claude_runs_sign_in_required",
733
- "claude_runs_auth_check_unavailable",
734
- "claude_runs_usage_limited",
735
- ].includes(error.message)
742
+ // The reason, so a failure can be told apart later;
743
+ // anything else the CLI threw stays generic.
744
+ error: RUN_FAILURE_CODE.test(error?.message ?? "")
736
745
  ? error.message
737
746
  : "claude_runs_generation_failed",
738
747
  }
@@ -88,6 +88,11 @@ import {
88
88
  } from "../profile-materializer.mjs";
89
89
  import { reconcilePublicHermesSkills } from "../public-skills.mjs";
90
90
  import { createCustomerRuntimePhaseTelemetry } from "./customer-runtime-phase-telemetry.mjs";
91
+ import {
92
+ endpointChainStepAdmissionDeadline,
93
+ endpointModelSelectionChain,
94
+ nextEndpointChainStep,
95
+ } from "./endpoint-model-selection.mjs";
91
96
  import {
92
97
  holdEndpointUatNativeCompletion,
93
98
  validateEndpointUatCompletionControl,
@@ -98,11 +103,11 @@ import {
98
103
  validateEndpointModelSelection,
99
104
  validateOpenerOutputContract,
100
105
  } from "./hermes-runs-client.mjs";
101
- import { createConfiguredRunsClient } from "./runs-api-connections.mjs";
102
106
  import {
103
107
  createOpenerStageExecution,
104
108
  usesOpenerDraftStrategy,
105
109
  } from "./opener-stage-execution.mjs";
110
+ import { createConfiguredRunsClient } from "./runs-api-connections.mjs";
106
111
 
107
112
  const DATA_ROOT = "/opt/data";
108
113
  const PROFILES_ROOT = join(DATA_ROOT, "profiles");
@@ -126,7 +131,7 @@ const RUNTIME_GENERATION = Number(
126
131
  const BOOT_SESSION_ID = randomUUID();
127
132
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
128
133
  const INSTALLER_PACKAGE =
129
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.801";
134
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.803";
130
135
  const MCP_PACKAGE =
131
136
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.957";
132
137
  export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
@@ -1074,6 +1079,10 @@ function endpointEventEnvelope(event, ordinal) {
1074
1079
  outputDigest: event.outputDigest ?? null,
1075
1080
  errorDigest: event.errorDigest ?? null,
1076
1081
  evidenceDigest: event.evidence?.digest ?? null,
1082
+ // Which model of an endpoint's fallback chain the event belongs to.
1083
+ ...(Number.isSafeInteger(event.chainStep)
1084
+ ? { chainStep: event.chainStep }
1085
+ : {}),
1077
1086
  ...(event.servedRuntime || event.usage
1078
1087
  ? {
1079
1088
  servedProvider: event.servedRuntime?.provider ?? null,
@@ -1402,10 +1411,19 @@ export function endpointOpenerOutputContract(claim) {
1402
1411
  // A single-run opener on a caller-named model (Opus on OpenRouter) is held to
1403
1412
  // the same strict schema, which the bridge sends as the provider's json_schema
1404
1413
  // so the model cannot return malformed JSON. The machine's default model has
1405
- // no strict transport, so it gets no contract.
1406
- export function endpointStrictOutputContract(claim, modelSelection) {
1414
+ // no strict transport, so it gets no contract. Neither does a Codex step: the
1415
+ // bridge enforces the strict format only on OpenRouter. In a fallback chain
1416
+ // the server attaches the schema for its custom Runs steps, so a Hermes step
1417
+ // (Codex or OpenRouter) runs without it, as it would on its own.
1418
+ export function endpointStrictOutputContract(
1419
+ claim,
1420
+ modelSelection,
1421
+ { chained = false } = {}
1422
+ ) {
1407
1423
  if (
1408
1424
  !modelSelection ||
1425
+ (!modelSelection.runsApiBaseUrl &&
1426
+ (chained || modelSelection.provider === "openai-codex")) ||
1409
1427
  usesOpenerDraftStrategy(claim) ||
1410
1428
  claim?.run?.productExecution?.workKind !== "OPENER" ||
1411
1429
  !claim.run.productExecution.providerOutputSchema
@@ -1954,7 +1972,22 @@ export function createCustomerEndpointRelay(options = {}) {
1954
1972
  throw new Error("customer_endpoint_mcp_descriptor_rejected");
1955
1973
  }
1956
1974
  const apiKeyFile = getApiKeyFile({ claim, activeRuntime });
1957
- const runs = createRunsClient({ apiKeyFile, claim, activeRuntime });
1975
+ // The models this run tries, in order. Each step opens its own client from
1976
+ // a claim carrying exactly that step's selection, so the trusted
1977
+ // connection checks are the same as for a single-model run.
1978
+ const chain = endpointModelSelectionChain(modelSelection);
1979
+ const chained = chain.length > 1;
1980
+ // A one-model run is a chain of one; no selection is the Agent default.
1981
+ const steps = chain.length ? chain : [null];
1982
+ const stepClaim = (index) =>
1983
+ chain.length === 0
1984
+ ? claim
1985
+ : { ...claim, run: { ...claim.run, modelSelection: chain[index] } };
1986
+ const runs = createRunsClient({
1987
+ apiKeyFile,
1988
+ claim: stepClaim(0),
1989
+ activeRuntime,
1990
+ });
1958
1991
  const controller = new AbortController();
1959
1992
  const abortFromOuter = () => controller.abort(outerSignal.reason);
1960
1993
  outerSignal.addEventListener("abort", abortFromOuter, { once: true });
@@ -2056,7 +2089,6 @@ export function createCustomerEndpointRelay(options = {}) {
2056
2089
  await abortablePause(20_000, controller.signal, sleepImpl);
2057
2090
  }
2058
2091
  })();
2059
- let terminalEvent = null;
2060
2092
  let ordinal = 0;
2061
2093
  const pendingStreamEvents = [];
2062
2094
  const flushStreamEvents = async () => {
@@ -2066,104 +2098,31 @@ export function createCustomerEndpointRelay(options = {}) {
2066
2098
  pendingStreamEvents.splice(0, batch.length);
2067
2099
  held.committedSequence = sequences.at(-1);
2068
2100
  };
2069
- try {
2070
- if (usesOpenerDraftStrategy(claim) && modelSelection) {
2071
- // The three-draft stages are pinned to the Codex output-format path in
2072
- // the Hermes bridge; refuse rather than silently serve the default.
2073
- await complete(claim, identity, {
2074
- attemptStatus: "FAILED",
2075
- status: "FAILED",
2076
- });
2077
- return { ok: false, code: "OPENER_STAGE_MODEL_SELECTION_UNSUPPORTED" };
2078
- }
2079
- if (usesOpenerDraftStrategy(claim)) {
2080
- // Phase 206: writers are private native sessions within this one fenced
2081
- // claim. Only the editor's validated result reaches durable completion.
2082
- let stageEventChain = Promise.resolve();
2083
- const appendStageEvent = (event) => {
2084
- stageEventChain = stageEventChain.then(() =>
2085
- append(claim, identity, event, ordinal++)
2086
- );
2087
- return stageEventChain;
2088
- };
2089
- held.openerStages = createOpenerStageExecution({
2090
- runs,
2091
- input: claim.run.input,
2092
- instructions: endpointRunInstructions(claim),
2093
- outputContract: endpointOpenerOutputContract(claim),
2094
- correlation: `${claim.attempt.conversationId}:${claim.attempt.runId}:${claim.attempt.id}:${randomUUID()}`,
2095
- deadlineAt: new Date(deadlineMs),
2096
- signal: controller.signal,
2097
- now,
2098
- validateOutput: (output) =>
2099
- endpointProductProjection(claim.run.productExecution, output).ok,
2100
- onSubmitted: async ({ stage, runId, requestDigest }) => {
2101
- const first = held.nativeRunId === null;
2102
- held.nativeRunId ??= runId;
2103
- await appendStageEvent({
2104
- kind: first
2105
- ? "NATIVE_SUBMIT_ACCEPTED"
2106
- : "OPENER_STAGE_SUBMIT_ACCEPTED",
2107
- stage,
2108
- requestDigest,
2109
- nativeRunIdDigest: sha256(runId),
2110
- });
2111
- if (held.pendingControl) await queueControl(held.pendingControl);
2112
- },
2113
- onReceipt: async (receipt) => {
2114
- await appendStageEvent({
2115
- kind: "OPENER_STAGE_COMPLETED",
2116
- ...receipt,
2101
+ const done = (result) => ({ done: true, result });
2102
+ // Runs one model of the chain. A final outcome has already completed the
2103
+ // attempt (`done`); a clean pre-output failure returns its class and a
2104
+ // `finish` that completes the attempt as a single model would have.
2105
+ const runNativeStep = async (stepIndex, stepRuns) => {
2106
+ const selection = chain[stepIndex] ?? null;
2107
+ // Events of a chained run name the model they belong to.
2108
+ const tag = (event) =>
2109
+ chained ? { ...event, chainStep: stepIndex } : event;
2110
+ if (!stepRuns) {
2111
+ return {
2112
+ failure: "STEP_UNAVAILABLE",
2113
+ finish: async () => {
2114
+ await complete(claim, identity, {
2115
+ attemptStatus: "FAILED",
2116
+ status: "FAILED",
2117
2117
  });
2118
+ return { ok: false, code: "FAILED" };
2118
2119
  },
2119
- });
2120
- const selected = await held.openerStages.run();
2121
- const observed = await holdEndpointUatNativeCompletion({
2122
- control: uatCompletionControl,
2123
- claim,
2124
- event: {
2125
- kind: "RUN_COMPLETED",
2126
- terminal: true,
2127
- output: selected.output,
2128
- outputDigest: selected.outputDigest,
2129
- timestamp: selected.timestamp,
2130
- nativeEventIdentity: selected.nativeEventIdentity,
2131
- },
2132
- now,
2133
- sleep: sleepImpl,
2134
- signal: controller.signal,
2135
- });
2136
- const late = deadlineExpired || now().getTime() >= deadlineMs;
2137
- await append(
2138
- claim,
2139
- identity,
2140
- late
2141
- ? {
2142
- kind: "LATE_COMPLETION_NOTICE",
2143
- outputDigest: observed.outputDigest,
2144
- timestamp: observed.timestamp,
2145
- nativeEventIdentity: observed.nativeEventIdentity,
2146
- }
2147
- : observed,
2148
- ordinal++
2149
- );
2150
- if (late) return { ok: true, code: "LATE_NOTICE_ONLY" };
2151
- const projection = endpointProductProjection(
2152
- claim.run.productExecution,
2153
- selected.output
2154
- );
2155
- if (!projection.ok) throw new Error("OPENER_STAGE_OUTPUT_INVALID");
2156
- const completion = await complete(claim, identity, {
2157
- attemptStatus: "SUCCEEDED",
2158
- status: "SUCCEEDED",
2159
- output: selected.output,
2160
- ...projection.completion,
2161
- });
2162
- return completion?.accepted === true
2163
- ? { ok: true, code: "SUCCEEDED" }
2164
- : { ok: false, code: "OPENER_STAGE_COMPLETION_REJECTED" };
2120
+ };
2165
2121
  }
2122
+ held.runs = stepRuns;
2123
+ held.nativeRunId = null;
2166
2124
  let submitted;
2125
+ let terminalEvent = null;
2167
2126
  const submitRequest = {
2168
2127
  input: claim.run.input,
2169
2128
  instructions: endpointRunInstructions(claim),
@@ -2171,17 +2130,26 @@ export function createCustomerEndpointRelay(options = {}) {
2171
2130
  claim.run.conversationHistory
2172
2131
  ),
2173
2132
  correlation: `${claim.attempt.conversationId}:${claim.attempt.runId}:${claim.attempt.id}`,
2174
- ...(modelSelection ? { modelSelection } : {}),
2133
+ ...(selection ? { modelSelection: selection } : {}),
2175
2134
  };
2176
2135
  const strictOutputContract = endpointStrictOutputContract(
2177
2136
  claim,
2178
- modelSelection
2137
+ selection,
2138
+ { chained }
2179
2139
  );
2180
2140
  if (strictOutputContract)
2181
2141
  submitRequest.outputContract = strictOutputContract;
2142
+ // A busy step stops waiting at its own budget so the next model still
2143
+ // has time; the last step (or a single model) waits until the deadline.
2144
+ const admissionDeadlineMs = endpointChainStepAdmissionDeadline({
2145
+ startedAtMs: now().getTime(),
2146
+ deadlineMs,
2147
+ stepIndex,
2148
+ stepCount: steps.length,
2149
+ });
2182
2150
  while (!submitted) {
2183
2151
  try {
2184
- submitted = await runs.submit(submitRequest, {
2152
+ submitted = await stepRuns.submit(submitRequest, {
2185
2153
  signal: controller.signal,
2186
2154
  });
2187
2155
  } catch (error) {
@@ -2190,22 +2158,27 @@ export function createCustomerEndpointRelay(options = {}) {
2190
2158
  attemptStatus: "UNCERTAIN",
2191
2159
  status: "UNCERTAIN",
2192
2160
  });
2193
- return { ok: false, code: "SUBMIT_AMBIGUOUS" };
2161
+ return done({ ok: false, code: "SUBMIT_AMBIGUOUS" });
2194
2162
  }
2195
2163
  if (error?.classification !== "SUBMIT_NOT_ADMITTED") throw error;
2196
- const remainingMs = deadlineMs - now().getTime();
2197
- if (deadlineExpired || remainingMs <= 0) {
2164
+ const notAdmittedTimeout = async () => {
2198
2165
  await complete(claim, identity, {
2199
2166
  attemptStatus: "TIMED_OUT",
2200
2167
  status: "TIMED_OUT",
2201
2168
  });
2202
2169
  return { ok: false, code: "SUBMIT_NOT_ADMITTED_TIMEOUT" };
2203
- }
2170
+ };
2171
+ const remainingMs = deadlineMs - now().getTime();
2172
+ if (deadlineExpired || remainingMs <= 0)
2173
+ return done(await notAdmittedTimeout());
2174
+ const admissionRemainingMs = admissionDeadlineMs - now().getTime();
2175
+ if (admissionRemainingMs <= 0)
2176
+ return { failure: "NOT_ADMITTED", finish: notAdmittedTimeout };
2204
2177
  const retryAfterMs = Number.isFinite(error.retryAfterMs)
2205
2178
  ? Math.max(100, Math.min(5_000, error.retryAfterMs))
2206
2179
  : 1_000;
2207
2180
  await abortablePause(
2208
- Math.min(retryAfterMs, remainingMs),
2181
+ Math.min(retryAfterMs, admissionRemainingMs),
2209
2182
  controller.signal,
2210
2183
  sleepImpl
2211
2184
  );
@@ -2219,17 +2192,17 @@ export function createCustomerEndpointRelay(options = {}) {
2219
2192
  await append(
2220
2193
  claim,
2221
2194
  identity,
2222
- {
2195
+ tag({
2223
2196
  kind: "NATIVE_SUBMIT_ACCEPTED",
2224
2197
  requestDigest: submitted.requestDigest,
2225
2198
  nativeRunIdDigest: sha256(submitted.runId),
2226
- },
2199
+ }),
2227
2200
  ordinal++
2228
2201
  );
2229
2202
  if (held.pendingControl) await queueControl(held.pendingControl);
2230
2203
  let streamed;
2231
2204
  try {
2232
- streamed = await runs.streamEvents(submitted.runId, {
2205
+ streamed = await stepRuns.streamEvents(submitted.runId, {
2233
2206
  signal: controller.signal,
2234
2207
  onEvent: async (event) => {
2235
2208
  const observedEvent = await holdEndpointUatNativeCompletion({
@@ -2259,7 +2232,7 @@ export function createCustomerEndpointRelay(options = {}) {
2259
2232
  }
2260
2233
  : observedEvent;
2261
2234
  pendingStreamEvents.push({
2262
- event: durableEvent,
2235
+ event: tag(durableEvent),
2263
2236
  ordinal: ordinal++,
2264
2237
  });
2265
2238
  const approvalBoundary =
@@ -2283,7 +2256,7 @@ export function createCustomerEndpointRelay(options = {}) {
2283
2256
  }
2284
2257
  }
2285
2258
  if (!terminalEvent && !controller.signal.aborted) {
2286
- const status = await runs.status(submitted.runId, {
2259
+ const status = await stepRuns.status(submitted.runId, {
2287
2260
  signal: controller.signal,
2288
2261
  });
2289
2262
  if (status.terminal) {
@@ -2300,7 +2273,7 @@ export function createCustomerEndpointRelay(options = {}) {
2300
2273
  late: deadlineExpired || now().getTime() >= deadlineMs,
2301
2274
  };
2302
2275
  } else if (status.ok === false) {
2303
- await append(claim, identity, status, ordinal++);
2276
+ await append(claim, identity, tag(status), ordinal++);
2304
2277
  }
2305
2278
  }
2306
2279
  if (
@@ -2315,18 +2288,24 @@ export function createCustomerEndpointRelay(options = {}) {
2315
2288
  // Server timeout is already canonical. The committed digest-only
2316
2289
  // late notice above is the sole mutation; never replace timeout with
2317
2290
  // a second completion or expose the late output as canonical.
2318
- return { ok: true, code: "LATE_NOTICE_ONLY" };
2291
+ return done({ ok: true, code: "LATE_NOTICE_ONLY" });
2319
2292
  }
2320
2293
  const productProjection = endpointProductProjection(
2321
2294
  claim.run.productExecution,
2322
2295
  terminalEvent.output
2323
2296
  );
2324
2297
  if (!productProjection.ok) {
2325
- await complete(claim, identity, {
2326
- attemptStatus: "SUCCEEDED",
2327
- status: "OUTPUT_CONTRACT_FAILED",
2328
- });
2329
- return { ok: false, code: "OUTPUT_CONTRACT_FAILED" };
2298
+ // The output was never used, so another model may still write.
2299
+ return {
2300
+ failure: "OUTPUT_CONTRACT_FAILED",
2301
+ finish: async () => {
2302
+ await complete(claim, identity, {
2303
+ attemptStatus: "SUCCEEDED",
2304
+ status: "OUTPUT_CONTRACT_FAILED",
2305
+ });
2306
+ return { ok: false, code: "OUTPUT_CONTRACT_FAILED" };
2307
+ },
2308
+ };
2330
2309
  }
2331
2310
  await complete(claim, identity, {
2332
2311
  attemptStatus: "SUCCEEDED",
@@ -2334,7 +2313,7 @@ export function createCustomerEndpointRelay(options = {}) {
2334
2313
  output: terminalEvent.output,
2335
2314
  ...productProjection.completion,
2336
2315
  });
2337
- return { ok: true, code: "SUCCEEDED" };
2316
+ return done({ ok: true, code: "SUCCEEDED" });
2338
2317
  }
2339
2318
  if (
2340
2319
  terminalEvent?.classification === "CANCELLED_POSITIVE" ||
@@ -2345,26 +2324,185 @@ export function createCustomerEndpointRelay(options = {}) {
2345
2324
  status: "CANCELLED_PRE_EFFECT",
2346
2325
  positiveCancellation: true,
2347
2326
  });
2348
- return { ok: true, code: "CANCELLED" };
2327
+ return done({ ok: true, code: "CANCELLED" });
2349
2328
  }
2350
2329
  if (terminalEvent) {
2351
- await complete(claim, identity, {
2352
- attemptStatus: "FAILED",
2353
- status: "FAILED",
2354
- });
2355
- return { ok: false, code: "FAILED" };
2330
+ // A native failure carries no output (Claude Code usage limit and
2331
+ // sign-in refusals arrive this way too).
2332
+ return {
2333
+ failure: "NATIVE_FAILED",
2334
+ finish: async () => {
2335
+ await complete(claim, identity, {
2336
+ attemptStatus: "FAILED",
2337
+ status: "FAILED",
2338
+ });
2339
+ return { ok: false, code: "FAILED" };
2340
+ },
2341
+ };
2356
2342
  }
2357
2343
  const deadline = deadlineExpired || now().getTime() >= deadlineMs;
2358
2344
  await complete(claim, identity, {
2359
2345
  attemptStatus: deadline ? "TIMED_OUT_UNCERTAIN" : "UNCERTAIN",
2360
2346
  status: deadline ? "TIMED_OUT_UNCERTAIN" : "UNCERTAIN",
2361
2347
  });
2362
- return {
2348
+ return done({
2363
2349
  ok: false,
2364
2350
  code:
2365
2351
  streamed?.classification ??
2366
2352
  (deadline ? "TIMED_OUT_UNCERTAIN" : "NATIVE_LOST_UNCERTAIN"),
2367
- };
2353
+ });
2354
+ };
2355
+ try {
2356
+ if (usesOpenerDraftStrategy(claim) && modelSelection) {
2357
+ // The three-draft stages are pinned to the Codex output-format path in
2358
+ // the Hermes bridge; refuse rather than silently serve the default.
2359
+ await complete(claim, identity, {
2360
+ attemptStatus: "FAILED",
2361
+ status: "FAILED",
2362
+ });
2363
+ return { ok: false, code: "OPENER_STAGE_MODEL_SELECTION_UNSUPPORTED" };
2364
+ }
2365
+ if (usesOpenerDraftStrategy(claim)) {
2366
+ // Phase 206: writers are private native sessions within this one fenced
2367
+ // claim. Only the editor's validated result reaches durable completion.
2368
+ let stageEventChain = Promise.resolve();
2369
+ const appendStageEvent = (event) => {
2370
+ stageEventChain = stageEventChain.then(() =>
2371
+ append(claim, identity, event, ordinal++)
2372
+ );
2373
+ return stageEventChain;
2374
+ };
2375
+ held.openerStages = createOpenerStageExecution({
2376
+ runs,
2377
+ input: claim.run.input,
2378
+ instructions: endpointRunInstructions(claim),
2379
+ outputContract: endpointOpenerOutputContract(claim),
2380
+ correlation: `${claim.attempt.conversationId}:${claim.attempt.runId}:${claim.attempt.id}:${randomUUID()}`,
2381
+ deadlineAt: new Date(deadlineMs),
2382
+ signal: controller.signal,
2383
+ now,
2384
+ validateOutput: (output) =>
2385
+ endpointProductProjection(claim.run.productExecution, output).ok,
2386
+ onSubmitted: async ({ stage, runId, requestDigest }) => {
2387
+ const first = held.nativeRunId === null;
2388
+ held.nativeRunId ??= runId;
2389
+ await appendStageEvent({
2390
+ kind: first
2391
+ ? "NATIVE_SUBMIT_ACCEPTED"
2392
+ : "OPENER_STAGE_SUBMIT_ACCEPTED",
2393
+ stage,
2394
+ requestDigest,
2395
+ nativeRunIdDigest: sha256(runId),
2396
+ });
2397
+ if (held.pendingControl) await queueControl(held.pendingControl);
2398
+ },
2399
+ onReceipt: async (receipt) => {
2400
+ await appendStageEvent({
2401
+ kind: "OPENER_STAGE_COMPLETED",
2402
+ ...receipt,
2403
+ });
2404
+ },
2405
+ });
2406
+ const selected = await held.openerStages.run();
2407
+ const observed = await holdEndpointUatNativeCompletion({
2408
+ control: uatCompletionControl,
2409
+ claim,
2410
+ event: {
2411
+ kind: "RUN_COMPLETED",
2412
+ terminal: true,
2413
+ output: selected.output,
2414
+ outputDigest: selected.outputDigest,
2415
+ timestamp: selected.timestamp,
2416
+ nativeEventIdentity: selected.nativeEventIdentity,
2417
+ },
2418
+ now,
2419
+ sleep: sleepImpl,
2420
+ signal: controller.signal,
2421
+ });
2422
+ const late = deadlineExpired || now().getTime() >= deadlineMs;
2423
+ await append(
2424
+ claim,
2425
+ identity,
2426
+ late
2427
+ ? {
2428
+ kind: "LATE_COMPLETION_NOTICE",
2429
+ outputDigest: observed.outputDigest,
2430
+ timestamp: observed.timestamp,
2431
+ nativeEventIdentity: observed.nativeEventIdentity,
2432
+ }
2433
+ : observed,
2434
+ ordinal++
2435
+ );
2436
+ if (late) return { ok: true, code: "LATE_NOTICE_ONLY" };
2437
+ const projection = endpointProductProjection(
2438
+ claim.run.productExecution,
2439
+ selected.output
2440
+ );
2441
+ if (!projection.ok) throw new Error("OPENER_STAGE_OUTPUT_INVALID");
2442
+ const completion = await complete(claim, identity, {
2443
+ attemptStatus: "SUCCEEDED",
2444
+ status: "SUCCEEDED",
2445
+ output: selected.output,
2446
+ ...projection.completion,
2447
+ });
2448
+ return completion?.accepted === true
2449
+ ? { ok: true, code: "SUCCEEDED" }
2450
+ : { ok: false, code: "OPENER_STAGE_COMPLETION_REJECTED" };
2451
+ }
2452
+ // A step that failed cleanly before producing any output hands the run
2453
+ // to the next model; every other outcome completes this attempt once.
2454
+ // Only when the failed step cannot have changed anything: a product draft
2455
+ // run has no effects by construction, and an endpoint without tools has
2456
+ // nothing to call. Any other run may already have used a tool, so another
2457
+ // model must not repeat the turn.
2458
+ const chainMayAdvance =
2459
+ claim.run.productExecution != null ||
2460
+ (descriptorBinding.expected.allowedToolNames ?? []).length === 0;
2461
+ const history = [];
2462
+ let stepIndex = 0;
2463
+ let stepRuns = runs;
2464
+ for (;;) {
2465
+ const outcome = await runNativeStep(stepIndex, stepRuns);
2466
+ if (outcome.done) return outcome.result;
2467
+ // A stop, a gateway fence or a lost lease (which aborts the
2468
+ // controller) ends the run as a single model would have ended it.
2469
+ const stopRequested =
2470
+ held.stopRequested ||
2471
+ held.pendingControl?.kind === "STOP_PENDING" ||
2472
+ controller.signal.aborted;
2473
+ const decision =
2474
+ stopRequested || !chainMayAdvance
2475
+ ? { advance: false }
2476
+ : nextEndpointChainStep(
2477
+ steps,
2478
+ history,
2479
+ outcome.failure,
2480
+ deadlineMs - now().getTime()
2481
+ );
2482
+ if (!decision.advance) return await outcome.finish();
2483
+ await append(
2484
+ claim,
2485
+ identity,
2486
+ {
2487
+ kind: "CHAIN_STEP_ADVANCED",
2488
+ classification: outcome.failure,
2489
+ chainStep: stepIndex,
2490
+ },
2491
+ ordinal++
2492
+ );
2493
+ history.push(outcome.failure);
2494
+ stepIndex = decision.stepIndex;
2495
+ try {
2496
+ stepRuns = createRunsClient({
2497
+ apiKeyFile,
2498
+ claim: stepClaim(stepIndex),
2499
+ activeRuntime,
2500
+ });
2501
+ } catch {
2502
+ // Nothing was sent for this step; it counts as a clean failure.
2503
+ stepRuns = null;
2504
+ }
2505
+ }
2368
2506
  } catch (error) {
2369
2507
  if (heartbeatFailure) throw heartbeatFailure;
2370
2508
  if (
@@ -5,6 +5,7 @@ export const ENDPOINT_MODEL_SELECTION_ALLOWLIST: Readonly<{
5
5
  "openai/gpt-6-luna",
6
6
  "anthropic/claude-sonnet-5.5",
7
7
  ];
8
+ "openai-codex": readonly ["gpt-6.1-sol"];
8
9
  }>;
9
10
  export const ENDPOINT_MODEL_REASONING_EFFORTS: readonly [
10
11
  "none",
@@ -13,14 +14,60 @@ export const ENDPOINT_MODEL_REASONING_EFFORTS: readonly [
13
14
  "high",
14
15
  "xhigh",
15
16
  ];
16
- export type EndpointModelSelection = Readonly<{
17
+ export const ENDPOINT_MODEL_MAX_FALLBACKS: 2;
18
+ export type EndpointModelStep = Readonly<{
17
19
  provider: string;
18
20
  model: string;
19
21
  reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
20
22
  runsApiBaseUrl?: string;
21
- fallbackPolicy?: "STOP" | "PRODUCT_DEFAULT";
22
23
  }>;
24
+ export type EndpointModelSelection = Readonly<
25
+ EndpointModelStep & {
26
+ fallbackPolicy?: "STOP" | "PRODUCT_DEFAULT";
27
+ fallbacks?: readonly EndpointModelStep[];
28
+ }
29
+ >;
30
+ export type EndpointChainFailure =
31
+ | "NATIVE_FAILED"
32
+ | "OUTPUT_CONTRACT_FAILED"
33
+ | "NOT_ADMITTED"
34
+ | "STEP_UNAVAILABLE";
35
+ export type EndpointChainDecision =
36
+ | Readonly<{
37
+ advance: true;
38
+ stepIndex: number;
39
+ selection: EndpointModelStep;
40
+ }>
41
+ | Readonly<{
42
+ advance: false;
43
+ reason:
44
+ | "INVALID"
45
+ | "NOT_ADVANCEABLE"
46
+ | "CHAIN_EXHAUSTED"
47
+ | "NO_TIME_LEFT";
48
+ }>;
23
49
  export function normalizeRunsApiBaseUrl(value: unknown): string | null;
24
50
  export function compileEndpointModelSelection(
25
51
  value: unknown
26
52
  ): EndpointModelSelection | null | "invalid";
53
+ export function endpointModelSelectionChain(
54
+ selection: unknown
55
+ ): EndpointModelStep[];
56
+ export function endpointModelSelectionSource(
57
+ selection: { provider: string; runsApiBaseUrl?: string } | null | undefined
58
+ ): "default" | "openrouter" | "codex" | "custom";
59
+ export const ENDPOINT_CHAIN_STEP_ADMISSION_MS: number;
60
+ export const ENDPOINT_CHAIN_MIN_STEP_MS: number;
61
+ export const ENDPOINT_CHAIN_ADVANCE_FAILURES: readonly EndpointChainFailure[];
62
+ export function nextEndpointChainStep(
63
+ chain: readonly EndpointModelStep[],
64
+ history: readonly string[],
65
+ failure: string,
66
+ remainingMs?: number
67
+ ): EndpointChainDecision;
68
+ export function endpointChainStepAdmissionDeadline(input: {
69
+ startedAtMs: number;
70
+ deadlineMs: number;
71
+ stepIndex: number;
72
+ stepCount: number;
73
+ }): number;
@@ -8,6 +8,9 @@ export const ENDPOINT_MODEL_SELECTION_ALLOWLIST = Object.freeze({
8
8
  "openai/gpt-6-luna",
9
9
  "anthropic/claude-sonnet-5.5",
10
10
  ]),
11
+ // The Agent's own Codex model (model-auth-reconciler.mjs), served by Hermes
12
+ // per run from the machine's Codex subscription pool.
13
+ "openai-codex": Object.freeze(["gpt-6.1-sol"]),
11
14
  });
12
15
  export const ENDPOINT_MODEL_REASONING_EFFORTS = Object.freeze([
13
16
  "none",
@@ -16,6 +19,7 @@ export const ENDPOINT_MODEL_REASONING_EFFORTS = Object.freeze([
16
19
  "high",
17
20
  "xhigh",
18
21
  ]);
22
+ export const ENDPOINT_MODEL_MAX_FALLBACKS = 2;
19
23
 
20
24
  export function normalizeRunsApiBaseUrl(value) {
21
25
  if (
@@ -45,39 +49,33 @@ export function normalizeRunsApiBaseUrl(value) {
45
49
  }
46
50
  }
47
51
 
48
- export function compileEndpointModelSelection(value) {
49
- if (value === undefined || value === null) return null;
50
- if (typeof value !== "object" || Array.isArray(value)) return "invalid";
52
+ // One model: provider, model, effort and, for a custom Runs API, its URL.
53
+ function compileEndpointModelStep(value, extraKeys = []) {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
51
55
  const custom = Object.hasOwn(value, "runsApiBaseUrl");
52
- const hasFallbackPolicy = Object.hasOwn(value, "fallbackPolicy");
53
56
  if (
54
57
  Object.keys(value)
55
- .filter((key) => key !== "fallbackPolicy")
58
+ .filter((key) => !extraKeys.includes(key))
56
59
  .sort()
57
60
  .join(",") !==
58
61
  (custom
59
62
  ? "model,provider,reasoningEffort,runsApiBaseUrl"
60
63
  : "model,provider,reasoningEffort")
61
64
  )
62
- return "invalid";
63
- if (
64
- hasFallbackPolicy &&
65
- !["STOP", "PRODUCT_DEFAULT"].includes(value.fallbackPolicy)
66
- )
67
- return "invalid";
65
+ return null;
68
66
  const { provider, model, reasoningEffort } = value;
69
67
  if (
70
68
  typeof provider !== "string" ||
71
69
  typeof model !== "string" ||
72
70
  !ENDPOINT_MODEL_REASONING_EFFORTS.includes(reasoningEffort)
73
71
  )
74
- return "invalid";
72
+ return null;
75
73
  if (
76
74
  !custom &&
77
75
  (!Object.hasOwn(ENDPOINT_MODEL_SELECTION_ALLOWLIST, provider) ||
78
76
  !ENDPOINT_MODEL_SELECTION_ALLOWLIST[provider].includes(model))
79
77
  )
80
- return "invalid";
78
+ return null;
81
79
  const runsApiBaseUrl = custom
82
80
  ? normalizeRunsApiBaseUrl(value.runsApiBaseUrl)
83
81
  : null;
@@ -87,12 +85,150 @@ export function compileEndpointModelSelection(value) {
87
85
  !/^[a-z][a-z0-9_-]{0,63}$/.test(provider) ||
88
86
  !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(model))
89
87
  )
90
- return "invalid";
88
+ return null;
91
89
  return Object.freeze({
92
90
  provider,
93
91
  model,
94
92
  reasoningEffort,
95
93
  ...(custom ? { runsApiBaseUrl } : {}),
94
+ });
95
+ }
96
+
97
+ const sameStep = (left, right) =>
98
+ left.provider === right.provider &&
99
+ left.model === right.model &&
100
+ left.reasoningEffort === right.reasoningEffort &&
101
+ left.runsApiBaseUrl === right.runsApiBaseUrl;
102
+
103
+ /**
104
+ * The top-level fields are the endpoint's first model. An optional ordered
105
+ * `fallbacks` list holds up to two more, each a full model choice of its own;
106
+ * `fallbackPolicy` applies only after every model in the chain has failed.
107
+ */
108
+ export function compileEndpointModelSelection(value) {
109
+ if (value === undefined || value === null) return null;
110
+ if (typeof value !== "object" || Array.isArray(value)) return "invalid";
111
+ const primary = compileEndpointModelStep(value, [
112
+ "fallbackPolicy",
113
+ "fallbacks",
114
+ ]);
115
+ if (!primary) return "invalid";
116
+ const hasFallbackPolicy = Object.hasOwn(value, "fallbackPolicy");
117
+ if (
118
+ hasFallbackPolicy &&
119
+ !["STOP", "PRODUCT_DEFAULT"].includes(value.fallbackPolicy)
120
+ )
121
+ return "invalid";
122
+ let fallbacks = null;
123
+ if (Object.hasOwn(value, "fallbacks")) {
124
+ if (
125
+ !Array.isArray(value.fallbacks) ||
126
+ value.fallbacks.length < 1 ||
127
+ value.fallbacks.length > ENDPOINT_MODEL_MAX_FALLBACKS
128
+ )
129
+ return "invalid";
130
+ fallbacks = value.fallbacks.map((step) => compileEndpointModelStep(step));
131
+ if (
132
+ fallbacks.some(
133
+ (step, index) =>
134
+ !step ||
135
+ [primary, ...fallbacks.slice(0, index)].some((earlier) =>
136
+ sameStep(earlier, step)
137
+ )
138
+ )
139
+ )
140
+ return "invalid";
141
+ }
142
+ return Object.freeze({
143
+ ...primary,
96
144
  ...(hasFallbackPolicy ? { fallbackPolicy: value.fallbackPolicy } : {}),
145
+ ...(fallbacks ? { fallbacks: Object.freeze(fallbacks) } : {}),
97
146
  });
98
147
  }
148
+
149
+ /** The models a run tries, in order; empty means the Agent's own default. */
150
+ export function endpointModelSelectionChain(selection) {
151
+ const compiled = compileEndpointModelSelection(selection);
152
+ if (!compiled || compiled === "invalid") return [];
153
+ const { fallbacks = [], fallbackPolicy: _policy, ...primary } = compiled;
154
+ return [Object.freeze(primary), ...fallbacks];
155
+ }
156
+
157
+ /** The settings source a model belongs to: Agent default, OpenRouter, Codex or a custom Runs API. */
158
+ export function endpointModelSelectionSource(selection) {
159
+ if (!selection) return "default";
160
+ if (selection.runsApiBaseUrl) return "custom";
161
+ return selection.provider === "openai-codex" ? "codex" : "openrouter";
162
+ }
163
+
164
+ // A step that cannot be admitted waits this long at most before the next
165
+ // model takes the run. Claude Code runs take 9-15 s, so a busy slot normally
166
+ // frees well inside it.
167
+ export const ENDPOINT_CHAIN_STEP_ADMISSION_MS = 30_000;
168
+ // Starting another model with less time than this left cannot finish it.
169
+ export const ENDPOINT_CHAIN_MIN_STEP_MS = 5_000;
170
+ // A step that failed in one of these ways produced no output, so another
171
+ // model can write without risking a second draft or send.
172
+ export const ENDPOINT_CHAIN_ADVANCE_FAILURES = Object.freeze([
173
+ "NATIVE_FAILED", // the run failed with no output (incl. Claude Code usage limit or sign-in refusals)
174
+ "OUTPUT_CONTRACT_FAILED", // its output failed the product contract and was not used
175
+ "NOT_ADMITTED", // the service stayed busy (429/503) for the step's admission budget
176
+ "STEP_UNAVAILABLE", // this machine could not open the step's connection; nothing was sent
177
+ ]);
178
+
179
+ /**
180
+ * Decide whether a run moves to the next model of its chain. `history` holds
181
+ * the failure classes of the steps already left behind; `failure` is how the
182
+ * current step ended. Anything not listed in ENDPOINT_CHAIN_ADVANCE_FAILURES
183
+ * (an ambiguous submit, a lost stream, a stop or cancel, a late completion,
184
+ * success including a SUPPRESS decision, a timeout) ends the run.
185
+ */
186
+ export function nextEndpointChainStep(
187
+ chain,
188
+ history,
189
+ failure,
190
+ remainingMs = Number.POSITIVE_INFINITY
191
+ ) {
192
+ if (
193
+ !Array.isArray(chain) ||
194
+ chain.length < 1 ||
195
+ chain.length > ENDPOINT_MODEL_MAX_FALLBACKS + 1 ||
196
+ !Array.isArray(history) ||
197
+ history.length >= chain.length ||
198
+ history.some((item) => !ENDPOINT_CHAIN_ADVANCE_FAILURES.includes(item))
199
+ )
200
+ return Object.freeze({ advance: false, reason: "INVALID" });
201
+ if (!ENDPOINT_CHAIN_ADVANCE_FAILURES.includes(failure))
202
+ return Object.freeze({ advance: false, reason: "NOT_ADVANCEABLE" });
203
+ const stepIndex = history.length + 1;
204
+ if (stepIndex >= chain.length)
205
+ return Object.freeze({ advance: false, reason: "CHAIN_EXHAUSTED" });
206
+ if (!(remainingMs >= ENDPOINT_CHAIN_MIN_STEP_MS))
207
+ return Object.freeze({ advance: false, reason: "NO_TIME_LEFT" });
208
+ return Object.freeze({
209
+ advance: true,
210
+ stepIndex,
211
+ selection: chain[stepIndex],
212
+ });
213
+ }
214
+
215
+ /**
216
+ * When a busy step stops waiting for admission. Every step but the last gets
217
+ * an equal share of the time left, at most ENDPOINT_CHAIN_STEP_ADMISSION_MS;
218
+ * the last step, like a single model, waits until the attempt deadline.
219
+ */
220
+ export function endpointChainStepAdmissionDeadline({
221
+ startedAtMs,
222
+ deadlineMs,
223
+ stepIndex,
224
+ stepCount,
225
+ }) {
226
+ if (stepIndex >= stepCount - 1) return deadlineMs;
227
+ const share = Math.floor(
228
+ (deadlineMs - startedAtMs) / (stepCount - stepIndex)
229
+ );
230
+ return Math.min(
231
+ deadlineMs,
232
+ startedAtMs + Math.max(0, Math.min(ENDPOINT_CHAIN_STEP_ADMISSION_MS, share))
233
+ );
234
+ }
@@ -1,7 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { pathToFileURL } from "node:url";
4
- import { compileEndpointModelSelection } from "./endpoint-model-selection.mjs";
4
+ import {
5
+ compileEndpointModelSelection,
6
+ endpointModelSelectionChain,
7
+ endpointModelSelectionSource,
8
+ } from "./endpoint-model-selection.mjs";
5
9
  import { createHermesRunsClient } from "./hermes-runs-client.mjs";
6
10
  import {
7
11
  createConfiguredRunsClient,
@@ -132,8 +136,12 @@ export async function verifyRunsApiSelection(
132
136
  !/^[A-Za-z0-9_-]{1,128}$/.test(request.profileId ?? "")
133
137
  )
134
138
  return result("NOT_CONFIGURED");
135
- const selection = compileEndpointModelSelection(request.modelSelection);
136
- if (selection === "invalid") return result("NOT_CONFIGURED");
139
+ const compiled = compileEndpointModelSelection(request.modelSelection);
140
+ if (compiled === "invalid") return result("NOT_CONFIGURED");
141
+ // A fallback chain is verified one step per call. The control plane sends
142
+ // the primary step's check the whole chain, so a machine whose compiler
143
+ // cannot run a chain refuses it here and no chain is saved for it.
144
+ const selection = endpointModelSelectionChain(compiled)[0] ?? null;
137
145
  let verificationWorkKind = "STANDARD";
138
146
  if (selection?.runsApiBaseUrl) {
139
147
  const connection = runsApiConnectionsForRuntime(
@@ -156,7 +164,7 @@ export async function verifyRunsApiSelection(
156
164
  const readiness = await health(request, { env });
157
165
  if (readiness.state !== "READY") return readiness;
158
166
  }
159
- } else if (request.connectionId !== (selection ? "openrouter" : "default"))
167
+ } else if (request.connectionId !== endpointModelSelectionSource(selection))
160
168
  return result("NOT_CONFIGURED");
161
169
  const apiKeyFile =
162
170
  env.SELLABLE_AGENT_SERVER_AUTHENTICATED_KIND === "ADMIN"
@@ -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.801";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.803";
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.801" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.803" ||
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.801" ||
315
+ desired.installerPackage !== "@sellable/install@0.1.803" ||
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.801";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.803";
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.801",
3
+ "version": "0.1.803",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {