@moondeck/station 0.0.1 → 1.0.1

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 (330) 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/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Agent transport selection (ADR-061). Adapters call createAgentTransport()
3
+ * where they used to build a LineProcess directly; it returns either:
4
+ *
5
+ * - provider-owned interactive pipes when
6
+ * UAI_DURABLE_SESSIONS=0, or
7
+ * - the durable path: an in-container runner owning the CLI, driven through
8
+ * inbox/outbox files on the workspace mount (DurableProcess).
9
+ *
10
+ * Durable mode makes host restarts transparent WITHOUT orchestrator
11
+ * changes: the orchestrator's lazy respawn calls factory.create as always,
12
+ * and this helper quietly ATTACHES to a still-live runner (fresh heartbeat +
13
+ * a `running` host_agent_sessions row) at the persisted outbox offset
14
+ * instead of spawning a new CLI — the agent's in-memory context survives.
15
+ * Adapters that re-handshake per process (codex) pass allowAttach:false and
16
+ * get a fresh runner each create — the stale predecessor is asked to stop
17
+ * via its inbox first.
18
+ *
19
+ * The runner script is COPIED into each session dir (not bind-mounted):
20
+ * the task environment sees the workspace at its provider path, so each
21
+ * durable spawn ships the runner version matching this host.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { basename, dirname, join } from "node:path";
26
+
27
+ import { and, eq, isNull } from "drizzle-orm";
28
+
29
+ import { getDb, schema, type Db } from "../db";
30
+ import { taskWorkspaceDir } from "../env";
31
+ import { machineSessionAuthorityEnv } from "../runtime-authority";
32
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
33
+ import type { TaskEnvironmentAgentSessionSurface, TaskEnvironmentHandle } from "../task-environment/types";
34
+ import {
35
+ appendContainedFile,
36
+ containedModifiedAt,
37
+ hostPathParts,
38
+ makeContainedDirectories,
39
+ makeContainedDirectoriesSync,
40
+ removeContainedEntry,
41
+ readContainedFrom,
42
+ replaceContainedLink,
43
+ runtimeNamespaceOf,
44
+ writeContainedFile,
45
+ writeContainedFileSync,
46
+ type RuntimeNamespace,
47
+ } from "../task-environment/workspace-files";
48
+ import { DurableProcess, runnerScriptPath } from "./durable-proc";
49
+ import {
50
+ MachineDurableProcess,
51
+ deployRunner,
52
+ machineSessionDir,
53
+ publishMachineCurrentSession,
54
+ stopMachinePredecessor,
55
+ type MachineSessionEnvironment,
56
+ } from "./machine-durable";
57
+ import {
58
+ AGENT_SESSION_OUTPUT_BUFFER_BYTES,
59
+ EnvironmentLineProcess,
60
+ type ExitHandler,
61
+ type LineHandler,
62
+ } from "./proc";
63
+ import { agentCliArgv } from "../agent-cli";
64
+ import { isEnvironmentTransportProvider } from "../task-environment/types";
65
+ import { attachCompatibilityKeysMatch } from "./attach-compatibility";
66
+
67
+ /** The shared surface adapters program against (LineProcess's shape). */
68
+ export interface LineTransport {
69
+ /** Exact durable row generation owned by this transport, when durable. */
70
+ readonly durableSessionDir?: string;
71
+ onLine(handler: LineHandler): void;
72
+ onExit(handler: ExitHandler): void;
73
+ writeLine(value: unknown): void;
74
+ readonly stderrTail: string;
75
+ readonly isClosed: boolean;
76
+ close(): Promise<void>;
77
+ }
78
+
79
+ export interface AgentTransportOptions {
80
+ taskId: string;
81
+ agentId: string;
82
+ environment: TaskEnvironmentAgentSessionSurface;
83
+ /** CLI + literal argv as observed inside the task environment. */
84
+ cli: string;
85
+ cliArgs: string[];
86
+ /** Env names forwarded from the host's own environment. */
87
+ passEnv?: string[];
88
+ /** Explicit per-session environment values. */
89
+ explicitEnv?: Record<string, string>;
90
+ /**
91
+ * Whether a live runner from a previous host process may be attached to.
92
+ * True for stateless adapters (claude); false for ones whose host-side
93
+ * protocol state can't outlive the host process (codex handshake).
94
+ */
95
+ allowAttach: boolean;
96
+ /** Host-owned launch-settings generation. Both absent remains compatible. */
97
+ attachCompatibilityKey?: string;
98
+ kind: string;
99
+ debugLabel?: string;
100
+ /** Restore adapter counters from already-consumed output, before an attach
101
+ * resumes delivery. No historical line is emitted again. */
102
+ restoreProtocolState?: {
103
+ accepts(line: string): boolean;
104
+ restore(lines: readonly string[], complete: boolean): void;
105
+ };
106
+ }
107
+
108
+ /** Bounded recovery: absence beyond this window is unknown, never zero. */
109
+ export const PROTOCOL_HISTORY_BYTES = 8 * 1024 * 1024;
110
+
111
+ function restoreProtocolWindow(
112
+ state: NonNullable<AgentTransportOptions["restoreProtocolState"]>,
113
+ bytes: Buffer | null,
114
+ offset: number,
115
+ ): void {
116
+ if (!bytes || !Number.isSafeInteger(offset) || offset < 0 ||
117
+ bytes.length !== Math.min(offset, PROTOCOL_HISTORY_BYTES) ||
118
+ (bytes.length > 0 && bytes.at(-1) !== 10)) {
119
+ state.restore([], false);
120
+ return;
121
+ }
122
+ const complete = offset <= PROTOCOL_HISTORY_BYTES;
123
+ let text = bytes.toString("utf8");
124
+ // The window may begin inside a line (or a UTF-8 code point).
125
+ if (!complete) text = text.slice(text.indexOf("\n") + 1);
126
+ // Rebuild counter state in order: the final raw result can itself be stale.
127
+ state.restore(text.split("\n").filter(line => state.accepts(line)), complete);
128
+ }
129
+
130
+ /** Linux machine workspace read, pinned to the saved checkpoint. Open every
131
+ * component relative to its directory fd, without following task-made links;
132
+ * read only the bounded suffix of the already-consumed prefix. */
133
+ const MACHINE_PROTOCOL_HISTORY_READ = String.raw`
134
+ const fs = require("node:fs"), path = require("node:path");
135
+ const [root, file, offsetText, limitText] = process.argv.slice(1);
136
+ const offset = Number(offsetText), limit = Number(limitText);
137
+ const parts = path.relative(root, file).split(path.sep);
138
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 ||
139
+ parts.some(p => !p || p === ".." || p === ".")) process.exit(1);
140
+ let fd;
141
+ try {
142
+ fd = fs.openSync(root, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
143
+ for (let i = 0; i < parts.length; i++) {
144
+ const next = fs.openSync("/proc/self/fd/" + fd + "/" + parts[i], fs.constants.O_RDONLY |
145
+ fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK | (i + 1 < parts.length ? fs.constants.O_DIRECTORY : 0));
146
+ fs.closeSync(fd); fd = next;
147
+ }
148
+ const stat = fs.fstatSync(fd);
149
+ if (!stat.isFile() || stat.size < offset) process.exitCode = 1;
150
+ else {
151
+ const start = Math.max(0, offset - limit), out = Buffer.alloc(offset - start);
152
+ let done = 0;
153
+ while (done < out.length) {
154
+ const n = fs.readSync(fd, out, done, out.length - done, start + done);
155
+ if (!n) throw new Error("short read");
156
+ done += n;
157
+ }
158
+ process.stdout.write(out);
159
+ }
160
+ } catch { process.exitCode = 1; }
161
+ finally { if (fd !== undefined) fs.closeSync(fd); }
162
+ `;
163
+
164
+ async function restoreMachineProtocolState(
165
+ opts: AgentTransportOptions,
166
+ environment: MachineSessionEnvironment,
167
+ sessionDir: string,
168
+ offset: number,
169
+ ): Promise<void> {
170
+ const state = opts.restoreProtocolState;
171
+ if (!state) return;
172
+ if (offset === 0) { state.restore([], true); return; }
173
+ let bytes: Buffer | null = null;
174
+ try {
175
+ const result = await environment.exec({
176
+ argv: ["node", "-e", MACHINE_PROTOCOL_HISTORY_READ, environment.descriptor.workspacePath,
177
+ join(sessionDir, "outbox.jsonl"), String(offset), String(PROTOCOL_HISTORY_BYTES)],
178
+ projectEnvProfile: "maintenance",
179
+ timeoutMs: 30_000,
180
+ maxOutputBytes: PROTOCOL_HISTORY_BYTES,
181
+ });
182
+ if (result.exitCode === 0 && !result.stdoutTruncated) bytes = Buffer.from(result.stdout);
183
+ } catch { /* Unknown history must not turn a cumulative total into a charge. */ }
184
+ restoreProtocolWindow(state, bytes, offset);
185
+ }
186
+
187
+ /** Runner heartbeat is 5 s; older than this = not attachable. */
188
+ const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
189
+
190
+ /**
191
+ * ONE live outbox consumer per (task, agent) in this host process. A session
192
+ * dropped from the orchestrator's map without close()/detach() (e.g. the
193
+ * error-event path — a pipes-era "process is dead" assumption) leaves its
194
+ * DurableProcess polling FOREVER: its liveness check only trips when the
195
+ * RUNNER dies, and the runner is alive. The next channelEnsure then attaches
196
+ * a second tail to the same outbox, and every agent turn is emitted once per
197
+ * leaked tail — live incident 2026-07-20: one agent's messages posted ×5 to
198
+ * the feed, multiplicity growing by one per errored turn. Enforcing the
199
+ * invariant here covers every replacement path, including future ones.
200
+ */
201
+ interface DetachableTail extends LineTransport {
202
+ detach(): void;
203
+ }
204
+
205
+ const liveTails = new Map<string, DetachableTail>();
206
+
207
+ export function durableAgentSessionId(taskId: string, agentId: string): string | undefined {
208
+ const tail = liveTails.get(`${taskId}:${agentId}`);
209
+ return tail && !tail.isClosed ? tail.durableSessionDir : undefined;
210
+ }
211
+
212
+ function claimTail<T extends DetachableTail>(key: string, proc: T): T {
213
+ liveTails.get(key)?.detach();
214
+ liveTails.set(key, proc);
215
+ return proc;
216
+ }
217
+
218
+ function durableEnabled(): boolean {
219
+ return process.env.UAI_DURABLE_SESSIONS !== "0";
220
+ }
221
+
222
+ /** Durable rows are keyed by task + agent for lookup, but callbacks belong to
223
+ * one exact generation. A delayed offset/exit from the predecessor must never
224
+ * mutate the replacement row after its upsert. */
225
+ function durableRowCallbacks(
226
+ db: Db,
227
+ taskId: string,
228
+ agentId: string,
229
+ sessionDir: string,
230
+ ): {
231
+ persistOffset(offset: number): void;
232
+ markClosed(): void;
233
+ } {
234
+ const ownedRow = () =>
235
+ and(
236
+ eq(schema.hostAgentSessions.taskId, taskId),
237
+ eq(schema.hostAgentSessions.agentId, agentId),
238
+ eq(schema.hostAgentSessions.sessionDir, sessionDir),
239
+ );
240
+ return {
241
+ persistOffset(offset: number): void {
242
+ db.update(schema.hostAgentSessions)
243
+ .set({ outboxOffset: offset, updatedAt: Date.now() })
244
+ .where(ownedRow())
245
+ .run();
246
+ },
247
+ markClosed(): void {
248
+ db.update(schema.hostAgentSessions)
249
+ .set({ status: "closed", updatedAt: Date.now() })
250
+ .where(ownedRow())
251
+ .run();
252
+ },
253
+ };
254
+ }
255
+
256
+ /** Claim a compatible durable row and promote a one-release legacy key to the
257
+ * rich launch fingerprint. The exact generation/key fence prevents a stale
258
+ * host process from rewriting a replacement row after reading its snapshot. */
259
+ function claimCompatibleAttachRow(
260
+ db: Db,
261
+ taskId: string,
262
+ agentId: string,
263
+ sessionDir: string,
264
+ stored: string | null | undefined,
265
+ desired: string | null | undefined,
266
+ ): boolean {
267
+ if (!attachCompatibilityKeysMatch(stored, desired)) return false;
268
+ const storedKey = stored ?? null;
269
+ const desiredKey = desired ?? null;
270
+ if (storedKey === desiredKey) return true;
271
+ if (desiredKey === null) return false;
272
+ const result = db
273
+ .update(schema.hostAgentSessions)
274
+ .set({ attachCompatibilityKey: desiredKey, updatedAt: Date.now() })
275
+ .where(
276
+ and(
277
+ eq(schema.hostAgentSessions.taskId, taskId),
278
+ eq(schema.hostAgentSessions.agentId, agentId),
279
+ eq(schema.hostAgentSessions.sessionDir, sessionDir),
280
+ storedKey === null
281
+ ? isNull(schema.hostAgentSessions.attachCompatibilityKey)
282
+ : eq(schema.hostAgentSessions.attachCompatibilityKey, storedKey),
283
+ ),
284
+ )
285
+ .run();
286
+ return result.changes === 1;
287
+ }
288
+
289
+ export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
290
+ // Connection stderr/provider errors may contain arbitrary credentials.
291
+ if (opts.explicitEnv?.UAI_CUSTOM_CONNECTION === "1") opts = { ...opts, debugLabel: undefined };
292
+ // ADR-121: machine-backed sessions ride the environment's own transport —
293
+ // the container-runtime preflight is a docker/apple concern a machine task
294
+ // must not trip over, and the host-FS durable flow below polls files a
295
+ // machine does not share. Machine durability uses the ssh-tail backend.
296
+ if (isEnvironmentTransportProvider(opts.environment.descriptor.locator.provider)) {
297
+ // The session env was assembled with the CONTAINER runtime authority
298
+ // (asdf/corepack pins to shared read-only volumes) — on a machine those
299
+ // paths do not exist and the pins actively break package managers.
300
+ // Swap in the machine authority; credentials and task env pass through.
301
+ const machineOpts: AgentTransportOptions = {
302
+ ...opts,
303
+ explicitEnv: machineSessionAuthorityEnv(opts.explicitEnv),
304
+ };
305
+ if (!durableEnabled()) {
306
+ clearCurrentSession(machineOpts.taskId, machineOpts.agentId, workspaceRuntime(machineOpts.environment));
307
+ return directEnvironmentTransport(machineOpts);
308
+ }
309
+ return machineDurableTransport(machineOpts);
310
+ }
311
+ // Session creation can happen after channel/task lifecycle queues drain, well
312
+ // after the command-level runtime preflight. Recheck at the actual attach or
313
+ // spawn boundary so a cached ready verdict cannot launch container work.
314
+ requireContainerRuntimeOperational();
315
+ if (!durableEnabled()) {
316
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
317
+ return directEnvironmentTransport(opts);
318
+ }
319
+
320
+ const db = getDb();
321
+ const row = db
322
+ .select()
323
+ .from(schema.hostAgentSessions)
324
+ .where(
325
+ and(
326
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
327
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
328
+ ),
329
+ )
330
+ .get();
331
+
332
+ const tailKey = `${opts.taskId}:${opts.agentId}`;
333
+
334
+ const runtime = workspaceRuntime(opts.environment);
335
+ const rowFiles = row ? sessionFilesOf(opts.taskId, row.sessionDir) : null;
336
+
337
+ // ---- Attach: a previous host process left this agent's runner alive. ----
338
+ if (
339
+ opts.allowAttach &&
340
+ row &&
341
+ rowFiles &&
342
+ row.status === "running" &&
343
+ row.containerName === opts.environment.durableIdentity &&
344
+ row.kind === opts.kind &&
345
+ heartbeatFresh(rowFiles) &&
346
+ claimCompatibleAttachRow(
347
+ db,
348
+ opts.taskId,
349
+ opts.agentId,
350
+ row.sessionDir,
351
+ row.attachCompatibilityKey,
352
+ opts.attachCompatibilityKey,
353
+ )
354
+ ) {
355
+ if (opts.restoreProtocolState) {
356
+ let bytes: Buffer | null = null;
357
+ try {
358
+ const start = Math.max(0, row.outboxOffset - PROTOCOL_HISTORY_BYTES);
359
+ const prior = readContainedFrom(rowFiles.root, [...rowFiles.parts, "outbox.jsonl"], start,
360
+ row.outboxOffset - start);
361
+ if (prior && prior.size >= row.outboxOffset) bytes = prior.bytes;
362
+ else if (row.outboxOffset === 0) bytes = Buffer.alloc(0);
363
+ } catch { /* A missing/refused history remains unknown. */ }
364
+ restoreProtocolWindow(opts.restoreProtocolState, bytes, row.outboxOffset);
365
+ }
366
+ const { persistOffset, markClosed } = durableRowCallbacks(
367
+ db,
368
+ opts.taskId,
369
+ opts.agentId,
370
+ row.sessionDir,
371
+ );
372
+ const proc = new DurableProcess({
373
+ hostSessionDir: row.sessionDir,
374
+ sessionFiles: rowFiles,
375
+ initialOutboxOffset: row.outboxOffset,
376
+ onOffsetAdvance: persistOffset,
377
+ onCloseRequested: markClosed,
378
+ debugLabel: opts.debugLabel,
379
+ });
380
+ proc.onExit(markClosed);
381
+ publishCurrentSession(rowFiles, runtime);
382
+ return claimTail(tailKey, proc);
383
+ }
384
+
385
+ // ---- Spawn a fresh runner, asking any predecessor to stop. --------------
386
+ // Unconditional (not gated on row.status): a runner falsely marked closed
387
+ // by a stale-heartbeat verdict may still be alive, and a stop appended to
388
+ // a dead session's inbox is harmless — this is what guarantees one live
389
+ // CLI per (task, agent).
390
+ if (rowFiles) {
391
+ void appendContainedFile(rowFiles.root, [...rowFiles.parts, "inbox.jsonl"], Buffer.from('{"__uai":"stop"}\n')).catch(
392
+ () => {
393
+ // Dir already gone / runner already dead — nothing to stop.
394
+ },
395
+ );
396
+ }
397
+
398
+ const workspace = taskWorkspaceDir(opts.taskId);
399
+ const sessionFiles = {
400
+ root: workspace,
401
+ parts: [".moondeck", "sessions", opts.agentId, `${String(Date.now())}-${process.pid}`],
402
+ };
403
+ const sessionDir = join(workspace, ...sessionFiles.parts);
404
+ const environmentSessionDir = join(
405
+ opts.environment.descriptor.workspacePath,
406
+ ".moondeck",
407
+ "sessions",
408
+ opts.agentId,
409
+ basename(sessionDir),
410
+ );
411
+ const { persistOffset, markClosed } = durableRowCallbacks(
412
+ db,
413
+ opts.taskId,
414
+ opts.agentId,
415
+ sessionDir,
416
+ );
417
+ const runnerInEnvironment = join(environmentSessionDir, "runner.mjs");
418
+ let runnerSource: Buffer;
419
+ try {
420
+ runnerSource = readFileSync(runnerScriptPath());
421
+ } catch (err) {
422
+ // Runner asset missing (a packaging miss — the 0.3.0/0.3.1 desktop
423
+ // builds vendored the agent without runner/). Degrade to legacy pipes:
424
+ // sessions work, they just don't survive host restarts.
425
+ console.warn(
426
+ `[transport] station unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
427
+ );
428
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
429
+ return directEnvironmentTransport(opts);
430
+ }
431
+
432
+ // Ship this host's runner with the session: works in containers created
433
+ // before this feature (the workspace is mounted at its host path) and
434
+ // never skews against the host version. Its directory and the runner are
435
+ // made in the task's workspace never through a link the task put there
436
+ // (#cb3j0z), before the runner launches.
437
+ // On Linux both are made at once; on macOS the directory is made through
438
+ // the task's runtime first.
439
+ let preparedNow = false;
440
+ let prepared: Promise<void>;
441
+ try {
442
+ if (makeContainedDirectoriesSync(workspace, sessionFiles.parts)) {
443
+ writeContainedFileSync(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
444
+ preparedNow = true;
445
+ prepared = Promise.resolve();
446
+ } else {
447
+ prepared = (async () => {
448
+ await makeContainedDirectories(workspace, sessionFiles.parts, runtime);
449
+ await writeContainedFile(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
450
+ })();
451
+ }
452
+ } catch (error) {
453
+ prepared = Promise.reject(error);
454
+ }
455
+ prepared.catch(() => {
456
+ // Reported through the launch, which awaits it.
457
+ });
458
+ const proc = new DurableProcess({
459
+ hostSessionDir: sessionDir,
460
+ sessionFiles,
461
+ ...(preparedNow ? {} : { ready: prepared }),
462
+ onOffsetAdvance: persistOffset,
463
+ onCloseRequested: markClosed,
464
+ debugLabel: opts.debugLabel,
465
+ launch: async () => {
466
+ await prepared;
467
+ const cliArgv = agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]);
468
+ const result = await opts.environment.launchDetachedSession({
469
+ argv: [
470
+ "node",
471
+ runnerInEnvironment,
472
+ environmentSessionDir,
473
+ "--",
474
+ ...cliArgv,
475
+ ],
476
+ projectEnvProfile: "task",
477
+ inheritEnv: opts.passEnv ?? [],
478
+ env: opts.explicitEnv ?? {},
479
+ launchTimeoutMs: 30_000,
480
+ maxOutputBytes: 256 * 1024,
481
+ });
482
+ return { exitCode: result.exitCode, stderr: result.stderr };
483
+ },
484
+ });
485
+
486
+ const now = Date.now();
487
+ db.insert(schema.hostAgentSessions)
488
+ .values({
489
+ taskId: opts.taskId,
490
+ agentId: opts.agentId,
491
+ sessionDir,
492
+ containerName: opts.environment.durableIdentity,
493
+ kind: opts.kind,
494
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
495
+ outboxOffset: 0,
496
+ status: "running",
497
+ createdAt: now,
498
+ updatedAt: now,
499
+ })
500
+ .onConflictDoUpdate({
501
+ target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
502
+ set: {
503
+ sessionDir,
504
+ containerName: opts.environment.durableIdentity,
505
+ kind: opts.kind,
506
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
507
+ outboxOffset: 0,
508
+ status: "running",
509
+ updatedAt: now,
510
+ },
511
+ })
512
+ .run();
513
+
514
+ proc.onExit(markClosed);
515
+ if (preparedNow) publishCurrentSession(sessionFiles, runtime);
516
+ else void prepared.then(() => publishCurrentSession(sessionFiles, runtime), () => {});
517
+ return claimTail(tailKey, proc);
518
+ }
519
+
520
+ /**
521
+ * ADR-121: durable machine sessions. Same DB row + attach/replace semantics
522
+ * as the container flow; the session files live in the MACHINE's workspace
523
+ * and are reached over the environment transport (MachineDurableProcess).
524
+ * Degrades to direct pipes when the handle lacks the wider ops or the runner
525
+ * asset is missing — sessions work, they just don't survive host restarts.
526
+ */
527
+ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
528
+ const surface = opts.environment;
529
+ const capable =
530
+ "exec" in surface && "spawnSession" in surface && "writeWorkspaceFile" in surface;
531
+ let runnerSource: Buffer | null = null;
532
+ if (capable) {
533
+ try {
534
+ runnerSource = readFileSync(runnerScriptPath());
535
+ } catch (err) {
536
+ console.warn(
537
+ `[transport] station unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
538
+ );
539
+ }
540
+ }
541
+ if (!capable || runnerSource === null) {
542
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
543
+ return directEnvironmentTransport(opts);
544
+ }
545
+ const environment = surface as unknown as MachineSessionEnvironment &
546
+ TaskEnvironmentAgentSessionSurface;
547
+
548
+ const db = getDb();
549
+ const row = db
550
+ .select()
551
+ .from(schema.hostAgentSessions)
552
+ .where(
553
+ and(
554
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
555
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
556
+ ),
557
+ )
558
+ .get();
559
+ const tailKey = `${opts.taskId}:${opts.agentId}`;
560
+
561
+ // ---- Attach: a previous host process left this machine's runner alive. --
562
+ // Freshness cannot be a sync stat over ssh; MachineDurableProcess probes
563
+ // the heartbeat immediately on attach and finishes fast when it is stale.
564
+ if (
565
+ opts.allowAttach &&
566
+ row &&
567
+ row.status === "running" &&
568
+ row.containerName === environment.durableIdentity &&
569
+ row.kind === opts.kind &&
570
+ claimCompatibleAttachRow(
571
+ db,
572
+ opts.taskId,
573
+ opts.agentId,
574
+ row.sessionDir,
575
+ row.attachCompatibilityKey,
576
+ opts.attachCompatibilityKey,
577
+ )
578
+ ) {
579
+ const { persistOffset, markClosed } = durableRowCallbacks(
580
+ db,
581
+ opts.taskId,
582
+ opts.agentId,
583
+ row.sessionDir,
584
+ );
585
+ const proc = new MachineDurableProcess({
586
+ environment,
587
+ sessionDir: row.sessionDir,
588
+ initialOutboxOffset: row.outboxOffset,
589
+ ...(opts.restoreProtocolState ? {
590
+ ready: restoreMachineProtocolState(opts, environment, row.sessionDir, row.outboxOffset),
591
+ } : {}),
592
+ onOffsetAdvance: persistOffset,
593
+ onCloseRequested: markClosed,
594
+ debugLabel: opts.debugLabel,
595
+ });
596
+ proc.onExit(markClosed);
597
+ return claimTail(tailKey, proc);
598
+ }
599
+
600
+ // ---- Spawn a fresh runner, asking any predecessor to stop. --------------
601
+ const staleMachineSession =
602
+ row && row.containerName === environment.durableIdentity
603
+ ? row.sessionDir
604
+ : null;
605
+ const sessionDir = machineSessionDir(
606
+ environment.descriptor.workspacePath,
607
+ opts.agentId,
608
+ );
609
+ const { persistOffset, markClosed } = durableRowCallbacks(
610
+ db,
611
+ opts.taskId,
612
+ opts.agentId,
613
+ sessionDir,
614
+ );
615
+ const proc = new MachineDurableProcess({
616
+ environment,
617
+ sessionDir,
618
+ onOffsetAdvance: persistOffset,
619
+ onCloseRequested: markClosed,
620
+ debugLabel: opts.debugLabel,
621
+ launch: async () => {
622
+ // Unconditional stop of a same-machine predecessor (see the container
623
+ // flow): a runner falsely marked closed may still be alive, and a stop
624
+ // appended to a dead session's inbox is harmless.
625
+ if (staleMachineSession) {
626
+ await stopMachinePredecessor(environment, staleMachineSession);
627
+ }
628
+ const runnerPath = await deployRunner(environment, sessionDir);
629
+ await publishMachineCurrentSession(environment, sessionDir);
630
+ const cliArgv = agentCliArgv(environment, [opts.cli, ...opts.cliArgs]);
631
+ const result = await environment.launchDetachedSession({
632
+ argv: [
633
+ "node",
634
+ runnerPath,
635
+ sessionDir,
636
+ "--",
637
+ ...cliArgv,
638
+ ],
639
+ projectEnvProfile: "task",
640
+ inheritEnv: opts.passEnv ?? [],
641
+ env: opts.explicitEnv ?? {},
642
+ launchTimeoutMs: 30_000,
643
+ maxOutputBytes: 256 * 1024,
644
+ });
645
+ return { exitCode: result.exitCode, stderr: result.stderr };
646
+ },
647
+ });
648
+
649
+ const now = Date.now();
650
+ db.insert(schema.hostAgentSessions)
651
+ .values({
652
+ taskId: opts.taskId,
653
+ agentId: opts.agentId,
654
+ sessionDir,
655
+ containerName: environment.durableIdentity,
656
+ kind: opts.kind,
657
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
658
+ outboxOffset: 0,
659
+ status: "running",
660
+ createdAt: now,
661
+ updatedAt: now,
662
+ })
663
+ .onConflictDoUpdate({
664
+ target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
665
+ set: {
666
+ sessionDir,
667
+ containerName: environment.durableIdentity,
668
+ kind: opts.kind,
669
+ attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
670
+ outboxOffset: 0,
671
+ status: "running",
672
+ updatedAt: now,
673
+ },
674
+ })
675
+ .run();
676
+
677
+ proc.onExit(markClosed);
678
+ return claimTail(tailKey, proc);
679
+ }
680
+
681
+ function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport {
682
+ return new EnvironmentLineProcess({
683
+ environment: opts.environment,
684
+ request: {
685
+ argv: agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]),
686
+ projectEnvProfile: "task",
687
+ inheritEnv: opts.passEnv ?? [],
688
+ env: opts.explicitEnv ?? {},
689
+ maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
690
+ },
691
+ debugLabel: opts.debugLabel,
692
+ });
693
+ }
694
+
695
+ /** #cb3j0z: the task's runtime, where only it can make, remove or link an
696
+ * entry in the task's workspace without following its links (macOS). */
697
+ function workspaceRuntime(environment: TaskEnvironmentAgentSessionSurface): RuntimeNamespace | undefined {
698
+ return "exec" in environment
699
+ ? runtimeNamespaceOf(environment as unknown as Pick<TaskEnvironmentHandle, "exec" | "descriptor">)
700
+ : undefined;
701
+ }
702
+
703
+ /** A stored session directory as the task's workspace root and its parts
704
+ * below it; null when it is not below the workspace root. */
705
+ function sessionFilesOf(taskId: string, sessionDir: string): { root: string; parts: string[] } | null {
706
+ const root = taskWorkspaceDir(taskId);
707
+ try {
708
+ return { root, parts: hostPathParts(root, sessionDir) };
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ /**
715
+ * Make the opaque generation directory discoverable from inside the task.
716
+ * The relative link resolves on both sides of the workspace bind mount. The
717
+ * pointer is diagnostic only: failure to publish it must not kill a session.
718
+ * Made beside the generation, never through a link the task put there.
719
+ */
720
+ function publishCurrentSession(files: { root: string; parts: readonly string[] }, runtime?: RuntimeNamespace): void {
721
+ const generation = files.parts[files.parts.length - 1]!;
722
+ void replaceContainedLink(files.root, [...files.parts.slice(0, -1), "current"], generation, runtime).catch(() => {
723
+ // Best-effort navigation aid; the generation path remains authoritative.
724
+ });
725
+ }
726
+
727
+ /** A direct-pipe session must not leave an older durable inbox looking live. */
728
+ function clearCurrentSession(taskId: string, agentId: string, runtime?: RuntimeNamespace): void {
729
+ void removeContainedEntry(taskWorkspaceDir(taskId), [".moondeck", "sessions", agentId, "current"], runtime).catch(() => {
730
+ // Same best-effort rule as publishCurrentSession.
731
+ });
732
+ }
733
+
734
+ function heartbeatFresh(files: { root: string; parts: readonly string[] }): boolean {
735
+ try {
736
+ const beat = containedModifiedAt(files.root, [...files.parts, "heartbeat"]);
737
+ return beat !== null && Date.now() - beat < ATTACH_HEARTBEAT_FRESH_MS;
738
+ } catch {
739
+ return false;
740
+ }
741
+ }