@moondeck/station 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
package/ui/app.js ADDED
@@ -0,0 +1,1102 @@
1
+ /* Local host UI (ADR-028). Vanilla JS, no framework, no build step.
2
+ Polls the read-only JSON API every 2s and renders four panels. */
3
+
4
+ "use strict";
5
+
6
+ const $ = (id) => document.getElementById(id);
7
+ const expanded = new Set(); // task ids whose events are expanded (persist across polls)
8
+ let latestTasks = [];
9
+ let latestEvents = [];
10
+ let latestEngines = null; // { catalog, statuses, cli, accounts } from /api/engines
11
+
12
+ const KIND_CLASS = {
13
+ "task.created": "created",
14
+ "task.started": "started",
15
+ "task.ended": "ended",
16
+ "task.ship": "ship",
17
+ };
18
+
19
+ async function getJSON(path) {
20
+ const res = await fetch(path, { cache: "no-store" });
21
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
22
+ return res.json();
23
+ }
24
+
25
+ async function poll() {
26
+ try {
27
+ const [status, cloud, tasks, events, engines] = await Promise.all([
28
+ getJSON("/api/status"),
29
+ getJSON("/api/cloud"),
30
+ getJSON("/api/tasks"),
31
+ getJSON("/api/events"),
32
+ // Engines are non-fatal: a failure here must not flip the badge to
33
+ // "unreachable" — keep the last known catalog.
34
+ getJSON("/api/engines").catch(() => latestEngines),
35
+ ]);
36
+ renderStatus(status);
37
+ renderCloud(cloud);
38
+ renderEvents(events.events);
39
+ renderTasks(tasks.tasks, events.events);
40
+ renderEngines(engines);
41
+ $("foot-note").textContent =
42
+ `127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
43
+ } catch {
44
+ setBadge("bad", "unreachable");
45
+ }
46
+ }
47
+
48
+ // --- service ----------------------------------------------------------------
49
+
50
+ function renderStatus(s) {
51
+ $("host-name").textContent = s.hostName;
52
+ $("host-sub").textContent = `host monitor · v${s.version}`;
53
+ const kv = $("service-kv");
54
+ kv.replaceChildren();
55
+ addKv(kv, "PID", String(s.pid));
56
+ addKv(kv, "Uptime", fmtDuration(s.uptime * 1000));
57
+ addKv(kv, "Cloud", s.cloudUrl);
58
+ addKv(kv, "Host ID", s.hostId);
59
+ addKv(kv, "Log", s.logPath);
60
+ }
61
+
62
+ function addKv(dl, key, value) {
63
+ const dt = document.createElement("dt");
64
+ dt.textContent = key;
65
+ const dd = document.createElement("dd");
66
+ dd.textContent = value;
67
+ dl.append(dt, dd);
68
+ }
69
+
70
+ // --- connection badge -------------------------------------------------------
71
+
72
+ function renderCloud(c) {
73
+ const map = {
74
+ connected: ["ok", "connected"],
75
+ reconnecting: ["warn", "reconnecting"],
76
+ disconnected: ["bad", "disconnected"],
77
+ };
78
+ const [cls, label] = map[c.state] || ["bad", c.state];
79
+ setBadge(cls, label);
80
+
81
+ const d = $("badge-detail");
82
+ d.replaceChildren();
83
+ d.append(detailRow("Last connected", c.lastConnectedAt ? fmtTime(c.lastConnectedAt) : "never"));
84
+ d.append(detailRow("Reconnects", String(c.reconnectCount)));
85
+ if (c.lastError) {
86
+ const e = document.createElement("div");
87
+ e.className = "err";
88
+ e.textContent = c.lastError;
89
+ d.append(e);
90
+ }
91
+ }
92
+
93
+ function setBadge(cls, label) {
94
+ $("badge-dot").className = "dot " + cls;
95
+ $("badge-label").textContent = label;
96
+ }
97
+
98
+ function detailRow(key, value) {
99
+ const row = document.createElement("div");
100
+ row.className = "row";
101
+ const k = document.createElement("span");
102
+ k.textContent = key;
103
+ const v = document.createElement("span");
104
+ v.textContent = value;
105
+ row.append(k, v);
106
+ return row;
107
+ }
108
+
109
+ // --- tasks ------------------------------------------------------------------
110
+
111
+ function renderTasks(tasks, events) {
112
+ latestTasks = tasks;
113
+ latestEvents = events;
114
+ const wrap = $("tasks");
115
+ $("tasks-count").textContent = tasks.length ? `(${tasks.length})` : "";
116
+ $("tasks-empty").hidden = tasks.length > 0;
117
+ wrap.querySelectorAll(".task").forEach((n) => n.remove());
118
+ for (const t of tasks) wrap.append(taskNode(t, events));
119
+ }
120
+
121
+ function taskNode(t, events) {
122
+ const open = expanded.has(t.taskId);
123
+ const node = document.createElement("div");
124
+ node.className = "task" + (open ? " open" : "");
125
+
126
+ const head = document.createElement("div");
127
+ head.className = "task-head";
128
+ head.addEventListener("click", () => {
129
+ if (expanded.has(t.taskId)) expanded.delete(t.taskId);
130
+ else expanded.add(t.taskId);
131
+ renderTasks(latestTasks, latestEvents);
132
+ });
133
+
134
+ const main = document.createElement("div");
135
+ main.className = "task-main";
136
+
137
+ const title = document.createElement("div");
138
+ title.className = "task-title";
139
+ const owner = document.createElement("span");
140
+ owner.className = "task-owner";
141
+ owner.textContent = t.ownerEmail || "unknown owner";
142
+ title.append(owner);
143
+ for (const slug of t.projectSlugs) {
144
+ const s = document.createElement("span");
145
+ s.className = "slug";
146
+ s.textContent = slug;
147
+ title.append(s);
148
+ }
149
+ if (t.projectSlugs.length === 0) {
150
+ const s = document.createElement("span");
151
+ s.className = "slug";
152
+ s.textContent = "scratchpad";
153
+ title.append(s);
154
+ }
155
+ main.append(title);
156
+
157
+ const meta = document.createElement("div");
158
+ meta.className = "task-meta";
159
+ const bits = [
160
+ t.status || "—",
161
+ t.ageMs != null ? `up ${fmtDuration(t.ageMs)}` : null,
162
+ t.memoryBytes != null ? fmtBytes(t.memoryBytes) : null,
163
+ ].filter(Boolean);
164
+ meta.append(document.createTextNode(bits.join(" · ") + " "));
165
+ const id = document.createElement("span");
166
+ id.className = "task-id";
167
+ id.textContent = t.taskId;
168
+ meta.append(id);
169
+ main.append(meta);
170
+
171
+ const chev = document.createElement("span");
172
+ chev.className = "chev";
173
+ chev.textContent = "›";
174
+
175
+ head.append(main);
176
+ // Stop — pause a running task's containers (resumable). Owner-agnostic: the
177
+ // operator controls what runs on their machine (ADR-028).
178
+ if (t.status === "running" || t.status === "starting") {
179
+ const stop = document.createElement("button");
180
+ stop.className = "link-btn danger task-stop";
181
+ stop.type = "button";
182
+ stop.textContent = "Stop";
183
+ stop.addEventListener("click", async (ev) => {
184
+ ev.stopPropagation();
185
+ if (!confirm(`Stop this task?\n\n${t.taskId}\n\nContainers stop (freeing memory); the worktree is kept so it can be resumed.`)) return;
186
+ stop.disabled = true;
187
+ stop.textContent = "Stopping…";
188
+ try {
189
+ const r = await postJSON("/api/tasks/stop", { taskId: t.taskId });
190
+ if (!r.ok) alert(`Stop failed: ${r.message || "unknown error"}`);
191
+ } catch {
192
+ /* poll re-syncs truth */
193
+ }
194
+ await poll();
195
+ });
196
+ head.append(stop);
197
+ }
198
+ head.append(chev);
199
+ node.append(head);
200
+
201
+ if (open) {
202
+ const ev = document.createElement("div");
203
+ ev.className = "task-events";
204
+ const list = document.createElement("ol");
205
+ const mine = events.filter((e) => e.taskId === t.taskId);
206
+ if (mine.length === 0) {
207
+ const li = document.createElement("li");
208
+ li.className = "empty";
209
+ li.textContent = "No events for this task.";
210
+ list.append(li);
211
+ } else {
212
+ for (const e of mine) {
213
+ const li = document.createElement("li");
214
+ li.textContent = `${e.kind} — ${fmtTime(e.ts)}${e.detail ? " · " + e.detail : ""}`;
215
+ list.append(li);
216
+ }
217
+ }
218
+ ev.append(list);
219
+ node.append(ev);
220
+ }
221
+ return node;
222
+ }
223
+
224
+ // --- events -----------------------------------------------------------------
225
+
226
+ function renderEvents(events) {
227
+ latestEvents = events;
228
+ const ol = $("events");
229
+ ol.querySelectorAll("li:not(.empty)").forEach((n) => n.remove());
230
+ $("events-empty").hidden = events.length > 0;
231
+ for (const e of events) {
232
+ const li = document.createElement("li");
233
+ const kind = document.createElement("span");
234
+ kind.className = "ev-kind " + (KIND_CLASS[e.kind] || "");
235
+ kind.textContent = e.kind;
236
+ const time = document.createElement("span");
237
+ time.className = "ev-time";
238
+ time.textContent = fmtAgo(e.ts);
239
+ const task = document.createElement("span");
240
+ task.className = "ev-task";
241
+ task.textContent = e.taskId;
242
+ li.append(kind, time, task);
243
+ ol.append(li);
244
+ }
245
+ }
246
+
247
+ // --- engines ----------------------------------------------------------------
248
+
249
+ async function postJSON(path, body) {
250
+ const res = await fetch(path, {
251
+ method: "POST",
252
+ headers: { "content-type": "application/json" },
253
+ body: JSON.stringify(body),
254
+ });
255
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
256
+ return res.json();
257
+ }
258
+
259
+ function engineByKind(kind) {
260
+ return (latestEngines?.catalog || []).find((e) => e.kind === kind) || null;
261
+ }
262
+
263
+ function monogram(label) {
264
+ const m = document.createElement("span");
265
+ m.className = "engine-mono";
266
+ m.textContent = (label || "?").trim().charAt(0).toUpperCase();
267
+ return m;
268
+ }
269
+
270
+ function renderEngines(data) {
271
+ if (data) latestEngines = data;
272
+ const wrap = $("engines");
273
+ if (!wrap) return;
274
+ const catalog = latestEngines?.catalog || [];
275
+ const statuses = latestEngines?.statuses || {};
276
+ const connected = catalog.filter((e) => statuses[e.kind]);
277
+
278
+ $("engines-count").textContent = connected.length ? `(${connected.length})` : "";
279
+ $("engines-empty").hidden = connected.length > 0;
280
+ wrap.querySelectorAll(".engine-card").forEach((n) => n.remove());
281
+ for (const e of connected) wrap.append(engineCard(e));
282
+ }
283
+
284
+ function engineCard(e) {
285
+ const card = document.createElement("div");
286
+ card.className = "engine-card";
287
+
288
+ const head = document.createElement("div");
289
+ head.className = "engine-head";
290
+ head.append(monogram(e.label));
291
+ const meta = document.createElement("div");
292
+ meta.className = "engine-meta";
293
+ const name = document.createElement("div");
294
+ name.className = "engine-name";
295
+ name.textContent = e.label;
296
+ const sub = document.createElement("div");
297
+ sub.className = "engine-sub ok";
298
+ sub.textContent = "Connected";
299
+ meta.append(name, sub);
300
+ head.append(meta);
301
+ card.append(head);
302
+
303
+ // ADR-076: multiple accounts per engine. Rendered for account-model kinds
304
+ // (claude/codex/opencode). Extra accounts can be removed; claude/codex can
305
+ // add one by pasting a token / API key.
306
+ const accounts = (latestEngines?.accounts || {})[e.kind] || [];
307
+ const extras = accounts.filter((a) => !a.isDefault);
308
+ const canAdd = e.kind === "claude" || e.kind === "codex";
309
+ if (accounts.length > 0 && (extras.length > 0 || canAdd)) {
310
+ card.append(engineAccounts(e, accounts, canAdd));
311
+ }
312
+
313
+ const btn = document.createElement("button");
314
+ btn.className = "link-btn danger";
315
+ btn.type = "button";
316
+ btn.textContent = "Disconnect";
317
+ btn.addEventListener("click", async () => {
318
+ btn.disabled = true;
319
+ btn.textContent = "Disconnecting…";
320
+ try {
321
+ await postJSON("/api/engines/disconnect", { kind: e.kind });
322
+ } catch {
323
+ /* poll re-syncs truth */
324
+ }
325
+ await poll();
326
+ });
327
+ card.append(btn);
328
+ return card;
329
+ }
330
+
331
+ /** Accounts sub-panel for an engine card (ADR-076). */
332
+ function engineAccounts(e, accounts, canAdd) {
333
+ const box = document.createElement("div");
334
+ box.className = "engine-accounts";
335
+
336
+ const title = document.createElement("div");
337
+ title.className = "engine-sub";
338
+ title.textContent = `Accounts (${accounts.length}) — rotated automatically`;
339
+ box.append(title);
340
+
341
+ for (const a of accounts) {
342
+ const row = document.createElement("div");
343
+ row.className = "engine-account-row";
344
+ const name = document.createElement("span");
345
+ name.textContent = a.isDefault ? `${a.label} (default)` : a.label;
346
+ row.append(name);
347
+ if (!a.isDefault) {
348
+ const rm = document.createElement("button");
349
+ rm.className = "link-btn danger";
350
+ rm.type = "button";
351
+ rm.textContent = "Remove";
352
+ rm.addEventListener("click", async () => {
353
+ rm.disabled = true;
354
+ try {
355
+ await postJSON("/api/engines/accounts/remove", { id: a.id });
356
+ } catch {
357
+ /* poll re-syncs truth */
358
+ }
359
+ await poll();
360
+ });
361
+ row.append(rm);
362
+ }
363
+ box.append(row);
364
+ }
365
+
366
+ if (canAdd) {
367
+ const add = document.createElement("button");
368
+ add.className = "link-btn";
369
+ add.type = "button";
370
+ add.textContent = "+ Add account";
371
+ add.addEventListener("click", () => openAddAccount(e));
372
+ box.append(add);
373
+ }
374
+ return box;
375
+ }
376
+
377
+ /**
378
+ * Add-account modal (ADR-076). Replaces the old back-to-back window.prompt()
379
+ * flow: a single-line prompt hid what you pasted and silently truncated long
380
+ * `claude setup-token` values, which then surfaced downstream as a 401 on the
381
+ * rotated account. A real form (visible multi-line textarea + inline
382
+ * validation) makes a bad paste obvious before it's saved.
383
+ */
384
+ function openAddAccount(e) {
385
+ $("engine-modal-title").textContent = `Add ${e.label} account`;
386
+ const body = $("engine-modal-body");
387
+ body.replaceChildren();
388
+ body.append(addAccountForm(e));
389
+ openModal();
390
+ }
391
+
392
+ function addAccountForm(e) {
393
+ const isToken = e.authMode === "token-command"; // Claude: browser sign-in
394
+ const form = document.createElement("div");
395
+ form.className = "engine-setup";
396
+
397
+ const note = document.createElement("p");
398
+ note.className = "setup-note";
399
+ note.textContent = isToken
400
+ ? "Sign in with the account you want to add — a browser opens to authorize Claude and the token is captured here automatically. This account rotates in automatically when another Claude account hits its limit."
401
+ : "Paste an OpenAI API key (starts with sk-…). This account rotates in automatically when another account of this kind hits its limit.";
402
+ form.append(note);
403
+
404
+ const labelField = document.createElement("div");
405
+ labelField.className = "field";
406
+ const labelInput = document.createElement("input");
407
+ labelInput.type = "text";
408
+ labelInput.className = "text-input";
409
+ labelInput.placeholder = "Label (e.g. work)";
410
+ labelInput.autocomplete = "off";
411
+ labelInput.spellcheck = false;
412
+ labelField.append(labelInput);
413
+ form.append(labelField);
414
+
415
+ const status = document.createElement("div");
416
+ status.className = "setup-status";
417
+ const log = document.createElement("pre");
418
+ log.className = "log";
419
+ log.hidden = true;
420
+ const pushLine = (line) => {
421
+ log.textContent += (log.textContent ? "\n" : "") + line;
422
+ log.scrollTop = log.scrollHeight;
423
+ };
424
+ const fail = (msg) => {
425
+ status.className = "setup-status err";
426
+ status.textContent = msg;
427
+ };
428
+
429
+ const actions = document.createElement("div");
430
+ actions.className = "setup-actions";
431
+
432
+ // --- Automatic sign-in (token-command / Claude) --------------------------
433
+ if (isToken) {
434
+ const signIn = document.createElement("button");
435
+ signIn.className = "btn";
436
+ signIn.type = "button";
437
+ signIn.textContent = "Sign in with Claude";
438
+ signIn.addEventListener("click", async () => {
439
+ const label = labelInput.value.trim();
440
+ if (!label) return fail("Give the account a label first.");
441
+ signIn.disabled = true;
442
+ signIn.textContent = "Signing in…";
443
+ status.className = "setup-status";
444
+ status.textContent = "A browser will open to authorize Claude…";
445
+ log.hidden = false;
446
+ log.textContent = "";
447
+ const result = await runStream(
448
+ "/api/engines/accounts/connect",
449
+ { kind: e.kind, label },
450
+ pushLine,
451
+ );
452
+ if (result.ok) {
453
+ await poll();
454
+ closeModal();
455
+ return;
456
+ }
457
+ signIn.disabled = false;
458
+ signIn.textContent = "Try again";
459
+ fail(result.message || "Sign-in failed.");
460
+ });
461
+ actions.append(signIn);
462
+ }
463
+
464
+ const cancel = document.createElement("button");
465
+ cancel.className = "link-btn";
466
+ cancel.type = "button";
467
+ cancel.textContent = "Cancel";
468
+ cancel.addEventListener("click", closeModal);
469
+ actions.append(cancel);
470
+ form.append(actions, status, log);
471
+
472
+ // --- Manual fallback: copyable command + paste box -----------------------
473
+ if (isToken) {
474
+ const or = document.createElement("p");
475
+ or.className = "setup-note setup-or";
476
+ or.textContent =
477
+ "Can't use the browser here? Run this in a terminal, then paste the token:";
478
+ form.append(or, copyRow("claude setup-token"));
479
+ }
480
+
481
+ const secretField = document.createElement("div");
482
+ secretField.className = "field";
483
+ const secretInput = document.createElement("textarea");
484
+ secretInput.className = "text-input token-area";
485
+ secretInput.rows = 3;
486
+ secretInput.placeholder = isToken ? "Claude token or API key" : "OpenAI API key";
487
+ secretInput.autocomplete = "off";
488
+ secretInput.spellcheck = false;
489
+ secretField.append(secretInput);
490
+ form.append(secretField);
491
+
492
+ const pasteActions = document.createElement("div");
493
+ pasteActions.className = "setup-actions";
494
+ const save = document.createElement("button");
495
+ save.className = isToken ? "link-btn" : "btn";
496
+ save.type = "button";
497
+ save.textContent = isToken ? "Add pasted token" : "Add account";
498
+ save.addEventListener("click", async () => {
499
+ const label = labelInput.value.trim();
500
+ const secret = secretInput.value.trim();
501
+ if (!label) return fail("Give the account a label.");
502
+ if (!secret) return fail("Paste a token or API key.");
503
+ // Mirrors the host-side check — a token with internal whitespace/newlines is
504
+ // a mangled paste, not a value; catch it here so it never becomes a 401.
505
+ if (/\s/.test(secret)) {
506
+ return fail("That value has spaces or line breaks — paste just the token.");
507
+ }
508
+ save.disabled = true;
509
+ save.textContent = "Adding…";
510
+ const payload =
511
+ e.kind === "claude"
512
+ ? { kind: e.kind, label, token: secret }
513
+ : { kind: e.kind, label, apiKey: secret };
514
+ let result;
515
+ try {
516
+ result = await postJSON("/api/engines/accounts/add", payload);
517
+ } catch {
518
+ result = { ok: false, message: "Couldn't reach the host." };
519
+ }
520
+ if (result && result.ok) {
521
+ await poll();
522
+ closeModal();
523
+ return;
524
+ }
525
+ save.disabled = false;
526
+ save.textContent = isToken ? "Add pasted token" : "Add account";
527
+ fail((result && result.message) || "Couldn't add the account.");
528
+ });
529
+ pasteActions.append(save);
530
+ form.append(pasteActions);
531
+
532
+ setTimeout(() => labelInput.focus(), 0);
533
+ return form;
534
+ }
535
+
536
+ /** A monospace command line with a Copy button (writes to the clipboard). */
537
+ function copyRow(cmd) {
538
+ const row = document.createElement("div");
539
+ row.className = "copy-row";
540
+ const code = document.createElement("code");
541
+ code.className = "copy-cmd";
542
+ code.textContent = cmd;
543
+ const btn = document.createElement("button");
544
+ btn.className = "link-btn";
545
+ btn.type = "button";
546
+ btn.textContent = "Copy";
547
+ btn.addEventListener("click", async () => {
548
+ try {
549
+ await navigator.clipboard.writeText(cmd);
550
+ btn.textContent = "Copied";
551
+ setTimeout(() => {
552
+ btn.textContent = "Copy";
553
+ }, 1500);
554
+ } catch {
555
+ // Clipboard API blocked (non-secure context) — select the text so the
556
+ // user can copy it manually.
557
+ const range = document.createRange();
558
+ range.selectNodeContents(code);
559
+ const sel = window.getSelection();
560
+ sel.removeAllRanges();
561
+ sel.addRange(range);
562
+ }
563
+ });
564
+ row.append(code, btn);
565
+ return row;
566
+ }
567
+
568
+ // --- add-engine modal -------------------------------------------------------
569
+
570
+ function openModal() {
571
+ $("engine-modal").hidden = false;
572
+ }
573
+ function closeModal() {
574
+ $("engine-modal").hidden = true;
575
+ $("engine-modal-body").replaceChildren();
576
+ $("engine-modal-title").textContent = "Add engine";
577
+ }
578
+
579
+ function openAddEngine() {
580
+ $("engine-modal-title").textContent = "Add engine";
581
+ const body = $("engine-modal-body");
582
+ body.replaceChildren();
583
+
584
+ const catalog = latestEngines?.catalog || [];
585
+ const statuses = latestEngines?.statuses || {};
586
+ const available = catalog.filter((e) => !statuses[e.kind]);
587
+
588
+ if (available.length === 0) {
589
+ const p = document.createElement("p");
590
+ p.className = "empty";
591
+ p.textContent = "All engines are connected.";
592
+ body.append(p);
593
+ openModal();
594
+ return;
595
+ }
596
+
597
+ const list = document.createElement("div");
598
+ list.className = "engine-picker";
599
+ for (const e of available) {
600
+ const row = document.createElement("button");
601
+ row.className = "engine-pick";
602
+ row.type = "button";
603
+ row.append(monogram(e.label));
604
+ const meta = document.createElement("div");
605
+ meta.className = "engine-meta";
606
+ const name = document.createElement("div");
607
+ name.className = "engine-name";
608
+ name.textContent = e.label;
609
+ if (e.notes) {
610
+ const sub = document.createElement("div");
611
+ sub.className = "engine-sub";
612
+ sub.textContent = e.notes;
613
+ meta.append(name, sub);
614
+ } else {
615
+ meta.append(name);
616
+ }
617
+ row.append(meta);
618
+ const chev = document.createElement("span");
619
+ chev.className = "chev";
620
+ chev.textContent = "›";
621
+ row.append(chev);
622
+ row.addEventListener("click", () => setupEngine(e));
623
+ list.append(row);
624
+ }
625
+ body.append(list);
626
+ openModal();
627
+ }
628
+
629
+ function setupEngine(e) {
630
+ $("engine-modal-title").textContent = `Connect ${e.label}`;
631
+ const body = $("engine-modal-body");
632
+ body.replaceChildren();
633
+
634
+ if (e.authMode === "api-key") {
635
+ body.append(apiKeyForm(e));
636
+ } else if (e.authMode === "external-login") {
637
+ body.append(externalLoginForm(e));
638
+ } else {
639
+ body.append(commandForm(e));
640
+ }
641
+
642
+ const back = document.createElement("button");
643
+ back.className = "link-btn back";
644
+ back.type = "button";
645
+ back.textContent = "‹ Back";
646
+ back.addEventListener("click", openAddEngine);
647
+ body.append(back);
648
+ }
649
+
650
+ /** api-key mode (Cursor): a masked field + Save + "get a key" link. */
651
+ function apiKeyForm(e) {
652
+ const form = document.createElement("div");
653
+ form.className = "engine-setup";
654
+
655
+ if (e.notes) {
656
+ const note = document.createElement("p");
657
+ note.className = "setup-note";
658
+ note.textContent = e.notes;
659
+ form.append(note);
660
+ }
661
+
662
+ const field = document.createElement("div");
663
+ field.className = "field";
664
+ const input = document.createElement("input");
665
+ input.type = "password";
666
+ input.className = "text-input";
667
+ input.placeholder = `${e.label} API key`;
668
+ input.autocomplete = "off";
669
+ input.spellcheck = false;
670
+ field.append(input);
671
+ form.append(field);
672
+
673
+ const status = document.createElement("div");
674
+ status.className = "setup-status";
675
+ form.append(status);
676
+
677
+ const actions = document.createElement("div");
678
+ actions.className = "setup-actions";
679
+ const save = document.createElement("button");
680
+ save.className = "btn";
681
+ save.type = "button";
682
+ save.textContent = "Save";
683
+ save.addEventListener("click", async () => {
684
+ const apiKey = input.value.trim();
685
+ if (!apiKey) {
686
+ status.className = "setup-status err";
687
+ status.textContent = "Paste a key first.";
688
+ return;
689
+ }
690
+ save.disabled = true;
691
+ save.textContent = "Saving…";
692
+ const result = await runConnect({ kind: e.kind, apiKey });
693
+ if (result.ok) {
694
+ await poll();
695
+ closeModal();
696
+ } else {
697
+ save.disabled = false;
698
+ save.textContent = "Save";
699
+ status.className = "setup-status err";
700
+ status.textContent = result.message || "Couldn't save the key.";
701
+ }
702
+ });
703
+ actions.append(save);
704
+ if (e.getKeyUrl) {
705
+ const link = document.createElement("a");
706
+ link.className = "link-btn";
707
+ link.href = e.getKeyUrl;
708
+ link.target = "_blank";
709
+ link.rel = "noreferrer";
710
+ link.textContent = "Get a key";
711
+ actions.append(link);
712
+ }
713
+ form.append(actions);
714
+ return form;
715
+ }
716
+
717
+ /**
718
+ * external-login mode (OpenCode): the engine's own CLI is an interactive TUI we
719
+ * can't drive here, so the owner runs it in their terminal and we just detect
720
+ * the credential it wrote. Install (if the CLI is missing) → run the login
721
+ * command → "Check connection".
722
+ */
723
+ function externalLoginForm(e) {
724
+ const form = document.createElement("div");
725
+ form.className = "engine-setup";
726
+
727
+ if (e.notes) {
728
+ const note = document.createElement("p");
729
+ note.className = "setup-note";
730
+ note.textContent = e.notes;
731
+ form.append(note);
732
+ }
733
+
734
+ const status = document.createElement("div");
735
+ status.className = "setup-status";
736
+ const log = document.createElement("pre");
737
+ log.className = "log";
738
+ log.hidden = true;
739
+ const pushLine = (line) => {
740
+ log.textContent += (log.textContent ? "\n" : "") + line;
741
+ log.scrollTop = log.scrollHeight;
742
+ };
743
+
744
+ const cliMissing =
745
+ !!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
746
+
747
+ // Step 1 (only when the CLI is absent): install it.
748
+ const installWrap = document.createElement("div");
749
+ if (cliMissing) {
750
+ const installNote = document.createElement("p");
751
+ installNote.className = "setup-note";
752
+ installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
753
+ const installCmd = document.createElement("pre");
754
+ installCmd.className = "log";
755
+ installCmd.textContent = e.installHint;
756
+ const install = document.createElement("button");
757
+ install.className = "btn";
758
+ install.type = "button";
759
+ install.textContent = "Install";
760
+ install.addEventListener("click", async () => {
761
+ install.disabled = true;
762
+ install.textContent = "Installing…";
763
+ status.className = "setup-status";
764
+ status.textContent = "Running the installer…";
765
+ log.hidden = false;
766
+ log.textContent = "";
767
+ const result = await runInstall({ kind: e.kind }, pushLine);
768
+ if (result.ok) {
769
+ await poll();
770
+ installWrap.hidden = true;
771
+ status.className = "setup-status";
772
+ status.textContent = "Installed. Now sign in from your terminal below.";
773
+ } else {
774
+ install.disabled = false;
775
+ install.textContent = "Try again";
776
+ status.className = "setup-status err";
777
+ status.textContent = result.message || "Install failed.";
778
+ }
779
+ });
780
+ installWrap.append(installNote, installCmd, install);
781
+ } else {
782
+ installWrap.hidden = true;
783
+ }
784
+ form.append(installWrap);
785
+
786
+ // Step 2: the command the owner runs in their own terminal.
787
+ if (e.loginCmd) {
788
+ const runNote = document.createElement("p");
789
+ runNote.className = "setup-note";
790
+ runNote.textContent = "Run this in your terminal to sign in:";
791
+ const cmd = document.createElement("pre");
792
+ cmd.className = "log";
793
+ cmd.textContent = e.loginCmd;
794
+ form.append(runNote, cmd);
795
+ }
796
+
797
+ // Step 3: re-detect what login wrote.
798
+ const actions = document.createElement("div");
799
+ actions.className = "setup-actions";
800
+ const check = document.createElement("button");
801
+ check.className = "btn";
802
+ check.type = "button";
803
+ check.textContent = "Check connection";
804
+ check.addEventListener("click", async () => {
805
+ check.disabled = true;
806
+ check.textContent = "Checking…";
807
+ status.className = "setup-status";
808
+ status.textContent = "Looking for your credentials…";
809
+ const result = await runConnect({ kind: e.kind });
810
+ if (result.ok) {
811
+ await poll();
812
+ closeModal();
813
+ } else {
814
+ check.disabled = false;
815
+ check.textContent = "Check again";
816
+ status.className = "setup-status err";
817
+ status.textContent = result.message || "Not signed in yet.";
818
+ }
819
+ });
820
+ actions.append(check);
821
+ form.append(actions, status, log);
822
+ return form;
823
+ }
824
+
825
+ /** token/login mode: a Connect button + a live log; Claude adds a paste link. */
826
+ function commandForm(e) {
827
+ const form = document.createElement("div");
828
+ form.className = "engine-setup";
829
+
830
+ if (e.notes) {
831
+ const note = document.createElement("p");
832
+ note.className = "setup-note";
833
+ note.textContent = e.notes;
834
+ form.append(note);
835
+ }
836
+
837
+ const status = document.createElement("div");
838
+ status.className = "setup-status";
839
+ const log = document.createElement("pre");
840
+ log.className = "log";
841
+ log.hidden = true;
842
+
843
+ const pushLine = (line) => {
844
+ log.textContent += (log.textContent ? "\n" : "") + line;
845
+ log.scrollTop = log.scrollHeight;
846
+ };
847
+
848
+ const actions = document.createElement("div");
849
+ actions.className = "setup-actions";
850
+ const connect = document.createElement("button");
851
+ connect.className = "btn";
852
+ connect.type = "button";
853
+ connect.textContent = "Connect";
854
+ connect.addEventListener("click", async () => {
855
+ connect.disabled = true;
856
+ connect.textContent = "Connecting…";
857
+ status.className = "setup-status";
858
+ status.textContent =
859
+ e.authMode === "token-command"
860
+ ? "Your browser will open to authorize…"
861
+ : "Your browser will open to sign in…";
862
+ log.hidden = false;
863
+ log.textContent = "";
864
+ const result = await runConnect({ kind: e.kind }, pushLine);
865
+ if (result.ok) {
866
+ await poll();
867
+ closeModal();
868
+ } else {
869
+ connect.disabled = false;
870
+ connect.textContent = "Try again";
871
+ status.className = "setup-status err";
872
+ status.textContent = result.message || "Connect failed.";
873
+ }
874
+ });
875
+ actions.append(connect);
876
+
877
+ // CLI missing → offer the pinned installer first, chaining into connect on
878
+ // success. The command is shown verbatim; its output streams into the log.
879
+ const cliMissing =
880
+ !!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
881
+ if (cliMissing) {
882
+ connect.hidden = true;
883
+ const installNote = document.createElement("p");
884
+ installNote.className = "setup-note";
885
+ installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
886
+ const installCmd = document.createElement("pre");
887
+ installCmd.className = "log";
888
+ installCmd.textContent = e.installHint;
889
+ const install = document.createElement("button");
890
+ install.className = "btn";
891
+ install.type = "button";
892
+ install.textContent = "Install & connect";
893
+ install.addEventListener("click", async () => {
894
+ install.disabled = true;
895
+ install.textContent = "Installing…";
896
+ status.className = "setup-status";
897
+ status.textContent = "Running the installer…";
898
+ log.hidden = false;
899
+ log.textContent = "";
900
+ const result = await runInstall({ kind: e.kind }, pushLine);
901
+ if (result.ok) {
902
+ await poll();
903
+ install.hidden = true;
904
+ connect.hidden = false;
905
+ connect.click();
906
+ } else {
907
+ install.disabled = false;
908
+ install.textContent = "Try again";
909
+ status.className = "setup-status err";
910
+ status.textContent = result.message || "Install failed.";
911
+ }
912
+ });
913
+ actions.prepend(install);
914
+ form.append(installNote, installCmd);
915
+ }
916
+
917
+ form.append(actions, status, log);
918
+
919
+ // Manual paste fallback for when the browser flow isn't possible: Claude
920
+ // takes a token or API key (one box, classified server-side); login-command
921
+ // engines with an apiKeyHint (Codex, Grok) take an API key.
922
+ const isToken = e.authMode === "token-command";
923
+ if (isToken || e.apiKeyHint) {
924
+ const saveLabel = isToken ? "Save token" : "Save key";
925
+ const pasteToggle = document.createElement("button");
926
+ pasteToggle.className = "link-btn";
927
+ pasteToggle.type = "button";
928
+ pasteToggle.textContent = isToken
929
+ ? "Paste a token or API key instead"
930
+ : "Use an API key instead";
931
+ const pasteWrap = document.createElement("div");
932
+ pasteWrap.className = "engine-setup";
933
+ pasteWrap.hidden = true;
934
+ const pInput = document.createElement("input");
935
+ pInput.type = "password";
936
+ pInput.className = "text-input";
937
+ pInput.placeholder = e.apiKeyHint || "sk-ant-…";
938
+ pInput.autocomplete = "off";
939
+ pInput.spellcheck = false;
940
+ const pField = document.createElement("div");
941
+ pField.className = "field";
942
+ pField.append(pInput);
943
+ const pStatus = document.createElement("div");
944
+ pStatus.className = "setup-status";
945
+ const pSave = document.createElement("button");
946
+ pSave.className = "btn";
947
+ pSave.type = "button";
948
+ pSave.textContent = saveLabel;
949
+ pSave.addEventListener("click", async () => {
950
+ const value = pInput.value.trim();
951
+ if (!value) {
952
+ pStatus.className = "setup-status err";
953
+ pStatus.textContent = isToken ? "Paste a token first." : "Paste a key first.";
954
+ return;
955
+ }
956
+ pSave.disabled = true;
957
+ pSave.textContent = "Saving…";
958
+ const body = isToken
959
+ ? { kind: e.kind, pastedToken: value }
960
+ : { kind: e.kind, apiKey: value };
961
+ const result = await runConnect(body);
962
+ if (result.ok) {
963
+ await poll();
964
+ closeModal();
965
+ } else {
966
+ pSave.disabled = false;
967
+ pSave.textContent = saveLabel;
968
+ pStatus.className = "setup-status err";
969
+ pStatus.textContent = result.message || "Couldn't save it.";
970
+ }
971
+ });
972
+ const pActions = document.createElement("div");
973
+ pActions.className = "setup-actions";
974
+ pActions.append(pSave);
975
+ if (e.apiKeyUrl) {
976
+ const link = document.createElement("a");
977
+ link.className = "link-btn";
978
+ link.href = e.apiKeyUrl;
979
+ link.target = "_blank";
980
+ link.rel = "noreferrer";
981
+ link.textContent = "Get a key";
982
+ pActions.append(link);
983
+ }
984
+ pasteWrap.append(pField, pActions, pStatus);
985
+ pasteToggle.addEventListener("click", () => {
986
+ pasteWrap.hidden = !pasteWrap.hidden;
987
+ });
988
+ form.append(pasteToggle, pasteWrap);
989
+ }
990
+ return form;
991
+ }
992
+
993
+ /** POST /api/engines/connect and consume the NDJSON stream. */
994
+ function runConnect(body, onLine) {
995
+ return runStream("/api/engines/connect", body, onLine);
996
+ }
997
+
998
+ /** POST /api/engines/install and consume the NDJSON stream. */
999
+ function runInstall(body, onLine) {
1000
+ return runStream("/api/engines/install", body, onLine);
1001
+ }
1002
+
1003
+ /**
1004
+ * POST an NDJSON-streaming endpoint: `{line}` frames feed onLine; the final
1005
+ * `{done,ok,message}` frame is the result.
1006
+ */
1007
+ async function runStream(path, body, onLine) {
1008
+ let res;
1009
+ try {
1010
+ res = await fetch(path, {
1011
+ method: "POST",
1012
+ headers: { "content-type": "application/json" },
1013
+ body: JSON.stringify(body),
1014
+ });
1015
+ } catch (err) {
1016
+ return { ok: false, message: String(err) };
1017
+ }
1018
+ if (!res.ok || !res.body) {
1019
+ return { ok: false, message: `request failed (${res.status})` };
1020
+ }
1021
+ const reader = res.body.getReader();
1022
+ const dec = new TextDecoder();
1023
+ let buf = "";
1024
+ let result = null;
1025
+ for (;;) {
1026
+ const { value, done } = await reader.read();
1027
+ if (done) break;
1028
+ buf += dec.decode(value, { stream: true });
1029
+ let nl;
1030
+ while ((nl = buf.indexOf("\n")) >= 0) {
1031
+ const raw = buf.slice(0, nl);
1032
+ buf = buf.slice(nl + 1);
1033
+ if (!raw.trim()) continue;
1034
+ let msg;
1035
+ try {
1036
+ msg = JSON.parse(raw);
1037
+ } catch {
1038
+ continue;
1039
+ }
1040
+ if (typeof msg.line === "string" && onLine) onLine(msg.line);
1041
+ if (msg.done) result = { ok: !!msg.ok, message: msg.message || "" };
1042
+ }
1043
+ }
1044
+ return result || { ok: false, message: "Connection ended unexpectedly." };
1045
+ }
1046
+
1047
+ // --- formatting -------------------------------------------------------------
1048
+
1049
+ function fmtDuration(ms) {
1050
+ const s = Math.floor(ms / 1000);
1051
+ if (s < 60) return `${s}s`;
1052
+ const m = Math.floor(s / 60);
1053
+ if (m < 60) return `${m}m ${s % 60}s`;
1054
+ const h = Math.floor(m / 60);
1055
+ if (h < 24) return `${h}h ${m % 60}m`;
1056
+ const d = Math.floor(h / 24);
1057
+ return `${d}d ${h % 24}h`;
1058
+ }
1059
+
1060
+ function fmtAgo(epochMs) {
1061
+ const diff = Date.now() - epochMs;
1062
+ if (diff < 60_000) return "just now";
1063
+ return fmtDuration(diff) + " ago";
1064
+ }
1065
+
1066
+ function fmtTime(epochMs) {
1067
+ return new Date(epochMs).toLocaleString();
1068
+ }
1069
+
1070
+ function fmtBytes(n) {
1071
+ if (n < 1024) return `${n} B`;
1072
+ const units = ["KB", "MB", "GB", "TB"];
1073
+ let v = n / 1024;
1074
+ let i = 0;
1075
+ while (v >= 1024 && i < units.length - 1) {
1076
+ v /= 1024;
1077
+ i += 1;
1078
+ }
1079
+ return `${v.toFixed(1)} ${units[i]}`;
1080
+ }
1081
+
1082
+ // --- wire up ----------------------------------------------------------------
1083
+
1084
+ $("badge").addEventListener("click", () => {
1085
+ const detail = $("badge-detail");
1086
+ const show = detail.hidden;
1087
+ detail.hidden = !show;
1088
+ $("badge").setAttribute("aria-expanded", String(show));
1089
+ });
1090
+
1091
+ $("add-engine").addEventListener("click", openAddEngine);
1092
+ $("engine-modal-close").addEventListener("click", closeModal);
1093
+ $("engine-modal").addEventListener("click", (e) => {
1094
+ // Click the backdrop (not the dialog) to dismiss.
1095
+ if (e.target === $("engine-modal")) closeModal();
1096
+ });
1097
+ document.addEventListener("keydown", (e) => {
1098
+ if (e.key === "Escape" && !$("engine-modal").hidden) closeModal();
1099
+ });
1100
+
1101
+ poll();
1102
+ setInterval(poll, 2000);