@moondeck/station 0.0.1 → 1.0.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.
Files changed (326) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,46 @@
1
+ import { LEGACY_HOST_RELEASE_FEED } from "./release-feed";
2
+ import type { PinnedReleaseManifestKey } from "./release-manifest";
3
+
4
+ /**
5
+ * Stable manifest location for the public, release-artifact-only feed.
6
+ *
7
+ * The release coordinator provisions the first production key in this map in
8
+ * the same reviewed change that enables publishing. Keeping the empty map in
9
+ * source makes an unprovisioned updater fail closed; no network-provided key
10
+ * or environment variable can become a trust anchor.
11
+ */
12
+ export const STABLE_RELEASE_MANIFEST_URL =
13
+ LEGACY_HOST_RELEASE_FEED.stableManifestUrl;
14
+
15
+ /**
16
+ * Deliberate public-release latch for ADR-099.
17
+ *
18
+ * Provisioning a key is necessary but not sufficient to advertise or serve
19
+ * the one-line installer. The release coordinator flips this only in the same
20
+ * reviewed change that publishes the complete, version-matched four-platform
21
+ * release. Flipped 2026-08-19 in the change that provisioned
22
+ * `uai-release-2026-a` and wired auto-release to sign and publish the
23
+ * four-platform feed on every host version bump.
24
+ */
25
+ export const HOST_INSTALLER_RELEASE_PROMOTED = true;
26
+
27
+ /**
28
+ * `uai-release-2026-a`: minted by the operator 2026-08-19 (Ed25519; private
29
+ * key lives in Infisical and reaches CI as UAI_RELEASE_SIGNING_KEY). Rotation
30
+ * is additive: pin the successor key beside this one, publish releases signed
31
+ * by it, and retire this entry only after every supported host updated past
32
+ * a release that pins both.
33
+ */
34
+ export const PINNED_RELEASE_MANIFEST_KEYS: ReadonlyMap<
35
+ string,
36
+ PinnedReleaseManifestKey
37
+ > = new Map([
38
+ [
39
+ "uai-release-2026-a",
40
+ {
41
+ publicKey: `-----BEGIN PUBLIC KEY-----
42
+ MCowBQYDK2VwAyEAeCBzaLTNYNGkI59T+vNu2M/DMTeTsmfABDtwN9IDejY=
43
+ -----END PUBLIC KEY-----`,
44
+ },
45
+ ],
46
+ ]);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Retired bare-mirror host command.
3
+ *
4
+ * Project creation stores metadata only. Repository access happens at
5
+ * task-up, after the task creator and host are known, using that user's
6
+ * connected GitHub credential or their explicit SSH fallback. This legacy
7
+ * protocol command cannot express either principal, so running Git here would
8
+ * necessarily risk borrowing the host operator's ambient credential.
9
+ *
10
+ * Keep the command-shaped response for rolling-version compatibility, but
11
+ * fail closed without touching the filesystem or network. A future preflight
12
+ * needs a user/host-aware protocol of its own; see
13
+ * docs/repo-access-preflight.md.
14
+ */
15
+
16
+ import { projectRepoMirror } from "./env";
17
+
18
+ export interface CloneRepoInput {
19
+ url: string;
20
+ projectId: string;
21
+ }
22
+
23
+ export interface CloneRepoResult {
24
+ status: "ready" | "error";
25
+ error?: string;
26
+ /** Absolute path to the bare mirror directory. */
27
+ absolutePath: string;
28
+ }
29
+
30
+ export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
31
+ const target = projectRepoMirror(input.projectId);
32
+ return {
33
+ status: "error",
34
+ absolutePath: target,
35
+ error:
36
+ "repo.clone is retired because it has no task creator credential; start a task to perform the user-scoped repository fetch",
37
+ };
38
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * ADR-141 rung 1 — the SHADOW router.
3
+ *
4
+ * Nothing here changes what runs. On every human delivery to an agent in a
5
+ * workspace the cloud flagged (`ChannelEnsureInput.routing.shadow`), the
6
+ * runner classifies the message the way Auto mode would, resolves what Auto
7
+ * would have picked under the Balanced preset, and reports both beside what
8
+ * actually ran as an `agent.route.shadow` host event. A week of those rows
9
+ * turns the on-paper saving into a measured one.
10
+ *
11
+ * Shape, kept deliberately: the MODEL answers one fuzzy question ("how hard
12
+ * is this, and what kind of work"); everything with policy in it is code.
13
+ *
14
+ * fast path (rules) → classify (one-shot on the agent's own account)
15
+ * → hedge (rules that force the ceiling)
16
+ * → resolve (class → tier → model within the envelope)
17
+ *
18
+ * The one-shot runs INSIDE the task environment, as `node`, with the engine
19
+ * account the agent itself runs on — so Uai never pays for routing and the
20
+ * classifier sees the same credential the agent does. Rung 0 (2026-09-15)
21
+ * showed Claude, Codex and Grok all host the rubric within the noise floor
22
+ * (74–77% tier agreement, Haiku against itself included), so the host is
23
+ * simply whichever engine the agent is on.
24
+ */
25
+
26
+ export const ROUTE_CLASSES = ["trivial", "routine", "hard", "decision"] as const;
27
+ export type RouteClass = (typeof ROUTE_CLASSES)[number];
28
+ export const WORK_KINDS = ["edit", "debug", "explain", "plan", "research", "write", "browser", "other"] as const;
29
+ export type WorkKind = (typeof WORK_KINDS)[number];
30
+
31
+ /** hard and decision both route to the ceiling; that is what the resolver
32
+ * compares, and what rung 0 measured agreement on. */
33
+ export const TIER_OF: Record<RouteClass, 0 | 1 | 2> = {
34
+ trivial: 0,
35
+ routine: 1,
36
+ hard: 2,
37
+ decision: 2,
38
+ };
39
+
40
+ export const RUBRIC = `You classify ONE human message sent to a coding agent inside a task channel.
41
+
42
+ Pick exactly one class:
43
+ - trivial: acknowledgements, greetings, yes/no, "go ahead", "continue", thanks,
44
+ a one-line question about current state. No repository work is needed.
45
+ - routine: a clear, bounded ask done in one or two steps: explain a file, small
46
+ fix, rename, run the tests, answer a factual question about the code.
47
+ - hard: multi-step implementation, debugging where the cause is unknown, a
48
+ change across several files, a bug report, anything needing exploration first.
49
+ - decision: architecture, design or trade-off questions, planning, "should we…",
50
+ reviewing a proposal — judgement matters more than execution.
51
+
52
+ And one kind of work: edit, debug, explain, plan, research, write, browser, other.
53
+
54
+ The preceding channel turns are context only. Judge the LAST human message.
55
+ Return ONLY this JSON, nothing else:
56
+ {"class":"<trivial|routine|hard|decision>","kind":"<edit|debug|explain|plan|research|write|browser|other>","reason":"<8 words max>"}`;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Fast path: turns that never need the model. Rung 0's regex caught only 10 of
60
+ // 135 trivials; this one is wider on purpose and still conservative: short,
61
+ // no path, no code, no question about anything specific.
62
+ const ACK = new RegExp(
63
+ "^(ok(ay)?|yes|yeah|yep|ya|no|nope|go|go ahead|continue|proceed|do it|ship it|" +
64
+ "thanks|thank you|ty|thx|cool|nice|great|good|done|sure|k|kk|lgtm|approved|" +
65
+ "sounds good|perfect|awesome|please do|yes please|no thanks|not now|later|" +
66
+ "👍|🙏|✅)[.!\\s]*$",
67
+ "i",
68
+ );
69
+ const LOOKS_LIKE_WORK = /[\/\\`{}()=;]|\.(ts|js|tsx|py|md|json|sql|sh|yml|yaml)\b|https?:\/\//i;
70
+
71
+ export function fastPath(text: string): RouteClass | null {
72
+ const t = text.trim();
73
+ if (t.length === 0) return "trivial";
74
+ if (ACK.test(t)) return "trivial";
75
+ // A very short message with no code-shaped content and no question mark
76
+ // is a reaction, not a request ("k .11 is shipping", "@luigi").
77
+ if (t.length <= 24 && !LOOKS_LIKE_WORK.test(t) && !t.includes("?")) return "trivial";
78
+ return null;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Hedges: deterministic upgrades to the ceiling. Rung 0's systematic miss was
83
+ // bug reports and multi-ask messages labelled routine; these cut Codex's cold
84
+ // misses from 6 to 3 of 51 at one needless escalation in 70.
85
+ const BUG = /\b(bug|broken|not working|doesn'?t work|isn'?t working|won'?t|error|fail(ed|ing|s)?|duplicated|wrong|stuck|crash(ed|es)?|regression|não está|nao esta|não funciona|quebr(ou|ado))\b/i;
86
+ const ATTACH = /\[image:|attachments\/|screenshot|\bprint\b/i;
87
+ const MULTI = /(\n\s*[-*•]|\n\s*\d+[.)-]|another thing|a couple more|one more thing|also,|and then|then i want|\b[12]-\s)/i;
88
+ const DECIDE = /\b(should we|how is that gonna|how would that|what should|what would|do we need|is it (risky|worth|safe)|let'?s think|thoughts on|what do you think)\b/i;
89
+
90
+ export function hedge(text: string, cls: RouteClass): RouteClass {
91
+ if (TIER_OF[cls] === 2) return cls;
92
+ const long = text.length > 60;
93
+ if (DECIDE.test(text) && text.length > 40) return "decision";
94
+ if (BUG.test(text) && (ATTACH.test(text) || long)) return "hard";
95
+ if (MULTI.test(text) && text.length > 80) return "hard";
96
+ return cls;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Resolve: class → tier → model, per engine, under the Balanced preset. Rung 1
101
+ // keeps the agent's own engine (cross-engine is rung 3) and a static tier
102
+ // table; the catalog's tier column replaces this once it exists.
103
+ export interface WouldRoute {
104
+ engine: string;
105
+ model: string | null;
106
+ }
107
+
108
+ const BALANCED: Record<string, [string, string, string] | undefined> = {
109
+ // [floor, default, ceiling]
110
+ claude: ["haiku", "sonnet", "opus"],
111
+ uai: ["haiku-4.5", "sonnet-5", "opus-5"],
112
+ codex: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
113
+ };
114
+
115
+ export function resolveBalanced(cls: RouteClass, engine: string): WouldRoute {
116
+ const ladder = BALANCED[engine];
117
+ if (!ladder) return { engine, model: null };
118
+ return { engine, model: ladder[TIER_OF[cls]] };
119
+ }
120
+
121
+ /** The cheapest model each engine serves on a connected account — what the
122
+ * classifier itself runs on. Verified 2026-09-15: a ChatGPT-account Codex
123
+ * refuses gpt-5-mini and the codex-mini variants but serves Luna. */
124
+ export const CLASSIFIER_MODEL: Record<string, string | null> = {
125
+ claude: "haiku",
126
+ codex: "gpt-5.6-luna",
127
+ grok: null, // the CLI's default; the login decides
128
+ };
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // One-shot completion argv per engine. No tools, no session, plain text back.
132
+ export interface OneShot {
133
+ argv: [string, ...string[]];
134
+ /** Where the answer lands: the process's stdout, or a file it writes. */
135
+ output: { kind: "stdout-json"; field: string } | { kind: "file"; path: string };
136
+ }
137
+
138
+ export function oneShotFor(engine: string, prompt: string, scratchDir: string): OneShot | null {
139
+ switch (engine) {
140
+ case "claude":
141
+ case "uai":
142
+ return {
143
+ argv: ["claude", "-p", prompt, "--model", "haiku", "--output-format", "json", "--no-session-persistence"],
144
+ output: { kind: "stdout-json", field: "result" },
145
+ };
146
+ case "codex": {
147
+ const out = `${scratchDir}/router-last.txt`;
148
+ return {
149
+ argv: [
150
+ "codex", "exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only",
151
+ "-m", "gpt-5.6-luna", "-o", out, prompt,
152
+ ],
153
+ output: { kind: "file", path: out },
154
+ };
155
+ }
156
+ case "grok":
157
+ return {
158
+ argv: ["grok", "-p", prompt, "--output-format", "json", "--disable-web-search"],
159
+ output: { kind: "stdout-json", field: "text" },
160
+ };
161
+ default:
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export interface Label {
167
+ cls: RouteClass;
168
+ kind: WorkKind | null;
169
+ reason: string;
170
+ }
171
+
172
+ /** Tolerant of chatter around the JSON and of a model inventing a class. */
173
+ export function parseLabel(text: string): Label | null {
174
+ const m = text.match(/\{[^{}]*"class"[^{}]*\}/);
175
+ if (!m) return null;
176
+ try {
177
+ const o = JSON.parse(m[0]) as { class?: unknown; kind?: unknown; reason?: unknown };
178
+ const cls = String(o.class ?? "").toLowerCase() as RouteClass;
179
+ if (!ROUTE_CLASSES.includes(cls)) return null;
180
+ const kind = String(o.kind ?? "").toLowerCase() as WorkKind;
181
+ return {
182
+ cls,
183
+ kind: WORK_KINDS.includes(kind) ? kind : null,
184
+ reason: typeof o.reason === "string" ? o.reason.slice(0, 80) : "",
185
+ };
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export function buildPrompt(message: string, context: string): string {
192
+ const ctx = context.trim().length > 0 ? context.trim().slice(-900) : "(none)";
193
+ return `${RUBRIC}\n\nRECENT CHANNEL (context only):\n${ctx}\n\nLAST HUMAN MESSAGE:\n${message.slice(0, 1600)}`;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // The pass itself, with every side effect injected so it is testable without
198
+ // an orchestrator.
199
+ export interface ShadowDeps {
200
+ /** Run a one-shot in the task environment; null when it cannot run at all. */
201
+ exec: (argv: [string, ...string[]]) => Promise<{ exitCode: number | null; stdout: string } | null>;
202
+ readFile: (path: string) => Promise<string | null>;
203
+ scratchDir: string;
204
+ now?: () => number;
205
+ }
206
+
207
+ export interface ShadowResult {
208
+ cls: RouteClass;
209
+ kind: WorkKind | null;
210
+ via: "rules" | "account" | "none";
211
+ host: string | null;
212
+ would: WouldRoute;
213
+ ms: number;
214
+ }
215
+
216
+ export async function shadowRoute(
217
+ message: string,
218
+ context: string,
219
+ engine: string,
220
+ deps: ShadowDeps,
221
+ ): Promise<ShadowResult> {
222
+ const now = deps.now ?? Date.now;
223
+ const t0 = now();
224
+ const fast = fastPath(message);
225
+ if (fast) {
226
+ return { cls: fast, kind: null, via: "rules", host: null, would: resolveBalanced(fast, engine), ms: now() - t0 };
227
+ }
228
+ const shot = oneShotFor(engine, buildPrompt(message, context), deps.scratchDir);
229
+ let label: Label | null = null;
230
+ let host: string | null = null;
231
+ if (shot) {
232
+ const res = await deps.exec(shot.argv).catch(() => null);
233
+ if (res && res.exitCode === 0) {
234
+ let text = "";
235
+ if (shot.output.kind === "file") {
236
+ text = (await deps.readFile(shot.output.path).catch(() => null)) ?? "";
237
+ } else {
238
+ try {
239
+ const d = JSON.parse(res.stdout) as Record<string, unknown>;
240
+ text = typeof d[shot.output.field] === "string" ? (d[shot.output.field] as string) : res.stdout;
241
+ } catch {
242
+ text = res.stdout;
243
+ }
244
+ }
245
+ label = parseLabel(text);
246
+ if (label) host = engine;
247
+ }
248
+ }
249
+ // No usable answer: the deterministic layer still routes — default tier,
250
+ // hedged upward where the message says so.
251
+ const base: RouteClass = label?.cls ?? "routine";
252
+ const cls = hedge(message, base);
253
+ return {
254
+ cls,
255
+ kind: label?.kind ?? null,
256
+ via: label ? "account" : "none",
257
+ host,
258
+ would: resolveBalanced(cls, engine),
259
+ ms: now() - t0,
260
+ };
261
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Identity of this runner process lifetime.
3
+ *
4
+ * The bridge replay cursor and ADR-154's in-memory credential replicas must
5
+ * use the same value. Keeping the singleton in its own module prevents two
6
+ * subsystems from each drawing a plausible but different boot id.
7
+ */
8
+ import { newId } from "./ulid";
9
+
10
+ export const RUNNER_BOOT_ID = newId();
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `retry`: a transient failure, followed up within the epoch's bounded
3
+ * budget. `rerun`: the attempt was cut short by a one-time, monotonic
4
+ * transition (ADR-154: the runner leaving its own credentials), and the
5
+ * pass it now needs (one that sweeps first) runs at once, outside that
6
+ * budget and with a fresh one of its own, at most once per epoch.
7
+ */
8
+ export type RuntimeActivationAttemptResult = "complete" | "retry" | "rerun";
9
+
10
+ type ActivationAttempt = () => Promise<RuntimeActivationAttemptResult>;
11
+ type RetrySchedule = (callback: () => void, delayMs: number) => () => void;
12
+
13
+ interface RuntimeRecoveryGateOptions {
14
+ isCurrent(): boolean;
15
+ recover(isCurrent: () => boolean): Promise<boolean>;
16
+ runtimeReady(): Promise<boolean>;
17
+ afterRecovery(): Promise<void>;
18
+ }
19
+
20
+ /** Keep the operational publication path strictly behind a true recovery
21
+ * verdict. A false verdict probes the daemon only to choose between normal
22
+ * no-runtime handling and a post-tail retry; it never calls `afterRecovery`. */
23
+ export async function runRuntimeRecoveryGate(
24
+ options: RuntimeRecoveryGateOptions,
25
+ ): Promise<RuntimeActivationAttemptResult> {
26
+ const recovered = await options.recover(options.isCurrent);
27
+ if (!options.isCurrent()) return "complete";
28
+ if (!recovered) {
29
+ const ready = await options.runtimeReady();
30
+ return ready && options.isCurrent() ? "retry" : "complete";
31
+ }
32
+ await options.afterRecovery();
33
+ return "complete";
34
+ }
35
+
36
+ export type RuntimeMaintenanceVerdict = "ready" | "retry" | "stale";
37
+
38
+ interface MaintainedRuntimeActivationOptions {
39
+ isCurrent(): boolean;
40
+ /** One-way rolling fence before the first shared-volume writer. */
41
+ beforeMaintenance(): Promise<RuntimeMaintenanceVerdict>;
42
+ maintenance(): Promise<RuntimeMaintenanceVerdict>;
43
+ recover(isCurrent: () => boolean): Promise<boolean>;
44
+ runtimeReady(): Promise<boolean>;
45
+ afterRecoveryBeforeFinalMaintenance(): Promise<void>;
46
+ publishOperational(): Promise<void>;
47
+ }
48
+
49
+ /**
50
+ * Keep both standard-image/shared-volume maintenance passes on the same side
51
+ * of the recovery and operational-publication fences. A live daemon plus a
52
+ * transient maintenance failure requests the activation tail's bounded retry;
53
+ * a stale daemon generation simply completes and lets its readiness edge own
54
+ * the next attempt.
55
+ */
56
+ export async function runMaintainedRuntimeActivation(
57
+ options: MaintainedRuntimeActivationOptions,
58
+ ): Promise<RuntimeActivationAttemptResult> {
59
+ if (!options.isCurrent()) return "complete";
60
+ const prepared = await options.beforeMaintenance();
61
+ if (prepared !== "ready") {
62
+ return prepared === "retry" ? "retry" : "complete";
63
+ }
64
+ if (!options.isCurrent()) return "complete";
65
+ const initial = await options.maintenance();
66
+ if (initial !== "ready") {
67
+ return initial === "retry" ? "retry" : "complete";
68
+ }
69
+
70
+ let retryAfterRecovery = false;
71
+ const recoveryResult = await runRuntimeRecoveryGate({
72
+ isCurrent: options.isCurrent,
73
+ recover: options.recover,
74
+ runtimeReady: options.runtimeReady,
75
+ afterRecovery: async () => {
76
+ await options.afterRecoveryBeforeFinalMaintenance();
77
+ if (!options.isCurrent()) return;
78
+ const final = await options.maintenance();
79
+ if (final !== "ready") {
80
+ retryAfterRecovery = final === "retry";
81
+ return;
82
+ }
83
+ await options.publishOperational();
84
+ },
85
+ });
86
+ return retryAfterRecovery ? "retry" : recoveryResult;
87
+ }
88
+
89
+ interface RuntimeActivationTailOptions {
90
+ isCurrent(epoch: number): boolean;
91
+ retryDelayMs?: number;
92
+ maxRetries?: number;
93
+ schedule?: RetrySchedule;
94
+ onError?(error: unknown): void;
95
+ onExhausted?(epoch: number, attempts: number): void;
96
+ }
97
+
98
+ interface ActivationInFlight {
99
+ epoch: number;
100
+ promise: Promise<void>;
101
+ }
102
+
103
+ const defaultSchedule: RetrySchedule = (callback, delayMs) => {
104
+ const timer = setTimeout(callback, delayMs);
105
+ timer.unref?.();
106
+ return () => clearTimeout(timer);
107
+ };
108
+
109
+ /**
110
+ * Serialize runtime activation generations and turn a retry verdict into a
111
+ * bounded follow-up attempt. The retry timer is installed from the active
112
+ * promise's `finally`, after the tail has been cleared, so it cannot merely
113
+ * join the attempt that requested it.
114
+ */
115
+ export class RuntimeActivationTail {
116
+ private readonly isCurrent: (epoch: number) => boolean;
117
+ private readonly retryDelayMs: number;
118
+ private readonly maxRetries: number;
119
+ private readonly schedule: RetrySchedule;
120
+ private readonly onError: (error: unknown) => void;
121
+ private readonly onExhausted: (epoch: number, attempts: number) => void;
122
+ private inFlight: ActivationInFlight | null = null;
123
+ private retryEpoch: number | null = null;
124
+ private retryAttempts = 0;
125
+ private cancelRetry: (() => void) | null = null;
126
+ private exhaustionReported = false;
127
+ private stopped = false;
128
+ /** Who waits for no activation to be under way (whenSettled). */
129
+ private settledWaiters: Array<() => void> = [];
130
+ /** The epochs whose one rerun has been run. */
131
+ private readonly rerunEpochs = new Set<number>();
132
+
133
+ constructor(options: RuntimeActivationTailOptions) {
134
+ this.isCurrent = options.isCurrent;
135
+ this.retryDelayMs = options.retryDelayMs ?? 20_000;
136
+ this.maxRetries = Math.max(0, options.maxRetries ?? 3);
137
+ this.schedule = options.schedule ?? defaultSchedule;
138
+ this.onError = options.onError ?? (() => {});
139
+ this.onExhausted = options.onExhausted ?? (() => {});
140
+ }
141
+
142
+ request(epoch: number, attempt: ActivationAttempt): Promise<void> {
143
+ if (this.stopped || !this.isCurrent(epoch)) return Promise.resolve();
144
+ if (this.inFlight?.epoch === epoch) return this.inFlight.promise;
145
+
146
+ this.selectEpoch(epoch);
147
+ // An explicit activation supersedes a delayed retry for the same epoch.
148
+ // Keep the consumed retry count so repeated external nudges cannot reset
149
+ // the bound without creating a new daemon generation.
150
+ this.cancelScheduledRetry();
151
+
152
+ const predecessor = this.inFlight?.promise;
153
+ let result: RuntimeActivationAttemptResult = "complete";
154
+ const run = async (): Promise<void> => {
155
+ if (this.stopped || !this.isCurrent(epoch)) return;
156
+ try {
157
+ result = await attempt();
158
+ } catch (error) {
159
+ result = "retry";
160
+ try {
161
+ this.onError(error);
162
+ } catch {
163
+ // Diagnostics must not break the retry tail.
164
+ }
165
+ }
166
+ };
167
+
168
+ let running!: Promise<void>;
169
+ running = (predecessor
170
+ ? predecessor.catch(() => {}).then(run)
171
+ : run()
172
+ ).finally(() => {
173
+ if (this.inFlight?.promise !== running) return;
174
+ this.inFlight = null;
175
+ if (
176
+ result === "rerun" &&
177
+ !this.stopped &&
178
+ this.isCurrent(epoch) &&
179
+ !this.rerunEpochs.has(epoch)
180
+ ) {
181
+ // Outside the transient budget, with a fresh one of its own.
182
+ this.rerunEpochs.add(epoch);
183
+ this.cancelScheduledRetry();
184
+ this.retryAttempts = 0;
185
+ this.exhaustionReported = false;
186
+ void this.request(epoch, attempt);
187
+ } else if (
188
+ (result === "retry" || result === "rerun") &&
189
+ !this.stopped &&
190
+ this.isCurrent(epoch)
191
+ ) {
192
+ this.scheduleRetry(epoch, attempt);
193
+ } else {
194
+ this.resolve(epoch);
195
+ }
196
+ this.notifySettled();
197
+ });
198
+ this.inFlight = { epoch, promise: running };
199
+ return running;
200
+ }
201
+
202
+ /** Schedule a retry requested outside the activation tail (for example, by
203
+ * reconnect revalidation). An active attempt for the epoch already owns the
204
+ * verdict and will schedule its own retry if needed. */
205
+ scheduleRetry(epoch: number, attempt: ActivationAttempt): boolean {
206
+ if (this.stopped || !this.isCurrent(epoch)) return false;
207
+ if (this.inFlight?.epoch === epoch) return true;
208
+ this.selectEpoch(epoch);
209
+ if (this.cancelRetry) return true;
210
+ if (this.retryAttempts >= this.maxRetries) {
211
+ if (!this.exhaustionReported) {
212
+ this.exhaustionReported = true;
213
+ this.onExhausted(epoch, this.retryAttempts);
214
+ }
215
+ return false;
216
+ }
217
+
218
+ this.retryAttempts += 1;
219
+ let cancel!: () => void;
220
+ cancel = this.schedule(() => {
221
+ if (this.cancelRetry !== cancel) return;
222
+ this.cancelRetry = null;
223
+ if (this.stopped || !this.isCurrent(epoch)) {
224
+ this.notifySettled();
225
+ return;
226
+ }
227
+ void this.request(epoch, attempt);
228
+ }, this.retryDelayMs);
229
+ this.cancelRetry = cancel;
230
+ return true;
231
+ }
232
+
233
+ /** Whether an activation is under way: one running, or a retry of one
234
+ * scheduled (#p2jdax: a recheck asked for meanwhile waits for it). */
235
+ underWay(): boolean {
236
+ return !this.stopped && (this.inFlight !== null || this.cancelRetry !== null);
237
+ }
238
+
239
+ /** Resolves once no activation is under way; never, once stopped (a
240
+ * stopping host starts nothing more). */
241
+ whenSettled(): Promise<void> {
242
+ if (this.stopped) return new Promise<void>(() => {});
243
+ if (!this.underWay()) return Promise.resolve();
244
+ return new Promise<void>((resolve) => this.settledWaiters.push(resolve));
245
+ }
246
+
247
+ /** Clear retry bookkeeping after another path proves the epoch operational. */
248
+ resolve(epoch: number): void {
249
+ if (this.retryEpoch !== epoch) return;
250
+ this.cancelScheduledRetry();
251
+ this.retryEpoch = null;
252
+ this.retryAttempts = 0;
253
+ this.exhaustionReported = false;
254
+ this.notifySettled();
255
+ }
256
+
257
+ private notifySettled(): void {
258
+ if (this.stopped || this.underWay()) return;
259
+ for (const resolve of this.settledWaiters.splice(0)) resolve();
260
+ }
261
+
262
+ stop(): void {
263
+ this.stopped = true;
264
+ this.cancelScheduledRetry();
265
+ }
266
+
267
+ private selectEpoch(epoch: number): void {
268
+ if (this.retryEpoch === epoch) return;
269
+ this.cancelScheduledRetry();
270
+ this.retryEpoch = epoch;
271
+ this.retryAttempts = 0;
272
+ this.exhaustionReported = false;
273
+ }
274
+
275
+ private cancelScheduledRetry(): void {
276
+ this.cancelRetry?.();
277
+ this.cancelRetry = null;
278
+ }
279
+ }