@loompad/cli 0.1.0 → 1.1.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.
- package/ARCHITECTURE.md +96 -11
- package/CHANGELOG.md +1042 -0
- package/README.md +1473 -44
- package/THIRD_PARTY_NOTICES.md +275 -0
- package/dist/adapters/antigravity-cli.d.ts +62 -0
- package/dist/adapters/antigravity-cli.js +258 -0
- package/dist/adapters/antigravity-cli.js.map +1 -0
- package/dist/adapters/base.d.ts +61 -9
- package/dist/adapters/base.js +266 -15
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/bridges/antigravity.d.ts +9 -12
- package/dist/adapters/bridges/antigravity.js +9 -12
- package/dist/adapters/bridges/antigravity.js.map +1 -1
- package/dist/adapters/echo.d.ts +1 -0
- package/dist/adapters/echo.js +59 -3
- package/dist/adapters/echo.js.map +1 -1
- package/dist/adapters/grok.d.ts +7 -0
- package/dist/adapters/grok.js +20 -10
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/index.d.ts +5 -1
- package/dist/adapters/index.js +53 -7
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/model.d.ts +137 -0
- package/dist/adapters/model.js +527 -0
- package/dist/adapters/model.js.map +1 -0
- package/dist/adapters/opencode.d.ts +101 -1
- package/dist/adapters/opencode.js +874 -98
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/cli/doctor.d.ts +32 -0
- package/dist/cli/doctor.js +89 -4
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +2651 -87
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/tui-model.js +3 -1
- package/dist/cli/tui-model.js.map +1 -1
- package/dist/cli/tui.js +44 -1
- package/dist/cli/tui.js.map +1 -1
- package/dist/cli/ui.d.ts +2 -0
- package/dist/cli/ui.js +13 -0
- package/dist/cli/ui.js.map +1 -1
- package/dist/core/ades.d.ts +32 -16
- package/dist/core/ades.js +57 -20
- package/dist/core/ades.js.map +1 -1
- package/dist/core/agent-state.d.ts +14 -0
- package/dist/core/agent-state.js +32 -0
- package/dist/core/agent-state.js.map +1 -0
- package/dist/core/approvals.d.ts +33 -0
- package/dist/core/approvals.js +29 -0
- package/dist/core/approvals.js.map +1 -0
- package/dist/core/backup.d.ts +19 -0
- package/dist/core/backup.js +86 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/baton.d.ts +2 -2
- package/dist/core/baton.js.map +1 -1
- package/dist/core/brain-extract.d.ts +17 -0
- package/dist/core/brain-extract.js +28 -2
- package/dist/core/brain-extract.js.map +1 -1
- package/dist/core/brain-index.d.ts +71 -0
- package/dist/core/brain-index.js +204 -5
- package/dist/core/brain-index.js.map +1 -1
- package/dist/core/brain.d.ts +58 -2
- package/dist/core/brain.js +79 -1
- package/dist/core/brain.js.map +1 -1
- package/dist/core/chat-import.d.ts +71 -0
- package/dist/core/chat-import.js +419 -0
- package/dist/core/chat-import.js.map +1 -0
- package/dist/core/checkpoint.d.ts +107 -0
- package/dist/core/checkpoint.js +259 -0
- package/dist/core/checkpoint.js.map +1 -0
- package/dist/core/claude-cli.js +1 -1
- package/dist/core/claude-cli.js.map +1 -1
- package/dist/core/continuity/artifacts.d.ts +14 -0
- package/dist/core/continuity/artifacts.js +118 -0
- package/dist/core/continuity/artifacts.js.map +1 -0
- package/dist/core/continuity/capabilities.d.ts +65 -0
- package/dist/core/continuity/capabilities.js +129 -0
- package/dist/core/continuity/capabilities.js.map +1 -0
- package/dist/core/continuity/contracts.d.ts +352 -0
- package/dist/core/continuity/contracts.js +117 -0
- package/dist/core/continuity/contracts.js.map +1 -0
- package/dist/core/continuity/engine.d.ts +55 -0
- package/dist/core/continuity/engine.js +504 -0
- package/dist/core/continuity/engine.js.map +1 -0
- package/dist/core/continuity/store.d.ts +73 -0
- package/dist/core/continuity/store.js +438 -0
- package/dist/core/continuity/store.js.map +1 -0
- package/dist/core/conversations.d.ts +19 -0
- package/dist/core/conversations.js +87 -0
- package/dist/core/conversations.js.map +1 -0
- package/dist/core/crew-coordination.d.ts +6 -0
- package/dist/core/crew-coordination.js +24 -0
- package/dist/core/crew-coordination.js.map +1 -0
- package/dist/core/crew-policy.d.ts +18 -0
- package/dist/core/crew-policy.js +40 -0
- package/dist/core/crew-policy.js.map +1 -0
- package/dist/core/crew-protocol.d.ts +64 -0
- package/dist/core/crew-protocol.js +146 -0
- package/dist/core/crew-protocol.js.map +1 -0
- package/dist/core/crew-requests.d.ts +14 -0
- package/dist/core/crew-requests.js +22 -0
- package/dist/core/crew-requests.js.map +1 -0
- package/dist/core/crew.d.ts +395 -0
- package/dist/core/crew.js +1857 -0
- package/dist/core/crew.js.map +1 -0
- package/dist/core/digest.d.ts +40 -0
- package/dist/core/digest.js +94 -0
- package/dist/core/digest.js.map +1 -0
- package/dist/core/distill.js +3 -5
- package/dist/core/distill.js.map +1 -1
- package/dist/core/eventlog.d.ts +27 -25
- package/dist/core/eventlog.js +97 -150
- package/dist/core/eventlog.js.map +1 -1
- package/dist/core/events/contracts.d.ts +51 -0
- package/dist/core/events/contracts.js +2 -0
- package/dist/core/events/contracts.js.map +1 -0
- package/dist/core/events/jsonl-store.d.ts +23 -0
- package/dist/core/events/jsonl-store.js +90 -0
- package/dist/core/events/jsonl-store.js.map +1 -0
- package/dist/core/events/sqlite-store.d.ts +37 -0
- package/dist/core/events/sqlite-store.js +215 -0
- package/dist/core/events/sqlite-store.js.map +1 -0
- package/dist/core/forecast.d.ts +44 -0
- package/dist/core/forecast.js +46 -0
- package/dist/core/forecast.js.map +1 -0
- package/dist/core/free-pool.d.ts +65 -0
- package/dist/core/free-pool.js +171 -0
- package/dist/core/free-pool.js.map +1 -0
- package/dist/core/git.d.ts +30 -0
- package/dist/core/git.js +50 -2
- package/dist/core/git.js.map +1 -1
- package/dist/core/github-events.d.ts +53 -0
- package/dist/core/github-events.js +227 -0
- package/dist/core/github-events.js.map +1 -0
- package/dist/core/goal-lanes.d.ts +53 -0
- package/dist/core/goal-lanes.js +99 -0
- package/dist/core/goal-lanes.js.map +1 -0
- package/dist/core/hosted.d.ts +27 -0
- package/dist/core/hosted.js +50 -0
- package/dist/core/hosted.js.map +1 -0
- package/dist/core/http-server.d.ts +5 -0
- package/dist/core/http-server.js +8 -0
- package/dist/core/http-server.js.map +1 -0
- package/dist/core/invite-link.d.ts +31 -0
- package/dist/core/invite-link.js +45 -0
- package/dist/core/invite-link.js.map +1 -0
- package/dist/core/loops.d.ts +37 -0
- package/dist/core/loops.js +81 -0
- package/dist/core/loops.js.map +1 -0
- package/dist/core/mcp-catalog.d.ts +182 -0
- package/dist/core/mcp-catalog.js +431 -0
- package/dist/core/mcp-catalog.js.map +1 -0
- package/dist/core/mcp.d.ts +87 -0
- package/dist/core/mcp.js +181 -0
- package/dist/core/mcp.js.map +1 -0
- package/dist/core/memory.d.ts +1 -0
- package/dist/core/memory.js +5 -2
- package/dist/core/memory.js.map +1 -1
- package/dist/core/model-tools.d.ts +97 -0
- package/dist/core/model-tools.js +388 -0
- package/dist/core/model-tools.js.map +1 -0
- package/dist/core/notify.js +19 -1
- package/dist/core/notify.js.map +1 -1
- package/dist/core/orchestra.d.ts +635 -0
- package/dist/core/orchestra.js +2120 -0
- package/dist/core/orchestra.js.map +1 -0
- package/dist/core/permissions.d.ts +56 -0
- package/dist/core/permissions.js +122 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/preview-proxy.d.ts +70 -0
- package/dist/core/preview-proxy.js +563 -0
- package/dist/core/preview-proxy.js.map +1 -0
- package/dist/core/preview-shot.d.ts +46 -0
- package/dist/core/preview-shot.js +109 -0
- package/dist/core/preview-shot.js.map +1 -0
- package/dist/core/process.d.ts +36 -0
- package/dist/core/process.js +168 -0
- package/dist/core/process.js.map +1 -0
- package/dist/core/prompt-queue.d.ts +136 -0
- package/dist/core/prompt-queue.js +269 -0
- package/dist/core/prompt-queue.js.map +1 -0
- package/dist/core/prompts.d.ts +53 -0
- package/dist/core/prompts.js +127 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/proof.d.ts +82 -0
- package/dist/core/proof.js +216 -0
- package/dist/core/proof.js.map +1 -0
- package/dist/core/provider-text.d.ts +23 -0
- package/dist/core/provider-text.js +66 -0
- package/dist/core/provider-text.js.map +1 -0
- package/dist/core/providers.d.ts +152 -0
- package/dist/core/providers.js +343 -0
- package/dist/core/providers.js.map +1 -0
- package/dist/core/quota.d.ts +33 -0
- package/dist/core/quota.js +74 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/recipes.d.ts +50 -0
- package/dist/core/recipes.js +99 -0
- package/dist/core/recipes.js.map +1 -0
- package/dist/core/registry.d.ts +54 -0
- package/dist/core/registry.js +62 -5
- package/dist/core/registry.js.map +1 -1
- package/dist/core/relay-client.d.ts +67 -0
- package/dist/core/relay-client.js +184 -0
- package/dist/core/relay-client.js.map +1 -0
- package/dist/core/relay-protocol.d.ts +140 -0
- package/dist/core/relay-protocol.js +189 -0
- package/dist/core/relay-protocol.js.map +1 -0
- package/dist/core/replay.d.ts +18 -0
- package/dist/core/replay.js +191 -0
- package/dist/core/replay.js.map +1 -0
- package/dist/core/review-triage.d.ts +41 -0
- package/dist/core/review-triage.js +124 -0
- package/dist/core/review-triage.js.map +1 -0
- package/dist/core/router.js +2 -2
- package/dist/core/router.js.map +1 -1
- package/dist/core/routes.d.ts +39 -3
- package/dist/core/routes.js +137 -4
- package/dist/core/routes.js.map +1 -1
- package/dist/core/run-command.d.ts +14 -0
- package/dist/core/run-command.js +45 -0
- package/dist/core/run-command.js.map +1 -0
- package/dist/core/runner-setup.d.ts +36 -0
- package/dist/core/runner-setup.js +114 -0
- package/dist/core/runner-setup.js.map +1 -0
- package/dist/core/search.js +0 -0
- package/dist/core/search.js.map +1 -1
- package/dist/core/semantic.d.ts +92 -0
- package/dist/core/semantic.js +227 -0
- package/dist/core/semantic.js.map +1 -0
- package/dist/core/servers.d.ts +115 -0
- package/dist/core/servers.js +305 -0
- package/dist/core/servers.js.map +1 -0
- package/dist/core/setup.js +64 -6
- package/dist/core/setup.js.map +1 -1
- package/dist/core/shield.d.ts +17 -0
- package/dist/core/shield.js +81 -0
- package/dist/core/shield.js.map +1 -0
- package/dist/core/shift.d.ts +69 -0
- package/dist/core/shift.js +120 -0
- package/dist/core/shift.js.map +1 -0
- package/dist/core/skill-install.d.ts +125 -0
- package/dist/core/skill-install.js +328 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/skills.d.ts +116 -0
- package/dist/core/skills.js +223 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/step-conditions.d.ts +65 -0
- package/dist/core/step-conditions.js +102 -0
- package/dist/core/step-conditions.js.map +1 -0
- package/dist/core/subagents.d.ts +45 -0
- package/dist/core/subagents.js +90 -0
- package/dist/core/subagents.js.map +1 -0
- package/dist/core/team-canon.d.ts +42 -0
- package/dist/core/team-canon.js +121 -0
- package/dist/core/team-canon.js.map +1 -0
- package/dist/core/team-crypto.d.ts +97 -0
- package/dist/core/team-crypto.js +175 -0
- package/dist/core/team-crypto.js.map +1 -0
- package/dist/core/team-hub.d.ts +514 -0
- package/dist/core/team-hub.js +964 -0
- package/dist/core/team-hub.js.map +1 -0
- package/dist/core/team-landing.d.ts +239 -0
- package/dist/core/team-landing.js +504 -0
- package/dist/core/team-landing.js.map +1 -0
- package/dist/core/team-leases.d.ts +44 -0
- package/dist/core/team-leases.js +145 -0
- package/dist/core/team-leases.js.map +1 -0
- package/dist/core/team-memory.d.ts +42 -0
- package/dist/core/team-memory.js +84 -0
- package/dist/core/team-memory.js.map +1 -0
- package/dist/core/team-policy.d.ts +85 -0
- package/dist/core/team-policy.js +191 -0
- package/dist/core/team-policy.js.map +1 -0
- package/dist/core/thread-md.d.ts +6 -0
- package/dist/core/thread-md.js +42 -0
- package/dist/core/thread-md.js.map +1 -0
- package/dist/core/turn-stats.d.ts +61 -0
- package/dist/core/turn-stats.js +124 -0
- package/dist/core/turn-stats.js.map +1 -0
- package/dist/core/updater.d.ts +73 -0
- package/dist/core/updater.js +153 -0
- package/dist/core/updater.js.map +1 -0
- package/dist/core/usage.d.ts +75 -0
- package/dist/core/usage.js +163 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/windows-jobs.d.ts +15 -0
- package/dist/core/windows-jobs.js +170 -0
- package/dist/core/windows-jobs.js.map +1 -0
- package/dist/core/worktree-merge.d.ts +57 -0
- package/dist/core/worktree-merge.js +123 -0
- package/dist/core/worktree-merge.js.map +1 -0
- package/dist/core/worktree.d.ts +5 -0
- package/dist/core/worktree.js +45 -5
- package/dist/core/worktree.js.map +1 -1
- package/dist/core/worktrees.d.ts +6 -0
- package/dist/core/worktrees.js +21 -0
- package/dist/core/worktrees.js.map +1 -0
- package/dist/daemon/app-page.d.ts +0 -17
- package/dist/daemon/app-page.js +10 -6346
- package/dist/daemon/app-page.js.map +1 -1
- package/dist/daemon/auth.d.ts +24 -3
- package/dist/daemon/auth.js +66 -7
- package/dist/daemon/auth.js.map +1 -1
- package/dist/daemon/board.d.ts +7 -0
- package/dist/daemon/board.js +12 -1
- package/dist/daemon/board.js.map +1 -1
- package/dist/daemon/brand-icons.d.ts +6 -1
- package/dist/daemon/brand-icons.js +11 -3
- package/dist/daemon/brand-icons.js.map +1 -1
- package/dist/daemon/client.d.ts +665 -1
- package/dist/daemon/client.js +381 -3
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/delivery.d.ts +34 -0
- package/dist/daemon/delivery.js +63 -0
- package/dist/daemon/delivery.js.map +1 -0
- package/dist/daemon/deploys.d.ts +62 -0
- package/dist/daemon/deploys.js +159 -0
- package/dist/daemon/deploys.js.map +1 -0
- package/dist/daemon/landing.d.ts +192 -0
- package/dist/daemon/landing.js +992 -0
- package/dist/daemon/landing.js.map +1 -0
- package/dist/daemon/onboard.d.ts +150 -0
- package/dist/daemon/onboard.js +390 -0
- package/dist/daemon/onboard.js.map +1 -0
- package/dist/daemon/proof.d.ts +70 -0
- package/dist/daemon/proof.js +172 -0
- package/dist/daemon/proof.js.map +1 -0
- package/dist/daemon/push.d.ts +19 -1
- package/dist/daemon/push.js +90 -3
- package/dist/daemon/push.js.map +1 -1
- package/dist/daemon/relay.d.ts +82 -0
- package/dist/daemon/relay.js +351 -0
- package/dist/daemon/relay.js.map +1 -0
- package/dist/daemon/routes/agent-discovery.d.ts +4 -0
- package/dist/daemon/routes/agent-discovery.js +29 -0
- package/dist/daemon/routes/agent-discovery.js.map +1 -0
- package/dist/daemon/routes/agent-settings.d.ts +4 -0
- package/dist/daemon/routes/agent-settings.js +94 -0
- package/dist/daemon/routes/agent-settings.js.map +1 -0
- package/dist/daemon/routes/agents.d.ts +4 -0
- package/dist/daemon/routes/agents.js +105 -0
- package/dist/daemon/routes/agents.js.map +1 -0
- package/dist/daemon/routes/alerts.d.ts +4 -0
- package/dist/daemon/routes/alerts.js +137 -0
- package/dist/daemon/routes/alerts.js.map +1 -0
- package/dist/daemon/routes/approvals.d.ts +4 -0
- package/dist/daemon/routes/approvals.js +40 -0
- package/dist/daemon/routes/approvals.js.map +1 -0
- package/dist/daemon/routes/assets.d.ts +3 -0
- package/dist/daemon/routes/assets.js +64 -0
- package/dist/daemon/routes/assets.js.map +1 -0
- package/dist/daemon/routes/board.d.ts +4 -0
- package/dist/daemon/routes/board.js +147 -0
- package/dist/daemon/routes/board.js.map +1 -0
- package/dist/daemon/routes/brain.d.ts +4 -0
- package/dist/daemon/routes/brain.js +270 -0
- package/dist/daemon/routes/brain.js.map +1 -0
- package/dist/daemon/routes/chats.d.ts +4 -0
- package/dist/daemon/routes/chats.js +249 -0
- package/dist/daemon/routes/chats.js.map +1 -0
- package/dist/daemon/routes/clients.d.ts +4 -0
- package/dist/daemon/routes/clients.js +49 -0
- package/dist/daemon/routes/clients.js.map +1 -0
- package/dist/daemon/routes/cloud.d.ts +4 -0
- package/dist/daemon/routes/cloud.js +46 -0
- package/dist/daemon/routes/cloud.js.map +1 -0
- package/dist/daemon/routes/config.d.ts +4 -0
- package/dist/daemon/routes/config.js +35 -0
- package/dist/daemon/routes/config.js.map +1 -0
- package/dist/daemon/routes/context.d.ts +51 -0
- package/dist/daemon/routes/context.js +2 -0
- package/dist/daemon/routes/context.js.map +1 -0
- package/dist/daemon/routes/crews.d.ts +4 -0
- package/dist/daemon/routes/crews.js +149 -0
- package/dist/daemon/routes/crews.js.map +1 -0
- package/dist/daemon/routes/files.d.ts +3 -0
- package/dist/daemon/routes/files.js +253 -0
- package/dist/daemon/routes/files.js.map +1 -0
- package/dist/daemon/routes/git.d.ts +4 -0
- package/dist/daemon/routes/git.js +96 -0
- package/dist/daemon/routes/git.js.map +1 -0
- package/dist/daemon/routes/history.d.ts +4 -0
- package/dist/daemon/routes/history.js +101 -0
- package/dist/daemon/routes/history.js.map +1 -0
- package/dist/daemon/routes/integrations-project.d.ts +3 -0
- package/dist/daemon/routes/integrations-project.js +103 -0
- package/dist/daemon/routes/integrations-project.js.map +1 -0
- package/dist/daemon/routes/integrations.d.ts +3 -0
- package/dist/daemon/routes/integrations.js +111 -0
- package/dist/daemon/routes/integrations.js.map +1 -0
- package/dist/daemon/routes/logs.d.ts +3 -0
- package/dist/daemon/routes/logs.js +40 -0
- package/dist/daemon/routes/logs.js.map +1 -0
- package/dist/daemon/routes/messages.d.ts +4 -0
- package/dist/daemon/routes/messages.js +60 -0
- package/dist/daemon/routes/messages.js.map +1 -0
- package/dist/daemon/routes/observability.d.ts +4 -0
- package/dist/daemon/routes/observability.js +247 -0
- package/dist/daemon/routes/observability.js.map +1 -0
- package/dist/daemon/routes/onboard.d.ts +12 -0
- package/dist/daemon/routes/onboard.js +67 -0
- package/dist/daemon/routes/onboard.js.map +1 -0
- package/dist/daemon/routes/orchestra.d.ts +4 -0
- package/dist/daemon/routes/orchestra.js +110 -0
- package/dist/daemon/routes/orchestra.js.map +1 -0
- package/dist/daemon/routes/pairing.d.ts +4 -0
- package/dist/daemon/routes/pairing.js +155 -0
- package/dist/daemon/routes/pairing.js.map +1 -0
- package/dist/daemon/routes/preview.d.ts +24 -0
- package/dist/daemon/routes/preview.js +122 -0
- package/dist/daemon/routes/preview.js.map +1 -0
- package/dist/daemon/routes/project-actions.d.ts +4 -0
- package/dist/daemon/routes/project-actions.js +53 -0
- package/dist/daemon/routes/project-actions.js.map +1 -0
- package/dist/daemon/routes/project-team.d.ts +4 -0
- package/dist/daemon/routes/project-team.js +201 -0
- package/dist/daemon/routes/project-team.js.map +1 -0
- package/dist/daemon/routes/projects.d.ts +14 -0
- package/dist/daemon/routes/projects.js +138 -0
- package/dist/daemon/routes/projects.js.map +1 -0
- package/dist/daemon/routes/prompts.d.ts +4 -0
- package/dist/daemon/routes/prompts.js +44 -0
- package/dist/daemon/routes/prompts.js.map +1 -0
- package/dist/daemon/routes/proof.d.ts +4 -0
- package/dist/daemon/routes/proof.js +22 -0
- package/dist/daemon/routes/proof.js.map +1 -0
- package/dist/daemon/routes/providers.d.ts +3 -0
- package/dist/daemon/routes/providers.js +60 -0
- package/dist/daemon/routes/providers.js.map +1 -0
- package/dist/daemon/routes/public.d.ts +4 -0
- package/dist/daemon/routes/public.js +64 -0
- package/dist/daemon/routes/public.js.map +1 -0
- package/dist/daemon/routes/queue.d.ts +4 -0
- package/dist/daemon/routes/queue.js +140 -0
- package/dist/daemon/routes/queue.js.map +1 -0
- package/dist/daemon/routes/routing.d.ts +4 -0
- package/dist/daemon/routes/routing.js +61 -0
- package/dist/daemon/routes/routing.js.map +1 -0
- package/dist/daemon/routes/runtime-handler.d.ts +2 -0
- package/dist/daemon/routes/runtime-handler.js +63 -0
- package/dist/daemon/routes/runtime-handler.js.map +1 -0
- package/dist/daemon/routes/search.d.ts +4 -0
- package/dist/daemon/routes/search.js +17 -0
- package/dist/daemon/routes/search.js.map +1 -0
- package/dist/daemon/routes/servers.d.ts +4 -0
- package/dist/daemon/routes/servers.js +133 -0
- package/dist/daemon/routes/servers.js.map +1 -0
- package/dist/daemon/routes/setup.d.ts +3 -0
- package/dist/daemon/routes/setup.js +50 -0
- package/dist/daemon/routes/setup.js.map +1 -0
- package/dist/daemon/routes/shift.d.ts +4 -0
- package/dist/daemon/routes/shift.js +30 -0
- package/dist/daemon/routes/shift.js.map +1 -0
- package/dist/daemon/routes/specs.d.ts +4 -0
- package/dist/daemon/routes/specs.js +57 -0
- package/dist/daemon/routes/specs.js.map +1 -0
- package/dist/daemon/routes/task-delivery.d.ts +4 -0
- package/dist/daemon/routes/task-delivery.js +38 -0
- package/dist/daemon/routes/task-delivery.js.map +1 -0
- package/dist/daemon/routes/team.d.ts +4 -0
- package/dist/daemon/routes/team.js +193 -0
- package/dist/daemon/routes/team.js.map +1 -0
- package/dist/daemon/routes/terminals.d.ts +4 -0
- package/dist/daemon/routes/terminals.js +87 -0
- package/dist/daemon/routes/terminals.js.map +1 -0
- package/dist/daemon/routes/tools.d.ts +4 -0
- package/dist/daemon/routes/tools.js +180 -0
- package/dist/daemon/routes/tools.js.map +1 -0
- package/dist/daemon/routes/updates.d.ts +4 -0
- package/dist/daemon/routes/updates.js +89 -0
- package/dist/daemon/routes/updates.js.map +1 -0
- package/dist/daemon/routes/voice.d.ts +4 -0
- package/dist/daemon/routes/voice.js +53 -0
- package/dist/daemon/routes/voice.js.map +1 -0
- package/dist/daemon/runner.d.ts +186 -0
- package/dist/daemon/runner.js +612 -0
- package/dist/daemon/runner.js.map +1 -0
- package/dist/daemon/runtime/accounting.d.ts +130 -0
- package/dist/daemon/runtime/accounting.js +303 -0
- package/dist/daemon/runtime/accounting.js.map +1 -0
- package/dist/daemon/runtime/agents.d.ts +16 -0
- package/dist/daemon/runtime/agents.js +71 -0
- package/dist/daemon/runtime/agents.js.map +1 -0
- package/dist/daemon/runtime/briefings.d.ts +134 -0
- package/dist/daemon/runtime/briefings.js +329 -0
- package/dist/daemon/runtime/briefings.js.map +1 -0
- package/dist/daemon/runtime/native-usage.d.ts +21 -0
- package/dist/daemon/runtime/native-usage.js +71 -0
- package/dist/daemon/runtime/native-usage.js.map +1 -0
- package/dist/daemon/runtime/queue.d.ts +94 -0
- package/dist/daemon/runtime/queue.js +222 -0
- package/dist/daemon/runtime/queue.js.map +1 -0
- package/dist/daemon/runtime/relay.d.ts +93 -0
- package/dist/daemon/runtime/relay.js +240 -0
- package/dist/daemon/runtime/relay.js.map +1 -0
- package/dist/daemon/runtime/turns.d.ts +202 -0
- package/dist/daemon/runtime/turns.js +663 -0
- package/dist/daemon/runtime/turns.js.map +1 -0
- package/dist/daemon/runtime-support.d.ts +76 -0
- package/dist/daemon/runtime-support.js +134 -0
- package/dist/daemon/runtime-support.js.map +1 -0
- package/dist/daemon/runtime.d.ts +739 -70
- package/dist/daemon/runtime.js +2751 -470
- package/dist/daemon/runtime.js.map +1 -1
- package/dist/daemon/server.d.ts +82 -55
- package/dist/daemon/server.js +674 -1435
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/shift.d.ts +70 -0
- package/dist/daemon/shift.js +170 -0
- package/dist/daemon/shift.js.map +1 -0
- package/dist/daemon/specs.d.ts +92 -0
- package/dist/daemon/specs.js +285 -0
- package/dist/daemon/specs.js.map +1 -0
- package/dist/daemon/system.d.ts +208 -0
- package/dist/daemon/system.js +557 -0
- package/dist/daemon/system.js.map +1 -0
- package/dist/daemon/tasks.js +1 -1
- package/dist/daemon/team-brain.d.ts +136 -0
- package/dist/daemon/team-brain.js +659 -0
- package/dist/daemon/team-brain.js.map +1 -0
- package/dist/daemon/team-coordinator.d.ts +78 -0
- package/dist/daemon/team-coordinator.js +408 -0
- package/dist/daemon/team-coordinator.js.map +1 -0
- package/dist/daemon/team.d.ts +339 -0
- package/dist/daemon/team.js +1433 -0
- package/dist/daemon/team.js.map +1 -0
- package/dist/daemon/terminals.d.ts +22 -3
- package/dist/daemon/terminals.js +236 -16
- package/dist/daemon/terminals.js.map +1 -1
- package/dist/hub/client.d.ts +120 -0
- package/dist/hub/client.js +228 -0
- package/dist/hub/client.js.map +1 -0
- package/dist/hub/server.d.ts +32 -0
- package/dist/hub/server.js +180 -0
- package/dist/hub/server.js.map +1 -0
- package/dist/hub/supabase-client.d.ts +220 -0
- package/dist/hub/supabase-client.js +843 -0
- package/dist/hub/supabase-client.js.map +1 -0
- package/dist/observability/ask.d.ts +91 -0
- package/dist/observability/ask.js +179 -0
- package/dist/observability/ask.js.map +1 -0
- package/dist/observability/clickhouse.d.ts +33 -0
- package/dist/observability/clickhouse.js +48 -0
- package/dist/observability/clickhouse.js.map +1 -0
- package/dist/observability/decisions.d.ts +176 -0
- package/dist/observability/decisions.js +377 -0
- package/dist/observability/decisions.js.map +1 -0
- package/dist/observability/index.d.ts +89 -0
- package/dist/observability/index.js +348 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/insights.d.ts +151 -0
- package/dist/observability/insights.js +319 -0
- package/dist/observability/insights.js.map +1 -0
- package/dist/observability/logs-query.d.ts +82 -0
- package/dist/observability/logs-query.js +102 -0
- package/dist/observability/logs-query.js.map +1 -0
- package/dist/observability/logs.d.ts +72 -0
- package/dist/observability/logs.js +295 -0
- package/dist/observability/logs.js.map +1 -0
- package/dist/observability/metrics.d.ts +86 -0
- package/dist/observability/metrics.js +303 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/otlp.d.ts +127 -0
- package/dist/observability/otlp.js +242 -0
- package/dist/observability/otlp.js.map +1 -0
- package/dist/observability/snapshots.d.ts +43 -0
- package/dist/observability/snapshots.js +129 -0
- package/dist/observability/snapshots.js.map +1 -0
- package/dist/observability/triage.d.ts +49 -0
- package/dist/observability/triage.js +284 -0
- package/dist/observability/triage.js.map +1 -0
- package/dist/providers/adapter.d.ts +50 -0
- package/dist/providers/adapter.js +38 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/agent.d.ts +110 -0
- package/dist/providers/agent.js +487 -0
- package/dist/providers/agent.js.map +1 -0
- package/dist/providers/approvals.d.ts +24 -0
- package/dist/providers/approvals.js +78 -0
- package/dist/providers/approvals.js.map +1 -0
- package/dist/providers/attachments.d.ts +20 -0
- package/dist/providers/attachments.js +51 -0
- package/dist/providers/attachments.js.map +1 -0
- package/dist/providers/claude/adapter.d.ts +99 -0
- package/dist/providers/claude/adapter.js +790 -0
- package/dist/providers/claude/adapter.js.map +1 -0
- package/dist/providers/codex/adapter.d.ts +113 -0
- package/dist/providers/codex/adapter.js +881 -0
- package/dist/providers/codex/adapter.js.map +1 -0
- package/dist/providers/codex/instructions.d.ts +11 -0
- package/dist/providers/codex/instructions.js +154 -0
- package/dist/providers/codex/instructions.js.map +1 -0
- package/dist/providers/codex/rpc.d.ts +36 -0
- package/dist/providers/codex/rpc.js +104 -0
- package/dist/providers/codex/rpc.js.map +1 -0
- package/dist/providers/contracts.d.ts +339 -0
- package/dist/providers/contracts.js +19 -0
- package/dist/providers/contracts.js.map +1 -0
- package/dist/providers/directory.d.ts +70 -0
- package/dist/providers/directory.js +95 -0
- package/dist/providers/directory.js.map +1 -0
- package/dist/providers/errors.d.ts +47 -0
- package/dist/providers/errors.js +24 -0
- package/dist/providers/errors.js.map +1 -0
- package/dist/providers/index.d.ts +18 -0
- package/dist/providers/index.js +19 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/ingestion.d.ts +80 -0
- package/dist/providers/ingestion.js +330 -0
- package/dist/providers/ingestion.js.map +1 -0
- package/dist/providers/live.d.ts +22 -0
- package/dist/providers/live.js +56 -0
- package/dist/providers/live.js.map +1 -0
- package/dist/providers/process.d.ts +29 -0
- package/dist/providers/process.js +71 -0
- package/dist/providers/process.js.map +1 -0
- package/dist/providers/reaper.d.ts +33 -0
- package/dist/providers/reaper.js +65 -0
- package/dist/providers/reaper.js.map +1 -0
- package/dist/providers/service.d.ts +87 -0
- package/dist/providers/service.js +256 -0
- package/dist/providers/service.js.map +1 -0
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +1 -1
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +385 -6
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +7 -0
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -0
- package/dist/web/app.css +4643 -0
- package/dist/web/app.js +22640 -0
- package/dist/web/app.js.map +7 -0
- package/dist/web/shell.html +39 -0
- package/docs/README.md +21 -0
- package/docs/SETUP.md +10 -0
- package/docs/adapters.md +21 -8
- package/docs/brain-continuity.md +186 -0
- package/docs/cloud.md +108 -0
- package/docs/crews.md +62 -0
- package/docs/design-system.md +31 -2
- package/docs/integration-notes.md +128 -20
- package/docs/loompad.md +105 -0
- package/docs/observability.md +220 -0
- package/docs/play-store.md +123 -0
- package/docs/proposals/agent-teams.md +249 -0
- package/docs/proposals/brain-continuity-implementation.md +468 -0
- package/docs/proposals/brain-session-continuity.md +623 -0
- package/docs/proposals/t3code-port.md +81 -0
- package/docs/proposals/t3code-reference.md +95 -0
- package/docs/refactoring/AGENT-TEAMS-PROGRESS.md +88 -0
- package/docs/refactoring/BOUNDARIES.md +120 -0
- package/docs/refactoring/BRAIN-AUDIT.md +176 -0
- package/docs/refactoring/BRAIN-NOTES.md +390 -0
- package/docs/refactoring/BRAIN-TODO.md +214 -0
- package/docs/refactoring/NOTES.md +129 -0
- package/docs/refactoring/T3-PORT-NOTES.md +165 -0
- package/docs/refactoring/T3-PORT-TODO.md +123 -0
- package/docs/refactoring/TODO.md +46 -0
- package/docs/refactoring/brain-benchmark-2026-09-28.json +48 -0
- package/docs/refactoring/brain-benchmark-audit-2026-09-28.json +48 -0
- package/docs/release-notes/v0.2.0.md +35 -0
- package/docs/release-notes/v0.2.1.md +59 -0
- package/docs/release-notes/v0.2.3.md +77 -0
- package/docs/release-notes/v0.2.4.md +128 -0
- package/docs/release-notes/v0.2.5.md +153 -0
- package/docs/release-notes/v0.2.6.md +62 -0
- package/docs/release-notes/v1.0.0.md +120 -0
- package/docs/release-notes/v1.0.1.md +77 -0
- package/docs/release-notes/v1.1.0.md +71 -0
- package/docs/release-notes/v1.1.1.md +38 -0
- package/docs/teams-architecture.md +697 -0
- package/docs/teams.md +171 -0
- package/docs/windows.md +85 -0
- package/package.json +22 -9
- package/dist/adapters/claude-code.d.ts +0 -26
- package/dist/adapters/claude-code.js +0 -209
- package/dist/adapters/claude-code.js.map +0 -1
- package/dist/adapters/codex.d.ts +0 -51
- package/dist/adapters/codex.js +0 -291
- package/dist/adapters/codex.js.map +0 -1
- package/docs/img/board-linear.png +0 -0
- package/docs/img/board-projects.png +0 -0
- package/docs/img/brain.png +0 -0
- package/docs/img/settings.png +0 -0
- package/docs/img/workspace.png +0 -0
package/dist/daemon/server.js
CHANGED
|
@@ -1,149 +1,78 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* (CLI today, iOS app next). REST for commands, WebSocket for the live
|
|
4
|
-
* event stream.
|
|
5
|
-
*/
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { ClientDelivery } from "./delivery.js";
|
|
2
|
+
import express from "express";
|
|
7
3
|
import crypto from "node:crypto";
|
|
8
4
|
import fs from "node:fs";
|
|
9
5
|
import http from "node:http";
|
|
10
|
-
import {
|
|
6
|
+
import { configureHttpServer } from "../core/http-server.js";
|
|
11
7
|
import os from "node:os";
|
|
12
8
|
import path from "node:path";
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import QRCode from "qrcode";
|
|
16
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
17
|
-
import { NotHolderError } from "../core/baton.js";
|
|
18
|
-
import { retrieve } from "../core/brain-index.js";
|
|
19
|
-
import { RouteActiveError } from "../core/routes.js";
|
|
20
|
-
import { ADES, buildDefaultRoutes, defaultAgentConfigs, detectAdes } from "../core/ades.js";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
10
|
+
import { setApprovalBroker } from "../core/approvals.js";
|
|
21
11
|
import { logbook } from "../core/logbook.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
12
|
+
import { hostedTarget } from "../core/hosted.js";
|
|
13
|
+
import { ensureDaemonConfig, ensureLoomHome, findProject, listProjects, loomHome, readDaemonConfig, registerProject, unregisterProject, writeDaemonConfig } from "../core/registry.js";
|
|
14
|
+
import { packCredentials, toB64 } from "../core/relay-protocol.js";
|
|
15
|
+
import { CHECK_TTL_MS, latestRelease, newerThan } from "../core/updater.js";
|
|
16
|
+
import { VERSION } from "../version.js";
|
|
17
|
+
import { recordAgentEvent } from "../observability/index.js";
|
|
18
|
+
import { recentAgentErrors } from "../observability/insights.js";
|
|
29
19
|
import { AuthManager, bearerToken } from "./auth.js";
|
|
30
|
-
import {
|
|
20
|
+
import { pushCategory, pushContent, sendExpoPush, wantsPush } from "./push.js";
|
|
21
|
+
import { ensureCredentials, readCloudSettings, RelayBridge, supabaseTarget, supabaseTransport, writeCloudSettings, } from "./relay.js";
|
|
22
|
+
import { registerAgentDiscoveryRoutes } from './routes/agent-discovery.js';
|
|
23
|
+
import { registerAgentSettingsRoutes } from './routes/agent-settings.js';
|
|
24
|
+
import { registerAgentsRoutes } from './routes/agents.js';
|
|
25
|
+
import { registerAlertsRoutes } from './routes/alerts.js';
|
|
26
|
+
import { registerApprovalsRoutes } from './routes/approvals.js';
|
|
27
|
+
import { registerAssetsRoutes } from './routes/assets.js';
|
|
28
|
+
import { registerBoardRoutes } from './routes/board.js';
|
|
29
|
+
import { registerBrainRoutes } from './routes/brain.js';
|
|
30
|
+
import { registerChatsRoutes } from './routes/chats.js';
|
|
31
|
+
import { registerClientsRoutes } from './routes/clients.js';
|
|
32
|
+
import { registerCloudRoutes } from './routes/cloud.js';
|
|
33
|
+
import { registerConfigRoutes } from './routes/config.js';
|
|
34
|
+
import { registerPreviewPublic, registerPreviewRoutes } from "./routes/preview.js";
|
|
35
|
+
import { registerFilesRoutes } from './routes/files.js';
|
|
36
|
+
import { registerGitRoutes } from './routes/git.js';
|
|
37
|
+
import { registerHistoryRoutes } from './routes/history.js';
|
|
38
|
+
import { registerIntegrationsProjectRoutes } from './routes/integrations-project.js';
|
|
39
|
+
import { registerIntegrationsRoutes } from './routes/integrations.js';
|
|
40
|
+
import { registerLogsRoutes } from './routes/logs.js';
|
|
41
|
+
import { registerMessagesRoutes } from './routes/messages.js';
|
|
42
|
+
import { registerObservabilityRoutes } from './routes/observability.js';
|
|
43
|
+
import { registerOrchestraRoutes } from './routes/orchestra.js';
|
|
44
|
+
import { registerPairingRoutes } from './routes/pairing.js';
|
|
45
|
+
import { registerProjectActionsRoutes } from './routes/project-actions.js';
|
|
46
|
+
import { registerProjectTeamRoutes } from './routes/project-team.js';
|
|
47
|
+
import { registerProjectsRoutes } from './routes/projects.js';
|
|
48
|
+
import { registerPromptsRoutes } from './routes/prompts.js';
|
|
49
|
+
import { registerProvidersRoutes } from './routes/providers.js';
|
|
50
|
+
import { registerPublicRoutes } from './routes/public.js';
|
|
51
|
+
import { registerQueueRoutes } from './routes/queue.js';
|
|
52
|
+
import { registerRoutingRoutes } from './routes/routing.js';
|
|
53
|
+
import { createRuntimeHandler } from './routes/runtime-handler.js';
|
|
54
|
+
import { registerSearchRoutes } from './routes/search.js';
|
|
55
|
+
import { registerServersRoutes } from './routes/servers.js';
|
|
56
|
+
import { registerSetupRoutes } from './routes/setup.js';
|
|
57
|
+
import { registerSpecsRoutes } from './routes/specs.js';
|
|
58
|
+
import { registerTaskDeliveryRoutes } from './routes/task-delivery.js';
|
|
59
|
+
import { registerTeamRoutes } from './routes/team.js';
|
|
60
|
+
import { registerTerminalsRoutes } from './routes/terminals.js';
|
|
61
|
+
import { registerToolsRoutes } from './routes/tools.js';
|
|
62
|
+
import { registerUpdatesRoutes } from './routes/updates.js';
|
|
63
|
+
import { registerVoiceRoutes } from './routes/voice.js';
|
|
31
64
|
import { ProjectRuntime } from "./runtime.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* already running", the shell agreed it was current, and a daemon kept serving
|
|
44
|
-
* the old code from memory. A correct fix looked like it did nothing, which
|
|
45
|
-
* sends you debugging code that is already right.
|
|
46
|
-
*
|
|
47
|
-
* Content-based on purpose: mtimes are unreliable across runtimes on some
|
|
48
|
-
* filesystems (exFAT drives skew them by the local timezone offset). Names are
|
|
49
|
-
* hashed alongside contents so a rename or a deletion moves the rev too.
|
|
50
|
-
*
|
|
51
|
-
* Reading ~39 files (about half a megabyte) costs a couple of milliseconds at
|
|
52
|
-
* import, once. A stale daemon costs an afternoon.
|
|
53
|
-
*
|
|
54
|
-
* The desktop shell has a twin of this in desktop/loom-app.js — it can't import
|
|
55
|
-
* this module without pulling express into Electron's main process. They must
|
|
56
|
-
* agree byte for byte; test/desktop-app.test.ts compares them against the real
|
|
57
|
-
* built output so a drift fails there rather than in the field.
|
|
58
|
-
*/
|
|
59
|
-
export function fingerprintBuild(root) {
|
|
60
|
-
const rels = [];
|
|
61
|
-
const walk = (dir) => {
|
|
62
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
-
const full = path.join(dir, entry.name);
|
|
64
|
-
if (entry.isDirectory())
|
|
65
|
-
walk(full);
|
|
66
|
-
else if (entry.name.endsWith(".js"))
|
|
67
|
-
rels.push(path.relative(root, full));
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
walk(root);
|
|
71
|
-
if (rels.length === 0)
|
|
72
|
-
return null;
|
|
73
|
-
rels.sort(); // readdir order is filesystem-dependent; the hash must not be
|
|
74
|
-
const hash = crypto.createHash("sha256");
|
|
75
|
-
for (const rel of rels) {
|
|
76
|
-
hash.update(rel);
|
|
77
|
-
hash.update(fs.readFileSync(path.join(root, rel)));
|
|
78
|
-
}
|
|
79
|
-
return hash.digest("hex").slice(0, 16);
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* This build's rev. "dev" when there's nothing compiled to hash — running from
|
|
83
|
-
* source under tsx, where the tree is .ts and the walk finds no .js at all.
|
|
84
|
-
*/
|
|
85
|
-
export const BUILD_REV = (() => {
|
|
86
|
-
try {
|
|
87
|
-
// dist/daemon/server.js → dist: everything this process can import.
|
|
88
|
-
const me = fileURLToPath(import.meta.url);
|
|
89
|
-
return fingerprintBuild(path.dirname(path.dirname(me))) ?? "dev";
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return "dev";
|
|
93
|
-
}
|
|
94
|
-
})();
|
|
95
|
-
/**
|
|
96
|
-
* Loom's own install root — the directory to ask "am I behind my remote?".
|
|
97
|
-
*
|
|
98
|
-
* Walks up from this module looking for a .git directory (a source checkout or
|
|
99
|
-
* a cloned install). null when Loom was installed some other way (a package),
|
|
100
|
-
* in which case "check for updates" honestly says there's no git tree to check.
|
|
101
|
-
*/
|
|
102
|
-
function loomRoot() {
|
|
103
|
-
try {
|
|
104
|
-
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
105
|
-
for (let i = 0; i < 6; i++) {
|
|
106
|
-
if (fs.existsSync(path.join(dir, ".git")))
|
|
107
|
-
return dir;
|
|
108
|
-
const parent = path.dirname(dir);
|
|
109
|
-
if (parent === dir)
|
|
110
|
-
break;
|
|
111
|
-
dir = parent;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
/* fall through */
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
const TERM_MARK = "__LOOM_END__";
|
|
120
|
-
/**
|
|
121
|
-
* Just enough to give a pasted attachment a sensible extension.
|
|
122
|
-
*/
|
|
123
|
-
const MIME_EXT = {
|
|
124
|
-
"image/png": "png",
|
|
125
|
-
"image/jpeg": "jpg",
|
|
126
|
-
"image/gif": "gif",
|
|
127
|
-
"image/webp": "webp",
|
|
128
|
-
"image/svg+xml": "svg",
|
|
129
|
-
"text/markdown": "md",
|
|
130
|
-
"text/plain": "txt",
|
|
131
|
-
"application/pdf": "pdf",
|
|
132
|
-
};
|
|
133
|
-
/**
|
|
134
|
-
* Paths from an HTTP body, as strings and nothing else.
|
|
135
|
-
*
|
|
136
|
-
* These reach `git checkout --` and `git clean -fd`, which delete things. A
|
|
137
|
-
* body is whatever the caller felt like sending, so anything that isn't a
|
|
138
|
-
* string is dropped here rather than stringified into a path somewhere deeper.
|
|
139
|
-
* This is the shape check; core/git.ts does the safety check, resolving every
|
|
140
|
-
* one of them against the project root.
|
|
141
|
-
*/
|
|
142
|
-
function asPaths(v) {
|
|
143
|
-
if (!Array.isArray(v))
|
|
144
|
-
return [];
|
|
145
|
-
return v.filter((x) => typeof x === "string" && x.length > 0).slice(0, 500);
|
|
146
|
-
}
|
|
65
|
+
import { SpecRunner } from "./specs.js";
|
|
66
|
+
import { BUILD_REV, DEFAULT_PORT, tailscaleIp } from './system.js';
|
|
67
|
+
import { TeamLink } from "./team.js";
|
|
68
|
+
import { Onboarding } from "./onboard.js";
|
|
69
|
+
import { addProjectAt } from "./routes/projects.js";
|
|
70
|
+
import { registerCrewsRoutes } from "./routes/crews.js";
|
|
71
|
+
import { registerProofRoutes } from "./routes/proof.js";
|
|
72
|
+
import { registerShiftRoutes } from "./routes/shift.js";
|
|
73
|
+
import { registerOnboardRoutes } from "./routes/onboard.js";
|
|
74
|
+
import { TerminalManager } from "./terminals.js";
|
|
75
|
+
export { BUILD_REV, DEFAULT_PORT, fingerprintBuild, isLoopback, isLoopbackHost, lanIp, listModelsForKind, tailscaleFunnel, tailscaleIp, tailscaleState, tailscaleUp } from './system.js';
|
|
147
76
|
export class LoomDaemon {
|
|
148
77
|
app = express();
|
|
149
78
|
server = null;
|
|
@@ -151,6 +80,9 @@ export class LoomDaemon {
|
|
|
151
80
|
auth;
|
|
152
81
|
runtimes = new Map();
|
|
153
82
|
sockets = new Map();
|
|
83
|
+
delivery = new ClientDelivery(this.sockets, (socket) => socket.terminate());
|
|
84
|
+
/** In-flight self-heal recheck timers, cleared on close. */
|
|
85
|
+
healTimers = new Set();
|
|
154
86
|
/** Terminal shells — a real pty when node-pty loaded, else plain pipes. */
|
|
155
87
|
terminals = new TerminalManager({
|
|
156
88
|
onData: (projectId, term, chunk) => this.broadcastTerm(projectId, { type: "term", term, chunk }),
|
|
@@ -160,8 +92,10 @@ export class LoomDaemon {
|
|
|
160
92
|
this.broadcastTerm(projectId, { type: "term", term, closed: true });
|
|
161
93
|
},
|
|
162
94
|
onTitle: (projectId, term, title) => this.broadcastTerm(projectId, { type: "term", term, title }),
|
|
163
|
-
});
|
|
95
|
+
}, 12, path.join(ensureLoomHome(), "scrollback"));
|
|
164
96
|
unstreamLogs = null;
|
|
97
|
+
/** Playwright runs, one per project at a time. See specs.ts. */
|
|
98
|
+
specRunner = new SpecRunner();
|
|
165
99
|
host;
|
|
166
100
|
port;
|
|
167
101
|
/**
|
|
@@ -172,7 +106,100 @@ export class LoomDaemon {
|
|
|
172
106
|
* localhost listener ever being disturbed.
|
|
173
107
|
*/
|
|
174
108
|
extra = new Map();
|
|
109
|
+
/** Tool uses waiting on a human, from agents in "always ask" mode. */
|
|
110
|
+
approvals = new Map();
|
|
111
|
+
/**
|
|
112
|
+
* Put a tool use in front of a person and wait.
|
|
113
|
+
*
|
|
114
|
+
* One implementation for every caller — CLI adapters and model agents all
|
|
115
|
+
* ask from inside this process (core/approvals.ts registers this as the
|
|
116
|
+
* broker). The card, the
|
|
117
|
+
* thread entry, the timeout and the audit trail are the same either way,
|
|
118
|
+
* because "who is asking" should not change what you are shown.
|
|
119
|
+
*/
|
|
120
|
+
async askHuman(req) {
|
|
121
|
+
if (req.signal?.aborted)
|
|
122
|
+
return { behavior: "deny", message: "The agent stopped waiting." };
|
|
123
|
+
const rt = await this.runtime(req.project).catch(() => null);
|
|
124
|
+
if (!rt)
|
|
125
|
+
return { behavior: "deny", message: "project not open" };
|
|
126
|
+
const id = crypto.randomBytes(6).toString("hex");
|
|
127
|
+
const tool = req.tool.slice(0, 120);
|
|
128
|
+
const preview = (req.summary ?? JSON.stringify(req.input ?? {})).slice(0, 4000);
|
|
129
|
+
const chat = rt.chatOf(req.agent);
|
|
130
|
+
rt.log.append({
|
|
131
|
+
kind: "approval",
|
|
132
|
+
agentId: req.agent,
|
|
133
|
+
...(chat ? { chat } : {}),
|
|
134
|
+
payload: { phase: "requested", approvalId: id, tool, input: preview, ...(req.sessionOption ? { sessionOption: true } : {}) },
|
|
135
|
+
});
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const abandoned = () => settle({ behavior: "deny", message: "The agent stopped waiting." });
|
|
138
|
+
const settle = (d) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
req.signal?.removeEventListener("abort", abandoned);
|
|
141
|
+
if (!this.approvals.delete(id))
|
|
142
|
+
return;
|
|
143
|
+
rt.log.append({
|
|
144
|
+
kind: "approval",
|
|
145
|
+
agentId: req.agent,
|
|
146
|
+
...(chat ? { chat } : {}),
|
|
147
|
+
payload: { phase: "decided", approvalId: id, tool, behavior: d.behavior, ...(d.scope === "session" ? { scope: "session" } : {}), ...(d.message ? { message: d.message } : {}) },
|
|
148
|
+
});
|
|
149
|
+
resolve(d);
|
|
150
|
+
};
|
|
151
|
+
const timer = setTimeout(() => settle({ behavior: "deny", message: "No answer within 30 minutes — denied." }), 30 * 60_000);
|
|
152
|
+
this.approvals.set(id, {
|
|
153
|
+
id,
|
|
154
|
+
projectId: req.project,
|
|
155
|
+
agent: req.agent,
|
|
156
|
+
tool,
|
|
157
|
+
input: req.input ?? {},
|
|
158
|
+
...(req.sessionOption ? { sessionOption: true } : {}),
|
|
159
|
+
createdAt: Date.now(),
|
|
160
|
+
settle,
|
|
161
|
+
});
|
|
162
|
+
// An agent whose turn ended has stopped waiting; don't hold the card open.
|
|
163
|
+
req.signal?.addEventListener("abort", abandoned, { once: true });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Loom Cloud relay, when enabled. See daemon/relay.ts. */
|
|
167
|
+
relay = null;
|
|
168
|
+
relayError = null;
|
|
169
|
+
relayTransportFactory;
|
|
170
|
+
/** Loom Teams: this daemon on a Team Hub. See daemon/team.ts. */
|
|
171
|
+
team;
|
|
172
|
+
/** One-link onboarding: joining a team, its repo and its crews from an invite. See daemon/onboard.ts. */
|
|
173
|
+
onboarding;
|
|
174
|
+
gh;
|
|
175
175
|
constructor(opts = {}) {
|
|
176
|
+
this.team = new TeamLink({
|
|
177
|
+
runtimes: () => [...this.runtimes.values()],
|
|
178
|
+
broadcast: (frame) => {
|
|
179
|
+
this.delivery.publish(frame, { kind: "admin" });
|
|
180
|
+
},
|
|
181
|
+
...(opts.hubFactory ? { hubFactory: opts.hubFactory } : {}),
|
|
182
|
+
...(opts.runnerExec ? { runnerExec: opts.runnerExec } : {}),
|
|
183
|
+
...(opts.gh ? { gh: opts.gh } : {}),
|
|
184
|
+
// Phase 5: a runner opens each goal's fresh clone as its own project, and drops it after.
|
|
185
|
+
openProject: async (dir, name) => this.runtime(registerProject(dir, name).id),
|
|
186
|
+
closeProject: async (rt) => {
|
|
187
|
+
await rt.close().catch(() => { });
|
|
188
|
+
this.runtimes.delete(rt.info.id);
|
|
189
|
+
unregisterProject(rt.info.id);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
this.onboarding = new Onboarding({
|
|
193
|
+
team: this.team,
|
|
194
|
+
projects: () => listProjects(),
|
|
195
|
+
addProject: async (dir, name) => (await addProjectAt(dir, name)).info,
|
|
196
|
+
runtime: (id) => this.runtime(id),
|
|
197
|
+
broadcast: (frame) => this.delivery.publish(frame, { kind: "admin" }),
|
|
198
|
+
...(opts.gh ? { gh: opts.gh } : {}),
|
|
199
|
+
...opts.onboard,
|
|
200
|
+
});
|
|
201
|
+
this.gh = opts.gh;
|
|
202
|
+
this.relayTransportFactory = opts.relayTransport;
|
|
176
203
|
this.host = opts.host ?? "127.0.0.1";
|
|
177
204
|
this.port = opts.port ?? DEFAULT_PORT;
|
|
178
205
|
const cfg = ensureDaemonConfig({ host: this.host, port: this.port });
|
|
@@ -183,8 +210,55 @@ export class LoomDaemon {
|
|
|
183
210
|
// HTTP routes
|
|
184
211
|
// -------------------------------------------------------------------------
|
|
185
212
|
routes() {
|
|
213
|
+
const daemon = this;
|
|
214
|
+
const ctx = {
|
|
215
|
+
get terminals() { return daemon.terminals; },
|
|
216
|
+
get auth() { return daemon.auth; },
|
|
217
|
+
askHuman: (...args) => this.askHuman(...args),
|
|
218
|
+
cachedRelease: (...args) => this.cachedRelease(...args),
|
|
219
|
+
get updating() { return daemon.updating; },
|
|
220
|
+
set updating(value) { daemon.updating = value; },
|
|
221
|
+
close: (...args) => this.close(...args),
|
|
222
|
+
exposedIps: (...args) => this.exposedIps(...args),
|
|
223
|
+
get host() { return daemon.host; },
|
|
224
|
+
get port() { return daemon.port; },
|
|
225
|
+
expose: (...args) => this.expose(...args),
|
|
226
|
+
cloudLinkParams: (...args) => this.cloudLinkParams(...args),
|
|
227
|
+
get team() { return daemon.team; },
|
|
228
|
+
get runtimes() { return daemon.runtimes; },
|
|
229
|
+
cloudStatus: (...args) => this.cloudStatus(...args),
|
|
230
|
+
startCloud: (...args) => this.startCloud(...args),
|
|
231
|
+
stopCloud: (...args) => this.stopCloud(...args),
|
|
232
|
+
pushTokens: (...args) => this.pushTokens(...args),
|
|
233
|
+
runtime: (...args) => this.runtime(...args),
|
|
234
|
+
get specRunner() { return daemon.specRunner; },
|
|
235
|
+
startHealLoop: (...args) => this.startHealLoop(...args),
|
|
236
|
+
broadcastTerm: (...args) => this.broadcastTerm(...args),
|
|
237
|
+
get approvals() { return daemon.approvals; },
|
|
238
|
+
};
|
|
186
239
|
const app = this.app;
|
|
187
|
-
app.
|
|
240
|
+
app.disable("x-powered-by");
|
|
241
|
+
// Headers every response carries. No full CSP: the app shell is one
|
|
242
|
+
// inline document by design. What it does get: no MIME sniffing, no
|
|
243
|
+
// referrer leaking a token-bearing URL to a site you click through to,
|
|
244
|
+
// and only Loom itself may frame the app (the Browser tab's previews
|
|
245
|
+
// are same-origin, so they keep working).
|
|
246
|
+
app.use((req, res, next) => {
|
|
247
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
248
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
249
|
+
res.setHeader("Permissions-Policy", "geolocation=(), payment=(), usb=(), serial=(), bluetooth=()");
|
|
250
|
+
if (req.path === "/app" || req.path === "/") {
|
|
251
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
252
|
+
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
|
|
253
|
+
}
|
|
254
|
+
next();
|
|
255
|
+
});
|
|
256
|
+
// 2 MB for every JSON body but an attachment: a 12 MB file is ~16 MB as
|
|
257
|
+
// base64, and the global cap used to refuse a retina screenshot at 413
|
|
258
|
+
// before the route's own 12 MB check ever ran.
|
|
259
|
+
const smallJson = express.json({ limit: "2mb" });
|
|
260
|
+
const attachmentJson = express.json({ limit: "17mb" });
|
|
261
|
+
app.use((req, res, next) => (/^\/api\/projects\/[^/]+\/attachments$/.test(req.path) ? attachmentJson : smallJson)(req, res, next));
|
|
188
262
|
// CORS for same-machine browser origins only (the Expo web dev server running
|
|
189
263
|
// on another localhost port, etc.). Scoped to loopback so it can't be abused
|
|
190
264
|
// cross-site; the bearer wall below still guards every data route. Preflight
|
|
@@ -194,1221 +268,141 @@ export class LoomDaemon {
|
|
|
194
268
|
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
195
269
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
196
270
|
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
197
|
-
|
|
271
|
+
// PUT and PATCH belong here: toggling a skill, switching an agent on or
|
|
272
|
+
// off, and updating an MCP server all use them, and a cross-origin
|
|
273
|
+
// client (the Expo web build, a paired browser on another port) had
|
|
274
|
+
// those requests refused at the preflight while the same-origin console
|
|
275
|
+
// worked — which makes it look like the feature is broken only on
|
|
276
|
+
// mobile.
|
|
277
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
198
278
|
res.setHeader("Vary", "Origin");
|
|
199
279
|
if (req.method === "OPTIONS")
|
|
200
280
|
return void res.sendStatus(204);
|
|
201
281
|
}
|
|
202
282
|
next();
|
|
203
283
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
app.get("/app", (_req, res) => {
|
|
208
|
-
// Never cache the shell: a redeployed daemon must serve its own UI.
|
|
209
|
-
res.type("html").setHeader("Cache-Control", "no-store").send(APP_HTML);
|
|
210
|
-
});
|
|
211
|
-
app.get("/app/manifest.webmanifest", (_req, res) => {
|
|
212
|
-
res
|
|
213
|
-
.type("application/manifest+json")
|
|
214
|
-
.setHeader("Cache-Control", "no-store")
|
|
215
|
-
.send(JSON.stringify(APP_MANIFEST));
|
|
216
|
-
});
|
|
217
|
-
// The UI sans (Geist, SIL OFL 1.1) — embedded so the app works offline
|
|
218
|
-
// on the tailnet with no CDN. Immutable: cache hard.
|
|
219
|
-
app.get("/app/fonts/geist.woff2", (_req, res) => {
|
|
220
|
-
res
|
|
221
|
-
.type("font/woff2")
|
|
222
|
-
.setHeader("Cache-Control", "public, max-age=31536000, immutable")
|
|
223
|
-
.send(GEIST_WOFF2);
|
|
224
|
-
});
|
|
225
|
-
// xterm.js and its addons, served straight from node_modules — the app has
|
|
226
|
-
// no build step and must work offline on a tailnet, so no bundler, no CDN.
|
|
227
|
-
// These are plain UMD files the browser loads with <script>.
|
|
228
|
-
const vendor = {
|
|
229
|
-
"xterm.js": ["@xterm/xterm/lib/xterm.js", "application/javascript"],
|
|
230
|
-
"xterm.css": ["@xterm/xterm/css/xterm.css", "text/css"],
|
|
231
|
-
"addon-fit.js": ["@xterm/addon-fit/lib/addon-fit.js", "application/javascript"],
|
|
232
|
-
"addon-web-links.js": [
|
|
233
|
-
"@xterm/addon-web-links/lib/addon-web-links.js",
|
|
234
|
-
"application/javascript",
|
|
235
|
-
],
|
|
236
|
-
};
|
|
237
|
-
app.get("/app/vendor/:file", (req, res) => {
|
|
238
|
-
const entry = vendor[String(req.params.file)];
|
|
239
|
-
if (!entry)
|
|
240
|
-
return void res.status(404).end();
|
|
241
|
-
try {
|
|
242
|
-
res
|
|
243
|
-
.type(entry[1])
|
|
244
|
-
.setHeader("Cache-Control", "public, max-age=31536000, immutable")
|
|
245
|
-
.send(fs.readFileSync(createRequire(import.meta.url).resolve(entry[0])));
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
res.status(404).end();
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
app.get("/api/health", (_req, res) => {
|
|
252
|
-
res.json({
|
|
253
|
-
ok: true,
|
|
254
|
-
name: "loom",
|
|
255
|
-
version: "0.1.0",
|
|
256
|
-
rev: BUILD_REV,
|
|
257
|
-
terminal: this.terminals.mode,
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
app.post("/api/pair/claim", (req, res) => {
|
|
261
|
-
const { token, name } = (req.body ?? {});
|
|
262
|
-
if (!token)
|
|
263
|
-
return void res.status(400).json({ error: "missing token" });
|
|
264
|
-
const claimed = this.auth.claim(token, name ?? "device");
|
|
265
|
-
if (!claimed)
|
|
266
|
-
return void res.status(403).json({ error: "invalid or expired pairing token" });
|
|
267
|
-
res.json(claimed);
|
|
268
|
-
});
|
|
269
|
-
/**
|
|
270
|
-
* The local admin console bootstraps here — before the bearer wall, gated by
|
|
271
|
-
* the socket being loopback. A same-machine caller gets the admin token (it
|
|
272
|
-
* lives in a config file they can already read), which is what lets the web
|
|
273
|
-
* app served on localhost mint pairing codes and open phone access. Everyone
|
|
274
|
-
* else — a phone on the tailnet, anything past localhost — is turned away and
|
|
275
|
-
* pairs like any other device. Admin-ness stays a property of the *token*,
|
|
276
|
-
* so a paired client is never an admin no matter where it connects from.
|
|
277
|
-
*/
|
|
278
|
-
app.get("/api/bootstrap", (req, res) => {
|
|
279
|
-
// Both must hold: the TCP peer is loopback (can't be spoofed by a header),
|
|
280
|
-
// AND the Host is a loopback literal (defeats DNS rebinding, where the
|
|
281
|
-
// socket is loopback but the browser sends the attacker's hostname).
|
|
282
|
-
if (!isLoopback(req.socket.remoteAddress) || !isLoopbackHost(req.headers.host)) {
|
|
283
|
-
return void res.status(403).json({ error: "not a local request" });
|
|
284
|
-
}
|
|
285
|
-
res.json({ token: this.auth.adminToken(), admin: true });
|
|
286
|
-
});
|
|
284
|
+
registerAssetsRoutes(app);
|
|
285
|
+
registerPublicRoutes(app, ctx);
|
|
286
|
+
registerPreviewPublic(app);
|
|
287
287
|
// Everything else requires a bearer token.
|
|
288
288
|
app.use((req, res, next) => {
|
|
289
|
+
// Inbound alert webhooks can't carry the admin bearer token — an
|
|
290
|
+
// Alertmanager-style sender has no Loom token to present — so they
|
|
291
|
+
// authenticate with their own LOOM_WEBHOOK_SECRET instead. Set that
|
|
292
|
+
// secret whenever the daemon binds past localhost.
|
|
293
|
+
if (req.path.startsWith("/api/webhooks/")) {
|
|
294
|
+
next();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
289
297
|
const token = bearerToken(req.headers.authorization);
|
|
290
298
|
if (!this.auth.isAuthorized(token)) {
|
|
291
299
|
res.status(401).json({ error: "unauthorized" });
|
|
292
300
|
return;
|
|
293
301
|
}
|
|
302
|
+
// Admin is the admin token, and only the admin token. A same-machine
|
|
303
|
+
// caller becomes admin by *fetching* that token from /api/bootstrap
|
|
304
|
+
// (above) — which is what the local console does — never by virtue of
|
|
305
|
+
// the socket it arrived on. Trusting loopback here would silently
|
|
306
|
+
// promote every paired client that happens to connect over it, and a
|
|
307
|
+
// revoked phone would keep working for as long as it stayed local.
|
|
294
308
|
req.isAdmin = this.auth.isAdmin(token);
|
|
309
|
+
req.projectScope =
|
|
310
|
+
this.auth.allowedProjects(token);
|
|
295
311
|
next();
|
|
296
312
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
* and none of a stranger's business — which matters the moment the daemon
|
|
303
|
-
* binds past localhost (--host, Tailscale).
|
|
304
|
-
*
|
|
305
|
-
* Probing GUI bridges means a couple of HTTP round trips to their debug
|
|
306
|
-
* ports, so this is a request you make when you open Settings, not something
|
|
307
|
-
* the app polls.
|
|
308
|
-
*/
|
|
309
|
-
app.get("/api/setup", (_req, res) => {
|
|
310
|
-
void setupReport()
|
|
311
|
-
.then((report) => res.json(report))
|
|
312
|
-
.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
|
|
313
|
+
// Cheap liveness: HEAD answers with no body, for probes that only ask "is
|
|
314
|
+
// it up" — a monitor hitting GET /api/health every second serialises the
|
|
315
|
+
// whole health payload to throw it away.
|
|
316
|
+
app.head("/api/health", (_req, res) => {
|
|
317
|
+
res.status(200).end();
|
|
313
318
|
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
* module-init time.
|
|
319
|
-
*/
|
|
320
|
-
app.get("/api/doctor", (req, res) => {
|
|
321
|
-
void (async () => {
|
|
322
|
-
try {
|
|
323
|
-
const { envChecks, projectChecks } = await import("../cli/doctor.js");
|
|
324
|
-
const checks = await envChecks();
|
|
325
|
-
const projId = req.query.project;
|
|
326
|
-
if (projId) {
|
|
327
|
-
const info = findProject(projId);
|
|
328
|
-
if (info)
|
|
329
|
-
checks.push(...projectChecks(info.dir));
|
|
330
|
-
}
|
|
331
|
-
res.json({ checks });
|
|
332
|
-
}
|
|
333
|
-
catch (err) {
|
|
334
|
-
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
335
|
-
}
|
|
336
|
-
})();
|
|
337
|
-
});
|
|
338
|
-
/**
|
|
339
|
-
* Is this Loom current? Version + build rev, and — when Loom itself is a git
|
|
340
|
-
* checkout — how many commits its own tree is behind its remote. Honest about
|
|
341
|
-
* the two different "updates" that matter: a newer daemon build waiting to be
|
|
342
|
-
* restarted (rev), and newer code waiting to be pulled (behind).
|
|
343
|
-
*/
|
|
344
|
-
app.get("/api/updates", (_req, res) => {
|
|
345
|
-
void (async () => {
|
|
346
|
-
const root = loomRoot();
|
|
347
|
-
let git = null;
|
|
348
|
-
if (root) {
|
|
349
|
-
const { remoteBehind } = await import("../core/git.js");
|
|
350
|
-
git = await remoteBehind(root).catch(() => null);
|
|
351
|
-
}
|
|
352
|
-
res.json({ version: "0.1.0", rev: BUILD_REV, root, git });
|
|
353
|
-
})();
|
|
354
|
-
});
|
|
355
|
-
/**
|
|
356
|
-
* Is `gh` logged in, and as whom — machine-wide, so no project needed. The
|
|
357
|
-
* whole GitHub half of Loom (board PRs, Projects, review) rides on this; the
|
|
358
|
-
* status bar shows it and offers Connect when it's false.
|
|
359
|
-
*/
|
|
360
|
-
app.get("/api/github/status", (_req, res) => {
|
|
361
|
-
void ghAuthStatus()
|
|
362
|
-
.then((s) => res.json(s))
|
|
363
|
-
.catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
|
|
364
|
-
});
|
|
365
|
-
/**
|
|
366
|
-
* LoomPad connectivity — proxies the voice backend's /health so the web app
|
|
367
|
-
* can show a live "LoomPad connected" pill without a cross-origin fetch. The
|
|
368
|
-
* backend (orchestrator-pad) does STT -> agent -> TTS for the physical pad;
|
|
369
|
-
* when it's up, the pad gets its spoken replies. Best-effort: an unreachable
|
|
370
|
-
* backend just returns { up:false } (the pill goes grey), never an error.
|
|
371
|
-
*/
|
|
372
|
-
app.get("/api/loompad/health", (_req, res) => {
|
|
373
|
-
const base = (process.env.LOOMPAD_BACKEND_URL || "http://127.0.0.1:8080").replace(/\/+$/, "");
|
|
374
|
-
const ctl = new AbortController();
|
|
375
|
-
const timer = setTimeout(() => ctl.abort(), 2500);
|
|
376
|
-
void fetch(base + "/health", { signal: ctl.signal })
|
|
377
|
-
.then(async (r) => {
|
|
378
|
-
clearTimeout(timer);
|
|
379
|
-
if (!r.ok)
|
|
380
|
-
return void res.json({ up: false, backend: base, status: r.status });
|
|
381
|
-
const body = (await r.json().catch(() => ({})));
|
|
382
|
-
res.json({ up: true, backend: base, ...body });
|
|
383
|
-
})
|
|
384
|
-
.catch(() => {
|
|
385
|
-
clearTimeout(timer);
|
|
386
|
-
res.json({ up: false, backend: base });
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
/**
|
|
390
|
-
* The two networks a phone could use to reach this daemon — the LAN and the
|
|
391
|
-
* tailnet — with, for each, the address and whether the phone can actually
|
|
392
|
-
* get here on it *right now*. It can't when we're bound to localhost, which
|
|
393
|
-
* is the default; `reachable:false` is the modal's cue to offer "enable
|
|
394
|
-
* phone access" (expose) before showing a QR that wouldn't resolve.
|
|
395
|
-
*/
|
|
396
|
-
app.get("/api/pair/networks", (req, res) => {
|
|
397
|
-
if (!req.isAdmin) {
|
|
398
|
-
return void res.status(403).json({ error: "admin only" });
|
|
399
|
-
}
|
|
400
|
-
void (async () => {
|
|
401
|
-
const exposed = this.exposedIps();
|
|
402
|
-
const reach = (ip) => Boolean(ip) && (this.host === "0.0.0.0" || this.host === ip || exposed.includes(ip));
|
|
403
|
-
const lan = lanIp();
|
|
404
|
-
let ts = null;
|
|
405
|
-
try {
|
|
406
|
-
ts = await tailscaleIp();
|
|
407
|
-
}
|
|
408
|
-
catch {
|
|
409
|
-
ts = null;
|
|
410
|
-
}
|
|
411
|
-
res.json({
|
|
412
|
-
port: this.port,
|
|
413
|
-
boundHost: this.host,
|
|
414
|
-
exposed,
|
|
415
|
-
localnet: { ip: lan, reachable: reach(lan) },
|
|
416
|
-
tailnet: ts
|
|
417
|
-
? { ip: ts, available: true, reachable: reach(ts) }
|
|
418
|
-
: {
|
|
419
|
-
ip: null,
|
|
420
|
-
available: false,
|
|
421
|
-
reachable: false,
|
|
422
|
-
reason: "Tailscale isn't logged in on this machine (run `tailscale up`)",
|
|
423
|
-
},
|
|
424
|
-
});
|
|
425
|
-
})();
|
|
426
|
-
});
|
|
427
|
-
/**
|
|
428
|
-
* Make a phone-reachable address go live — a phone can't reach a
|
|
429
|
-
* localhost-only daemon. We add a second listener on the requested LAN or
|
|
430
|
-
* tailnet IP (never touching localhost), so this is safe to await and report
|
|
431
|
-
* on directly. Explicit and user-driven (you clicked "connect a phone"), and
|
|
432
|
-
* behind the token wall the whole time.
|
|
433
|
-
*/
|
|
434
|
-
app.post("/api/pair/expose", (req, res) => {
|
|
435
|
-
if (!req.isAdmin) {
|
|
436
|
-
return void res.status(403).json({ error: "admin only" });
|
|
437
|
-
}
|
|
438
|
-
void (async () => {
|
|
439
|
-
const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
|
|
440
|
-
let ts = null;
|
|
441
|
-
try {
|
|
442
|
-
ts = await tailscaleIp();
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
ts = null;
|
|
446
|
-
}
|
|
447
|
-
// Only ever bind an address that is genuinely ours (LAN or tailnet).
|
|
448
|
-
const allowed = new Set([lanIp(), ts].filter(Boolean));
|
|
449
|
-
if (!wanted || !allowed.has(wanted)) {
|
|
450
|
-
return void res.status(400).json({ error: "not a local or tailnet address of this machine" });
|
|
451
|
-
}
|
|
452
|
-
try {
|
|
453
|
-
await this.expose(wanted);
|
|
454
|
-
res.json({ ok: true, ip: wanted, port: this.port, exposed: this.exposedIps() });
|
|
455
|
-
}
|
|
456
|
-
catch (err) {
|
|
457
|
-
logbook.error("daemon", `could not open phone access on ${wanted}`, err);
|
|
458
|
-
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
459
|
-
}
|
|
460
|
-
})();
|
|
461
|
-
});
|
|
462
|
-
app.post("/api/pair/new", (req, res) => {
|
|
463
|
-
if (!req.isAdmin) {
|
|
464
|
-
return void res.status(403).json({ error: "admin only" });
|
|
465
|
-
}
|
|
466
|
-
void (async () => {
|
|
467
|
-
// The QR must point at the address the phone will actually use, so the
|
|
468
|
-
// caller may ask for the LAN or tailnet host — but only those. An
|
|
469
|
-
// arbitrary host from the client never reaches the link.
|
|
470
|
-
const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
|
|
471
|
-
let ts = null;
|
|
472
|
-
try {
|
|
473
|
-
ts = await tailscaleIp();
|
|
474
|
-
}
|
|
475
|
-
catch {
|
|
476
|
-
ts = null;
|
|
477
|
-
}
|
|
478
|
-
const allowed = new Set([this.host, lanIp(), ts].filter(Boolean));
|
|
479
|
-
const host = wanted && allowed.has(wanted) ? wanted : this.host;
|
|
480
|
-
const { token, expiresAt } = this.auth.newPairingToken();
|
|
481
|
-
const url = `http://${host}:${this.port}`;
|
|
482
|
-
// Deep link: scanning it with any camera opens the app, which claims the
|
|
483
|
-
// single-use token from the URL fragment and pairs itself.
|
|
484
|
-
const link = `${url}/app#pair=${token}`;
|
|
485
|
-
let qrSvg;
|
|
486
|
-
try {
|
|
487
|
-
qrSvg = await QRCode.toString(link, {
|
|
488
|
-
type: "svg",
|
|
489
|
-
margin: 1,
|
|
490
|
-
errorCorrectionLevel: "M",
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
catch (err) {
|
|
494
|
-
// The link still works even if the QR doesn't render — degrade, don't fail.
|
|
495
|
-
logbook.warn("pair", "QR render failed — the copy link still works", err);
|
|
496
|
-
}
|
|
497
|
-
res.json({ token, expiresAt, url, link, ...(qrSvg ? { qrSvg } : {}) });
|
|
498
|
-
})();
|
|
499
|
-
});
|
|
500
|
-
app.get("/api/pair/clients", (_req, res) => {
|
|
501
|
-
res.json({ clients: this.auth.clients() });
|
|
502
|
-
});
|
|
503
|
-
app.delete("/api/pair/clients/:clientId", (req, res) => {
|
|
504
|
-
if (!req.isAdmin) {
|
|
505
|
-
return void res.status(403).json({ error: "admin only" });
|
|
506
|
-
}
|
|
507
|
-
const revoked = this.auth.revoke(String(req.params.clientId));
|
|
508
|
-
if (!revoked)
|
|
509
|
-
return void res.status(404).json({ error: "unknown client" });
|
|
510
|
-
res.json({ revoked: true });
|
|
511
|
-
});
|
|
512
|
-
// A paired device registers (or clears) its Expo push token.
|
|
513
|
-
app.post("/api/push/register", (req, res) => {
|
|
514
|
-
const me = this.auth.clientFor(bearerToken(req.headers.authorization));
|
|
515
|
-
if (!me)
|
|
516
|
-
return void res.status(403).json({ error: "device tokens only — pair first" });
|
|
517
|
-
const { token, platform } = (req.body ?? {});
|
|
518
|
-
if (!token?.trim())
|
|
519
|
-
return void res.status(400).json({ error: "missing token" });
|
|
520
|
-
this.auth.setPushToken(me.id, token.trim(), platform);
|
|
521
|
-
res.json({ registered: true });
|
|
522
|
-
});
|
|
523
|
-
app.delete("/api/push/register", (req, res) => {
|
|
524
|
-
const me = this.auth.clientFor(bearerToken(req.headers.authorization));
|
|
525
|
-
if (!me)
|
|
526
|
-
return void res.status(403).json({ error: "device tokens only" });
|
|
527
|
-
this.auth.setPushToken(me.id, null);
|
|
528
|
-
res.json({ registered: false });
|
|
529
|
-
});
|
|
530
|
-
// Admin: fire a test push at every registered device.
|
|
531
|
-
app.post("/api/push/test", (req, res) => {
|
|
532
|
-
if (!req.isAdmin) {
|
|
533
|
-
return void res.status(403).json({ error: "admin only" });
|
|
534
|
-
}
|
|
535
|
-
const tokens = this.pushTokens();
|
|
536
|
-
void sendExpoPush(tokens, {
|
|
537
|
-
title: "Loom",
|
|
538
|
-
body: "test notification — pairing works ✓",
|
|
539
|
-
});
|
|
540
|
-
res.json({ sent: tokens.length });
|
|
541
|
-
});
|
|
542
|
-
app.get("/api/projects", (_req, res) => {
|
|
543
|
-
void (async () => {
|
|
544
|
-
const projects = [];
|
|
545
|
-
for (const info of listProjects()) {
|
|
546
|
-
try {
|
|
547
|
-
const rt = await this.runtime(info.id);
|
|
548
|
-
projects.push(await rt.status());
|
|
549
|
-
}
|
|
550
|
-
catch (err) {
|
|
551
|
-
projects.push({
|
|
552
|
-
id: info.id,
|
|
553
|
-
name: info.name,
|
|
554
|
-
dir: info.dir,
|
|
555
|
-
holder: null,
|
|
556
|
-
agents: [],
|
|
557
|
-
lastEvent: null,
|
|
558
|
-
needsInput: false,
|
|
559
|
-
error: String(err instanceof Error ? err.message : err),
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
res.json({ projects });
|
|
564
|
-
})();
|
|
565
|
-
});
|
|
566
|
-
app.post("/api/projects", (req, res) => {
|
|
567
|
-
void (async () => {
|
|
568
|
-
const { dir, name } = (req.body ?? {});
|
|
569
|
-
if (!dir)
|
|
570
|
-
return void res.status(400).json({ error: "missing dir" });
|
|
571
|
-
const resolved = path.resolve(dir);
|
|
572
|
-
if (!fs.existsSync(resolved)) {
|
|
573
|
-
return void res.status(400).json({ error: `no such directory: ${resolved}` });
|
|
574
|
-
}
|
|
575
|
-
let config = readProjectConfig(resolved);
|
|
576
|
-
if (!config) {
|
|
577
|
-
// Every ADE Loom can drive, probed in parallel — see core/ades.ts.
|
|
578
|
-
// This used to name claude and opencode by hand, which is how the list
|
|
579
|
-
// of what Loom actually drives drifted from the list of logos it ships.
|
|
580
|
-
const availability = await detectAdes();
|
|
581
|
-
const agents = defaultAgentConfigs(availability);
|
|
582
|
-
const routes = buildDefaultRoutes(agents);
|
|
583
|
-
config = {
|
|
584
|
-
name: name ?? path.basename(resolved),
|
|
585
|
-
agents,
|
|
586
|
-
...(routes ? { routes } : {}),
|
|
587
|
-
};
|
|
588
|
-
writeProjectConfig(resolved, config);
|
|
589
|
-
}
|
|
590
|
-
const info = registerProject(resolved, config.name);
|
|
591
|
-
res.json({ project: info, config });
|
|
592
|
-
})();
|
|
593
|
-
});
|
|
594
|
-
const withRuntime = (handler) => {
|
|
595
|
-
return (req, res) => {
|
|
596
|
-
void (async () => {
|
|
597
|
-
try {
|
|
598
|
-
const rt = await this.runtime(String(req.params.id));
|
|
599
|
-
await handler(rt, req, res);
|
|
600
|
-
}
|
|
601
|
-
catch (err) {
|
|
602
|
-
if (err instanceof NotHolderError) {
|
|
603
|
-
res.status(409).json({
|
|
604
|
-
error: "not_holder",
|
|
605
|
-
holder: err.holder,
|
|
606
|
-
agentId: err.agentId,
|
|
607
|
-
message: err.message,
|
|
608
|
-
});
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
if (err instanceof RouteActiveError) {
|
|
612
|
-
res.status(409).json({ error: "route_active", message: err.message });
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
// A 500 used to be a sentence for one caller and nothing else: no
|
|
616
|
-
// stack, no record, gone the moment the fetch resolved. Now the
|
|
617
|
-
// Console gets it with the stack and the route that produced it.
|
|
618
|
-
logbook.error("api", `${req.method} ${req.path} failed: ${err instanceof Error ? err.message : String(err)}`, err, String(req.params.id ?? ""));
|
|
619
|
-
res.status(500).json({ error: String(err instanceof Error ? err.message : err) });
|
|
620
|
-
}
|
|
621
|
-
})();
|
|
622
|
-
};
|
|
623
|
-
};
|
|
624
|
-
app.get("/api/projects/:id", withRuntime(async (rt, _req, res) => {
|
|
625
|
-
res.json({ project: await rt.status() });
|
|
626
|
-
}));
|
|
627
|
-
app.get("/api/projects/:id/events", withRuntime(async (rt, req, res) => {
|
|
628
|
-
const since = req.query.since ? Number(req.query.since) : undefined;
|
|
629
|
-
const limit = req.query.limit ? Number(req.query.limit) : 200;
|
|
630
|
-
// no ?chat= means the whole project — old clients keep seeing the
|
|
631
|
-
// whole thread, which is what they've always shown
|
|
632
|
-
const chat = req.query.chat ? String(req.query.chat) : undefined;
|
|
633
|
-
res.json({ events: rt.log.list({ since, limit, ...(chat ? { chat } : {}) }) });
|
|
634
|
-
}));
|
|
635
|
-
app.post("/api/projects/:id/messages", withRuntime(async (rt, req, res) => {
|
|
636
|
-
const { text, agentId, chat } = (req.body ?? {});
|
|
637
|
-
if (!text?.trim())
|
|
638
|
-
return void res.status(400).json({ error: "missing text" });
|
|
639
|
-
const result = await rt.sendMessage(text, agentId, chat ? { chat } : {});
|
|
640
|
-
res.json(result);
|
|
641
|
-
}));
|
|
642
|
-
/**
|
|
643
|
-
* Drive a GUI agent: type into Antigravity's or Kiro's own chat and read
|
|
644
|
-
* back what appeared.
|
|
645
|
-
*
|
|
646
|
-
* Separate from /messages because it is a different act. /messages hands a
|
|
647
|
-
* turn to something that can hold the baton; this types into an app you're
|
|
648
|
-
* signed into and waits for its panel to settle. The bridge never takes the
|
|
649
|
-
* lock, so an adapter mid-turn is untouched.
|
|
650
|
-
*
|
|
651
|
-
* It blocks for as long as the app takes to answer — minutes, for a real
|
|
652
|
-
* task. That's why it's its own route: nothing else here is allowed to be
|
|
653
|
-
* this slow, and the client needs to know to wait.
|
|
654
|
-
*/
|
|
655
|
-
app.post("/api/projects/:id/bridge/:agentId/ask", withRuntime(async (rt, req, res) => {
|
|
656
|
-
const { text, chat } = (req.body ?? {});
|
|
657
|
-
const agentId = String(req.params.agentId);
|
|
658
|
-
if (!text?.trim())
|
|
659
|
-
return void res.status(400).json({ error: "missing text" });
|
|
660
|
-
try {
|
|
661
|
-
const result = await rt.askBridge(agentId, text, chat ? { chat } : {});
|
|
662
|
-
res.json(result);
|
|
663
|
-
}
|
|
664
|
-
catch (err) {
|
|
665
|
-
// 409, not 500: "log into Antigravity" is a state you can fix, not a
|
|
666
|
-
// bug in the daemon, and the message is the whole value of the reply.
|
|
667
|
-
res.status(409).json({ error: err instanceof Error ? err.message : String(err) });
|
|
668
|
-
}
|
|
669
|
-
}));
|
|
670
|
-
app.post("/api/projects/:id/handoff", withRuntime(async (rt, req, res) => {
|
|
671
|
-
const { to } = (req.body ?? {});
|
|
672
|
-
if (!to)
|
|
673
|
-
return void res.status(400).json({ error: "missing to" });
|
|
674
|
-
const result = await rt.handoff(to);
|
|
675
|
-
res.json({ ...result, to });
|
|
676
|
-
}));
|
|
677
|
-
// Chats — several conversations inside one project. They share the brain,
|
|
678
|
-
// the baton and the working tree; only the talking is separate.
|
|
679
|
-
app.get("/api/projects/:id/chats", withRuntime(async (rt, _req, res) => {
|
|
680
|
-
res.json({ chats: rt.chats() });
|
|
681
|
-
}));
|
|
682
|
-
app.post("/api/projects/:id/chats", withRuntime(async (rt, req, res) => {
|
|
683
|
-
const { title } = (req.body ?? {});
|
|
684
|
-
res.json({ chat: rt.createChat(String(title ?? "")) });
|
|
685
|
-
}));
|
|
686
|
-
app.post("/api/projects/:id/chats/:chatId/rename", withRuntime(async (rt, req, res) => {
|
|
687
|
-
const { title } = (req.body ?? {});
|
|
688
|
-
if (!title?.trim())
|
|
689
|
-
return void res.status(400).json({ error: "missing title" });
|
|
690
|
-
const chat = rt.renameChat(String(req.params.chatId), title);
|
|
691
|
-
if (!chat)
|
|
692
|
-
return void res.status(400).json({ error: "cannot rename that chat" });
|
|
693
|
-
res.json({ chat });
|
|
694
|
-
}));
|
|
695
|
-
app.delete("/api/projects/:id/chats/:chatId", withRuntime(async (rt, req, res) => {
|
|
696
|
-
if (!rt.deleteChat(String(req.params.chatId))) {
|
|
697
|
-
return void res.status(400).json({ error: "cannot delete that chat" });
|
|
698
|
-
}
|
|
699
|
-
res.json({ deleted: true });
|
|
700
|
-
}));
|
|
701
|
-
// Rename an agent's role. It's free text — your project decides what jobs
|
|
702
|
-
// exist, not us. Writes .loom/config.json, which is the source of truth.
|
|
703
|
-
app.post("/api/projects/:id/agents/:agentId/role", withRuntime(async (rt, req, res) => {
|
|
704
|
-
const { role } = (req.body ?? {});
|
|
705
|
-
if (typeof role !== "string")
|
|
706
|
-
return void res.status(400).json({ error: "missing role" });
|
|
707
|
-
const clean = role.trim().slice(0, 40);
|
|
708
|
-
if (!clean)
|
|
709
|
-
return void res.status(400).json({ error: "role cannot be empty" });
|
|
710
|
-
const updated = rt.setAgentRole(String(req.params.agentId), clean);
|
|
711
|
-
if (!updated)
|
|
712
|
-
return void res.status(404).json({ error: "unknown agent" });
|
|
713
|
-
res.json(updated);
|
|
714
|
-
}));
|
|
715
|
-
// The Settings screen reads its editable knobs here — brain extractor,
|
|
716
|
-
// projection mode, default agent — with the roster the picker chooses from.
|
|
717
|
-
app.get("/api/projects/:id/config", withRuntime(async (rt, _req, res) => {
|
|
718
|
-
res.json(rt.settings());
|
|
719
|
-
}));
|
|
720
|
-
// The Settings screen's editable knobs: the brain extractor, the projection
|
|
721
|
-
// mode, the default agent. Everything is read live from config, so a merge
|
|
722
|
-
// here lands on the next turn/handoff with no restart. Partial — send only
|
|
723
|
-
// what changed. Returns the full config so the screen can re-render.
|
|
724
|
-
app.patch("/api/projects/:id/config", withRuntime(async (rt, req, res) => {
|
|
725
|
-
const body = (req.body ?? {});
|
|
726
|
-
try {
|
|
727
|
-
const cfg = rt.patchConfig({
|
|
728
|
-
brain: body.brain,
|
|
729
|
-
projection: body.projection,
|
|
730
|
-
defaultAgent: body.defaultAgent,
|
|
731
|
-
});
|
|
732
|
-
res.json({
|
|
733
|
-
brain: cfg.brain ?? {},
|
|
734
|
-
projection: cfg.projection ?? {},
|
|
735
|
-
defaultAgent: cfg.defaultAgent ?? "",
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
catch (err) {
|
|
739
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
740
|
-
}
|
|
741
|
-
}));
|
|
742
|
-
// ---- search -----------------------------------------------------------
|
|
743
|
-
// Finding a file by name was the whole of search, which is its least useful
|
|
744
|
-
// half: you remember a line, not a filename. And the thread — where a
|
|
745
|
-
// project's actual reasoning lives — wasn't searchable at all.
|
|
746
|
-
app.get("/api/projects/:id/grep", withRuntime(async (rt, req, res) => {
|
|
747
|
-
res.json(await searchCode(rt.info.dir, String(req.query.q ?? "")));
|
|
748
|
-
}));
|
|
749
|
-
app.get("/api/projects/:id/chats/search", withRuntime(async (rt, req, res) => {
|
|
750
|
-
res.json(searchChats(rt.log, String(req.query.q ?? ""), {
|
|
751
|
-
...(req.query.chat ? { chat: String(req.query.chat) } : {}),
|
|
752
|
-
}));
|
|
753
|
-
}));
|
|
754
|
-
// ---- source control ---------------------------------------------------
|
|
755
|
-
// Reading the working tree has been possible since the Explorer landed;
|
|
756
|
-
// doing anything about it has not. These are the writes, and they're the
|
|
757
|
-
// only endpoints in Loom that can destroy work — hence the path checks in
|
|
758
|
-
// core/git.ts and the noise in the log when you discard.
|
|
759
|
-
app.get("/api/projects/:id/git/status", withRuntime(async (rt, _req, res) => {
|
|
760
|
-
res.json(await gitStatus(rt.info.dir));
|
|
761
|
-
}));
|
|
762
|
-
const gitWrite = (fn) => withRuntime(async (rt, req, res) => {
|
|
763
|
-
try {
|
|
764
|
-
res.json(await fn(rt.info.dir, (req.body ?? {})));
|
|
765
|
-
}
|
|
766
|
-
catch (err) {
|
|
767
|
-
// git's own words, not ours: "nothing to commit, working tree clean"
|
|
768
|
-
// beats anything we'd invent about an exit code.
|
|
769
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
770
|
-
logbook.warn("git", message, err instanceof GitError ? err.stderr : err, rt.info.id);
|
|
771
|
-
res.status(400).json({ error: message });
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
app.post("/api/projects/:id/git/stage", gitWrite((dir, b) => gitStage(dir, asPaths(b.paths))));
|
|
775
|
-
app.post("/api/projects/:id/git/unstage", gitWrite((dir, b) => gitUnstage(dir, asPaths(b.paths))));
|
|
776
|
-
app.post("/api/projects/:id/git/discard", gitWrite((dir, b) => gitDiscard(dir, asPaths(b.paths), asPaths(b.untracked))));
|
|
777
|
-
app.post("/api/projects/:id/git/commit", gitWrite((dir, b) => gitCommit(dir, String(b.message ?? ""))));
|
|
778
|
-
// init / push / checkout — all write, all through the same error surface.
|
|
779
|
-
app.post("/api/projects/:id/git/init", gitWrite((dir) => gitInit(dir)));
|
|
780
|
-
app.post("/api/projects/:id/git/push", gitWrite((dir) => gitPush(dir)));
|
|
781
|
-
app.post("/api/projects/:id/git/checkout", gitWrite((dir, b) => gitCheckout(dir, String(b.ref ?? ""))));
|
|
782
|
-
// read-only: the commit log, one file's diff, and the branch list
|
|
783
|
-
app.get("/api/projects/:id/git/log", withRuntime(async (rt, req, res) => {
|
|
784
|
-
const limit = Number(req.query.limit) || 30;
|
|
785
|
-
res.json({ commits: await gitLog(rt.info.dir, limit) });
|
|
786
|
-
}));
|
|
787
|
-
app.get("/api/projects/:id/git/diff", withRuntime(async (rt, req, res) => {
|
|
788
|
-
const p = String(req.query.path ?? "");
|
|
789
|
-
if (!p)
|
|
790
|
-
return void res.status(400).json({ error: "missing path" });
|
|
791
|
-
try {
|
|
792
|
-
res.json({ path: p, patch: await gitFileDiff(rt.info.dir, p) });
|
|
793
|
-
}
|
|
794
|
-
catch (err) {
|
|
795
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
796
|
-
}
|
|
797
|
-
}));
|
|
798
|
-
app.get("/api/projects/:id/git/branches", withRuntime(async (rt, _req, res) => {
|
|
799
|
-
res.json(await gitBranches(rt.info.dir));
|
|
800
|
-
}));
|
|
801
|
-
// Draft a commit message from the staged diff, via the logged-in Claude CLI
|
|
802
|
-
// — the "Generate" affordance. No key; a no-op-ish 400 when Claude isn't
|
|
803
|
-
// there, so the field just stays empty and the user types their own.
|
|
804
|
-
app.post("/api/projects/:id/git/suggest-message", withRuntime(async (rt, _req, res) => {
|
|
805
|
-
const diff = await gitStagedDiff(rt.info.dir).catch(() => "");
|
|
806
|
-
if (!diff.trim())
|
|
807
|
-
return void res.status(400).json({ error: "nothing to describe — stage or edit some files first" });
|
|
808
|
-
try {
|
|
809
|
-
const prompt = "Write a single-line Conventional Commit subject (type(scope): summary, imperative mood, <72 chars) " +
|
|
810
|
-
"for this diff. Reply with ONLY the subject line, no quotes, no body.\n\n" +
|
|
811
|
-
diff;
|
|
812
|
-
const out = (await claudeText(prompt, { model: "haiku", timeoutMs: 30_000 })).trim();
|
|
813
|
-
const message = out.split("\n")[0]?.replace(/^["'`]|["'`]$/g, "").trim().slice(0, 120) ?? "";
|
|
814
|
-
if (!message)
|
|
815
|
-
return void res.status(502).json({ error: "Claude returned nothing — type a message instead" });
|
|
816
|
-
res.json({ message });
|
|
817
|
-
}
|
|
818
|
-
catch {
|
|
819
|
-
// claudeText's raw "claude exited N" helps no one at the commit box.
|
|
820
|
-
res.status(502).json({ error: "couldn't reach Claude to draft a message — type one instead" });
|
|
821
|
-
}
|
|
822
|
-
}));
|
|
823
|
-
// ---- the Console ------------------------------------------------------
|
|
824
|
-
// Everything that went wrong, for the tab next to the terminal. Until this
|
|
825
|
-
// existed an error's only home was ~/.loom/daemon.log, which you have to
|
|
826
|
-
// know about, find, and tail — so in practice errors reached nobody.
|
|
827
|
-
app.get("/api/logs", (req, res) => {
|
|
828
|
-
const since = req.query.since === undefined ? undefined : Number(req.query.since);
|
|
829
|
-
const level = req.query.level;
|
|
319
|
+
// What exactly is running: build rev, node, uptime, platform. /api/health
|
|
320
|
+
// carries rev for staleness checks; this is the fuller "loom health"
|
|
321
|
+
// answer, and it's behind the wall because build details are inventory.
|
|
322
|
+
app.get("/api/version", (_req, res) => {
|
|
830
323
|
res.json({
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
});
|
|
837
|
-
});
|
|
838
|
-
app.delete("/api/logs", (_req, res) => {
|
|
839
|
-
logbook.clear();
|
|
840
|
-
res.json({ ok: true });
|
|
841
|
-
});
|
|
842
|
-
/**
|
|
843
|
-
* The window reporting its own errors — a failed fetch, a thrown render, an
|
|
844
|
-
* unhandled rejection. Client-side failures used to die in the browser
|
|
845
|
-
* console where no one was looking; now they land in the same Console tab as
|
|
846
|
-
* the daemon's, streamed to every window and kept in the ring buffer.
|
|
847
|
-
*/
|
|
848
|
-
app.post("/api/logs", (req, res) => {
|
|
849
|
-
const b = (req.body ?? {});
|
|
850
|
-
const level = b.level === "error" || b.level === "warn" ? b.level : "info";
|
|
851
|
-
const message = String(b.message ?? "").slice(0, 500);
|
|
852
|
-
if (!message)
|
|
853
|
-
return void res.status(400).json({ error: "missing message" });
|
|
854
|
-
const scope = (b.scope ? String(b.scope) : "app").slice(0, 40);
|
|
855
|
-
const rec = logbook.add(level, scope, message, b.detail, b.project ? String(b.project) : undefined);
|
|
856
|
-
res.json({ ok: true, id: rec.id });
|
|
857
|
-
});
|
|
858
|
-
// Which agents Loom can drive on this machine, and which are already in
|
|
859
|
-
// this project. The UI needs both to offer you the difference.
|
|
860
|
-
app.get("/api/projects/:id/agents/available", withRuntime(async (rt, _req, res) => {
|
|
861
|
-
const availability = await detectAdes();
|
|
862
|
-
const inProject = new Set(rt.config.agents.map((a) => a.kind));
|
|
863
|
-
res.json({
|
|
864
|
-
ades: ADES.map((a) => ({
|
|
865
|
-
kind: a.kind,
|
|
866
|
-
label: a.label,
|
|
867
|
-
tier: a.tier,
|
|
868
|
-
// Bridges are never "installed" — they're an app you launch with a
|
|
869
|
-
// debug port, so presence is a live question, not a lookup.
|
|
870
|
-
installed: a.tier === "adapter" ? Boolean(availability[a.kind]) : null,
|
|
871
|
-
inProject: inProject.has(a.kind),
|
|
872
|
-
models: a.models ?? [],
|
|
873
|
-
})),
|
|
874
|
-
});
|
|
875
|
-
}));
|
|
876
|
-
// Add an agent to a project. A roster used to be frozen at creation: install
|
|
877
|
-
// a new ADE and your existing projects never heard of it.
|
|
878
|
-
app.post("/api/projects/:id/agents", withRuntime(async (rt, req, res) => {
|
|
879
|
-
const { kind, id, role } = (req.body ?? {});
|
|
880
|
-
if (!kind?.trim())
|
|
881
|
-
return void res.status(400).json({ error: "missing kind" });
|
|
882
|
-
try {
|
|
883
|
-
res.json(rt.addAgent(kind.trim(), { id, role }));
|
|
884
|
-
}
|
|
885
|
-
catch (err) {
|
|
886
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
887
|
-
}
|
|
888
|
-
}));
|
|
889
|
-
app.delete("/api/projects/:id/agents/:agentId", withRuntime(async (rt, req, res) => {
|
|
890
|
-
try {
|
|
891
|
-
res.json(rt.removeAgent(String(req.params.agentId)));
|
|
892
|
-
}
|
|
893
|
-
catch (err) {
|
|
894
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
895
|
-
}
|
|
896
|
-
}));
|
|
897
|
-
// Point an agent at a different model. Empty string clears the override.
|
|
898
|
-
app.post("/api/projects/:id/agents/:agentId/model", withRuntime(async (rt, req, res) => {
|
|
899
|
-
const { model } = (req.body ?? {});
|
|
900
|
-
try {
|
|
901
|
-
const cfg = rt.setAgentModel(String(req.params.agentId), model ?? "");
|
|
902
|
-
res.json({ agent: cfg });
|
|
903
|
-
}
|
|
904
|
-
catch (err) {
|
|
905
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
906
|
-
}
|
|
907
|
-
}));
|
|
908
|
-
app.post("/api/projects/:id/interrupt", withRuntime(async (rt, _req, res) => {
|
|
909
|
-
res.json(await rt.interrupt());
|
|
910
|
-
}));
|
|
911
|
-
app.post("/api/projects/:id/decisions", withRuntime(async (rt, req, res) => {
|
|
912
|
-
const { text } = (req.body ?? {});
|
|
913
|
-
if (!text?.trim())
|
|
914
|
-
return void res.status(400).json({ error: "missing text" });
|
|
915
|
-
const event = rt.log.append({ kind: "decision", payload: { text } });
|
|
916
|
-
// Also a memory. The decision event stays because the projection and
|
|
917
|
-
// forty other things read it; the memory is the addressable copy — the
|
|
918
|
-
// one that can be retrieved by what it's about, corrected, and
|
|
919
|
-
// forgotten. Seeding the brain from the surface people already use
|
|
920
|
-
// beats asking them to fill a second box.
|
|
921
|
-
rt.brain.add({
|
|
922
|
-
kind: "decision",
|
|
923
|
-
text,
|
|
924
|
-
provenance: { agentId: "user", eventId: event.id, ts: event.ts },
|
|
925
|
-
});
|
|
926
|
-
res.json({ event });
|
|
927
|
-
}));
|
|
928
|
-
// --- the brain ---------------------------------------------------------
|
|
929
|
-
app.get("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
|
|
930
|
-
const q = req.query;
|
|
931
|
-
const memories = rt.brain.list({
|
|
932
|
-
...(q.kind ? { kind: q.kind } : {}),
|
|
933
|
-
...(q.chat ? { chat: q.chat } : {}),
|
|
934
|
-
...(q.includeExpired === "1" ? { includeExpired: true } : {}),
|
|
935
|
-
...(q.limit ? { limit: Math.min(500, Number(q.limit) || 100) } : {}),
|
|
936
|
-
});
|
|
937
|
-
res.json({ memories, stats: rt.brain.stats() });
|
|
938
|
-
}));
|
|
939
|
-
app.get("/api/projects/:id/brain/search", withRuntime(async (rt, req, res) => {
|
|
940
|
-
const q = req.query;
|
|
941
|
-
const files = q.files ? q.files.split(",").filter(Boolean) : [];
|
|
942
|
-
if (!q.q?.trim() && !files.length) {
|
|
943
|
-
return void res.status(400).json({ error: "missing q or files" });
|
|
944
|
-
}
|
|
945
|
-
const hits = retrieve(rt.brain, {
|
|
946
|
-
...(q.q ? { query: q.q } : {}),
|
|
947
|
-
...(files.length ? { files } : {}),
|
|
948
|
-
...(q.chat ? { chat: q.chat } : {}),
|
|
949
|
-
...(q.agent ? { agent: q.agent } : {}),
|
|
950
|
-
limit: Math.min(50, Number(q.limit) || 12),
|
|
951
|
-
explain: q.explain === "1",
|
|
952
|
-
});
|
|
953
|
-
res.json({ hits });
|
|
954
|
-
}));
|
|
955
|
-
app.post("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
|
|
956
|
-
const body = (req.body ?? {});
|
|
957
|
-
if (!body.text?.trim())
|
|
958
|
-
return void res.status(400).json({ error: "missing text" });
|
|
959
|
-
try {
|
|
960
|
-
const { memory, created } = rt.brain.add({
|
|
961
|
-
kind: body.kind ?? "fact",
|
|
962
|
-
text: body.text,
|
|
963
|
-
...(body.entities ? { entities: body.entities } : {}),
|
|
964
|
-
...(body.chat ? { scope: { chat: body.chat } } : {}),
|
|
965
|
-
...(body.confidence !== undefined ? { confidence: body.confidence } : {}),
|
|
966
|
-
provenance: { agentId: "user", eventId: rt.log.lastId(), ts: Date.now() },
|
|
967
|
-
});
|
|
968
|
-
res.json({ memory, created });
|
|
969
|
-
}
|
|
970
|
-
catch (err) {
|
|
971
|
-
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
972
|
-
}
|
|
973
|
-
}));
|
|
974
|
-
app.patch("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
|
|
975
|
-
try {
|
|
976
|
-
res.json({ memory: rt.brain.update(String(req.params.mid), (req.body ?? {}), "user") });
|
|
977
|
-
}
|
|
978
|
-
catch (err) {
|
|
979
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
980
|
-
res.status(/no such memory/.test(msg) ? 404 : 400).json({ error: msg });
|
|
981
|
-
}
|
|
982
|
-
}));
|
|
983
|
-
app.delete("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
|
|
984
|
-
const reason = String(req.query.reason ?? "").trim();
|
|
985
|
-
if (!reason)
|
|
986
|
-
return void res.status(400).json({ error: "forgetting needs a reason" });
|
|
987
|
-
const forgot = rt.brain.forget(String(req.params.mid), reason, "user");
|
|
988
|
-
if (!forgot)
|
|
989
|
-
return void res.status(404).json({ error: "no such memory" });
|
|
990
|
-
res.json({ forgot: true });
|
|
991
|
-
}));
|
|
992
|
-
app.get("/api/projects/:id/brain/:mid/history", withRuntime(async (rt, req, res) => {
|
|
993
|
-
res.json({ history: rt.brain.history(String(req.params.mid)) });
|
|
994
|
-
}));
|
|
995
|
-
app.post("/api/projects/:id/route", withRuntime(async (rt, req, res) => {
|
|
996
|
-
const { task, spec, router, maxHops } = (req.body ?? {});
|
|
997
|
-
if (!task?.trim())
|
|
998
|
-
return void res.status(400).json({ error: "missing task" });
|
|
999
|
-
const route = await rt.startRoute({
|
|
1000
|
-
task,
|
|
1001
|
-
...(spec !== undefined ? { spec } : {}),
|
|
1002
|
-
...(router ? { router } : {}),
|
|
1003
|
-
...(maxHops ? { maxHops: Number(maxHops) } : {}),
|
|
1004
|
-
});
|
|
1005
|
-
res.json({ route });
|
|
1006
|
-
}));
|
|
1007
|
-
app.get("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
|
|
1008
|
-
res.json({ route: rt.routeState() });
|
|
1009
|
-
}));
|
|
1010
|
-
app.get("/api/projects/:id/costs", withRuntime(async (rt, _req, res) => {
|
|
1011
|
-
res.json({ costs: rt.costSummary() });
|
|
1012
|
-
}));
|
|
1013
|
-
app.get("/api/projects/:id/tree", withRuntime(async (rt, _req, res) => {
|
|
1014
|
-
res.json({ tree: await rt.workingTree() });
|
|
1015
|
-
}));
|
|
1016
|
-
app.get("/api/projects/:id/memory", withRuntime(async (rt, _req, res) => {
|
|
1017
|
-
res.json({ memory: rt.unifiedMemory() });
|
|
1018
|
-
}));
|
|
1019
|
-
app.post("/api/projects/:id/memory/import", withRuntime(async (rt, _req, res) => {
|
|
1020
|
-
res.json(rt.importMemories());
|
|
1021
|
-
}));
|
|
1022
|
-
app.delete("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
|
|
1023
|
-
res.json({ route: await rt.abortRoute() });
|
|
1024
|
-
}));
|
|
1025
|
-
// Terminal: one long-lived shell per tab. A real pty when node-pty is
|
|
1026
|
-
// available (echo, job control, vim), otherwise a pipe-backed shell — see
|
|
1027
|
-
// terminals.ts. Output streams over the project WebSocket; input arrives
|
|
1028
|
-
// there too, because a tty needs a round-trip per keystroke. Bearer auth +
|
|
1029
|
-
// the tailnet are the trust boundary, same as the agents the daemon runs.
|
|
1030
|
-
app.post("/api/projects/:id/term/open", (req, res) => {
|
|
1031
|
-
const info = findProject(String(req.params.id));
|
|
1032
|
-
if (!info)
|
|
1033
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1034
|
-
const { term, cols, rows } = (req.body ?? {});
|
|
1035
|
-
const termId = String(term ?? "t1");
|
|
1036
|
-
const existing = this.terminals.get(info.id, termId);
|
|
1037
|
-
if (existing) {
|
|
1038
|
-
// A reload rejoins the session it left, and gets replayed what it missed.
|
|
1039
|
-
return void res.json({
|
|
1040
|
-
term: termId,
|
|
1041
|
-
cwd: existing.cwd,
|
|
1042
|
-
mode: existing.mode,
|
|
1043
|
-
reused: true,
|
|
1044
|
-
scrollback: existing.scrollback(),
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
try {
|
|
1048
|
-
const sess = this.terminals.open(info.id, termId, info.dir, cols ?? 80, rows ?? 24);
|
|
1049
|
-
res.json({ term: termId, cwd: sess.cwd, mode: sess.mode, reused: false, scrollback: "" });
|
|
1050
|
-
}
|
|
1051
|
-
catch (err) {
|
|
1052
|
-
// only the cap is a 429 — a shell that won't spawn is our problem, not
|
|
1053
|
-
// the client's rate
|
|
1054
|
-
res.status(err instanceof TooManySessionsError ? 429 : 500).json({
|
|
1055
|
-
error: err.message,
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
});
|
|
1059
|
-
app.post("/api/projects/:id/term/input", (req, res) => {
|
|
1060
|
-
const info = findProject(String(req.params.id));
|
|
1061
|
-
if (!info)
|
|
1062
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1063
|
-
const { term, data } = (req.body ?? {});
|
|
1064
|
-
const termId = String(term ?? "t1");
|
|
1065
|
-
try {
|
|
1066
|
-
// this opens a session when none exists, so it can fail the same ways
|
|
1067
|
-
// /term/open can — uncaught, Express answers a JSON client with HTML
|
|
1068
|
-
const sess = this.terminals.get(info.id, termId) ?? this.terminals.open(info.id, termId, info.dir);
|
|
1069
|
-
sess.write(String(data ?? ""));
|
|
1070
|
-
res.json({ ok: true });
|
|
1071
|
-
}
|
|
1072
|
-
catch (err) {
|
|
1073
|
-
res.status(err instanceof TooManySessionsError ? 429 : 500).json({
|
|
1074
|
-
error: err.message,
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
});
|
|
1078
|
-
app.post("/api/projects/:id/term/signal", (req, res) => {
|
|
1079
|
-
const info = findProject(String(req.params.id));
|
|
1080
|
-
if (!info)
|
|
1081
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1082
|
-
const sess = this.terminals.get(info.id, String((req.body ?? {}).term ?? "t1"));
|
|
1083
|
-
if (!sess)
|
|
1084
|
-
return void res.json({ signalled: false });
|
|
1085
|
-
sess.interrupt();
|
|
1086
|
-
res.json({ signalled: true });
|
|
1087
|
-
});
|
|
1088
|
-
app.post("/api/projects/:id/term/resize", (req, res) => {
|
|
1089
|
-
const info = findProject(String(req.params.id));
|
|
1090
|
-
if (!info)
|
|
1091
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1092
|
-
const { term, cols, rows } = (req.body ?? {});
|
|
1093
|
-
const sess = this.terminals.get(info.id, String(term ?? "t1"));
|
|
1094
|
-
if (!sess)
|
|
1095
|
-
return void res.json({ resized: false });
|
|
1096
|
-
sess.resize(Number(cols) || 80, Number(rows) || 24);
|
|
1097
|
-
res.json({ resized: true });
|
|
1098
|
-
});
|
|
1099
|
-
app.post("/api/projects/:id/term/close", (req, res) => {
|
|
1100
|
-
const info = findProject(String(req.params.id));
|
|
1101
|
-
if (!info)
|
|
1102
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1103
|
-
this.terminals.close(info.id, String((req.body ?? {}).term ?? "t1"));
|
|
1104
|
-
res.json({ closed: true });
|
|
1105
|
-
});
|
|
1106
|
-
// The board: live agents (from us) + pull requests (from gh), sorted into
|
|
1107
|
-
// working → needs you → in review → ready. See board.ts.
|
|
1108
|
-
app.get("/api/projects/:id/board", async (req, res) => {
|
|
1109
|
-
const info = findProject(String(req.params.id));
|
|
1110
|
-
if (!info)
|
|
1111
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1112
|
-
try {
|
|
1113
|
-
const rt = await this.runtime(info.id);
|
|
1114
|
-
const status = await rt.status();
|
|
1115
|
-
const blocked = status.blockedAgent ? [status.blockedAgent] : [];
|
|
1116
|
-
const search = req.query.search ? String(req.query.search) : undefined;
|
|
1117
|
-
res.json(await buildBoard(info.dir, status.agents, blocked, {
|
|
1118
|
-
tasks: rt.boardTasks(),
|
|
1119
|
-
...(search ? { search } : {}),
|
|
1120
|
-
}));
|
|
1121
|
-
}
|
|
1122
|
-
catch (err) {
|
|
1123
|
-
res.status(500).json({ error: err.message });
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
// Cards you write yourself. Unlike an agent or a PR, these are ours, so a
|
|
1127
|
-
// drag really moves them — the column IS the state.
|
|
1128
|
-
app.post("/api/projects/:id/board/tasks", withRuntime(async (rt, req, res) => {
|
|
1129
|
-
const { title, column, agent } = (req.body ?? {});
|
|
1130
|
-
if (!title?.trim())
|
|
1131
|
-
return void res.status(400).json({ error: "missing title" });
|
|
1132
|
-
res.json({ task: rt.createTask({ title, ...(column ? { column } : {}), ...(agent ? { agent } : {}) }) });
|
|
1133
|
-
}));
|
|
1134
|
-
app.post("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
|
|
1135
|
-
const { title, column, agent } = (req.body ?? {});
|
|
1136
|
-
const task = rt.updateTask(String(req.params.taskId), {
|
|
1137
|
-
...(title !== undefined ? { title } : {}),
|
|
1138
|
-
...(column !== undefined ? { column } : {}),
|
|
1139
|
-
...(agent !== undefined ? { agent } : {}),
|
|
1140
|
-
});
|
|
1141
|
-
if (!task)
|
|
1142
|
-
return void res.status(404).json({ error: "unknown task" });
|
|
1143
|
-
res.json({ task });
|
|
1144
|
-
}));
|
|
1145
|
-
app.delete("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
|
|
1146
|
-
if (!rt.deleteTask(String(req.params.taskId))) {
|
|
1147
|
-
return void res.status(404).json({ error: "unknown task" });
|
|
1148
|
-
}
|
|
1149
|
-
res.json({ deleted: true });
|
|
1150
|
-
}));
|
|
1151
|
-
// Issues / PRs for the project's GitHub remote, read through the user's
|
|
1152
|
-
// own gh CLI (see tasks.ts) — Loom holds no token of its own.
|
|
1153
|
-
app.get("/api/projects/:id/tasks", async (req, res) => {
|
|
1154
|
-
const info = findProject(String(req.params.id));
|
|
1155
|
-
if (!info)
|
|
1156
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1157
|
-
const kind = String(req.query.kind ?? "issue") === "pr" ? "pr" : "issue";
|
|
1158
|
-
res.json(await listTasks(info.dir, {
|
|
1159
|
-
kind,
|
|
1160
|
-
...(req.query.search ? { search: String(req.query.search) } : {}),
|
|
1161
|
-
}));
|
|
1162
|
-
});
|
|
1163
|
-
// Small async wrapper: resolve the project or 404, run the handler, and turn
|
|
1164
|
-
// any throw into a 500 with its message. The GitHub/Linear/worktree reads
|
|
1165
|
-
// below all share this shape.
|
|
1166
|
-
const projectRoute = (fn) => (req, res) => {
|
|
1167
|
-
const info = findProject(String(req.params.id));
|
|
1168
|
-
if (!info)
|
|
1169
|
-
return void res.status(404).json({ error: "unknown project" });
|
|
1170
|
-
void fn(info.dir, req, res).catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
|
|
1171
|
-
};
|
|
1172
|
-
// ---- GitHub Projects (v2) — the owner's boards, browsed in-app ----------
|
|
1173
|
-
app.get("/api/projects/:id/gh/projects", projectRoute(async (dir, _req, res) => {
|
|
1174
|
-
res.json(await ghProjects(dir));
|
|
1175
|
-
}));
|
|
1176
|
-
app.get("/api/projects/:id/gh/projects/:num/items", projectRoute(async (dir, req, res) => {
|
|
1177
|
-
res.json(await ghProjectItems(dir, Number(req.params.num)));
|
|
1178
|
-
}));
|
|
1179
|
-
// ---- Pull-request review — diff + approve / request-changes / comment ----
|
|
1180
|
-
app.get("/api/projects/:id/prs/:num", projectRoute(async (dir, req, res) => {
|
|
1181
|
-
res.json(await prView(dir, Number(req.params.num)));
|
|
1182
|
-
}));
|
|
1183
|
-
app.post("/api/projects/:id/prs/:num/review", projectRoute(async (dir, req, res) => {
|
|
1184
|
-
const { action, body } = (req.body ?? {});
|
|
1185
|
-
const allowed = ["approve", "request-changes", "comment"];
|
|
1186
|
-
if (!allowed.includes(action)) {
|
|
1187
|
-
return void res.status(400).json({ error: "action must be approve, request-changes, or comment" });
|
|
1188
|
-
}
|
|
1189
|
-
const result = await prReview(dir, Number(req.params.num), action, body ?? "");
|
|
1190
|
-
if ("available" in result)
|
|
1191
|
-
return void res.status(400).json({ error: result.detail });
|
|
1192
|
-
res.json(result);
|
|
1193
|
-
}));
|
|
1194
|
-
// ---- Worktrees — open a checked-out branch from any task ----------------
|
|
1195
|
-
app.get("/api/projects/:id/worktrees", projectRoute(async (dir, _req, res) => {
|
|
1196
|
-
res.json({ worktrees: await gitListWorktrees(dir) });
|
|
1197
|
-
}));
|
|
1198
|
-
app.post("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
|
|
1199
|
-
const b = (req.body ?? {});
|
|
1200
|
-
if (b.pr) {
|
|
1201
|
-
const n = Number(b.pr);
|
|
1202
|
-
const wt = await gitAddWorktree(dir, { slug: "pr-" + n, detached: true });
|
|
1203
|
-
try {
|
|
1204
|
-
// gh handles fork PRs (adds the remote, fetches, makes the branch)
|
|
1205
|
-
await runGh(["pr", "checkout", String(n)], wt.path);
|
|
1206
|
-
}
|
|
1207
|
-
catch (err) {
|
|
1208
|
-
// don't strand an empty detached worktree if the checkout fails
|
|
1209
|
-
await gitRemoveWorktree(dir, wt.path, true).catch(() => { });
|
|
1210
|
-
throw err;
|
|
1211
|
-
}
|
|
1212
|
-
return void res.json({ path: wt.path, source: `PR #${n}` });
|
|
1213
|
-
}
|
|
1214
|
-
if (b.newBranch) {
|
|
1215
|
-
const wt = await gitAddWorktree(dir, {
|
|
1216
|
-
slug: b.newBranch,
|
|
1217
|
-
newBranch: String(b.newBranch),
|
|
1218
|
-
...(b.base ? { base: String(b.base) } : {}),
|
|
1219
|
-
});
|
|
1220
|
-
return void res.json({ path: wt.path, branch: wt.branch });
|
|
1221
|
-
}
|
|
1222
|
-
if (b.branch) {
|
|
1223
|
-
const wt = await gitAddWorktree(dir, { slug: String(b.branch), branch: String(b.branch) });
|
|
1224
|
-
return void res.json({ path: wt.path, branch: wt.branch });
|
|
1225
|
-
}
|
|
1226
|
-
if (b.issue) {
|
|
1227
|
-
const slug = "issue-" + Number(b.issue);
|
|
1228
|
-
const wt = await gitAddWorktree(dir, { slug, newBranch: slug });
|
|
1229
|
-
return void res.json({ path: wt.path, branch: wt.branch, source: `issue #${Number(b.issue)}` });
|
|
1230
|
-
}
|
|
1231
|
-
res.status(400).json({ error: "say which: pr, issue, branch, or newBranch" });
|
|
1232
|
-
}));
|
|
1233
|
-
app.delete("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
|
|
1234
|
-
const wtPath = String((req.body ?? {}).path ?? req.query.path ?? "");
|
|
1235
|
-
if (!wtPath)
|
|
1236
|
-
return void res.status(400).json({ error: "which worktree? pass its path" });
|
|
1237
|
-
await gitRemoveWorktree(dir, wtPath, Boolean((req.body ?? {}).force));
|
|
1238
|
-
res.json({ removed: wtPath });
|
|
1239
|
-
}));
|
|
1240
|
-
// ---- Linear — teams + create issue, through the user's own key ----------
|
|
1241
|
-
app.get("/api/projects/:id/linear/teams", projectRoute(async (_dir, _req, res) => {
|
|
1242
|
-
res.json(await linearTeams());
|
|
1243
|
-
}));
|
|
1244
|
-
app.get("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
|
|
1245
|
-
res.json(await listLinearIssues(req.query.team ? String(req.query.team) : undefined));
|
|
1246
|
-
}));
|
|
1247
|
-
app.post("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
|
|
1248
|
-
const { teamId, title, description } = (req.body ?? {});
|
|
1249
|
-
const result = await linearCreateIssue({
|
|
1250
|
-
teamId: teamId ?? "",
|
|
1251
|
-
title: title ?? "",
|
|
1252
|
-
...(description ? { description } : {}),
|
|
1253
|
-
});
|
|
1254
|
-
if (result.available)
|
|
1255
|
-
return void res.json(result);
|
|
1256
|
-
res.status(400).json({ error: result.detail });
|
|
1257
|
-
}));
|
|
1258
|
-
// Explorer: list a directory, read a file, search filenames. All strictly
|
|
1259
|
-
// sandboxed to the project directory (no traversal outside it).
|
|
1260
|
-
const contains = (base, target) => target === base || target.startsWith(base + path.sep);
|
|
1261
|
-
/**
|
|
1262
|
-
* Resolve a project-relative path, or null if it escapes the project.
|
|
1263
|
-
* Two checks, because they catch different attacks: the lexical one stops
|
|
1264
|
-
* `../` traversal (and works for paths that don't exist yet), and the
|
|
1265
|
-
* realpath one stops a symlink *inside* the project from pointing out of
|
|
1266
|
-
* it — path.resolve happily resolves through links.
|
|
1267
|
-
*/
|
|
1268
|
-
const projectPath = (id, rel) => {
|
|
1269
|
-
const info = findProject(id);
|
|
1270
|
-
if (!info)
|
|
1271
|
-
return null;
|
|
1272
|
-
let base;
|
|
1273
|
-
try {
|
|
1274
|
-
base = fs.realpathSync(path.resolve(info.dir));
|
|
1275
|
-
}
|
|
1276
|
-
catch {
|
|
1277
|
-
return null;
|
|
1278
|
-
}
|
|
1279
|
-
const target = path.resolve(base, rel ?? ".");
|
|
1280
|
-
if (!contains(base, target))
|
|
1281
|
-
return null;
|
|
1282
|
-
try {
|
|
1283
|
-
if (!contains(base, fs.realpathSync(target)))
|
|
1284
|
-
return null;
|
|
1285
|
-
}
|
|
1286
|
-
catch {
|
|
1287
|
-
// doesn't exist — the lexical check above is the whole answer
|
|
1288
|
-
}
|
|
1289
|
-
return target;
|
|
1290
|
-
};
|
|
1291
|
-
const HIDE_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage"]);
|
|
1292
|
-
app.get("/api/projects/:id/files", (req, res) => {
|
|
1293
|
-
const dir = projectPath(String(req.params.id), req.query.dir ? String(req.query.dir) : ".");
|
|
1294
|
-
if (!dir)
|
|
1295
|
-
return void res.status(404).json({ error: "not found" });
|
|
1296
|
-
const base = projectPath(String(req.params.id), ".");
|
|
1297
|
-
fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
|
|
1298
|
-
if (err)
|
|
1299
|
-
return void res.status(400).json({ error: err.message });
|
|
1300
|
-
const entries = ents
|
|
1301
|
-
.filter((e) => e.name !== ".git")
|
|
1302
|
-
.map((e) => ({
|
|
1303
|
-
name: e.name,
|
|
1304
|
-
path: path.relative(base, path.join(dir, e.name)),
|
|
1305
|
-
dir: e.isDirectory(),
|
|
1306
|
-
}))
|
|
1307
|
-
.sort((a, b) => a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)
|
|
1308
|
-
.slice(0, 500);
|
|
1309
|
-
res.json({ dir: path.relative(base, dir), entries });
|
|
1310
|
-
});
|
|
1311
|
-
});
|
|
1312
|
-
app.get("/api/projects/:id/file", (req, res) => {
|
|
1313
|
-
const file = projectPath(String(req.params.id), req.query.path ? String(req.query.path) : "");
|
|
1314
|
-
if (!file)
|
|
1315
|
-
return void res.status(404).json({ error: "not found" });
|
|
1316
|
-
fs.stat(file, (err, st) => {
|
|
1317
|
-
if (err)
|
|
1318
|
-
return void res.status(400).json({ error: err.message });
|
|
1319
|
-
if (st.isDirectory())
|
|
1320
|
-
return void res.status(400).json({ error: "is a directory" });
|
|
1321
|
-
const MAX = 400_000;
|
|
1322
|
-
const truncated = st.size > MAX;
|
|
1323
|
-
const stream = fs.createReadStream(file, { start: 0, end: Math.min(st.size, MAX) - 1, encoding: "utf8" });
|
|
1324
|
-
let content = "";
|
|
1325
|
-
stream.on("data", (c) => (content += c));
|
|
1326
|
-
stream.on("error", (e) => res.status(400).json({ error: e.message }));
|
|
1327
|
-
stream.on("end", () => {
|
|
1328
|
-
const base = projectPath(String(req.params.id), ".");
|
|
1329
|
-
res.json({ path: path.relative(base, file), content, truncated, size: st.size });
|
|
1330
|
-
});
|
|
324
|
+
rev: BUILD_REV,
|
|
325
|
+
node: process.version,
|
|
326
|
+
platform: `${process.platform}-${process.arch}`,
|
|
327
|
+
uptimeSec: Math.round(process.uptime()),
|
|
328
|
+
pid: process.pid,
|
|
1331
329
|
});
|
|
1332
330
|
});
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
app
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
}
|
|
1408
|
-
catch (err) {
|
|
1409
|
-
return void res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
1410
|
-
}
|
|
1411
|
-
res.json({ path: rel, bytes: buf.length, mime });
|
|
331
|
+
// The scope wall: one guard over every project route, so a scoped token
|
|
332
|
+
// cannot reach a project it wasn't paired for. Matching by resolved id —
|
|
333
|
+
// the param may be a name, and a scope you could dodge by spelling the
|
|
334
|
+
// project differently would be theatre.
|
|
335
|
+
app.use("/api/projects/:id", (req, res, next) => {
|
|
336
|
+
const scope = req.projectScope;
|
|
337
|
+
if (!scope)
|
|
338
|
+
return void next();
|
|
339
|
+
const resolved = findProject(String(req.params.id))?.id ?? String(req.params.id);
|
|
340
|
+
if (scope.includes(resolved))
|
|
341
|
+
return void next();
|
|
342
|
+
res.status(403).json({ error: "this token is not scoped to that project" });
|
|
343
|
+
});
|
|
344
|
+
registerSetupRoutes(app);
|
|
345
|
+
registerProvidersRoutes(app);
|
|
346
|
+
registerUpdatesRoutes(app, ctx);
|
|
347
|
+
registerIntegrationsRoutes(app);
|
|
348
|
+
registerPairingRoutes(app, ctx);
|
|
349
|
+
registerTeamRoutes(app, ctx);
|
|
350
|
+
registerCloudRoutes(app, ctx);
|
|
351
|
+
registerClientsRoutes(app, ctx);
|
|
352
|
+
registerProjectsRoutes(app, ctx);
|
|
353
|
+
const withRuntime = createRuntimeHandler(ctx);
|
|
354
|
+
registerObservabilityRoutes(app, withRuntime);
|
|
355
|
+
registerAlertsRoutes(app, ctx);
|
|
356
|
+
registerMessagesRoutes(app, withRuntime);
|
|
357
|
+
registerServersRoutes(app, withRuntime);
|
|
358
|
+
registerTaskDeliveryRoutes(app, withRuntime);
|
|
359
|
+
registerQueueRoutes(app, withRuntime);
|
|
360
|
+
registerChatsRoutes(app, withRuntime);
|
|
361
|
+
registerAgentSettingsRoutes(app, withRuntime);
|
|
362
|
+
registerToolsRoutes(app, withRuntime);
|
|
363
|
+
registerConfigRoutes(app, withRuntime);
|
|
364
|
+
registerSearchRoutes(app, withRuntime);
|
|
365
|
+
registerGitRoutes(app, withRuntime);
|
|
366
|
+
registerLogsRoutes(app);
|
|
367
|
+
registerAgentDiscoveryRoutes(app, withRuntime);
|
|
368
|
+
registerSpecsRoutes(app, ctx, withRuntime);
|
|
369
|
+
registerProjectActionsRoutes(app, withRuntime);
|
|
370
|
+
registerVoiceRoutes(app, withRuntime);
|
|
371
|
+
registerHistoryRoutes(app, withRuntime);
|
|
372
|
+
registerPromptsRoutes(app, ctx);
|
|
373
|
+
registerProjectTeamRoutes(app, ctx, withRuntime);
|
|
374
|
+
registerApprovalsRoutes(app, ctx, withRuntime);
|
|
375
|
+
registerOrchestraRoutes(app, withRuntime);
|
|
376
|
+
registerCrewsRoutes(app, withRuntime);
|
|
377
|
+
registerProofRoutes(app, withRuntime);
|
|
378
|
+
registerShiftRoutes(app, withRuntime);
|
|
379
|
+
registerOnboardRoutes(app, { team: this.team, onboarding: this.onboarding, ...(this.gh ? { gh: this.gh } : {}) }, withRuntime);
|
|
380
|
+
registerAgentsRoutes(app, withRuntime);
|
|
381
|
+
registerBrainRoutes(app, withRuntime);
|
|
382
|
+
registerRoutingRoutes(app, withRuntime);
|
|
383
|
+
registerTerminalsRoutes(app, ctx);
|
|
384
|
+
registerBoardRoutes(app, ctx, withRuntime);
|
|
385
|
+
registerIntegrationsProjectRoutes(app);
|
|
386
|
+
registerFilesRoutes(app);
|
|
387
|
+
registerPreviewRoutes(app);
|
|
388
|
+
// Anything under /api nobody answered: JSON, like every other API reply,
|
|
389
|
+
// not Express's HTML page.
|
|
390
|
+
app.use("/api", (_req, res) => {
|
|
391
|
+
res.status(404).json({ error: "no such endpoint" });
|
|
392
|
+
});
|
|
393
|
+
// Errors that escaped a route, and bodies that never parsed. Express's
|
|
394
|
+
// default answers these with an HTML page carrying a stack trace.
|
|
395
|
+
app.use((err, _req, res, next) => {
|
|
396
|
+
if (res.headersSent)
|
|
397
|
+
return void next(err);
|
|
398
|
+
const e = (err ?? {});
|
|
399
|
+
if (e.type === "entity.too.large")
|
|
400
|
+
return void res.status(413).json({ error: "that request is too large (the limit is 2 MB)" });
|
|
401
|
+
if (e.type === "entity.parse.failed")
|
|
402
|
+
return void res.status(400).json({ error: "that request body isn't valid JSON" });
|
|
403
|
+
logbook.error("daemon", `request failed: ${e.message ?? String(err)}`, err);
|
|
404
|
+
const status = typeof e.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 500;
|
|
405
|
+
res.status(status).json({ error: status >= 500 ? "something went wrong in the daemon — the Console has the details" : e.message || "bad request" });
|
|
1412
406
|
});
|
|
1413
407
|
}
|
|
1414
408
|
// -------------------------------------------------------------------------
|
|
@@ -1422,6 +416,7 @@ export class LoomDaemon {
|
|
|
1422
416
|
if (existing) {
|
|
1423
417
|
// Hot-reload edited .loom/config.json once the project is quiet.
|
|
1424
418
|
if (existing.configStale() && !existing.anyBusy()) {
|
|
419
|
+
this.team.detachRuntime(info.id);
|
|
1425
420
|
await existing.close();
|
|
1426
421
|
this.runtimes.delete(info.id);
|
|
1427
422
|
}
|
|
@@ -1430,19 +425,78 @@ export class LoomDaemon {
|
|
|
1430
425
|
}
|
|
1431
426
|
}
|
|
1432
427
|
const rt = await ProjectRuntime.open(info);
|
|
1433
|
-
rt.
|
|
428
|
+
rt.onServerEvent((f) => {
|
|
429
|
+
this.broadcastFrame({ type: "server", projectId: info.id, ...f }, info.id);
|
|
430
|
+
});
|
|
431
|
+
rt.onQueueChange((q) => {
|
|
432
|
+
const head = q.items[0];
|
|
433
|
+
const waitingFor = head && !q.paused ? rt.queueBlocker(head) : null;
|
|
434
|
+
this.broadcastFrame({
|
|
435
|
+
type: "queue",
|
|
436
|
+
projectId: info.id,
|
|
437
|
+
queue: q.items,
|
|
438
|
+
paused: q.paused,
|
|
439
|
+
...(q.reason ? { reason: q.reason } : {}),
|
|
440
|
+
...(waitingFor ? { waitingFor } : {}),
|
|
441
|
+
}, info.id);
|
|
442
|
+
});
|
|
443
|
+
rt.onLiveDelta((d) => {
|
|
444
|
+
this.broadcastFrame({ type: "phase" in d ? "item" : "delta", projectId: info.id, ...d }, info.id);
|
|
445
|
+
});
|
|
446
|
+
// Replies as they're written. Deltas are coalesced per agent for a frame
|
|
447
|
+
// (~40ms), so a fast model is a few dozen socket frames a second at most,
|
|
448
|
+
// not one per token. Never logged: the finished message is the record.
|
|
449
|
+
const live = new Map();
|
|
450
|
+
let liveTimer = null;
|
|
451
|
+
const flushLive = () => {
|
|
452
|
+
liveTimer = null;
|
|
453
|
+
for (const [agentId, f] of live) {
|
|
454
|
+
this.broadcastFrame({
|
|
455
|
+
type: "stream", projectId: info.id, agentId, chat: f.chat, text: f.text,
|
|
456
|
+
...(f.reasoning ? { reasoning: true } : {}),
|
|
457
|
+
...(f.off !== undefined ? { off: f.off } : {}),
|
|
458
|
+
}, info.id);
|
|
459
|
+
}
|
|
460
|
+
live.clear();
|
|
461
|
+
};
|
|
462
|
+
rt.onStream((f) => {
|
|
463
|
+
const key = f.agentId;
|
|
464
|
+
const have = live.get(key);
|
|
465
|
+
// A switch between thinking and answering (or thread) is a new piece.
|
|
466
|
+
if (have && (have.reasoning !== Boolean(f.reasoning) || have.chat !== f.chat))
|
|
467
|
+
flushLive();
|
|
468
|
+
const cur = live.get(key);
|
|
469
|
+
if (cur)
|
|
470
|
+
cur.text += f.text;
|
|
471
|
+
else
|
|
472
|
+
live.set(key, { chat: f.chat, text: f.text, reasoning: Boolean(f.reasoning), ...(f.off !== undefined ? { off: f.off } : {}) });
|
|
473
|
+
if (!liveTimer)
|
|
474
|
+
liveTimer = setTimeout(flushLive, 40);
|
|
475
|
+
});
|
|
476
|
+
rt.log.onEvent((e) => {
|
|
477
|
+
// A finished message supersedes its typing: send what's buffered first
|
|
478
|
+
// so the last few characters can't land after the message they belong to.
|
|
479
|
+
if (e.kind === "message" && e.agentId && live.has(e.agentId)) {
|
|
480
|
+
if (liveTimer)
|
|
481
|
+
clearTimeout(liveTimer);
|
|
482
|
+
flushLive();
|
|
483
|
+
}
|
|
484
|
+
this.broadcast(info.id, e);
|
|
485
|
+
// The single central hook for live events — agent turns as well as
|
|
486
|
+
// API-driven handoffs, routes and memory folds. Rehydration reads through
|
|
487
|
+
// log.list(), not append(), so replaying history never re-exports it.
|
|
488
|
+
recordAgentEvent(e, { project: info.name });
|
|
489
|
+
});
|
|
1434
490
|
this.runtimes.set(info.id, rt);
|
|
491
|
+
this.team.attachRuntime(rt);
|
|
1435
492
|
return rt;
|
|
1436
493
|
}
|
|
494
|
+
/** One frame to everyone watching this project (or everyone, with no project). */
|
|
495
|
+
broadcastFrame(payload, projectId) {
|
|
496
|
+
this.delivery.publish(payload, { kind: "project", projectId });
|
|
497
|
+
}
|
|
1437
498
|
broadcast(projectId, event) {
|
|
1438
|
-
|
|
1439
|
-
for (const [ws, sub] of this.sockets) {
|
|
1440
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
1441
|
-
continue;
|
|
1442
|
-
if (sub.project && sub.project !== projectId)
|
|
1443
|
-
continue;
|
|
1444
|
-
ws.send(frame);
|
|
1445
|
-
}
|
|
499
|
+
this.broadcastFrame({ type: "event", projectId, event }, projectId);
|
|
1446
500
|
// An agent's error is a thread event AND a log line. The thread shows it to
|
|
1447
501
|
// whoever is reading that conversation; the Console shows it to whoever is
|
|
1448
502
|
// wondering why nothing happened. Those are often the same person and never
|
|
@@ -1461,44 +515,63 @@ export class LoomDaemon {
|
|
|
1461
515
|
*/
|
|
1462
516
|
streamLogs() {
|
|
1463
517
|
return logbook.subscribe((record) => {
|
|
1464
|
-
|
|
1465
|
-
for (const [ws] of this.sockets) {
|
|
1466
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
1467
|
-
ws.send(frame);
|
|
1468
|
-
}
|
|
518
|
+
this.delivery.publish({ type: "log", record }, { kind: "log", projectId: record.project });
|
|
1469
519
|
});
|
|
1470
520
|
}
|
|
1471
521
|
/** Fan a terminal frame out to every socket watching this project. */
|
|
1472
522
|
broadcastTerm(projectId, frame) {
|
|
1473
|
-
|
|
1474
|
-
for (const [ws, sub] of this.sockets) {
|
|
1475
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
1476
|
-
continue;
|
|
1477
|
-
if (sub.project && sub.project !== projectId)
|
|
1478
|
-
continue;
|
|
1479
|
-
ws.send(payload);
|
|
1480
|
-
}
|
|
523
|
+
this.delivery.publish(frame, { kind: "project", projectId });
|
|
1481
524
|
}
|
|
1482
|
-
pushTokens() {
|
|
525
|
+
pushTokens(category) {
|
|
1483
526
|
const cfg = readDaemonConfig();
|
|
1484
527
|
return (cfg?.clients ?? [])
|
|
528
|
+
.filter((c) => !category || wantsPush(c.pushKinds, category))
|
|
1485
529
|
.map((c) => c.pushToken)
|
|
1486
530
|
.filter((t) => Boolean(t));
|
|
1487
531
|
}
|
|
1488
532
|
/** Fire-and-notify to phones. Route hops stay quiet; the outcome pushes. */
|
|
1489
533
|
maybePush(projectId, event) {
|
|
1490
|
-
|
|
534
|
+
const category = pushCategory(event);
|
|
535
|
+
if (!category)
|
|
1491
536
|
return;
|
|
1492
|
-
|
|
1493
|
-
|
|
537
|
+
const rt = this.runtimes.get(projectId);
|
|
538
|
+
let reply;
|
|
539
|
+
if (event.kind === "run_complete") {
|
|
540
|
+
if (rt?.routes.isActive())
|
|
541
|
+
return; // a pipeline in flight buzzes once at the end, not per hop
|
|
542
|
+
if (event.payload.error)
|
|
543
|
+
return; // the error event says it
|
|
544
|
+
if (rt) {
|
|
545
|
+
// a goal's or crew's own task turns stay quiet: the goal pushes when it's done
|
|
546
|
+
try {
|
|
547
|
+
const chat = event.chat ?? "main";
|
|
548
|
+
const g = rt.chats().find((c) => c.id === chat)?.group;
|
|
549
|
+
if (g && g.kind !== "import")
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
catch { /* push anyway */ }
|
|
553
|
+
try {
|
|
554
|
+
reply = rt.log.list({ kinds: ["message"], chat: event.chat ?? "main", before: event.id, limit: 1 })
|
|
555
|
+
.find((m) => !m.agentId || m.agentId === event.agentId)?.payload.text;
|
|
556
|
+
}
|
|
557
|
+
catch { /* a push without the snippet */ }
|
|
558
|
+
}
|
|
1494
559
|
}
|
|
1495
|
-
const tokens = this.pushTokens();
|
|
560
|
+
const tokens = this.pushTokens(category);
|
|
1496
561
|
if (!tokens.length)
|
|
1497
562
|
return;
|
|
1498
563
|
const name = listProjects().find((p) => p.id === projectId)?.name ?? "project";
|
|
1499
564
|
void sendExpoPush(tokens, {
|
|
1500
|
-
...pushContent(name, event),
|
|
1501
|
-
|
|
565
|
+
...pushContent(name, event, { reply }),
|
|
566
|
+
// the phone opens this project (and goal, or chat) when the alert is tapped
|
|
567
|
+
data: {
|
|
568
|
+
projectId,
|
|
569
|
+
kind: event.kind,
|
|
570
|
+
category,
|
|
571
|
+
...(typeof event.payload.runId === "string" ? { runId: event.payload.runId } : {}),
|
|
572
|
+
...(typeof event.payload.crewId === "string" ? { crewId: event.payload.crewId } : {}),
|
|
573
|
+
...(event.chat ? { chat: event.chat } : {}),
|
|
574
|
+
},
|
|
1502
575
|
});
|
|
1503
576
|
}
|
|
1504
577
|
// -------------------------------------------------------------------------
|
|
@@ -1514,6 +587,7 @@ export class LoomDaemon {
|
|
|
1514
587
|
// resolve this as a phantom success — a daemon that prints "listening" and
|
|
1515
588
|
// exits 0 while another process actually holds the port.
|
|
1516
589
|
const server = this.app.listen(this.port, this.host);
|
|
590
|
+
configureHttpServer(server);
|
|
1517
591
|
this.server = server;
|
|
1518
592
|
let settled = false;
|
|
1519
593
|
server.once("error", (err) => {
|
|
@@ -1533,11 +607,58 @@ export class LoomDaemon {
|
|
|
1533
607
|
if (addr && typeof addr === "object")
|
|
1534
608
|
this.port = addr.port; // ephemeral port support
|
|
1535
609
|
this.wss = this.attachWs(this.server);
|
|
610
|
+
// Agents ask from inside the daemon (core/approvals.ts).
|
|
611
|
+
setApprovalBroker((req) => this.askHuman(req));
|
|
1536
612
|
// Fan every log record out to connected clients (the Console tab).
|
|
1537
613
|
this.unstreamLogs = this.streamLogs();
|
|
1538
614
|
this.writeConfig();
|
|
615
|
+
if (readCloudSettings().enabled)
|
|
616
|
+
void this.startCloud().catch(() => { });
|
|
617
|
+
void this.team.start().catch(() => { });
|
|
618
|
+
this.watchReleases();
|
|
1539
619
|
return { host: this.host, port: this.port };
|
|
1540
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* A new Loom release tells each phone once ("updates" push). The first look
|
|
623
|
+
* waits a minute so a restart doesn't hit GitHub at once, then every few
|
|
624
|
+
* hours; the version already announced is remembered across restarts.
|
|
625
|
+
*/
|
|
626
|
+
watchReleases() {
|
|
627
|
+
if (process.env.LOOM_NO_UPDATE_CHECK || process.env.VITEST)
|
|
628
|
+
return;
|
|
629
|
+
const look = async () => {
|
|
630
|
+
const release = await this.cachedRelease(false);
|
|
631
|
+
if (!release || !newerThan(release.version, VERSION))
|
|
632
|
+
return;
|
|
633
|
+
const told = path.join(loomHome(), "update-announced");
|
|
634
|
+
let last = "";
|
|
635
|
+
try {
|
|
636
|
+
last = fs.readFileSync(told, "utf8").trim();
|
|
637
|
+
}
|
|
638
|
+
catch { /* never told */ }
|
|
639
|
+
if (last === release.version)
|
|
640
|
+
return;
|
|
641
|
+
const tokens = this.pushTokens("updates");
|
|
642
|
+
try {
|
|
643
|
+
fs.writeFileSync(told, release.version);
|
|
644
|
+
}
|
|
645
|
+
catch { /* tell again next time */ }
|
|
646
|
+
if (!tokens.length)
|
|
647
|
+
return;
|
|
648
|
+
void sendExpoPush(tokens, {
|
|
649
|
+
title: `Loom ${release.version} is out`,
|
|
650
|
+
body: `This computer runs ${VERSION}. Update it from Loom's Settings, or run: loom update`,
|
|
651
|
+
data: { kind: "update", category: "updates", version: release.version, url: release.url },
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
const first = setTimeout(() => void look().catch(() => { }), 60_000);
|
|
655
|
+
const every = setInterval(() => void look().catch(() => { }), CHECK_TTL_MS);
|
|
656
|
+
first.unref?.();
|
|
657
|
+
every.unref?.();
|
|
658
|
+
this.healTimers.add(first);
|
|
659
|
+
this.releaseTimer = every;
|
|
660
|
+
}
|
|
661
|
+
releaseTimer = null;
|
|
1541
662
|
/**
|
|
1542
663
|
* Attach a WebSocket server (path /ws) to an HTTP server and wire the
|
|
1543
664
|
* connection handler. Returned so extra phone-access listeners can track and
|
|
@@ -1545,6 +666,25 @@ export class LoomDaemon {
|
|
|
1545
666
|
*/
|
|
1546
667
|
attachWs(server) {
|
|
1547
668
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
669
|
+
const heartbeat = setInterval(() => {
|
|
670
|
+
for (const ws of wss.clients) {
|
|
671
|
+
const live = ws;
|
|
672
|
+
if (live.loomAlive === false) {
|
|
673
|
+
try {
|
|
674
|
+
ws.terminate();
|
|
675
|
+
}
|
|
676
|
+
catch { /* gone */ }
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
live.loomAlive = false;
|
|
680
|
+
try {
|
|
681
|
+
ws.ping();
|
|
682
|
+
}
|
|
683
|
+
catch { /* closing */ }
|
|
684
|
+
}
|
|
685
|
+
}, 30_000);
|
|
686
|
+
heartbeat.unref();
|
|
687
|
+
wss.on("close", () => clearInterval(heartbeat));
|
|
1548
688
|
wss.on("connection", (ws, req) => {
|
|
1549
689
|
const url = new URL(req.url ?? "/ws", `http://${this.host}:${this.port}`);
|
|
1550
690
|
// Prefer the token in the `Sec-WebSocket-Protocol` header (a header isn't
|
|
@@ -1567,7 +707,11 @@ export class LoomDaemon {
|
|
|
1567
707
|
// Ensure the runtime is live so its events flow.
|
|
1568
708
|
void this.runtime(project).catch(() => { });
|
|
1569
709
|
}
|
|
1570
|
-
this.
|
|
710
|
+
const wsScope = this.auth.allowedProjects(token);
|
|
711
|
+
this.sockets.set(ws, {
|
|
712
|
+
...(resolvedProject ? { project: resolvedProject } : {}),
|
|
713
|
+
...(wsScope ? { scope: wsScope } : {}),
|
|
714
|
+
});
|
|
1571
715
|
ws.send(JSON.stringify({
|
|
1572
716
|
type: "hello",
|
|
1573
717
|
projects: listProjects().map((p) => p.id),
|
|
@@ -1595,6 +739,10 @@ export class LoomDaemon {
|
|
|
1595
739
|
sess.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
|
|
1596
740
|
}
|
|
1597
741
|
});
|
|
742
|
+
// heartbeat: a socket that misses a pong is half-open (a sleeping laptop,
|
|
743
|
+
// a phone off its network) and is dropped instead of buffering for minutes
|
|
744
|
+
ws.loomAlive = true;
|
|
745
|
+
ws.on("pong", () => { ws.loomAlive = true; });
|
|
1598
746
|
ws.on("close", () => this.sockets.delete(ws));
|
|
1599
747
|
});
|
|
1600
748
|
return wss;
|
|
@@ -1619,6 +767,7 @@ export class LoomDaemon {
|
|
|
1619
767
|
if (!ip || ip === this.host || this.extra.has(ip))
|
|
1620
768
|
return;
|
|
1621
769
|
const server = http.createServer(this.app);
|
|
770
|
+
configureHttpServer(server);
|
|
1622
771
|
await new Promise((resolve, reject) => {
|
|
1623
772
|
server.listen(this.port, ip, () => resolve());
|
|
1624
773
|
server.on("error", reject);
|
|
@@ -1631,10 +780,162 @@ export class LoomDaemon {
|
|
|
1631
780
|
exposedIps() {
|
|
1632
781
|
return [...this.extra.keys()];
|
|
1633
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* The self-heal recheck loop: after a firing alert fails the baton over, wait
|
|
785
|
+
* LOOM_HEAL_RECHECK_MS, ask the telemetry store (or the local log) whether the
|
|
786
|
+
* agent has errored since it was quarantined, and if not, hand the baton back
|
|
787
|
+
* — retrying up to LOOM_HEAL_MAX_RETRIES times before giving up. Best-effort
|
|
788
|
+
* and unref'd: a daemon restart simply forgets the loop.
|
|
789
|
+
*/
|
|
790
|
+
startHealLoop(rt, agent, alert, since) {
|
|
791
|
+
if (process.env.LOOM_HEAL_DISABLED === "1")
|
|
792
|
+
return;
|
|
793
|
+
const recheckMs = Math.max(1, Number(process.env.LOOM_HEAL_RECHECK_MS) || 60_000);
|
|
794
|
+
const maxRetries = Math.max(1, Number(process.env.LOOM_HEAL_MAX_RETRIES) || 3);
|
|
795
|
+
let attempt = 0;
|
|
796
|
+
const tick = async () => {
|
|
797
|
+
attempt += 1;
|
|
798
|
+
if (!rt.quarantined()[agent])
|
|
799
|
+
return; // lifted already (a resolved alert, say)
|
|
800
|
+
const recovered = await this.agentRecovered(rt, agent, since).catch(() => false);
|
|
801
|
+
if (recovered) {
|
|
802
|
+
rt.unquarantine(agent);
|
|
803
|
+
const retried = rt.baton.holder() !== agent;
|
|
804
|
+
// A hand-back that's refused (the stand-in is mid-turn, say) is part
|
|
805
|
+
// of what happened; recording "recovered" without it reads as done.
|
|
806
|
+
let handBack;
|
|
807
|
+
if (retried)
|
|
808
|
+
await rt.handoff(agent).catch((err) => { handBack = err instanceof Error ? err.message : String(err); });
|
|
809
|
+
rt.log.append({ kind: "status", agentId: agent, payload: { state: "alert_recovery", alert, retried, attempt, via: "recheck", ...(handBack ? { handBackFailed: handBack } : {}) } });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (attempt >= maxRetries) {
|
|
813
|
+
rt.log.append({ kind: "status", agentId: agent, payload: { state: "alert_heal_exhausted", alert, attempts: attempt } });
|
|
814
|
+
return; // stays quarantined for a human
|
|
815
|
+
}
|
|
816
|
+
schedule();
|
|
817
|
+
};
|
|
818
|
+
const schedule = () => {
|
|
819
|
+
const t = setTimeout(() => { this.healTimers.delete(t); void tick(); }, recheckMs);
|
|
820
|
+
if (typeof t.unref === "function")
|
|
821
|
+
t.unref();
|
|
822
|
+
this.healTimers.add(t);
|
|
823
|
+
};
|
|
824
|
+
schedule();
|
|
825
|
+
}
|
|
826
|
+
/** Recovered = no error spans since it was quarantined (backend first, local log fallback). */
|
|
827
|
+
async agentRecovered(rt, agent, sinceMs) {
|
|
828
|
+
try {
|
|
829
|
+
return (await recentAgentErrors(rt.info.name, agent, sinceMs)) === 0;
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
const errs = rt.log.list({ limit: 400 }).filter((e) => e.ts > sinceMs && e.agentId === agent &&
|
|
833
|
+
(e.kind === "error" || (e.kind === "run_complete" && e.payload.error)));
|
|
834
|
+
return errs.length === 0;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// -------------------------------------------------------------------------
|
|
838
|
+
// Loom Cloud
|
|
839
|
+
// -------------------------------------------------------------------------
|
|
840
|
+
/** `&relay=<channel.key>&sb=<url>&sbk=<anon key>` when the relay is on, else "". */
|
|
841
|
+
cloudLinkParams() {
|
|
842
|
+
const s = readCloudSettings();
|
|
843
|
+
const target = supabaseTarget(s);
|
|
844
|
+
if (!s.enabled || !s.channel || !s.key || !target)
|
|
845
|
+
return "";
|
|
846
|
+
const b64 = (v) => toB64(new TextEncoder().encode(v));
|
|
847
|
+
return `&relay=${packCredentials({ channel: s.channel, key: s.key })}&sb=${b64(target.url)}&sbk=${b64(target.anonKey)}`;
|
|
848
|
+
}
|
|
849
|
+
cloudStatus() {
|
|
850
|
+
const s = readCloudSettings();
|
|
851
|
+
const target = supabaseTarget(s);
|
|
852
|
+
return {
|
|
853
|
+
configured: Boolean(target || this.relayTransportFactory),
|
|
854
|
+
enabled: s.enabled,
|
|
855
|
+
connected: Boolean(this.relay),
|
|
856
|
+
clients: this.relay?.clientCount() ?? 0,
|
|
857
|
+
stats: this.relay?.stats ?? null,
|
|
858
|
+
supabaseUrl: target?.url ?? null,
|
|
859
|
+
hostedUrl: hostedTarget().supabaseUrl,
|
|
860
|
+
error: this.relayError,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
async startCloud() {
|
|
864
|
+
const s = readCloudSettings();
|
|
865
|
+
const creds = ensureCredentials(s);
|
|
866
|
+
s.enabled = true;
|
|
867
|
+
writeCloudSettings(s);
|
|
868
|
+
await this.relay?.close().catch(() => { });
|
|
869
|
+
this.relay = null;
|
|
870
|
+
this.relayError = null;
|
|
871
|
+
try {
|
|
872
|
+
let transport;
|
|
873
|
+
if (this.relayTransportFactory)
|
|
874
|
+
transport = await this.relayTransportFactory(creds.channel);
|
|
875
|
+
else {
|
|
876
|
+
const target = supabaseTarget(s);
|
|
877
|
+
if (!target)
|
|
878
|
+
throw new Error("no Supabase project — set LOOM_SUPABASE_URL and LOOM_SUPABASE_ANON_KEY");
|
|
879
|
+
transport = await supabaseTransport(target.url, target.anonKey, creds.channel);
|
|
880
|
+
}
|
|
881
|
+
await Promise.race([
|
|
882
|
+
transport.ready(),
|
|
883
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("timed out joining the relay channel")), 15_000)),
|
|
884
|
+
]);
|
|
885
|
+
this.relay = new RelayBridge({
|
|
886
|
+
transport,
|
|
887
|
+
key: creds.key,
|
|
888
|
+
localBase: () => `http://127.0.0.1:${this.port}`,
|
|
889
|
+
identity: () => ({ version: BUILD_REV, name: os.hostname() }),
|
|
890
|
+
});
|
|
891
|
+
logbook.info("cloud", "Loom Cloud relay connected");
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
this.relayError = err.message;
|
|
895
|
+
logbook.warn("cloud", "Loom Cloud relay failed to start", this.relayError);
|
|
896
|
+
throw err;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async stopCloud(opts = {}) {
|
|
900
|
+
await this.relay?.close().catch(() => { });
|
|
901
|
+
this.relay = null;
|
|
902
|
+
const s = readCloudSettings();
|
|
903
|
+
s.enabled = false;
|
|
904
|
+
if (opts.rotate) {
|
|
905
|
+
delete s.channel;
|
|
906
|
+
delete s.key;
|
|
907
|
+
}
|
|
908
|
+
writeCloudSettings(s);
|
|
909
|
+
}
|
|
910
|
+
/** The last release check, kept so a poll can't hammer GitHub's API. */
|
|
911
|
+
releaseSeen = null;
|
|
912
|
+
updating = false;
|
|
913
|
+
async cachedRelease(refresh) {
|
|
914
|
+
if (!refresh && this.releaseSeen && Date.now() - this.releaseSeen.at < CHECK_TTL_MS)
|
|
915
|
+
return this.releaseSeen.release;
|
|
916
|
+
const release = await latestRelease();
|
|
917
|
+
this.releaseSeen = { at: Date.now(), release };
|
|
918
|
+
return release;
|
|
919
|
+
}
|
|
1634
920
|
async close() {
|
|
921
|
+
// A broker pointing at a closed daemon would leave the next adapter
|
|
922
|
+
// waiting on a card nobody will ever see.
|
|
923
|
+
setApprovalBroker(null);
|
|
924
|
+
for (const a of [...this.approvals.values()]) {
|
|
925
|
+
a.settle({ behavior: "deny", message: "the daemon is shutting down" });
|
|
926
|
+
}
|
|
927
|
+
await this.team.stop().catch(() => { });
|
|
928
|
+
await this.relay?.close().catch(() => { });
|
|
929
|
+
this.relay = null;
|
|
930
|
+
for (const t of this.healTimers)
|
|
931
|
+
clearTimeout(t);
|
|
932
|
+
if (this.releaseTimer)
|
|
933
|
+
clearInterval(this.releaseTimer);
|
|
934
|
+
this.healTimers.clear();
|
|
1635
935
|
this.unstreamLogs?.();
|
|
1636
936
|
this.unstreamLogs = null;
|
|
1637
|
-
this.
|
|
937
|
+
this.specRunner.closeAll();
|
|
938
|
+
await this.terminals.closeAll();
|
|
1638
939
|
for (const rt of this.runtimes.values())
|
|
1639
940
|
await rt.close();
|
|
1640
941
|
this.runtimes.clear();
|
|
@@ -1643,11 +944,18 @@ export class LoomDaemon {
|
|
|
1643
944
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
1644
945
|
}
|
|
1645
946
|
this.extra.clear();
|
|
947
|
+
// Close the live sockets too. wss.close() stops new connections but leaves
|
|
948
|
+
// open ones open, and server.close() waits for every keep-alive socket a
|
|
949
|
+
// browser holds — so a restart used to leave the old daemon alive forever,
|
|
950
|
+
// its port freed but the process (and everything it held) still running.
|
|
951
|
+
for (const ws of this.wss?.clients ?? [])
|
|
952
|
+
ws.terminate();
|
|
1646
953
|
this.wss?.close();
|
|
1647
954
|
await new Promise((resolve) => {
|
|
1648
955
|
if (!this.server)
|
|
1649
956
|
return resolve();
|
|
1650
957
|
this.server.close(() => resolve());
|
|
958
|
+
this.server.closeAllConnections?.();
|
|
1651
959
|
});
|
|
1652
960
|
const cfg = readDaemonConfig();
|
|
1653
961
|
if (cfg && cfg.pid === process.pid) {
|
|
@@ -1656,73 +964,4 @@ export class LoomDaemon {
|
|
|
1656
964
|
}
|
|
1657
965
|
}
|
|
1658
966
|
}
|
|
1659
|
-
/** Resolve this machine's Tailscale IPv4 — the tailnet is the trust boundary. */
|
|
1660
|
-
export function tailscaleIp() {
|
|
1661
|
-
return new Promise((resolve, reject) => {
|
|
1662
|
-
execFile("tailscale", ["ip", "-4"], (err, stdout) => {
|
|
1663
|
-
if (err) {
|
|
1664
|
-
reject(new Error("could not resolve a Tailscale IP (is tailscale installed and up?) — refusing to bind beyond localhost"));
|
|
1665
|
-
return;
|
|
1666
|
-
}
|
|
1667
|
-
const ip = stdout.trim().split("\n")[0];
|
|
1668
|
-
if (!ip)
|
|
1669
|
-
return void reject(new Error("tailscale returned no IPv4"));
|
|
1670
|
-
resolve(ip);
|
|
1671
|
-
});
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
/**
|
|
1675
|
-
* This machine's LAN IPv4 — the address a phone on the same Wi-Fi uses. We skip
|
|
1676
|
-
* loopback, link-local (169.254), and Tailscale's own 100.64/10 CGNAT range so
|
|
1677
|
-
* "local network" and "tailnet" stay distinct choices. Returns null when the
|
|
1678
|
-
* only addresses are loopback (e.g. no network) — the caller says so honestly
|
|
1679
|
-
* rather than minting an unreachable QR.
|
|
1680
|
-
*/
|
|
1681
|
-
export function lanIp() {
|
|
1682
|
-
const ifaces = os.networkInterfaces();
|
|
1683
|
-
const candidates = [];
|
|
1684
|
-
for (const addrs of Object.values(ifaces)) {
|
|
1685
|
-
for (const a of addrs ?? []) {
|
|
1686
|
-
if (a.family !== "IPv4" || a.internal)
|
|
1687
|
-
continue;
|
|
1688
|
-
if (a.address.startsWith("169.254."))
|
|
1689
|
-
continue; // link-local, not routable
|
|
1690
|
-
if (a.address.startsWith("100."))
|
|
1691
|
-
continue; // Tailscale CGNAT — that's the tailnet
|
|
1692
|
-
candidates.push(a.address);
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
// Prefer the common private ranges (a real LAN) over anything exotic.
|
|
1696
|
-
const priv = candidates.find((ip) => ip.startsWith("192.168.") || ip.startsWith("10.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip));
|
|
1697
|
-
return priv ?? candidates[0] ?? null;
|
|
1698
|
-
}
|
|
1699
|
-
/** Is this connection from the same machine? (127.0.0.1, ::1, or v4-mapped v6.) */
|
|
1700
|
-
export function isLoopback(remoteAddress) {
|
|
1701
|
-
if (!remoteAddress)
|
|
1702
|
-
return false;
|
|
1703
|
-
return (remoteAddress === "127.0.0.1" ||
|
|
1704
|
-
remoteAddress === "::1" ||
|
|
1705
|
-
remoteAddress === "::ffff:127.0.0.1");
|
|
1706
|
-
}
|
|
1707
|
-
/**
|
|
1708
|
-
* Is the request's `Host` header a loopback literal? The anti-DNS-rebinding
|
|
1709
|
-
* check: a rebinding attacker loads a page from their own domain, so the browser
|
|
1710
|
-
* sends `Host: attacker.example` even after the name rebinds to 127.0.0.1 — while
|
|
1711
|
-
* the genuine local console is always reached at `127.0.0.1`/`localhost`. Pairing
|
|
1712
|
-
* the socket check with this closes the "any website → localhost token oracle"
|
|
1713
|
-
* path. The port is ignored; only the host is checked.
|
|
1714
|
-
*/
|
|
1715
|
-
export function isLoopbackHost(hostHeader) {
|
|
1716
|
-
if (!hostHeader)
|
|
1717
|
-
return false;
|
|
1718
|
-
let host = hostHeader.trim().toLowerCase();
|
|
1719
|
-
if (host.startsWith("[")) {
|
|
1720
|
-
const end = host.indexOf("]");
|
|
1721
|
-
host = end > 0 ? host.slice(1, end) : host.slice(1);
|
|
1722
|
-
}
|
|
1723
|
-
else if ((host.match(/:/g) || []).length === 1) {
|
|
1724
|
-
host = host.slice(0, host.indexOf(":")); // strip the port off host:port
|
|
1725
|
-
}
|
|
1726
|
-
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
1727
|
-
}
|
|
1728
967
|
//# sourceMappingURL=server.js.map
|