@moondeck/station 0.0.0-stage → 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 (325) 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 +131 -4
  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
@@ -0,0 +1,604 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { agentOutputSource } from "./output-source";
3
+ /**
4
+ * ADR-121 + ADR-061: durable agent sessions for MACHINE-backed tasks.
5
+ *
6
+ * The container path's DurableProcess polls the session files on a host
7
+ * bind mount — a machine shares no filesystem with the host, so this
8
+ * variant speaks the same runner protocol over the environment transport:
9
+ *
10
+ * outbox one long-lived `tail -c +<offset+1> -F` streamed over ssh;
11
+ * byte offset = initial + bytes received (append-only file).
12
+ * A dropped ssh stream is respawned from the current offset.
13
+ * inbox chained one-shot appends (`cat >> inbox.jsonl` with stdin).
14
+ * liveness periodic heartbeat mtime probe via exec; the runner beats
15
+ * every 5s, so >25s of silence without an exit meta = dead.
16
+ *
17
+ * The runner itself (runner.mjs) is dependency-free Node and runs on the
18
+ * machine unchanged; it is delivered per-session via writeWorkspaceFile so
19
+ * it can never skew against this host's version.
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { posix } from "node:path";
24
+
25
+ import type {
26
+ TaskEnvironmentHandle,
27
+ TaskEnvironmentProcess,
28
+ } from "../task-environment/types";
29
+ import { runnerScriptPath } from "./durable-proc";
30
+ import type { ExitHandler, LineHandler } from "./proc";
31
+ import type { LineTransport } from "./transport";
32
+
33
+ /** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
34
+ const HEARTBEAT_STALE_MS = 25_000;
35
+ const HEARTBEAT_PROBE_MS = 10_000;
36
+ const CLOSE_GRACE_MS = 8_000;
37
+ const STEP_TIMEOUT_MS = 30_000;
38
+ const STEP_OUTPUT_BYTES = 64 * 1024;
39
+ const TAIL_RESPAWN_DELAY_MS = 1_000;
40
+
41
+ interface RunnerMeta {
42
+ __uai: string;
43
+ code?: number | null;
44
+ stderrTail?: string;
45
+ }
46
+
47
+ /** The handle ops this transport needs beyond the session surface. */
48
+ export type MachineSessionEnvironment = Pick<
49
+ TaskEnvironmentHandle<unknown>,
50
+ | "descriptor"
51
+ | "exec"
52
+ | "spawnSession"
53
+ | "writeWorkspaceFile"
54
+ | "launchDetachedSession"
55
+ >;
56
+
57
+ async function execStep(
58
+ environment: MachineSessionEnvironment,
59
+ argv: readonly [string, ...string[]],
60
+ stdin?: Uint8Array,
61
+ ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
62
+ const result = await environment.exec({
63
+ argv,
64
+ projectEnvProfile: "maintenance",
65
+ ...(stdin ? { stdin } : {}),
66
+ timeoutMs: STEP_TIMEOUT_MS,
67
+ maxOutputBytes: STEP_OUTPUT_BYTES,
68
+ });
69
+ return {
70
+ exitCode: result.exitCode,
71
+ stdout: Buffer.from(result.stdout).toString("utf8"),
72
+ stderr: Buffer.from(result.stderr).toString("utf8"),
73
+ };
74
+ }
75
+
76
+ /** Append bytes to an environment file (creating parents), via exec stdin. */
77
+ export async function appendEnvironmentFile(
78
+ environment: MachineSessionEnvironment,
79
+ path: string,
80
+ bytes: string,
81
+ ): Promise<void> {
82
+ const result = await execStep(
83
+ environment,
84
+ [
85
+ "/bin/sh",
86
+ "-c",
87
+ 'mkdir -p "$(dirname "$1")" && cat >> "$1"',
88
+ "append",
89
+ path,
90
+ ],
91
+ Buffer.from(bytes),
92
+ );
93
+ if (result.exitCode !== 0) {
94
+ throw new Error(`environment append to ${path} exited ${result.exitCode}`);
95
+ }
96
+ }
97
+
98
+ /** Heartbeat freshness of an environment-side session dir (attach check). */
99
+ export async function machineHeartbeatFresh(
100
+ environment: MachineSessionEnvironment,
101
+ sessionDir: string,
102
+ freshMs: number,
103
+ ): Promise<boolean> {
104
+ try {
105
+ const result = await execStep(environment, [
106
+ "/bin/sh",
107
+ "-c",
108
+ 'stat -c %Y "$1/heartbeat"',
109
+ "stat",
110
+ sessionDir,
111
+ ]);
112
+ if (result.exitCode !== 0) return false;
113
+ const mtimeSeconds = Number(result.stdout.trim());
114
+ if (!Number.isFinite(mtimeSeconds)) return false;
115
+ return Date.now() - mtimeSeconds * 1000 < freshMs;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ export interface MachineDurableOptions {
122
+ environment: MachineSessionEnvironment;
123
+ /** Environment-absolute session dir (inside the workspace). */
124
+ sessionDir: string;
125
+ /** Resume consumption from this outbox byte offset (attach path). */
126
+ initialOutboxOffset?: number;
127
+ /** Adapter history is restored before tail delivery or checkpoint advance. */
128
+ ready?: Promise<void>;
129
+ onOffsetAdvance?: (offset: number) => void;
130
+ onCloseRequested?: () => void;
131
+ /** Provider-owned detached launch. Omit to attach to a live runner. */
132
+ launch?: () => Promise<{ exitCode: number | null; stderr: Uint8Array }>;
133
+ debugLabel?: string;
134
+ }
135
+
136
+ export class MachineDurableProcess implements LineTransport {
137
+ readonly durableSessionDir: string;
138
+ readonly #environment: MachineSessionEnvironment;
139
+ readonly #sessionDir: string;
140
+ readonly #outboxPath: string;
141
+ readonly #inboxPath: string;
142
+
143
+ readonly #lineHandlers = new Set<LineHandler>();
144
+ readonly #exitHandlers = new Set<ExitHandler>();
145
+
146
+ readonly #decoder = new StringDecoder("utf8");
147
+ #lineOffset = 0;
148
+ #offset: number;
149
+ #lineBuf = "";
150
+ #stderrBuf = "";
151
+ #closed = false;
152
+ #detached = false;
153
+ #sawSpawnMeta = false;
154
+ /** Completed-and-empty direct outbox probes past the spawn grace. */
155
+ #noMetaStrikes = 0;
156
+ readonly #startedAt = Date.now();
157
+ #tail: TaskEnvironmentProcess | null = null;
158
+ #tailGeneration = 0;
159
+ #heartbeatTimer: ReturnType<typeof setInterval> | null = null;
160
+ #closeTimer: ReturnType<typeof setTimeout> | null = null;
161
+ #respawnTimer: ReturnType<typeof setTimeout> | null = null;
162
+ #probing = false;
163
+ #inboxChain: Promise<void> = Promise.resolve();
164
+ readonly #onOffsetAdvance: ((offset: number) => void) | null;
165
+ readonly #onCloseRequested: (() => void) | null;
166
+ readonly #debug: string | null;
167
+
168
+ constructor(opts: MachineDurableOptions) {
169
+ this.durableSessionDir = opts.sessionDir;
170
+ this.#environment = opts.environment;
171
+ this.#sessionDir = opts.sessionDir;
172
+ this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
173
+ this.#inboxPath = posix.join(opts.sessionDir, "inbox.jsonl");
174
+ this.#offset = opts.initialOutboxOffset ?? 0;
175
+ this.#lineOffset = this.#offset;
176
+ this.#onOffsetAdvance = opts.onOffsetAdvance ?? null;
177
+ this.#onCloseRequested = opts.onCloseRequested ?? null;
178
+ this.#debug =
179
+ opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
180
+
181
+ if (opts.launch) {
182
+ void opts.launch().then(
183
+ (result) => {
184
+ const stderr = Buffer.from(result.stderr).toString("utf8");
185
+ if (stderr) this.#stderrBuf = (this.#stderrBuf + stderr).slice(-8192);
186
+ if (result.exitCode !== 0) {
187
+ this.#finish(null, `launch exited ${result.exitCode}`);
188
+ }
189
+ },
190
+ (error: unknown) => {
191
+ this.#stderrBuf = (
192
+ this.#stderrBuf +
193
+ (error instanceof Error ? error.message : String(error))
194
+ ).slice(-8192);
195
+ this.#finish(null, "launch threw");
196
+ },
197
+ );
198
+ } else {
199
+ this.#sawSpawnMeta = true; // attach: the runner pre-exists
200
+ // The container path pre-checks heartbeat freshness with a sync stat;
201
+ // over ssh that check is async, so attach optimistically and probe
202
+ // NOW — a dead runner finishes within a round-trip instead of a
203
+ // full probe interval, and the orchestrator respawns fresh.
204
+ void this.#probeLiveness();
205
+ }
206
+
207
+ const start = () => {
208
+ if (this.#closed || this.#detached) return;
209
+ void this.#spawnTail();
210
+ this.#heartbeatTimer = setInterval(() => {
211
+ void this.#probeLiveness();
212
+ }, HEARTBEAT_PROBE_MS);
213
+ };
214
+ if (opts.ready) {
215
+ this.#inboxChain = opts.ready;
216
+ void opts.ready.then(start, () => this.#finish(null, "protocol history unavailable"));
217
+ } else start();
218
+ }
219
+
220
+ #log(message: string): void {
221
+ console.error(`[uai-agent ${this.#debug}] ${message}`);
222
+ }
223
+
224
+ // ---- outbox tail over ssh ------------------------------------------------
225
+
226
+ async #spawnTail(): Promise<void> {
227
+ if (this.#closed || this.#detached) return;
228
+ const generation = ++this.#tailGeneration;
229
+ let tail: TaskEnvironmentProcess;
230
+ try {
231
+ // spawnSession, deliberately: a session request carries NO timeoutMs,
232
+ // and the streaming runner only arms its kill timer when one exists —
233
+ // the tail must live as long as the runner does.
234
+ tail = await this.#environment.spawnSession({
235
+ // -c +N is 1-based (start AT byte N): resume just past the consumed
236
+ // prefix. -F survives the outbox not existing yet; its chatter goes
237
+ // to stderr, never the byte stream.
238
+ argv: [
239
+ "/bin/sh",
240
+ "-c",
241
+ 'exec tail -c +"$1" -F "$2" 2>/dev/null',
242
+ "tail",
243
+ String(this.#offset + 1),
244
+ this.#outboxPath,
245
+ ],
246
+ projectEnvProfile: "maintenance",
247
+ maxOutputBytes: 8 * 1024 * 1024,
248
+ });
249
+ } catch {
250
+ this.#scheduleTailRespawn(generation);
251
+ return;
252
+ }
253
+ if (this.#closed || this.#detached || generation !== this.#tailGeneration) {
254
+ await tail.terminate().catch(() => {});
255
+ return;
256
+ }
257
+ this.#tail = tail;
258
+ void (async () => {
259
+ try {
260
+ for await (const chunk of tail.stdout) {
261
+ if (this.#closed || this.#detached) break;
262
+ if (generation !== this.#tailGeneration) break;
263
+ const text = Buffer.from(chunk);
264
+ this.#offset += text.byteLength;
265
+ this.#lineBuf += this.#decoder.write(text);
266
+ this.#drainLines();
267
+ if (this.#onOffsetAdvance && !this.#closed) {
268
+ try {
269
+ this.#onOffsetAdvance(this.#lineOffset);
270
+ } catch {
271
+ // Persistence hiccup — the next batch retries larger.
272
+ }
273
+ }
274
+ }
275
+ } catch {
276
+ // Stream error — the respawn below re-establishes the tail.
277
+ }
278
+ // The ssh stream ended (drop, machine restart, terminate). If the
279
+ // session is still live, come back from the current offset.
280
+ if (!this.#closed && !this.#detached && generation === this.#tailGeneration) {
281
+ this.#scheduleTailRespawn(generation);
282
+ }
283
+ })();
284
+ }
285
+
286
+ #scheduleTailRespawn(generation: number): void {
287
+ if (this.#closed || this.#detached) return;
288
+ if (generation !== this.#tailGeneration) return;
289
+ if (this.#respawnTimer) return;
290
+ this.#respawnTimer = setTimeout(() => {
291
+ this.#respawnTimer = null;
292
+ void this.#spawnTail();
293
+ }, TAIL_RESPAWN_DELAY_MS);
294
+ }
295
+
296
+ #drainLines(): void {
297
+ let nl: number;
298
+ while ((nl = this.#lineBuf.indexOf("\n")) >= 0) {
299
+ const rawLine = this.#lineBuf.slice(0, nl);
300
+ this.#lineOffset += Buffer.byteLength(rawLine) + 1;
301
+ const line = rawLine.trim();
302
+ this.#lineBuf = this.#lineBuf.slice(nl + 1);
303
+ if (line.length === 0) continue;
304
+ if (line.startsWith('{"__uai"')) {
305
+ this.#handleMeta(line);
306
+ continue;
307
+ }
308
+ if (this.#debug) this.#log(`<- ${line.slice(0, 1000)}`);
309
+ for (const handler of this.#lineHandlers) {
310
+ try {
311
+ agentOutputSource.run(this.durableSessionDir + ":" + this.#lineOffset, () => handler(line));
312
+ } catch {
313
+ // A broken handler must not wedge the tail loop.
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ #handleMeta(line: string): void {
320
+ let meta: RunnerMeta;
321
+ try {
322
+ meta = JSON.parse(line) as RunnerMeta;
323
+ } catch {
324
+ return;
325
+ }
326
+ if (meta.__uai === "spawn") {
327
+ this.#sawSpawnMeta = true;
328
+ } else if (meta.__uai === "exit") {
329
+ if (typeof meta.stderrTail === "string" && meta.stderrTail) {
330
+ this.#stderrBuf = meta.stderrTail.slice(-8192);
331
+ }
332
+ if (this.#debug) this.#log(`exit meta: code ${meta.code ?? null}`);
333
+ this.#finish(
334
+ typeof meta.code === "number" ? meta.code : null,
335
+ "runner exit meta",
336
+ );
337
+ }
338
+ }
339
+
340
+ // ---- liveness ------------------------------------------------------------
341
+
342
+ async #probeLiveness(): Promise<void> {
343
+ if (this.#closed || this.#detached || this.#probing) return;
344
+ this.#probing = true;
345
+ try {
346
+ if (!this.#sawSpawnMeta) {
347
+ if (Date.now() - this.#startedAt <= HEARTBEAT_STALE_MS) return;
348
+ // Grace elapsed on OUR clock — but the spawn meta arrives via the
349
+ // tail, an ssh child of THIS host, and both starve together when the
350
+ // host CPU is pegged (live 2026-08-29: a 12-task launch burst pushed
351
+ // load to 16, tails lagged, and healthy runners were declared dead —
352
+ // then respawned, ORPHANING the originals). Before any verdict, ask
353
+ // the machine directly: the runner writes its spawn meta as the
354
+ // outbox's first line.
355
+ let sawOnMachine = false;
356
+ try {
357
+ const probe = await execStep(this.#environment, [
358
+ "/bin/sh",
359
+ "-c",
360
+ 'head -n1 "$1/outbox.jsonl" 2>/dev/null | grep -c __uai',
361
+ "probe",
362
+ this.#sessionDir,
363
+ ]);
364
+ sawOnMachine =
365
+ probe.exitCode === 0 && Number(probe.stdout.trim()) > 0;
366
+ } catch {
367
+ // Unreachable machine: counts as a strike below.
368
+ }
369
+ if (this.#closed || this.#detached) return;
370
+ if (sawOnMachine) {
371
+ this.#sawSpawnMeta = true;
372
+ return;
373
+ }
374
+ // Two COMPLETED empty probes past the grace — not elapsed time — are
375
+ // the death sentence; probes that never ran prove nothing.
376
+ this.#noMetaStrikes += 1;
377
+ if (this.#noMetaStrikes < 2) return;
378
+ await this.#killRemoteRunner();
379
+ if (this.#closed || this.#detached) return;
380
+ this.#finish(null, "no spawn meta within grace");
381
+ return;
382
+ }
383
+ const fresh = await machineHeartbeatFresh(
384
+ this.#environment,
385
+ this.#sessionDir,
386
+ HEARTBEAT_STALE_MS,
387
+ );
388
+ if (this.#closed || this.#detached) return;
389
+ if (!fresh) {
390
+ // A stale-but-alive runner would be doubled by the respawn; the
391
+ // single-runner-per-session invariant is enforced here, not hoped for.
392
+ await this.#killRemoteRunner();
393
+ if (this.#closed || this.#detached) return;
394
+ this.#finish(null, "heartbeat stale");
395
+ }
396
+ } finally {
397
+ this.#probing = false;
398
+ }
399
+ }
400
+
401
+ /** Best-effort remote kill of this session's runner before declaring it
402
+ * dead: a wrong verdict must cost a restart, never an orphaned agent.
403
+ * `runner[.]mjs` so the kill-shell's own argv (which carries this script
404
+ * text) can never self-match — the pgrep/pkill self-match class. */
405
+ async #killRemoteRunner(): Promise<void> {
406
+ try {
407
+ await execStep(this.#environment, [
408
+ "/bin/sh",
409
+ "-c",
410
+ 'pkill -f "runner[.]mjs $1" >/dev/null 2>&1; true',
411
+ "kill",
412
+ this.#sessionDir,
413
+ ]);
414
+ } catch {
415
+ // The finish verdict stands either way; the orphan sweep in recovery
416
+ // remains the backstop for an unreachable machine.
417
+ }
418
+ }
419
+
420
+ #finish(code: number | null, reason = "unspecified"): void {
421
+ if (this.#closed) return;
422
+ // Session deaths are load-bearing and were undiagnosable without this
423
+ // (live 2026-08-27: a 65s recycle loop with no trace). Always log.
424
+ console.warn(
425
+ `[machine-durable] session ${this.#sessionDir} finished (code ${code ?? "null"}): ${reason}`,
426
+ );
427
+ this.#closed = true;
428
+ this.#stopTimers();
429
+ const tail = this.#tail;
430
+ this.#tail = null;
431
+ if (tail) void tail.terminate().catch(() => {});
432
+ for (const handler of this.#exitHandlers) handler(code);
433
+ }
434
+
435
+ #stopTimers(): void {
436
+ if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
437
+ if (this.#closeTimer) clearTimeout(this.#closeTimer);
438
+ if (this.#respawnTimer) clearTimeout(this.#respawnTimer);
439
+ this.#heartbeatTimer = null;
440
+ this.#closeTimer = null;
441
+ this.#respawnTimer = null;
442
+ }
443
+
444
+ // ---- LineProcess-compatible surface ---------------------------------------
445
+
446
+ onLine(handler: LineHandler): void {
447
+ this.#lineHandlers.add(handler);
448
+ }
449
+
450
+ onExit(handler: ExitHandler): void {
451
+ this.#exitHandlers.add(handler);
452
+ }
453
+
454
+ /** `__uai` MUST stay the first key: both runner and host filter meta lines
455
+ * with a byte-prefix check, not a parse (see DurableProcess.writeLine). */
456
+ writeLine(value: unknown): void {
457
+ if (this.#closed || this.#detached) return;
458
+ const json = JSON.stringify(value);
459
+ const inputMeta = JSON.stringify({ __uai: "input", ts: Date.now() });
460
+ if (this.#debug) this.#log(`-> ${json.slice(0, 1000)}`);
461
+ this.#inboxChain = this.#inboxChain
462
+ .then(() =>
463
+ this.#closed || this.#detached ? undefined : appendEnvironmentFile(
464
+ this.#environment,
465
+ this.#inboxPath,
466
+ `${inputMeta}\n${json}\n`,
467
+ ),
468
+ )
469
+ .catch(() => {
470
+ if (this.#closed || this.#detached) return;
471
+ // A fresh heartbeat proves the runner lives, not that it received the
472
+ // message. Fail immediately through the adapter's normal error path.
473
+ // Never include the frame or exec stderr: either may contain secrets.
474
+ this.#stderrBuf = "Could not deliver the message to the agent because its input could not be written. Please try again.";
475
+ this.#finish(null, "inbox append failed");
476
+ void this.#killRemoteRunner();
477
+ });
478
+ }
479
+
480
+ get stderrTail(): string {
481
+ return this.#stderrBuf;
482
+ }
483
+
484
+ get isClosed(): boolean {
485
+ return this.#closed;
486
+ }
487
+
488
+ get outboxOffset(): number {
489
+ return this.#lineOffset;
490
+ }
491
+
492
+ /** Stop consuming WITHOUT touching the runner (host restart in miniature). */
493
+ detach(): void {
494
+ if (this.#detached || this.#closed) return;
495
+ this.#detached = true;
496
+ this.#stopTimers();
497
+ const tail = this.#tail;
498
+ this.#tail = null;
499
+ if (tail) void tail.terminate().catch(() => {});
500
+ }
501
+
502
+ /** Real teardown: ask the runner to stop its CLI, then observe the exit. */
503
+ async close(): Promise<void> {
504
+ if (this.#closed || this.#detached) return;
505
+ try {
506
+ this.#onCloseRequested?.();
507
+ } catch {
508
+ // Bookkeeping only.
509
+ }
510
+ try {
511
+ await this.#inboxChain;
512
+ await appendEnvironmentFile(
513
+ this.#environment,
514
+ this.#inboxPath,
515
+ '{"__uai":"stop"}\n',
516
+ );
517
+ } catch {
518
+ // Inbox unwritable — fall through to the grace timer.
519
+ }
520
+ this.#closeTimer = setTimeout(
521
+ () => this.#finish(null, "close grace elapsed"),
522
+ CLOSE_GRACE_MS,
523
+ );
524
+ }
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Session-dir plumbing shared with the transport layer.
529
+ // ---------------------------------------------------------------------------
530
+
531
+ export function machineSessionDir(
532
+ workspacePath: string,
533
+ agentId: string,
534
+ ): string {
535
+ return posix.join(
536
+ workspacePath,
537
+ ".moondeck",
538
+ "sessions",
539
+ agentId,
540
+ `${String(Date.now())}-${process.pid}`,
541
+ );
542
+ }
543
+
544
+ /** Deliver this host's runner into the environment session dir. */
545
+ export async function deployRunner(
546
+ environment: MachineSessionEnvironment,
547
+ sessionDir: string,
548
+ ): Promise<string> {
549
+ const runnerPath = posix.join(sessionDir, "runner.mjs");
550
+ await environment.writeWorkspaceFile(
551
+ runnerPath,
552
+ readFileSync(runnerScriptPath()),
553
+ );
554
+ return runnerPath;
555
+ }
556
+
557
+ /** Environment-side `current` pointer (agents read the path from their
558
+ * preamble). Best-effort: failure must never kill a session. */
559
+ export async function publishMachineCurrentSession(
560
+ environment: MachineSessionEnvironment,
561
+ sessionDir: string,
562
+ ): Promise<void> {
563
+ try {
564
+ await execStep(environment, [
565
+ "/bin/sh",
566
+ "-c",
567
+ 'ln -sfn "$(basename "$1")" "$(dirname "$1")/current"',
568
+ "publish",
569
+ sessionDir,
570
+ ]);
571
+ } catch {
572
+ // Diagnostic pointer only.
573
+ }
574
+ }
575
+
576
+ /** Ask a (possibly dead) predecessor runner to stop — same gesture as the
577
+ * host-FS append, over the transport, and just as harmless when it's gone. */
578
+ export async function stopMachinePredecessor(
579
+ environment: MachineSessionEnvironment,
580
+ sessionDir: string,
581
+ ): Promise<void> {
582
+ try {
583
+ await appendEnvironmentFile(
584
+ environment,
585
+ posix.join(sessionDir, "inbox.jsonl"),
586
+ '{"__uai":"stop"}\n',
587
+ );
588
+ } catch {
589
+ // Dir gone / runner dead — nothing to stop.
590
+ }
591
+ }
592
+
593
+ /** Removal/recovery variant: failure is not success because no later attach
594
+ * will provide the transport layer's usual predecessor-stop backstop. */
595
+ export async function stopMachinePredecessorStrict(
596
+ environment: MachineSessionEnvironment,
597
+ sessionDir: string,
598
+ ): Promise<void> {
599
+ await appendEnvironmentFile(
600
+ environment,
601
+ posix.join(sessionDir, "inbox.jsonl"),
602
+ '{"__uai":"stop"}\n',
603
+ );
604
+ }