@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,1507 @@
1
+ import { TASK_PROJECT_ADD_WAIT_MS } from "../src/protocol";
2
+ import { validateShareablePath, ENGINE_ACCOUNT_PATH_RULES } from "../src/workspace-path-policy";
3
+ /**
4
+ * In-container `moondeck` CLI materialisation (ADR-048; Moondeck plan §5 row 7).
5
+ *
6
+ * At task-up the host writes two files into the task workspace (bind-mounted at
7
+ * /workspace, like skills — no docker cp/exec needed):
8
+ * - `.moondeck/cli.mjs` — the self-contained agent CLI (Node ESM, fetch-only).
9
+ * - `.moondeck/moondeck.json` — shared, tokenless { apiUrl } config the CLI reads.
10
+ *
11
+ * A token is minted here per agent (host-side), carrying the task id, its owner
12
+ * user, that agent's identity, and only that agent's permissions; the cloud
13
+ * verifies it. Agents invoke the CLI as `moondeck <cmd>` (the task's
14
+ * `/usr/local/bin/moondeck` runs `node /workspace/.moondeck/cli.mjs`), as
15
+ * the system preamble says.
16
+ */
17
+ import {
18
+ lstatSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { resolve } from "node:path";
25
+
26
+ import { env, taskWorkspaceDir } from "./env";
27
+ import {
28
+ PROJECT_ENV_EXEC_PATH,
29
+ RUNTIME_AUTHORITY_ENV,
30
+ } from "./runtime-authority";
31
+ import { signTaskToken } from "./task-token";
32
+ import { assertSafeHostTaskId } from "./task-identity";
33
+ import type { RosterAgent } from "./agents/types";
34
+ import type { TaskEnvironmentAgentSessionSurface, TaskEnvironmentHandle } from "./task-environment/types";
35
+ import { makeContainedDirectoriesSync, writeContainedFile } from "./task-environment/workspace-files";
36
+ import { STATION_AGENT_CLI } from "../src/station-identity";
37
+ import { isProxiableDomain } from "./secrets-proxy/hostname";
38
+
39
+ /** Container path of the CLI — what the preamble tells agents to run. */
40
+ export const CONTAINER_CLI_PATH = STATION_AGENT_CLI.cliPath;
41
+
42
+ /**
43
+ * The `moondeck` command: scripts/agent/moondeck-cli.sh, byte for byte (a
44
+ * test checks). Containers get that file mounted at /usr/local/bin/moondeck
45
+ * (task-up.sh); a machine task gets these bytes at MACHINE_AGENT_COMMAND_PATH,
46
+ * on its agents' PATH (MACHINE_RUNTIME_AUTHORITY_ENV).
47
+ */
48
+ export const MOONDECK_COMMAND_SOURCE = `#!/bin/sh
49
+ # moondeck — this task's agent CLI (Moondeck plan §5 row 7). task-up.sh mounts
50
+ # it read-only at /usr/local/bin/moondeck; lib/agent-cli.ts writes the same
51
+ # bytes (MOONDECK_COMMAND_SOURCE) on task machines. There is no \`uai\` command.
52
+ exec node ${STATION_AGENT_CLI.cliPath} "$@"
53
+ `;
54
+ export const MACHINE_AGENT_COMMAND_PATH = `/home/node/.local/bin/${STATION_AGENT_CLI.command}`;
55
+
56
+ /**
57
+ * Whether this build may advertise AGENT_CLI_MOONDECK_PROTOCOL_FEATURE. The
58
+ * cloud reads that feature as "a station" for more than wording: it mints
59
+ * user-content previews on moondeckapp.com only for such hosts (#713), and
60
+ * those are safe only where no key is ever proxied there (#680's list). So a
61
+ * build whose never-proxy list lacks moondeckapp.com never claims it.
62
+ */
63
+ export function stationAgentCliAdvertisable(): boolean {
64
+ return !isProxiableDomain("preview.moondeckapp.com");
65
+ }
66
+
67
+ /** Runtime/cache authority for every agent docker exec, including preserved
68
+ * pre-L4 containers whose Config.Env may still contain project-controlled
69
+ * PATH/asdf/Corepack/loader values. Callers merge engine-account credentials
70
+ * underneath this object so those credentials cannot replace host policy. */
71
+ export const AGENT_RUNTIME_AUTHORITY_ENV = RUNTIME_AUTHORITY_ENV;
72
+
73
+ /**
74
+ * One rollout-aware argv policy for every agent CLI. The wrapper is installed
75
+ * only in PR2+ environments; legacy baked tasks must launch exactly as before.
76
+ */
77
+ export function agentCliArgv(
78
+ environment: Pick<TaskEnvironmentAgentSessionSurface, "descriptor">,
79
+ argv: readonly [string, ...string[]],
80
+ ): [string, ...string[]] {
81
+ return environment.descriptor.envMode === "loader"
82
+ ? [PROJECT_ENV_EXEC_PATH, ...argv]
83
+ : [...argv];
84
+ }
85
+
86
+ // The task's cli secret lives HOST-PRIVATE (never the container), so it survives
87
+ // host restarts without a DB migration. The cloud re-sends it on each task-up.
88
+ function taskSecretPath(taskId: string): string {
89
+ return resolve(env.dataDir, "task-secrets", taskId);
90
+ }
91
+
92
+ /** Persist the cloud-issued per-task cli secret (ADR-048). Best-effort. */
93
+ export function storeTaskCliSecret(taskId: string, secret: string): void {
94
+ try {
95
+ mkdirSync(resolve(env.dataDir, "task-secrets"), { recursive: true });
96
+ writeFileSync(taskSecretPath(taskId), secret, { mode: 0o600 });
97
+ } catch {
98
+ // best-effort — the CLI is simply unavailable if we can't persist it
99
+ }
100
+ }
101
+
102
+ /** Read the per-task cli secret, or null if we don't have it. */
103
+ export function loadTaskCliSecret(taskId: string): string | null {
104
+ try {
105
+ return readFileSync(taskSecretPath(taskId), "utf8").trim() || null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** Orphan GC is an authority deletion, not best-effort housekeeping. */
112
+ export function removeTaskCliSecretStrict(taskId: string): void {
113
+ assertSafeHostTaskId(taskId);
114
+ const path = taskSecretPath(taskId);
115
+ rmSync(path, { force: true });
116
+ try {
117
+ lstatSync(path);
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
120
+ throw error;
121
+ }
122
+ throw new Error("task CLI secret remains after removal");
123
+ }
124
+
125
+ /** The union used only to decide whether this task needs the shared CLI file. */
126
+ export function rosterPermissions(roster: RosterAgent[]): string[] {
127
+ return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
128
+ }
129
+
130
+ /** Derive the cloud HTTPS API base from the host's cloud WSS url. */
131
+ export function apiUrlFromCloudUrl(cloudUrl: string | undefined): string | null {
132
+ if (!cloudUrl) return null;
133
+ try {
134
+ const u = new URL(cloudUrl);
135
+ const proto = u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
136
+ return `${proto}//${u.host}`;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Write the CLI script + shared config (apiUrl only — NO token) into the task
144
+ * workspace. Each agent's OWN token is injected per-agent via its docker exec
145
+ * env (agentCliEnv), so no shared file ever holds a token. Best-effort +
146
+ * idempotent. No-op (false) if there's no api url, or if the roster has zero
147
+ * permissions and the task has no permissionless role action (Secretary
148
+ * dispatch). The latter is explicit rather than globally minting empty-power
149
+ * tokens for every task.
150
+ */
151
+ export async function writeAgentCli(
152
+ taskId: string,
153
+ roster: RosterAgent[],
154
+ apiUrl: string | null,
155
+ allowPermissionless = false,
156
+ ): Promise<boolean> {
157
+ if (
158
+ !apiUrl ||
159
+ (roster.length === 0 && !allowPermissionless)
160
+ ) {
161
+ return false;
162
+ }
163
+ try {
164
+ // Into the task's own .moondeck, never through a link the task put there
165
+ // (#cb3j0z). The workspace root is the runner's own path; .moondeck,
166
+ // directly below it, is made if gone without following a link.
167
+ const workspace = taskWorkspaceDir(taskId);
168
+ mkdirSync(workspace, { recursive: true });
169
+ makeContainedDirectoriesSync(workspace, [STATION_AGENT_CLI.stateDir]);
170
+ await writeContainedFile(workspace, [STATION_AGENT_CLI.stateDir, STATION_AGENT_CLI.configFile], Buffer.from(`${JSON.stringify({ apiUrl }, null, 2)}\n`));
171
+ await writeContainedFile(workspace, [STATION_AGENT_CLI.stateDir, "cli.mjs"], Buffer.from(CLI_SOURCE), { mode: 0o755 });
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * ADR-121: the same CLI materialization, but through the environment's own
180
+ * transport — machine workspaces are not host-reachable via taskWorkspaceDir.
181
+ * Same no-token invariant: moondeck.json carries the apiUrl only.
182
+ */
183
+ export async function writeAgentCliViaEnvironment(
184
+ environment: {
185
+ readonly descriptor: { workspacePath: string };
186
+ writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void>;
187
+ /** When the environment can run commands (a machine), the `moondeck`
188
+ * command is installed through it too. */
189
+ exec?: TaskEnvironmentHandle<unknown>["exec"];
190
+ },
191
+ roster: RosterAgent[],
192
+ apiUrl: string | null,
193
+ allowPermissionless = false,
194
+ ): Promise<boolean> {
195
+ if (
196
+ !apiUrl ||
197
+ (roster.length === 0 && !allowPermissionless)
198
+ ) {
199
+ return false;
200
+ }
201
+ try {
202
+ const root = environment.descriptor.workspacePath;
203
+ await environment.writeWorkspaceFile(
204
+ `${root}/${STATION_AGENT_CLI.stateDir}/${STATION_AGENT_CLI.configFile}`,
205
+ Buffer.from(`${JSON.stringify({ apiUrl }, null, 2)}\n`),
206
+ );
207
+ await environment.writeWorkspaceFile(
208
+ `${root}/${STATION_AGENT_CLI.stateDir}/cli.mjs`,
209
+ Buffer.from(CLI_SOURCE),
210
+ );
211
+ if (environment.exec) {
212
+ // Atomic, as the agent user, into a directory on its PATH.
213
+ const installed = await environment.exec({
214
+ argv: [
215
+ "/bin/sh",
216
+ "-c",
217
+ 'mkdir -p "$(dirname "$1")" && cat > "$1.tmp" && chmod 0755 "$1.tmp" && mv -f "$1.tmp" "$1"',
218
+ "moondeck-command",
219
+ MACHINE_AGENT_COMMAND_PATH,
220
+ ],
221
+ projectEnvProfile: "maintenance",
222
+ stdin: Buffer.from(MOONDECK_COMMAND_SOURCE),
223
+ timeoutMs: 30_000,
224
+ maxOutputBytes: 8_192,
225
+ });
226
+ if (installed.exitCode !== 0) return false;
227
+ }
228
+ return true;
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
236
+ * task token (carrying only ITS permissions) + the API url. Every agent gets
237
+ * one, permissions or not: it is the agent's identity — the Uai Managed
238
+ * Engine's gateway authenticates and bills by it (ADR-128), and the
239
+ * Secretary's dispatch is role-gated on it. Until 2026-10-20 an agent with no
240
+ * CLI permissions got none, so such a persona could not run on the managed
241
+ * engine at all. An empty-permission token cannot borrow anyone else's powers:
242
+ * every permissioned route refuses it, and the self-scoped ones
243
+ * (`requireAnyPersonaPermission`) do too. Empty only when the task has no
244
+ * owner, API url or CLI secret.
245
+ */
246
+ export function agentCliEnv(
247
+ taskId: string,
248
+ agent: RosterAgent,
249
+ ownerUserId: string | null,
250
+ apiUrl: string | null,
251
+ cliSecret: string | null,
252
+ leadRunId?: string | null,
253
+ ): Record<string, string> {
254
+ const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
255
+ const permissions = agent.permissions ?? [];
256
+ if (!ownerUserId || !apiUrl || !cliSecret) return runtimeAuthority;
257
+ return {
258
+ ...runtimeAuthority,
259
+ UAI_TASK_TOKEN: signTaskToken(
260
+ {
261
+ taskId,
262
+ userId: ownerUserId,
263
+ permissions,
264
+ agentId: agent.id,
265
+ // ADR-157 §3: binds the token to this agent, not merely its handle.
266
+ ...(agent.tokenGeneration ? { generation: agent.tokenGeneration } : {}),
267
+ ...(leadRunId ? { leadRunId } : {}),
268
+ },
269
+ cliSecret,
270
+ ),
271
+ UAI_API_URL: apiUrl,
272
+ };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // The CLI itself — kept as a self-contained ESM string so the host writes ONE
277
+ // file with no dependencies (Node 22 in the image provides global fetch).
278
+ // ---------------------------------------------------------------------------
279
+
280
+ export const CLI_SOURCE = `#!/usr/bin/env node
281
+ // moondeck — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
282
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
283
+ import { open, realpath } from "node:fs/promises";
284
+ import { dirname, isAbsolute, join, relative, resolve, sep, basename } from "node:path";
285
+ import { fileURLToPath } from "node:url";
286
+ import { randomUUID, createHash } from "node:crypto";
287
+ import { spawnSync } from "node:child_process";
288
+
289
+ const here = dirname(fileURLToPath(import.meta.url));
290
+ let cfg = {};
291
+ try { cfg = JSON.parse(readFileSync(join(here, "moondeck.json"), "utf8")); } catch {}
292
+ // Per-agent token comes from the env (each agent's docker exec carries only its
293
+ // own — ADR-048); apiUrl from env or the shared config file.
294
+ const TOKEN = process.env.UAI_TASK_TOKEN || cfg.token;
295
+ const API_URL = process.env.UAI_API_URL || cfg.apiUrl;
296
+ if (!TOKEN || !API_URL) {
297
+ console.error("moondeck: not available for this agent — no permissions granted (or not inside a task).");
298
+ process.exit(1);
299
+ }
300
+
301
+ /** The task this agent's token names: its unsigned body, read for display
302
+ * only; the cloud verifies the token and resolves the id itself. */
303
+ function tokenTaskId() {
304
+ if (!TOKEN.startsWith("uai_")) return null;
305
+ try { return JSON.parse(Buffer.from(TOKEN.slice(4).split(".")[0], "base64url").toString("utf8")).t || null; }
306
+ catch { return null; }
307
+ }
308
+
309
+ function parseFlags(args) {
310
+ const flags = {}; const rest = [];
311
+ for (let i = 0; i < args.length; i++) {
312
+ const a = args[i];
313
+ if (a.startsWith("--")) { const k = a.slice(2); const v = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "true"; flags[k] = v; }
314
+ else rest.push(a);
315
+ }
316
+ return { flags, rest };
317
+ }
318
+
319
+ // A punchlist item's text is prose, and prose written in THIS repo contains
320
+ // --id and --text. parseFlags is global and greedy, so routing todo text
321
+ // through it silently rewrote the request: "todo edit aaaaaa the --id flag"
322
+ // resolved to { ref: "flag", text: "aaaaaa the" } and edited a different item,
323
+ // and a valueless --text became the literal string "true" — an item
324
+ // overwritten with garbage, or worse, the wrong item overwritten at all. Todo
325
+ // text is therefore read from the raw argv tail and never flag-parsed. The
326
+ // --id <ref> form survives only in leading position, where it cannot be
327
+ // mistaken for prose.
328
+ function todoRef(args) {
329
+ return args[0] === "--id"
330
+ ? { ref: args[1], tail: args.slice(2) }
331
+ : { ref: args[0] && !args[0].startsWith("--") ? args[0] : "", tail: args.slice(1) };
332
+ }
333
+ function todoText(args) { return args.join(" ").trim(); }
334
+
335
+ async function api(method, path, body) {
336
+ const res = await fetch(API_URL + path, {
337
+ method,
338
+ headers: { authorization: "Bearer " + TOKEN, "content-type": "application/json" },
339
+ body: body ? JSON.stringify(body) : undefined,
340
+ });
341
+ const text = await res.text();
342
+ let json; try { json = JSON.parse(text); } catch { json = text; }
343
+ if (!res.ok) {
344
+ const msg = (json && json.error && json.error.message) || text || res.statusText;
345
+ console.error("moondeck: " + res.status + " " + msg);
346
+ process.exit(1);
347
+ }
348
+ return json;
349
+ }
350
+ function out(v) { console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); }
351
+ const NL = String.fromCharCode(10);
352
+ function flagValue(v) { return typeof v === "string" && v !== "true" && v.length > 0; }
353
+ // ADR-161 §3.2: setup fields read from files, so a brief or a .tool-versions
354
+ // keeps its newlines and quotes without shell escaping.
355
+ function readFlagFile(path) {
356
+ try { return path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); }
357
+ catch (error) { console.error("moondeck: cannot read " + path + ": " + error.message); process.exit(1); }
358
+ }
359
+ function projectSetupFields(flags) {
360
+ const fields = {};
361
+ if (flagValue(flags.slug)) fields.slug = flags.slug;
362
+ if (flagValue(flags["prompt-file"])) fields.defaultPrompt = readFlagFile(flags["prompt-file"]);
363
+ else if (flagValue(flags.prompt)) fields.defaultPrompt = flags.prompt;
364
+ if (flagValue(flags["previews-file"])) {
365
+ try { fields.previewPorts = JSON.parse(readFlagFile(flags["previews-file"])); }
366
+ catch { console.error("moondeck: --previews-file must hold a JSON array of preview declarations"); process.exit(1); }
367
+ }
368
+ if (flagValue(flags["tool-versions-file"])) fields.toolVersions = readFlagFile(flags["tool-versions-file"]);
369
+ return fields;
370
+ }
371
+
372
+ // ADR-161 §2: add a repository to the running task this agent is on. The add
373
+ // is a durable operation; by default the CLI waits for its outcome.
374
+ function opKey() { return "cli-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12); }
375
+ function opLine(op) {
376
+ if (op.state === "failed") return "could not add " + op.slug + ": " + (op.error ? op.error.code + " — " + op.error.message : "failed");
377
+ if (op.state === "ready" || op.state === "ready_with_warning") {
378
+ return "added " + op.slug + " at " + op.path + (op.warning ? " (warning: " + op.warning + ")" : "");
379
+ }
380
+ return "adding " + op.slug + " (" + op.state + "); check again with: moondeck task project status " + op.operationId;
381
+ }
382
+ function opProgress(op) {
383
+ const attempts = Number.isInteger(op.attempts) ? "; attempts: " + op.attempts : "";
384
+ const lastError = op.lastError || (op.error && op.error.message);
385
+ return "[" + op.operationId + "] " + opLine(op) + attempts +
386
+ (lastError && op.state !== "failed" ? "; last error: " + lastError : "");
387
+ }
388
+ async function waitForProjectOp(op) {
389
+ const deadline = Date.now() + ${TASK_PROJECT_ADD_WAIT_MS};
390
+ let progress = opProgress(op);
391
+ let reportedAt = Date.now();
392
+ console.error(progress);
393
+ while (!op.done && Date.now() < deadline) {
394
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
395
+ op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(op.operationId))).operation;
396
+ const next = opProgress(op);
397
+ if (!op.done && (next !== progress || Date.now() - reportedAt >= 30_000)) {
398
+ console.error(next);
399
+ progress = next;
400
+ reportedAt = Date.now();
401
+ }
402
+ }
403
+ return op;
404
+ }
405
+ async function addProjectToTask(project, wait) {
406
+ const started = await api("POST", "/api/agent/task/projects", { projectId: project, idempotencyKey: opKey() });
407
+ const op = wait ? await waitForProjectOp(started.operation) : started.operation;
408
+ if (op.state === "failed") { console.error("moondeck: " + opLine(op)); process.exit(1); }
409
+ if (wait && !op.done) {
410
+ console.error("moondeck: timed out waiting; the add is still in progress. " + opProgress(op));
411
+ process.exit(1);
412
+ }
413
+ out(wait ? opLine(op) : opProgress(op));
414
+ }
415
+
416
+ // ADR-161 §1.3. UAI_FILES_ROOT and UAI_FILES_STATE_FILE exist so tests can
417
+ // point at a temp dir; a task leaves them unset and uses the defaults.
418
+ const FILES_CAP_COUNT = 500;
419
+ const FILES_CAP_BYTES = 1024 * 1024 * 1024;
420
+ const FILES_ROOT = process.env.UAI_FILES_ROOT || "/workspace/files";
421
+ const FILES_STATE = process.env.UAI_FILES_STATE_FILE || "/workspace/.moondeck/files-state.json";
422
+
423
+ function filesTarget(raw) {
424
+ if (raw === "org" || raw === "me") return { scope: raw, path: "" };
425
+ if (typeof raw === "string" && raw.startsWith("org/")) return { scope: "org", path: raw.slice(4) };
426
+ if (typeof raw === "string" && raw.startsWith("me/")) return { scope: "me", path: raw.slice(3) };
427
+ return null;
428
+ }
429
+ function filesQuery(scope, path, cursor) {
430
+ let q = "/api/agent/files?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path || "");
431
+ if (cursor) q += "&cursor=" + encodeURIComponent(cursor);
432
+ return q;
433
+ }
434
+ async function filesListFolder(scope, path, bounded) {
435
+ const entries = [];
436
+ let cursor = "";
437
+ let pages = 0;
438
+ do {
439
+ if (bounded && pages >= FILES_CAP_COUNT) {
440
+ console.error("moondeck: files get -r stops at 500 files");
441
+ process.exit(1);
442
+ }
443
+ pages += 1;
444
+ const page = await api("GET", filesQuery(scope, path, cursor));
445
+ for (const entry of page.entries || []) entries.push(entry);
446
+ cursor = page.cursor || "";
447
+ } while (cursor);
448
+ return entries;
449
+ }
450
+ function defaultFilesDest(scope, path) {
451
+ return FILES_ROOT + "/" + scope + (path ? "/" + path : "");
452
+ }
453
+ function filesRefuse(message) {
454
+ console.error("moondeck: " + message);
455
+ process.exit(1);
456
+ }
457
+ function legacyFilesMount(root) {
458
+ try {
459
+ lstatSync(join(dirname(root), ".moondeck", "files-mounted"));
460
+ return true;
461
+ } catch (err) {
462
+ if (err && err.code === "ENOENT") return false;
463
+ throw err;
464
+ }
465
+ }
466
+ // Default destinations stay under FILES_ROOT. The directories are opened
467
+ // with O_DIRECTORY|O_NOFOLLOW and held across the download; the temp file
468
+ // is created and renamed relative to those descriptors. An explicit <dest>
469
+ // uses prepareExplicitDest and is not contained.
470
+ function closeFds(fds) {
471
+ for (const fd of fds) {
472
+ try { closeSync(fd); } catch (err) {}
473
+ }
474
+ }
475
+ function refuseHeld(fds, message) {
476
+ closeFds(fds);
477
+ filesRefuse(message);
478
+ }
479
+ function lstatChild(dirFd, name) {
480
+ try {
481
+ return lstatSync("/proc/self/fd/" + dirFd + "/" + name);
482
+ } catch (err) {
483
+ if (err && err.code === "ENOENT") return null;
484
+ throw err;
485
+ }
486
+ }
487
+ function openDirAt(parentFd, name) {
488
+ const target = parentFd < 0 ? name : "/proc/self/fd/" + parentFd + "/" + name;
489
+ let fd = -1;
490
+ try {
491
+ fd = openSync(target, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
492
+ } catch (err) {
493
+ if (err && (err.code === "ELOOP" || err.code === "ENOTDIR" || err.code === "EPERM")) {
494
+ filesRefuse("files destination follows a symlink");
495
+ }
496
+ throw err;
497
+ }
498
+ const opened = fstatSync(fd);
499
+ const listed = lstatSync(target);
500
+ if (
501
+ listed.isSymbolicLink() ||
502
+ !listed.isDirectory() ||
503
+ opened.dev !== listed.dev ||
504
+ opened.ino !== listed.ino
505
+ ) {
506
+ closeSync(fd);
507
+ filesRefuse("files destination follows a symlink");
508
+ }
509
+ return fd;
510
+ }
511
+ function staysUnder(rootReal, fileReal) {
512
+ return fileReal === rootReal || fileReal.indexOf(rootReal + sep) === 0;
513
+ }
514
+ function openContainedDest(dest) {
515
+ const fds = [];
516
+ const root = resolve(FILES_ROOT);
517
+ let rootStat = null;
518
+ try {
519
+ rootStat = lstatSync(root);
520
+ } catch (err) {
521
+ if (!err || err.code !== "ENOENT") throw err;
522
+ }
523
+ if (rootStat && rootStat.isSymbolicLink()) filesRefuse("refusing a legacy shared-files mount");
524
+ if (legacyFilesMount(root)) filesRefuse("refusing a legacy shared-files mount");
525
+ const target = resolve(dest);
526
+ const rel = relative(root, target);
527
+ if (rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
528
+ filesRefuse("files destination must stay under /workspace/files");
529
+ }
530
+ if (!rootStat) {
531
+ const parent = dirname(root);
532
+ let parentStat = null;
533
+ try { parentStat = lstatSync(parent); } catch (err) {
534
+ if (!err || err.code !== "ENOENT") throw err;
535
+ }
536
+ if (!parentStat || parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
537
+ filesRefuse("files destination follows a symlink");
538
+ }
539
+ mkdirSync(root);
540
+ } else if (!rootStat.isDirectory()) {
541
+ filesRefuse("files destination is not a directory");
542
+ }
543
+ const rootFd = openDirAt(-1, root);
544
+ fds.push(rootFd);
545
+ // The allowed root is the pathname at open time, not wherever the held
546
+ // directory descriptor points later. realOf(rootFd) follows a rename of
547
+ // /workspace/files itself, so both sides of the comparison would move and
548
+ // a download would land outside. Pin realpath once, before any await.
549
+ let rootReal = "";
550
+ try {
551
+ rootReal = realpathSync(root);
552
+ } catch (err) {
553
+ refuseHeld(fds, "files destination left /workspace/files");
554
+ }
555
+ const pinned = lstatSync(rootReal);
556
+ const openedRoot = fstatSync(rootFd);
557
+ if (
558
+ pinned.isSymbolicLink() ||
559
+ !pinned.isDirectory() ||
560
+ pinned.dev !== openedRoot.dev ||
561
+ pinned.ino !== openedRoot.ino
562
+ ) {
563
+ refuseHeld(fds, "files destination left /workspace/files");
564
+ }
565
+ const parts = rel.split(sep);
566
+ let dirFd = rootFd;
567
+ for (let i = 0; i < parts.length; i++) {
568
+ const name = parts[i];
569
+ if (!name || name === "." || name === "..") {
570
+ refuseHeld(fds, "files destination must stay under /workspace/files");
571
+ }
572
+ const last = i === parts.length - 1;
573
+ const st = lstatChild(dirFd, name);
574
+ if (st && st.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
575
+ if (last) {
576
+ if (st && !st.isFile()) refuseHeld(fds, "files destination is not a file");
577
+ return { rootFd: rootFd, dirFd: dirFd, name: name, fds: fds, rootReal: rootReal };
578
+ }
579
+ if (!st) {
580
+ try {
581
+ mkdirSync("/proc/self/fd/" + dirFd + "/" + name);
582
+ } catch (err) {
583
+ if (!err || err.code !== "EEXIST") {
584
+ closeFds(fds);
585
+ throw err;
586
+ }
587
+ }
588
+ const created = lstatChild(dirFd, name);
589
+ if (!created || created.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
590
+ if (!created.isDirectory()) refuseHeld(fds, "files destination is not a directory");
591
+ } else if (!st.isDirectory()) {
592
+ refuseHeld(fds, "files destination is not a directory");
593
+ }
594
+ try {
595
+ dirFd = openDirAt(dirFd, name);
596
+ } catch (err) {
597
+ closeFds(fds);
598
+ throw err;
599
+ }
600
+ fds.push(dirFd);
601
+ }
602
+ refuseHeld(fds, "files destination must stay under /workspace/files");
603
+ }
604
+ function prepareExplicitDest(dest) {
605
+ mkdirSync(dirname(dest), { recursive: true });
606
+ return dest;
607
+ }
608
+ let filesPartSeq = 0;
609
+ function recordFileState(scope, path, etag) {
610
+ mkdirSync(dirname(FILES_STATE), { recursive: true });
611
+ const lock = FILES_STATE + ".lock";
612
+ let fd = -1;
613
+ const started = Date.now();
614
+ while (fd < 0) {
615
+ try {
616
+ fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
617
+ } catch (err) {
618
+ if (!err || err.code !== "EEXIST") throw err;
619
+ if (Date.now() - started > 5000) throw new Error("files state is locked");
620
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
621
+ }
622
+ }
623
+ try {
624
+ let state = {};
625
+ try { state = JSON.parse(readFileSync(FILES_STATE, "utf8")); } catch {}
626
+ if (!state || typeof state !== "object") state = {};
627
+ state[scope + "/" + path] = { etag: etag };
628
+ const tmp = FILES_STATE + ".tmp";
629
+ writeFileSync(tmp, JSON.stringify(state));
630
+ renameSync(tmp, FILES_STATE);
631
+ } finally {
632
+ closeSync(fd);
633
+ try { unlinkSync(lock); } catch {}
634
+ }
635
+ }
636
+ function procChild(dirFd, name) {
637
+ return "/proc/self/fd/" + dirFd + "/" + name;
638
+ }
639
+ function unlinkChild(dirFd, name) {
640
+ try { unlinkSync(procChild(dirFd, name)); } catch (unlinkErr) {}
641
+ }
642
+ function realOf(fd) {
643
+ return realpathSync("/proc/self/fd/" + fd);
644
+ }
645
+ function fileUnderRoot(rootReal, fd) {
646
+ try {
647
+ return staysUnder(rootReal, realOf(fd));
648
+ } catch (err) {
649
+ return false;
650
+ }
651
+ }
652
+ function refuseEscaped(dirFd, name, fd) {
653
+ if (fd >= 0) {
654
+ try { closeSync(fd); } catch (closeErr) {}
655
+ }
656
+ unlinkChild(dirFd, name);
657
+ filesRefuse("files destination left /workspace/files");
658
+ }
659
+ async function streamInto(fd, body, budget) {
660
+ const reader = body.getReader();
661
+ let accepted = 0;
662
+ while (true) {
663
+ const step = await reader.read();
664
+ if (step.done) break;
665
+ const buf = Buffer.from(step.value);
666
+ if (budget && accepted + buf.length > budget.remaining) {
667
+ try { await reader.cancel(); } catch (cancelErr) {}
668
+ const capErr = new Error("cap");
669
+ capErr.code = "FILES_CAP";
670
+ throw capErr;
671
+ }
672
+ writeSync(fd, buf);
673
+ accepted += buf.length;
674
+ }
675
+ return accepted;
676
+ }
677
+ function throwFilesCap(err) {
678
+ if (err && err.code === "FILES_CAP") {
679
+ console.error("moondeck: files get -r stops at 1 GB");
680
+ process.exit(1);
681
+ }
682
+ throw err;
683
+ }
684
+ // Explicit destinations are the caller's path. Default destinations are
685
+ // created and renamed through the directory descriptors opened before the
686
+ // link request, then checked to still sit under the files root.
687
+ async function writePath(dest, res, budget) {
688
+ filesPartSeq += 1;
689
+ const tmp = dest + ".part-" + process.pid + "-" + filesPartSeq;
690
+ let fd = -1;
691
+ try {
692
+ fd = openSync(tmp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
693
+ const accepted = await streamInto(fd, res.body, budget);
694
+ closeSync(fd);
695
+ fd = -1;
696
+ renameSync(tmp, dest);
697
+ if (budget) budget.remaining -= accepted;
698
+ } catch (err) {
699
+ if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
700
+ try { unlinkSync(tmp); } catch (unlinkErr) {}
701
+ throwFilesCap(err);
702
+ }
703
+ }
704
+ async function writeHeld(held, res, budget) {
705
+ const rootReal = held.rootReal;
706
+ if (typeof rootReal !== "string" || !rootReal || !staysUnder(rootReal, realOf(held.dirFd))) {
707
+ filesRefuse("files destination left /workspace/files");
708
+ }
709
+ const existing = lstatChild(held.dirFd, held.name);
710
+ if (existing && existing.isSymbolicLink()) filesRefuse("files destination follows a symlink");
711
+ if (existing && !existing.isFile()) filesRefuse("files destination is not a file");
712
+ filesPartSeq += 1;
713
+ const tmpName = ".part-" + process.pid + "-" + filesPartSeq;
714
+ const tmpPath = procChild(held.dirFd, tmpName);
715
+ const finalPath = procChild(held.dirFd, held.name);
716
+ let fd = -1;
717
+ try {
718
+ try {
719
+ fd = openSync(tmpPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
720
+ } catch (err) {
721
+ if (err && err.code === "ELOOP") filesRefuse("files destination follows a symlink");
722
+ throw err;
723
+ }
724
+ if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
725
+ const accepted = await streamInto(fd, res.body, budget);
726
+ if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
727
+ closeSync(fd);
728
+ fd = -1;
729
+ if (!staysUnder(rootReal, realOf(held.dirFd))) {
730
+ unlinkChild(held.dirFd, tmpName);
731
+ filesRefuse("files destination left /workspace/files");
732
+ }
733
+ renameSync(tmpPath, finalPath);
734
+ const placed = lstatChild(held.dirFd, held.name);
735
+ let placedReal = "";
736
+ try { placedReal = realpathSync(finalPath); } catch (err) { placedReal = ""; }
737
+ if (!placed || placed.isSymbolicLink() || !placed.isFile() || !staysUnder(rootReal, placedReal)) {
738
+ unlinkChild(held.dirFd, held.name);
739
+ filesRefuse("files destination left /workspace/files");
740
+ }
741
+ if (budget) budget.remaining -= accepted;
742
+ } catch (err) {
743
+ if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
744
+ unlinkChild(held.dirFd, tmpName);
745
+ throwFilesCap(err);
746
+ }
747
+ }
748
+ async function downloadFile(scope, path, dest, contained, budget) {
749
+ const held = contained ? openContainedDest(dest) : null;
750
+ try {
751
+ if (!contained) prepareExplicitDest(dest);
752
+ const link = await api(
753
+ "GET",
754
+ "/api/agent/files/link?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path),
755
+ );
756
+ let res;
757
+ try {
758
+ res = await fetch(link.url);
759
+ } catch {
760
+ console.error("moondeck: download failed");
761
+ process.exit(1);
762
+ }
763
+ if (!res.ok) {
764
+ console.error("moondeck: download failed (" + res.status + ")");
765
+ process.exit(1);
766
+ }
767
+ const etag = res.headers.get("etag");
768
+ if (!etag || (link.etag && etag !== link.etag)) {
769
+ if (res.body && res.body.cancel) {
770
+ try { await res.body.cancel(); } catch (cancelErr) {}
771
+ }
772
+ console.error(etag ? "moondeck: download etag does not match the inspected file" : "moondeck: download did not name an etag");
773
+ process.exit(1);
774
+ }
775
+ if (!res.body || !res.body.getReader) {
776
+ console.error("moondeck: download failed");
777
+ process.exit(1);
778
+ }
779
+ if (held) await writeHeld(held, res, budget);
780
+ else await writePath(dest, res, budget);
781
+ recordFileState(scope, path, etag);
782
+ } finally {
783
+ if (held) closeFds(held.fds);
784
+ }
785
+ }
786
+
787
+ // Explicit '-' only: omitted argv text never blocks waiting for a terminal.
788
+ // Existing cloud schemas enforce the same instruction/prompt length limits.
789
+ function stdinText(label) {
790
+ if (process.stdin.isTTY) {
791
+ console.error("moondeck: " + label + " stdin is a terminal; use a pipe or a quoted heredoc with - <<'EOF' (see moondeck --help)");
792
+ process.exit(1);
793
+ }
794
+ const text = readFileSync(0, "utf8").trim();
795
+ if (!text) { console.error("moondeck: " + label + " stdin is empty"); process.exit(1); }
796
+ return text;
797
+ }
798
+
799
+ const argv = process.argv.slice(2);
800
+ const [group, action, ...rest] = argv;
801
+ const { flags, rest: pos } = parseFlags(rest);
802
+
803
+ const validateShareablePath = ${validateShareablePath.toString()};
804
+ const assertShareablePath = (path) => validateShareablePath(path, ${JSON.stringify(ENGINE_ACCOUNT_PATH_RULES)});
805
+ async function main() {
806
+ if (group === "secret") {
807
+ if (action !== "request" || pos.length !== 1 || !flags.why || Object.keys(flags).some(key => !["why", "proxy-domain", "no-wait"].includes(key))) { console.error('moondeck: secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]'); process.exit(1); }
808
+ // Repeated domains retain order from argv; the generic flag parser intentionally keeps one value.
809
+ const domains = [];
810
+ for (let i = 0; i < rest.length; i++) if (rest[i] === "--proxy-domain" && rest[i + 1]) domains.push(rest[++i]);
811
+ const created = await api("POST", "/api/agent/secrets/requests", { name: pos[0], why: flags.why, domains });
812
+ let request = created.request;
813
+ out({ request });
814
+ if (flags["no-wait"] === "true") return;
815
+ while (request.status === "pending") {
816
+ await new Promise(resolve => setTimeout(resolve, 2000));
817
+ request = (await api("GET", "/api/agent/secrets/requests/" + encodeURIComponent(request.id))).request;
818
+ }
819
+ out({ request });
820
+ if (request.status !== "provided") process.exit(1);
821
+ return;
822
+ }
823
+ if (group === "artifacts") {
824
+ const rows = (await api("GET", "/api/agent/artifacts")).artifacts || [];
825
+ out(rows.length ? rows.map((a) => a.id + " " + a.name + " (" + a.size + " bytes) " + API_URL + a.open).join("\\n") : "no shared files");
826
+ return;
827
+ }
828
+ if (group === "share") {
829
+ const parsed = parseFlags([action, ...rest].filter(Boolean));
830
+ if (parsed.rest.length !== 1 || Object.keys(parsed.flags).some((k) => k !== "title")) throw new Error("share <path> [--title <text>]");
831
+ const requestedPath = isAbsolute(parsed.rest[0]) ? parsed.rest[0] : process.cwd() + "/" + parsed.rest[0];
832
+ const lexical = assertShareablePath(requestedPath);
833
+ const sourcePath = assertShareablePath(await realpath(lexical));
834
+ const file = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
835
+ try {
836
+ const stat = await file.stat();
837
+ if (!stat.isFile() || stat.size > 100 * 1024 * 1024) throw new Error("share needs a regular file of at most 100 MiB");
838
+ const hash = createHash("sha256");
839
+ for await (const chunk of file.createReadStream({ autoClose: false })) hash.update(chunk);
840
+ const created = await api("POST", "/api/agent/artifacts", { name: basename(sourcePath), size: stat.size, sha256: hash.digest("hex"), sourcePath, requestedPath, title: parsed.flags.title });
841
+ const uploaded = await fetch(created.upload.url, { method: "PUT", headers: created.upload.headers, body: file.createReadStream({ start: 0, autoClose: false }), duplex: "half" });
842
+ if (!uploaded.ok) throw new Error("staging upload refused (" + uploaded.status + "); retry share to make a new receipt");
843
+ let done;
844
+ for (let attempt = 0; attempt < 30; attempt++) {
845
+ done = await api("POST", "/api/agent/artifacts/" + encodeURIComponent(created.artifact.id) + "/complete");
846
+ if (done.result !== "pending") break;
847
+ await new Promise((resolve) => setTimeout(resolve, 1000));
848
+ }
849
+ if (done?.result !== "published" || done.artifact.state !== "ready") throw new Error("artifact " + created.artifact.id + " is " + (done?.result || "recovering") + "; check moondeck artifacts shortly");
850
+ out(done.artifact.id + " " + API_URL + done.artifact.open);
851
+ } finally { await file.close(); }
852
+ return;
853
+ }
854
+ if (group === "dispatch") {
855
+ const args = [action, ...rest].filter((value) => typeof value === "string");
856
+ const recipientArgs = [];
857
+ while (args.length && /^@[A-Za-z0-9_-]+$/.test(args[0])) {
858
+ recipientArgs.push(args.shift().slice(1));
859
+ }
860
+ if (!recipientArgs.length) {
861
+ console.error('moondeck: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
862
+ process.exit(1);
863
+ }
864
+ const tags = {};
865
+ while (args[0] && ["--kind", "--size", "--pr", "--request-id"].includes(args[0])) {
866
+ const key = args.shift().slice(2);
867
+ const value = args.shift();
868
+ if (!value || value === "-" || value.startsWith("--")) {
869
+ console.error("moondeck: dispatch --" + key + " needs a value"); process.exit(1);
870
+ }
871
+ tags[key === "request-id" ? "requestId" : key] = value;
872
+ }
873
+ if (tags.kind && !["build", "fix", "review", "research", "design", "docs", "ops"].includes(tags.kind)) {
874
+ console.error("moondeck: dispatch --kind must be build, fix, review, research, design, docs or ops"); process.exit(1);
875
+ }
876
+ if (tags.size && !["s", "m", "l"].includes(tags.size)) {
877
+ console.error("moondeck: dispatch --size must be s, m or l"); process.exit(1);
878
+ }
879
+ const instruction = args.length === 1 && args[0] === "-" ? stdinText("dispatch") : args.join(" ").trim();
880
+ if (!instruction) {
881
+ console.error('moondeck: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
882
+ process.exit(1);
883
+ }
884
+ const result = await api("POST", "/api/agent/dispatch", {
885
+ to: recipientArgs,
886
+ instruction,
887
+ requestId: tags.requestId || randomUUID(),
888
+ ...tags,
889
+ });
890
+ if (result.recipients.length) out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
891
+ if (result.pendingRecipients && result.pendingRecipients.length) out("pending/in flight: " + result.pendingRecipients.map((id) => "@" + id).join(", "));
892
+ if (result.canceledRecipients && result.canceledRecipients.length) out("canceled: " + result.canceledRecipients.map((id) => "@" + id).join(", "));
893
+ if (Array.isArray(result.failedRecipients) && result.failedRecipients.length) {
894
+ console.error(
895
+ "moondeck: could not wake " +
896
+ result.failedRecipients.map((id) => "@" + id).join(", "),
897
+ );
898
+ }
899
+ return;
900
+ }
901
+ if (group === "review" && action === "report") {
902
+ const counts = ["must", "should", "nits"];
903
+ if (!flags.pr || !/^[0-9a-f]{40}$/.test(flags.head || "") || !["approve", "changes"].includes(flags.verdict) ||
904
+ counts.some(k => !/^[0-9]+$/.test(flags[k] || "") || Number(flags[k]) > 1000) ||
905
+ (flags.revision && !/^[0-9]+$/.test(flags.revision))) {
906
+ console.error("moondeck: review report --pr <number|URL> --head <full-sha> --must <0..1000> --should <0..1000> --nits <0..1000> --verdict approve|changes [--summary text] [--assignment id] [--revision n]"); process.exit(1);
907
+ }
908
+ out(await api("POST", "/api/agent/review/report", { pr: flags.pr, head: flags.head,
909
+ must: Number(flags.must), should: Number(flags.should), nits: Number(flags.nits), verdict: flags.verdict,
910
+ summary: flags.summary, assignmentId: flags.assignment, expectedRevision: Number(flags.revision || 0) })); return;
911
+ }
912
+ if (group === "branch" && action === "name") {
913
+ // uai/<taskId>/<name>: this task's server-issued branch namespace. A PR
914
+ // from it links to this task exactly like one from the task branch.
915
+ const name = pos[0] || "";
916
+ const valid = name.length > 0 && name.length <= 150 && !name.includes("..") &&
917
+ name.split("/").every(part => /^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(part) && !part.endsWith(".lock") && !part.endsWith("."));
918
+ if (pos.length !== 1 || Object.keys(flags).length || !valid) {
919
+ console.error("moondeck: branch name <name> (letters, digits, '.', '_', '-' and '/'; no leading '-' or '.')"); process.exit(1);
920
+ }
921
+ const taskId = tokenTaskId();
922
+ if (!taskId || !/^[0-9a-hjkmnp-tv-z]{26}$/.test(taskId)) { console.error("moondeck: branch name: this task's id is unavailable"); process.exit(1); }
923
+ const branch = "uai/" + taskId + "/" + name;
924
+ // git's own rule, when git is here to ask.
925
+ const git = spawnSync("git", ["check-ref-format", "--branch", branch], { encoding: "utf8" });
926
+ if (!git.error && git.status !== 0) { console.error("moondeck: branch name: git refuses " + branch); process.exit(1); }
927
+ out(branch); return;
928
+ }
929
+ if (group === "crew") {
930
+ // ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
931
+ // persona) changes its own crew. One agent per call, always with a --why.
932
+ const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
933
+ const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
934
+ if (action === "outcome") {
935
+ if (!pos[0] || !["accepted", "reworked", "failed"].includes(pos[1]) || (flags.revision && !/^[0-9]+$/.test(flags.revision))) {
936
+ console.error("moondeck: crew outcome @agent accepted|reworked|failed [--assignment id] [--note text] [--revision n]"); process.exit(1);
937
+ }
938
+ out(await api("POST", "/api/agent/crew-outcome", { recipient: handleOf(pos[0]), outcome: pos[1],
939
+ assignmentId: flags.assignment, note: flags.note, expectedRevision: Number(flags.revision || 0) })); return;
940
+ }
941
+ if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
942
+ if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
943
+ if (action === "store") { out(await api("GET", "/api/agent/crew-store")); return; }
944
+ if (action === "suggest") {
945
+ // The Astronaut suggests a persona; a person creates it (ADR-157 addendum).
946
+ // From the Store (--template) or from scratch (--name and --brief).
947
+ if (!flags.why || !(flags.template || flags.name)) {
948
+ console.error('moondeck: crew suggest needs --template <slug> (see crew store), or --name and --brief for a new one, and --why "<reason>"');
949
+ process.exit(1);
950
+ }
951
+ // --skill repeats, and parseFlags keeps only the last, so read them off the raw argv.
952
+ const skills = [];
953
+ for (let i = 0; i < rest.length; i++) {
954
+ if (rest[i] === "--skill" && rest[i + 1]) skills.push(...rest[++i].split(",").filter(Boolean));
955
+ }
956
+ const body = { why: flags.why };
957
+ if (flags.template) body.template = flags.template;
958
+ for (const k of ["name", "role", "summary", "brief", "model", "effort"]) if (flags[k]) body[k] = flags[k];
959
+ if (flags.handle) body.handle = handleOf(flags.handle);
960
+ if (flags.engine) body.engine = flags.engine;
961
+ if (skills.length) body.skills = skills;
962
+ const r = await api("POST", "/api/agent/crew-suggest", body);
963
+ out("suggested " + r.suggestion.name + " (@" + r.suggestion.handle + "). " + r.message);
964
+ return;
965
+ }
966
+ if (action === "add") {
967
+ const personaId = pos[0];
968
+ if (!personaId || !flags.why) {
969
+ console.error('moondeck: crew add needs a persona id (see crew list) and --why "<reason>"');
970
+ process.exit(1);
971
+ }
972
+ const body = { personaId, why: flags.why };
973
+ if (flags.as) body.handle = handleOf(flags.as);
974
+ if (flags.engine) body.kind = flags.engine;
975
+ if (flags.model) body.model = flags.model;
976
+ if (flags.effort) body.effort = flags.effort;
977
+ if (flags.account) body.account = flags.account === "default" ? null : flags.account;
978
+ const r = await api("POST", "/api/agent/crew", body);
979
+ out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
980
+ return;
981
+ }
982
+ if (action === "model") {
983
+ const id = handleOf(pos[0] || "");
984
+ if (!id || !flags.why || !(flags.model || flags.engine || flags.effort || flags.account)) {
985
+ console.error('moondeck: crew model needs @agent, at least one of --engine, --model, --effort and --account, and --why "<reason>"');
986
+ process.exit(1);
987
+ }
988
+ // "default" clears the setting back to the engine's own default (null).
989
+ const cleared = (v) => (v === "default" ? null : v);
990
+ const body = { why: flags.why };
991
+ if (flags.engine) body.kind = flags.engine;
992
+ if (flags.model) body.model = cleared(flags.model);
993
+ if (flags.effort) body.effort = cleared(flags.effort);
994
+ if (flags.account) body.account = cleared(flags.account);
995
+ const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
996
+ out(r.changed
997
+ ? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
998
+ : "@" + id + " already runs on " + launch(r.agent) + "; nothing changed");
999
+ return;
1000
+ }
1001
+ if (action === "remove") {
1002
+ const id = handleOf(pos[0] || "");
1003
+ if (!id || !flags.why) {
1004
+ console.error('moondeck: crew remove needs @agent and --why "<reason>"');
1005
+ process.exit(1);
1006
+ }
1007
+ await api("DELETE", "/api/agent/crew/" + encodeURIComponent(id), { why: flags.why });
1008
+ out("removed @" + id);
1009
+ return;
1010
+ }
1011
+ console.error("moondeck: crew commands are list, status, store, suggest, add, model and remove (Astronaut only)");
1012
+ process.exit(1);
1013
+ }
1014
+ if (group === "files") {
1015
+ const args = rest.filter((value) => typeof value === "string");
1016
+ const recursive = args.indexOf("-r") !== -1;
1017
+ const positional = args.filter((value) => value !== "-r");
1018
+ if (action === "ls") {
1019
+ if (positional.length > 1) {
1020
+ console.error("moondeck: files ls [org|me[/prefix]]");
1021
+ process.exit(1);
1022
+ }
1023
+ const target = filesTarget(positional[0] || "org");
1024
+ if (!target) {
1025
+ console.error("moondeck: files ls [org|me[/prefix]]");
1026
+ process.exit(1);
1027
+ }
1028
+ out({ entries: await filesListFolder(target.scope, target.path) });
1029
+ return;
1030
+ }
1031
+ if (action === "get") {
1032
+ if (!positional.length || positional.length > 2) {
1033
+ console.error("moondeck: files get <org|me>/<path> [<dest>] [-r]");
1034
+ process.exit(1);
1035
+ }
1036
+ const target = filesTarget(positional[0]);
1037
+ if (!target) {
1038
+ console.error("moondeck: files get <org|me>/<path> [<dest>] [-r]");
1039
+ process.exit(1);
1040
+ }
1041
+ if (!recursive) {
1042
+ if (!target.path) {
1043
+ console.error("moondeck: files get needs a file path (or -r for a folder)");
1044
+ process.exit(1);
1045
+ }
1046
+ const dest = positional[1] || defaultFilesDest(target.scope, target.path);
1047
+ await downloadFile(target.scope, target.path, dest, !positional[1], null);
1048
+ out(dest);
1049
+ return;
1050
+ }
1051
+ const root = target.path;
1052
+ const pending = [root];
1053
+ const files = [];
1054
+ let bytes = 0;
1055
+ let folders = 0;
1056
+ while (pending.length) {
1057
+ if (folders >= FILES_CAP_COUNT) {
1058
+ console.error("moondeck: files get -r stops at 500 files");
1059
+ process.exit(1);
1060
+ }
1061
+ folders += 1;
1062
+ const dir = pending.shift();
1063
+ const entries = await filesListFolder(target.scope, dir, true);
1064
+ for (const entry of entries) {
1065
+ const child = dir ? dir + "/" + entry.name : entry.name;
1066
+ if (entry.dir) {
1067
+ pending.push(child);
1068
+ continue;
1069
+ }
1070
+ const size = entry.size || 0;
1071
+ if (files.length >= FILES_CAP_COUNT) {
1072
+ console.error("moondeck: files get -r stops at 500 files");
1073
+ process.exit(1);
1074
+ }
1075
+ if (bytes + size > FILES_CAP_BYTES) {
1076
+ console.error("moondeck: files get -r stops at 1 GB");
1077
+ process.exit(1);
1078
+ }
1079
+ bytes += size;
1080
+ files.push(child);
1081
+ }
1082
+ }
1083
+ const budget = { remaining: FILES_CAP_BYTES };
1084
+ const explicitRoot = positional[1] ? String(positional[1]).replace(/\\/+$/, "") : "";
1085
+ for (const file of files) {
1086
+ const rel = root && file.indexOf(root + "/") === 0 ? file.slice(root.length + 1) : file;
1087
+ const dest = explicitRoot ? explicitRoot + "/" + rel : defaultFilesDest(target.scope, file);
1088
+ await downloadFile(target.scope, file, dest, !explicitRoot, budget);
1089
+ }
1090
+ out({ files: files.length, dest: positional[1] || defaultFilesDest(target.scope, root) });
1091
+ return;
1092
+ }
1093
+ console.error("moondeck: files commands are ls and get");
1094
+ process.exit(1);
1095
+ }
1096
+ const key = group + " " + (action || "");
1097
+ switch (key) {
1098
+ case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
1099
+ case "people list": out(await api("GET", "/api/agent/people")); break;
1100
+ case "task list": out((await api("GET", "/api/agent/tasks")).tasks); break;
1101
+ case "memory search": {
1102
+ const q = pos.join(" ") || flags.q || "";
1103
+ const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
1104
+ const limit = flags.limit ? "&limit=" + encodeURIComponent(String(flags.limit)) : "";
1105
+ const res = await api(
1106
+ "GET",
1107
+ "/api/agent/memory?q=" + encodeURIComponent(q) + author + limit,
1108
+ );
1109
+ out(res.memories);
1110
+ // Truncation goes to stderr so it cannot corrupt piped JSON, and it is
1111
+ // stated rather than implied: recalling across tasks means reading the
1112
+ // WHOLE store, and a short page that says nothing reads as all there is.
1113
+ // NOTE: this file is a template literal. No backticks, no \${} in here.
1114
+ if (res.truncated) {
1115
+ console.error(
1116
+ "moondeck: showing " + res.memories.length + " of " + res.total +
1117
+ " — re-run with --limit all for every row.",
1118
+ );
1119
+ }
1120
+ break;
1121
+ }
1122
+ case "memory save": {
1123
+ const text = pos.join(" ") || flags.text || "";
1124
+ if (!text) { console.error("moondeck: memory save needs text"); process.exit(1); }
1125
+ out((await api("POST", "/api/agent/memory", {
1126
+ text,
1127
+ author: flags.as || null,
1128
+ projectId: flags.project || null,
1129
+ tags: flags.tags ? String(flags.tags).split(",").map((s) => s.trim()).filter(Boolean) : [],
1130
+ })).memory);
1131
+ break;
1132
+ }
1133
+ case "memory delete": {
1134
+ const id = pos[0] || flags.id;
1135
+ if (!id) { console.error("moondeck: memory delete needs an id"); process.exit(1); }
1136
+ await api("DELETE", "/api/agent/memory/" + encodeURIComponent(id));
1137
+ out("deleted " + id);
1138
+ break;
1139
+ }
1140
+ case "task create": {
1141
+ const prompt = flags.prompt === "-" ? stdinText("task create --prompt -") : flags.prompt;
1142
+ let astronaut;
1143
+ if (flags.astronaut) {
1144
+ const parts = String(flags.astronaut).split("/");
1145
+ if (parts.length < 2 || parts.some((part) => !part) || flags.astronaut === "true") {
1146
+ console.error("moondeck: --astronaut needs engine/model[/effort]"); process.exit(1);
1147
+ }
1148
+ const kind = parts.shift();
1149
+ const effort = parts.length > 1 ? parts.pop() : undefined;
1150
+ const model = parts.join("/");
1151
+ astronaut = { kind, ...(model !== "default" ? { model } : {}), ...(effort && effort !== "default" ? { effort } : {}) };
1152
+ }
1153
+ if (flags.start && flags.start !== "true") {
1154
+ console.error("moondeck: --start is a flag; it takes no value"); process.exit(1);
1155
+ }
1156
+ if (flags.start && (!flags["request-id"] || flags["request-id"] === "true")) {
1157
+ console.error("moondeck: --start requires --request-id <logical-run-id>; reuse it after a timeout"); process.exit(1);
1158
+ }
1159
+ const created = (await api("POST", "/api/agent/tasks", {
1160
+ name: flags.name,
1161
+ prompt,
1162
+ projects: flags.projects ? String(flags.projects).split(",").map((s) => s.trim()).filter(Boolean) : [],
1163
+ team: flags.team || null,
1164
+ agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : [],
1165
+ parent: flags.parent || null,
1166
+ start: flags.start === "true",
1167
+ ...(astronaut ? { astronaut } : {}),
1168
+ requestId: flags["request-id"] || randomUUID(),
1169
+ })).task;
1170
+ if (flags.start && (!created || typeof created.id !== "string" || !created.id || !["queued", "starting", "running"].includes(created.status))) {
1171
+ console.error("moondeck: the cloud did not start the child" + (created?.id ? " " + created.id : "") + "; it may need updating. Reuse --request-id when retrying."); process.exit(1);
1172
+ }
1173
+ out(created);
1174
+ break;
1175
+ }
1176
+ case "task watch":
1177
+ case "task usage": {
1178
+ if (pos.length !== 1) { console.error("moondeck: task " + action + " needs a child task id"); process.exit(1); }
1179
+ const query = new URLSearchParams();
1180
+ if (action === "watch") {
1181
+ if (flags.after) query.set("after", String(flags.after));
1182
+ if (flags.limit) query.set("limit", String(flags.limit));
1183
+ }
1184
+ out(await api("GET", "/api/agent/tasks/" + encodeURIComponent(pos[0]) + "/" + action + (query.size ? "?" + query.toString() : "")));
1185
+ break;
1186
+ }
1187
+ case "task send": {
1188
+ // The text is prose, not CLI flags. Preserve literal --options in it.
1189
+ const [childId, ...words] = rest;
1190
+ const text = words.join(" ").trim();
1191
+ if (!childId || !text) { console.error("moondeck: task send needs a child task id and text"); process.exit(1); }
1192
+ out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/send", { text }));
1193
+ break;
1194
+ }
1195
+ case "task answer": {
1196
+ const [childId, cardId, ...words] = rest;
1197
+ const option = words.join(" ").trim();
1198
+ if (!childId || !cardId || !option) { console.error("moondeck: task answer needs a child task id, card id and option text"); process.exit(1); }
1199
+ out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/answer", { cardId, option }));
1200
+ break;
1201
+ }
1202
+ case "task close": {
1203
+ const allowed = new Set(["status", "reason", "child"]);
1204
+ const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
1205
+ const childMode = Object.prototype.hasOwnProperty.call(flags, "child");
1206
+ if ((childMode && (flags.child === "true" || !String(flags.child).trim())) || (pos.length === 1 && !String(pos[0]).trim())) {
1207
+ console.error("moondeck: --child requires a child task id"); process.exit(1);
1208
+ }
1209
+ if (pos.length > 1 || unknown.length || (childMode && pos.length)) {
1210
+ console.error("moondeck: task close accepts --child <childId>, --status and --reason");
1211
+ process.exit(1);
1212
+ }
1213
+ const childId = childMode ? flags.child : pos[0];
1214
+ const status = flags.status;
1215
+ if (status !== "finished" && status !== "canceled") {
1216
+ console.error("moondeck: task close needs --status finished|canceled");
1217
+ process.exit(1);
1218
+ }
1219
+ if (flags.reason === "true") {
1220
+ console.error("moondeck: --reason needs text (quote multiword reasons)");
1221
+ process.exit(1);
1222
+ }
1223
+ const path = childId ? "/api/agent/tasks/" + encodeURIComponent(childId) + "/close" : "/api/agent/tasks/close";
1224
+ const closed = await api("POST", path, {
1225
+ status,
1226
+ ...(flags.reason ? { reason: String(flags.reason) } : {}),
1227
+ });
1228
+ out("task closed as " + closed.status);
1229
+ break;
1230
+ }
1231
+ case "todo list": {
1232
+ const todos = (await api("GET", "/api/agent/todos")).todos || [];
1233
+ if (!todos.length) { out("punchlist is empty"); break; }
1234
+ const done = todos.filter((t) => t.status === "done").length;
1235
+ const lines = todos.map((t) => {
1236
+ const box = t.status === "done" ? "[x]" : t.claimedBy ? "[~]" : "[ ]";
1237
+ const who = t.status === "done"
1238
+ ? (t.claimedBy ? " — done by @" + t.claimedBy : " — done")
1239
+ : t.claimedBy ? " — @" + t.claimedBy + " working" : "";
1240
+ return " #" + t.shortId + " " + box + " " + t.text + who;
1241
+ });
1242
+ out("Punchlist (" + done + "/" + todos.length + " done):\\n" + lines.join("\\n"));
1243
+ break;
1244
+ }
1245
+ case "todo add": {
1246
+ const text = todoText(rest);
1247
+ if (!text) { console.error("moondeck: todo add needs text"); process.exit(1); }
1248
+ const t = (await api("POST", "/api/agent/todos", { op: "add", text })).todo;
1249
+ out("added #" + t.shortId + ": " + t.text);
1250
+ break;
1251
+ }
1252
+ case "todo edit": {
1253
+ const { ref, tail } = todoRef(rest);
1254
+ const text = todoText(tail);
1255
+ if (!ref) { console.error("moondeck: todo edit needs an item id (see 'moondeck todo list')"); process.exit(1); }
1256
+ if (!text) { console.error("moondeck: todo edit needs replacement text"); process.exit(1); }
1257
+ const t = (await api("POST", "/api/agent/todos", { op: "edit", ref, text })).todo;
1258
+ out("edited #" + t.shortId + ": " + t.text);
1259
+ break;
1260
+ }
1261
+ case "todo remove": {
1262
+ const { ref } = todoRef(rest);
1263
+ if (!ref) { console.error("moondeck: todo remove needs an item id (see 'moondeck todo list')"); process.exit(1); }
1264
+ await api("POST", "/api/agent/todos", { op: "remove", ref });
1265
+ out("removed #" + String(ref).replace(/^#/, ""));
1266
+ break;
1267
+ }
1268
+ case "todo claim": case "todo done": case "todo reopen": case "todo unclaim": {
1269
+ const { ref } = todoRef(rest);
1270
+ if (!ref) { console.error("moondeck: todo " + action + " needs an item id (see 'moondeck todo list')"); process.exit(1); }
1271
+ const t = (await api("POST", "/api/agent/todos", { op: action, ref })).todo;
1272
+ out(action + " #" + t.shortId + ": " + t.text);
1273
+ break;
1274
+ }
1275
+ case "preview add": {
1276
+ const name = pos[0] || flags.name;
1277
+ const port = Number(pos[1] || flags.port);
1278
+ if (!name || !Number.isInteger(port)) { console.error("moondeck: preview add <name> <containerPort>"); process.exit(1); }
1279
+ const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
1280
+ out("preview '" + r.name + "' on port " + r.containerPort + " — turn it on with: moondeck preview enable " + r.name);
1281
+ break;
1282
+ }
1283
+ case "preview enable":
1284
+ case "preview disable":
1285
+ case "preview public":
1286
+ case "preview private": {
1287
+ const name = pos[0] || flags.name;
1288
+ if (!name) { console.error("moondeck: preview " + action + " <name>"); process.exit(1); }
1289
+ const body = action === "enable" || action === "disable"
1290
+ ? { enabled: action === "enable" }
1291
+ : { public: action === "public" };
1292
+ const r = await api("PATCH", "/api/agent/previews/" + encodeURIComponent(name), body);
1293
+ const state = (r.enabled ? "on" : "off") + ", " + (r.public ? "public (anyone with the link)" : "private (sign-in)");
1294
+ out("preview '" + r.name + "' is " + state + (r.url ? " — " + r.url : ""));
1295
+ if (!r.enabled && r.public) console.error("moondeck: it's public but off — it serves nothing until: moondeck preview enable " + r.name);
1296
+ break;
1297
+ }
1298
+ case "project list": {
1299
+ const projects = (await api("GET", "/api/agent/projects")).projects || [];
1300
+ out(projects.length ? projects.map((p) => p.id + " " + p.slug + " " + p.name).join(NL) : "no repositories");
1301
+ break;
1302
+ }
1303
+ case "project show": {
1304
+ if (!pos[0]) { console.error("moondeck: project show <id|slug>"); process.exit(1); }
1305
+ out((await api("GET", "/api/agent/projects/" + encodeURIComponent(pos[0]))).project);
1306
+ break;
1307
+ }
1308
+ case "project create": {
1309
+ const attach = rest.includes("--attach");
1310
+ const name = flags.name || pos.join(" ");
1311
+ if (!name || !flags.repo || flags.repo === "true") { console.error("moondeck: project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach]"); process.exit(1); }
1312
+ const body = { name, repoUrl: flags.repo, ...projectSetupFields(flags) };
1313
+ const p = (await api("POST", "/api/agent/projects", body)).project;
1314
+ out("created project " + p.slug + " (" + p.id + ")");
1315
+ if (attach) await addProjectToTask(p.id, true);
1316
+ break;
1317
+ }
1318
+ case "project update": {
1319
+ if (!pos[0]) { console.error("moondeck: project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>]"); process.exit(1); }
1320
+ const body = { ...(flagValue(flags.name) ? { name: flags.name } : {}), ...(flagValue(flags.repo) ? { repoUrl: flags.repo } : {}), ...projectSetupFields(flags) };
1321
+ if (Object.keys(body).length === 0) { console.error("moondeck: project update needs at least one field"); process.exit(1); }
1322
+ const r = await api("PATCH", "/api/agent/projects/" + encodeURIComponent(pos[0]), body);
1323
+ const proposed = r.proposed || [];
1324
+ out("updated " + r.project.slug + " (applies to the next task)");
1325
+ for (const pr of proposed) out("proposed " + pr.field + " (proposal " + pr.id + "): it changes what later tasks clone or read, so a member must approve it in the repository's Settings.");
1326
+ break;
1327
+ }
1328
+ case "project propose-extra": {
1329
+ if (!pos[0] || !flagValue(flags["extra-file"])) { console.error("moondeck: project propose-extra <id|slug> --extra-file <file>"); process.exit(1); }
1330
+ const r = await api("POST", "/api/agent/projects/" + encodeURIComponent(pos[0]) + "/extra-proposals", { extra: readFlagFile(flags["extra-file"]) });
1331
+ out("proposed Dockerfile additions (proposal " + r.proposal.id + "). They run as root at image build, so a member must approve them in the repository's Settings before any task uses them.");
1332
+ break;
1333
+ }
1334
+ case "project env": {
1335
+ const sub = pos[0], project = pos[1], name = pos[2];
1336
+ const base = "/api/agent/projects/" + encodeURIComponent(project || "") + "/env";
1337
+ if (!project || (["add", "remove", "propose-proxy"].includes(sub) && !name)) {
1338
+ console.error("moondeck: project env list|observed <id> | add <id> NAME --label <l> [--secret] | remove <id> NAME | propose-proxy <id> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>]");
1339
+ process.exit(1);
1340
+ }
1341
+ if (sub === "list") {
1342
+ const keys = (await api("GET", base + "/keys")).keys || [];
1343
+ out(keys.length ? keys.map((k) => k.key + (k.secret ? " (secret)" : "") + " " + k.label).join(NL) : "no env keys declared");
1344
+ } else if (sub === "observed") {
1345
+ out((await api("GET", base + "/declarations")).latest);
1346
+ } else if (sub === "add") {
1347
+ const label = flagValue(flags.label) ? flags.label : undefined;
1348
+ const r = await api("POST", base + "/keys", { key: name, ...(label ? { label } : {}), ...(rest.includes("--secret") ? { secret: true } : {}) });
1349
+ out("declared " + r.key + ". It has no value: a member saves its value in the repository's Settings.");
1350
+ } else if (sub === "remove") {
1351
+ const r = await api("DELETE", base + "/keys", { key: name });
1352
+ out("removed the declaration of " + r.key + ". No value was touched.");
1353
+ } else if (sub === "propose-proxy") {
1354
+ if (!flagValue(flags.domain)) { console.error("moondeck: project env propose-proxy <id> NAME --domain <d>[,<d>…]"); process.exit(1); }
1355
+ const prefix = flags["placeholder-prefix"], length = flags["placeholder-length"];
1356
+ if ((prefix === undefined) !== (length === undefined)) { console.error("moondeck: --placeholder-prefix and --placeholder-length go together"); process.exit(1); }
1357
+ const placeholder = prefix === undefined ? null : { prefix: prefix === "true" ? "" : prefix, totalLength: Number(length) };
1358
+ const domains = String(flags.domain).split(",").map((d) => d.trim()).filter(Boolean);
1359
+ const r = await api("POST", base + "/proxy-proposals", { key: name, domains, placeholder });
1360
+ out("proposed sending " + name + " only to " + r.proposal.domains.join(", ") + " (proposal " + r.proposal.id + "). A member must confirm it in the repository's Settings before the proxy uses it.");
1361
+ } else {
1362
+ console.error("moondeck: project env commands are list, add, remove, propose-proxy and observed");
1363
+ process.exit(1);
1364
+ }
1365
+ break;
1366
+ }
1367
+ case "task project": {
1368
+ const words = rest.filter((word) => word !== "--no-wait");
1369
+ const sub = words[0];
1370
+ if (sub === "add" && words.length === 2 && !words[1].startsWith("--")) {
1371
+ await addProjectToTask(words[1], !rest.includes("--no-wait"));
1372
+ break;
1373
+ }
1374
+ if (sub === "status" && words.length === 2) {
1375
+ const op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(words[1]))).operation;
1376
+ out(opProgress(op));
1377
+ if (op.state === "failed") process.exit(1);
1378
+ break;
1379
+ }
1380
+ console.error("moondeck: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
1381
+ process.exit(1);
1382
+ }
1383
+ case "task rename": {
1384
+ // The name is prose, not flags: read the raw tail, like todo text.
1385
+ const name = rest.join(" ").trim();
1386
+ if (!name || name.length > 120) { console.error('moondeck: task rename "<new name>" (1-120 characters)'); process.exit(1); }
1387
+ const r = await api("PATCH", "/api/agent/task", { name });
1388
+ out(r.changed ? "renamed the task to " + r.task.name + " (branch stays " + r.task.branch + ")" : "the task is already named " + r.task.name);
1389
+ break;
1390
+ }
1391
+ case "task label": {
1392
+ const sub = rest[0];
1393
+ // One shell argument is one label, literally: commas are valid in label
1394
+ // names, so "bug, urgent" is a single label and bug urgent is two.
1395
+ const names = rest.slice(1).map((n) => String(n).trim()).filter(Boolean);
1396
+ if ((sub !== "add" && sub !== "remove") || !names.length) { console.error('moondeck: task label add <name> [<name>…] | task label remove <name> [<name>…] (quote a name with spaces or commas)'); process.exit(1); }
1397
+ const r = await api(sub === "add" ? "POST" : "DELETE", "/api/agent/task/labels", { names });
1398
+ const changed = sub === "add" ? r.added : r.removed;
1399
+ out((changed.length ? (sub === "add" ? "added " : "removed ") + changed.join(", ") : "no change") + " — labels: " + (r.labels.map((l) => l.name).join(", ") || "none"));
1400
+ break;
1401
+ }
1402
+ case "task delete": {
1403
+ if (pos.length !== 1 || Object.keys(flags).length) { console.error("moondeck: task delete <childId> (a draft you created; close a started child instead)"); process.exit(1); }
1404
+ const r = await api("DELETE", "/api/agent/tasks/" + encodeURIComponent(pos[0]));
1405
+ out("deleted draft " + r.deleted);
1406
+ break;
1407
+ }
1408
+ case "task search": {
1409
+ const q = pos.join(" ") || flags.q || "";
1410
+ if (!q) { console.error("moondeck: task search <query>"); process.exit(1); }
1411
+ const hits = (await api("GET", "/api/agent/history?q=" + encodeURIComponent(q))).messages || [];
1412
+ if (!hits.length) { out("no matches in your past tasks"); break; }
1413
+ out(hits.map((h) => "[" + h.taskName + "] @" + h.author + ": " + h.snippet).join("\\n"));
1414
+ break;
1415
+ }
1416
+ case "task read": {
1417
+ const id = pos[0] || flags.id;
1418
+ if (!id) { console.error("moondeck: task read <taskId> (see 'moondeck task history')"); process.exit(1); }
1419
+ const chat = await api("GET", "/api/agent/history?taskId=" + encodeURIComponent(id));
1420
+ out("# " + chat.taskName + "\\n" + (chat.messages || []).map((m) => "@" + m.author + ": " + m.snippet).join("\\n"));
1421
+ break;
1422
+ }
1423
+ case "task history": {
1424
+ const tasks = (await api("GET", "/api/agent/history")).tasks || [];
1425
+ out(tasks.length ? tasks.map((t) => t.taskId + " " + t.taskName + " (" + t.status + ")").join("\\n") : "no past tasks");
1426
+ break;
1427
+ }
1428
+ case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
1429
+ case "react heart": case "react check": case "react x": {
1430
+ const r = await api("POST", "/api/agent/react", { emoji: action, msg: flags.msg || undefined });
1431
+ out((r.reacted ? "reacted to " : "un-reacted from ") + r.messageId);
1432
+ break;
1433
+ }
1434
+ default:
1435
+ console.error([
1436
+ "moondeck — agent CLI. Commands:",
1437
+ ' moondeck share <path> [--title "text"] (share an immutable file with people; Files plan, workspace quota)',
1438
+ ' moondeck secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]',
1439
+ " Never paste secrets into chat. A person supplies them through the private dialog.",
1440
+ " moondeck artifacts",
1441
+ " moondeck projects list",
1442
+ " moondeck files ls [org|me[/prefix]] (one folder: names, sizes, mtimes, etags; defaults to org)",
1443
+ " moondeck files get <org|me>/<path> [<dest>] (download; default /workspace/files/<org|me>/<path>; -r is a folder, capped at 500 files / 1 GB)",
1444
+ " moondeck people list",
1445
+ " moondeck task list",
1446
+ " moondeck task create --name <n> --prompt <text|-> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]",
1447
+ " moondeck task watch <childId> [--after <cursor>] [--limit N]",
1448
+ " moondeck task usage <childId>",
1449
+ ' moondeck task send <childId> "text"',
1450
+ ' moondeck task answer <childId> <cardId> "option text"',
1451
+ ' moondeck task close [--child <childId>] --status finished|canceled [--reason "final report"]',
1452
+ " moondeck task delete <childId> (delete a draft you created for this task; started children are closed instead)",
1453
+ ' moondeck task rename "<new name>" (rename this running task; its branch keeps its name)',
1454
+ ' moondeck task label add <name> [<name>…] (label this running task; one argument per label, quote "a, b" to keep it whole; missing labels are created)',
1455
+ " moondeck task label remove <name> [<name>…]",
1456
+ " moondeck memory search <query> [--as handle] [--limit N|all]",
1457
+ " moondeck memory save <text> [--project id] [--tags a,b]",
1458
+ " moondeck memory delete <id>",
1459
+ " moondeck todo list",
1460
+ " moondeck todo add <text>",
1461
+ " moondeck todo edit <#id> <text>",
1462
+ " moondeck todo remove <#id>",
1463
+ " moondeck todo claim|done|reopen|unclaim <#id>",
1464
+ ' moondeck branch name <name> (prints uai/<taskId>/<name>; a PR from it links to this task: git switch -c "$(moondeck branch name fix-foo)")',
1465
+ " moondeck review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
1466
+ " moondeck crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
1467
+ " Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
1468
+ " moondeck preview add <name> <containerPort>",
1469
+ " moondeck preview enable|disable <name> (turn it on; prints its URL)",
1470
+ " moondeck preview public|private <name> (public: a link anyone can open)",
1471
+ " moondeck project list | show <id|slug>",
1472
+ " moondeck project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach] (--attach: also add it to this task)",
1473
+ " moondeck project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] (applies to the next task; --repo and the brief are proposals unless you created the repository in this task)",
1474
+ " moondeck project propose-extra <id|slug> --extra-file <f> (Dockerfile additions; a member approves them)",
1475
+ " moondeck project env list|observed <id|slug>",
1476
+ " moondeck project env add <id|slug> NAME [--label <l>] [--secret] (declares a key; values are a member's)",
1477
+ " moondeck project env remove <id|slug> NAME (drops a declaration with no value; never deletes a value; when the workspace allows it)",
1478
+ " moondeck project env propose-proxy <id|slug> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>] (a member confirms)",
1479
+ " moondeck task project add <projectId|slug> [--no-wait] (clone a repository into this running task; waits until ready)",
1480
+ " moondeck task project status <operationId>",
1481
+ " moondeck task search <query> (your own past tasks)",
1482
+ " moondeck task history (list your past tasks)",
1483
+ " moondeck task read <taskId> (full chat of a past task you were on)",
1484
+ ' moondeck dispatch @agent [@agent…] [--kind build|fix|review|research|design|docs|ops] [--size s|m|l] [--pr URL|number] [--request-id key] <instruction|-> (Secretary only)',
1485
+ ' Tags precede the instruction; --pr is a candidate link, not proof of contribution. Reuse --request-id to retry the same logical dispatch.',
1486
+ " Explicit - reads UTF-8 stdin, trimmed; terminal stdin and omitted text are refused.",
1487
+ " For multi-line text or shell syntax use a quoted heredoc (executes nothing):",
1488
+ " moondeck dispatch @agent - <<'EOF'",
1489
+ " Your literal instruction, including quotes and shell syntax.",
1490
+ " EOF",
1491
+ " moondeck task create --name <n> --prompt - reads stdin the same way.",
1492
+ " moondeck crew list (Astronaut only: personas with summary, skills and permissions, engines, models, limits, roster)",
1493
+ " moondeck crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
1494
+ " moondeck crew store (Astronaut only: the Store's persona and skill templates, names and types only)",
1495
+ ' moondeck crew suggest --template <slug> --why "<reason>" (Astronaut only: propose a persona from the Store; a person creates it)',
1496
+ ' moondeck crew suggest --name <n> --brief <text> [--role <r>] [--summary <s>] [--handle <h>] [--engine <kind>] [--model <m>] [--effort <e>] [--skill <slug|id>]... --why "<reason>"',
1497
+ ' moondeck crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] [--account <id|label|default>] --why "<reason>"',
1498
+ ' moondeck crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] [--account <id|label|default>] --why "<reason>"',
1499
+ ' moondeck crew remove @agent --why "<reason>"',
1500
+ " moondeck react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
1501
+ " moondeck whoami",
1502
+ ].join("\\n"));
1503
+ process.exit(argv.length ? 1 : 0);
1504
+ }
1505
+ }
1506
+ main().catch((e) => { console.error("moondeck: " + (e && e.message ? e.message : e)); process.exit(1); });
1507
+ `;