@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
package/lib/skills.ts ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Per-agent skills materialisation (ADR-046).
3
+ *
4
+ * A task agent's skills (reference links + documents, resolved cloud-side from
5
+ * its persona + team) are written to a per-agent `SKILL.md` inside the task
6
+ * workspace. The workspace is bind-mounted into the container at `/workspace`
7
+ * (ADR-014), so writing on the host makes the file appear in the container —
8
+ * no `docker cp`/`exec` needed (same mechanism as attachments).
9
+ *
10
+ * Each agent gets its OWN file under `.moondeck/agents/<id>/` so agents in a shared
11
+ * container don't see each other's material; the agent's system preamble points
12
+ * at its file (buildSystemPreamble), which is the reliable cross-CLI delivery
13
+ * (no dependency on Claude's project-skill auto-discovery in headless mode).
14
+ *
15
+ * ADR-047 adds a `package` skill type — a native Claude Agent Skill (folder +
16
+ * scripts) installed into the container's Claude skills dir via `docker exec`
17
+ * (git clone or an install command), so Claude discovers it natively. Package
18
+ * skills are Claude-only and task-level (shared by the container's agents),
19
+ * NOT written into the flat per-agent SKILL.md.
20
+ */
21
+
22
+ import { taskWorkspaceDir } from "./env";
23
+ import { dockerCli } from "./docker-exec";
24
+ import {
25
+ taskAppContainerName,
26
+ taskContainerCli,
27
+ } from "./task-container-cli";
28
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
29
+ import type { RosterAgent, RosterSkill } from "./agents/types";
30
+ import {
31
+ SKILL_FILES_DIR,
32
+ SKILL_FILES_MANIFEST,
33
+ flatSkills,
34
+ isSkillFileName,
35
+ renderSkillLayout,
36
+ skillSlug,
37
+ } from "./skill-layout";
38
+
39
+ export {
40
+ SKILL_FILE_MAX_BYTES,
41
+ SKILL_FILE_MAX_LINES,
42
+ SKILL_FILES_DIR,
43
+ SKILL_INDEX_THRESHOLD_LINES,
44
+ fitsSkillFileBudget,
45
+ hasSkillIndex,
46
+ isSkillFileName,
47
+ renderSkillLayout,
48
+ renderSkillMd,
49
+ skillSlug,
50
+ skillSummary,
51
+ splitSkillDocument,
52
+ type SkillLayout,
53
+ } from "./skill-layout";
54
+ import {
55
+ makeContainedDirectories,
56
+ makeContainedDirectoriesSync,
57
+ readContainedFrom,
58
+ removeContainedEntry,
59
+ removeContainedEntrySync,
60
+ WorkspaceDirectoryMissingError,
61
+ writeContainedFileSync,
62
+ type RuntimeNamespace,
63
+ } from "./task-environment/workspace-files";
64
+
65
+ /** The container path of an agent's SKILL.md — what the preamble points at. */
66
+ export function containerSkillFile(agentId: string): string {
67
+ return `/workspace/.moondeck/agents/${agentId}/SKILL.md`;
68
+ }
69
+
70
+ /** The files the last split wrote, from its manifest; none when unreadable. */
71
+ function previousSkillFiles(workspace: string, folder: readonly string[]): string[] {
72
+ try {
73
+ const read = readContainedFrom(workspace, [...folder, SKILL_FILES_MANIFEST], 0, 256 * 1024);
74
+ if (!read) return [];
75
+ return read.bytes.toString("utf8").split("\n").filter(isSkillFileName);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Each agent's skills work still in flight through the task's runtime
83
+ * (macOS), by task and agent. A write while one is in flight waits for it,
84
+ * then reads the folder and writes its layout, so no deletion or write of an
85
+ * older layout ever lands after a newer one. On Linux every step is done at
86
+ * once and nothing waits here.
87
+ */
88
+ const skillsInFlight = new Map<string, Promise<void>>();
89
+ /**
90
+ * Each agent's latest write, so a queued write that a newer one replaced is
91
+ * skipped. Only queued writes read it, so a key is dropped once nothing is
92
+ * queued for it: the runner is long-lived, and tasks and agents come and go.
93
+ */
94
+ const skillWrites = new Map<string, number>();
95
+
96
+ /** Tests only: how many agents the runner still holds skills state for. */
97
+ export function skillsStateForTests(): { writes: number; inFlight: number } {
98
+ return { writes: skillWrites.size, inFlight: skillsInFlight.size };
99
+ }
100
+
101
+ /**
102
+ * Write an agent's SKILL.md into the task workspace (idempotent: overwrites,
103
+ * or removes the file when the agent has no skills). A split skill's files
104
+ * go beside it in `skills/`, written before the index that names them.
105
+ * Files the previous layout wrote and this one does not are removed first;
106
+ * never a file this layout writes. Returns the container path when written
107
+ * (or queued behind runtime work still in flight), else null. Best-effort —
108
+ * a write failure is swallowed so a skills problem never blocks the agent
109
+ * from running.
110
+ */
111
+ export function writeAgentSkills(
112
+ taskId: string,
113
+ agent: RosterAgent,
114
+ runtime?: () => Promise<RuntimeNamespace | null>,
115
+ ): string | null {
116
+ const key = `${taskId}\0${agent.id}`;
117
+ const generation = (skillWrites.get(key) ?? 0) + 1;
118
+ skillWrites.set(key, generation);
119
+ const result = flatSkills(agent).length === 0 ? null : containerSkillFile(agent.id);
120
+ const inFlight = skillsInFlight.get(key);
121
+ if (inFlight) {
122
+ // Behind the runtime work of an earlier write; only the latest queued one runs.
123
+ track(key, inFlight.then(() => (skillWrites.get(key) === generation ? writeSkillsNow(taskId, agent, runtime)?.done : undefined)));
124
+ return result;
125
+ }
126
+ const attempt = writeSkillsNow(taskId, agent, runtime);
127
+ if (attempt?.done) {
128
+ track(key, attempt.done);
129
+ } else {
130
+ // Done at once (or refused), with nothing queued: no state to keep.
131
+ skillWrites.delete(key);
132
+ }
133
+ return attempt === null ? null : attempt.path;
134
+ }
135
+
136
+ /**
137
+ * Hold `work` as the agent's in-flight skills work until it settles. When
138
+ * it is still the latest (nothing newer queued behind it), the agent's
139
+ * state goes with it, however the work ended.
140
+ */
141
+ function track(key: string, work: Promise<void>): void {
142
+ const settled = work.catch(() => {}).finally(() => {
143
+ if (skillsInFlight.get(key) !== settled) return;
144
+ skillsInFlight.delete(key);
145
+ skillWrites.delete(key);
146
+ });
147
+ skillsInFlight.set(key, settled);
148
+ }
149
+
150
+ /**
151
+ * One write, done at once where the host can. What only the runtime can do
152
+ * is in `done`, in order: removals first, then writes. Null when refused.
153
+ */
154
+ function writeSkillsNow(
155
+ taskId: string,
156
+ agent: RosterAgent,
157
+ runtime?: () => Promise<RuntimeNamespace | null>,
158
+ ): { path: string | null; done?: Promise<void> } | null {
159
+ // Into the task's own .moondeck, never through a link the task put there
160
+ // (#cb3j0z). Where only the task's runtime can make the agent's directory
161
+ // (macOS), it is made there once the runtime is reachable, then the file
162
+ // written; the agent reads it when it consults its skills.
163
+ const workspace = taskWorkspaceDir(taskId);
164
+ const directory = [".moondeck", "agents", agent.id];
165
+ const file = [...directory, "SKILL.md"];
166
+ const folder = [...directory, SKILL_FILES_DIR];
167
+ const later: ((reached: RuntimeNamespace) => Promise<void>)[] = [];
168
+ /**
169
+ * Remove entries now where the host can; the rest through the runtime,
170
+ * in order, never a skills file the layout being written names (`keep`).
171
+ */
172
+ const remove = (entries: string[][], keep: ReadonlySet<string>) => {
173
+ const left: string[][] = [];
174
+ for (const entry of entries) {
175
+ try {
176
+ removeContainedEntrySync(workspace, entry);
177
+ } catch {
178
+ left.push(entry);
179
+ }
180
+ }
181
+ if (left.length === 0) return;
182
+ later.push(async (reached) => {
183
+ for (const entry of left) {
184
+ const inFolder = entry.length === folder.length + 1 && entry.slice(0, -1).join("/") === folder.join("/");
185
+ if (inFolder && keep.has(entry[entry.length - 1]!)) continue;
186
+ await removeContainedEntry(workspace, entry, reached);
187
+ }
188
+ });
189
+ };
190
+ const deferred = (): Promise<void> | undefined => {
191
+ if (later.length === 0 || !runtime) return undefined;
192
+ return (async () => {
193
+ const reached = await runtime();
194
+ if (!reached) return;
195
+ for (const step of later) await step(reached);
196
+ })();
197
+ };
198
+ try {
199
+ const previous = previousSkillFiles(workspace, folder);
200
+ const previousEntries = previous.length
201
+ ? [...previous.map((name) => [...folder, name]), [...folder, SKILL_FILES_MANIFEST]]
202
+ : [];
203
+ if (flatSkills(agent).length === 0) {
204
+ remove([...previousEntries, file], new Set());
205
+ return { path: null, done: deferred() };
206
+ }
207
+ const { skillMd, files } = renderSkillLayout(agent);
208
+ const names = files.map((f) => f.name);
209
+ if (names.some((name) => !isSkillFileName(name))) return null;
210
+ const keep = new Set(names);
211
+ const writes: { parts: string[]; bytes: Uint8Array }[] = [
212
+ ...files.map((f) => ({ parts: [...folder, f.name], bytes: Buffer.from(f.content) })),
213
+ ...(files.length ? [{ parts: [...folder, SKILL_FILES_MANIFEST], bytes: Buffer.from(`${names.join("\n")}\n`) }] : []),
214
+ { parts: file, bytes: Buffer.from(skillMd) },
215
+ ];
216
+ // Stale: what the previous layout wrote and this one does not, removed
217
+ // before the new manifest is written. A flat layout keeps none.
218
+ remove(
219
+ files.length
220
+ ? previous.filter((name) => !keep.has(name)).map((name) => [...folder, name])
221
+ : previousEntries,
222
+ keep,
223
+ );
224
+ const directories = files.length ? [directory, folder] : [directory];
225
+ const writeAll = () => {
226
+ for (const write of writes) writeContainedFileSync(workspace, write.parts, write.bytes);
227
+ };
228
+ if (!directories.every((d) => makeContainedDirectoriesSync(workspace, d))) {
229
+ let written = false;
230
+ try {
231
+ writeAll();
232
+ written = true;
233
+ } catch (error) {
234
+ if (!(error instanceof WorkspaceDirectoryMissingError) || !runtime) throw error;
235
+ }
236
+ if (!written) {
237
+ later.push(async (reached) => {
238
+ for (const d of directories) await makeContainedDirectories(workspace, d, reached);
239
+ writeAll();
240
+ });
241
+ }
242
+ return { path: containerSkillFile(agent.id), done: deferred() };
243
+ }
244
+ writeAll();
245
+ return { path: containerSkillFile(agent.id), done: deferred() };
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // ADR-047 package skills — native Claude Agent Skills installed in-container.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ const SKILL_EXEC_TIMEOUT_MS = 180_000; // clones / npx installs can be slow
256
+
257
+ /**
258
+ * In-container git-skill installer. Shallow-clones a repo, then works out where
259
+ * the actual skill(s) live and copies each into `~/.claude/skills/<name>/`:
260
+ *
261
+ * - `$2` (subpath) given → that folder is the skill.
262
+ * - else repo root has a SKILL.md → the whole repo is the skill.
263
+ * - else search the clone for SKILL.md files → install every skill folder it
264
+ * finds (a "collection" repo), each under its own folder name.
265
+ * - else exit 3 → the repo isn't a ready skill (needs a build; use cli mode).
266
+ *
267
+ * Params are positional argv ($1=url, $2=subpath, $3=name) — never interpolated
268
+ * into this text — so a repo URL / subpath can't shell-inject. `.git` is
269
+ * stripped and the temp clone removed; existing skill dirs are left untouched.
270
+ */
271
+ const GIT_INSTALL_SCRIPT = `
272
+ set -eu
273
+ url="$1"; sub="$2"; name="$3"
274
+ skills="$HOME/.claude/skills"
275
+ mkdir -p "$skills"
276
+ tmp="$(mktemp -d)"
277
+ trap 'rm -rf "$tmp"' EXIT
278
+ git clone --depth 1 --quiet "$url" "$tmp/repo"
279
+ repo="$tmp/repo"
280
+ install_one() {
281
+ src="$1"; dest="$skills/$2"
282
+ if [ -e "$dest" ]; then echo "skip $2"; return 0; fi
283
+ if [ ! -f "$src/SKILL.md" ]; then echo "no-skill-md: $src"; return 1; fi
284
+ rm -rf "$src/.git"
285
+ cp -R "$src" "$dest"
286
+ echo "installed $2"
287
+ }
288
+ if [ -n "$sub" ]; then
289
+ install_one "$repo/$sub" "$name"
290
+ elif [ -f "$repo/SKILL.md" ]; then
291
+ install_one "$repo" "$name"
292
+ else
293
+ n=0
294
+ while IFS= read -r -d '' md; do
295
+ d="$(dirname "$md")"
296
+ if install_one "$d" "$(basename "$d")"; then n=$((n+1)); fi
297
+ done < <(find "$repo" -maxdepth 4 -name SKILL.md -not -path '*/.git/*' -print0)
298
+ if [ "$n" -eq 0 ]; then echo "no-skills-found"; exit 3; fi
299
+ fi
300
+ `;
301
+
302
+ /** A short label for an installed package skill (for the preamble note). */
303
+ export function installedSkillPath(s: RosterSkill): string {
304
+ return s.source === "git"
305
+ ? `~/.claude/skills/${skillSlug(s.name)}/`
306
+ : `${s.name} (install command)`;
307
+ }
308
+
309
+ /**
310
+ * Package skills across the Claude agents in a roster, deduped by folder slug.
311
+ * Package skills are a Claude Code capability (ADR-047), so Codex agents are
312
+ * skipped; the install is task-level (one container, shared skills dir).
313
+ */
314
+ export function collectPackageSkills(agents: RosterAgent[]): RosterSkill[] {
315
+ const seen = new Set<string>();
316
+ const out: RosterSkill[] = [];
317
+ for (const a of agents) {
318
+ if (a.kind !== "claude") continue;
319
+ for (const s of a.skills ?? []) {
320
+ if (s.type !== "package" || !s.value) continue;
321
+ const key = skillSlug(s.name);
322
+ if (seen.has(key)) continue;
323
+ seen.add(key);
324
+ out.push(s);
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+
330
+ /** Injectable docker-exec seam (mocked in tests). Runs `cmd` in `container` as
331
+ * the `node` user, optionally with a working dir. Values are argv, never a
332
+ * shell string — a URL/subpath can't shell-inject (cli mode is the exception:
333
+ * it runs the author's command via `bash -lc` by design). */
334
+ export type SkillExec = (
335
+ container: string,
336
+ cmd: string[],
337
+ cwd?: string,
338
+ ) => Promise<{ status: number | null; stderr: string }>;
339
+
340
+ export function packageSkillDockerExecArgs(
341
+ container: string,
342
+ cmd: string[],
343
+ cwd?: string,
344
+ ): string[] {
345
+ return [
346
+ "exec",
347
+ "-u",
348
+ "node",
349
+ ...(cwd ? ["-w", cwd] : []),
350
+ ...runtimeAuthorityDockerExecArgs(),
351
+ container,
352
+ ...cmd,
353
+ ];
354
+ }
355
+
356
+ const dockerExec: SkillExec = async (container, cmd, cwd) => {
357
+ const full = packageSkillDockerExecArgs(container, cmd, cwd);
358
+ const res = await taskContainerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
359
+ return { status: res.status, stderr: res.stderr };
360
+ };
361
+
362
+ /**
363
+ * A `skills add …` install command (github.com/vercel-labs/skills, the CLI
364
+ * behind the skills marketplace) asks which agents to install for and for a
365
+ * confirmation unless an agent is detected or `-y`/`--agent` are given. The
366
+ * install runs at task-up with no TTY and, on a fresh machine, before any
367
+ * agent has left its footprint, so the CLI refused: "Interactive prompt
368
+ * required but stdin is not a TTY … Use --agent <name> and -y" (every task
369
+ * start since the Marketing persona gained the skill; live 2026-09-10).
370
+ * Insert the two flags right after `skills add` when the author left them
371
+ * out — after the subcommand, so a trailing `&& …` or a leading `cd …`
372
+ * keeps working. Any other install command is returned as written.
373
+ */
374
+ export function nonInteractiveInstallCommand(command: string): string {
375
+ const add = /\bskills\s+add\b/;
376
+ const found = add.exec(command);
377
+ if (!found) return command;
378
+ // Only the flags AFTER the subcommand count: `npx -y skills add …` is npx's
379
+ // own -y, not the CLI's. And the CLI wants its options AFTER the source —
380
+ // put before it, the parser reports "Missing required argument: source"
381
+ // (live 2026-09-10, smoke 14) — so the flags go right after the source
382
+ // token: the first token past `add` that is not an option or an option's
383
+ // value, stopping at a shell operator.
384
+ const afterAdd = found.index + found[0].length;
385
+ const rest = command.slice(afterAdd);
386
+ const tail = rest.split(/(?=\s(?:&&|\|\||[|;]))/)[0] ?? rest;
387
+ const flags: string[] = [];
388
+ if (!/(^|\s)(-y|--yes)(\s|$)/.test(tail)) flags.push("-y");
389
+ if (!/(^|\s)(-a|--agent)(\s|=)/.test(tail)) flags.push("--agent claude-code");
390
+ if (flags.length === 0) return command;
391
+ const valued = /^(-a|--agent|-s|--skill)$/;
392
+ const tokens = [...tail.matchAll(/\S+/g)];
393
+ let sourceEnd = -1;
394
+ for (let i = 0; i < tokens.length; i++) {
395
+ const token = tokens[i]![0];
396
+ if (token.startsWith("-")) {
397
+ if (valued.test(token)) i += 1; // skip the option's value
398
+ continue;
399
+ }
400
+ sourceEnd = tokens[i]!.index! + token.length;
401
+ break;
402
+ }
403
+ const at = sourceEnd >= 0 ? afterAdd + sourceEnd : afterAdd + tail.length;
404
+ return `${command.slice(0, at)} ${flags.join(" ")}${command.slice(at)}`;
405
+ }
406
+
407
+ /**
408
+ * Install a roster's package skills into the task container (ADR-047). Runs at
409
+ * task-up (after the container is running), mirroring the git-identity/github
410
+ * `docker exec` setup: best-effort, idempotent, never blocks the agents.
411
+ *
412
+ * Returns the human-readable paths of skills that are now present (for the
413
+ * agents' preamble note). `exec` is injectable for tests.
414
+ */
415
+ export async function installPackageSkills(
416
+ taskId: string,
417
+ agents: RosterAgent[],
418
+ exec: SkillExec = dockerExec,
419
+ ): Promise<string[]> {
420
+ const pkgs = collectPackageSkills(agents);
421
+ if (pkgs.length === 0) return [];
422
+ const container = taskAppContainerName(taskId);
423
+ const done: string[] = [];
424
+
425
+ for (const s of pkgs) {
426
+ const slug = skillSlug(s.name);
427
+ try {
428
+ if (s.source === "git") {
429
+ // Idempotent across ensureSessions re-runs: a marker records that the
430
+ // clone+install already ran, so reconnects don't re-clone.
431
+ const marker = `/home/node/.claude/skills/.uai-git-${slug}.done`;
432
+ if ((await exec(container, ["test", "-e", marker])).status === 0) {
433
+ done.push(installedSkillPath(s));
434
+ continue;
435
+ }
436
+ // subpath is de-fanged against traversal; url/subpath/name reach the
437
+ // installer as positional argv (never interpolated) — no shell inject.
438
+ const sub = (s.subpath ?? "").replace(/^[/.]+/, "").replace(/\.\.+/g, "");
439
+ const r = await exec(container, [
440
+ "bash",
441
+ "-lc",
442
+ GIT_INSTALL_SCRIPT,
443
+ "uai-skill", // $0
444
+ s.value, // $1 url
445
+ sub, // $2 subpath
446
+ slug, // $3 name
447
+ ]);
448
+ if (r.status !== 0) {
449
+ const why =
450
+ r.status === 3
451
+ ? "no SKILL.md found — the repo may need a build (try cli mode)"
452
+ : r.stderr.trim();
453
+ console.warn(`[skills] ${taskId}: git skill ${s.name} failed: ${why}`);
454
+ continue;
455
+ }
456
+ await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
457
+ await exec(container, ["touch", marker]);
458
+ console.log(`[skills] ${taskId}: installed git skill ${s.name}`);
459
+ done.push(installedSkillPath(s));
460
+ } else if (s.source === "cli") {
461
+ // Idempotent across ensureSessions re-runs (reconnects): a marker file
462
+ // records that the command already ran, since a CLI install isn't
463
+ // self-idempotent like `git clone` into an existing dir.
464
+ const marker = `/home/node/.claude/skills/.uai-cli-${slug}.done`;
465
+ if ((await exec(container, ["test", "-e", marker])).status === 0) {
466
+ done.push(installedSkillPath(s));
467
+ continue;
468
+ }
469
+ // The author's CLI drops files into .claude/skills; run it in the repo.
470
+ const c = await exec(
471
+ container,
472
+ ["bash", "-lc", nonInteractiveInstallCommand(s.value)],
473
+ "/workspace",
474
+ );
475
+ if (c.status !== 0) {
476
+ console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
477
+ continue;
478
+ }
479
+ await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
480
+ await exec(container, ["touch", marker]);
481
+ console.log(`[skills] ${taskId}: ran install command for ${s.name}`);
482
+ done.push(installedSkillPath(s));
483
+ }
484
+ } catch (e) {
485
+ console.warn(`[skills] ${taskId}: package skill ${s.name} errored: ${String(e)}`);
486
+ }
487
+ }
488
+ return done;
489
+ }