@loompad/cli 0.1.0 → 1.1.2
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 +1054 -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 +4646 -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/release-notes/v1.1.2.md +21 -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
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team Link — a member's daemon on a Loom Team Hub (docs/teams-architecture.md).
|
|
3
|
+
*
|
|
4
|
+
* Phase 1, "see each other". What it does, and the decision each part serves:
|
|
5
|
+
* - signs in (D4: a GitHub login), holds this device's keys, and the team
|
|
6
|
+
* keys it has been given (D5 via invite links, D6 via sealed envelopes)
|
|
7
|
+
* - shares projects opt-in, or by git remote matching a team repo (D8)
|
|
8
|
+
* - heartbeats every live session in shared projects: agent, state, branch,
|
|
9
|
+
* file globs in the clear; goal/task titles sealed (D2, D24 — intent, never
|
|
10
|
+
* prompts or transcripts)
|
|
11
|
+
* - publishes goal lifecycle to the team feed, and PR/check activity by
|
|
12
|
+
* polling `gh` when the team has no GitHub App (D7)
|
|
13
|
+
* - keeps a decrypted live view (members, presence, feed) for the UI
|
|
14
|
+
*
|
|
15
|
+
* State lives in ~/.loom/team.json (0600): hub session, device keys, team keys.
|
|
16
|
+
* The hub never holds a readable team key.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from "../core/process.js";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { crewRequests } from "../core/crew-requests.js";
|
|
24
|
+
import { loomHome } from "../core/registry.js";
|
|
25
|
+
import { logbook } from "../core/logbook.js";
|
|
26
|
+
import { newDeviceKeys, newTeamKey, openFromTeam, openTeamKey, packInvite, sealForTeam, sealTeamKey, signPayload, verifyPayload, inviteSigned, unpackInvite, } from "../core/team-crypto.js";
|
|
27
|
+
import { LEASE_TTL_MS, normalizeRepo, inviteTag, } from "../core/team-hub.js";
|
|
28
|
+
import { HttpHubClient, hubErrorText, hubSignIn } from "../hub/client.js";
|
|
29
|
+
import { hostedSignIn, openInBrowser, SupabaseHubClient } from "../hub/supabase-client.js";
|
|
30
|
+
import { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } from "../core/hosted.js";
|
|
31
|
+
import { DEPLOY_POLL_MS, Deploys } from "./deploys.js";
|
|
32
|
+
import { defaultExec, Landing } from "./landing.js";
|
|
33
|
+
import { TeamBrain } from "./team-brain.js";
|
|
34
|
+
import { TeamCoordinator } from "./team-coordinator.js";
|
|
35
|
+
import { Runner, readRunnerConfig, writeRunnerConfig } from "./runner.js";
|
|
36
|
+
import { rollupCosts } from "../core/team-landing.js";
|
|
37
|
+
import { inviteFragment, inviteLink } from "../core/invite-link.js";
|
|
38
|
+
import { prRowFeed, WEBHOOK_EVENTS } from "../core/github-events.js";
|
|
39
|
+
function defaultStateFile() {
|
|
40
|
+
return path.join(loomHome(), "team.json");
|
|
41
|
+
}
|
|
42
|
+
function readState(file) {
|
|
43
|
+
try {
|
|
44
|
+
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
+
return { ...s, teams: s.teams ?? {} };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { teams: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function writeState(f, s) {
|
|
52
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
53
|
+
fs.writeFileSync(f + ".tmp", JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
54
|
+
fs.renameSync(f + ".tmp", f);
|
|
55
|
+
}
|
|
56
|
+
function run(cmd, args, cwd) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
execFile(cmd, args, { cwd, timeout: 20_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The project's GitHub repo ("owner/name"): `git config loom.repo` when set (a
|
|
63
|
+
* mirror, or a remote that isn't github.com), else its origin remote.
|
|
64
|
+
*/
|
|
65
|
+
export async function repoOf(dir) {
|
|
66
|
+
const pinned = (await run("git", ["config", "--get", "loom.repo"], dir).catch(() => "")).trim();
|
|
67
|
+
if (pinned) {
|
|
68
|
+
try {
|
|
69
|
+
return normalizeRepo(pinned);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* fall through to origin */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return normalizeRepo((await run("git", ["remote", "get-url", "origin"], dir)).trim());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const HEARTBEAT_MS = 15_000;
|
|
83
|
+
const GH_POLL_MS = 60_000;
|
|
84
|
+
const BRAIN_SYNC_MS = 60_000;
|
|
85
|
+
export class TeamLink {
|
|
86
|
+
host;
|
|
87
|
+
file;
|
|
88
|
+
state;
|
|
89
|
+
hubClient = null;
|
|
90
|
+
views = new Map();
|
|
91
|
+
unsubs = new Map();
|
|
92
|
+
timers = [];
|
|
93
|
+
live = new Map(); // teamId → presence keys this device reported last beat
|
|
94
|
+
logSubs = new Map(); // projectId → log unsubscribe
|
|
95
|
+
coordinators = new Map(); // projectId → coordinator (Phase 2)
|
|
96
|
+
brains = new Map(); // projectId → team brain (Phase 3)
|
|
97
|
+
landings = new Map(); // projectId → goal PRs on their way to main (Phase 4)
|
|
98
|
+
deploys = new Map(); // projectId → deploy watch (Phase 5)
|
|
99
|
+
/** Phase 5: this daemon as a runner, when it's configured as one. */
|
|
100
|
+
runner = null;
|
|
101
|
+
/** Return jobs we asked for → the project whose goal comes home (D76). */
|
|
102
|
+
pendingReturns = new Map();
|
|
103
|
+
started = false;
|
|
104
|
+
constructor(host) {
|
|
105
|
+
this.host = host;
|
|
106
|
+
this.file = host.statePath ?? defaultStateFile();
|
|
107
|
+
this.state = readState(this.file);
|
|
108
|
+
}
|
|
109
|
+
// ── lifecycle ──
|
|
110
|
+
async start() {
|
|
111
|
+
if (this.started)
|
|
112
|
+
return;
|
|
113
|
+
this.started = true;
|
|
114
|
+
if (this.state.hub)
|
|
115
|
+
await this.connect().catch((e) => logbook.warn("team", "couldn't reach the team hub", String(e)));
|
|
116
|
+
this.timers.push(setInterval(() => void this.tick().catch(() => { }), HEARTBEAT_MS));
|
|
117
|
+
this.timers.push(setInterval(() => void this.pollGitHub().catch(() => { }), GH_POLL_MS));
|
|
118
|
+
this.timers.push(setInterval(() => void this.syncBrains().catch(() => { }), BRAIN_SYNC_MS));
|
|
119
|
+
this.timers.push(setInterval(() => void this.pollDeploys().catch(() => { }), DEPLOY_POLL_MS));
|
|
120
|
+
for (const t of this.timers)
|
|
121
|
+
t.unref?.();
|
|
122
|
+
if ((this.runnerConfig().enabled || this.host.runnerExec) && this.state.hub)
|
|
123
|
+
await this.startRunner().catch((e) => logbook.warn("runner", "couldn't start the runner", String(e)));
|
|
124
|
+
}
|
|
125
|
+
async stop() {
|
|
126
|
+
for (const t of this.timers)
|
|
127
|
+
clearInterval(t);
|
|
128
|
+
this.timers = [];
|
|
129
|
+
for (const u of this.unsubs.values())
|
|
130
|
+
u();
|
|
131
|
+
this.unsubs.clear();
|
|
132
|
+
for (const u of this.logSubs.values())
|
|
133
|
+
u();
|
|
134
|
+
this.logSubs.clear();
|
|
135
|
+
for (const c of this.coordinators.values())
|
|
136
|
+
c.stop();
|
|
137
|
+
for (const b of this.brains.values())
|
|
138
|
+
b.stop();
|
|
139
|
+
for (const l of this.landings.values())
|
|
140
|
+
l.stop();
|
|
141
|
+
await this.runner?.stop();
|
|
142
|
+
// Say goodbye rather than let teammates wait out the TTL.
|
|
143
|
+
await this.clearAllPresence().catch(() => { });
|
|
144
|
+
this.started = false;
|
|
145
|
+
}
|
|
146
|
+
hub() {
|
|
147
|
+
if (!this.hubClient)
|
|
148
|
+
throw new Error("not signed in to a team hub — `loom team signin <hub-url>`");
|
|
149
|
+
return this.hubClient;
|
|
150
|
+
}
|
|
151
|
+
makeClient(url, token, key) {
|
|
152
|
+
if (this.host.hubFactory)
|
|
153
|
+
return this.host.hubFactory(url, token);
|
|
154
|
+
const supabaseUrl = hostedSupabaseUrl(url);
|
|
155
|
+
if (supabaseUrl === null)
|
|
156
|
+
return new HttpHubClient(url, token);
|
|
157
|
+
return new SupabaseHubClient({
|
|
158
|
+
supabaseUrl,
|
|
159
|
+
publishableKey: key || publishableKeyFor(supabaseUrl),
|
|
160
|
+
refreshToken: token,
|
|
161
|
+
onSession: (s) => this.persistHostedSession(hostedHubUrl(supabaseUrl), s.refreshToken),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Refresh tokens rotate: the one in team.json must always be the newest, or the next start signs us out. */
|
|
165
|
+
persistHostedSession(url, refreshToken) {
|
|
166
|
+
const h = this.state.hub;
|
|
167
|
+
if (!h || h.url !== url || h.token === refreshToken)
|
|
168
|
+
return;
|
|
169
|
+
h.token = refreshToken;
|
|
170
|
+
writeState(this.file, this.state);
|
|
171
|
+
}
|
|
172
|
+
/** Swap the hub client, closing a hosted one we replace (its refresh timer would race the new one's). */
|
|
173
|
+
setHubClient(client) {
|
|
174
|
+
const old = this.hubClient;
|
|
175
|
+
this.hubClient = client;
|
|
176
|
+
if (old && old !== client && old instanceof SupabaseHubClient)
|
|
177
|
+
void old.close();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Sign in to a hub. Hosted (no URL, "hosted", or `supabase:<url>`): GitHub
|
|
181
|
+
* OAuth in the browser, or a refresh token from a CLI that already did it.
|
|
182
|
+
* Self-hosted: as a GitHub login (defaults to the local `gh` user).
|
|
183
|
+
*/
|
|
184
|
+
async signIn(url, opts = {}) {
|
|
185
|
+
const supabaseUrl = hostedSupabaseUrl(url);
|
|
186
|
+
if (supabaseUrl !== null)
|
|
187
|
+
return this.signInHosted(supabaseUrl, opts.token);
|
|
188
|
+
const github = opts.github || (await run("gh", ["api", "user", "-q", ".login"]).then((s) => s.trim()).catch(() => ""));
|
|
189
|
+
if (!github)
|
|
190
|
+
throw new Error("which GitHub account? pass --github <login> (or sign in to gh)");
|
|
191
|
+
let token = opts.token;
|
|
192
|
+
let userId = "";
|
|
193
|
+
if (!token) {
|
|
194
|
+
const out = await hubSignIn(url, github, { ...(opts.secret ? { secret: opts.secret } : {}) });
|
|
195
|
+
token = out.token;
|
|
196
|
+
userId = out.user.id;
|
|
197
|
+
}
|
|
198
|
+
const client = this.makeClient(url, token);
|
|
199
|
+
if (!userId)
|
|
200
|
+
userId = (await client.me()).id;
|
|
201
|
+
this.state = { ...this.state, hub: { url: url.replace(/\/$/, ""), token, github: github.toLowerCase(), userId } };
|
|
202
|
+
writeState(this.file, this.state);
|
|
203
|
+
this.setHubClient(client);
|
|
204
|
+
for (const rt of this.host.runtimes())
|
|
205
|
+
rt.memberLogin = this.state.hub.github;
|
|
206
|
+
await this.ensureDevice();
|
|
207
|
+
}
|
|
208
|
+
/** The hosted hub (D65): a Supabase session from GitHub OAuth, kept as its refresh token. */
|
|
209
|
+
async signInHosted(supabaseUrl, refreshToken) {
|
|
210
|
+
const url = hostedHubUrl(supabaseUrl);
|
|
211
|
+
const publishableKey = publishableKeyFor(supabaseUrl);
|
|
212
|
+
let client;
|
|
213
|
+
let session = null;
|
|
214
|
+
if (this.host.hubFactory) {
|
|
215
|
+
if (!refreshToken)
|
|
216
|
+
throw new Error("hosted sign-in needs a browser; tests pass a token");
|
|
217
|
+
client = this.host.hubFactory(url, refreshToken);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
session = refreshToken
|
|
221
|
+
? null
|
|
222
|
+
: await hostedSignIn({
|
|
223
|
+
supabaseUrl,
|
|
224
|
+
publishableKey,
|
|
225
|
+
openBrowser: (u) => {
|
|
226
|
+
logbook.info("team", "opening GitHub sign-in in your browser", u);
|
|
227
|
+
openInBrowser(u);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
client = new SupabaseHubClient({
|
|
231
|
+
supabaseUrl,
|
|
232
|
+
publishableKey,
|
|
233
|
+
...(session ? { session } : { refreshToken: refreshToken }),
|
|
234
|
+
onSession: (s) => this.persistHostedSession(url, s.refreshToken),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const me = await client.me();
|
|
238
|
+
// me() restored (and maybe refreshed) the session: keep the newest refresh token
|
|
239
|
+
const token = client instanceof SupabaseHubClient ? (client.session()?.refreshToken ?? refreshToken) : refreshToken;
|
|
240
|
+
this.state = { ...this.state, hub: { url, token, github: me.github.toLowerCase(), userId: me.id, key: publishableKey } };
|
|
241
|
+
writeState(this.file, this.state);
|
|
242
|
+
this.setHubClient(client);
|
|
243
|
+
for (const rt of this.host.runtimes())
|
|
244
|
+
rt.memberLogin = this.state.hub.github;
|
|
245
|
+
await this.ensureDevice();
|
|
246
|
+
}
|
|
247
|
+
async ensureDevice() {
|
|
248
|
+
const keys = this.state.device ?? newDeviceKeys();
|
|
249
|
+
const d = await this.hub().registerDevice({ label: os.hostname().slice(0, 60), sealPub: keys.sealPub, signPub: keys.signPub });
|
|
250
|
+
this.state.device = { ...keys, id: d.id };
|
|
251
|
+
writeState(this.file, this.state);
|
|
252
|
+
return this.state.device;
|
|
253
|
+
}
|
|
254
|
+
device() {
|
|
255
|
+
const d = this.state.device;
|
|
256
|
+
if (!d?.id)
|
|
257
|
+
throw new Error("this device isn't registered with the hub yet");
|
|
258
|
+
return d;
|
|
259
|
+
}
|
|
260
|
+
/** (Re)attach to the hub: every team we know, live subscriptions, and shared projects. */
|
|
261
|
+
async connect() {
|
|
262
|
+
const h = this.state.hub;
|
|
263
|
+
if (!h)
|
|
264
|
+
return;
|
|
265
|
+
// A hosted client already holds the live session (its refresh token may be
|
|
266
|
+
// newer than any we'd rebuild from): keep it rather than race it.
|
|
267
|
+
const cur = this.hubClient;
|
|
268
|
+
const keep = cur instanceof SupabaseHubClient && hostedSupabaseUrl(h.url) === cur.supabaseUrl && !this.host.hubFactory;
|
|
269
|
+
if (!keep)
|
|
270
|
+
this.setHubClient(this.makeClient(h.url, h.token, h.key));
|
|
271
|
+
let teams;
|
|
272
|
+
try {
|
|
273
|
+
await this.ensureDevice();
|
|
274
|
+
teams = await this.hub().teams();
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
this.noteHub(err);
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
this.noteHub();
|
|
281
|
+
// A team the hub no longer lists is marked, not forgotten: a self-hosted
|
|
282
|
+
// hub restarted without its data lists nothing, and deleting here would
|
|
283
|
+
// throw away the only copy of the team keys. Removal arrives as
|
|
284
|
+
// `member_left`, and the keys rotate past us then anyway.
|
|
285
|
+
for (const [id, t] of Object.entries(this.state.teams)) {
|
|
286
|
+
if (!teams.some((x) => x.id === id))
|
|
287
|
+
t.missing = true;
|
|
288
|
+
}
|
|
289
|
+
for (const t of teams) {
|
|
290
|
+
const known = this.state.teams[t.id] ?? { name: t.name, role: t.role, keys: [] };
|
|
291
|
+
const { missing: _m, ...rest } = known;
|
|
292
|
+
this.state.teams[t.id] = { ...rest, name: t.name, role: t.role };
|
|
293
|
+
await this.pullKeys(t.id);
|
|
294
|
+
await this.attach(t.id);
|
|
295
|
+
}
|
|
296
|
+
writeState(this.file, this.state);
|
|
297
|
+
this.connected = true;
|
|
298
|
+
this.watchRuntimes();
|
|
299
|
+
void this.syncBrains().catch(() => { });
|
|
300
|
+
}
|
|
301
|
+
/** Open any key envelopes sealed to this device we don't have yet (D6). */
|
|
302
|
+
async pullKeys(teamId) {
|
|
303
|
+
const envs = await this.hub().keyEnvelopes(teamId, this.device().id);
|
|
304
|
+
const team = this.state.teams[teamId];
|
|
305
|
+
for (const e of envs) {
|
|
306
|
+
if (team.keys.some((k) => k.version === e.version))
|
|
307
|
+
continue;
|
|
308
|
+
const key = openTeamKey(this.device(), e.box);
|
|
309
|
+
if (key)
|
|
310
|
+
team.keys.push(key);
|
|
311
|
+
}
|
|
312
|
+
team.keys.sort((a, b) => a.version - b.version);
|
|
313
|
+
}
|
|
314
|
+
currentKey(teamId) {
|
|
315
|
+
const keys = this.state.teams[teamId]?.keys ?? [];
|
|
316
|
+
const k = keys[keys.length - 1];
|
|
317
|
+
if (!k)
|
|
318
|
+
throw new Error("no team key on this device — join through an invite link");
|
|
319
|
+
return k;
|
|
320
|
+
}
|
|
321
|
+
async attach(teamId) {
|
|
322
|
+
if (this.unsubs.has(teamId))
|
|
323
|
+
return;
|
|
324
|
+
const [members, repos, presence, feed, leases] = await Promise.all([
|
|
325
|
+
this.hub().members(teamId),
|
|
326
|
+
this.hub().repos(teamId),
|
|
327
|
+
this.hub().presence(teamId),
|
|
328
|
+
this.hub().feed(teamId, { limit: 200 }),
|
|
329
|
+
this.hub().leases(teamId).catch(() => []),
|
|
330
|
+
]);
|
|
331
|
+
this.views.set(teamId, {
|
|
332
|
+
members,
|
|
333
|
+
repos,
|
|
334
|
+
presence: new Map(presence.map((p) => [presenceKey(p), p])),
|
|
335
|
+
feed,
|
|
336
|
+
leases: new Map(leases.map((l) => [l.id, l])),
|
|
337
|
+
});
|
|
338
|
+
const unsub = await this.hub().subscribe(teamId, (e) => void this.onHubEvent(e));
|
|
339
|
+
this.unsubs.set(teamId, unsub);
|
|
340
|
+
// Someone may have joined on one of our invites while this Loom was off:
|
|
341
|
+
// they're in the member list now, newer than the invite.
|
|
342
|
+
// A join on one of our invites while this Loom was off: the feed says which
|
|
343
|
+
// invite each joiner used (hubs that say so); otherwise, members newer than it.
|
|
344
|
+
const granted = new Set();
|
|
345
|
+
for (const g of (this.state.grants ?? []).filter((x) => x.teamId === teamId && x.createdAt)) {
|
|
346
|
+
const byTag = g.tag ? feed.find((f) => f.type === "member_joined" && f.meta?.invite === g.tag) : undefined;
|
|
347
|
+
const login = byTag ? String(byTag.meta.github ?? "")
|
|
348
|
+
: feed.some((f) => f.type === "member_joined" && f.meta?.invite) ? "" // this hub tags joins: an untagged match isn't this invite's
|
|
349
|
+
: members.find((m) => m.joinedAt >= g.createdAt && m.user.github !== this.state.hub?.github && !granted.has(m.user.github))?.user.github ?? "";
|
|
350
|
+
if (!login || granted.has(login))
|
|
351
|
+
continue;
|
|
352
|
+
granted.add(login);
|
|
353
|
+
void this.grantJoiner(teamId, login, g.tag).catch((err) => logbook.warn("team", "couldn't give the new teammate access", String(err)));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async onHubEvent(e) {
|
|
357
|
+
const v = this.views.get(e.teamId);
|
|
358
|
+
if (!v)
|
|
359
|
+
return;
|
|
360
|
+
if (e.type === "memory")
|
|
361
|
+
for (const b of this.brains.values())
|
|
362
|
+
b.onMemory(e.memory);
|
|
363
|
+
else if (e.type === "job")
|
|
364
|
+
this.onJob(e.teamId, e.job);
|
|
365
|
+
else if (e.type === "lease")
|
|
366
|
+
v.leases.set(e.lease.id, e.lease);
|
|
367
|
+
else if (e.type === "lease_gone")
|
|
368
|
+
for (const id of e.leaseIds)
|
|
369
|
+
v.leases.delete(id);
|
|
370
|
+
else if (e.type === "presence")
|
|
371
|
+
v.presence.set(presenceKey(e.presence), e.presence);
|
|
372
|
+
else if (e.type === "presence_gone") {
|
|
373
|
+
for (const [k, p] of v.presence) {
|
|
374
|
+
if (p.userId === e.userId && p.deviceId === e.deviceId && p.agent === e.agent && p.repo === e.repo)
|
|
375
|
+
v.presence.delete(k);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else if (e.type === "feed") {
|
|
379
|
+
v.feed.push(e.event);
|
|
380
|
+
if (e.event.type === "crew_request" && String(e.event.meta.owner).toLowerCase() === this.state.hub?.github.toLowerCase()) {
|
|
381
|
+
for (const rt of this.host.runtimes()) {
|
|
382
|
+
const share = await this.teamFor(rt);
|
|
383
|
+
if (share?.teamId !== e.teamId || share.repo !== e.event.repo)
|
|
384
|
+
continue;
|
|
385
|
+
try {
|
|
386
|
+
const crew = rt.crews.get(String(e.event.meta.crew));
|
|
387
|
+
rt.log.append({ kind: "crew", chat: crew.state.channel, payload: { phase: "request_received", crewId: crew.id,
|
|
388
|
+
requestId: e.event.meta.requestId, from: e.event.github, teammate: e.event.meta.teammate } });
|
|
389
|
+
}
|
|
390
|
+
catch { /* absent local crew: request stays in the team inbox */ }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const c of this.coordinators.values()) {
|
|
394
|
+
try {
|
|
395
|
+
c.onTeamEvent(e.event);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
/* one project's coordinator never breaks the feed */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const l of this.landings.values()) {
|
|
402
|
+
try {
|
|
403
|
+
l.onTeamEvent(e.event);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
/* nor does its landing */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (v.feed.length > 500)
|
|
410
|
+
v.feed.splice(0, v.feed.length - 500);
|
|
411
|
+
const t = e.event.type;
|
|
412
|
+
if (t === "key_rotated")
|
|
413
|
+
await this.pullKeys(e.teamId).then(() => writeState(this.file, this.state)).catch(() => { });
|
|
414
|
+
if (t === "member_joined" || t === "member_left" || t === "key_rotated")
|
|
415
|
+
v.members = await this.hub().members(e.teamId).catch(() => v.members);
|
|
416
|
+
if (t === "member_joined")
|
|
417
|
+
void this.grantJoiner(e.teamId, String(e.event.meta.github ?? ""), typeof e.event.meta.invite === "string" ? e.event.meta.invite : undefined).catch((err) => logbook.warn("team", "couldn't give the new teammate access", String(err)));
|
|
418
|
+
// Someone left: nothing new should be readable to them. One owner rotates
|
|
419
|
+
// (the first by id, so two owners don't race to version N+1).
|
|
420
|
+
if (t === "member_left" && e.event.meta.github !== this.state.hub?.github && this.state.teams[e.teamId]?.role === "owner" &&
|
|
421
|
+
Date.now() - (this.rotatedAt.get(e.teamId) ?? 0) > 15_000) { // a remove we just did rotated already
|
|
422
|
+
const owners = (v.members ?? []).filter((m) => m.role === "owner").map((m) => m.user.id).sort();
|
|
423
|
+
if (!owners.length || owners[0] === this.state.hub?.userId) {
|
|
424
|
+
void this.rotate(e.teamId).then((r) => logbook.info("team", `rotated the team key to v${r.keyVersion} after a member left`)).catch((err) => logbook.warn("team", "couldn't rotate the key after a member left", String(err)));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (t === "repo_shared")
|
|
428
|
+
v.repos = await this.hub().repos(e.teamId).catch(() => v.repos);
|
|
429
|
+
if (t === "member_left" && e.event.meta.github === this.state.hub?.github) {
|
|
430
|
+
// We were removed: drop the team and its keys.
|
|
431
|
+
this.unsubs.get(e.teamId)?.();
|
|
432
|
+
this.unsubs.delete(e.teamId);
|
|
433
|
+
this.views.delete(e.teamId);
|
|
434
|
+
delete this.state.teams[e.teamId];
|
|
435
|
+
writeState(this.file, this.state);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
this.host.broadcast({ type: "team", teamId: e.teamId, event: this.decryptEvent(e) });
|
|
439
|
+
}
|
|
440
|
+
// ── teams, invites, membership ──
|
|
441
|
+
async createTeam(name) {
|
|
442
|
+
const team = await this.hub().createTeam(name);
|
|
443
|
+
const key = newTeamKey(1);
|
|
444
|
+
await this.hub().putKeyEnvelopes(team.id, 1, [{ deviceId: this.device().id, box: sealTeamKey(this.device().sealPub, key) }]);
|
|
445
|
+
this.state.teams[team.id] = { name: team.name, role: "owner", keys: [key] };
|
|
446
|
+
writeState(this.file, this.state);
|
|
447
|
+
await this.attach(team.id);
|
|
448
|
+
return { id: team.id, name: team.name };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* An invite link: the token and the current team key ride the #fragment
|
|
452
|
+
* (D5) — never sent to any server, the join page included. `extra` makes it
|
|
453
|
+
* a one-link onboarding (daemon/onboard.ts): the repo to clone, the crews to
|
|
454
|
+
* set up, and whether joining grants push access to the repo.
|
|
455
|
+
*/
|
|
456
|
+
async invite(teamId = this.defaultTeam(), extra = {}) {
|
|
457
|
+
const { invite, expiresAt } = await this.hub().createInvite(teamId);
|
|
458
|
+
const f = {
|
|
459
|
+
invite,
|
|
460
|
+
key: this.currentKey(teamId),
|
|
461
|
+
hub: this.state.hub.url,
|
|
462
|
+
teamId,
|
|
463
|
+
team: this.state.teams[teamId]?.name,
|
|
464
|
+
...(this.state.hub?.github ? { from: this.state.hub.github } : {}),
|
|
465
|
+
...(extra.repo ? { repo: extra.repo } : {}),
|
|
466
|
+
...(extra.project ? { project: extra.project.slice(0, 60) } : {}),
|
|
467
|
+
...(extra.crews?.length ? { crews: extra.crews } : {}),
|
|
468
|
+
};
|
|
469
|
+
f.dev = this.device().id;
|
|
470
|
+
f.sig = signPayload(this.device(), inviteSigned(f));
|
|
471
|
+
const frag = packInvite(f);
|
|
472
|
+
if (extra.grant && extra.repo) {
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
this.state.grants = [...(this.state.grants ?? []).filter((g) => g.expiresAt > now), { teamId, repo: extra.repo, expiresAt, createdAt: now, tag: inviteTag(invite) }];
|
|
475
|
+
writeState(this.file, this.state);
|
|
476
|
+
}
|
|
477
|
+
return { link: inviteLink(frag), expiresAt };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Is what a link asks for (the repo to clone, the crews to set up) what its
|
|
481
|
+
* inviter signed? Checked once we're on the team and can see the inviter's
|
|
482
|
+
* registered devices: anyone holding the link holds the team key, so only the
|
|
483
|
+
* device signature can't be forged. "unsigned" is a link from an older Loom —
|
|
484
|
+
* or one with the signature stripped, so it gets no more trust than a bad one.
|
|
485
|
+
*/
|
|
486
|
+
verifyInvite(inv, teamId) {
|
|
487
|
+
if (!inv.sig || !inv.dev)
|
|
488
|
+
return "unsigned";
|
|
489
|
+
const m = this.views.get(teamId)?.members.find((x) => x.devices.some((d) => d.id === inv.dev));
|
|
490
|
+
const d = m?.devices.find((x) => x.id === inv.dev);
|
|
491
|
+
if (!m || !d)
|
|
492
|
+
return "bad";
|
|
493
|
+
if (inv.from && m.user.github.toLowerCase() !== inv.from.toLowerCase())
|
|
494
|
+
return "bad";
|
|
495
|
+
return verifyPayload(d.signPub, inviteSigned(inv), inv.sig) ? "ok" : "bad";
|
|
496
|
+
}
|
|
497
|
+
/** Signed in to this hub already? (A link names its hub.) */
|
|
498
|
+
signedInTo(hub) {
|
|
499
|
+
return Boolean(this.state.hub && this.state.hub.url === hub.replace(/\/$/, ""));
|
|
500
|
+
}
|
|
501
|
+
/** This daemon's GitHub login on its hub. */
|
|
502
|
+
github() {
|
|
503
|
+
return this.state.hub?.github ?? null;
|
|
504
|
+
}
|
|
505
|
+
/** The teams this daemon is in, with the repos each shares. */
|
|
506
|
+
teams() {
|
|
507
|
+
return Object.entries(this.state.teams).map(([id, t]) => ({ id, name: t.name, repos: this.views.get(id)?.repos ?? [] }));
|
|
508
|
+
}
|
|
509
|
+
async join(link, opts = {}) {
|
|
510
|
+
const inv = unpackInvite(inviteFragment(link));
|
|
511
|
+
if (!inv)
|
|
512
|
+
throw new Error("that isn't a Loom team invite link");
|
|
513
|
+
if (!this.signedInTo(inv.hub))
|
|
514
|
+
await this.signIn(inv.hub, opts);
|
|
515
|
+
// Clicking your own link twice (or one for a team you're in) is not an error.
|
|
516
|
+
if (inv.teamId && this.state.teams[inv.teamId]) {
|
|
517
|
+
if (!this.views.has(inv.teamId))
|
|
518
|
+
await this.attach(inv.teamId);
|
|
519
|
+
return { id: inv.teamId, name: this.state.teams[inv.teamId].name, alreadyMember: true };
|
|
520
|
+
}
|
|
521
|
+
const { team, role } = await this.hub().redeemInvite(inv.invite);
|
|
522
|
+
// Seal the key we were handed to our own device, so it survives a reinstall
|
|
523
|
+
// of team.json from the hub, and so rotation has a device to seal to.
|
|
524
|
+
await this.hub().putKeyEnvelopes(team.id, inv.key.version, [
|
|
525
|
+
{ deviceId: this.device().id, box: sealTeamKey(this.device().sealPub, inv.key) },
|
|
526
|
+
]);
|
|
527
|
+
this.state.teams[team.id] = { name: team.name, role, keys: [inv.key] };
|
|
528
|
+
writeState(this.file, this.state);
|
|
529
|
+
await this.attach(team.id);
|
|
530
|
+
this.watchRuntimes();
|
|
531
|
+
return { id: team.id, name: team.name };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Remove a member, then rotate the key forward (D6): a new key, sealed to
|
|
535
|
+
* every remaining device. The removed member keeps what they already saw —
|
|
536
|
+
* nothing new is readable to them.
|
|
537
|
+
*/
|
|
538
|
+
async removeMember(userId, teamId = this.defaultTeam()) {
|
|
539
|
+
await this.hub().removeMember(teamId, userId);
|
|
540
|
+
return this.rotate(teamId);
|
|
541
|
+
}
|
|
542
|
+
/** When this daemon last rotated each team's key. */
|
|
543
|
+
rotatedAt = new Map();
|
|
544
|
+
async rotate(teamId = this.defaultTeam()) {
|
|
545
|
+
this.rotatedAt.set(teamId, Date.now());
|
|
546
|
+
const members = await this.hub().members(teamId);
|
|
547
|
+
const next = newTeamKey(this.currentKey(teamId).version + 1);
|
|
548
|
+
const envelopes = members.flatMap((m) => m.devices.map((d) => ({ deviceId: d.id, box: sealTeamKey(d.sealPub, next) })));
|
|
549
|
+
await this.hub().putKeyEnvelopes(teamId, next.version, envelopes);
|
|
550
|
+
this.state.teams[teamId].keys.push(next);
|
|
551
|
+
writeState(this.file, this.state);
|
|
552
|
+
return { keyVersion: next.version };
|
|
553
|
+
}
|
|
554
|
+
async leave(teamId = this.defaultTeam()) {
|
|
555
|
+
await this.hub().removeMember(teamId, this.state.hub.userId);
|
|
556
|
+
this.unsubs.get(teamId)?.();
|
|
557
|
+
this.unsubs.delete(teamId);
|
|
558
|
+
this.views.delete(teamId);
|
|
559
|
+
delete this.state.teams[teamId];
|
|
560
|
+
writeState(this.file, this.state);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Someone redeemed an invite that promised repo access: add them as a
|
|
564
|
+
* collaborator with push, through this machine's `gh` (it's your repo, and
|
|
565
|
+
* your invite). GitHub sends them an invitation their Loom accepts.
|
|
566
|
+
*/
|
|
567
|
+
async grantJoiner(teamId, github, tag) {
|
|
568
|
+
if (!github || github === this.state.hub?.github)
|
|
569
|
+
return;
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
const grants = (this.state.grants ?? []).filter((g) => g.expiresAt > now);
|
|
572
|
+
// The grant for the invite this person used; a hub that doesn't tag joins
|
|
573
|
+
// (the hosted one, today) falls back to the oldest grant for the team.
|
|
574
|
+
const at = tag ? grants.findIndex((g) => g.teamId === teamId && g.tag === tag) : grants.findIndex((g) => g.teamId === teamId);
|
|
575
|
+
if (at < 0)
|
|
576
|
+
return;
|
|
577
|
+
const [grant] = grants.splice(at, 1);
|
|
578
|
+
this.state.grants = grants;
|
|
579
|
+
writeState(this.file, this.state);
|
|
580
|
+
const gh = this.host.gh ?? ((args) => run("gh", args));
|
|
581
|
+
await gh(["api", "-X", "PUT", `repos/${grant.repo}/collaborators/${github}`, "-f", "permission=push"]);
|
|
582
|
+
logbook.info("team", `gave @${github} push access to ${grant.repo}`);
|
|
583
|
+
this.host.broadcast({ type: "team", teamId, event: { type: "access_granted", meta: { github, repo: grant.repo }, ts: now } });
|
|
584
|
+
}
|
|
585
|
+
defaultTeam() {
|
|
586
|
+
const ids = Object.keys(this.state.teams);
|
|
587
|
+
if (ids.length === 1)
|
|
588
|
+
return ids[0];
|
|
589
|
+
if (!ids.length)
|
|
590
|
+
throw new Error("you aren't in a team yet — create one or join with an invite link");
|
|
591
|
+
throw new Error("you're in several teams — say which one");
|
|
592
|
+
}
|
|
593
|
+
// ── projects ──
|
|
594
|
+
/** Share a project's repo with a team (D8: explicit opt-in). */
|
|
595
|
+
async share(rt, teamId = this.defaultTeam()) {
|
|
596
|
+
const repo = await repoOf(rt.info.dir);
|
|
597
|
+
if (!repo)
|
|
598
|
+
throw new Error("this project has no GitHub `origin` remote — teams work per GitHub repo");
|
|
599
|
+
await this.hub().shareRepo(teamId, repo);
|
|
600
|
+
rt.setTeam({ teamId, repo });
|
|
601
|
+
this.watchRuntimes();
|
|
602
|
+
void this.brainFor(rt).sync().catch(() => { });
|
|
603
|
+
return { repo, teamId };
|
|
604
|
+
}
|
|
605
|
+
unshare(rt) {
|
|
606
|
+
rt.setTeam(null);
|
|
607
|
+
}
|
|
608
|
+
/** Which team a project publishes to: its explicit share, else a team whose repo matches its remote (D8). */
|
|
609
|
+
async teamFor(rt) {
|
|
610
|
+
const explicit = rt.config.team;
|
|
611
|
+
if (explicit === null || explicit?.optOut)
|
|
612
|
+
return null;
|
|
613
|
+
if (explicit?.teamId && explicit.repo && this.state.teams[explicit.teamId]) {
|
|
614
|
+
return { teamId: explicit.teamId, repo: explicit.repo };
|
|
615
|
+
}
|
|
616
|
+
const repo = await repoOf(rt.info.dir);
|
|
617
|
+
if (!repo)
|
|
618
|
+
return null;
|
|
619
|
+
for (const [teamId, v] of this.views)
|
|
620
|
+
if (v.repos.includes(repo))
|
|
621
|
+
return { teamId, repo };
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
// ── presence ──
|
|
625
|
+
// ── hub reachability ──
|
|
626
|
+
/** Whether the last call to the hub got through, and the error when it didn't. */
|
|
627
|
+
hubOk = { ok: null, at: 0 };
|
|
628
|
+
connected = false;
|
|
629
|
+
noteHub(err) {
|
|
630
|
+
this.hubOk = err ? { ok: false, at: Date.now(), error: hubErrorText(err, this.state.hub?.url) } : { ok: true, at: Date.now() };
|
|
631
|
+
}
|
|
632
|
+
/** The heartbeat timer: reconnect when the hub was down at start, then beat. */
|
|
633
|
+
async tick() {
|
|
634
|
+
if (this.state.hub && !this.connected) {
|
|
635
|
+
await this.connect().catch(() => { });
|
|
636
|
+
if (!this.connected)
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
await this.beat();
|
|
640
|
+
}
|
|
641
|
+
/** One heartbeat for every live session in every shared project; returns how many the hub took. */
|
|
642
|
+
async beat() {
|
|
643
|
+
if (!this.hubClient || !this.state.device?.id)
|
|
644
|
+
return 0;
|
|
645
|
+
const seen = new Map();
|
|
646
|
+
let count = 0;
|
|
647
|
+
for (const rt of this.host.runtimes()) {
|
|
648
|
+
const share = await this.teamFor(rt);
|
|
649
|
+
if (!share)
|
|
650
|
+
continue;
|
|
651
|
+
let key;
|
|
652
|
+
try {
|
|
653
|
+
key = this.currentKey(share.teamId);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
for (const p of sessionsOf(rt, share.repo)) {
|
|
659
|
+
const sealed = sealForTeam(key, p.intent);
|
|
660
|
+
const beat = { ...p.presence, deviceId: this.device().id, sealed };
|
|
661
|
+
beat.sig = signPayload(this.device(), { ...beat, sig: undefined });
|
|
662
|
+
const set = seen.get(share.teamId) ?? new Set();
|
|
663
|
+
set.add(`${beat.repo}/${beat.agent}`);
|
|
664
|
+
seen.set(share.teamId, set);
|
|
665
|
+
try {
|
|
666
|
+
await this.hub().heartbeat(share.teamId, beat);
|
|
667
|
+
this.noteHub();
|
|
668
|
+
count++;
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
this.noteHub(e);
|
|
672
|
+
logbook.warn("team", "heartbeat refused", String(e));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Sessions that ended since the last beat: say so now.
|
|
677
|
+
for (const [teamId, prev] of this.live) {
|
|
678
|
+
const now = seen.get(teamId) ?? new Set();
|
|
679
|
+
for (const k of prev) {
|
|
680
|
+
if (now.has(k))
|
|
681
|
+
continue;
|
|
682
|
+
const [owner, name, ...agentParts] = k.split("/");
|
|
683
|
+
await this.hub()
|
|
684
|
+
.clearPresence(teamId, { deviceId: this.device().id, repo: `${owner}/${name}`, agent: agentParts.join("/") })
|
|
685
|
+
.catch(() => { });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
this.live = seen;
|
|
689
|
+
// Leases live as long as this device keeps saying so (D12).
|
|
690
|
+
for (const teamId of Object.keys(this.state.teams)) {
|
|
691
|
+
await this.hub().renewLeases(teamId, this.device().id).catch(() => 0);
|
|
692
|
+
}
|
|
693
|
+
return count;
|
|
694
|
+
}
|
|
695
|
+
async clearAllPresence() {
|
|
696
|
+
if (!this.hubClient || !this.state.device?.id)
|
|
697
|
+
return;
|
|
698
|
+
for (const [teamId, keys] of this.live) {
|
|
699
|
+
for (const k of keys) {
|
|
700
|
+
const [owner, name, ...agentParts] = k.split("/");
|
|
701
|
+
await this.hub().clearPresence(teamId, { deviceId: this.device().id, repo: `${owner}/${name}`, agent: agentParts.join("/") });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
this.live.clear();
|
|
705
|
+
}
|
|
706
|
+
// ── feed: goal lifecycle from each shared project's log ──
|
|
707
|
+
watchRuntimes() {
|
|
708
|
+
for (const rt of this.host.runtimes()) {
|
|
709
|
+
if (this.logSubs.has(rt.info.id))
|
|
710
|
+
continue;
|
|
711
|
+
const unsub = rt.log.onEvent((e) => void this.onProjectEvent(rt, e).catch(() => { }));
|
|
712
|
+
this.logSubs.set(rt.info.id, unsub);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Called by the daemon before it drops a runtime (config hot-reload, project
|
|
717
|
+
* removal, a runner's finished clone). Without it the closed runtime stayed
|
|
718
|
+
* pinned here — its landing loop ticking against a closed log — and the
|
|
719
|
+
* reopened one never got a coordinator, team brain or feed of its own.
|
|
720
|
+
*/
|
|
721
|
+
detachRuntime(projectId) {
|
|
722
|
+
this.landings.get(projectId)?.stop();
|
|
723
|
+
this.landings.delete(projectId);
|
|
724
|
+
this.brains.get(projectId)?.stop();
|
|
725
|
+
this.brains.delete(projectId);
|
|
726
|
+
this.coordinators.get(projectId)?.stop();
|
|
727
|
+
this.coordinators.delete(projectId);
|
|
728
|
+
this.deploys.delete(projectId);
|
|
729
|
+
this.logSubs.get(projectId)?.();
|
|
730
|
+
this.logSubs.delete(projectId);
|
|
731
|
+
}
|
|
732
|
+
/** Called by the daemon when it opens a runtime. */
|
|
733
|
+
attachRuntime(rt) {
|
|
734
|
+
rt.memberLogin = this.state.hub?.github ?? null;
|
|
735
|
+
this.coordinatorFor(rt);
|
|
736
|
+
const brain = this.brainFor(rt);
|
|
737
|
+
if (this.hubClient)
|
|
738
|
+
void brain.sync().catch(() => { });
|
|
739
|
+
this.landingFor(rt).start();
|
|
740
|
+
if (this.state.hub)
|
|
741
|
+
this.watchRuntimes();
|
|
742
|
+
}
|
|
743
|
+
// ── runners (Phase 5, D67–D77) ──
|
|
744
|
+
runnerConfig() {
|
|
745
|
+
return this.host.runnerConfig?.() ?? readRunnerConfig();
|
|
746
|
+
}
|
|
747
|
+
/** Turn this daemon into a runner (or restart it with new settings). */
|
|
748
|
+
async startRunner(patch = {}) {
|
|
749
|
+
if (!this.state.hub || !this.state.device?.id)
|
|
750
|
+
throw new Error("sign in first — a runner is one of your devices on the team hub");
|
|
751
|
+
if (!this.host.runnerConfig && !this.host.runnerExec)
|
|
752
|
+
writeRunnerConfig({ ...this.runnerConfig(), ...patch, enabled: true });
|
|
753
|
+
if (!this.host.openProject || !this.host.closeProject)
|
|
754
|
+
throw new Error("this daemon can't open projects for a runner");
|
|
755
|
+
if (!this.runner) {
|
|
756
|
+
this.runner = new Runner({
|
|
757
|
+
hub: () => this.hubClient,
|
|
758
|
+
deviceId: () => this.state.device?.id ?? null,
|
|
759
|
+
userId: () => this.state.hub?.userId ?? null,
|
|
760
|
+
github: () => this.state.hub?.github ?? null,
|
|
761
|
+
teams: () => Object.keys(this.state.teams),
|
|
762
|
+
keys: (teamId) => this.state.teams[teamId]?.keys ?? [],
|
|
763
|
+
feed: (teamId) => this.views.get(teamId)?.feed ?? [],
|
|
764
|
+
presence: (teamId) => [...(this.views.get(teamId)?.presence.values() ?? [])],
|
|
765
|
+
openProject: this.host.openProject.bind(this.host),
|
|
766
|
+
closeProject: async (rt) => {
|
|
767
|
+
this.detachRuntime(rt.info.id);
|
|
768
|
+
await this.host.closeProject(rt);
|
|
769
|
+
},
|
|
770
|
+
share: async (rt, teamId) => {
|
|
771
|
+
const repo = await repoOf(rt.info.dir);
|
|
772
|
+
if (repo)
|
|
773
|
+
rt.setTeam({ teamId, repo });
|
|
774
|
+
this.watchRuntimes();
|
|
775
|
+
await this.coordinatorFor(rt).teamPolicy().catch(() => null);
|
|
776
|
+
},
|
|
777
|
+
landing: (rt) => this.landingFor(rt),
|
|
778
|
+
...(this.host.runnerCloneUrl ? { cloneUrl: this.host.runnerCloneUrl.bind(this.host) } : {}),
|
|
779
|
+
kinds: async () => {
|
|
780
|
+
const c = this.runnerConfig();
|
|
781
|
+
if (c.kinds.length)
|
|
782
|
+
return c.kinds;
|
|
783
|
+
if (this.host.runnerKinds)
|
|
784
|
+
return this.host.runnerKinds();
|
|
785
|
+
const { detectAdes } = await import("../core/ades.js");
|
|
786
|
+
const found = await detectAdes();
|
|
787
|
+
return Object.entries(found).filter(([, ok]) => ok).map(([k]) => k);
|
|
788
|
+
},
|
|
789
|
+
config: () => this.runnerConfig(),
|
|
790
|
+
statePath: () => this.file,
|
|
791
|
+
...(this.host.runnerExec ? { exec: this.host.runnerExec } : {}),
|
|
792
|
+
...(this.host.runnerAwayMs !== undefined ? { awayMs: this.host.runnerAwayMs } : {}),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
await this.runner.register();
|
|
797
|
+
}
|
|
798
|
+
await this.runner.start();
|
|
799
|
+
return this.runner.status();
|
|
800
|
+
}
|
|
801
|
+
async stopRunner() {
|
|
802
|
+
await this.runner?.stop();
|
|
803
|
+
if (!this.host.runnerConfig)
|
|
804
|
+
writeRunnerConfig({ ...this.runnerConfig(), enabled: false });
|
|
805
|
+
}
|
|
806
|
+
onJob(teamId, job) {
|
|
807
|
+
this.runner?.onJob(teamId, job);
|
|
808
|
+
// Bring back (D76): the runner handed the goal over; rebuild it here.
|
|
809
|
+
const projectId = this.pendingReturns.get(job.id);
|
|
810
|
+
if (!projectId || (job.state !== "done" && job.state !== "failed"))
|
|
811
|
+
return;
|
|
812
|
+
this.pendingReturns.delete(job.id);
|
|
813
|
+
const rt = this.host.runtimes().find((r) => r.info.id === projectId);
|
|
814
|
+
if (!rt)
|
|
815
|
+
return;
|
|
816
|
+
if (job.state === "failed") {
|
|
817
|
+
logbook.warn("runner", "the runner couldn't hand the goal back", job.error ?? "");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const res = openFromTeam(this.state.teams[teamId]?.keys ?? [], job.result);
|
|
821
|
+
if (res?.record)
|
|
822
|
+
void rt.orchestra.importRun(res.record, { from: job.runnerGithub ? `${job.runnerGithub}'s runner` : "the runner" }).catch((e) => logbook.warn("runner", "couldn't bring the goal back", String(e)));
|
|
823
|
+
}
|
|
824
|
+
/** A one-time link that makes another machine one of your runners (D74). Send it like a password. */
|
|
825
|
+
pairRunnerLink() {
|
|
826
|
+
const h = this.state.hub;
|
|
827
|
+
if (!h)
|
|
828
|
+
throw new Error("sign in first");
|
|
829
|
+
const body = {
|
|
830
|
+
v: 1,
|
|
831
|
+
hub: h.url,
|
|
832
|
+
...(h.key ? { key: h.key } : {}),
|
|
833
|
+
github: h.github,
|
|
834
|
+
teams: Object.fromEntries(Object.entries(this.state.teams).map(([id, t]) => [id, { name: t.name, role: t.role, keys: t.keys }])),
|
|
835
|
+
};
|
|
836
|
+
return `loom-runner:${Buffer.from(JSON.stringify(body)).toString("base64url")}`;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* On the runner box: take the pairing link's team keys, sign in as the same
|
|
840
|
+
* member (self-hosted: --secret; hosted: a refresh token from the paste-URL
|
|
841
|
+
* flow), register this device, and seal the keys to it.
|
|
842
|
+
*/
|
|
843
|
+
async joinAsRunner(link, opts = {}) {
|
|
844
|
+
const raw = link.trim().replace(/^loom-runner:/, "");
|
|
845
|
+
let body;
|
|
846
|
+
try {
|
|
847
|
+
body = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
throw new Error("that isn't a Loom runner pairing link");
|
|
851
|
+
}
|
|
852
|
+
if (!body.hub || !body.teams)
|
|
853
|
+
throw new Error("that isn't a Loom runner pairing link");
|
|
854
|
+
await this.signIn(body.hub, { github: opts.github ?? body.github, ...(opts.secret ? { secret: opts.secret } : {}), ...(opts.token ? { token: opts.token } : {}) });
|
|
855
|
+
if (this.state.hub?.github !== body.github)
|
|
856
|
+
throw new Error(`this link is ${body.github}'s — sign in as them on the runner`);
|
|
857
|
+
for (const [id, t] of Object.entries(body.teams)) {
|
|
858
|
+
this.state.teams[id] = { name: t.name, role: t.role, keys: t.keys };
|
|
859
|
+
for (const k of t.keys) {
|
|
860
|
+
await this.hub().putKeyEnvelopes(id, k.version, [{ deviceId: this.device().id, box: sealTeamKey(this.device().sealPub, k) }]).catch(() => { });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
writeState(this.file, this.state);
|
|
864
|
+
await this.connect();
|
|
865
|
+
return this.startRunner({ ...(opts.shared !== undefined ? { shared: opts.shared } : {}) });
|
|
866
|
+
}
|
|
867
|
+
/** Revoke a runner: remove the device, then rotate every team's key (D74, D6). */
|
|
868
|
+
async revokeRunner(deviceId) {
|
|
869
|
+
await this.hub().revokeDevice(deviceId);
|
|
870
|
+
for (const teamId of Object.keys(this.state.teams))
|
|
871
|
+
await this.rotate(teamId).catch((e) => logbook.warn("runner", "rotation failed", String(e)));
|
|
872
|
+
}
|
|
873
|
+
/** Runners that can take this project's goals, and the jobs in flight (decrypted). */
|
|
874
|
+
async runnersView(rt) {
|
|
875
|
+
const share = await this.teamFor(rt);
|
|
876
|
+
if (!share || !this.hubClient)
|
|
877
|
+
return { runners: [], jobs: [] };
|
|
878
|
+
const me = this.state.hub?.userId;
|
|
879
|
+
const keys = this.state.teams[share.teamId]?.keys ?? [];
|
|
880
|
+
const runners = (await this.hub().runners(share.teamId)).filter((r) => r.userId === me || r.shared);
|
|
881
|
+
const jobs = (await this.hub().jobs(share.teamId)).filter((j) => j.repo === share.repo).slice(-50);
|
|
882
|
+
return {
|
|
883
|
+
runners: runners.map((r) => ({ ...r, mine: r.userId === me, online: Date.now() - r.lastSeen < 3 * 60_000 })),
|
|
884
|
+
jobs: jobs.map((j) => {
|
|
885
|
+
const { sealed, progress, result, ...rest } = j;
|
|
886
|
+
const p = openFromTeam(keys, sealed);
|
|
887
|
+
return {
|
|
888
|
+
...rest,
|
|
889
|
+
goal: (p?.goal ?? p?.record?.goal ?? "").split("\n")[0].slice(0, 200),
|
|
890
|
+
runId: p?.runId ?? p?.record?.id ?? null,
|
|
891
|
+
progress: openFromTeam(keys, progress) ?? null,
|
|
892
|
+
mine: j.userId === me,
|
|
893
|
+
...(result ? { hasResult: true } : {}),
|
|
894
|
+
};
|
|
895
|
+
}),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
async jobShare(rt) {
|
|
899
|
+
const share = await this.teamFor(rt);
|
|
900
|
+
if (!share)
|
|
901
|
+
throw new Error("runners take goals of team projects — share this project first");
|
|
902
|
+
return { ...share, key: this.currentKey(share.teamId), device: this.device().id };
|
|
903
|
+
}
|
|
904
|
+
/** Pick a runner: the one named, else my first online one. */
|
|
905
|
+
async pickRunner(teamId, wanted) {
|
|
906
|
+
const runners = await this.hub().runners(teamId);
|
|
907
|
+
if (wanted) {
|
|
908
|
+
const r = runners.find((x) => x.deviceId === wanted || x.label === wanted);
|
|
909
|
+
if (!r)
|
|
910
|
+
throw new Error(`no runner "${wanted}"`);
|
|
911
|
+
return r.deviceId;
|
|
912
|
+
}
|
|
913
|
+
const mine = runners.filter((r) => r.userId === this.state.hub?.userId && r.deviceId !== this.state.device?.id);
|
|
914
|
+
if (!mine.length)
|
|
915
|
+
throw new Error("you have no runner yet — `loom runner pair` here, then `loom runner join <link>` on the box");
|
|
916
|
+
return (mine.sort((a, b) => b.lastSeen - a.lastSeen)[0] ?? mine[0]).deviceId;
|
|
917
|
+
}
|
|
918
|
+
/** Start a new goal on a runner (D69, D73). */
|
|
919
|
+
async startCrewOnRunner(rt, id, text, runner) {
|
|
920
|
+
if (!text.trim() || text.length > 12000)
|
|
921
|
+
throw new Error("crew goal must contain 1–12000 characters");
|
|
922
|
+
const s = await this.jobShare(rt), target = await this.pickRunner(s.teamId, runner), crew = rt.crews.get(id);
|
|
923
|
+
const payload = { goal: text, nativeCrew: rt.config.brain?.continuity === true, crew: { id: crew.id, name: crew.name, teammates: crew.teammates,
|
|
924
|
+
play: crew.play, maxParallel: crew.maxParallel, budgetUsd: crew.budgetUsd,
|
|
925
|
+
agents: rt.config.agents.map(agent => ({ id: agent.id, kind: agent.kind })) }, from: os.hostname() };
|
|
926
|
+
const job = await this.hub().createJob(s.teamId, { repo: s.repo, kind: "start", target, sealed: sealForTeam(s.key, payload), deviceId: s.device });
|
|
927
|
+
return { jobId: job.id };
|
|
928
|
+
}
|
|
929
|
+
async controlRunnerCrew(rt, jobId, action, text, to) {
|
|
930
|
+
if (!["approve", "say", "pause", "resume", "stop", "pick"].includes(action))
|
|
931
|
+
throw new Error("unsupported crew control");
|
|
932
|
+
const s = await this.jobShare(rt), jobs = await this.hub().jobs(s.teamId, { active: true }), job = jobs.find(j => j.id === jobId);
|
|
933
|
+
if (!job || job.userId !== this.state.hub?.userId || !job.runnerId || job.repo !== s.repo)
|
|
934
|
+
throw new Error("this is not your active runner crew job");
|
|
935
|
+
const progress = openFromTeam(this.state.teams[s.teamId].keys, job.progress);
|
|
936
|
+
if (!progress?.crew || !progress.runId)
|
|
937
|
+
throw new Error("the runner has not prepared this crew yet");
|
|
938
|
+
const payload = { runId: progress.runId, crewAction: { jobId, action, text, to } };
|
|
939
|
+
const control = await this.hub().createJob(s.teamId, { repo: s.repo, kind: "land", target: job.runnerId, sealed: sealForTeam(s.key, payload), deviceId: s.device });
|
|
940
|
+
return { jobId: control.id };
|
|
941
|
+
}
|
|
942
|
+
async requestCrew(rt, input) {
|
|
943
|
+
if (typeof input.text !== "string" || !input.text.trim() || input.text.length > 4000)
|
|
944
|
+
throw new Error("request text must contain 1–4000 characters");
|
|
945
|
+
const s = await this.jobShare(rt), id = randomUUID();
|
|
946
|
+
const event = { type: "crew_request", repo: s.repo, deviceId: s.device, dedupeKey: `crew-request:${id}`,
|
|
947
|
+
meta: { requestId: id, owner: input.owner, crew: input.crew, teammate: input.teammate, expiresAt: Date.now() + 86400000 },
|
|
948
|
+
sealed: sealForTeam(s.key, { text: input.text.trim() }) };
|
|
949
|
+
event.sig = signPayload(this.device(), event);
|
|
950
|
+
await this.hub().appendFeed(s.teamId, event);
|
|
951
|
+
return { requestId: id };
|
|
952
|
+
}
|
|
953
|
+
async answerCrewRequest(rt, requestId, accept) {
|
|
954
|
+
const s = await this.jobShare(rt);
|
|
955
|
+
const events = await this.hub().feed(s.teamId, { limit: 1000 });
|
|
956
|
+
const request = crewRequests(events, e => openFromTeam(this.state.teams[s.teamId].keys, e.sealed)).find(r => r.id === requestId);
|
|
957
|
+
if (!request || request.repo !== s.repo)
|
|
958
|
+
throw new Error("crew request not found for this project");
|
|
959
|
+
if (request.owner.toLowerCase() !== this.state.hub?.github.toLowerCase())
|
|
960
|
+
throw new Error("only the requested crew's owner may answer");
|
|
961
|
+
if (request.state === "expired" || request.state === (accept ? "declined" : "accepted"))
|
|
962
|
+
throw new Error("request expired or already answered differently");
|
|
963
|
+
if (accept) {
|
|
964
|
+
const crew = rt.crews.get(request.crew);
|
|
965
|
+
const seat = crew.teammates.find(t => t.id === request.teammate);
|
|
966
|
+
if (!seat || !["reviewer", "tester", "researcher"].includes(seat.role))
|
|
967
|
+
throw new Error("choose a reviewer, tester or researcher seat");
|
|
968
|
+
}
|
|
969
|
+
const event = { type: "crew_request_answered", repo: s.repo, deviceId: s.device,
|
|
970
|
+
meta: { requestId, state: accept ? "accepted" : "declined" }, dedupeKey: `crew-answer:${requestId}` };
|
|
971
|
+
event.sig = signPayload(this.device(), event);
|
|
972
|
+
await this.hub().appendFeed(s.teamId, event);
|
|
973
|
+
if (!accept)
|
|
974
|
+
return { delivery: "declined" };
|
|
975
|
+
return rt.crews.receiveRequest(request.crew, { id: request.id, from: request.from, to: request.teammate, text: request.text, expiresAt: request.expiresAt });
|
|
976
|
+
}
|
|
977
|
+
async startOnRunner(rt, g) {
|
|
978
|
+
if (!g.goal?.trim())
|
|
979
|
+
throw new Error("what's the goal?");
|
|
980
|
+
const s = await this.jobShare(rt);
|
|
981
|
+
const target = await this.pickRunner(s.teamId, g.runner);
|
|
982
|
+
const payload = { goal: g.goal.trim(), ...(g.orchestrator ? { orchestrator: g.orchestrator } : {}), ...(g.workers?.length ? { workers: g.workers } : {}), ...(g.plan ? { plan: true } : {}), from: os.hostname() };
|
|
983
|
+
const job = await this.hub().createJob(s.teamId, { repo: s.repo, kind: "start", ...(target ? { target } : {}), sealed: sealForTeam(s.key, payload), deviceId: s.device });
|
|
984
|
+
return { jobId: job.id };
|
|
985
|
+
}
|
|
986
|
+
/** Move a running goal to a runner at a safe point (D75). */
|
|
987
|
+
async continueOnRunner(rt, runId, opts = {}) {
|
|
988
|
+
const s = await this.jobShare(rt);
|
|
989
|
+
const target = await this.pickRunner(s.teamId, opts.runner);
|
|
990
|
+
const runners = await this.hub().runners(s.teamId);
|
|
991
|
+
const label = runners.find((r) => r.deviceId === target)?.label ?? "a runner";
|
|
992
|
+
const { record } = await rt.orchestra.moveOut(runId, `runner ${label}`, { ...(opts.graceMs !== undefined ? { graceMs: opts.graceMs } : {}) });
|
|
993
|
+
const payload = { record, from: os.hostname() };
|
|
994
|
+
const job = await this.hub().createJob(s.teamId, { repo: s.repo, kind: "continue", ...(target ? { target } : {}), sealed: sealForTeam(s.key, payload), deviceId: s.device });
|
|
995
|
+
const fe = { repo: s.repo, type: "goal_moved", meta: { runId, to: label, jobId: job.id }, deviceId: s.device, sealed: sealForTeam(s.key, { goal: record.goal.split("\n")[0].slice(0, 200) }) };
|
|
996
|
+
await this.hub().appendFeed(s.teamId, fe).catch(() => null);
|
|
997
|
+
return { jobId: job.id };
|
|
998
|
+
}
|
|
999
|
+
/** The runner job currently holding a goal. */
|
|
1000
|
+
async holderJob(teamId, runId) {
|
|
1001
|
+
const keys = this.state.teams[teamId]?.keys ?? [];
|
|
1002
|
+
const jobs = await this.hub().jobs(teamId, { active: true });
|
|
1003
|
+
const j = jobs.find((x) => {
|
|
1004
|
+
if (x.kind === "return" || x.kind === "land")
|
|
1005
|
+
return false;
|
|
1006
|
+
const p = openFromTeam(keys, x.progress);
|
|
1007
|
+
const pl = openFromTeam(keys, x.sealed);
|
|
1008
|
+
return p?.runId === runId || pl?.record?.id === runId;
|
|
1009
|
+
});
|
|
1010
|
+
if (!j?.runnerId)
|
|
1011
|
+
throw new Error(`no runner holds goal ${runId}`);
|
|
1012
|
+
return j;
|
|
1013
|
+
}
|
|
1014
|
+
/** Bring a goal home from the runner (D76): the runner moves it out, this daemon imports it. */
|
|
1015
|
+
async bringBack(rt, runId) {
|
|
1016
|
+
const s = await this.jobShare(rt);
|
|
1017
|
+
const holder = await this.holderJob(s.teamId, runId);
|
|
1018
|
+
const job = await this.hub().createJob(s.teamId, {
|
|
1019
|
+
repo: s.repo,
|
|
1020
|
+
kind: "return",
|
|
1021
|
+
target: holder.runnerId,
|
|
1022
|
+
sealed: sealForTeam(s.key, { runId, from: os.hostname() }),
|
|
1023
|
+
deviceId: s.device,
|
|
1024
|
+
});
|
|
1025
|
+
this.pendingReturns.set(job.id, rt.info.id);
|
|
1026
|
+
return { jobId: job.id };
|
|
1027
|
+
}
|
|
1028
|
+
/** Land a goal that lives on a runner (the owner's click, carried as a job). */
|
|
1029
|
+
async landOnRunner(rt, runId) {
|
|
1030
|
+
const s = await this.jobShare(rt);
|
|
1031
|
+
const holder = await this.holderJob(s.teamId, runId);
|
|
1032
|
+
const job = await this.hub().createJob(s.teamId, {
|
|
1033
|
+
repo: s.repo,
|
|
1034
|
+
kind: "land",
|
|
1035
|
+
target: holder.runnerId,
|
|
1036
|
+
sealed: sealForTeam(s.key, { runId }),
|
|
1037
|
+
deviceId: s.device,
|
|
1038
|
+
});
|
|
1039
|
+
return { jobId: job.id };
|
|
1040
|
+
}
|
|
1041
|
+
/** Phase 5 (D72): the project's deploy watch and release notes. */
|
|
1042
|
+
deploysFor(rt) {
|
|
1043
|
+
let d = this.deploys.get(rt.info.id);
|
|
1044
|
+
if (!d) {
|
|
1045
|
+
d = new Deploys(rt, {
|
|
1046
|
+
share: (r) => (this.hubClient ? this.teamFor(r) : Promise.resolve(null)),
|
|
1047
|
+
post: async (r, e) => {
|
|
1048
|
+
const share = await this.teamFor(r);
|
|
1049
|
+
if (!share || !this.hubClient || !this.state.device?.id)
|
|
1050
|
+
return;
|
|
1051
|
+
await this.hub().appendFeed(share.teamId, { ...e, deviceId: this.state.device.id }).catch(() => null);
|
|
1052
|
+
},
|
|
1053
|
+
...(this.host.landingExec ? { exec: this.host.landingExec } : {}),
|
|
1054
|
+
});
|
|
1055
|
+
this.deploys.set(rt.info.id, d);
|
|
1056
|
+
}
|
|
1057
|
+
return d;
|
|
1058
|
+
}
|
|
1059
|
+
/** One deploy poll per shared repo (the feed dedupes across members). */
|
|
1060
|
+
async pollDeploys() {
|
|
1061
|
+
if (!this.hubClient)
|
|
1062
|
+
return 0;
|
|
1063
|
+
let n = 0;
|
|
1064
|
+
const seen = new Set();
|
|
1065
|
+
for (const rt of this.host.runtimes()) {
|
|
1066
|
+
const share = await this.teamFor(rt);
|
|
1067
|
+
if (!share || seen.has(share.repo) || rt.runnerMode)
|
|
1068
|
+
continue;
|
|
1069
|
+
seen.add(share.repo);
|
|
1070
|
+
n += await this.deploysFor(rt).poll().catch(() => 0);
|
|
1071
|
+
}
|
|
1072
|
+
return n;
|
|
1073
|
+
}
|
|
1074
|
+
/** Phase 4: the project's goal PRs — checks, fixes, review, landing, adopt. */
|
|
1075
|
+
landingFor(rt) {
|
|
1076
|
+
let l = this.landings.get(rt.info.id);
|
|
1077
|
+
if (!l) {
|
|
1078
|
+
l = new Landing(rt, {
|
|
1079
|
+
hub: () => this.hubClient,
|
|
1080
|
+
deviceId: () => this.state.device?.id ?? null,
|
|
1081
|
+
github: () => this.state.hub?.github ?? null,
|
|
1082
|
+
share: (r) => (this.hubClient ? this.teamFor(r) : Promise.resolve(null)),
|
|
1083
|
+
keys: (teamId) => this.state.teams[teamId]?.keys ?? [],
|
|
1084
|
+
feed: (teamId) => this.views.get(teamId)?.feed ?? [],
|
|
1085
|
+
presence: (teamId) => [...(this.views.get(teamId)?.presence.values() ?? [])],
|
|
1086
|
+
policy: () => this.coordinatorFor(rt).teamPolicy(),
|
|
1087
|
+
...(this.host.landingExec ? { exec: this.host.landingExec } : {}),
|
|
1088
|
+
...(this.host.landingRerunSettleMs !== undefined ? { rerunSettleMs: this.host.landingRerunSettleMs } : {}),
|
|
1089
|
+
...(this.host.landingTrainSettleMs !== undefined ? { trainSettleMs: this.host.landingTrainSettleMs } : {}),
|
|
1090
|
+
});
|
|
1091
|
+
this.landings.set(rt.info.id, l);
|
|
1092
|
+
}
|
|
1093
|
+
return l;
|
|
1094
|
+
}
|
|
1095
|
+
/** Phase 2: the project's team coordinator, created once and handed to its orchestra. */
|
|
1096
|
+
coordinatorFor(rt) {
|
|
1097
|
+
let c = this.coordinators.get(rt.info.id);
|
|
1098
|
+
if (!c) {
|
|
1099
|
+
c = new TeamCoordinator(rt, {
|
|
1100
|
+
hub: () => this.hubClient,
|
|
1101
|
+
deviceId: () => this.state.device?.id ?? null,
|
|
1102
|
+
github: () => this.state.hub?.github ?? null,
|
|
1103
|
+
share: (r) => (this.hubClient ? this.teamFor(r) : Promise.resolve(null)),
|
|
1104
|
+
keys: (teamId) => this.state.teams[teamId]?.keys ?? [],
|
|
1105
|
+
feed: (teamId) => this.views.get(teamId)?.feed ?? [],
|
|
1106
|
+
presence: (teamId) => [...(this.views.get(teamId)?.presence.values() ?? [])],
|
|
1107
|
+
});
|
|
1108
|
+
this.coordinators.set(rt.info.id, c);
|
|
1109
|
+
rt.coordinator = c;
|
|
1110
|
+
}
|
|
1111
|
+
return c;
|
|
1112
|
+
}
|
|
1113
|
+
/** Phase 3: the project's share of the team brain, created once and handed to its runtime. */
|
|
1114
|
+
brainFor(rt) {
|
|
1115
|
+
let b = this.brains.get(rt.info.id);
|
|
1116
|
+
if (!b) {
|
|
1117
|
+
b = new TeamBrain(rt, {
|
|
1118
|
+
hub: () => this.hubClient,
|
|
1119
|
+
deviceId: () => this.state.device?.id ?? null,
|
|
1120
|
+
github: () => this.state.hub?.github ?? null,
|
|
1121
|
+
share: (r) => (this.hubClient ? this.teamFor(r) : Promise.resolve(null)),
|
|
1122
|
+
keys: (teamId) => this.state.teams[teamId]?.keys ?? [],
|
|
1123
|
+
feed: (teamId) => this.views.get(teamId)?.feed ?? [],
|
|
1124
|
+
leases: (teamId) => [...(this.views.get(teamId)?.leases.values() ?? [])],
|
|
1125
|
+
});
|
|
1126
|
+
this.brains.set(rt.info.id, b);
|
|
1127
|
+
rt.teamBrain = b;
|
|
1128
|
+
}
|
|
1129
|
+
return b;
|
|
1130
|
+
}
|
|
1131
|
+
/** Sync every open project's team brain (backfill, publish, canon). */
|
|
1132
|
+
async syncBrains() {
|
|
1133
|
+
if (!this.hubClient)
|
|
1134
|
+
return;
|
|
1135
|
+
for (const rt of this.host.runtimes()) {
|
|
1136
|
+
await this.brainFor(rt).sync().catch((e) => logbook.warn("team", "team brain sync failed", String(e)));
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
async onProjectEvent(rt, e) {
|
|
1140
|
+
if (e.kind === "crew" && this.hubClient) {
|
|
1141
|
+
const p = e.payload, cfg = String(p.crewId ?? ""), phase = String(p.phase ?? "");
|
|
1142
|
+
const run = rt.crews.coordination(cfg);
|
|
1143
|
+
const type = phase === "goal_started" ? "goal_started" : phase === "delivered" ? "pr_opened" : ["completed", "failed", "stopped"].includes(phase) ? "goal_finished" : null;
|
|
1144
|
+
const share = await this.teamFor(rt);
|
|
1145
|
+
if (type && share && run)
|
|
1146
|
+
await this.hub().appendFeed(share.teamId, {
|
|
1147
|
+
type, repo: share.repo, meta: { runId: run.id, crewId: cfg, status: run.status, branch: run.branch, tasks: run.tasks.length,
|
|
1148
|
+
...(type === "pr_opened" ? { url: p.url, number: Number(/\/pull\/(\d+)/.exec(String(p.url))?.[1]) } : {}),
|
|
1149
|
+
...(type === "goal_finished" ? { costUsd: run.costUsd } : {}) },
|
|
1150
|
+
sealed: sealForTeam(this.currentKey(share.teamId), { goal: run.goal.split("\n")[0].slice(0, 200) }),
|
|
1151
|
+
deviceId: this.device().id, dedupeKey: `${e.id}@${rt.info.id}`,
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (e.kind !== "orchestra" || !this.hubClient)
|
|
1156
|
+
return;
|
|
1157
|
+
const p = e.payload;
|
|
1158
|
+
const phase = String(p.phase ?? "");
|
|
1159
|
+
// D36: work that reached the base branch without a PR has landed too.
|
|
1160
|
+
if ((phase === "delivered" && (p.mode === "commit" || p.mode === "push")) || phase === "applied") {
|
|
1161
|
+
void this.coordinators.get(rt.info.id)?.release(String(p.runId), phase === "applied" ? "applied locally" : `delivered (${String(p.mode)})`);
|
|
1162
|
+
}
|
|
1163
|
+
const map = {
|
|
1164
|
+
started: "goal_started",
|
|
1165
|
+
completed: "goal_finished",
|
|
1166
|
+
failed: "goal_finished",
|
|
1167
|
+
aborted: "goal_finished",
|
|
1168
|
+
plan_written: "plan_written",
|
|
1169
|
+
delivered: "pr_opened",
|
|
1170
|
+
};
|
|
1171
|
+
const type = map[phase];
|
|
1172
|
+
if (!type || (phase === "delivered" && !p.prUrl))
|
|
1173
|
+
return;
|
|
1174
|
+
if (phase === "plan_written" && p.final)
|
|
1175
|
+
return;
|
|
1176
|
+
const share = await this.teamFor(rt);
|
|
1177
|
+
if (!share)
|
|
1178
|
+
return;
|
|
1179
|
+
const run = rt.orchestra.get(String(p.runId));
|
|
1180
|
+
const meta = {
|
|
1181
|
+
runId: p.runId,
|
|
1182
|
+
status: p.status,
|
|
1183
|
+
...(run ? { orchestrator: run.orchestrator.agent, workers: run.workers, tasks: run.tasks.length, branch: run.branch } : {}),
|
|
1184
|
+
// what the goal cost, for the team's rollups (D64)
|
|
1185
|
+
...(run && type === "goal_finished" ? { costUsd: Math.round(run.costUsd * 100) / 100 } : {}),
|
|
1186
|
+
...(p.prUrl ? { prUrl: p.prUrl } : {}),
|
|
1187
|
+
...(phase === "plan_written" ? { dir: p.dir } : {}),
|
|
1188
|
+
};
|
|
1189
|
+
const sealed = sealForTeam(this.currentKey(share.teamId), {
|
|
1190
|
+
goal: run?.goal.split("\n")[0].slice(0, 200) ?? "",
|
|
1191
|
+
...(run?.summary ? { summary: run.summary.slice(0, 500) } : {}),
|
|
1192
|
+
});
|
|
1193
|
+
const fe = { repo: share.repo, type, meta, sealed, deviceId: this.device().id, dedupeKey: `${e.id}@${rt.info.id}` };
|
|
1194
|
+
fe.sig = signPayload(this.device(), { ...fe, sig: undefined });
|
|
1195
|
+
await this.hub().appendFeed(share.teamId, fe);
|
|
1196
|
+
}
|
|
1197
|
+
// ── GitHub, without an App (D7 fallback) ──
|
|
1198
|
+
async pollGitHub() {
|
|
1199
|
+
if (!this.hubClient)
|
|
1200
|
+
return 0;
|
|
1201
|
+
let added = 0;
|
|
1202
|
+
const polled = new Set();
|
|
1203
|
+
for (const rt of this.host.runtimes()) {
|
|
1204
|
+
const share = await this.teamFor(rt);
|
|
1205
|
+
if (!share || polled.has(share.repo))
|
|
1206
|
+
continue;
|
|
1207
|
+
polled.add(share.repo);
|
|
1208
|
+
let prs;
|
|
1209
|
+
try {
|
|
1210
|
+
prs = JSON.parse(await run("gh", [
|
|
1211
|
+
"pr", "list", "--repo", share.repo, "--state", "all", "--limit", "20",
|
|
1212
|
+
"--json", "number,title,state,headRefName,headRefOid,author,url,mergedAt,statusCheckRollup,files",
|
|
1213
|
+
]));
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
for (const pr of prs) {
|
|
1219
|
+
for (const fe of githubFeedEvents(share.repo, pr)) {
|
|
1220
|
+
fe.sealed = sealForTeam(this.currentKey(share.teamId), { title: String(pr.title ?? "").slice(0, 200) });
|
|
1221
|
+
if (await this.hub().appendFeed(share.teamId, fe).catch(() => null))
|
|
1222
|
+
added++;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return added;
|
|
1227
|
+
}
|
|
1228
|
+
// ── GitHub webhooks into the hub (Phase 6, D83): D7's App path without an App ──
|
|
1229
|
+
/** Where a repo webhook posts for this team: the self-hosted hub's route, or the hosted hub's Edge Function. */
|
|
1230
|
+
webhookUrl(teamId) {
|
|
1231
|
+
const h = this.state.hub;
|
|
1232
|
+
if (!h)
|
|
1233
|
+
throw new Error("sign in to a team hub first");
|
|
1234
|
+
const supabaseUrl = hostedSupabaseUrl(h.url);
|
|
1235
|
+
if (supabaseUrl !== null)
|
|
1236
|
+
return `${supabaseUrl.replace(/\/$/, "")}/functions/v1/github-webhook/${teamId}`;
|
|
1237
|
+
return `${h.url.replace(/\/$/, "")}/github/webhook/${teamId}`;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* The team's webhook: its payload URL and secret (owners only; created on
|
|
1241
|
+
* first ask, replaced with `rotate`), and with `install` a repo webhook made
|
|
1242
|
+
* through `gh` for the events Loom maps. The repo defaults to the project's
|
|
1243
|
+
* shared repo, or the team's only one.
|
|
1244
|
+
*/
|
|
1245
|
+
async webhook(opts = {}) {
|
|
1246
|
+
let teamId = opts.teamId;
|
|
1247
|
+
let repo = opts.repo ? normalizeRepo(opts.repo) : null;
|
|
1248
|
+
if (!repo && opts.rt) {
|
|
1249
|
+
const share = await this.teamFor(opts.rt);
|
|
1250
|
+
if (share) {
|
|
1251
|
+
repo = share.repo;
|
|
1252
|
+
teamId ??= share.teamId;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
teamId ??= this.defaultTeam();
|
|
1256
|
+
// An empty cached view is not evidence that the team shares nothing — it
|
|
1257
|
+
// is usually a view fetched before the share landed. Only a view that
|
|
1258
|
+
// actually lists repos may answer; otherwise ask the hub, which knows.
|
|
1259
|
+
// (A stale empty view made this warn "acme/app isn't shared with this
|
|
1260
|
+
// team" about a repo that was, which reads as a refusal.)
|
|
1261
|
+
const cached = this.views.get(teamId)?.repos;
|
|
1262
|
+
const repos = cached?.length ? cached : await this.hub().repos(teamId);
|
|
1263
|
+
if (!repo && repos.length === 1)
|
|
1264
|
+
repo = repos[0];
|
|
1265
|
+
const { secret } = await this.hub().webhookSecret(teamId, Boolean(opts.rotate));
|
|
1266
|
+
const url = this.webhookUrl(teamId);
|
|
1267
|
+
const local = /^https?:\/\/(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(url);
|
|
1268
|
+
const out = {
|
|
1269
|
+
teamId,
|
|
1270
|
+
url,
|
|
1271
|
+
secret,
|
|
1272
|
+
repo,
|
|
1273
|
+
events: [...WEBHOOK_EVENTS],
|
|
1274
|
+
...(local ? { warning: "this hub listens on a local address GitHub can't reach — expose it (a tunnel or a public host) first" } : {}),
|
|
1275
|
+
};
|
|
1276
|
+
if (repo && !repos.includes(repo))
|
|
1277
|
+
out.warning = `${repo} isn't shared with this team — its events would be dropped (loom team share)`;
|
|
1278
|
+
if (!opts.install)
|
|
1279
|
+
return out;
|
|
1280
|
+
if (!repo)
|
|
1281
|
+
throw new Error("which repo? pass --repo owner/name");
|
|
1282
|
+
const body = JSON.stringify({ name: "web", active: true, events: [...WEBHOOK_EVENTS], config: { url, content_type: "json", secret, insecure_ssl: "0" } });
|
|
1283
|
+
const exec = this.host.landingExec ?? defaultExec;
|
|
1284
|
+
const r = await exec("gh", ["api", `repos/${repo}/hooks`, "-X", "POST", "--input", "-"], process.cwd(), { input: body, timeoutMs: 30_000 });
|
|
1285
|
+
if (r.code !== 0)
|
|
1286
|
+
throw new Error(`gh couldn't create the webhook on ${repo} (it needs admin on the repo): ${(r.err || r.out).trim().slice(0, 300)}`);
|
|
1287
|
+
let id = null;
|
|
1288
|
+
try {
|
|
1289
|
+
id = Number(JSON.parse(r.out).id) || null;
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
id = null;
|
|
1293
|
+
}
|
|
1294
|
+
return { ...out, installed: { id, repo } };
|
|
1295
|
+
}
|
|
1296
|
+
// ── the view the UI reads ──
|
|
1297
|
+
status() {
|
|
1298
|
+
const h = this.state.hub;
|
|
1299
|
+
return {
|
|
1300
|
+
signedIn: Boolean(h),
|
|
1301
|
+
hub: h?.url ?? null,
|
|
1302
|
+
// null until the first call; false with the reason while the hub is down
|
|
1303
|
+
connected: h ? this.hubOk.ok : null,
|
|
1304
|
+
lastError: this.hubOk.ok === false ? this.hubOk.error ?? null : null,
|
|
1305
|
+
checkedAt: this.hubOk.at || null,
|
|
1306
|
+
github: h?.github ?? null,
|
|
1307
|
+
device: this.state.device?.id ?? null,
|
|
1308
|
+
teams: Object.entries(this.state.teams).map(([id, t]) => {
|
|
1309
|
+
const v = this.views.get(id);
|
|
1310
|
+
const keys = t.keys;
|
|
1311
|
+
return {
|
|
1312
|
+
id,
|
|
1313
|
+
name: t.name,
|
|
1314
|
+
role: t.role,
|
|
1315
|
+
...(t.missing ? { missing: true } : {}),
|
|
1316
|
+
keyVersion: keys[keys.length - 1]?.version ?? null,
|
|
1317
|
+
members: v?.members.map((m) => ({ id: m.user.id, github: m.user.github, name: m.user.name, role: m.role, devices: m.devices.length })) ?? [],
|
|
1318
|
+
repos: v?.repos ?? [],
|
|
1319
|
+
presence: v ? [...v.presence.values()].filter((p) => Date.now() - p.ts < 45_000).map((p) => this.decryptPresence(id, p)) : [],
|
|
1320
|
+
feed: v ? v.feed.slice(-100).map((e) => this.decryptFeed(id, e)) : [],
|
|
1321
|
+
leases: v ? [...v.leases.values()].map((l) => this.decryptLease(id, l)) : [],
|
|
1322
|
+
costs: v ? rollupCosts(v.feed) : null,
|
|
1323
|
+
crewRequests: v ? crewRequests(v.feed, e => openFromTeam(keys, e.sealed)) : [],
|
|
1324
|
+
};
|
|
1325
|
+
}),
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
decryptPresence(teamId, p) {
|
|
1329
|
+
const { sealed, sig: _sig, ...rest } = p;
|
|
1330
|
+
return { ...rest, intent: openFromTeam(this.state.teams[teamId]?.keys ?? [], sealed) ?? null, mine: p.userId === this.state.hub?.userId };
|
|
1331
|
+
}
|
|
1332
|
+
/** A lease for the UI: who, which goal/task (decrypted), what it covers, and its state. */
|
|
1333
|
+
decryptLease(teamId, l) {
|
|
1334
|
+
const { sealed, files, ...rest } = l;
|
|
1335
|
+
return {
|
|
1336
|
+
...rest,
|
|
1337
|
+
fileCount: files.length,
|
|
1338
|
+
files: files.slice(0, 20),
|
|
1339
|
+
// computed on every read: a flag frozen when the lease last changed would
|
|
1340
|
+
// show a sleeping teammate as active until something else happened (D12)
|
|
1341
|
+
stale: Date.now() - l.ts > LEASE_TTL_MS,
|
|
1342
|
+
intent: openFromTeam(this.state.teams[teamId]?.keys ?? [], sealed) ?? null,
|
|
1343
|
+
mine: l.userId === this.state.hub?.userId,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
decryptFeed(teamId, e) {
|
|
1347
|
+
const { sealed, sig: _sig, ...rest } = e;
|
|
1348
|
+
return { ...rest, content: openFromTeam(this.state.teams[teamId]?.keys ?? [], sealed) ?? null };
|
|
1349
|
+
}
|
|
1350
|
+
decryptEvent(e) {
|
|
1351
|
+
if (e.type === "lease")
|
|
1352
|
+
return { type: e.type, lease: this.decryptLease(e.teamId, e.lease) };
|
|
1353
|
+
if (e.type === "presence")
|
|
1354
|
+
return { type: e.type, presence: this.decryptPresence(e.teamId, e.presence) };
|
|
1355
|
+
if (e.type === "feed")
|
|
1356
|
+
return { type: e.type, event: this.decryptFeed(e.teamId, e.event) };
|
|
1357
|
+
return { ...e };
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
function presenceKey(p) {
|
|
1361
|
+
return `${p.userId}/${p.deviceId}/${p.repo}/${p.agent}`;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* The live sessions in one project, as presence: busy roster agents and every
|
|
1365
|
+
* orchestra task that is running or queued. Titles go in `intent` (sealed);
|
|
1366
|
+
* everything in `presence` is plain metadata (D2, D24).
|
|
1367
|
+
*/
|
|
1368
|
+
export function sessionsOf(rt, repo) {
|
|
1369
|
+
const out = [];
|
|
1370
|
+
for (const crew of rt.crews.list()) {
|
|
1371
|
+
const goal = crew.state.goal, run = rt.crews.coordination(crew.id);
|
|
1372
|
+
if (!goal || !run || ["completed", "failed", "stopped"].includes(goal.status))
|
|
1373
|
+
continue;
|
|
1374
|
+
for (const mate of crew.teammates) {
|
|
1375
|
+
const active = crew.active.find(t => t.teammate === mate.id);
|
|
1376
|
+
const card = active?.card ? goal.cards.find(c => c.id === active.card) : undefined;
|
|
1377
|
+
out.push({ presence: { repo, runId: run.id, ...(card ? { taskId: card.id } : {}),
|
|
1378
|
+
agent: `${mate.agent}#crew:${crew.id}:${mate.id}`, kind: rt.config.agents.find(a => a.id === mate.agent)?.kind ?? mate.agent,
|
|
1379
|
+
branch: card?.workspace?.branch ?? goal.branch, touches: card?.touches ?? [],
|
|
1380
|
+
state: active ? mate.role === "reviewer" ? "reviewing" : "running" : goal.status === "planning" ? "planning" : "waiting_human",
|
|
1381
|
+
since: active?.since ?? goal.startedAt },
|
|
1382
|
+
intent: { crew: crew.id, crewName: crew.name, teammate: mate.id, role: mate.role,
|
|
1383
|
+
goal: goal.text.split("\n")[0].slice(0, 200), ...(card ? { card: card.title.slice(0, 200) } : {}) } });
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
const act = rt.activity();
|
|
1387
|
+
const run = act.orchestra;
|
|
1388
|
+
const runLive = run && !["completed", "failed", "aborted"].includes(run.status);
|
|
1389
|
+
const full = runLive ? rt.orchestra.get(run.id) : undefined;
|
|
1390
|
+
if (runLive && full) {
|
|
1391
|
+
const orchState = run.status === "reviewing" ? "reviewing" : run.status === "waiting_human" ? "waiting_human" : run.status === "planning" ? "planning" : "running";
|
|
1392
|
+
out.push({
|
|
1393
|
+
presence: {
|
|
1394
|
+
repo, runId: run.id, agent: `${run.orchestrator}#orch`, kind: full.orchestrator.kind, branch: full.branch,
|
|
1395
|
+
touches: [], state: orchState, since: full.createdAt,
|
|
1396
|
+
},
|
|
1397
|
+
intent: { goal: full.goal.split("\n")[0].slice(0, 200), role: "orchestrator" },
|
|
1398
|
+
});
|
|
1399
|
+
for (const t of full.tasks) {
|
|
1400
|
+
if (t.status !== "running" && t.status !== "pending")
|
|
1401
|
+
continue;
|
|
1402
|
+
out.push({
|
|
1403
|
+
presence: {
|
|
1404
|
+
repo, runId: full.id, taskId: t.id, agent: `${t.agent}#${t.id}`, kind: t.kind,
|
|
1405
|
+
...(t.branch ? { branch: t.branch } : {}),
|
|
1406
|
+
touches: t.touches?.length ? t.touches : (t.files ?? []),
|
|
1407
|
+
state: t.status === "running" ? "running" : "planning",
|
|
1408
|
+
since: t.startedAt ?? full.createdAt,
|
|
1409
|
+
},
|
|
1410
|
+
intent: { goal: full.goal.split("\n")[0].slice(0, 200), task: t.title.slice(0, 200) },
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
for (const a of act.agents) {
|
|
1415
|
+
if (!a.busy)
|
|
1416
|
+
continue;
|
|
1417
|
+
out.push({
|
|
1418
|
+
presence: { repo, agent: a.id, kind: a.kind, touches: [], state: "running", since: a.since ?? Date.now() },
|
|
1419
|
+
// The thread's title, never its messages (D24).
|
|
1420
|
+
intent: { thread: a.chatTitle ?? "" },
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
return out;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* PR and check facts from one `gh pr list` row, as idempotent feed events. The
|
|
1427
|
+
* mapping lives in core/github-events.ts, shared with the webhook receivers so
|
|
1428
|
+
* polling and webhooks produce the same dedupe keys (D84).
|
|
1429
|
+
*/
|
|
1430
|
+
export function githubFeedEvents(repo, pr) {
|
|
1431
|
+
return prRowFeed(repo, pr);
|
|
1432
|
+
}
|
|
1433
|
+
//# sourceMappingURL=team.js.map
|