@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,918 @@
1
+ /**
2
+ * ClaudeSession — a real AgentSession backed by the Claude Code CLI in
3
+ * stream-json mode, run inside the task environment (ADR-010).
4
+ *
5
+ * claude --print --input-format stream-json --output-format stream-json \
6
+ * --verbose --no-session-persistence
7
+ *
8
+ * The CLI is a persistent bidirectional process: uai writes one JSON
9
+ * line per user turn to stdin and reads a stream of JSON event lines
10
+ * from stdout. Same binary, same subscription auth as the TUI — not the
11
+ * paid API (docs/interaction-model.md).
12
+ *
13
+ * Protocol mapping lives in the pure `mapClaudeLine` function so it can
14
+ * be unit-tested against fixture lines without spawning anything.
15
+ *
16
+ * ⚠️ VERIFY-ON-MAC: the exact stream-json envelope — especially the
17
+ * permission/control-request shape — must be checked against the
18
+ * installed `claude` build. The mapping is isolated here precisely so
19
+ * those fixups stay in one place.
20
+ */
21
+
22
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
23
+ import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
24
+ import { newId } from "../ulid";
25
+ import { createAgentTransport, type LineTransport } from "./transport";
26
+ import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
27
+ import { register } from "./registry";
28
+ import { CLAUDE_MODELS } from "../../src/engine-models";
29
+ import {
30
+ DISPATCH_INPUT_SCHEMA,
31
+ DISPATCH_RECIPIENT_ID_PATTERN,
32
+ DISPATCH_TOOL_NAME,
33
+ MAX_DISPATCH_INSTRUCTION_CHARS,
34
+ MAX_DISPATCH_RECIPIENT_ID_CHARS,
35
+ MAX_DISPATCH_RECIPIENTS,
36
+ parseDispatchInput,
37
+ type DispatchInput,
38
+ } from "./dispatch";
39
+ import { extractResultUsage } from "./usage";
40
+ import type {
41
+ AgentEvent,
42
+ AgentEventHandler,
43
+ AgentAccountUsage,
44
+ AgentKind,
45
+ AgentSession,
46
+ RosterAgent,
47
+ } from "./types";
48
+
49
+ // Published Claude model aliases the CLI accepts via `--model`. The CLI
50
+ // resolves these aliases to the current dated snapshots, so this list is
51
+ // stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
52
+ // retired alias). Order is display order in the cloud picker.
53
+ // The model list is host-agent/src/engine-models.ts (CLAUDE_MODELS).
54
+
55
+ // Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
56
+ // Fable 5, the Mythos-class flagship) is opt-in per agent. Update
57
+ // alongside CLAUDE_MODELS.
58
+ const CLAUDE_DEFAULT_MODEL = "opus";
59
+
60
+ // Reasoning levels passed through via `claude --effort <level>`. Taken from
61
+ // the CLI's `--help`: `--effort <level>` accepts (low, medium, high, xhigh,
62
+ // max). UPDATE WHEN THE CLI CHANGES its effort levels.
63
+ const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
64
+
65
+ // High is the default reasoning level. Update alongside CLAUDE_EFFORTS.
66
+ const CLAUDE_DEFAULT_EFFORT = "high";
67
+
68
+ // ADR-083: cheap defaults apply at the enforcement boundary as well as in the
69
+ // picker. Explicit per-agent choices still win; an API client that omits them
70
+ // must not accidentally run the communicator on Claude's costly account
71
+ // defaults merely because it bypassed the web recommendation button.
72
+ const CLAUDE_COMMUNICATOR_MODEL = "haiku";
73
+ const CLAUDE_COMMUNICATOR_EFFORT = "low";
74
+
75
+ /**
76
+ * ADR-083's one host-owned communicator tool. The server is deliberately
77
+ * dependency-free and rides in the adapter argv so an updated host can add it
78
+ * to an already-built task image. It performs no routing itself: Claude's
79
+ * structured tool_use block is the durable runner output, and ClaudeSession
80
+ * correlates its successful tool_result into the typed AgentEvent the
81
+ * orchestrator forwards.
82
+ *
83
+ * Keeping the MCP server a constant also keeps the strict config independent
84
+ * of project/user MCP files. The communicator gets exactly this server, never
85
+ * the task's arbitrary extension set.
86
+ */
87
+ export const CLAUDE_DISPATCH_TOOL = `mcp__uai__${DISPATCH_TOOL_NAME}`;
88
+
89
+ export const CLAUDE_DISPATCH_MCP_SOURCE = String.raw`
90
+ import { createInterface } from "node:readline";
91
+
92
+ const RECIPIENT_ID = new RegExp(${JSON.stringify(DISPATCH_RECIPIENT_ID_PATTERN)});
93
+
94
+ const TOOL = {
95
+ name: "dispatch",
96
+ title: "Dispatch work to Moondeck crew agents",
97
+ description:
98
+ "Send one concrete instruction to one or more Moondeck crew agents. Use this instead of naming agents in prose; prose stays in the normal human-facing lane.",
99
+ inputSchema: ${JSON.stringify(DISPATCH_INPUT_SCHEMA)},
100
+ };
101
+
102
+ const write = (value) => process.stdout.write(JSON.stringify(value) + "\n");
103
+ const ok = (id, result) => write({ jsonrpc: "2.0", id, result });
104
+ const fail = (id, code, message) =>
105
+ write({ jsonrpc: "2.0", id, error: { code, message } });
106
+ const validArguments = (value) =>
107
+ value &&
108
+ typeof value === "object" &&
109
+ !Array.isArray(value) &&
110
+ Array.isArray(value.recipients) &&
111
+ value.recipients.length > 0 &&
112
+ value.recipients.length <= ${MAX_DISPATCH_RECIPIENTS} &&
113
+ value.recipients.every(
114
+ (recipient) =>
115
+ typeof recipient === "string" &&
116
+ recipient.length <= ${MAX_DISPATCH_RECIPIENT_ID_CHARS} &&
117
+ RECIPIENT_ID.test(recipient),
118
+ ) &&
119
+ new Set(value.recipients).size === value.recipients.length &&
120
+ typeof value.instruction === "string" &&
121
+ value.instruction.trim().length > 0 &&
122
+ value.instruction.length <= ${MAX_DISPATCH_INSTRUCTION_CHARS};
123
+
124
+ createInterface({ input: process.stdin, crlfDelay: Infinity }).on(
125
+ "line",
126
+ (line) => {
127
+ let message;
128
+ try {
129
+ message = JSON.parse(line);
130
+ } catch {
131
+ return;
132
+ }
133
+ if (!message || typeof message !== "object" || !("id" in message)) return;
134
+ if (message.method === "initialize") {
135
+ ok(message.id, {
136
+ protocolVersion: message.params?.protocolVersion ?? "2025-06-18",
137
+ capabilities: { tools: {} },
138
+ serverInfo: { name: "uai-dispatch", version: "1" },
139
+ });
140
+ return;
141
+ }
142
+ if (message.method === "ping") {
143
+ ok(message.id, {});
144
+ return;
145
+ }
146
+ if (message.method === "tools/list") {
147
+ ok(message.id, { tools: [TOOL] });
148
+ return;
149
+ }
150
+ if (message.method === "tools/call") {
151
+ if (
152
+ message.params?.name !== "dispatch" ||
153
+ !validArguments(message.params?.arguments)
154
+ ) {
155
+ ok(message.id, {
156
+ content: [{ type: "text", text: "Dispatch rejected: provide 1-${MAX_DISPATCH_RECIPIENTS} unique recipient ids and a non-empty instruction." }],
157
+ isError: true,
158
+ });
159
+ return;
160
+ }
161
+ ok(message.id, {
162
+ content: [{ type: "text", text: "Dispatch request accepted by the host; Moondeck will validate recipients and delivery." }],
163
+ });
164
+ return;
165
+ }
166
+ fail(message.id, -32601, "Method not found");
167
+ },
168
+ );
169
+ `;
170
+
171
+ const CLAUDE_DISPATCH_MCP_CONFIG = JSON.stringify({
172
+ mcpServers: {
173
+ uai: {
174
+ command: "node",
175
+ args: ["--input-type=module", "--eval", CLAUDE_DISPATCH_MCP_SOURCE],
176
+ // The trusted bridge needs no task/provider credentials. Override the
177
+ // secrets the host intentionally forwards to Claude instead of enabling
178
+ // Claude's global subprocess scrub, which hard-requires bubblewrap and
179
+ // makes the CLI abort in existing task images that do not contain it.
180
+ env: {
181
+ CLAUDE_CODE_OAUTH_TOKEN: "",
182
+ ANTHROPIC_API_KEY: "",
183
+ ANTHROPIC_AUTH_TOKEN: "",
184
+ UAI_TASK_TOKEN: "",
185
+ },
186
+ // Claude defers MCP schemas by default. Dispatch is a core action named
187
+ // in the preamble, so it must be connected and visible on prompt one.
188
+ alwaysLoad: true,
189
+ },
190
+ },
191
+ });
192
+
193
+ const CLAUDE_COMMUNICATOR_SETTINGS = JSON.stringify({
194
+ disableAllHooks: true,
195
+ enabledPlugins: {},
196
+ });
197
+
198
+ /**
199
+ * `--safe-mode` cannot be used here: Claude 2.1.220 disables even an explicit
200
+ * `--mcp-config` in safe mode, which makes the host-owned dispatch tool
201
+ * disappear. Recreate the customization lockdown while leaving that one
202
+ * strict MCP server available. These values override any agent/account env.
203
+ */
204
+ const CLAUDE_COMMUNICATOR_ENV: Record<string, string> = {
205
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
206
+ CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
207
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
208
+ CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1",
209
+ CLAUDE_CODE_DISABLE_POLICY_SKILLS: "1",
210
+ CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
211
+ CLAUDE_CODE_SKIP_PLUGIN_MCP_SERVERS: "1",
212
+ CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1",
213
+ ENABLE_CLAUDEAI_MCP_SERVERS: "false",
214
+ // Some hosts export this globally. Claude's current implementation aborts
215
+ // when it is enabled but the task image has no bubblewrap binary.
216
+ CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "",
217
+ // The enforced profile dispatches through its host-owned MCP event, not the
218
+ // task CLI. Keep the bearer token and API origin out of the Claude process
219
+ // itself (not merely its MCP child), where Read could otherwise inspect
220
+ // /proc/self/environ and recover a permission-null task credential.
221
+ UAI_TASK_TOKEN: "",
222
+ UAI_API_URL: "",
223
+ };
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Pure protocol mapping — stream-json line → AgentEvent[].
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /** Narrow an unknown to a plain object. */
230
+ function isObj(v: unknown): v is Record<string, unknown> {
231
+ return typeof v === "object" && v !== null;
232
+ }
233
+
234
+ function parseLineObject(raw: string): Record<string, unknown> | null {
235
+ try {
236
+ const value: unknown = JSON.parse(raw);
237
+ return isObj(value) ? value : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Map one stream-json stdout line to zero or more AgentEvents.
245
+ *
246
+ * Contract:
247
+ * - `stream_event` content_block_delta(text) → `message_delta`
248
+ * - `assistant` message tool_use blocks → `tool_call`
249
+ * - `result` → `message_complete` + `turn_complete`
250
+ * - control/permission request → `permission_request`
251
+ * - anything else → [] (ignored)
252
+ *
253
+ * A malformed line yields []. The mapper is intentionally total and
254
+ * side-effect free.
255
+ */
256
+ export function mapClaudeLine(raw: string, now: number = Date.now()): AgentEvent[] {
257
+ const json = parseLineObject(raw);
258
+ if (!json) return [];
259
+
260
+ const type = json.type;
261
+
262
+ // Claude Code 2.1.306 emits this exact stream-json envelope. Its embedded
263
+ // schema defines reset timestamps as Unix seconds and utilization as a
264
+ // fraction (normally 0..1).
265
+ if (type === "rate_limit_event" && isObj(json.rate_limit_info)) {
266
+ const usage = mapClaudeRateLimitInfo(json.rate_limit_info, now);
267
+ return usage ? [{ type: "account_usage", usage }] : [];
268
+ }
269
+
270
+ // --- streaming text deltas --------------------------------------------
271
+ if (type === "stream_event" && isObj(json.event)) {
272
+ const event = json.event;
273
+ if (
274
+ event.type === "content_block_delta" &&
275
+ isObj(event.delta) &&
276
+ event.delta.type === "text_delta" &&
277
+ typeof event.delta.text === "string"
278
+ ) {
279
+ return [{ type: "message_delta", text: event.delta.text }];
280
+ }
281
+ return [];
282
+ }
283
+
284
+ // --- full assistant message: harvest tool calls -----------------------
285
+ if (type === "assistant" && isObj(json.message)) {
286
+ const content = json.message.content;
287
+ if (!Array.isArray(content)) return [];
288
+ const out: AgentEvent[] = [];
289
+ for (const block of content) {
290
+ if (isObj(block) && block.type === "tool_use") {
291
+ const name = typeof block.name === "string" ? block.name : "tool";
292
+ if (name === CLAUDE_DISPATCH_TOOL) {
293
+ // Dispatch is intentionally absent here. Claude emits this assistant
294
+ // block before it calls the MCP server; ClaudeSession correlates the
295
+ // later tool_result and emits only after the call succeeds.
296
+ continue;
297
+ }
298
+ out.push({
299
+ type: "tool_call",
300
+ id: typeof block.id === "string" ? block.id : newId(),
301
+ title: name,
302
+ detail: safeStringify(block.input),
303
+ });
304
+ }
305
+ }
306
+ return out;
307
+ }
308
+
309
+ // --- turn result ------------------------------------------------------
310
+ if (type === "result") {
311
+ const text = typeof json.result === "string" ? json.result : "";
312
+ const usage = extractResultUsage(json);
313
+ if (json.is_error === true) {
314
+ // An errored turn still cost tokens — meter it.
315
+ const message = text || "claude returned an error";
316
+ const accountUsage = rateLimitErrorUsage(message, now);
317
+ return [
318
+ ...(accountUsage
319
+ ? [{ type: "account_usage", usage: accountUsage } as const]
320
+ : []),
321
+ // ADR-076: a rate-limit/usage-cap error is failed over to another
322
+ // account by the orchestrator rather than surfaced to the user.
323
+ accountUsage
324
+ ? {
325
+ type: "error",
326
+ message,
327
+ retryable: "rate_limit",
328
+ ...(accountUsage.resetsAt === undefined
329
+ ? {}
330
+ : { resetsAt: accountUsage.resetsAt }),
331
+ }
332
+ : { type: "error", message },
333
+ { type: "turn_complete", usage },
334
+ ];
335
+ }
336
+ return [
337
+ { type: "message_complete", text },
338
+ { type: "turn_complete", usage },
339
+ ];
340
+ }
341
+
342
+ // --- permission / control request -------------------------------------
343
+ // ⚠️ VERIFY-ON-MAC: confirm this envelope against the real CLI.
344
+ if (type === "control_request" && isObj(json.request)) {
345
+ const req = json.request;
346
+ if (req.subtype === "can_use_tool") {
347
+ return [
348
+ {
349
+ type: "permission_request",
350
+ id: typeof json.request_id === "string" ? json.request_id : newId(),
351
+ title:
352
+ typeof req.tool_name === "string"
353
+ ? `Allow ${req.tool_name}?`
354
+ : "Permission requested",
355
+ detail: safeStringify(req.input),
356
+ },
357
+ ];
358
+ }
359
+ return [];
360
+ }
361
+
362
+ return [];
363
+ }
364
+
365
+ const CLAUDE_WINDOW_MINUTES: Readonly<Record<string, number>> = {
366
+ five_hour: 5 * 60,
367
+ seven_day: 7 * 24 * 60,
368
+ seven_day_opus: 7 * 24 * 60,
369
+ seven_day_sonnet: 7 * 24 * 60,
370
+ seven_day_overage_included: 7 * 24 * 60,
371
+ };
372
+
373
+ function utilizationPercent(value: unknown): number | undefined {
374
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
375
+ return undefined;
376
+ }
377
+ return Math.min(100, value * 100);
378
+ }
379
+
380
+ /** Map Claude Code 2.1.306's embedded `RateLimitInfo` schema. */
381
+ export function mapClaudeRateLimitInfo(
382
+ info: Record<string, unknown>,
383
+ observedAt: number = Date.now(),
384
+ ): AgentAccountUsage | null {
385
+ const status =
386
+ info.status === "allowed"
387
+ ? "allowed"
388
+ : info.status === "allowed_warning"
389
+ ? "warning"
390
+ : info.status === "rejected"
391
+ ? "limited"
392
+ : null;
393
+ if (!status) return null;
394
+
395
+ const windows: AgentAccountUsage["windows"] = [];
396
+ const seen = new Set<string>();
397
+ if (isObj(info.unifiedWindows)) {
398
+ for (const window of [
399
+ "five_hour",
400
+ "seven_day",
401
+ "seven_day_overage_included",
402
+ ] as const) {
403
+ const value = info.unifiedWindows[window];
404
+ if (!isObj(value)) continue;
405
+ const usedPercent = utilizationPercent(value.utilization);
406
+ const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
407
+ windows.push({
408
+ window,
409
+ windowMinutes: CLAUDE_WINDOW_MINUTES[window],
410
+ ...(usedPercent === undefined ? {} : { usedPercent }),
411
+ ...(resetsAt === undefined ? {} : { resetsAt }),
412
+ });
413
+ seen.add(window);
414
+ }
415
+ }
416
+
417
+ const rateLimitType =
418
+ typeof info.rateLimitType === "string" &&
419
+ info.rateLimitType.length > 0 &&
420
+ info.rateLimitType.length <= 64
421
+ ? info.rateLimitType
422
+ : undefined;
423
+ const resetsAt = resetSecondsToEpochMs(info.resetsAt, observedAt);
424
+ if (rateLimitType && !seen.has(rateLimitType)) {
425
+ const usedPercent = utilizationPercent(info.utilization);
426
+ windows.push({
427
+ window: rateLimitType,
428
+ ...(CLAUDE_WINDOW_MINUTES[rateLimitType] === undefined
429
+ ? {}
430
+ : { windowMinutes: CLAUDE_WINDOW_MINUTES[rateLimitType] }),
431
+ ...(usedPercent === undefined ? {} : { usedPercent }),
432
+ ...(resetsAt === undefined ? {} : { resetsAt }),
433
+ });
434
+ }
435
+
436
+ return {
437
+ status,
438
+ ...(resetsAt === undefined ? {} : { resetsAt }),
439
+ ...(typeof info.isUsingOverage === "boolean"
440
+ ? { usingOverage: info.isUsingOverage }
441
+ : {}),
442
+ windows,
443
+ source: "claude-rate-limit-event",
444
+ observedAt,
445
+ ...(rateLimitType === undefined
446
+ ? {}
447
+ : { coverage: { kind: "window" as const, window: rateLimitType } }),
448
+ };
449
+ }
450
+
451
+ function safeStringify(v: unknown): string {
452
+ if (v === undefined) return "";
453
+ if (typeof v === "string") return v;
454
+ try {
455
+ return JSON.stringify(v, null, 2);
456
+ } catch {
457
+ return String(v);
458
+ }
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // The session.
463
+ // ---------------------------------------------------------------------------
464
+
465
+ const CLAUDE_BASE_ARGS = [
466
+ "--print",
467
+ "--input-format",
468
+ "stream-json",
469
+ "--output-format",
470
+ "stream-json",
471
+ "--verbose",
472
+ // uai owns durable chat persistence. Claude Code's JSON transcript
473
+ // files are an implementation detail and can contain redacted
474
+ // thinking blocks. Replaying those from disk has caused Anthropic API
475
+ // 400s when a later turn sees a changed thinking block, so keep each
476
+ // managed stream-json process in-memory only.
477
+ "--no-session-persistence",
478
+ ];
479
+
480
+ const CLAUDE_FULL_ACCESS_ARGS = [
481
+ ...CLAUDE_BASE_ARGS,
482
+ // Full tool access, no per-call prompts. Safe here precisely because
483
+ // a uai task runs in a throwaway, isolated container operating on a
484
+ // disposable worktree (ADR-001 / ADR-010) — the container *is* the
485
+ // sandbox. Without this, stream-json has no interactive approver and
486
+ // every Write/Bash silently self-denies.
487
+ "--dangerously-skip-permissions",
488
+ // MCP servers come from the uai-managed file, EXPLICITLY (ADR-057/061).
489
+ // Discovered project .mcp.json servers sit behind a per-server approval
490
+ // that headless --print can never answer ("⏸ Pending approval"), which
491
+ // silently loaded a stale subset. --mcp-config bypasses the approval
492
+ // gate; --strict-mcp-config keeps stray host-copied user configs out.
493
+ // setupMcpTaskConfig always writes this file (empty when no connections)
494
+ // before sessions spawn.
495
+ "--mcp-config",
496
+ "/workspace/.mcp.json",
497
+ "--strict-mcp-config",
498
+ ];
499
+
500
+ /**
501
+ * ADR-157 Astronaut profile: the normal tool set, minus Claude Code's native
502
+ * subagents. An Astronaut's helpers must be crew agents the task can see and
503
+ * bill; a built-in Agent/Task subagent would run (and spend) outside the crew.
504
+ */
505
+ const CLAUDE_ASTRONAUT_ARGS = [
506
+ ...CLAUDE_FULL_ACCESS_ARGS,
507
+ "--disallowedTools",
508
+ "Agent,Task",
509
+ ];
510
+
511
+ /**
512
+ * ADR-083 communicator profile. This is deliberately an engine-level tool
513
+ * boundary, not prompt advice: setting sources, hooks, skills, workflows and
514
+ * plug-in MCP are suppressed; the explicit tool set contains no shell or
515
+ * mutation primitive; `dontAsk` denies anything outside it; and strict MCP
516
+ * exposes only Uai's host-owned dispatch tool.
517
+ */
518
+ const CLAUDE_COMMUNICATOR_ARGS = [
519
+ ...CLAUDE_BASE_ARGS,
520
+ "--setting-sources",
521
+ "",
522
+ "--settings",
523
+ CLAUDE_COMMUNICATOR_SETTINGS,
524
+ "--disable-slash-commands",
525
+ "--no-chrome",
526
+ "--permission-mode",
527
+ "dontAsk",
528
+ "--tools",
529
+ "Read,Glob,Grep",
530
+ "--allowedTools",
531
+ CLAUDE_DISPATCH_TOOL,
532
+ "--disallowedTools",
533
+ "Bash,Edit,Write,NotebookEdit,Agent,Task,WebFetch,WebSearch",
534
+ "--mcp-config",
535
+ CLAUDE_DISPATCH_MCP_CONFIG,
536
+ "--strict-mcp-config",
537
+ ];
538
+
539
+ export class ClaudeSession implements AgentSession {
540
+ readonly agentId: string;
541
+ readonly kind: AgentKind = "claude";
542
+
543
+ private readonly proc: LineTransport;
544
+ private readonly handlers = new Set<AgentEventHandler>();
545
+ private readonly pendingDispatches = new Map<string, DispatchInput>();
546
+ private readonly settledDispatches = new Set<string>();
547
+ private closed = false;
548
+ // Claude's `total_cost_usd` is CUMULATIVE across the persistent session
549
+ // process (ADR-061 keeps one claude alive across turns), so per-turn cost is
550
+ // the delta from the previous turn — otherwise summing turns over-counts
551
+ // wildly (live 2026-07-22: a 4-turn task billed ~4x). Token counts, by
552
+ // contrast, are per-turn already. A fresh CLI starts at zero; attaching an
553
+ // existing durable CLI restores its counter at the saved outbox checkpoint.
554
+ // Undefined means the bounded history could not prove a baseline: skip one
555
+ // estimate instead of rebilling the session's whole previous spend.
556
+ private lastCumulativeCostUsd: number | undefined = 0;
557
+ private costSessionId: string | undefined;
558
+ private readonly costBySession = new Map<string, number>();
559
+
560
+ constructor(args: {
561
+ taskId: string;
562
+ agent: RosterAgent;
563
+ environment: TaskEnvironmentAgentSessionSurface;
564
+ systemPreamble: string;
565
+ executionProfile?: "communicator" | "astronaut";
566
+ attachCompatibilityKey?: string;
567
+ agentEnv?: Record<string, string>;
568
+ claudeSettings?: Record<string, unknown>;
569
+ }) {
570
+ this.agentId = args.agent.id;
571
+
572
+ // The uai channel briefing (how to @-mention, the agent roster, the
573
+ // project's defaultPrompt) is passed as a real system prompt via
574
+ // `--append-system-prompt`, so it applies to every turn — not
575
+ // smuggled into the first user message.
576
+ const cliArgs = [
577
+ ...(args.executionProfile === "communicator"
578
+ ? CLAUDE_COMMUNICATOR_ARGS
579
+ : args.executionProfile === "astronaut"
580
+ ? CLAUDE_ASTRONAUT_ARGS
581
+ : CLAUDE_FULL_ACCESS_ARGS),
582
+ ];
583
+ if (args.claudeSettings) {
584
+ // Empty helper settings and fallbackModel override inherited values.
585
+ // Preserve native settings sources, including project hooks/plugins.
586
+ // Connection env separately pins routing with PROVIDER_MANAGED_BY_HOST.
587
+ const settings = {
588
+ ...(args.executionProfile === "communicator" ? JSON.parse(CLAUDE_COMMUNICATOR_SETTINGS) as Record<string, unknown> : {}),
589
+ ...args.claudeSettings,
590
+ };
591
+ cliArgs.push("--settings", JSON.stringify(settings));
592
+ }
593
+ if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
594
+ cliArgs.push("--include-partial-messages");
595
+ }
596
+ // Explicit task configuration wins; the communicator uses its declared
597
+ // fast/cheap defaults when the task left either choice unspecified.
598
+ const model =
599
+ args.agent.model ??
600
+ (args.executionProfile === "communicator"
601
+ ? CLAUDE_COMMUNICATOR_MODEL
602
+ : undefined);
603
+ if (model) {
604
+ cliArgs.push("--model", model);
605
+ }
606
+ const effort =
607
+ args.agent.effort ??
608
+ (args.executionProfile === "communicator"
609
+ ? CLAUDE_COMMUNICATOR_EFFORT
610
+ : undefined);
611
+ if (effort) {
612
+ cliArgs.push("--effort", effort);
613
+ }
614
+ if (args.systemPreamble.trim().length > 0) {
615
+ cliArgs.push("--append-system-prompt", args.systemPreamble);
616
+ }
617
+ // Forward host-resident Claude auth into the headless exec. Headless
618
+ // `--print` does NOT use the interactive subscription/keychain path, so
619
+ // it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
620
+ // API key. These live in the host-agent's env (never the cloud, ADR-015);
621
+ // only ones actually set are forwarded.
622
+ // ADR-061: durable by default — the CLI is owned by an in-container
623
+ // runner and survives host restarts (attach resumes it); legacy pipes
624
+ // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
625
+ // live runner can be re-attached (allowAttach). A communicator profile is
626
+ // never attached, and the orchestrator also supplies a persisted policy
627
+ // compatibility key for normal-tools Secretaries. That second fence makes
628
+ // the reverse rolling upgrade safe: an old restricted runner cannot be
629
+ // inherited after the default policy changes back to normal tools.
630
+ this.proc = createAgentTransport({
631
+ taskId: args.taskId,
632
+ agentId: this.agentId,
633
+ environment: args.environment,
634
+ cli: "claude",
635
+ cliArgs,
636
+ passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
637
+ explicitEnv:
638
+ args.executionProfile === "communicator"
639
+ ? { ...(args.agentEnv ?? {}), ...CLAUDE_COMMUNICATOR_ENV }
640
+ : (args.agentEnv ?? {}),
641
+ allowAttach: args.executionProfile !== "communicator",
642
+ attachCompatibilityKey: args.attachCompatibilityKey,
643
+ kind: "claude",
644
+ debugLabel: `claude:${this.agentId}`,
645
+ restoreProtocolState: {
646
+ accepts: (line) => {
647
+ const json = parseLineObject(line);
648
+ return json?.type === "result" && this.validCumulativeCost(json) !== undefined;
649
+ },
650
+ restore: (lines, complete) => {
651
+ this.lastCumulativeCostUsd = complete ? 0 : undefined;
652
+ this.costSessionId = undefined;
653
+ this.costBySession.clear();
654
+ for (const line of lines) {
655
+ const json = parseLineObject(line);
656
+ if (json) this.perTurnCost({ type: "turn_complete", usage: { costUsd: this.validCumulativeCost(json) } }, json);
657
+ }
658
+ },
659
+ },
660
+ });
661
+ this.proc.onLine((line) => {
662
+ for (const event of this.dispatchEventsForLine(line)) this.emit(event);
663
+ const json = parseLineObject(line);
664
+ for (const event of mapClaudeLine(line)) this.emit(this.perTurnCost(event, json));
665
+ });
666
+ this.proc.onExit((code) => {
667
+ if (this.closed) return;
668
+ const tail = this.proc.stderrTail.trim();
669
+ // Surface an error event when:
670
+ // - the process exited non-zero, OR
671
+ // - it exited 0 but stderr says "Claude configuration file not
672
+ // found". That message means Claude silently bailed because
673
+ // its config was unlinked (Docker Desktop macOS atomic-write
674
+ // race). The orchestrator handles this by repairing the
675
+ // config and respawning, but only if it sees the error event.
676
+ const configMissing =
677
+ /Claude configuration file not found/i.test(tail);
678
+ if (code !== 0 || configMissing) {
679
+ this.emit({
680
+ type: "error",
681
+ message:
682
+ `claude process exited (${code ?? "spawn failed"})` +
683
+ (tail
684
+ ? `:\n${tail}`
685
+ : " — no stderr. Is the claude CLI installed in the task container, and is the container running?"),
686
+ });
687
+ }
688
+ this.clearDispatches();
689
+ this.closed = true;
690
+ this.emit({ type: "exit", code: code ?? -1 });
691
+ });
692
+ }
693
+
694
+ onEvent(handler: AgentEventHandler): () => void {
695
+ this.handlers.add(handler);
696
+ return () => this.handlers.delete(handler);
697
+ }
698
+
699
+ private emit(event: AgentEvent): void {
700
+ if (this.closed && event.type !== "exit") return;
701
+ for (const h of this.handlers) h(event);
702
+ }
703
+
704
+ /**
705
+ * Claude writes an assistant tool_use before invoking MCP, then a user
706
+ * tool_result carrying the same id. Waking crew on the first line would turn
707
+ * a rejected or failed tool call into real work, so hold validated input
708
+ * until the matching result succeeds. The settled set makes replayed output
709
+ * idempotent for the remainder of the turn.
710
+ */
711
+ private dispatchEventsForLine(raw: string): AgentEvent[] {
712
+ const json = parseLineObject(raw);
713
+ if (!json) return [];
714
+
715
+ if (json.type === "assistant" && isObj(json.message)) {
716
+ const content = json.message.content;
717
+ if (!Array.isArray(content)) return [];
718
+ for (const block of content) {
719
+ if (
720
+ !isObj(block) ||
721
+ block.type !== "tool_use" ||
722
+ block.name !== CLAUDE_DISPATCH_TOOL ||
723
+ typeof block.id !== "string" ||
724
+ this.settledDispatches.has(block.id)
725
+ ) {
726
+ continue;
727
+ }
728
+ const input = parseDispatchInput(block.input);
729
+ if (input) this.pendingDispatches.set(block.id, input);
730
+ else this.pendingDispatches.delete(block.id);
731
+ }
732
+ return [];
733
+ }
734
+
735
+ if (json.type === "user" && isObj(json.message)) {
736
+ const content = json.message.content;
737
+ if (!Array.isArray(content)) return [];
738
+ const events: AgentEvent[] = [];
739
+ for (const block of content) {
740
+ if (
741
+ !isObj(block) ||
742
+ block.type !== "tool_result" ||
743
+ typeof block.tool_use_id !== "string"
744
+ ) {
745
+ continue;
746
+ }
747
+ const pending = this.pendingDispatches.get(block.tool_use_id);
748
+ if (!pending) continue;
749
+ this.pendingDispatches.delete(block.tool_use_id);
750
+ this.settledDispatches.add(block.tool_use_id);
751
+ if (block.is_error !== true) {
752
+ events.push({
753
+ type: "dispatch",
754
+ dispatchId: block.tool_use_id,
755
+ ...pending,
756
+ });
757
+ }
758
+ }
759
+ return events;
760
+ }
761
+
762
+ if (json.type === "result") this.clearDispatches();
763
+ return [];
764
+ }
765
+
766
+ private clearDispatches(): void {
767
+ this.pendingDispatches.clear();
768
+ this.settledDispatches.clear();
769
+ }
770
+
771
+ private validCumulativeCost(json: Record<string, unknown>): number | undefined {
772
+ const cost = json.total_cost_usd;
773
+ // A synthetic crash result may carry zeroed counters. It cannot erase a
774
+ // prior baseline or make the following result rebill the whole session.
775
+ return typeof cost === "number" && Number.isFinite(cost) && cost >= 0 &&
776
+ !(cost === 0 && json.is_error === true) ? cost : undefined;
777
+ }
778
+
779
+ /** SDK streaming results carry session-cumulative estimated USD; usage
780
+ * tokens are per-turn. /clear, /reset and /new change session_id and reset
781
+ * cost even when the new counter already exceeds the old one. */
782
+ private perTurnCost(event: AgentEvent, json: Record<string, unknown> | null): AgentEvent {
783
+ if (event.type !== "turn_complete" || event.usage?.costUsd === undefined) {
784
+ return event;
785
+ }
786
+ const cumulative = json && this.validCumulativeCost(json);
787
+ const withoutCost = () => {
788
+ const { costUsd: _cost, ...usage } = event.usage!;
789
+ return { ...event, usage };
790
+ };
791
+ if (cumulative === null || cumulative === undefined) return withoutCost();
792
+ const sessionId = typeof json?.session_id === "string" ? json.session_id : undefined;
793
+ const previous = sessionId !== undefined && this.costBySession.has(sessionId)
794
+ ? this.costBySession.get(sessionId)
795
+ : sessionId !== undefined && this.costSessionId !== undefined && sessionId !== this.costSessionId
796
+ ? 0 : this.lastCumulativeCostUsd;
797
+ // A decreasing counter in the same identified session is stale/replayed
798
+ // output, not proof of a reset. Keep the last known baseline.
799
+ if (previous !== undefined && cumulative < previous && sessionId !== undefined && this.costBySession.has(sessionId)) {
800
+ return withoutCost();
801
+ }
802
+ this.lastCumulativeCostUsd = cumulative;
803
+ this.costSessionId = sessionId ?? this.costSessionId;
804
+ if (sessionId !== undefined) this.costBySession.set(sessionId, cumulative);
805
+ if (previous === undefined) return withoutCost();
806
+ const delta = cumulative - previous;
807
+ // Older result envelopes without session_id retain the counter-reset
808
+ // fallback; current SDK frames identify that boundary explicitly.
809
+ return {
810
+ ...event,
811
+ usage: { ...event.usage, costUsd: delta >= 0 ? delta : cumulative },
812
+ };
813
+ }
814
+
815
+ async send(text: string): Promise<void> {
816
+ if (this.closed) return;
817
+ // The transport can outlive the command that created it. Gate each new
818
+ // turn at its actual runner boundary, while still allowing interrupt and
819
+ // close traffic during runtime recovery.
820
+ requireContainerRuntimeOperational();
821
+ this.proc.writeLine({
822
+ type: "user",
823
+ message: { role: "user", content: text },
824
+ });
825
+ }
826
+
827
+ async interrupt(): Promise<void> {
828
+ if (this.closed) return;
829
+ // Claude Code's stream-json control channel: an `interrupt` control-request
830
+ // stops the in-flight turn (the SDK's ESC). Safe to send when idle — the
831
+ // CLI acks with a control_response we don't need to track.
832
+ // ⚠️ VERIFY-ON-MAC: confirm the control-request `interrupt` envelope.
833
+ this.proc.writeLine({
834
+ type: "control_request",
835
+ request_id: newId(),
836
+ request: { subtype: "interrupt" },
837
+ });
838
+ }
839
+
840
+ async resolvePermission(
841
+ requestId: string,
842
+ decision: "accept" | "decline",
843
+ ): Promise<void> {
844
+ if (this.closed) return;
845
+ // ⚠️ VERIFY-ON-MAC: confirm the control-response envelope.
846
+ this.proc.writeLine({
847
+ type: "control_response",
848
+ request_id: requestId,
849
+ response: {
850
+ subtype: "can_use_tool",
851
+ behavior: decision === "accept" ? "allow" : "deny",
852
+ },
853
+ });
854
+ }
855
+
856
+ async close(): Promise<void> {
857
+ if (this.closed) {
858
+ await this.proc.close();
859
+ return;
860
+ }
861
+ this.closed = true;
862
+ this.clearDispatches();
863
+ await this.proc.close();
864
+ this.emit({ type: "exit", code: 0 });
865
+ this.handlers.clear();
866
+ }
867
+ }
868
+
869
+ // Register the Claude adapter at module load (ADR-021). The `--model`
870
+ // pass-through above means `model` flows from the roster entry to the CLI.
871
+ register({
872
+ kind: "claude",
873
+ label: "Claude",
874
+ supportedModels: () => [...CLAUDE_MODELS],
875
+ defaultModel: CLAUDE_DEFAULT_MODEL,
876
+ supportedEfforts: () => [...CLAUDE_EFFORTS],
877
+ defaultEffort: CLAUDE_DEFAULT_EFFORT,
878
+ executionProfiles: [
879
+ {
880
+ id: "communicator",
881
+ mechanism: "claude-explicit-tool-boundary-v2",
882
+ defaultModel: CLAUDE_COMMUNICATOR_MODEL,
883
+ defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
884
+ },
885
+ {
886
+ id: "astronaut",
887
+ mechanism: "claude-disallowed-agent-task-v1",
888
+ },
889
+ ],
890
+ // Usable only when a Claude credential is in the env (injected into task
891
+ // containers at task-up). Gates advertisement (ADR-044 P2).
892
+ available: () =>
893
+ Boolean(
894
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ||
895
+ process.env.ANTHROPIC_API_KEY ||
896
+ process.env.ANTHROPIC_AUTH_TOKEN,
897
+ ),
898
+ create: async ({
899
+ taskId,
900
+ agent,
901
+ environment,
902
+ systemPreamble,
903
+ executionProfile,
904
+ attachCompatibilityKey,
905
+ agentEnv,
906
+ claudeSettings,
907
+ }) =>
908
+ new ClaudeSession({
909
+ taskId,
910
+ agent,
911
+ environment,
912
+ systemPreamble,
913
+ executionProfile,
914
+ attachCompatibilityKey,
915
+ agentEnv,
916
+ claudeSettings,
917
+ }),
918
+ });