@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
package/dist/cli/index.js
CHANGED
|
@@ -5,20 +5,40 @@
|
|
|
5
5
|
* Weaves Claude Code, OpenCode (and bridges like Antigravity) into a single
|
|
6
6
|
* shared thread per project: one conversation, one baton, shared memory.
|
|
7
7
|
*/
|
|
8
|
-
import { spawn } from "
|
|
8
|
+
import { spawn } from "../core/process.js";
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
+
import { freeUsage } from "../core/free-pool.js";
|
|
10
11
|
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
11
13
|
import readline from "node:readline";
|
|
12
14
|
import { fileURLToPath } from "node:url";
|
|
13
15
|
import { Command } from "commander";
|
|
14
16
|
import pc from "picocolors";
|
|
15
17
|
import qrcode from "qrcode-terminal";
|
|
18
|
+
// package.json says node >=22.5, but npm doesn't enforce engines at run time.
|
|
19
|
+
// Below that there is no node:sqlite, so every project's history would read
|
|
20
|
+
// as empty — say so up front instead of looking like the data is gone.
|
|
21
|
+
{
|
|
22
|
+
const [maj, min] = process.versions.node.split(".").map(Number);
|
|
23
|
+
if (maj < 22 || (maj === 22 && min < 5)) {
|
|
24
|
+
console.error(`loom needs Node 22.5 or newer — this is Node ${process.versions.node} (${process.execPath}).\n` +
|
|
25
|
+
`If you use nvm, \`nvm use 22\` (or newer) — or put a newer node first on your PATH.`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
16
29
|
import { DaemonClient, DaemonError, daemonRunning, ensureDaemon, stopDaemon, } from "../daemon/client.js";
|
|
17
30
|
import { installCrashGuards } from "../daemon/guards.js";
|
|
18
31
|
import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
|
|
19
|
-
import { ensureLoomHome } from "../core/registry.js";
|
|
32
|
+
import { ensureLoomHome, loomHome } from "../core/registry.js";
|
|
33
|
+
import { VERSION } from "../version.js";
|
|
34
|
+
import { makeBackup } from "../core/backup.js";
|
|
35
|
+
import { threadMarkdown } from "../core/thread-md.js";
|
|
36
|
+
import { EventLog } from "../core/eventlog.js";
|
|
37
|
+
import { allModels, fetchModels, forgetProvider, listProviders, resolveProvider, setProvider, } from "../core/providers.js";
|
|
38
|
+
import { serviceFile, tokenFindings } from "../core/runner-setup.js";
|
|
39
|
+
import { readRunnerConfig, readRunnerToken, scrubEnv, writeRunnerToken } from "../daemon/runner.js";
|
|
20
40
|
import { NoProjectError, currentProjectDir, resolveCurrentProject } from "./common.js";
|
|
21
|
-
import { fmtUsd, formatAgentRow, formatEvent, formatProjectRow } from "./ui.js";
|
|
41
|
+
import { fmtUsd, formatAgentRosterRow, formatAgentRow, formatEvent, formatProjectRow } from "./ui.js";
|
|
22
42
|
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
43
|
/**
|
|
24
44
|
* Where a background daemon's output goes: ~/.loom/daemon.log, appended.
|
|
@@ -43,7 +63,7 @@ const program = new Command();
|
|
|
43
63
|
program
|
|
44
64
|
.name("loom")
|
|
45
65
|
.description("one CLI for all your coding agents — shared thread, shared memory, one baton")
|
|
46
|
-
.version(
|
|
66
|
+
.version(VERSION);
|
|
47
67
|
// ---------------------------------------------------------------------------
|
|
48
68
|
// Helpers
|
|
49
69
|
// ---------------------------------------------------------------------------
|
|
@@ -51,6 +71,33 @@ function fail(message) {
|
|
|
51
71
|
console.error(pc.red(`✗ ${message}`));
|
|
52
72
|
process.exit(1);
|
|
53
73
|
}
|
|
74
|
+
/** A hosted-hub session: a local browser (loopback), or --paste for machines without one (D74). */
|
|
75
|
+
async function hostedSession(supabaseUrl, publishableKey, paste) {
|
|
76
|
+
const { hostedSignIn, openInBrowser, pasteSignIn } = await import("../hub/supabase-client.js");
|
|
77
|
+
if (paste) {
|
|
78
|
+
return pasteSignIn({
|
|
79
|
+
supabaseUrl,
|
|
80
|
+
publishableKey,
|
|
81
|
+
ask: async (url) => {
|
|
82
|
+
console.log(`${pc.bold("sign in with GitHub")} — open this on any device:\n\n ${url}\n`);
|
|
83
|
+
console.log(pc.dim("you'll land on a page that won't load; copy its whole address and paste it here."));
|
|
84
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
85
|
+
const line = await new Promise((resolve) => rl.question("address: ", resolve));
|
|
86
|
+
rl.close();
|
|
87
|
+
return line;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
console.log(pc.dim("signing in to the hosted Loom Team Hub with GitHub…"));
|
|
92
|
+
return hostedSignIn({
|
|
93
|
+
supabaseUrl,
|
|
94
|
+
publishableKey,
|
|
95
|
+
openBrowser: (url) => {
|
|
96
|
+
console.log(`${pc.dim(" if your browser didn't open:")} ${url}`);
|
|
97
|
+
openInBrowser(url);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
54
101
|
async function currentProject(client) {
|
|
55
102
|
try {
|
|
56
103
|
return await resolveCurrentProject(client);
|
|
@@ -103,6 +150,18 @@ program
|
|
|
103
150
|
console.error(pc.red(`✗ invalid --port "${opts.port}"`));
|
|
104
151
|
process.exit(1);
|
|
105
152
|
}
|
|
153
|
+
// A runner box's agents don't inherit its secrets (Loom Teams D70).
|
|
154
|
+
if (readRunnerConfig().enabled) {
|
|
155
|
+
const { env, removed } = scrubEnv(process.env);
|
|
156
|
+
for (const k of Object.keys(process.env))
|
|
157
|
+
if (!(k in env))
|
|
158
|
+
delete process.env[k];
|
|
159
|
+
if (removed.length)
|
|
160
|
+
console.log(pc.dim(`runner: kept ${removed.length} secret-looking variable${removed.length === 1 ? "" : "s"} away from agents`));
|
|
161
|
+
}
|
|
162
|
+
const token = readRunnerToken();
|
|
163
|
+
if (readRunnerConfig().enabled && token && !process.env.GH_TOKEN)
|
|
164
|
+
process.env.GH_TOKEN = token;
|
|
106
165
|
const daemon = new LoomDaemon({ host: opts.host, port });
|
|
107
166
|
let bound;
|
|
108
167
|
try {
|
|
@@ -124,7 +183,10 @@ program
|
|
|
124
183
|
console.log(pc.dim("bound to the tailnet — pair your phone with `loom pair`"));
|
|
125
184
|
}
|
|
126
185
|
const shutdown = () => {
|
|
127
|
-
|
|
186
|
+
// A shutdown that can't finish in a few seconds is stuck on something it
|
|
187
|
+
// shouldn't wait for; leave rather than linger as a headless daemon.
|
|
188
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
189
|
+
void daemon.close().then(() => process.exit(0), () => process.exit(1));
|
|
128
190
|
};
|
|
129
191
|
process.on("SIGINT", shutdown);
|
|
130
192
|
process.on("SIGTERM", shutdown);
|
|
@@ -155,90 +217,2080 @@ program
|
|
|
155
217
|
while (Date.now() < gone && (await daemonRunning())) {
|
|
156
218
|
await new Promise((r) => setTimeout(r, 200));
|
|
157
219
|
}
|
|
158
|
-
}
|
|
159
|
-
const self = fileURLToPath(import.meta.url);
|
|
160
|
-
const args = [self, "daemon", ...(opts.tailnet ? ["--tailnet"] : [])];
|
|
161
|
-
// Keep the daemon's output. It used to be stdio:"ignore", which meant a
|
|
162
|
-
// background daemon threw away every byte it ever wrote — a crash left you
|
|
163
|
-
// with nothing to read. Appended, and rolled once it gets big, so it can't
|
|
164
|
-
// quietly eat the disk either.
|
|
165
|
-
const log = openDaemonLog();
|
|
166
|
-
const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log] });
|
|
167
|
-
child.unref();
|
|
168
|
-
const deadline = Date.now() + 15_000;
|
|
169
|
-
while (Date.now() < deadline) {
|
|
170
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
171
|
-
const cfg = await daemonRunning();
|
|
172
|
-
if (cfg) {
|
|
173
|
-
console.log(pc.green(`✓ daemon up on http://${cfg.host}:${cfg.port}`));
|
|
220
|
+
}
|
|
221
|
+
const self = fileURLToPath(import.meta.url);
|
|
222
|
+
const args = [self, "daemon", ...(opts.tailnet ? ["--tailnet"] : [])];
|
|
223
|
+
// Keep the daemon's output. It used to be stdio:"ignore", which meant a
|
|
224
|
+
// background daemon threw away every byte it ever wrote — a crash left you
|
|
225
|
+
// with nothing to read. Appended, and rolled once it gets big, so it can't
|
|
226
|
+
// quietly eat the disk either.
|
|
227
|
+
const log = openDaemonLog();
|
|
228
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log] });
|
|
229
|
+
child.unref();
|
|
230
|
+
const deadline = Date.now() + 15_000;
|
|
231
|
+
while (Date.now() < deadline) {
|
|
232
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
233
|
+
const cfg = await daemonRunning();
|
|
234
|
+
if (cfg) {
|
|
235
|
+
console.log(pc.green(`✓ daemon up on http://${cfg.host}:${cfg.port}`));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
fail("daemon did not become healthy — try `loom daemon` in the foreground");
|
|
240
|
+
});
|
|
241
|
+
program
|
|
242
|
+
.command("down")
|
|
243
|
+
.description("stop the background daemon")
|
|
244
|
+
.action(async () => {
|
|
245
|
+
if (await stopDaemon())
|
|
246
|
+
console.log(pc.green("✓ daemon stopped"));
|
|
247
|
+
else
|
|
248
|
+
console.log(pc.dim("no running daemon found"));
|
|
249
|
+
});
|
|
250
|
+
program
|
|
251
|
+
.command("status")
|
|
252
|
+
.description("daemon health and project board")
|
|
253
|
+
.action(async () => {
|
|
254
|
+
const cfg = await daemonRunning();
|
|
255
|
+
if (!cfg) {
|
|
256
|
+
console.log(pc.dim("daemon: not running (loom up)"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
|
|
260
|
+
const client = new DaemonClient(cfg);
|
|
261
|
+
const { projects } = await client.listProjects();
|
|
262
|
+
if (!projects.length)
|
|
263
|
+
console.log(pc.dim("no projects yet — loom init"));
|
|
264
|
+
for (const p of projects)
|
|
265
|
+
console.log(formatProjectRow(p));
|
|
266
|
+
});
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// init / projects / agents
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
program
|
|
271
|
+
.command("init")
|
|
272
|
+
.description("make the current directory a Loom project")
|
|
273
|
+
.option("--name <name>", "project name (default: directory name)")
|
|
274
|
+
.action(async (opts) => {
|
|
275
|
+
const client = await ensureDaemon();
|
|
276
|
+
const dir = process.cwd();
|
|
277
|
+
const res = await client.addProject(dir, opts.name);
|
|
278
|
+
const { project } = await client.project(res.project.id);
|
|
279
|
+
console.log(pc.green(`✓ project "${project.name}" (${project.id})`));
|
|
280
|
+
for (const a of project.agents)
|
|
281
|
+
console.log(formatAgentRow(a));
|
|
282
|
+
console.log(pc.dim("\nedit .loom/config.json to add/remove agents or change roles"));
|
|
283
|
+
console.log(pc.dim("then: loom chat"));
|
|
284
|
+
});
|
|
285
|
+
program
|
|
286
|
+
.command("projects")
|
|
287
|
+
.description("board of all projects")
|
|
288
|
+
.option("--forget <idOrName>", "stop tracking a project (its .loom/ stays on disk)")
|
|
289
|
+
.action(async (opts) => {
|
|
290
|
+
const client = await ensureDaemon();
|
|
291
|
+
if (opts.forget) {
|
|
292
|
+
const { projects } = await client.listProjects();
|
|
293
|
+
const hit = projects.find((p) => p.id === opts.forget || p.name === opts.forget);
|
|
294
|
+
if (!hit) {
|
|
295
|
+
console.error(pc.red(`no project "${opts.forget}"`));
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const r = await client.forgetProject(hit.id);
|
|
300
|
+
console.log(`forgot ${pc.bold(hit.name)} — ${pc.dim(hit.dir)}`);
|
|
301
|
+
// Say what was kept. "Removed" against a tool that also owns your event
|
|
302
|
+
// log reads as "deleted", and someone who believes that will not go
|
|
303
|
+
// looking for the history that is still sitting there.
|
|
304
|
+
console.log(pc.dim(`its ${r.keptOnDisk} is untouched — add the directory again to restore it`));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const { projects } = await client.listProjects();
|
|
308
|
+
if (!projects.length)
|
|
309
|
+
console.log(pc.dim("no projects yet — loom init"));
|
|
310
|
+
for (const p of projects)
|
|
311
|
+
console.log(formatProjectRow(p));
|
|
312
|
+
});
|
|
313
|
+
program
|
|
314
|
+
.command("agents")
|
|
315
|
+
.description("list agent ids, kinds, roles, models, and baton eligibility")
|
|
316
|
+
.option("--json", "print the agent roster as JSON")
|
|
317
|
+
.action(async (opts) => {
|
|
318
|
+
const client = await ensureDaemon();
|
|
319
|
+
const project = await currentProject(client);
|
|
320
|
+
if (opts.json) {
|
|
321
|
+
console.log(JSON.stringify(project.agents));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!project.agents.length) {
|
|
325
|
+
console.log(pc.dim("no agents configured — edit .loom/config.json or run loom init"));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (const a of project.agents)
|
|
329
|
+
console.log(formatAgentRosterRow(a));
|
|
330
|
+
});
|
|
331
|
+
program
|
|
332
|
+
.command("providers")
|
|
333
|
+
.description("where model agents send their turns, and whether each has a key")
|
|
334
|
+
.action(() => {
|
|
335
|
+
const rows = listProviders();
|
|
336
|
+
for (const p of rows) {
|
|
337
|
+
const mark = p.configured ? pc.green("\u2713") : pc.dim("\u00b7");
|
|
338
|
+
const key = p.configured ? pc.dim(p.hint ? `${p.hint} (${p.source})` : "no key needed") : pc.dim("not configured");
|
|
339
|
+
console.log(`${mark} ${pc.bold(p.id.padEnd(13))} ${pc.dim(p.baseUrl.padEnd(34))} ${key}`);
|
|
340
|
+
if (p.note)
|
|
341
|
+
console.log(pc.dim(` ${p.note}`));
|
|
342
|
+
}
|
|
343
|
+
const fu = freeUsage();
|
|
344
|
+
console.log(`\n${pc.bold("free models")} ${fu.used} request${fu.used === 1 ? "" : "s"} from this machine today (UTC)` +
|
|
345
|
+
(fu.cappedUntil ? pc.yellow(` · daily cap hit, resets ${new Date(fu.cappedUntil).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`) : "") +
|
|
346
|
+
pc.dim("\n OpenRouter allows 50 free requests a day per account (1000 with $10 of credits), shared by every :free model." +
|
|
347
|
+
"\n Spread the work: give model agents `pool:free` and each turn takes the next free model — `loom agents:add model --model openrouter/pool:free`"));
|
|
348
|
+
console.log(pc.dim("\nkeys live in ~/.loom/providers.json (0600) or the environment \u2014 never in a project"));
|
|
349
|
+
});
|
|
350
|
+
program
|
|
351
|
+
.command("providers:set <id>")
|
|
352
|
+
.description("set a provider's key, base URL or headers (a key is never printed back)")
|
|
353
|
+
.option("--key <key>", "the API key; prefer the environment on a shared machine")
|
|
354
|
+
.option("--base-url <url>", "for a custom provider, or to override a known one")
|
|
355
|
+
.option("--label <label>", "what to call it")
|
|
356
|
+
.option("--header <kv...>", "extra header, as name=value")
|
|
357
|
+
.action((id, opts) => {
|
|
358
|
+
const headers = {};
|
|
359
|
+
for (const kv of opts.header ?? []) {
|
|
360
|
+
const at = kv.indexOf("=");
|
|
361
|
+
if (at < 1)
|
|
362
|
+
fail(`--header wants name=value, not "${kv}"`);
|
|
363
|
+
headers[kv.slice(0, at).trim()] = kv.slice(at + 1).trim();
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
setProvider(id, {
|
|
367
|
+
...(opts.key ? { key: opts.key } : {}),
|
|
368
|
+
...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
|
|
369
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
370
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
375
|
+
}
|
|
376
|
+
const row = listProviders().find((p) => p.id === id.toLowerCase());
|
|
377
|
+
console.log(`${pc.green("\u2713")} ${id}: ${row?.baseUrl ?? ""} ${pc.dim(row?.hint ?? "")}`);
|
|
378
|
+
});
|
|
379
|
+
program
|
|
380
|
+
.command("providers:rm <id>")
|
|
381
|
+
.description("forget a provider's key and settings")
|
|
382
|
+
.action((id) => {
|
|
383
|
+
console.log(forgetProvider(id) ? `${pc.red("-")} ${id} forgotten` : pc.dim(`nothing stored for ${id}`));
|
|
384
|
+
});
|
|
385
|
+
program
|
|
386
|
+
.command("ask <words...>")
|
|
387
|
+
.description("ask several models the same thing at once, a thread each")
|
|
388
|
+
.option("--models <list>", "comma-separated model ids (provider/model, or just the model)")
|
|
389
|
+
.option("--free", "every free model the providers have, up to --limit")
|
|
390
|
+
.option("--limit <n>", "how many models --free may use", "3")
|
|
391
|
+
.option("--title <title>", "what to call the threads")
|
|
392
|
+
.option("--no-briefing", "don't send the project's memory brief with it")
|
|
393
|
+
.action(async (words, opts) => {
|
|
394
|
+
const client = await ensureDaemon();
|
|
395
|
+
const project = await currentProject(client);
|
|
396
|
+
let models = (opts.models ?? "").split(",").map((m) => m.trim()).filter(Boolean);
|
|
397
|
+
if (opts.free) {
|
|
398
|
+
// The point of --free: spend nothing, and let the providers say what
|
|
399
|
+
// that means today rather than a list going stale in this file.
|
|
400
|
+
const { models: all } = await allModels();
|
|
401
|
+
const limit = Math.max(1, Number(opts.limit) || 3);
|
|
402
|
+
models = all.filter((m) => m.free).slice(0, limit).map((m) => `${m.provider}/${m.id}`);
|
|
403
|
+
if (!models.length)
|
|
404
|
+
fail("no free models — loom models --refresh, or name them with --models");
|
|
405
|
+
}
|
|
406
|
+
if (!models.length)
|
|
407
|
+
fail('which models? e.g. loom ask --models "a,b" "your question"');
|
|
408
|
+
try {
|
|
409
|
+
const { asked } = await client.askModels(project.id, words.join(" "), models, {
|
|
410
|
+
...(opts.title ? { title: opts.title } : {}),
|
|
411
|
+
...(opts.briefing === false ? { briefing: false } : {}),
|
|
412
|
+
});
|
|
413
|
+
console.log(pc.cyan(`\u279c asked ${asked.length} model${asked.length === 1 ? "" : "s"}:`));
|
|
414
|
+
for (const a of asked)
|
|
415
|
+
console.log(` ${pc.bold(a.model)} ${pc.dim(a.chat)}`);
|
|
416
|
+
console.log(pc.dim("answers stream into their own threads \u2014 loom watch, or open the app"));
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
program
|
|
423
|
+
.command("routes:save <name> <steps...>")
|
|
424
|
+
.description('define a named route, e.g. loom routes:save ship planner executor reviewer\n' +
|
|
425
|
+
" a step may carry a condition on the previous turn: 'reviewer?lines>200'\n" +
|
|
426
|
+
" (changed>N, changed<N, lines>N, lines<N, touched:<glob>, !touched:<glob>)")
|
|
427
|
+
.action(async (name, steps) => {
|
|
428
|
+
const client = await ensureDaemon();
|
|
429
|
+
const project = await currentProject(client);
|
|
430
|
+
try {
|
|
431
|
+
await client.saveRoute(project.id, name, steps);
|
|
432
|
+
console.log(`${pc.green("✓")} ${name}: ${steps.join(" → ")}`);
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
program
|
|
440
|
+
.command("routes:rm <name>")
|
|
441
|
+
.description("remove a named route")
|
|
442
|
+
.action(async (name) => {
|
|
443
|
+
const client = await ensureDaemon();
|
|
444
|
+
const project = await currentProject(client);
|
|
445
|
+
try {
|
|
446
|
+
await client.deleteRoute(project.id, name);
|
|
447
|
+
console.log(`${pc.red("-")} ${name} removed`);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
program
|
|
455
|
+
.command("retry <agentId>")
|
|
456
|
+
.description("re-run the last failed turn on a different agent, failure attached as context")
|
|
457
|
+
.action(async (agentId) => {
|
|
458
|
+
const client = await ensureDaemon();
|
|
459
|
+
const project = await currentProject(client);
|
|
460
|
+
try {
|
|
461
|
+
const { retried } = await client.retryTurn(project.id, agentId);
|
|
462
|
+
console.log(`${pc.green("↻")} ${pc.bold(agentId)} retrying: ${pc.dim(retried.slice(0, 80))}`);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
466
|
+
process.exitCode = 1;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
program
|
|
470
|
+
.command("version")
|
|
471
|
+
.description("CLI build vs the daemon's — the mismatch loom up --restart fixes")
|
|
472
|
+
.action(async () => {
|
|
473
|
+
const { BUILD_REV } = await import("../daemon/server.js");
|
|
474
|
+
console.log(`cli ${BUILD_REV}`);
|
|
475
|
+
try {
|
|
476
|
+
const client = await ensureDaemon();
|
|
477
|
+
const v = await client.version();
|
|
478
|
+
const stale = v.rev !== BUILD_REV;
|
|
479
|
+
console.log(`daemon ${v.rev} ${pc.dim(`node ${v.node} · up ${Math.round(v.uptimeSec / 60)}m`)}`);
|
|
480
|
+
if (stale)
|
|
481
|
+
console.log(pc.yellow("daemon is running an older build — loom up --restart"));
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
console.log(pc.dim("daemon not running"));
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
program
|
|
488
|
+
.command("brain:forget <memoryId>")
|
|
489
|
+
.description("forget one memory unit (its history stays in the log)")
|
|
490
|
+
.action(async (memoryId) => {
|
|
491
|
+
const client = await ensureDaemon();
|
|
492
|
+
const project = await currentProject(client);
|
|
493
|
+
try {
|
|
494
|
+
await client.forgetMemory(project.id, memoryId);
|
|
495
|
+
console.log(`${pc.red("-")} ${memoryId} forgotten`);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
499
|
+
process.exitCode = 1;
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
program
|
|
503
|
+
.command("open")
|
|
504
|
+
.description("open this machine's Loom app in the browser")
|
|
505
|
+
.action(async () => {
|
|
506
|
+
const client = await ensureDaemon();
|
|
507
|
+
const url = `${client.baseUrl}/app`;
|
|
508
|
+
console.log(url);
|
|
509
|
+
const { spawn: sp } = await import("node:child_process");
|
|
510
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd.exe" : "xdg-open";
|
|
511
|
+
const child = sp(opener, process.platform === "win32" ? ["/d", "/c", "start", "", url] : [url], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
512
|
+
child.on("error", () => console.error(`Open ${url} in your browser.`));
|
|
513
|
+
child.unref();
|
|
514
|
+
});
|
|
515
|
+
program
|
|
516
|
+
.command("rename <name...>")
|
|
517
|
+
.description("rename this project (the id never moves, so nothing orphans)")
|
|
518
|
+
.action(async (words) => {
|
|
519
|
+
const client = await ensureDaemon();
|
|
520
|
+
const project = await currentProject(client);
|
|
521
|
+
const name = words.join(" ");
|
|
522
|
+
await client.renameProject(project.id, name);
|
|
523
|
+
console.log(`${pc.green("✓")} ${project.name} → ${pc.bold(name)}`);
|
|
524
|
+
});
|
|
525
|
+
program
|
|
526
|
+
.command("find <query...>")
|
|
527
|
+
.description("search this project's thread — messages, decisions, questions")
|
|
528
|
+
.action(async (words) => {
|
|
529
|
+
const client = await ensureDaemon();
|
|
530
|
+
const project = await currentProject(client);
|
|
531
|
+
const { hits } = await client.searchEvents(project.id, words.join(" "));
|
|
532
|
+
if (!hits.length)
|
|
533
|
+
return void console.log(pc.dim("nothing matches"));
|
|
534
|
+
for (const e of hits) {
|
|
535
|
+
const t = new Date(e.ts).toISOString().slice(0, 16).replace("T", " ");
|
|
536
|
+
const who = e.agentId ? pc.cyan(e.agentId) : pc.dim("you");
|
|
537
|
+
console.log(`${pc.dim(t)} ${who} ${String(e.payload.text ?? e.payload.question ?? "").slice(0, 110)}`);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
program
|
|
541
|
+
.command("specs")
|
|
542
|
+
.description("the project's Playwright specs, and whether one is running")
|
|
543
|
+
.action(async () => {
|
|
544
|
+
const client = await ensureDaemon();
|
|
545
|
+
const project = await currentProject(client);
|
|
546
|
+
const { specs, running } = await client.listSpecs(project.id);
|
|
547
|
+
if (!specs.length)
|
|
548
|
+
return void console.log(pc.dim("no *.spec.* / *.e2e.* files here"));
|
|
549
|
+
for (const s of specs) {
|
|
550
|
+
const live = running?.file === s.path ? pc.green(" ← running") : "";
|
|
551
|
+
console.log(`${s.path}${live}`);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
program
|
|
555
|
+
.command("specs:run <file>")
|
|
556
|
+
.description("run one Playwright spec through the daemon")
|
|
557
|
+
.option("--wait", "block until the verdict lands, and exit 1 on failure")
|
|
558
|
+
.action(async (file, opts) => {
|
|
559
|
+
const client = await ensureDaemon();
|
|
560
|
+
const project = await currentProject(client);
|
|
561
|
+
try {
|
|
562
|
+
const { run } = await client.runSpec(project.id, file);
|
|
563
|
+
if (!opts.wait) {
|
|
564
|
+
console.log(`${pc.cyan("▶")} ${run.file} ${pc.dim(`run ${run.id} — reporter streams to the app; result lands in the Console`)}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// Poll the runner state; the verdict is in the Console either way, but
|
|
568
|
+
// --wait exists so scripts and CI get an exit code, not a suggestion.
|
|
569
|
+
console.log(`${pc.cyan("▶")} ${run.file} ${pc.dim("waiting for the verdict…")}`);
|
|
570
|
+
for (;;) {
|
|
571
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
572
|
+
const { running } = await client.listSpecs(project.id);
|
|
573
|
+
if (!running || running.id !== run.id)
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
const { logs } = await client.logs({ level: undefined });
|
|
577
|
+
const verdict = [...logs].reverse().find((l) => l.scope === "specs" && l.message.includes(run.file));
|
|
578
|
+
if (verdict?.message.includes("passed")) {
|
|
579
|
+
console.log(`${pc.green("✓")} ${verdict.message}`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
console.error(pc.red(`✗ ${verdict?.message ?? "no verdict recorded"}`));
|
|
583
|
+
if (verdict?.detail)
|
|
584
|
+
console.error(pc.dim(verdict.detail.split("\n").slice(-12).join("\n")));
|
|
585
|
+
process.exitCode = 1;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
590
|
+
process.exitCode = 1;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
program
|
|
594
|
+
.command("stale")
|
|
595
|
+
.description("sessions that look hung — busy far longer than any plausible turn")
|
|
596
|
+
.action(async () => {
|
|
597
|
+
const client = await ensureDaemon();
|
|
598
|
+
const project = await currentProject(client);
|
|
599
|
+
const { stale } = await client.staleSessions(project.id);
|
|
600
|
+
if (!stale.length)
|
|
601
|
+
return void console.log(pc.dim("nothing looks hung"));
|
|
602
|
+
for (const s of stale) {
|
|
603
|
+
console.log(`${pc.red(s.agentId)} busy ${Math.round(s.busyMs / 60000)}m ${pc.dim(`— loom reap ${s.agentId}`)}`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
program
|
|
607
|
+
.command("reap <agentId>")
|
|
608
|
+
.description("put a hung session out of its misery and respawn it fresh")
|
|
609
|
+
.action(async (agentId) => {
|
|
610
|
+
const client = await ensureDaemon();
|
|
611
|
+
const project = await currentProject(client);
|
|
612
|
+
try {
|
|
613
|
+
await client.reapSession(project.id, agentId);
|
|
614
|
+
console.log(`${pc.green("✓")} ${agentId} respawned — baton released if it held it`);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
618
|
+
process.exitCode = 1;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
program
|
|
622
|
+
.command("brain:migrate [directory]")
|
|
623
|
+
.description("offline, backed-up JSONL → SQLite history import; conflicting IDs abort")
|
|
624
|
+
.action(async (directory) => {
|
|
625
|
+
if (await daemonRunning())
|
|
626
|
+
throw new Error("stop the Loom daemon before migrating history");
|
|
627
|
+
const dir = path.resolve(directory ?? process.cwd());
|
|
628
|
+
const result = await EventLog.migrateJsonl(path.join(dir, ".loom"));
|
|
629
|
+
console.log(`Imported ${result.imported}; already known ${result.known}. Original JSONL retained. Backup: ${result.backup}`);
|
|
630
|
+
});
|
|
631
|
+
program
|
|
632
|
+
.command("brain:search <query...>")
|
|
633
|
+
.description("retrieval, exactly as a briefing would see it — scores and all")
|
|
634
|
+
.option("--explain", "show the arithmetic per hit (bm25, entity, fuzzy, dense, recency)")
|
|
635
|
+
.action(async (words, opts) => {
|
|
636
|
+
const client = await ensureDaemon();
|
|
637
|
+
const project = await currentProject(client);
|
|
638
|
+
const { hits } = await client.searchBrain(project.id, words.join(" "), {
|
|
639
|
+
explain: Boolean(opts.explain),
|
|
640
|
+
});
|
|
641
|
+
if (!hits.length)
|
|
642
|
+
return void console.log(pc.dim("nothing retrieved — the briefing would carry no memories for this"));
|
|
643
|
+
for (const h of hits) {
|
|
644
|
+
console.log(`${pc.bold((h.score.toFixed(2)))} ${pc.magenta(h.memory.kind.padEnd(10))} ${h.memory.text.slice(0, 100)}`);
|
|
645
|
+
if (opts.explain && h.detail)
|
|
646
|
+
console.log(pc.dim(` ${JSON.stringify(h.detail)}`));
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
program
|
|
650
|
+
.command("brain:conflicts")
|
|
651
|
+
.description("units that likely contradict each other, for you to resolve")
|
|
652
|
+
.action(async () => {
|
|
653
|
+
const client = await ensureDaemon();
|
|
654
|
+
const project = await currentProject(client);
|
|
655
|
+
const { conflicts } = await client.brainConflicts(project.id);
|
|
656
|
+
if (!conflicts.length)
|
|
657
|
+
return void console.log(pc.dim("no likely contradictions"));
|
|
658
|
+
for (const c of conflicts) {
|
|
659
|
+
console.log(`${pc.yellow(c.signal)} ${pc.dim(`(${Math.round(c.similarity * 100)}% same topic)`)}`);
|
|
660
|
+
console.log(` A: ${c.a.text.slice(0, 90)}`);
|
|
661
|
+
console.log(` B: ${c.b.text.slice(0, 90)}`);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
program
|
|
665
|
+
.command("mcp:health")
|
|
666
|
+
.description("each MCP server's live state, from the background poll")
|
|
667
|
+
.action(async () => {
|
|
668
|
+
const client = await ensureDaemon();
|
|
669
|
+
const project = await currentProject(client);
|
|
670
|
+
const { health } = await client.mcpHealth(project.id);
|
|
671
|
+
const rows = Object.entries(health);
|
|
672
|
+
if (!rows.length)
|
|
673
|
+
return void console.log(pc.dim("no MCP servers configured (or none probed yet)"));
|
|
674
|
+
for (const [name, h] of rows) {
|
|
675
|
+
const age = Math.round((Date.now() - h.probedAt) / 1000);
|
|
676
|
+
console.log(`${name.padEnd(20)} ${h.up ? pc.green("up") : pc.red(`down ×${h.failures}`)} ${pc.dim(`${age}s ago`)}`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
/** "KEY=VAL" / "Header: value" pairs from repeated flags. */
|
|
680
|
+
function kv(list, sep) {
|
|
681
|
+
const out = {};
|
|
682
|
+
for (const item of list ?? []) {
|
|
683
|
+
const m = sep.exec(item);
|
|
684
|
+
if (!m || m.index === 0)
|
|
685
|
+
throw new Error(`"${item}" isn't a pair`);
|
|
686
|
+
out[item.slice(0, m.index).trim()] = item.slice(m.index + m[0].length).trim();
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
const collect = (v, prev = []) => [...prev, v];
|
|
691
|
+
program
|
|
692
|
+
.command("prompts [action] [args...]")
|
|
693
|
+
.description("saved prompts, shared with the app: list [query] | save <text> | show <id> | send <id> | pin <id> | unpin <id> | rename <id> <title> | rm <id>")
|
|
694
|
+
.option("-t, --title <title>", "save: a title (else the first words)")
|
|
695
|
+
.option("-a, --agent <id>", "send: address a specific agent")
|
|
696
|
+
.action(async (action, args, opts) => {
|
|
697
|
+
const client = await ensureDaemon();
|
|
698
|
+
const a = (action ?? "list").toLowerCase();
|
|
699
|
+
const rest = (args ?? []).join(" ").trim();
|
|
700
|
+
const find = async (ref) => {
|
|
701
|
+
const { saved } = await client.prompts();
|
|
702
|
+
const hit = saved.find((p) => p.id === ref) ?? saved.find((p) => p.id.startsWith(ref)) ??
|
|
703
|
+
saved.find((p) => p.title.toLowerCase() === ref.toLowerCase());
|
|
704
|
+
if (!hit)
|
|
705
|
+
throw new Error(`no saved prompt "${ref}" — loom prompts lists them`);
|
|
706
|
+
return hit;
|
|
707
|
+
};
|
|
708
|
+
if (a === "list" || a === "ls") {
|
|
709
|
+
const { saved } = await client.prompts(rest);
|
|
710
|
+
if (!saved.length)
|
|
711
|
+
return void console.log(pc.dim(rest ? `no saved prompt matches "${rest}"` : "no saved prompts yet — loom prompts save \"…\" (or ⌘⇧V in the app)"));
|
|
712
|
+
for (const p of saved) {
|
|
713
|
+
console.log(`${pc.dim(p.id.slice(0, 8))} ${p.pinned ? pc.yellow("★ ") : ""}${pc.bold(p.title)} ${pc.dim(`${p.uses}×`)}`);
|
|
714
|
+
console.log(` ${pc.dim(p.text.replace(/\s+/g, " ").slice(0, 90))}`);
|
|
715
|
+
}
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (a === "save" || a === "add") {
|
|
719
|
+
const text = rest || (process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"));
|
|
720
|
+
if (!text.trim())
|
|
721
|
+
throw new Error("what should it say? loom prompts save \"review this diff for…\" (or pipe it in)");
|
|
722
|
+
const { prompt } = await client.savePrompt({ text, ...(opts.title ? { title: opts.title } : {}) });
|
|
723
|
+
return void console.log(`${pc.green("✓")} saved ${pc.bold(prompt.title)} ${pc.dim(prompt.id.slice(0, 8))}`);
|
|
724
|
+
}
|
|
725
|
+
if (!["show", "cat", "send", "use", "pin", "unpin", "rename", "rm", "delete"].includes(a)) {
|
|
726
|
+
throw new Error(`unknown action "${a}" — list | save | show | send | pin | unpin | rename | rm`);
|
|
727
|
+
}
|
|
728
|
+
if (!rest)
|
|
729
|
+
throw new Error(`which prompt? loom prompts ${a} <id or title>`);
|
|
730
|
+
const [ref, ...more] = rest.split(/\s+/);
|
|
731
|
+
const p = await find(a === "rename" ? ref : rest);
|
|
732
|
+
if (a === "show" || a === "cat")
|
|
733
|
+
return void process.stdout.write(p.text + "\n");
|
|
734
|
+
if (a === "send" || a === "use") {
|
|
735
|
+
const project = await currentProject(client);
|
|
736
|
+
const now = new Date();
|
|
737
|
+
const text = p.text
|
|
738
|
+
.replace(/\{\{date\}\}/g, now.toLocaleDateString())
|
|
739
|
+
.replace(/\{\{time\}\}/g, now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))
|
|
740
|
+
.replace(/\{\{project\}\}/g, project.name)
|
|
741
|
+
.replace(/\{\{(?:selection|branch|chat|agent|last_reply|file)\}\}/g, "");
|
|
742
|
+
const result = await sendWithHandoffConfirm(client, project.id, text, opts.agent);
|
|
743
|
+
await client.updatePrompt(p.id, { used: true }).catch(() => { });
|
|
744
|
+
if (result)
|
|
745
|
+
console.log(pc.dim(`→ sent "${p.title}" to ${result.agentId} (loom log --follow to watch)`));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (a === "pin" || a === "unpin") {
|
|
749
|
+
await client.updatePrompt(p.id, { pinned: a === "pin" });
|
|
750
|
+
return void console.log(`${pc.green("✓")} ${a === "pin" ? "pinned" : "unpinned"} ${p.title}`);
|
|
751
|
+
}
|
|
752
|
+
if (a === "rename") {
|
|
753
|
+
const title = more.join(" ").trim();
|
|
754
|
+
if (!title)
|
|
755
|
+
throw new Error("loom prompts rename <id> <new title>");
|
|
756
|
+
await client.updatePrompt(p.id, { title });
|
|
757
|
+
return void console.log(`${pc.green("✓")} renamed to ${pc.bold(title)}`);
|
|
758
|
+
}
|
|
759
|
+
if (a === "rm" || a === "delete") {
|
|
760
|
+
await client.deletePrompt(p.id);
|
|
761
|
+
return void console.log(`${pc.green("✓")} removed ${p.title}`);
|
|
762
|
+
}
|
|
763
|
+
throw new Error(`unknown action "${a}" — list | save | show | send | pin | unpin | rename | rm`);
|
|
764
|
+
});
|
|
765
|
+
program
|
|
766
|
+
.command("skills [action] [args...]")
|
|
767
|
+
.description("skills for this project: list [query] | on <id> | off <id> | install <git-url|dir>[#name|#all] | rm <id>")
|
|
768
|
+
.option("--all", "list: every skill found on this machine, not just the ones on")
|
|
769
|
+
.option("--force", "install: replace a skill with the same name")
|
|
770
|
+
.action(async (action, args, opts) => {
|
|
771
|
+
const client = await ensureDaemon();
|
|
772
|
+
const project = await currentProject(client);
|
|
773
|
+
const a = (action ?? "list").toLowerCase();
|
|
774
|
+
const rest = (args ?? []).join(" ").trim();
|
|
775
|
+
if (a === "list" || a === "ls") {
|
|
776
|
+
const { skills } = await client.skillsCatalog(project.id);
|
|
777
|
+
const q = rest.toLowerCase();
|
|
778
|
+
const rows = skills.filter((k) => (opts.all || q || k.enabled) && (!q || `${k.id} ${k.name ?? ""} ${k.description ?? ""}`.toLowerCase().includes(q)));
|
|
779
|
+
const on = skills.filter((k) => k.enabled).length;
|
|
780
|
+
console.log(pc.dim(`${on} on · ${skills.length} found${opts.all || q ? "" : " — loom skills list --all shows them all"}`));
|
|
781
|
+
for (const k of rows) {
|
|
782
|
+
console.log(`${k.enabled ? pc.green("●") : pc.dim("○")} ${pc.bold(k.id)}${k.installed ? pc.dim(" (this project)") : k.origin ? pc.dim(` (${k.origin})`) : ""}`);
|
|
783
|
+
if (k.description)
|
|
784
|
+
console.log(` ${pc.dim(k.description.replace(/\s+/g, " ").slice(0, 96))}`);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!["on", "off", "install", "add", "rm", "remove"].includes(a))
|
|
789
|
+
throw new Error(`unknown action "${a}" — list | on | off | install | rm`);
|
|
790
|
+
if (!rest)
|
|
791
|
+
throw new Error(`loom skills ${a} <${a === "install" ? "git url or folder" : "skill id"}>`);
|
|
792
|
+
if (a === "on" || a === "off") {
|
|
793
|
+
await client.setSkill(project.id, rest, a === "on");
|
|
794
|
+
return void console.log(`${pc.green("✓")} ${rest} ${a === "on" ? "on" : "off"} for ${project.name}`);
|
|
795
|
+
}
|
|
796
|
+
if (a === "install" || a === "add") {
|
|
797
|
+
const local = !/^(https?:|git@|ssh:)/.test(rest) && fs.existsSync(rest.replace(/#.*$/, ""));
|
|
798
|
+
const { skill } = await client.installSkill(project.id, local
|
|
799
|
+
? { dir: path.resolve(rest), ...(opts.force ? { force: true } : {}) }
|
|
800
|
+
: { gitUrl: rest, ...(opts.force ? { force: true } : {}) });
|
|
801
|
+
const names = [skill.id, ...(skill.also ?? [])];
|
|
802
|
+
for (const n of names)
|
|
803
|
+
await client.setSkill(project.id, n, true).catch(() => { });
|
|
804
|
+
return void console.log(`${pc.green("✓")} installed and turned on ${names.map((n) => pc.bold(n)).join(", ")}`);
|
|
805
|
+
}
|
|
806
|
+
if (a === "rm" || a === "remove") {
|
|
807
|
+
await client.removeSkill(project.id, rest);
|
|
808
|
+
return void console.log(`${pc.green("✓")} removed ${rest} from ${project.name}`);
|
|
809
|
+
}
|
|
810
|
+
throw new Error(`unknown action "${a}" — list | on | off | install | rm`);
|
|
811
|
+
});
|
|
812
|
+
program
|
|
813
|
+
.command("mcp [action] [args...]")
|
|
814
|
+
.description("MCP servers for this project: list | add <name> (--url <url> | --command <cmd> [args…]) | on <name> | off <name> | rm <name>")
|
|
815
|
+
.option("--url <url>", "add: a remote server's endpoint")
|
|
816
|
+
.option("--command <cmd>", "add: a local server to run (args follow the name)")
|
|
817
|
+
.option("-H, --header <header>", "add: 'Name: value', repeatable", collect)
|
|
818
|
+
.option("-e, --env <pair>", "add: KEY=VALUE for a local server, repeatable", collect)
|
|
819
|
+
// the server's own flags (npx -y …) ride along as its args; put them after -- if they clash with ours
|
|
820
|
+
.allowUnknownOption()
|
|
821
|
+
.action(async (action, args, opts) => {
|
|
822
|
+
const client = await ensureDaemon();
|
|
823
|
+
const project = await currentProject(client);
|
|
824
|
+
const a = (action ?? "list").toLowerCase();
|
|
825
|
+
const [name, ...more] = args ?? [];
|
|
826
|
+
if (a === "list" || a === "ls") {
|
|
827
|
+
const { mcps } = await client.mcps(project.id);
|
|
828
|
+
const rows = mcps.filter((m) => m.url || m.command);
|
|
829
|
+
if (!rows.length)
|
|
830
|
+
return void console.log(pc.dim("no MCP servers yet — loom mcp add github --url https://api.githubcopilot.com/mcp/ (or Skills & MCP in the app)"));
|
|
831
|
+
for (const m of rows) {
|
|
832
|
+
const state = m.enabledForSession === false ? pc.dim("off") : m.command ? pc.cyan("local") : m.connected ? pc.green("up") : pc.red("unreachable");
|
|
833
|
+
console.log(`${m.name.padEnd(20)} ${state.padEnd(20)} ${pc.dim(m.url || [m.command, ...(m.args ?? [])].join(" "))}`);
|
|
834
|
+
}
|
|
835
|
+
console.log(pc.dim("Claude Code and Codex load these; other agents don't speak MCP."));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (!["add", "install", "on", "off", "rm", "remove"].includes(a))
|
|
839
|
+
throw new Error(`unknown action "${a}" — list | add | on | off | rm`);
|
|
840
|
+
if (!name)
|
|
841
|
+
throw new Error(`loom mcp ${a} <name>`);
|
|
842
|
+
if (a === "add" || a === "install") {
|
|
843
|
+
if (!opts.url && !opts.command)
|
|
844
|
+
throw new Error("add needs --url <endpoint> or --command <program>");
|
|
845
|
+
const { installed } = await client.installMcp(project.id, {
|
|
846
|
+
name,
|
|
847
|
+
...(opts.url ? { url: opts.url } : {}),
|
|
848
|
+
...(opts.command ? { command: opts.command, ...(more.length ? { args: more } : {}) } : {}),
|
|
849
|
+
...(opts.header?.length ? { headers: kv(opts.header, /:\s*/) } : {}),
|
|
850
|
+
...(opts.env?.length ? { env: kv(opts.env, /=/) } : {}),
|
|
851
|
+
});
|
|
852
|
+
const note = opts.command ? "it starts when an agent needs it" : installed?.connected ? "it answered" : "it didn't answer yet — check the URL, or it may need sign-in headers (-H)";
|
|
853
|
+
return void console.log(`${pc.green("✓")} added ${pc.bold(name)} — ${note}`);
|
|
854
|
+
}
|
|
855
|
+
if (a === "on" || a === "off") {
|
|
856
|
+
const { mcps } = await client.mcps(project.id, false);
|
|
857
|
+
const row = mcps.find((m) => m.name === name);
|
|
858
|
+
if (!row)
|
|
859
|
+
throw new Error(`no MCP server "${name}" — loom mcp lists them`);
|
|
860
|
+
const { connected: _c, ...keep } = row;
|
|
861
|
+
await client.patchMcp(project.id, { ...keep, enabledForSession: a === "on" });
|
|
862
|
+
return void console.log(`${pc.green("✓")} ${name} ${a}`);
|
|
863
|
+
}
|
|
864
|
+
if (a === "rm" || a === "remove") {
|
|
865
|
+
await client.removeMcp(project.id, name);
|
|
866
|
+
return void console.log(`${pc.green("✓")} removed ${name}`);
|
|
867
|
+
}
|
|
868
|
+
throw new Error(`unknown action "${a}" — list | add | on | off | rm`);
|
|
869
|
+
});
|
|
870
|
+
program
|
|
871
|
+
.command("snapshot [file]")
|
|
872
|
+
.description("checkpoint this project's brain + board + config to a file")
|
|
873
|
+
.action(async (file) => {
|
|
874
|
+
const client = await ensureDaemon();
|
|
875
|
+
const project = await currentProject(client);
|
|
876
|
+
const doc = await client.snapshot(project.id);
|
|
877
|
+
const out = file ?? `loom-snapshot-${project.name}.json`;
|
|
878
|
+
const fs = await import("node:fs");
|
|
879
|
+
fs.writeFileSync(out, JSON.stringify(doc, null, 2) + "\n");
|
|
880
|
+
const mems = (doc.brain?.memories ?? []).length;
|
|
881
|
+
const tasks = (doc.tasks ?? []).length;
|
|
882
|
+
console.log(`${pc.green("✓")} ${out} ${pc.dim(`· ${mems} memories · ${tasks} cards`)}`);
|
|
883
|
+
});
|
|
884
|
+
program
|
|
885
|
+
.command("restore <file>")
|
|
886
|
+
.description("bring a snapshot back — board and config replace, the brain merges")
|
|
887
|
+
.action(async (file) => {
|
|
888
|
+
const client = await ensureDaemon();
|
|
889
|
+
const project = await currentProject(client);
|
|
890
|
+
const fs = await import("node:fs");
|
|
891
|
+
let doc;
|
|
892
|
+
try {
|
|
893
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
console.error(pc.red(`could not read ${file}: ${err instanceof Error ? err.message : err}`));
|
|
897
|
+
process.exitCode = 1;
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
const out = await client.restore(project.id, doc);
|
|
902
|
+
console.log(`${pc.green("✓")} restored ${pc.dim(`· brain +${out.brain.added} (${out.brain.known} known) · ${out.tasks} cards`)}`);
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
906
|
+
process.exitCode = 1;
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
/**
|
|
910
|
+
* Rewind (#101). Deliberately a sibling of snapshot/restore above and not a
|
|
911
|
+
* flag on it: that pair moves the brain, the board and the config and leaves
|
|
912
|
+
* files to git. This one is the files, and leaves those alone. One command
|
|
913
|
+
* that did both would be a command nobody could predict.
|
|
914
|
+
*/
|
|
915
|
+
program
|
|
916
|
+
.command("rewind [checkpoint]")
|
|
917
|
+
.description("put the working tree back to a checkpoint (no argument lists them)")
|
|
918
|
+
.option("-y, --yes", "don't ask")
|
|
919
|
+
.action(async (checkpoint, opts) => {
|
|
920
|
+
const client = await ensureDaemon();
|
|
921
|
+
const project = await currentProject(client);
|
|
922
|
+
const { checkpoints } = await client.checkpoints(project.id);
|
|
923
|
+
if (!checkpoints.length) {
|
|
924
|
+
console.log(pc.dim("no checkpoints yet — one is taken before every turn, in a git repository"));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (!checkpoint) {
|
|
928
|
+
for (const c of checkpoints.slice(0, 20)) {
|
|
929
|
+
console.log(` ${pc.bold(c.id)} ${pc.dim(new Date(c.at).toLocaleString())} ${c.label}`);
|
|
930
|
+
}
|
|
931
|
+
console.log(pc.dim(`
|
|
932
|
+
loom rewind <id> · ${checkpoints.length} checkpoint${checkpoints.length === 1 ? "" : "s"}`));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const target = checkpoints.find((c) => c.id === checkpoint);
|
|
936
|
+
if (!target) {
|
|
937
|
+
console.error(pc.red(`no checkpoint "${checkpoint}" — run \`loom rewind\` to see them`));
|
|
938
|
+
process.exitCode = 1;
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (!opts.yes) {
|
|
942
|
+
console.log(`${pc.yellow("!")} this puts the files back to ${pc.bold(target.label)}`);
|
|
943
|
+
console.log(pc.dim(" anything written since is removed; commits, history and ignored files are untouched"));
|
|
944
|
+
console.log(pc.dim(" the rewind is itself saved, so it can be undone"));
|
|
945
|
+
const ok = await confirm("rewind?");
|
|
946
|
+
if (!ok)
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const out = await client.rewind(project.id, target.id);
|
|
951
|
+
console.log(`${pc.green("✓")} rewound ${pc.dim(`· ${out.changed.length} file${out.changed.length === 1 ? "" : "s"} · undo with \`loom rewind ${out.undo.id}\``)}`);
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
955
|
+
process.exitCode = 1;
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
program
|
|
959
|
+
.command("task <title...>")
|
|
960
|
+
.description("put a card on the board")
|
|
961
|
+
.option("--agent <id>", "who it's for")
|
|
962
|
+
.option("--column <col>", "working | needs-you | in-review | ready", "needs-you")
|
|
963
|
+
.option("--blocked-by <ids>", "comma-separated task ids this one waits on")
|
|
964
|
+
.action(async (words, opts) => {
|
|
965
|
+
const client = await ensureDaemon();
|
|
966
|
+
const project = await currentProject(client);
|
|
967
|
+
const { task } = await client.createTask(project.id, words.join(" "), {
|
|
968
|
+
column: opts.column,
|
|
969
|
+
...(opts.agent ? { agent: opts.agent } : {}),
|
|
970
|
+
...(opts.blockedBy ? { blockedBy: opts.blockedBy.split(",").map((s) => s.trim()) } : {}),
|
|
971
|
+
});
|
|
972
|
+
console.log(`${pc.green("+")} ${task.id} ${pc.dim(task.column)} ${task.title}`);
|
|
973
|
+
});
|
|
974
|
+
program
|
|
975
|
+
.command("tasks")
|
|
976
|
+
.description("the cards you wrote, by column")
|
|
977
|
+
.option("--json", "print as JSON")
|
|
978
|
+
.action(async (opts) => {
|
|
979
|
+
const client = await ensureDaemon();
|
|
980
|
+
const project = await currentProject(client);
|
|
981
|
+
const { tasks } = await client.listTasks(project.id);
|
|
982
|
+
if (opts.json)
|
|
983
|
+
return void console.log(JSON.stringify(tasks, null, 2));
|
|
984
|
+
if (!tasks.length)
|
|
985
|
+
return void console.log(pc.dim("no cards — loom task <title> makes one"));
|
|
986
|
+
for (const t of tasks) {
|
|
987
|
+
const blocked = t.blockedBy?.length ? pc.red(` ⛔${t.blockedBy.join(",")}`) : "";
|
|
988
|
+
console.log(`${t.id} ${t.column.padEnd(9)} ${t.agent ? pc.cyan(t.agent.padEnd(14)) : "".padEnd(14)} ${t.title}${blocked}`);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
program
|
|
992
|
+
.command("budgets")
|
|
993
|
+
.description("each agent's daily USD cap and today's real spend against it")
|
|
994
|
+
.action(async () => {
|
|
995
|
+
const client = await ensureDaemon();
|
|
996
|
+
const project = await currentProject(client);
|
|
997
|
+
const { status } = await client.budgets(project.id);
|
|
998
|
+
const rows = Object.entries(status);
|
|
999
|
+
if (!rows.length) {
|
|
1000
|
+
console.log(pc.dim("no caps set — loom budget <agentId> <usd> to add one"));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
for (const [agentId, s] of rows) {
|
|
1004
|
+
const bar = s.over ? pc.red("OVER — paused") : pc.dim(`${Math.round((s.spentTodayUsd / s.budgetUsd) * 100)}%`);
|
|
1005
|
+
console.log(`${agentId.padEnd(18)} $${s.spentTodayUsd.toFixed(2)} / $${s.budgetUsd.toFixed(2)} ${bar}`);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
program
|
|
1009
|
+
.command("budget <agentId> [usd]")
|
|
1010
|
+
.description("cap an agent's daily spend in USD — agentId 'all' caps every adapter (0 clears)")
|
|
1011
|
+
.action(async (agentId, usd) => {
|
|
1012
|
+
const client = await ensureDaemon();
|
|
1013
|
+
const project = await currentProject(client);
|
|
1014
|
+
const value = usd === undefined ? 0 : Number(usd);
|
|
1015
|
+
if (Number.isNaN(value) || value < 0) {
|
|
1016
|
+
console.error(pc.red(`"${usd}" is not a dollar amount`));
|
|
1017
|
+
process.exitCode = 1;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (agentId === "all") {
|
|
1021
|
+
const adapters = project.agents.filter((a) => a.tier === "adapter");
|
|
1022
|
+
for (const a of adapters)
|
|
1023
|
+
await client.setBudget(project.id, a.id, value);
|
|
1024
|
+
console.log(value > 0
|
|
1025
|
+
? `${pc.green("✓")} ${adapters.length} adapters capped at $${value.toFixed(2)}/day each`
|
|
1026
|
+
: `${pc.green("✓")} ${adapters.length} adapters uncapped`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
await client.setBudget(project.id, agentId, value);
|
|
1030
|
+
console.log(value > 0
|
|
1031
|
+
? `${pc.green("✓")} ${agentId} capped at $${value.toFixed(2)}/day — over the cap it pauses, hard`
|
|
1032
|
+
: `${pc.green("✓")} ${agentId} uncapped`);
|
|
1033
|
+
});
|
|
1034
|
+
program
|
|
1035
|
+
.command("watch")
|
|
1036
|
+
.description("tail this project's events live — turns, handoffs, memory, subtasks")
|
|
1037
|
+
.option("--all", "watch every project, prefixed with the project id")
|
|
1038
|
+
.option("--json", "one JSON event per line, for piping")
|
|
1039
|
+
.action(async (opts) => {
|
|
1040
|
+
const client = await ensureDaemon();
|
|
1041
|
+
const project = opts.all ? null : await currentProject(client);
|
|
1042
|
+
/**
|
|
1043
|
+
* One line per event, shaped for a human at a terminal. Everything else in
|
|
1044
|
+
* the log still flows — this names the kinds worth a glance and lets the
|
|
1045
|
+
* rest pass as a dim one-liner rather than pretending they don't exist.
|
|
1046
|
+
*/
|
|
1047
|
+
const line = (e) => {
|
|
1048
|
+
const t = new Date(e.ts).toTimeString().slice(0, 8);
|
|
1049
|
+
const who = e.agentId ? pc.cyan(e.agentId) : pc.dim("you");
|
|
1050
|
+
const p = e.payload;
|
|
1051
|
+
switch (e.kind) {
|
|
1052
|
+
case "message":
|
|
1053
|
+
return `${t} ${who} ${String(p.text ?? "").slice(0, 100)}`;
|
|
1054
|
+
case "handoff":
|
|
1055
|
+
return `${t} ${pc.yellow("baton")} ${String(p.from ?? "—")} → ${String(p.to ?? "—")}`;
|
|
1056
|
+
case "run_complete":
|
|
1057
|
+
return `${t} ${who} ${pc.green("turn done")}`;
|
|
1058
|
+
case "needs_input":
|
|
1059
|
+
return `${t} ${who} ${pc.red("needs you")}: ${String(p.question ?? "").slice(0, 80)}`;
|
|
1060
|
+
case "decision":
|
|
1061
|
+
return `${t} ${who} ${pc.magenta("decision")} ${String(p.text ?? "").slice(0, 90)}`;
|
|
1062
|
+
case "memory_add":
|
|
1063
|
+
return `${t} ${who} ${pc.magenta("learned")} ${String(p.memory?.text ?? "").slice(0, 90)}`;
|
|
1064
|
+
case "subtask_started":
|
|
1065
|
+
return `${t} ${who} ${pc.cyan("subtask")} for ${String(p.parent)}: ${String(p.task ?? "").slice(0, 70)}`;
|
|
1066
|
+
case "subtask_done":
|
|
1067
|
+
return `${t} ${who} ${pc.green("subtask done")}`;
|
|
1068
|
+
case "subtask_failed":
|
|
1069
|
+
return `${t} ${who} ${pc.red("subtask failed")}: ${String(p.message ?? "").slice(0, 80)}`;
|
|
1070
|
+
case "error":
|
|
1071
|
+
return `${t} ${who} ${pc.red("error")} ${String(p.message ?? "").slice(0, 90)}`;
|
|
1072
|
+
case "route_started":
|
|
1073
|
+
case "route_step":
|
|
1074
|
+
case "route_completed":
|
|
1075
|
+
case "route_failed":
|
|
1076
|
+
return `${t} ${pc.yellow(e.kind.replace("route_", "route "))} ${String(p.route ?? p.name ?? "")}`;
|
|
1077
|
+
default:
|
|
1078
|
+
return pc.dim(`${t} ${e.kind}`);
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
console.log(pc.dim(project
|
|
1082
|
+
? `watching ${project.name} — ^C to stop`
|
|
1083
|
+
: "watching every project — ^C to stop"));
|
|
1084
|
+
const close = client.subscribe((pid, e) => {
|
|
1085
|
+
if (opts.json) {
|
|
1086
|
+
console.log(JSON.stringify(opts.all ? { project: pid, ...e } : e));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const prefix = opts.all ? pc.dim(`[${pid.slice(0, 8)}] `) : "";
|
|
1090
|
+
console.log(prefix + line(e));
|
|
1091
|
+
}, project?.id);
|
|
1092
|
+
// Keep the process alive until ^C; close the socket on the way out so the
|
|
1093
|
+
// daemon isn't left holding a dead subscriber.
|
|
1094
|
+
await new Promise((resolve) => {
|
|
1095
|
+
process.on("SIGINT", () => {
|
|
1096
|
+
close();
|
|
1097
|
+
console.log();
|
|
1098
|
+
resolve();
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
program
|
|
1103
|
+
.command("brain:export [file]")
|
|
1104
|
+
.description("write this project's brain to a file (or stdout) so it can travel")
|
|
1105
|
+
.action(async (file) => {
|
|
1106
|
+
const client = await ensureDaemon();
|
|
1107
|
+
const project = await currentProject(client);
|
|
1108
|
+
const doc = await client.exportBrain(project.id);
|
|
1109
|
+
const json = JSON.stringify(doc, null, 2);
|
|
1110
|
+
if (file) {
|
|
1111
|
+
const fs = await import("node:fs");
|
|
1112
|
+
fs.writeFileSync(file, json + "\n");
|
|
1113
|
+
const n = doc.memories?.length ?? 0;
|
|
1114
|
+
console.log(`${pc.green("✓")} ${n} memor${n === 1 ? "y" : "ies"} → ${file}`);
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
console.log(json);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
program
|
|
1121
|
+
.command("brain:import <file>")
|
|
1122
|
+
.description("bring an exported brain into this project (dedupes what's already known)")
|
|
1123
|
+
.action(async (file) => {
|
|
1124
|
+
const client = await ensureDaemon();
|
|
1125
|
+
const project = await currentProject(client);
|
|
1126
|
+
const fs = await import("node:fs");
|
|
1127
|
+
let doc;
|
|
1128
|
+
try {
|
|
1129
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
console.error(pc.red(`could not read ${file}: ${err instanceof Error ? err.message : err}`));
|
|
1133
|
+
process.exitCode = 1;
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
try {
|
|
1137
|
+
const { added, known } = await client.importBrain(project.id, doc);
|
|
1138
|
+
console.log(`${pc.green("✓")} ${added} learned${known ? pc.dim(` · ${known} already known`) : ""}`);
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1142
|
+
process.exitCode = 1;
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
program
|
|
1146
|
+
.command("spawn <task>")
|
|
1147
|
+
.description("fan a subtask out to a child agent — the parent keeps the baton")
|
|
1148
|
+
.option("--agent <id>", "which agent runs the subtask")
|
|
1149
|
+
.option("--parent <id>", "the agent asking (default: whoever holds the baton)")
|
|
1150
|
+
.action(async (task, opts) => {
|
|
1151
|
+
const client = await ensureDaemon();
|
|
1152
|
+
const project = await currentProject(client);
|
|
1153
|
+
const parent = opts.parent ?? project.holder ?? project.agents[0]?.id;
|
|
1154
|
+
if (!parent) {
|
|
1155
|
+
console.error(pc.red("this project has no agents to parent a subtask"));
|
|
1156
|
+
process.exitCode = 1;
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
// Default the child to an agent that isn't the parent — the whole point is
|
|
1160
|
+
// another pair of hands, so borrowing the parent's own would just be a turn.
|
|
1161
|
+
const child = opts.agent ??
|
|
1162
|
+
project.agents.find((a) => a.id !== parent && a.tier === "adapter" && a.enabled !== false)?.id;
|
|
1163
|
+
if (!child) {
|
|
1164
|
+
console.error(pc.red("no other adapter available to run the subtask — add one, or pass --agent"));
|
|
1165
|
+
process.exitCode = 1;
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
const out = await client.spawnSubtask(project.id, parent, child, task);
|
|
1170
|
+
console.log(`${pc.cyan("↳")} ${pc.bold(out.agentId)} ${pc.dim(`subtask ${out.id}`)}\n` +
|
|
1171
|
+
pc.dim(` ${parent} keeps the baton; the result lands in the thread under its turn`));
|
|
1172
|
+
}
|
|
1173
|
+
catch (err) {
|
|
1174
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1175
|
+
process.exitCode = 1;
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
// ── orchestra: one orchestrator, many parallel workers (core/orchestra.ts) ──
|
|
1179
|
+
const RUN_DONE = new Set(["completed", "failed", "aborted", "waiting_human"]);
|
|
1180
|
+
const TASK_COLOR = {
|
|
1181
|
+
done: pc.green,
|
|
1182
|
+
running: pc.cyan,
|
|
1183
|
+
pending: pc.dim,
|
|
1184
|
+
failed: pc.red,
|
|
1185
|
+
conflict: pc.yellow,
|
|
1186
|
+
needs_input: pc.yellow,
|
|
1187
|
+
cancelled: pc.dim,
|
|
1188
|
+
};
|
|
1189
|
+
function printRun(run) {
|
|
1190
|
+
const done = run.tasks.filter((t) => t.status === "done").length;
|
|
1191
|
+
console.log(run.race
|
|
1192
|
+
? `${pc.magenta("🏁")} ${pc.bold(run.id)} ${pc.dim(run.status)} race · ${done}/${run.tasks.length} finished · ${fmtUsd(run.costUsd)}`
|
|
1193
|
+
: `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(run.status)} ${done}/${run.tasks.length} tasks · ` +
|
|
1194
|
+
`round ${run.round}/${run.maxRounds} · ${fmtUsd(run.costUsd)} · ${pc.dim(run.branch)}`);
|
|
1195
|
+
console.log(` ${pc.dim("goal")} ${run.goal}`);
|
|
1196
|
+
if (run.race)
|
|
1197
|
+
console.log(` ${pc.dim("entrants")} ${run.workers.join(", ")}`);
|
|
1198
|
+
else
|
|
1199
|
+
console.log(` ${pc.dim("orchestrator")} ${run.orchestrator.agent} ${pc.dim("workers")} ${run.workers.join(", ")}`);
|
|
1200
|
+
for (const t of run.tasks) {
|
|
1201
|
+
const color = TASK_COLOR[t.status] ?? ((x) => x);
|
|
1202
|
+
const deps = t.dependsOn.length ? pc.dim(` after ${t.dependsOn.join(",")}`) : "";
|
|
1203
|
+
const files = t.files?.length ? pc.dim(` · ${t.files.length} file${t.files.length === 1 ? "" : "s"}`) : "";
|
|
1204
|
+
console.log(` ${color(t.status.padEnd(11))} ${pc.bold(t.id)} ${t.title} ${pc.dim(`→ ${t.agent}`)}${deps}${files}`);
|
|
1205
|
+
if (t.hold)
|
|
1206
|
+
console.log(` ${pc.yellow(`held (${t.hold.kind}):`)} ${t.hold.reason.slice(0, 200)}`);
|
|
1207
|
+
if (t.error)
|
|
1208
|
+
console.log(` ${pc.red(t.error.split("\n")[0].slice(0, 160))}`);
|
|
1209
|
+
}
|
|
1210
|
+
if (run.question)
|
|
1211
|
+
console.log(` ${pc.yellow("asks:")} ${run.question} ${pc.dim(`(loom orchestra:reply ${run.id} "…")`)}`);
|
|
1212
|
+
if (run.summary)
|
|
1213
|
+
console.log(` ${pc.green("summary:")} ${run.summary}`);
|
|
1214
|
+
if (run.error)
|
|
1215
|
+
console.log(` ${pc.red("error:")} ${run.error}`);
|
|
1216
|
+
if (run.status === "completed" && !run.applied) {
|
|
1217
|
+
if (run.race) {
|
|
1218
|
+
const ok = run.tasks.filter((t) => t.status === "done").map((t) => t.id);
|
|
1219
|
+
if (ok.length)
|
|
1220
|
+
console.log(pc.dim(` keep one: loom orchestra:apply ${run.id} --task ${ok.join(" | ")}`));
|
|
1221
|
+
}
|
|
1222
|
+
else
|
|
1223
|
+
console.log(pc.dim(` apply it: loom orchestra:apply ${run.id}`));
|
|
1224
|
+
}
|
|
1225
|
+
if (run.race && run.applied?.task)
|
|
1226
|
+
console.log(` ${pc.green("kept:")} ${run.applied.task} → ${run.applied.into}`);
|
|
1227
|
+
}
|
|
1228
|
+
async function watchRun(client, projectId, runId) {
|
|
1229
|
+
let last = "";
|
|
1230
|
+
for (;;) {
|
|
1231
|
+
const { run } = await client.orchestraRun(projectId, runId);
|
|
1232
|
+
const line = `${run.status}|${run.tasks.map((t) => `${t.id}:${t.status}`).join(",")}`;
|
|
1233
|
+
if (line !== last) {
|
|
1234
|
+
last = line;
|
|
1235
|
+
const stamp = new Date().toLocaleTimeString();
|
|
1236
|
+
console.log(pc.dim(stamp) + " " + pc.bold(run.status) + " " +
|
|
1237
|
+
run.tasks.map((t) => (TASK_COLOR[t.status] ?? ((x) => x))(`${t.id}[${t.agent}]`)).join(" "));
|
|
1238
|
+
}
|
|
1239
|
+
if (RUN_DONE.has(run.status)) {
|
|
1240
|
+
console.log("");
|
|
1241
|
+
printRun(run);
|
|
1242
|
+
if (run.status === "failed" || run.status === "aborted")
|
|
1243
|
+
process.exitCode = 1;
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
program
|
|
1250
|
+
.command("orchestrate <goal>")
|
|
1251
|
+
.description("one orchestrator plans the goal; many worker agents build it in parallel, each in its own worktree")
|
|
1252
|
+
.option("-o, --orchestrator <agent>", "who plans and reviews (default: claude-code if present)")
|
|
1253
|
+
.option("-w, --workers <agents>", "comma-separated worker agents (default: every agent in the project)")
|
|
1254
|
+
.option("-p, --parallel <n>", "tasks running at once (1-12, default 4)")
|
|
1255
|
+
.option("--rounds <n>", "orchestrator review rounds before giving up (default 10)")
|
|
1256
|
+
.option("--plan", "plan mode: write the plan as markdown specs under plans/<run>/ that any agent can pick up")
|
|
1257
|
+
.option("--max-usd <n>", "stop this goal when it has spent this much, and say so")
|
|
1258
|
+
.option("--race", "race: every worker takes the whole goal in its own worktree; pick one with orchestra:apply <run> --task race-<agent>")
|
|
1259
|
+
.option("--no-watch", "start it and return instead of following it to the end")
|
|
1260
|
+
.action(async (goal, opts) => {
|
|
1261
|
+
const client = await ensureDaemon();
|
|
1262
|
+
const project = await currentProject(client);
|
|
1263
|
+
try {
|
|
1264
|
+
const { run } = await client.startOrchestra(project.id, {
|
|
1265
|
+
goal,
|
|
1266
|
+
...(opts.orchestrator ? { orchestrator: opts.orchestrator } : {}),
|
|
1267
|
+
...(opts.workers ? { workers: opts.workers.split(",").map((w) => w.trim()).filter(Boolean) } : {}),
|
|
1268
|
+
...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}),
|
|
1269
|
+
...(opts.rounds ? { maxRounds: Number(opts.rounds) } : {}),
|
|
1270
|
+
...(opts.plan ? { plan: true } : {}),
|
|
1271
|
+
...(opts.maxUsd ? { maxUsd: Number(opts.maxUsd) } : {}),
|
|
1272
|
+
...(opts.race ? { race: true } : {}),
|
|
1273
|
+
});
|
|
1274
|
+
console.log(run.race
|
|
1275
|
+
? `${pc.magenta("🏁")} ${pc.bold(run.id)} started — ${run.workers.join(", ")} racing on the same goal`
|
|
1276
|
+
: `${pc.magenta("🎼")} ${pc.bold(run.id)} started — ${pc.bold(run.orchestrator.agent)} orchestrating ` +
|
|
1277
|
+
`${run.workers.join(", ")} (${run.maxParallel} in parallel) on ${pc.dim(run.branch)}`);
|
|
1278
|
+
if (opts.watch)
|
|
1279
|
+
await watchRun(client, project.id, run.id);
|
|
1280
|
+
else
|
|
1281
|
+
console.log(pc.dim(` follow it: loom orchestra ${run.id} --watch`));
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1285
|
+
process.exitCode = 1;
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
program
|
|
1289
|
+
.command("orchestra [runId]")
|
|
1290
|
+
.description("orchestra runs in this project — or one run in detail")
|
|
1291
|
+
.option("--watch", "follow the run until it finishes")
|
|
1292
|
+
.action(async (runId, opts) => {
|
|
1293
|
+
const client = await ensureDaemon();
|
|
1294
|
+
const project = await currentProject(client);
|
|
1295
|
+
try {
|
|
1296
|
+
if (runId) {
|
|
1297
|
+
if (opts.watch)
|
|
1298
|
+
return void (await watchRun(client, project.id, runId));
|
|
1299
|
+
return void printRun((await client.orchestraRun(project.id, runId)).run);
|
|
1300
|
+
}
|
|
1301
|
+
const { runs } = await client.orchestraRuns(project.id);
|
|
1302
|
+
if (!runs.length)
|
|
1303
|
+
return void console.log(pc.dim('no orchestra runs yet — loom orchestrate "<goal>"'));
|
|
1304
|
+
for (const r of runs.slice(0, 15)) {
|
|
1305
|
+
const done = r.tasks.filter((t) => t.status === "done").length;
|
|
1306
|
+
console.log(`${pc.bold(r.id)} ${r.status.padEnd(13)} ${String(done).padStart(2)}/${String(r.tasks.length).padEnd(2)} ` +
|
|
1307
|
+
`${pc.dim(new Date(r.createdAt).toLocaleString())} ${r.goal.slice(0, 70)}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1312
|
+
process.exitCode = 1;
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
for (const action of ["abort", "apply", "cleanup", "resume"]) {
|
|
1316
|
+
const blurb = {
|
|
1317
|
+
abort: "stop a run — every worker is interrupted",
|
|
1318
|
+
resume: "carry on a run Loom stopped by restarting — its interrupted tasks pick up where they left off",
|
|
1319
|
+
apply: "merge a run's integration branch into your current branch (a race: --task <entrant>)",
|
|
1320
|
+
cleanup: "remove a finished run's worktrees (its branch stays)",
|
|
1321
|
+
}[action];
|
|
1322
|
+
program
|
|
1323
|
+
.command(`orchestra:${action} <runId>`)
|
|
1324
|
+
.description(blurb)
|
|
1325
|
+
.option("--task <id>", "apply: which race entrant to keep (race-<agent>)")
|
|
1326
|
+
.action(async (runId, opts) => {
|
|
1327
|
+
const client = await ensureDaemon();
|
|
1328
|
+
const project = await currentProject(client);
|
|
1329
|
+
try {
|
|
1330
|
+
const out = await client.orchestraAction(project.id, runId, action, action === "apply" && opts.task ? { task: opts.task } : undefined);
|
|
1331
|
+
if (action === "apply")
|
|
1332
|
+
console.log(`${pc.green("✓")} merged ${pc.bold(String(out.merged))} into ${pc.bold(String(out.into))}`);
|
|
1333
|
+
else
|
|
1334
|
+
console.log(`${pc.green("✓")} ${action} ${runId}`);
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1338
|
+
process.exitCode = 1;
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
program
|
|
1343
|
+
.command("orchestra:reply <runId> <text>")
|
|
1344
|
+
.description("answer the orchestrator's question, or steer a running orchestra")
|
|
1345
|
+
.action(async (runId, text) => {
|
|
1346
|
+
const client = await ensureDaemon();
|
|
1347
|
+
const project = await currentProject(client);
|
|
1348
|
+
try {
|
|
1349
|
+
await client.orchestraAction(project.id, runId, "reply", { text });
|
|
1350
|
+
console.log(`${pc.green("✓")} sent to the orchestrator`);
|
|
1351
|
+
}
|
|
1352
|
+
catch (err) {
|
|
1353
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1354
|
+
process.exitCode = 1;
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
// ── Loom Teams (docs/teams-architecture.md, daemon/team.ts) ──
|
|
1358
|
+
program
|
|
1359
|
+
.command("hub")
|
|
1360
|
+
.description("run a self-hosted Team Hub for your team (no Supabase needed)")
|
|
1361
|
+
.option("--port <n>", "port", "7430")
|
|
1362
|
+
.option("--host <ip>", "interface to bind (use your LAN/tailnet IP so teammates can reach it)", "127.0.0.1")
|
|
1363
|
+
.option("--secret <s>", "join secret every member must present (or LOOM_HUB_SECRET)")
|
|
1364
|
+
.action(async (opts) => {
|
|
1365
|
+
const secret = opts.secret || process.env.LOOM_HUB_SECRET;
|
|
1366
|
+
const loopback = opts.host === "127.0.0.1" || opts.host === "localhost";
|
|
1367
|
+
if (!loopback && !secret) {
|
|
1368
|
+
console.error(pc.red("a hub reachable by others needs a join secret: --secret <s> (or LOOM_HUB_SECRET)"));
|
|
1369
|
+
process.exitCode = 1;
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const { startHubServer } = await import("../hub/server.js");
|
|
1373
|
+
const hub = await startHubServer({ port: Number(opts.port), host: opts.host, ...(secret ? { secret } : {}) });
|
|
1374
|
+
console.log(`${pc.green("●")} Loom Team Hub on ${pc.bold(hub.url)}`);
|
|
1375
|
+
console.log(pc.dim(` members sign in with: loom team signin ${hub.url}${secret ? " --secret <secret>" : ""}`));
|
|
1376
|
+
console.log(pc.dim(" state is in memory: members' daemons re-join on their own after a restart"));
|
|
1377
|
+
const stop = async () => {
|
|
1378
|
+
await hub.close();
|
|
1379
|
+
process.exit(0);
|
|
1380
|
+
};
|
|
1381
|
+
process.on("SIGINT", stop);
|
|
1382
|
+
process.on("SIGTERM", stop);
|
|
1383
|
+
});
|
|
1384
|
+
function printTeam(t) {
|
|
1385
|
+
if (!t.signedIn) {
|
|
1386
|
+
console.log(pc.dim("not on a team hub — `loom team signin <hub-url>`, or join with an invite link"));
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
console.log(`${pc.bold(String(t.github))} on ${pc.dim(String(t.hub))}`);
|
|
1390
|
+
const teams = t.teams ?? [];
|
|
1391
|
+
if (!teams.length)
|
|
1392
|
+
console.log(pc.dim(" no teams yet — `loom team create <name>` or `loom team join <link>`"));
|
|
1393
|
+
for (const tm of teams) {
|
|
1394
|
+
const members = tm.members ?? [];
|
|
1395
|
+
console.log(`\n${pc.magenta("◆")} ${pc.bold(String(tm.name))} ${pc.dim(`(${String(tm.role)} · key v${String(tm.keyVersion)})`)}`);
|
|
1396
|
+
console.log(` members ${members.map((m) => (m.role === "owner" ? `${m.github}★` : m.github)).join(", ")}`);
|
|
1397
|
+
console.log(` repos ${(tm.repos ?? []).join(", ") || pc.dim("none shared yet — `loom team share` in a project")}`);
|
|
1398
|
+
const presence = tm.presence ?? [];
|
|
1399
|
+
if (presence.length)
|
|
1400
|
+
console.log(" live");
|
|
1401
|
+
for (const p of presence) {
|
|
1402
|
+
const intent = (p.intent ?? {});
|
|
1403
|
+
const what = intent.task ? `${intent.task} ${pc.dim(`(${intent.goal ?? ""})`)}` : intent.goal ?? intent.thread ?? "";
|
|
1404
|
+
const touches = p.touches ?? [];
|
|
1405
|
+
console.log(` ${pc.cyan(String(p.github).padEnd(12))} ${String(p.agent).padEnd(22)} ${String(p.state).padEnd(10)} ${what}` +
|
|
1406
|
+
(touches.length ? pc.dim(` ${touches.slice(0, 3).join(" ")}`) : ""));
|
|
1407
|
+
}
|
|
1408
|
+
const leases = (tm.leases ?? []).filter((l) => !l.stale);
|
|
1409
|
+
if (leases.length)
|
|
1410
|
+
console.log(" leases");
|
|
1411
|
+
for (const l of leases) {
|
|
1412
|
+
const intent = (l.intent ?? {});
|
|
1413
|
+
const globs = (l.globs ?? []).slice(0, 3).join(" ");
|
|
1414
|
+
console.log(` ${pc.cyan(String(l.github).padEnd(12))} ${String(l.runId)}/${String(l.taskId)} ${pc.dim(String(l.state).padEnd(8))} ${globs}` +
|
|
1415
|
+
(intent.task ? pc.dim(` ${intent.task}`) : ""));
|
|
1416
|
+
}
|
|
1417
|
+
const feed = (tm.feed ?? []).slice(-8);
|
|
1418
|
+
if (feed.length)
|
|
1419
|
+
console.log(" recent");
|
|
1420
|
+
for (const e of feed) {
|
|
1421
|
+
const c = (e.content ?? {});
|
|
1422
|
+
const meta = (e.meta ?? {});
|
|
1423
|
+
const label = c.goal ?? c.title ?? (meta.number ? `#${String(meta.number)}` : "");
|
|
1424
|
+
const detail = label || String(meta.repo ?? (meta.version ? `v${String(meta.version)}` : ""));
|
|
1425
|
+
// membership events are posted by the hub itself; the person is in meta
|
|
1426
|
+
const who = String(e.github ?? meta.github ?? (String(e.type).startsWith("pr_") || String(e.type).startsWith("check_") ? "github" : "team"));
|
|
1427
|
+
console.log(` ${pc.dim(new Date(Number(e.ts)).toLocaleTimeString())} ${who.padEnd(10)} ${String(e.type).padEnd(14)} ${detail}`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
program
|
|
1432
|
+
.command("invite")
|
|
1433
|
+
.description("invite a teammate to this project: one link sets up the team, the repo, their agents and your crews")
|
|
1434
|
+
.option("--team <id>", "which team, when you're in several")
|
|
1435
|
+
.option("--no-grant", "don't give them push access to the repo when they join")
|
|
1436
|
+
.option("--paste", "hosted sign-in without a local browser")
|
|
1437
|
+
.action(async (opts) => {
|
|
1438
|
+
const client = await ensureDaemon();
|
|
1439
|
+
const project = await currentProject(client);
|
|
1440
|
+
const invite = () => client.inviteTeammate(project.id, { ...(opts.team ? { teamId: opts.team } : {}), ...(opts.grant === false ? { grant: false } : {}) });
|
|
1441
|
+
let out;
|
|
1442
|
+
try {
|
|
1443
|
+
out = await invite();
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
if (!/sign in to a team hub/.test(err.message))
|
|
1447
|
+
throw err;
|
|
1448
|
+
// not on a hub yet: the hosted one, with GitHub, then try again
|
|
1449
|
+
const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
|
|
1450
|
+
const supabaseUrl = hostedSupabaseUrl("hosted");
|
|
1451
|
+
const session = await hostedSession(supabaseUrl, publishableKeyFor(supabaseUrl), Boolean(opts.paste));
|
|
1452
|
+
await client.teamAction("signin", { hub: hostedHubUrl(supabaseUrl), token: session.refreshToken });
|
|
1453
|
+
out = await invite();
|
|
1454
|
+
}
|
|
1455
|
+
console.log(`${pc.green("✓")} ${pc.bold(out.repo)} on ${pc.bold(out.team.name)} — send this to your teammate:\n`);
|
|
1456
|
+
console.log(` ${pc.cyan(out.link)}\n`);
|
|
1457
|
+
qrcode.generate(out.link, { small: true });
|
|
1458
|
+
console.log(pc.dim(` one click: GitHub sign-in, the team, the repo, their agents${out.grant ? ", push access" : ""} and your crews`));
|
|
1459
|
+
if (out.grantNote)
|
|
1460
|
+
console.log(pc.yellow(` ${out.grantNote}`));
|
|
1461
|
+
console.log(pc.dim(` works once, until ${new Date(out.expiresAt).toLocaleString()} · it carries the team key — send it like a password`));
|
|
1462
|
+
console.log(pc.dim(" no Loom yet on their side? the link shows the one command that installs it and joins"));
|
|
1463
|
+
});
|
|
1464
|
+
program
|
|
1465
|
+
.command("join <link>")
|
|
1466
|
+
.description("join a team from an invite link: sign in, clone the repo, open it with your agents, set up the crews")
|
|
1467
|
+
.option("--into <dir>", "clone here (default ~/loom-projects/<repo>)")
|
|
1468
|
+
.option("--github <login>", "your GitHub login, for a self-hosted hub")
|
|
1469
|
+
.option("--secret <s>", "a self-hosted hub's join secret")
|
|
1470
|
+
.option("--paste", "hosted sign-in without a local browser")
|
|
1471
|
+
.option("--no-open", "don't open the project in the browser after")
|
|
1472
|
+
.action(async (link, opts) => {
|
|
1473
|
+
const client = await ensureDaemon();
|
|
1474
|
+
const p = await client.previewInvite(link);
|
|
1475
|
+
console.log(`${pc.bold(p.team ?? "a Loom team")}${p.repo ? ` · ${p.repo}` : ""}${p.from ? pc.dim(` — from @${p.from}`) : ""}`);
|
|
1476
|
+
let token;
|
|
1477
|
+
if (!p.signedIn) {
|
|
1478
|
+
const { hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
|
|
1479
|
+
const sb = hostedSupabaseUrl(p.hub);
|
|
1480
|
+
if (sb !== null)
|
|
1481
|
+
token = (await hostedSession(sb, publishableKeyFor(sb), Boolean(opts.paste))).refreshToken;
|
|
1482
|
+
}
|
|
1483
|
+
let { job } = await client.startJoin({
|
|
1484
|
+
link,
|
|
1485
|
+
dir: process.cwd(),
|
|
1486
|
+
...(opts.into ? { into: opts.into } : {}),
|
|
1487
|
+
...(opts.github ? { github: opts.github } : {}),
|
|
1488
|
+
...(opts.secret ? { secret: opts.secret } : {}),
|
|
1489
|
+
...(token ? { token } : {}),
|
|
1490
|
+
});
|
|
1491
|
+
const said = new Map();
|
|
1492
|
+
const mark = { done: pc.green("✓"), skipped: pc.dim("·"), failed: pc.red("✗"), waiting: pc.yellow("…"), running: pc.cyan("→") };
|
|
1493
|
+
for (;;) {
|
|
1494
|
+
for (const s of job.steps) {
|
|
1495
|
+
const line = `${s.state}|${s.detail ?? ""}`;
|
|
1496
|
+
if (s.state === "pending" || said.get(s.id) === line)
|
|
1497
|
+
continue;
|
|
1498
|
+
said.set(s.id, line);
|
|
1499
|
+
if (s.state === "running" && !s.detail)
|
|
1500
|
+
continue;
|
|
1501
|
+
console.log(` ${mark[s.state] ?? " "} ${s.label}${s.detail ? pc.dim(` — ${s.detail}`) : ""}`);
|
|
1502
|
+
}
|
|
1503
|
+
if (job.state !== "running")
|
|
1504
|
+
break;
|
|
1505
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1506
|
+
job = (await client.joinStatus(job.id)).job;
|
|
1507
|
+
}
|
|
1508
|
+
if (job.state === "failed") {
|
|
1509
|
+
console.error(pc.red(`couldn't finish: ${job.error}`));
|
|
1510
|
+
console.error(pc.dim(" run the same command again once it's fixed — finished steps are skipped"));
|
|
1511
|
+
process.exitCode = 1;
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
console.log(`\n${pc.green("✓")} you're in${job.project ? ` — ${pc.bold(job.project.name)} at ${job.project.dir}` : ""}`);
|
|
1515
|
+
if (job.project) {
|
|
1516
|
+
console.log(pc.dim(` cd ${job.project.dir} && loom`));
|
|
1517
|
+
if (opts.open !== false) {
|
|
1518
|
+
const url = `${client.baseUrl}/app#p/${job.project.id}`;
|
|
1519
|
+
const { spawn: sp } = await import("node:child_process");
|
|
1520
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1521
|
+
sp(opener, [url], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
program
|
|
1526
|
+
.command("proof")
|
|
1527
|
+
.description("is \"done\" true? check the newest finished turn: its claims, what it ran, and the tests again in a clean checkout")
|
|
1528
|
+
.option("--chat <id>", "which chat (default: main)")
|
|
1529
|
+
.option("--test <command>", "test with this instead of the project's own command")
|
|
1530
|
+
.action(async (opts) => {
|
|
1531
|
+
const client = await ensureDaemon();
|
|
1532
|
+
const project = await currentProject(client);
|
|
1533
|
+
process.stdout.write(pc.dim("checking… (it runs the tests again, so it takes as long as they do)\n"));
|
|
1534
|
+
const r = await client.proof(project.id, { ...(opts.chat ? { chat: opts.chat } : {}), ...(opts.test ? { testCommand: opts.test } : {}) });
|
|
1535
|
+
const mark = r.verdict === "verified" ? pc.green("✓ verified") : r.verdict === "contradicted" ? pc.red("✗ didn't hold") : pc.yellow("? unverified");
|
|
1536
|
+
console.log(`${mark} ${r.headline}`);
|
|
1537
|
+
for (const c of r.claims ?? [])
|
|
1538
|
+
console.log(` ${c.status === "supported" ? pc.green("●") : c.status === "contradicted" ? pc.red("●") : pc.dim("●")} “${c.text}” ${pc.dim(`— ${c.why}`)}`);
|
|
1539
|
+
if (r.rerun) {
|
|
1540
|
+
console.log(pc.dim(` ${r.rerun.command} · exit ${r.rerun.exitCode ?? "?"} · ${(r.rerun.ms / 1000).toFixed(1)}s${r.rerun.clean === false ? " · in place" : " · clean checkout"}`));
|
|
1541
|
+
if (!r.rerun.ok)
|
|
1542
|
+
console.log(pc.dim(String(r.rerun.tail).split("\n").slice(-12).map((l) => ` ${l}`).join("\n")));
|
|
1543
|
+
}
|
|
1544
|
+
if (r.verdict === "contradicted")
|
|
1545
|
+
process.exitCode = 1;
|
|
1546
|
+
});
|
|
1547
|
+
const SHIFT_MARK = {
|
|
1548
|
+
verified: (s) => pc.green(s), done: (s) => s, didnt_hold: (s) => pc.red(s), failed: (s) => pc.red(s),
|
|
1549
|
+
needs_you: (s) => pc.yellow(s), not_started: (s) => pc.dim(s), running: (s) => pc.dim(s),
|
|
1550
|
+
};
|
|
1551
|
+
const SHIFT_WORD = { verified: "verified", done: "done", didnt_hold: "didn't hold", failed: "failed", needs_you: "needs you", not_started: "not started", running: "running" };
|
|
1552
|
+
const shiftLine = (t) => {
|
|
1553
|
+
const why = t.question ? `asks: ${t.question}` : t.error ?? t.headline ?? "";
|
|
1554
|
+
return ` ${(SHIFT_MARK[t.status] ?? ((x) => x))(String(SHIFT_WORD[t.status] ?? t.status).padEnd(11))} ${t.text}${why ? pc.dim(` — ${why}`) : ""}`;
|
|
1555
|
+
};
|
|
1556
|
+
program
|
|
1557
|
+
.command("shift [tasks...]")
|
|
1558
|
+
.description("Night Shift: run these tasks one after another while you're away, a chat each, then one report. No tasks: show the running shift or the last report")
|
|
1559
|
+
.option("--to <agent>", "who takes them (default: the router picks)")
|
|
1560
|
+
.option("--until <time>", "start no new task after this (7:00, 6:30am, an ISO time)")
|
|
1561
|
+
.option("--max <usd>", "stop starting new tasks once the shift has spent this")
|
|
1562
|
+
.option("--file <path>", "read the tasks from a file, one per line")
|
|
1563
|
+
.option("--stop", "stop the running shift: the current task finishes, then the report")
|
|
1564
|
+
.action(async (tasks, opts) => {
|
|
1565
|
+
const client = await ensureDaemon();
|
|
1566
|
+
const project = await currentProject(client);
|
|
1567
|
+
if (opts.stop) {
|
|
1568
|
+
await client.stopShift(project.id);
|
|
1569
|
+
console.log("night shift stopping — no new tasks start; the report follows the one running");
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const list = [...(tasks ?? []), ...(opts.file ? fs.readFileSync(opts.file, "utf8").split("\n") : [])].map((t) => t.trim()).filter(Boolean);
|
|
1573
|
+
if (list.length) {
|
|
1574
|
+
const r = await client.startShift(project.id, { tasks: list, ...(opts.to ? { to: opts.to } : {}), ...(opts.until ? { until: opts.until } : {}), ...(opts.max ? { maxUsd: Number(opts.max) } : {}) });
|
|
1575
|
+
console.log(`🌙 night shift started — ${r.shift.tasks.length} task${r.shift.tasks.length === 1 ? "" : "s"}${opts.to ? ` for ${opts.to}` : ""}. \`loom shift\` shows how it's going; the report lands in the thread and on your phone.`);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const s = await client.shift(project.id);
|
|
1579
|
+
if (s.active) {
|
|
1580
|
+
console.log(`🌙 night shift running${s.shift.stopped ? pc.yellow(` (stopping: ${s.shift.stopped})`) : ""}`);
|
|
1581
|
+
for (const t of s.tasks)
|
|
1582
|
+
console.log(shiftLine(t));
|
|
1583
|
+
}
|
|
1584
|
+
else if (s.last) {
|
|
1585
|
+
console.log(`🌙 last night shift: ${s.last.summary}${s.last.stopped ? pc.dim(` — stopped: ${s.last.stopped}`) : ""}`);
|
|
1586
|
+
for (const t of s.last.tasks)
|
|
1587
|
+
console.log(shiftLine(t));
|
|
1588
|
+
}
|
|
1589
|
+
else
|
|
1590
|
+
console.log(pc.dim("no night shift yet — `loom shift \"task one\" \"task two\" --to codex --until 7:00`"));
|
|
1591
|
+
});
|
|
1592
|
+
program
|
|
1593
|
+
.command("crew [action] [args...]")
|
|
1594
|
+
.description("Agent Teams: status | create | goal | say | approve | pause | stop | resume | pick | refresh | budget | overlap | deliver | apply | diff | remove")
|
|
1595
|
+
.option("--crew <id>", "which crew, when the project has several")
|
|
1596
|
+
.option("--to <teammate>", "say: to one teammate")
|
|
1597
|
+
.option("--no-approval", "create: the Lead's plan starts without your OK")
|
|
1598
|
+
.option("--play <play>", "ship, fanout or race")
|
|
1599
|
+
.option("--parallel <n>", "create: maximum concurrent builders (1–4)")
|
|
1600
|
+
.option("--amount <usd>", "budget: new goal budget in USD")
|
|
1601
|
+
.option("--card <id>", "overlap: held card id")
|
|
1602
|
+
.option("--decision <decision>", "overlap: narrow, wait:<goal>, or proceed:<reason>")
|
|
1603
|
+
.option("--touches <globs>", "overlap: comma-separated revised paths")
|
|
1604
|
+
.action(async (action, args, opts) => {
|
|
1605
|
+
const client = await ensureDaemon();
|
|
1606
|
+
const project = await currentProject(client);
|
|
1607
|
+
const a = (action ?? "status").toLowerCase();
|
|
1608
|
+
const words = (args ?? []).join(" ").trim();
|
|
1609
|
+
const { crews, templates } = await client.crews(project.id);
|
|
1610
|
+
if (a === "create") {
|
|
1611
|
+
const [first, ...rest] = args ?? [];
|
|
1612
|
+
const template = first && templates.includes(first.toLowerCase()) ? first.toLowerCase() : "ship";
|
|
1613
|
+
const name = (first && templates.includes(first.toLowerCase()) ? rest : args ?? []).join(" ");
|
|
1614
|
+
const { crew } = await client.createCrew(project.id, { template, ...(name ? { name } : {}), ...(opts.approval === false ? { planApproval: false } : {}),
|
|
1615
|
+
...(opts.play ? { play: opts.play } : {}), ...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}) });
|
|
1616
|
+
console.log(`${pc.green("✓")} ${pc.bold(crew.name)} (${crew.id}) — ${crew.teammates.map((t) => `${t.id}=${t.agent}`).join(", ")}`);
|
|
1617
|
+
console.log(pc.dim(` give it a goal: loom crew goal "…"${crews.length ? ` --crew ${crew.id}` : ""}`));
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (!crews.length) {
|
|
1621
|
+
console.log(pc.dim(`no crews here yet — loom crew create [${templates.join("|")}] [name]`));
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const crew = opts.crew ? crews.find((c) => c.id === opts.crew || c.name === opts.crew) : crews.length === 1 ? crews[0] : undefined;
|
|
1625
|
+
if (!crew)
|
|
1626
|
+
throw new Error(`which crew? --crew ${crews.map((c) => c.id).join(" | ")}`);
|
|
1627
|
+
if (a === "status") {
|
|
1628
|
+
for (const c of opts.crew ? [crew] : crews)
|
|
1629
|
+
printCrew(c);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (a === "diff") {
|
|
1633
|
+
const out = await client.crewDiff(project.id, crew.id);
|
|
1634
|
+
process.stdout.write(out.diff || pc.dim("no changes yet\n"));
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (a === "remove") {
|
|
1638
|
+
await client.removeCrew(project.id, crew.id);
|
|
1639
|
+
console.log(`${pc.green("✓")} removed ${crew.name}`);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if ((a === "goal" || a === "say") && !words)
|
|
1643
|
+
throw new Error(`loom crew ${a} "<text>"`);
|
|
1644
|
+
const out = await client.crewAction(project.id, crew.id, a, { ...(words ? { text: words } : {}), ...(opts.to ? { to: opts.to } : {}), ...(opts.play ? { play: opts.play } : {}),
|
|
1645
|
+
...(opts.amount ? { amount: Number(opts.amount) } : {}), ...(opts.card ? { card: opts.card } : {}), ...(opts.decision ? { decision: opts.decision } : {}),
|
|
1646
|
+
...(opts.touches ? { touches: opts.touches.split(",").map(s => s.trim()).filter(Boolean) } : {}) });
|
|
1647
|
+
if (a === "say")
|
|
1648
|
+
console.log(`${pc.green("✓")} ${out.routed === "goal" ? "new goal" : out.routed === "answer" ? "answered" : out.routed === "replan" ? "the Lead replans with that" : `noted for ${out.to}`}`);
|
|
1649
|
+
else if (a === "apply")
|
|
1650
|
+
console.log(`${pc.green("✓")} merged ${pc.bold(out.merged)} into ${pc.bold(out.into)}`);
|
|
1651
|
+
else
|
|
1652
|
+
printCrew(out.crew);
|
|
1653
|
+
});
|
|
1654
|
+
function printCrew(c) {
|
|
1655
|
+
const g = c.state?.goal;
|
|
1656
|
+
console.log(`${pc.bold(c.name)} ${pc.dim(`(${c.id})`)}${c.busy ? pc.cyan(" working") : ""}`);
|
|
1657
|
+
console.log(` ${c.teammates.map((t) => `${t.id}${pc.dim(`:${t.role}=${t.agent}`)}`).join(" ")}`);
|
|
1658
|
+
if (!g)
|
|
1659
|
+
return void console.log(pc.dim(" no goal yet — loom crew goal \"…\""));
|
|
1660
|
+
const color = g.status === "completed" ? pc.green : g.status === "failed" ? pc.red : g.status.startsWith("waiting") || g.status === "awaiting_approval" ? pc.yellow : pc.cyan;
|
|
1661
|
+
console.log(` ${color(g.status)} ${g.text.split("\n")[0].slice(0, 80)} ${pc.dim(g.branch)}`);
|
|
1662
|
+
for (const card of g.cards ?? []) {
|
|
1663
|
+
const m = card.stage === "done" ? pc.green("✓") : card.stage === "failed" ? pc.red("✗") : card.stage === "planned" ? pc.dim("·") : pc.cyan("→");
|
|
1664
|
+
console.log(` ${m} ${card.title}${card.builder ? pc.dim(` ${card.builder}`) : ""}${card.stage !== "done" && card.stage !== "planned" ? pc.dim(` [${card.stage}]`) : ""}${card.error ? pc.red(` ${card.error.slice(0, 80)}`) : ""}`);
|
|
1665
|
+
}
|
|
1666
|
+
if (g.question)
|
|
1667
|
+
console.log(pc.yellow(` ${g.question.teammate} asks: ${g.question.text}`) + pc.dim(" (loom crew say \"…\")"));
|
|
1668
|
+
if (g.status === "awaiting_approval")
|
|
1669
|
+
console.log(pc.dim(" loom crew approve — or loom crew say \"…\" to change the plan"));
|
|
1670
|
+
if (g.status === "completed" && !g.applied)
|
|
1671
|
+
console.log(pc.dim(" loom crew apply — merge it into your branch · loom crew diff"));
|
|
1672
|
+
if (g.summary && g.status === "completed")
|
|
1673
|
+
console.log(` ${g.summary.split("\n")[0].slice(0, 120)}`);
|
|
1674
|
+
}
|
|
1675
|
+
program
|
|
1676
|
+
.command("team [action] [args...]")
|
|
1677
|
+
.description("Loom Teams: status | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
|
|
1678
|
+
.option("--github <login>", "your GitHub login (defaults to the gh CLI's)")
|
|
1679
|
+
.option("--repo <owner/name>", "webhook: which repo (defaults to this project's)")
|
|
1680
|
+
.option("--install", "webhook: create it on the repo with gh (needs repo admin)")
|
|
1681
|
+
.option("--rotate", "webhook: replace the secret (update the repo's webhook after)")
|
|
1682
|
+
.option("--secret <s>", "the hub's join secret")
|
|
1683
|
+
.option("--team <id>", "which team, when you're in several")
|
|
1684
|
+
.option("--paste", "hosted sign-in without a local browser: open the link anywhere, paste back where you land")
|
|
1685
|
+
.action(async (action, args, opts) => {
|
|
1686
|
+
const client = await ensureDaemon();
|
|
1687
|
+
const a = (action ?? "status").toLowerCase();
|
|
1688
|
+
const arg = args?.[0];
|
|
1689
|
+
const extra = { ...(opts.github ? { github: opts.github } : {}), ...(opts.secret ? { secret: opts.secret } : {}), ...(opts.team ? { teamId: opts.team } : {}) };
|
|
1690
|
+
try {
|
|
1691
|
+
if (a === "status")
|
|
1692
|
+
return void printTeam(await client.team());
|
|
1693
|
+
if (a === "signin") {
|
|
1694
|
+
const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
|
|
1695
|
+
const supabaseUrl = hostedSupabaseUrl(arg);
|
|
1696
|
+
if (supabaseUrl !== null) {
|
|
1697
|
+
// The hosted hub: GitHub sign-in in the browser, run here (it can take
|
|
1698
|
+
// minutes); the daemon only receives the resulting session.
|
|
1699
|
+
const session = await hostedSession(supabaseUrl, publishableKeyFor(supabaseUrl), Boolean(opts.paste));
|
|
1700
|
+
const out = await client.teamAction("signin", { hub: hostedHubUrl(supabaseUrl), token: session.refreshToken, ...extra });
|
|
1701
|
+
return void printTeam(out.team);
|
|
1702
|
+
}
|
|
1703
|
+
const out = await client.teamAction("signin", { hub: arg, ...extra });
|
|
1704
|
+
return void printTeam(out.team);
|
|
1705
|
+
}
|
|
1706
|
+
if (a === "create") {
|
|
1707
|
+
if (!arg)
|
|
1708
|
+
throw new Error("name it: loom team create <name>");
|
|
1709
|
+
const out = await client.teamAction("create", { name: arg });
|
|
1710
|
+
console.log(`${pc.green("✓")} created ${pc.bold(String(out.result.name))} — you're its owner`);
|
|
1711
|
+
console.log(pc.dim(" invite teammates: loom team invite · share this project: loom team share"));
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (a === "invite") {
|
|
1715
|
+
const out = await client.teamAction("invite", extra);
|
|
1716
|
+
const { link, expiresAt } = out.result;
|
|
1717
|
+
console.log(`${pc.bold("invite link")} ${pc.dim(`(single use, until ${new Date(expiresAt).toLocaleString()})`)}\n\n ${link}\n`);
|
|
1718
|
+
console.log(pc.yellow(" it carries the team key — send it like a password"));
|
|
1719
|
+
qrcode.generate(link, { small: true });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (a === "join") {
|
|
1723
|
+
if (!arg)
|
|
1724
|
+
throw new Error("paste the invite: loom team join '<link>'");
|
|
1725
|
+
const out = await client.teamAction("join", { link: arg, ...extra });
|
|
1726
|
+
console.log(`${pc.green("✓")} joined ${pc.bold(String(out.result.name))}`);
|
|
1727
|
+
return void printTeam(out.team);
|
|
1728
|
+
}
|
|
1729
|
+
if (a === "share" || a === "unshare") {
|
|
1730
|
+
const project = await currentProject(client);
|
|
1731
|
+
if (a === "share") {
|
|
1732
|
+
const out = await client.shareProject(project.id, opts.team);
|
|
1733
|
+
console.log(`${pc.green("✓")} ${pc.bold(out.repo)} is shared — teammates see this project's agents and goals`);
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
await client.unshareProject(project.id);
|
|
1737
|
+
console.log(`${pc.green("✓")} ${project.name} is private — nothing from it reaches the team`);
|
|
1738
|
+
}
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (a === "remove") {
|
|
1742
|
+
if (!arg)
|
|
1743
|
+
throw new Error("who? loom team remove <github-login>");
|
|
1744
|
+
const st = await client.team();
|
|
1745
|
+
const teams = st.teams ?? [];
|
|
1746
|
+
const team = opts.team ? teams.find((t) => t.id === opts.team) : teams[0];
|
|
1747
|
+
const m = team?.members.find((x) => x.github === arg.toLowerCase());
|
|
1748
|
+
if (!team || !m)
|
|
1749
|
+
throw new Error(`no member "${arg}"`);
|
|
1750
|
+
const out = await client.teamAction("remove", { userId: m.id, teamId: team.id });
|
|
1751
|
+
console.log(`${pc.green("✓")} removed ${arg}; team key rotated to v${String(out.result.keyVersion)}`);
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (a === "brain") {
|
|
1755
|
+
// loom team brain [inbox|sync|promote <id,id>|resolve <winner> --loser <id>|trust <id>|private <id>]
|
|
1756
|
+
const project = await currentProject(client);
|
|
1757
|
+
const sub = (arg ?? "").toLowerCase();
|
|
1758
|
+
const rest = (args ?? []).slice(1);
|
|
1759
|
+
let view;
|
|
1760
|
+
if (!sub || sub === "inbox" || sub === "status")
|
|
1761
|
+
view = await client.teamBrain(project.id, { sync: true });
|
|
1762
|
+
else if (sub === "sync")
|
|
1763
|
+
view = await client.teamBrainAction(project.id, "sync");
|
|
1764
|
+
else if (sub === "promote") {
|
|
1765
|
+
if (!rest[0])
|
|
1766
|
+
throw new Error("which memories? loom team brain promote <id>[,<id>…]");
|
|
1767
|
+
const out = await client.teamBrainAction(project.id, "promote", { ids: rest[0].split(",") });
|
|
1768
|
+
const r = out.result;
|
|
1769
|
+
console.log(`${pc.green("✓")} ${r.added ? `proposed ${r.added} as canon on ${r.branch}` : "already canon"}${r.prUrl ? ` — ${r.prUrl}` : ""}`);
|
|
1770
|
+
if (r.note)
|
|
1771
|
+
console.log(pc.yellow(` ${r.note}`));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
else if (sub === "resolve" || sub === "merge") {
|
|
1775
|
+
if (!rest[0] || !rest[1])
|
|
1776
|
+
throw new Error(`loom team brain ${sub} <keep-id> <drop-id> [reason]`);
|
|
1777
|
+
view = await client.teamBrainAction(project.id, "resolve", { winner: rest[0], loser: rest[1], reason: rest.slice(2).join(" ") || sub });
|
|
1778
|
+
}
|
|
1779
|
+
else if (sub === "correct") {
|
|
1780
|
+
if (!rest[0] || !rest[1])
|
|
1781
|
+
throw new Error("loom team brain correct <id> <the corrected sentence>");
|
|
1782
|
+
view = await client.teamBrainAction(project.id, "correct", { id: rest[0], text: rest.slice(1).join(" ") });
|
|
1783
|
+
}
|
|
1784
|
+
else if (sub === "trust" || sub === "private") {
|
|
1785
|
+
if (!rest[0])
|
|
1786
|
+
throw new Error(`loom team brain ${sub} <id>`);
|
|
1787
|
+
view = await client.teamBrainAction(project.id, sub, { id: rest[0] });
|
|
1788
|
+
}
|
|
1789
|
+
else
|
|
1790
|
+
throw new Error(`unknown brain action "${sub}" — inbox, sync, promote, resolve, merge, correct, trust, private`);
|
|
1791
|
+
const st = view.status;
|
|
1792
|
+
if (!st.shared)
|
|
1793
|
+
return void console.log(pc.dim("this project isn't shared with a team — `loom team share`"));
|
|
1794
|
+
console.log(`${pc.bold("team brain")} ${String(st.repo)} ${pc.dim(`${String(st.canon)} canon · ${String(st.team)} team · ${String(st.confirmed)} confirmed · ${String(st.mine)} yours`)}`);
|
|
1795
|
+
if (st.lastError)
|
|
1796
|
+
console.log(pc.red(` ${String(st.lastError)}`));
|
|
1797
|
+
const tag = { canon: pc.green("canon"), confirmed: pc.cyan("confirmed"), own: pc.dim("yours"), proposed: pc.yellow("proposed") };
|
|
1798
|
+
for (const m of view.memories.slice(0, 40)) {
|
|
1799
|
+
console.log(` ${(tag[m.tier] ?? m.tier).padEnd(20)} ${m.text}${m.author && !m.mine ? pc.dim(` — ${m.author}`) : ""} ${pc.dim(m.id)}`);
|
|
1800
|
+
}
|
|
1801
|
+
if (view.inbox.length) {
|
|
1802
|
+
console.log(`\n${pc.bold("inbox")} ${pc.dim(`(${view.inbox.length})`)}`);
|
|
1803
|
+
for (const i of view.inbox) {
|
|
1804
|
+
console.log(` ${pc.magenta(i.type.padEnd(13))} ${i.detail}`);
|
|
1805
|
+
console.log(` ${pc.dim(i.a.id)} ${i.a.text}`);
|
|
1806
|
+
if (i.b)
|
|
1807
|
+
console.log(` ${pc.dim(i.b.id)} ${i.b.text}`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (a === "release-notes" || a === "deploys") {
|
|
1813
|
+
const project = await currentProject(client);
|
|
1814
|
+
if (a === "release-notes") {
|
|
1815
|
+
if (!arg)
|
|
1816
|
+
throw new Error("since which tag? loom team release-notes v1.2.0");
|
|
1817
|
+
process.stdout.write((await client.releaseNotes(project.id, arg)).markdown);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
const { deployments } = await client.deploys(project.id);
|
|
1821
|
+
if (!deployments.length)
|
|
1822
|
+
return void console.log(pc.dim("no deployments on this repo"));
|
|
1823
|
+
for (const d of deployments) {
|
|
1824
|
+
const st = String(d.state);
|
|
1825
|
+
const col = st === "success" ? pc.green : st === "failure" || st === "error" ? pc.red : pc.yellow;
|
|
1826
|
+
console.log(` ${col(st.padEnd(12))} ${String(d.environment).padEnd(14)} ${String(d.sha).slice(0, 8)} ${pc.dim(String(d.url ?? ""))}`);
|
|
1827
|
+
}
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
if (a === "landing" || a === "doctor" || a === "adopt") {
|
|
1831
|
+
const project = await currentProject(client);
|
|
1832
|
+
if (a === "doctor") {
|
|
1833
|
+
if ((args ?? []).includes("--fix") || arg === "fix") {
|
|
1834
|
+
const out = await client.teamDoctorFix(project.id);
|
|
1835
|
+
console.log(out.prUrl ? `${pc.green("✓")} opened ${out.prUrl} (${out.files.join(", ")})` : pc.dim("nothing to fix in the workflows"));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
const d = await client.teamDoctor(project.id);
|
|
1839
|
+
console.log(`${pc.bold("landing doctor")} ${d.repo ?? pc.dim("(no GitHub repo)")} · ${d.branch}`);
|
|
1840
|
+
const icon = { ok: pc.green("✓"), warn: pc.yellow("!"), error: pc.red("✗") };
|
|
1841
|
+
for (const f of d.findings) {
|
|
1842
|
+
console.log(` ${icon[f.level] ?? "·"} ${f.what}`);
|
|
1843
|
+
if (f.fix)
|
|
1844
|
+
console.log(pc.dim(` ${f.fix}`));
|
|
1845
|
+
}
|
|
1846
|
+
if (d.fixable.length)
|
|
1847
|
+
console.log(pc.dim(`\n loom team doctor fix — opens a PR adding merge_group to ${d.fixable.join(", ")}`));
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
if (a === "adopt") {
|
|
1851
|
+
const pr = Number(String(arg ?? "").replace(/^#/, ""));
|
|
1852
|
+
if (!pr)
|
|
1853
|
+
throw new Error("which PR? loom team adopt <number>");
|
|
1854
|
+
const out = await client.landingAction(project.id, "adopt", { pr });
|
|
1855
|
+
console.log(`${pc.green("✓")} adopted PR #${pr} — goal ${String(out.result.id)} is making it green; it's handed back when it is`);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
const v = await client.landing(project.id, { poll: true });
|
|
1859
|
+
if (!v.goals.length && !v.adoptable.length)
|
|
1860
|
+
return void console.log(pc.dim("no goal PRs in flight"));
|
|
1861
|
+
for (const g of v.goals) {
|
|
1862
|
+
const l = g.landing;
|
|
1863
|
+
const col = l.state === "green" || l.state === "merged" ? pc.green : l.state === "needs_human" || l.state === "failing" ? pc.red : pc.yellow;
|
|
1864
|
+
console.log(` ${col(l.state.padEnd(12))} #${l.pr} ${String(g.goal)} ${pc.dim(`${String(g.runId)} · fixes ${l.fixAttempts} · $${Number(g.costUsd ?? 0).toFixed(2)}`)}`);
|
|
1865
|
+
if (l.checks?.failing.length)
|
|
1866
|
+
console.log(pc.red(` failing: ${l.checks.failing.join(", ")}`));
|
|
1867
|
+
if (l.review)
|
|
1868
|
+
console.log(pc.dim(` review: ${l.review.state}${l.review.high ? ` (${l.review.high} high)` : ""}`));
|
|
1869
|
+
if (l.reason)
|
|
1870
|
+
console.log(pc.yellow(` ${l.reason}`));
|
|
1871
|
+
}
|
|
1872
|
+
if (v.adoptable.length) {
|
|
1873
|
+
console.log(`\n${pc.bold("needs someone")}`);
|
|
1874
|
+
for (const p of v.adoptable)
|
|
1875
|
+
console.log(` #${String(p.pr)} ${String(p.owner)} — ${String(p.reason)} ${pc.dim(`loom team adopt ${String(p.pr)}`)}`);
|
|
1876
|
+
}
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (a === "webhook") {
|
|
1880
|
+
// Phase 6 (D83): GitHub events pushed to the hub instead of polled
|
|
1881
|
+
const project = await currentProject(client).catch(() => null);
|
|
1882
|
+
const out = await client.teamAction("webhook", {
|
|
1883
|
+
...extra,
|
|
1884
|
+
...(opts.repo ? { repo: opts.repo } : {}),
|
|
1885
|
+
...(project ? { projectId: project.id } : {}),
|
|
1886
|
+
...(opts.install ? { install: "1" } : {}),
|
|
1887
|
+
...(opts.rotate ? { rotate: "1" } : {}),
|
|
1888
|
+
});
|
|
1889
|
+
const w = out.result;
|
|
1890
|
+
console.log(`${pc.bold("payload URL")} ${w.url}`);
|
|
1891
|
+
console.log(`${pc.bold("secret")} ${w.secret} ${pc.yellow("(treat it like a password)")}`);
|
|
1892
|
+
console.log(`${pc.bold("content type")} application/json`);
|
|
1893
|
+
console.log(`${pc.bold("events")} ${w.events.join(", ")}`);
|
|
1894
|
+
if (w.warning)
|
|
1895
|
+
console.log(pc.yellow(` ${w.warning}`));
|
|
1896
|
+
if (w.installed)
|
|
1897
|
+
console.log(`${pc.green("✓")} webhook created on ${w.installed.repo}${w.installed.id ? pc.dim(` (id ${w.installed.id})`) : ""}`);
|
|
1898
|
+
else
|
|
1899
|
+
console.log(pc.dim(`\n install it: loom team webhook --install${w.repo ? ` --repo ${w.repo}` : " --repo owner/name"} (or GitHub → Settings → Webhooks)`));
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
if (a === "leave") {
|
|
1903
|
+
await client.teamAction("leave", extra);
|
|
1904
|
+
console.log(`${pc.green("✓")} left the team`);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
throw new Error(`unknown action "${a}"`);
|
|
1908
|
+
}
|
|
1909
|
+
catch (err) {
|
|
1910
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
1911
|
+
process.exitCode = 1;
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
program
|
|
1915
|
+
.command("runner [action] [args...]")
|
|
1916
|
+
.description("Loom runners: status | pair | join <link> | start | stop | token <PAT> | doctor | install | revoke <device> | goal \"<goal>\" | move <runId> | back <runId> | jobs | exec")
|
|
1917
|
+
.option("--github <login>", "your GitHub login (self-hosted hub)")
|
|
1918
|
+
.option("--secret <s>", "the self-hosted hub's join secret")
|
|
1919
|
+
.option("--token <refresh>", "hosted hub: a session refresh token (from the paste-the-URL sign-in)")
|
|
1920
|
+
.option("--shared", "take teammates' goals too (when their repo's policy allows)")
|
|
1921
|
+
.option("--runner <id>", "which runner (device id or label)")
|
|
1922
|
+
.option("--job <id>", "exec: the claimed job to run")
|
|
1923
|
+
.option("--team <id>", "exec: the job's team")
|
|
1924
|
+
.option("--browser", "join: sign in with a local browser instead of pasting the address back")
|
|
1925
|
+
.action(async (action, args, opts) => {
|
|
1926
|
+
const a = (action ?? "status").toLowerCase();
|
|
1927
|
+
const arg = args?.[0];
|
|
1928
|
+
try {
|
|
1929
|
+
if (a === "install") {
|
|
1930
|
+
const loomBin = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
1931
|
+
const f = serviceFile({ platform: process.platform, home: os.homedir(), node: process.execPath, loom: loomBin, ...(process.env.LOOM_HOME ? { loomHome: process.env.LOOM_HOME } : {}), ...(process.env.APPDATA ? { appData: process.env.APPDATA } : {}) });
|
|
1932
|
+
fs.mkdirSync(path.dirname(f.path), { recursive: true });
|
|
1933
|
+
// Windows Script Host reads Unicode reliably as UTF-16 with a BOM.
|
|
1934
|
+
fs.writeFileSync(f.path, process.platform === "win32" ? Buffer.from("\uFEFF" + f.content, "utf16le") : f.content);
|
|
1935
|
+
console.log(`${pc.green("✓")} wrote ${f.path}`);
|
|
1936
|
+
for (const cmd of f.enable) {
|
|
1937
|
+
await new Promise((resolve, reject) => spawn(cmd[0], cmd.slice(1), { stdio: "inherit" })
|
|
1938
|
+
.on("error", reject).on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd[0]} failed (exit ${code})`))));
|
|
1939
|
+
}
|
|
1940
|
+
console.log(pc.dim(process.platform === "win32"
|
|
1941
|
+
? " the daemon now starts at login; `loom runner status` to check, `loom up` to restart after a failure"
|
|
1942
|
+
: " the daemon now starts at login and restarts if it stops; `loom runner status` to check"));
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (a === "token") {
|
|
1946
|
+
if (!arg)
|
|
1947
|
+
throw new Error("paste a fine-grained PAT: loom runner token github_pat_…");
|
|
1948
|
+
writeRunnerToken(arg);
|
|
1949
|
+
console.log(`${pc.green("✓")} saved (0600, ${path.join(loomHome(), "runner-token")}) — it never goes through the hub. Check it: loom runner doctor`);
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (a === "exec") {
|
|
1953
|
+
// Inside a runner's container: run one claimed job, then exit (D70).
|
|
1954
|
+
if (!opts.job || !opts.team)
|
|
1955
|
+
throw new Error("runner exec needs --job and --team");
|
|
1956
|
+
const daemon = new LoomDaemon({ host: "127.0.0.1", port: 0, runnerExec: { teamId: opts.team, jobId: opts.job, done: (ok) => process.exit(ok ? 0 : 1) } });
|
|
1957
|
+
await daemon.listen();
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const client = await ensureDaemon();
|
|
1961
|
+
if (a === "status") {
|
|
1962
|
+
const s = await client.runner();
|
|
1963
|
+
const c = s.config;
|
|
1964
|
+
console.log(`runner ${s.running ? pc.green("running") : c?.enabled ? pc.yellow("enabled, not running") : pc.dim("off")}${c ? pc.dim(` · ${c.shared ? "shared" : "personal"} · ${c.capacity ?? 1} at a time · isolation ${c.isolation ?? "auto"}`) : ""}`);
|
|
1965
|
+
for (const j of s.active ?? [])
|
|
1966
|
+
console.log(` ${String(j.kind).padEnd(9)} ${String(j.repo)} ${pc.dim(`${String(j.owner)} · job ${String(j.jobId)}${j.runId ? ` · goal ${String(j.runId)}` : ""}`)}`);
|
|
1967
|
+
if (!s.token)
|
|
1968
|
+
console.log(pc.dim(" no runner token — `loom runner token <PAT>` (see `loom runner doctor`)"));
|
|
1969
|
+
if (s.lastError)
|
|
1970
|
+
console.log(pc.red(` ${String(s.lastError)}`));
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (a === "pair") {
|
|
1974
|
+
const out = await client.runnerAction("pair");
|
|
1975
|
+
const { link } = out.result;
|
|
1976
|
+
console.log(`${pc.bold("runner pairing link")} — on the box: ${pc.cyan("loom runner join '<link>'")}\n\n ${link}\n`);
|
|
1977
|
+
console.log(pc.yellow(" it carries your team keys — send it like a password, to a machine you control"));
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (a === "join") {
|
|
1981
|
+
if (!arg)
|
|
1982
|
+
throw new Error("paste the link from `loom runner pair`");
|
|
1983
|
+
let token = opts.token;
|
|
1984
|
+
if (!token && !opts.secret) {
|
|
1985
|
+
// A hosted hub: sign in as yourself here (a runner box usually has no browser — D74).
|
|
1986
|
+
const hubUrl = (() => {
|
|
1987
|
+
try {
|
|
1988
|
+
return String(JSON.parse(Buffer.from(arg.replace(/^loom-runner:/, ""), "base64url").toString("utf8")).hub ?? "");
|
|
1989
|
+
}
|
|
1990
|
+
catch {
|
|
1991
|
+
return "";
|
|
1992
|
+
}
|
|
1993
|
+
})();
|
|
1994
|
+
const { hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
|
|
1995
|
+
const sbUrl = hostedSupabaseUrl(hubUrl);
|
|
1996
|
+
if (sbUrl !== null)
|
|
1997
|
+
token = (await hostedSession(sbUrl, publishableKeyFor(sbUrl), !opts.browser)).refreshToken;
|
|
1998
|
+
}
|
|
1999
|
+
const out = await client.runnerAction("join", { link: arg, ...(opts.github ? { github: opts.github } : {}), ...(opts.secret ? { secret: opts.secret } : {}), ...(token ? { token } : {}), ...(opts.shared ? { shared: true } : {}) });
|
|
2000
|
+
console.log(`${pc.green("✓")} this machine is now a runner`, pc.dim(JSON.stringify(out.result.registered ?? {})));
|
|
2001
|
+
console.log(pc.dim(" keep it running: loom runner install · check it: loom runner doctor"));
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (a === "start" || a === "stop") {
|
|
2005
|
+
await client.runnerAction(a, a === "start" && opts.shared ? { shared: true } : {});
|
|
2006
|
+
console.log(`${pc.green("✓")} runner ${a === "start" ? "started" : "stopped"}`);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (a === "revoke") {
|
|
2010
|
+
if (!arg)
|
|
2011
|
+
throw new Error("which runner device? see `loom runner jobs`");
|
|
2012
|
+
await client.runnerAction("revoke", { deviceId: arg });
|
|
2013
|
+
console.log(`${pc.green("✓")} revoked ${arg}; team keys rotated`);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (a === "doctor") {
|
|
2017
|
+
const token = readRunnerToken();
|
|
2018
|
+
const repos = {};
|
|
2019
|
+
let scopes = [];
|
|
2020
|
+
if (token) {
|
|
2021
|
+
const env = { ...process.env, GH_TOKEN: token };
|
|
2022
|
+
const run = (argv) => new Promise((resolve) => {
|
|
2023
|
+
const p = spawn("gh", argv, { env });
|
|
2024
|
+
let out = "";
|
|
2025
|
+
p.stdout.on("data", (d) => (out += String(d)));
|
|
2026
|
+
p.on("exit", () => resolve(out));
|
|
2027
|
+
p.on("error", () => resolve(""));
|
|
2028
|
+
});
|
|
2029
|
+
const head = await run(["api", "-i", "user"]);
|
|
2030
|
+
scopes = (/^x-oauth-scopes:\s*(.*)$/im.exec(head)?.[1] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
2031
|
+
const team = await client.team();
|
|
2032
|
+
for (const t of team.teams ?? []) {
|
|
2033
|
+
for (const r of t.repos) {
|
|
2034
|
+
try {
|
|
2035
|
+
repos[r] = JSON.parse(await run(["api", `repos/${r}`])).permissions ?? null;
|
|
2036
|
+
}
|
|
2037
|
+
catch {
|
|
2038
|
+
repos[r] = null;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
const s = await client.runner();
|
|
2044
|
+
const items = [
|
|
2045
|
+
...(s.running ? [{ level: "ok", what: "runner is running" }] : [{ level: "warn", what: "runner isn't running", fix: "loom runner start" }]),
|
|
2046
|
+
...tokenFindings({ present: Boolean(token), scopes, repos }),
|
|
2047
|
+
];
|
|
2048
|
+
const icon = { ok: pc.green("✓"), warn: pc.yellow("!"), error: pc.red("✗") };
|
|
2049
|
+
for (const f of items) {
|
|
2050
|
+
console.log(` ${icon[f.level]} ${f.what}`);
|
|
2051
|
+
if ("fix" in f && f.fix)
|
|
2052
|
+
console.log(pc.dim(` ${f.fix}`));
|
|
2053
|
+
}
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const project = await currentProject(client);
|
|
2057
|
+
if (a === "goal") {
|
|
2058
|
+
const goal = (args ?? []).join(" ").trim();
|
|
2059
|
+
if (!goal)
|
|
2060
|
+
throw new Error('what\'s the goal? loom runner goal "Add rate limiting"');
|
|
2061
|
+
const out = await client.runnersAction(project.id, "start", { goal, ...(opts.runner ? { runner: opts.runner } : {}) });
|
|
2062
|
+
console.log(`${pc.green("✓")} queued for your runner — job ${String(out.result.jobId)}; watch it: loom runner jobs`);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (a === "move" || a === "back") {
|
|
2066
|
+
if (!arg)
|
|
2067
|
+
throw new Error(`which goal? loom runner ${a} <runId>`);
|
|
2068
|
+
const out = await client.runnersAction(project.id, a === "move" ? "continue" : "bring-back", { runId: arg, ...(opts.runner ? { runner: opts.runner } : {}) });
|
|
2069
|
+
console.log(`${pc.green("✓")} ${a === "move" ? "moving the goal to your runner" : "asked the runner to hand it back"} — job ${String(out.result.jobId)}`);
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
if (a === "jobs") {
|
|
2073
|
+
const v = await client.runners(project.id);
|
|
2074
|
+
for (const r of v.runners)
|
|
2075
|
+
console.log(` ${r.online ? pc.green("●") : pc.dim("○")} ${String(r.label)} ${pc.dim(`${String(r.github)} · ${r.kinds.join(", ")}${r.shared ? " · shared" : ""} · ${String(r.deviceId)}`)}`);
|
|
2076
|
+
if (!v.runners.length)
|
|
2077
|
+
console.log(pc.dim(" no runners — `loom runner pair`, then `loom runner join <link>` on an always-on box"));
|
|
2078
|
+
for (const j of v.jobs.slice(-15)) {
|
|
2079
|
+
const p = j.progress;
|
|
2080
|
+
console.log(` ${String(j.state).padEnd(9)} ${String(j.kind).padEnd(8)} ${String(j.goal || j.runId || "")} ${pc.dim(`${p?.status ?? ""}${p?.tasks ? ` · ${p.tasks.length} tasks` : ""}${p?.costUsd ? ` · $${p.costUsd}` : ""}${j.error ? ` · ${String(j.error)}` : ""}`)}`);
|
|
2081
|
+
}
|
|
174
2082
|
return;
|
|
175
2083
|
}
|
|
2084
|
+
throw new Error(`unknown runner action "${a}"`);
|
|
2085
|
+
}
|
|
2086
|
+
catch (err) {
|
|
2087
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2088
|
+
process.exitCode = 1;
|
|
176
2089
|
}
|
|
177
|
-
fail("daemon did not become healthy — try `loom daemon` in the foreground");
|
|
178
2090
|
});
|
|
179
2091
|
program
|
|
180
|
-
.command("
|
|
181
|
-
.description("
|
|
182
|
-
.action(async () => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
2092
|
+
.command("land [runId]")
|
|
2093
|
+
.description("land a goal's PR: fresh main in, fast tests, push, merge when GitHub's rules pass (Loom Teams D56)")
|
|
2094
|
+
.action(async (runId) => {
|
|
2095
|
+
const client = await ensureDaemon();
|
|
2096
|
+
try {
|
|
2097
|
+
const project = await currentProject(client);
|
|
2098
|
+
let id = runId;
|
|
2099
|
+
if (!id) {
|
|
2100
|
+
const v = await client.landing(project.id);
|
|
2101
|
+
const ready = v.goals.filter((g) => !["merged", "closed"].includes(String(g.landing.state)));
|
|
2102
|
+
if (ready.length !== 1)
|
|
2103
|
+
throw new Error(ready.length ? `several goals have PRs — say which: ${ready.map((g) => String(g.runId)).join(", ")}` : "no goal PR to land");
|
|
2104
|
+
id = String(ready[0].runId);
|
|
2105
|
+
}
|
|
2106
|
+
const out = await client.landingAction(project.id, "land", { runId: id });
|
|
2107
|
+
const l = out.result;
|
|
2108
|
+
console.log(l.state === "landing" ? `${pc.green("✓")} PR #${l.pr} will merge when its checks and approvals pass` : `PR #${l.pr}: ${l.state}${l.reason ? ` — ${l.reason}` : ""}`);
|
|
2109
|
+
}
|
|
2110
|
+
catch (err) {
|
|
2111
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2112
|
+
process.exitCode = 1;
|
|
2113
|
+
}
|
|
187
2114
|
});
|
|
2115
|
+
// ── Loom Cloud: reach this daemon from any network (daemon/relay.ts) ──
|
|
188
2116
|
program
|
|
189
|
-
.command("
|
|
190
|
-
.description("
|
|
2117
|
+
.command("cloud [action]")
|
|
2118
|
+
.description("Loom Cloud relay: status | enable | disable | rotate — reach your agents from anywhere, end-to-end encrypted")
|
|
2119
|
+
.option("--url <supabaseUrl>", "Supabase project URL (or LOOM_SUPABASE_URL)")
|
|
2120
|
+
.option("--key <anonKey>", "Supabase anon key (or LOOM_SUPABASE_ANON_KEY)")
|
|
2121
|
+
.action(async (action, opts) => {
|
|
2122
|
+
const client = await ensureDaemon();
|
|
2123
|
+
try {
|
|
2124
|
+
const a = (action ?? "status").toLowerCase();
|
|
2125
|
+
let s;
|
|
2126
|
+
if (a === "status")
|
|
2127
|
+
s = await client.cloud();
|
|
2128
|
+
else if (a === "enable" || a === "disable" || a === "rotate") {
|
|
2129
|
+
s = await client.cloudAction(a, {
|
|
2130
|
+
...(opts.url ? { supabaseUrl: opts.url } : {}),
|
|
2131
|
+
...(opts.key ? { anonKey: opts.key } : {}),
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
else
|
|
2135
|
+
throw new Error(`unknown action "${a}" — status, enable, disable or rotate`);
|
|
2136
|
+
const on = s.connected ? pc.green("connected") : s.enabled ? pc.yellow("enabled, not connected") : pc.dim("off");
|
|
2137
|
+
console.log(`Loom Cloud ${on}${s.supabaseUrl ? pc.dim(` via ${String(s.supabaseUrl)}`) : ""}`);
|
|
2138
|
+
if (s.connected)
|
|
2139
|
+
console.log(pc.dim(` ${String(s.clients)} phone(s) on the relay · end-to-end encrypted — Supabase relays ciphertext only`));
|
|
2140
|
+
if (s.error)
|
|
2141
|
+
console.log(pc.red(` ${String(s.error)}`));
|
|
2142
|
+
if (!s.configured)
|
|
2143
|
+
console.log(pc.dim(" set a Supabase project: loom cloud enable --url https://<ref>.supabase.co --key <anon key>"));
|
|
2144
|
+
if (a === "enable" && s.connected)
|
|
2145
|
+
console.log(pc.dim(" pair a phone with `loom pair` — its QR now works from any network"));
|
|
2146
|
+
if (a === "rotate")
|
|
2147
|
+
console.log(pc.dim(" new key minted — phones paired through the cloud must pair again"));
|
|
2148
|
+
}
|
|
2149
|
+
catch (err) {
|
|
2150
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2151
|
+
process.exitCode = 1;
|
|
2152
|
+
}
|
|
2153
|
+
});
|
|
2154
|
+
program
|
|
2155
|
+
.command("subtasks")
|
|
2156
|
+
.description("subtasks running right now")
|
|
191
2157
|
.action(async () => {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
2158
|
+
const client = await ensureDaemon();
|
|
2159
|
+
const project = await currentProject(client);
|
|
2160
|
+
const { subtasks } = await client.subtasks(project.id);
|
|
2161
|
+
if (!subtasks.length)
|
|
2162
|
+
return void console.log(pc.dim("nothing running"));
|
|
2163
|
+
for (const s of subtasks) {
|
|
2164
|
+
console.log(`${pc.cyan(s.agentId)} ${pc.dim("for " + s.parent)} ${s.task.slice(0, 60)}`);
|
|
196
2165
|
}
|
|
197
|
-
console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
|
|
198
|
-
const client = new DaemonClient(cfg);
|
|
199
|
-
const { projects } = await client.listProjects();
|
|
200
|
-
if (!projects.length)
|
|
201
|
-
console.log(pc.dim("no projects yet — loom init"));
|
|
202
|
-
for (const p of projects)
|
|
203
|
-
console.log(formatProjectRow(p));
|
|
204
2166
|
});
|
|
205
|
-
// ---------------------------------------------------------------------------
|
|
206
|
-
// init / projects / agents
|
|
207
|
-
// ---------------------------------------------------------------------------
|
|
208
2167
|
program
|
|
209
|
-
.command("
|
|
210
|
-
.description("
|
|
211
|
-
.option("--
|
|
212
|
-
.
|
|
2168
|
+
.command("agents:add <kind>")
|
|
2169
|
+
.description("add an agent session to this project (repeat for a second session of the same kind)")
|
|
2170
|
+
.option("--as <name>", "name this instance (default: the kind, then kind-2, kind-3…)")
|
|
2171
|
+
.option("--role <role>", "what this instance is for, e.g. planner or reviewer")
|
|
2172
|
+
.option("--model <model>", "for --kind model: which model it runs (loom models)")
|
|
2173
|
+
.option("--provider <id>", "for --kind model: which provider (default openrouter)")
|
|
2174
|
+
.option("--tools", "for --kind model: let it read the project (read-only)")
|
|
2175
|
+
.option("--write", "for --kind model: let it write files too (every write asks you first)")
|
|
2176
|
+
.option("--run <list>", 'for --kind model: commands it may run, comma-separated, e.g. "npm test"')
|
|
2177
|
+
.action(async (kind, opts) => {
|
|
213
2178
|
const client = await ensureDaemon();
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
2179
|
+
const project = await currentProject(client);
|
|
2180
|
+
// A model agent with no model refuses every turn, so it's set here rather
|
|
2181
|
+
// than in a second step nobody mentions.
|
|
2182
|
+
const allowed = (opts.run ?? "").split(",").map((c) => c.trim()).filter(Boolean);
|
|
2183
|
+
// Writing and running are read-and-then-some: neither is reachable at all
|
|
2184
|
+
// without --tools, so asking for one without it is a mistake worth naming.
|
|
2185
|
+
if ((opts.write || allowed.length) && !opts.tools) {
|
|
2186
|
+
fail("--write and --run need --tools as well — they're the writing half of it");
|
|
2187
|
+
}
|
|
2188
|
+
const options = {
|
|
2189
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
2190
|
+
...(opts.provider ? { provider: opts.provider } : {}),
|
|
2191
|
+
...(opts.tools ? { tools: true } : {}),
|
|
2192
|
+
...(opts.write ? { write: true } : {}),
|
|
2193
|
+
...(allowed.length ? { run: allowed } : {}),
|
|
2194
|
+
};
|
|
2195
|
+
if (kind === "model" && !opts.model) {
|
|
2196
|
+
fail('a model agent needs a model: --model "<id>" (see loom models)');
|
|
2197
|
+
}
|
|
2198
|
+
try {
|
|
2199
|
+
const added = await client.addAgent(project.id, kind, {
|
|
2200
|
+
...opts,
|
|
2201
|
+
...(Object.keys(options).length ? { options } : {}),
|
|
2202
|
+
});
|
|
2203
|
+
const siblings = project.agents.filter((a) => a.kind === kind).length;
|
|
2204
|
+
console.log(`${pc.green("+")} ${pc.bold(added.id)} ${pc.dim(`(${added.kind} · ${added.role})`)}` +
|
|
2205
|
+
(siblings ? pc.dim(` — session ${siblings + 1} of ${kind}, sharing this project's brain`) : ""));
|
|
2206
|
+
}
|
|
2207
|
+
catch (err) {
|
|
2208
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2209
|
+
process.exitCode = 1;
|
|
2210
|
+
}
|
|
222
2211
|
});
|
|
223
2212
|
program
|
|
224
|
-
.command("
|
|
225
|
-
.description("
|
|
226
|
-
.action(async () => {
|
|
2213
|
+
.command("agents:rm <agentId>")
|
|
2214
|
+
.description("remove an agent session from this project")
|
|
2215
|
+
.action(async (agentId) => {
|
|
227
2216
|
const client = await ensureDaemon();
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
2217
|
+
const project = await currentProject(client);
|
|
2218
|
+
try {
|
|
2219
|
+
await client.removeAgent(project.id, agentId);
|
|
2220
|
+
console.log(`${pc.red("-")} ${agentId} removed`);
|
|
2221
|
+
}
|
|
2222
|
+
catch (err) {
|
|
2223
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2224
|
+
process.exitCode = 1;
|
|
2225
|
+
}
|
|
233
2226
|
});
|
|
234
2227
|
program
|
|
235
|
-
.command("agents")
|
|
236
|
-
.description("agents
|
|
237
|
-
.
|
|
2228
|
+
.command("agents:available")
|
|
2229
|
+
.description("which agents this machine can drive, and how many sessions are here")
|
|
2230
|
+
.option("--json", "print as JSON")
|
|
2231
|
+
.action(async (opts) => {
|
|
238
2232
|
const client = await ensureDaemon();
|
|
239
2233
|
const project = await currentProject(client);
|
|
240
|
-
|
|
241
|
-
|
|
2234
|
+
const { ades } = await client.availableAgents(project.id);
|
|
2235
|
+
if (opts.json)
|
|
2236
|
+
return void console.log(JSON.stringify(ades, null, 2));
|
|
2237
|
+
for (const a of ades) {
|
|
2238
|
+
const state = a.installed === false
|
|
2239
|
+
? pc.dim("not installed")
|
|
2240
|
+
: a.instances
|
|
2241
|
+
? pc.green(`${a.instances} session${a.instances === 1 ? "" : "s"} here`)
|
|
2242
|
+
: pc.dim("available");
|
|
2243
|
+
console.log(`${a.kind.padEnd(18)} ${a.label.padEnd(20)} ${state}`);
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
program
|
|
2247
|
+
.command("models [agentId]")
|
|
2248
|
+
.description("with an agent: the models it reports. Without: what every configured provider has")
|
|
2249
|
+
.option("--provider <id>", "just this provider")
|
|
2250
|
+
.option("--refresh", "ask again instead of trusting the cache")
|
|
2251
|
+
.option("--free", "only the ones that cost nothing")
|
|
2252
|
+
.action(async (agentId, opts) => {
|
|
2253
|
+
// An agent named means "what can THIS agent run" — a CLI reports its own.
|
|
2254
|
+
if (agentId) {
|
|
2255
|
+
const client = await ensureDaemon();
|
|
2256
|
+
const project = await currentProject(client);
|
|
2257
|
+
const { kind, models } = await client.models(project.id, agentId);
|
|
2258
|
+
if (!models.length) {
|
|
2259
|
+
console.log(pc.dim(`${agentId} (${kind}) reports no selectable models`));
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
console.log(pc.bold(`${agentId} (${kind}) · ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
2263
|
+
for (const model of models)
|
|
2264
|
+
console.log(model);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
// No agent: the provider catalogue, which needs no daemon and no project.
|
|
2268
|
+
const refresh = Boolean(opts.refresh);
|
|
2269
|
+
let models;
|
|
2270
|
+
let errors = [];
|
|
2271
|
+
if (opts.provider) {
|
|
2272
|
+
const p = resolveProvider(opts.provider);
|
|
2273
|
+
if (!p)
|
|
2274
|
+
fail(`no provider "${opts.provider}" — loom providers`);
|
|
2275
|
+
const got = await fetchModels(p, refresh ? { refresh: true } : {});
|
|
2276
|
+
models = got.models;
|
|
2277
|
+
if (got.error)
|
|
2278
|
+
errors = [{ provider: p.id, error: got.error }];
|
|
2279
|
+
}
|
|
2280
|
+
else {
|
|
2281
|
+
const got = await allModels(refresh ? { refresh: true } : {});
|
|
2282
|
+
models = got.models;
|
|
2283
|
+
errors = got.errors;
|
|
2284
|
+
}
|
|
2285
|
+
const shown = opts.free ? models.filter((m) => m.free) : models;
|
|
2286
|
+
if (!shown.length)
|
|
2287
|
+
console.log(pc.dim("no models — configure a provider with loom providers:set"));
|
|
2288
|
+
for (const m of shown) {
|
|
2289
|
+
console.log(`${pc.bold(m.id)} ${pc.dim(m.provider)}${m.free ? " " + pc.green("free") : ""}` +
|
|
2290
|
+
(m.endpoints ? pc.dim(` [${m.endpoints.join(", ")}]`) : ""));
|
|
2291
|
+
}
|
|
2292
|
+
for (const e of errors)
|
|
2293
|
+
console.log(pc.yellow(`⚠ ${e.provider}: ${e.error}`));
|
|
242
2294
|
});
|
|
243
2295
|
// ---------------------------------------------------------------------------
|
|
244
2296
|
// send / chat / handoff / interrupt / decision / log
|
|
@@ -385,6 +2437,339 @@ program
|
|
|
385
2437
|
const { interrupted } = await client.interrupt(project.id);
|
|
386
2438
|
console.log(pc.dim(interrupted ? `interrupted ${interrupted}` : "nothing running"));
|
|
387
2439
|
});
|
|
2440
|
+
program
|
|
2441
|
+
.command("digest")
|
|
2442
|
+
.description("what happened in this project while you were away")
|
|
2443
|
+
.option("--since <hours>", "how far back to look, in hours", "12")
|
|
2444
|
+
.action(async (opts) => {
|
|
2445
|
+
const client = await ensureDaemon();
|
|
2446
|
+
const project = await currentProject(client);
|
|
2447
|
+
const hours = Math.max(0.1, Number(opts.since) || 12);
|
|
2448
|
+
const d = await client.digest(project.id, Date.now() - hours * 3_600_000);
|
|
2449
|
+
if (!d.lines.length) {
|
|
2450
|
+
console.log(pc.dim(`nothing in the last ${hours} hour${hours === 1 ? "" : "s"}`));
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
const paint = {
|
|
2454
|
+
question: pc.yellow,
|
|
2455
|
+
failed: pc.red,
|
|
2456
|
+
landed: pc.green,
|
|
2457
|
+
goal: pc.cyan,
|
|
2458
|
+
server: pc.red,
|
|
2459
|
+
cost: pc.dim,
|
|
2460
|
+
turn: pc.dim,
|
|
2461
|
+
};
|
|
2462
|
+
for (const line of d.lines) {
|
|
2463
|
+
const when = new Date(line.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
2464
|
+
console.log(`${pc.dim(when)} ${(paint[line.kind] ?? ((x) => x))(line.text)}`);
|
|
2465
|
+
}
|
|
2466
|
+
if (d.waiting.length)
|
|
2467
|
+
console.log(pc.yellow(`\nwaiting on you: ${d.waiting.join(", ")}`));
|
|
2468
|
+
});
|
|
2469
|
+
/**
|
|
2470
|
+
* The project's dev servers — the thing the Browser tab previews.
|
|
2471
|
+
*
|
|
2472
|
+
* `loom servers` says what each one is doing, and "running" means a port
|
|
2473
|
+
* answered rather than a process existing. Starting and stopping are explicit:
|
|
2474
|
+
* nothing here runs a command because it guessed you wanted it.
|
|
2475
|
+
*/
|
|
2476
|
+
const serversCmd = program
|
|
2477
|
+
.command("servers")
|
|
2478
|
+
.description("this project's dev servers: what they are and what they're doing")
|
|
2479
|
+
.action(async () => {
|
|
2480
|
+
const client = await ensureDaemon();
|
|
2481
|
+
const project = await currentProject(client);
|
|
2482
|
+
const { servers, suggested } = await client.servers(project.id);
|
|
2483
|
+
if (!servers.length) {
|
|
2484
|
+
console.log(pc.dim("no servers configured for this project"));
|
|
2485
|
+
if (suggested.length) {
|
|
2486
|
+
console.log(pc.dim("package.json suggests:"));
|
|
2487
|
+
for (const s of suggested)
|
|
2488
|
+
console.log(` ${pc.cyan(s.name.padEnd(10))} ${s.command}${s.port ? pc.dim(` :${s.port}`) : ""}`);
|
|
2489
|
+
console.log(pc.dim('add them under "servers" in .loom/config.json'));
|
|
2490
|
+
}
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const paint = (s) => s.state === "running" ? pc.green("running") : s.state === "starting" ? pc.yellow("starting") : s.state === "crashed" ? pc.red("crashed") : pc.dim("stopped");
|
|
2494
|
+
for (const s of servers) {
|
|
2495
|
+
const up = s.startedAt ? pc.dim(` up ${Math.max(1, Math.round((Date.now() - s.startedAt) / 1000))}s`) : "";
|
|
2496
|
+
const why = s.state === "crashed" && s.exitCode !== null ? pc.dim(` (exit ${s.exitCode})`) : "";
|
|
2497
|
+
console.log(`${pc.cyan(s.name.padEnd(12))} ${paint(s).padEnd(18)} ${s.port ? pc.dim(":" + s.port) : ""}${up}${why}`);
|
|
2498
|
+
console.log(`${" ".repeat(13)}${pc.dim(s.command)}`);
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
for (const action of ["start", "stop", "restart"]) {
|
|
2502
|
+
serversCmd
|
|
2503
|
+
.command(`${action} <name>`)
|
|
2504
|
+
.description(`${action} a dev server`)
|
|
2505
|
+
.action(async (name) => {
|
|
2506
|
+
const client = await ensureDaemon();
|
|
2507
|
+
const project = await currentProject(client);
|
|
2508
|
+
const { server } = await client.serverAction(project.id, name, action);
|
|
2509
|
+
console.log(pc.dim(`${server.name}: ${server.state}${server.port ? ` on :${server.port}` : ""}`));
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
serversCmd
|
|
2513
|
+
.command("logs <name>")
|
|
2514
|
+
.description("a dev server's recent output")
|
|
2515
|
+
.option("-n, --lines <n>", "how many lines", "80")
|
|
2516
|
+
.action(async (name, opts) => {
|
|
2517
|
+
const client = await ensureDaemon();
|
|
2518
|
+
const project = await currentProject(client);
|
|
2519
|
+
const { lines } = await client.serverLog(project.id, name, Math.max(1, Number(opts.lines) || 80));
|
|
2520
|
+
for (const l of lines) {
|
|
2521
|
+
const mark = l.stream === "err" ? pc.red("!") : l.stream === "loom" ? pc.dim("\u00b7") : " ";
|
|
2522
|
+
console.log(`${mark} ${l.text}`);
|
|
2523
|
+
}
|
|
2524
|
+
if (!lines.length)
|
|
2525
|
+
console.log(pc.dim("nothing yet"));
|
|
2526
|
+
});
|
|
2527
|
+
/**
|
|
2528
|
+
* Is there a newer Loom, and fetch it.
|
|
2529
|
+
*
|
|
2530
|
+
* `--check` only looks. Without it, the update runs the same commands the
|
|
2531
|
+
* daemon would print: a checkout pulls and rebuilds, a global install
|
|
2532
|
+
* reinstalls itself, and anything else is told where the release is rather
|
|
2533
|
+
* than having a command guessed at and run over it.
|
|
2534
|
+
*/
|
|
2535
|
+
program
|
|
2536
|
+
.command("update")
|
|
2537
|
+
.description("check for a newer Loom and install it")
|
|
2538
|
+
.option("--check", "only say whether an update exists")
|
|
2539
|
+
.option("--yes", "don't ask before updating")
|
|
2540
|
+
.action(async (opts) => {
|
|
2541
|
+
const client = await ensureDaemon();
|
|
2542
|
+
const u = await client.updates(true);
|
|
2543
|
+
console.log(`installed ${u.version}${u.latest ? ` latest ${u.latest}` : ""} ${pc.dim(u.install)}`);
|
|
2544
|
+
if (!u.behindRelease) {
|
|
2545
|
+
console.log(pc.dim(u.latest ? "up to date" : "no published release to compare with"));
|
|
2546
|
+
if (u.git?.behind)
|
|
2547
|
+
console.log(pc.yellow(`the checkout is ${u.git.behind} commit(s) behind ${u.git.branch}`));
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
console.log(pc.yellow(`Loom ${u.latest} is out`) + (u.release?.url ? pc.dim(` ${u.release.url}`) : ""));
|
|
2551
|
+
if (opts.check)
|
|
2552
|
+
return;
|
|
2553
|
+
if (!u.canApply) {
|
|
2554
|
+
console.log(pc.dim(u.refusal ?? "this install can't update itself"));
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
console.log("this will run:");
|
|
2558
|
+
for (const step of u.steps)
|
|
2559
|
+
console.log(` ${pc.cyan(step)}`);
|
|
2560
|
+
if (!opts.yes) {
|
|
2561
|
+
const ok = await confirm("update now?");
|
|
2562
|
+
if (!ok)
|
|
2563
|
+
return console.log(pc.dim("left as it is"));
|
|
2564
|
+
}
|
|
2565
|
+
await client.applyUpdate();
|
|
2566
|
+
console.log(pc.dim("updating — the daemon restarts on the new build when it finishes (loom log -f to watch)"));
|
|
2567
|
+
});
|
|
2568
|
+
/**
|
|
2569
|
+
* The prompt queue: what you've lined up for this project, run one at a time.
|
|
2570
|
+
*
|
|
2571
|
+
* `loom queue` shows it; `loom queue add` puts one at the back; the rest edit
|
|
2572
|
+
* what's waiting. Positions are what you see in the list (1 is next to run), so
|
|
2573
|
+
* `loom queue rm 2` removes the one printed as 2 — ids work too.
|
|
2574
|
+
*/
|
|
2575
|
+
const queueCmd = program
|
|
2576
|
+
.command("queue")
|
|
2577
|
+
.description("prompts lined up for this project — see, edit, reorder, pause")
|
|
2578
|
+
.action(async () => {
|
|
2579
|
+
const client = await ensureDaemon();
|
|
2580
|
+
const project = await currentProject(client);
|
|
2581
|
+
const view = await client.queue(project.id);
|
|
2582
|
+
if (!view.queue.length) {
|
|
2583
|
+
console.log(pc.dim("nothing queued — loom queue add \"…\" puts one in line"));
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
const where = (t) => t.kind === "orchestra" ? "orchestrate" : t.kind === "auto" ? "auto" : t.agentId;
|
|
2587
|
+
view.queue.forEach((item, i) => {
|
|
2588
|
+
const head = `${pc.dim(String(i + 1).padStart(2))} ${pc.cyan(where(item.target).padEnd(12))}`;
|
|
2589
|
+
const lines = item.text.split("\n");
|
|
2590
|
+
console.log(`${head} ${lines[0]}${lines.length > 1 ? pc.dim(` +${lines.length - 1} more lines`) : ""}`);
|
|
2591
|
+
console.log(`${" ".repeat(15)}${pc.dim(item.id + (item.editedAt ? " · edited" : "") + (item.plan ? " · plan mode" : ""))}`);
|
|
2592
|
+
});
|
|
2593
|
+
const note = view.paused ? pc.yellow(view.reason ?? "paused") : view.waitingFor ? pc.dim(view.waitingFor) : pc.dim("next one goes as soon as it can");
|
|
2594
|
+
console.log(`\n${view.queue.length} queued${view.paused ? pc.yellow(" · paused") : ""} · ${note}`);
|
|
2595
|
+
});
|
|
2596
|
+
/** "15:00", "2026-09-21T03:00", "+90m" — the ways a person says when. */
|
|
2597
|
+
function readWhen(when) {
|
|
2598
|
+
const rel = /^\+(\d+)\s*([smhd])$/i.exec(when.trim());
|
|
2599
|
+
if (rel) {
|
|
2600
|
+
const n = Number(rel[1]);
|
|
2601
|
+
const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
|
|
2602
|
+
return Date.now() + n * unit;
|
|
2603
|
+
}
|
|
2604
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(when.trim());
|
|
2605
|
+
if (clock) {
|
|
2606
|
+
const d = new Date();
|
|
2607
|
+
d.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
2608
|
+
// a time already past today means tomorrow — nobody queues for the past
|
|
2609
|
+
if (d.getTime() <= Date.now())
|
|
2610
|
+
d.setDate(d.getDate() + 1);
|
|
2611
|
+
return d.getTime();
|
|
2612
|
+
}
|
|
2613
|
+
const parsed = Date.parse(when);
|
|
2614
|
+
if (Number.isFinite(parsed))
|
|
2615
|
+
return parsed;
|
|
2616
|
+
throw new Error(`I can't read "${when}" as a time — try 15:00, +90m, or 2026-09-21T03:00`);
|
|
2617
|
+
}
|
|
2618
|
+
/** The item you meant: a position from the printed list, or an id. */
|
|
2619
|
+
async function queueItemId(client, projectId, which) {
|
|
2620
|
+
const { queue } = await client.queue(projectId);
|
|
2621
|
+
const n = Number(which);
|
|
2622
|
+
if (Number.isInteger(n) && n >= 1 && n <= queue.length)
|
|
2623
|
+
return queue[n - 1].id;
|
|
2624
|
+
const hit = queue.find((i) => i.id === which);
|
|
2625
|
+
if (!hit)
|
|
2626
|
+
throw new Error(`no queued prompt "${which}" — loom queue lists them`);
|
|
2627
|
+
return hit.id;
|
|
2628
|
+
}
|
|
2629
|
+
queueCmd
|
|
2630
|
+
.command("add <text...>")
|
|
2631
|
+
.description("line a prompt up behind whatever is running")
|
|
2632
|
+
.option("--to <target>", 'who takes it: an agent id, "orchestrate" for a new goal, or "auto"', "auto")
|
|
2633
|
+
.option("--plan", "queue it in plan mode")
|
|
2634
|
+
.action(async (text, opts) => {
|
|
2635
|
+
const client = await ensureDaemon();
|
|
2636
|
+
const project = await currentProject(client);
|
|
2637
|
+
const target = opts.to === "orchestrate" ? "orchestra" : opts.to;
|
|
2638
|
+
const res = await client.queueAdd(project.id, { text: text.join(" "), target, ...(opts.plan ? { plan: true } : {}) });
|
|
2639
|
+
console.log(pc.dim(`queued ${res.item.id} · ${res.queue.length} waiting`));
|
|
2640
|
+
});
|
|
2641
|
+
queueCmd
|
|
2642
|
+
.command("edit <which> <text...>")
|
|
2643
|
+
.description("rewrite a queued prompt (by position or id)")
|
|
2644
|
+
.action(async (which, text) => {
|
|
2645
|
+
const client = await ensureDaemon();
|
|
2646
|
+
const project = await currentProject(client);
|
|
2647
|
+
const id = await queueItemId(client, project.id, which);
|
|
2648
|
+
await client.queueEdit(project.id, id, { text: text.join(" ") });
|
|
2649
|
+
console.log(pc.dim(`edited ${id}`));
|
|
2650
|
+
});
|
|
2651
|
+
queueCmd
|
|
2652
|
+
.command("to <which> <target>")
|
|
2653
|
+
.description('send a waiting prompt somewhere else: an agent id, "orchestrate" or "auto"')
|
|
2654
|
+
.action(async (which, target) => {
|
|
2655
|
+
const client = await ensureDaemon();
|
|
2656
|
+
const project = await currentProject(client);
|
|
2657
|
+
const id = await queueItemId(client, project.id, which);
|
|
2658
|
+
await client.queueEdit(project.id, id, { target: target === "orchestrate" ? "orchestra" : target });
|
|
2659
|
+
console.log(pc.dim(`${id} → ${target}`));
|
|
2660
|
+
});
|
|
2661
|
+
queueCmd
|
|
2662
|
+
.command("move <which> <position>")
|
|
2663
|
+
.description("move a queued prompt (1 is next to run)")
|
|
2664
|
+
.action(async (which, position) => {
|
|
2665
|
+
const client = await ensureDaemon();
|
|
2666
|
+
const project = await currentProject(client);
|
|
2667
|
+
const id = await queueItemId(client, project.id, which);
|
|
2668
|
+
await client.queueEdit(project.id, id, { to: Math.max(0, Number(position) - 1) });
|
|
2669
|
+
console.log(pc.dim(`moved ${id} to ${position}`));
|
|
2670
|
+
});
|
|
2671
|
+
queueCmd
|
|
2672
|
+
.command("rm <which>")
|
|
2673
|
+
.description("drop a queued prompt before it runs")
|
|
2674
|
+
.action(async (which) => {
|
|
2675
|
+
const client = await ensureDaemon();
|
|
2676
|
+
const project = await currentProject(client);
|
|
2677
|
+
const id = await queueItemId(client, project.id, which);
|
|
2678
|
+
const res = await client.queueRemove(project.id, id);
|
|
2679
|
+
console.log(pc.dim(`removed ${id} · ${res.queue.length} waiting`));
|
|
2680
|
+
});
|
|
2681
|
+
queueCmd
|
|
2682
|
+
.command("at <when> <text...>")
|
|
2683
|
+
.description('queue a prompt for later — "15:00", "2026-09-21T03:00", or "+90m"')
|
|
2684
|
+
.option("--to <target>", 'who takes it: an agent id, "orchestrate" or "auto"', "auto")
|
|
2685
|
+
.action(async (when, text, opts) => {
|
|
2686
|
+
const client = await ensureDaemon();
|
|
2687
|
+
const project = await currentProject(client);
|
|
2688
|
+
const at = readWhen(when);
|
|
2689
|
+
const res = await client.queueAdd(project.id, {
|
|
2690
|
+
text: text.join(" "),
|
|
2691
|
+
target: opts.to === "orchestrate" ? "orchestra" : opts.to,
|
|
2692
|
+
when: { kind: "at", at },
|
|
2693
|
+
});
|
|
2694
|
+
console.log(pc.dim(`queued ${res.item.id} for ${new Date(at).toLocaleString()}`));
|
|
2695
|
+
});
|
|
2696
|
+
queueCmd
|
|
2697
|
+
.command("after <what> <text...>")
|
|
2698
|
+
.description('queue a prompt until a goal lands or its checks go green: "landed:<runId>" or "green:<runId>"')
|
|
2699
|
+
.option("--to <target>", "who takes it", "auto")
|
|
2700
|
+
.action(async (what, text, opts) => {
|
|
2701
|
+
const client = await ensureDaemon();
|
|
2702
|
+
const project = await currentProject(client);
|
|
2703
|
+
const [kind, runId] = what.split(":");
|
|
2704
|
+
if (!runId || (kind !== "landed" && kind !== "green")) {
|
|
2705
|
+
throw new Error('say landed:<runId> or green:<runId> — loom orchestra lists the runs');
|
|
2706
|
+
}
|
|
2707
|
+
const res = await client.queueAdd(project.id, {
|
|
2708
|
+
text: text.join(" "),
|
|
2709
|
+
target: opts.to === "orchestrate" ? "orchestra" : opts.to,
|
|
2710
|
+
when: { kind: kind === "landed" ? "landed" : "checks-green", runId },
|
|
2711
|
+
});
|
|
2712
|
+
console.log(pc.dim(`queued ${res.item.id}, waiting for ${runId}`));
|
|
2713
|
+
});
|
|
2714
|
+
queueCmd
|
|
2715
|
+
.command("save <name>")
|
|
2716
|
+
.description("save what's queued as a recipe you can run anywhere")
|
|
2717
|
+
.action(async (name) => {
|
|
2718
|
+
const client = await ensureDaemon();
|
|
2719
|
+
const project = await currentProject(client);
|
|
2720
|
+
const { recipe } = await client.saveQueueRecipe(project.id, name);
|
|
2721
|
+
console.log(pc.dim(`saved "${recipe.name}" — ${recipe.steps.length} step(s)`));
|
|
2722
|
+
});
|
|
2723
|
+
queueCmd
|
|
2724
|
+
.command("run <name>")
|
|
2725
|
+
.description("queue a saved recipe on this project")
|
|
2726
|
+
.action(async (name) => {
|
|
2727
|
+
const client = await ensureDaemon();
|
|
2728
|
+
const project = await currentProject(client);
|
|
2729
|
+
const res = await client.runQueueRecipe(project.id, name);
|
|
2730
|
+
console.log(pc.dim(`queued ${res.added} step(s) — ${res.queue.length} waiting`));
|
|
2731
|
+
});
|
|
2732
|
+
queueCmd
|
|
2733
|
+
.command("recipes")
|
|
2734
|
+
.description("the recipes saved on this machine")
|
|
2735
|
+
.action(async () => {
|
|
2736
|
+
const client = await ensureDaemon();
|
|
2737
|
+
const { recipes } = await client.recipes();
|
|
2738
|
+
if (!recipes.length)
|
|
2739
|
+
return console.log(pc.dim("none saved — loom queue save <name>"));
|
|
2740
|
+
for (const r of recipes) {
|
|
2741
|
+
console.log(`${pc.cyan(r.name)} ${pc.dim(`${r.steps.length} step(s)${r.fromProject ? ` · from ${r.fromProject}` : ""}`)}`);
|
|
2742
|
+
for (const s of r.steps)
|
|
2743
|
+
console.log(` ${pc.dim(s.to.padEnd(12))} ${s.text.split("\n")[0].slice(0, 80)}`);
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2746
|
+
queueCmd
|
|
2747
|
+
.command("clear")
|
|
2748
|
+
.description("drop everything that's waiting")
|
|
2749
|
+
.action(async () => {
|
|
2750
|
+
const client = await ensureDaemon();
|
|
2751
|
+
const project = await currentProject(client);
|
|
2752
|
+
const res = await client.queueClear(project.id);
|
|
2753
|
+
console.log(pc.dim(`dropped ${res.dropped}`));
|
|
2754
|
+
});
|
|
2755
|
+
queueCmd
|
|
2756
|
+
.command("pause")
|
|
2757
|
+
.description("hold the queue where it is")
|
|
2758
|
+
.action(async () => {
|
|
2759
|
+
const client = await ensureDaemon();
|
|
2760
|
+
const project = await currentProject(client);
|
|
2761
|
+
await client.queuePause(project.id, true);
|
|
2762
|
+
console.log(pc.dim("paused — loom queue resume lets it run"));
|
|
2763
|
+
});
|
|
2764
|
+
queueCmd
|
|
2765
|
+
.command("resume")
|
|
2766
|
+
.description("let the queue run again")
|
|
2767
|
+
.action(async () => {
|
|
2768
|
+
const client = await ensureDaemon();
|
|
2769
|
+
const project = await currentProject(client);
|
|
2770
|
+
const view = await client.queuePause(project.id, false);
|
|
2771
|
+
console.log(pc.dim(`running · ${view.queue.length} waiting`));
|
|
2772
|
+
});
|
|
388
2773
|
const memoryCmd = program
|
|
389
2774
|
.command("memory")
|
|
390
2775
|
.description("the unified brain — one memory across every connected ADE")
|
|
@@ -410,9 +2795,23 @@ memoryCmd
|
|
|
410
2795
|
program
|
|
411
2796
|
.command("costs")
|
|
412
2797
|
.description("what this project has spent, per agent")
|
|
413
|
-
.
|
|
2798
|
+
.option("--series [days]", "daily breakdown instead of totals (default 14 days)")
|
|
2799
|
+
.action(async (opts) => {
|
|
414
2800
|
const client = await ensureDaemon();
|
|
415
2801
|
const project = await currentProject(client);
|
|
2802
|
+
if (opts.series) {
|
|
2803
|
+
const days = typeof opts.series === "string" ? Number(opts.series) || 14 : 14;
|
|
2804
|
+
const { series } = await client.costSeries(project.id, days);
|
|
2805
|
+
if (!series.length)
|
|
2806
|
+
return void console.log(pc.dim("no spend in this window"));
|
|
2807
|
+
for (const d of series) {
|
|
2808
|
+
const agents = Object.entries(d.byAgent)
|
|
2809
|
+
.map(([id, a]) => `${id} ${fmtUsd(a.usd)}`)
|
|
2810
|
+
.join(" ");
|
|
2811
|
+
console.log(`${d.day} ${fmtUsd(d.usd).padStart(9)} ${String(d.turns).padStart(4)} turns ${pc.dim(agents)}`);
|
|
2812
|
+
}
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
416
2815
|
const { costs } = await client.costs(project.id);
|
|
417
2816
|
console.log(pc.bold(`${project.name}`) +
|
|
418
2817
|
pc.dim(` total ${fmtUsd(costs.totalUsd)} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
|
|
@@ -491,7 +2890,9 @@ program
|
|
|
491
2890
|
return;
|
|
492
2891
|
}
|
|
493
2892
|
if (!spec || !words.length) {
|
|
494
|
-
fail('usage: loom route <name|steps> "<task>" e.g. loom route planner,executor "add dark mode"\n
|
|
2893
|
+
fail('usage: loom route <name|steps> "<task>" e.g. loom route planner,executor "add dark mode"\n' +
|
|
2894
|
+
" a step runs only if the previous turn meets its condition: 'planner,executor,reviewer?lines>200'\n" +
|
|
2895
|
+
" (or: loom route --status / --abort)");
|
|
495
2896
|
}
|
|
496
2897
|
const { route } = await client.startRoute(project.id, words.join(" "), spec, {
|
|
497
2898
|
...(opts.router ? { router: opts.router } : {}),
|
|
@@ -547,33 +2948,51 @@ program
|
|
|
547
2948
|
program
|
|
548
2949
|
.command("doctor")
|
|
549
2950
|
.description("diagnose the environment, daemon, and current project")
|
|
550
|
-
.
|
|
551
|
-
|
|
552
|
-
|
|
2951
|
+
.option("--json", "print machine-readable diagnostic results")
|
|
2952
|
+
.option("--fix", "repair what has exactly one safe repair; report the rest")
|
|
2953
|
+
.action(async (opts) => {
|
|
2954
|
+
const { doctorReport, envChecks, projectChecks, fixProject } = await import("./doctor.js");
|
|
553
2955
|
const dir = currentProjectDir();
|
|
2956
|
+
if (opts.fix) {
|
|
2957
|
+
if (!dir) {
|
|
2958
|
+
console.error(pc.red("not inside a Loom project — nothing to fix"));
|
|
2959
|
+
process.exitCode = 1;
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
const { fixed, unfixable } = fixProject(dir);
|
|
2963
|
+
for (const f of fixed)
|
|
2964
|
+
console.log(` ${pc.green("✓")} ${f}`);
|
|
2965
|
+
for (const u of unfixable)
|
|
2966
|
+
console.log(` ${pc.yellow("⚠")} ${u}`);
|
|
2967
|
+
if (!fixed.length && !unfixable.length)
|
|
2968
|
+
console.log(pc.dim("nothing needed fixing"));
|
|
2969
|
+
// fall through to a fresh diagnosis, so the report reflects the repairs
|
|
2970
|
+
}
|
|
2971
|
+
const checks = await envChecks();
|
|
554
2972
|
if (dir)
|
|
555
2973
|
checks.push(...projectChecks(dir));
|
|
556
2974
|
else
|
|
557
2975
|
checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
|
|
558
|
-
|
|
559
|
-
|
|
2976
|
+
const report = doctorReport(checks);
|
|
2977
|
+
if (opts.json) {
|
|
2978
|
+
console.log(JSON.stringify(report));
|
|
2979
|
+
if (report.summary.failures)
|
|
2980
|
+
process.exitCode = 1;
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
560
2983
|
for (const c of checks) {
|
|
561
2984
|
const icon = c.status === "ok" ? pc.green("✓") : c.status === "warn" ? pc.yellow("⚠") : pc.red("✗");
|
|
562
|
-
if (c.status === "fail")
|
|
563
|
-
failures++;
|
|
564
|
-
if (c.status === "warn")
|
|
565
|
-
warnings++;
|
|
566
2985
|
console.log(` ${icon} ${pc.bold(c.name.padEnd(11))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
|
|
567
2986
|
}
|
|
568
|
-
if (failures) {
|
|
569
|
-
console.log(pc.red(`\n${failures} problem${failures > 1 ? "s" : ""} found`));
|
|
2987
|
+
if (report.summary.failures) {
|
|
2988
|
+
console.log(pc.red(`\n${report.summary.failures} problem${report.summary.failures > 1 ? "s" : ""} found`));
|
|
570
2989
|
process.exit(1);
|
|
571
2990
|
}
|
|
572
2991
|
// "all clear" over a screen of warnings is a lie, and it's the lie that
|
|
573
2992
|
// teaches people to stop reading this command. Nothing is broken; several
|
|
574
2993
|
// things aren't set up. Those are different sentences.
|
|
575
|
-
if (warnings) {
|
|
576
|
-
console.log(pc.yellow(`\nnothing broken · ${warnings} thing${warnings > 1 ? "s" : ""} not set up (see above)`));
|
|
2994
|
+
if (report.summary.warnings) {
|
|
2995
|
+
console.log(pc.yellow(`\nnothing broken · ${report.summary.warnings} thing${report.summary.warnings > 1 ? "s" : ""} not set up (see above)`));
|
|
577
2996
|
return;
|
|
578
2997
|
}
|
|
579
2998
|
console.log(pc.green("\nall clear"));
|
|
@@ -625,15 +3044,160 @@ program
|
|
|
625
3044
|
" testing on this machine only? loom pair --allow-local"));
|
|
626
3045
|
process.exit(1);
|
|
627
3046
|
}
|
|
628
|
-
const
|
|
3047
|
+
const minted = await client.newPairingToken();
|
|
3048
|
+
const { token, expiresAt, url } = minted;
|
|
629
3049
|
// Deep link: scanning with any camera opens the phone app, which claims
|
|
630
3050
|
// the (single-use, 10-min) token from the URL fragment and pairs itself.
|
|
631
|
-
|
|
3051
|
+
// The daemon's own link carries the Loom Cloud route when Cloud is on, so
|
|
3052
|
+
// a phone paired from the terminal can reach home from any network too.
|
|
3053
|
+
const link = minted.link ?? `${url}/app#pair=${token}`;
|
|
632
3054
|
qrcode.generate(link, { small: true }, (qr) => console.log(qr));
|
|
633
3055
|
console.log(pc.bold(` ${link}`));
|
|
634
3056
|
console.log(pc.dim(` scan with your phone camera · single use · expires ${new Date(expiresAt).toLocaleTimeString()}`));
|
|
635
3057
|
console.log(pc.dim(` (manual claim: POST ${url}/api/pair/claim {"token":"${token.slice(0, 6)}…"})`));
|
|
636
3058
|
});
|
|
3059
|
+
program
|
|
3060
|
+
.command("backup")
|
|
3061
|
+
.description("everything Loom knows — settings, pairings, prompts, every project's log and brain — in one .tar.gz")
|
|
3062
|
+
.option("--out <file>", "where to write it (default: ~/loom-backup-<time>.tar.gz)")
|
|
3063
|
+
.action((opts) => {
|
|
3064
|
+
const r = makeBackup(opts.out);
|
|
3065
|
+
const mb = (r.bytes / 1024 / 1024).toFixed(1);
|
|
3066
|
+
console.log(pc.green(`✓ backed up ${r.projects} project${r.projects === 1 ? "" : "s"} and ~/.loom → ${r.file} (${mb} MB)`));
|
|
3067
|
+
console.log(pc.dim(" left out: orchestra worktrees (their branches are in your repos) and the model cache"));
|
|
3068
|
+
console.log(pc.yellow(" it holds your pairing tokens and cloud keys — keep it somewhere private"));
|
|
3069
|
+
});
|
|
3070
|
+
program
|
|
3071
|
+
.command("export")
|
|
3072
|
+
.description("write a chat as Markdown — prompts, replies, what was done, what changed")
|
|
3073
|
+
.option("--chat <chat>", "the chat's id or title (default: main)", "main")
|
|
3074
|
+
.option("--out <file>", "write to a file instead of printing")
|
|
3075
|
+
.action(async (opts) => {
|
|
3076
|
+
const client = await ensureDaemon();
|
|
3077
|
+
const project = await currentProject(client);
|
|
3078
|
+
const { chats } = await client.chats(project.id);
|
|
3079
|
+
const want = opts.chat.trim().toLowerCase();
|
|
3080
|
+
const chat = chats.find((c) => c.id === opts.chat) ?? chats.find((c) => c.title.toLowerCase() === want);
|
|
3081
|
+
if (!chat)
|
|
3082
|
+
fail(`no chat "${opts.chat}" in ${project.name} — try one of: ${chats.map((c) => c.title).join(", ")}`);
|
|
3083
|
+
let events = [];
|
|
3084
|
+
let before;
|
|
3085
|
+
for (let page = 0; page < 20; page++) {
|
|
3086
|
+
const { events: evs } = await client.chatEvents(project.id, chat.id, { ...(before ? { before } : {}), limit: 500 });
|
|
3087
|
+
events = [...evs, ...events];
|
|
3088
|
+
if (evs.length < 500)
|
|
3089
|
+
break;
|
|
3090
|
+
before = evs[0].id;
|
|
3091
|
+
}
|
|
3092
|
+
const md = threadMarkdown(events, `${project.name} — ${chat.title}`);
|
|
3093
|
+
if (!opts.out)
|
|
3094
|
+
return void process.stdout.write(md);
|
|
3095
|
+
fs.writeFileSync(opts.out, md);
|
|
3096
|
+
console.error(pc.green(`✓ ${events.length} events → ${opts.out}`));
|
|
3097
|
+
});
|
|
3098
|
+
program
|
|
3099
|
+
.command("replay")
|
|
3100
|
+
.description("a chat as one self-contained HTML page to share — prompts, replies, what ran, what changed, what Loom checked")
|
|
3101
|
+
.option("--chat <chat>", "the chat's id or title (default: main)", "main")
|
|
3102
|
+
.option("--out <file>", "where to write it (default: <project>-<chat>.replay.html)")
|
|
3103
|
+
.option("--open", "open it in your browser")
|
|
3104
|
+
.action(async (opts) => {
|
|
3105
|
+
const client = await ensureDaemon();
|
|
3106
|
+
const project = await currentProject(client);
|
|
3107
|
+
const want = opts.chat.trim().toLowerCase();
|
|
3108
|
+
let chat = { id: "main", title: "Main" };
|
|
3109
|
+
if (want !== "main") {
|
|
3110
|
+
const { chats } = await client.chats(project.id);
|
|
3111
|
+
const found = chats.find((c) => c.id === opts.chat) ?? chats.find((c) => c.title.toLowerCase() === want) ?? chats.find((c) => c.title.toLowerCase().includes(want));
|
|
3112
|
+
if (!found)
|
|
3113
|
+
fail(`no chat "${opts.chat}" in ${project.name} — try one of: ${chats.map((c) => c.title).join(", ")}`);
|
|
3114
|
+
chat = found;
|
|
3115
|
+
}
|
|
3116
|
+
const html = await client.replay(project.id, chat.id);
|
|
3117
|
+
const out = opts.out ?? `${project.name}-${chat.title}`.replace(/[^\w.-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() + ".replay.html";
|
|
3118
|
+
fs.writeFileSync(out, html);
|
|
3119
|
+
console.log(pc.green(`✓ ${out}`) + pc.dim(` — opens in any browser, nothing to install`));
|
|
3120
|
+
if (opts.open) {
|
|
3121
|
+
const { spawn: sp } = await import("node:child_process");
|
|
3122
|
+
sp(process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open", [out], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).on("error", () => { }).unref();
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
program
|
|
3126
|
+
.command("scorecard")
|
|
3127
|
+
.description("each agent's record here: turns, how often it finished, how often its \"done\" held up when Proof checked, circles, shield trips, and cost per verified result")
|
|
3128
|
+
.option("--days <n>", "only the last n days (default: everything)")
|
|
3129
|
+
.action(async (opts) => {
|
|
3130
|
+
const client = await ensureDaemon();
|
|
3131
|
+
const project = await currentProject(client);
|
|
3132
|
+
const since = opts.days ? Date.now() - Math.max(1, Number(opts.days) || 1) * 86_400_000 : undefined;
|
|
3133
|
+
const t = await client.turnStats(project.id, { ...(since ? { since } : {}), days: 7 });
|
|
3134
|
+
if (!t.leaderboard.length)
|
|
3135
|
+
return void console.log(pc.dim("no finished turns yet — the scorecard fills in as agents work"));
|
|
3136
|
+
const pct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
3137
|
+
console.log(pc.bold(`${project.name}${opts.days ? ` · last ${opts.days} days` : ""}`));
|
|
3138
|
+
console.log(pc.dim(` ${"agent".padEnd(16)}${"turns".padStart(6)}${"finished".padStart(10)}${"held up".padStart(10)}${"circles".padStart(9)}${"shield".padStart(8)} per verified`));
|
|
3139
|
+
for (const a of t.leaderboard) {
|
|
3140
|
+
const tr = a.trust ?? {};
|
|
3141
|
+
const checked = (tr.verified ?? 0) + (tr.contradicted ?? 0);
|
|
3142
|
+
const held = checked ? `${tr.verified}/${checked}` : "—";
|
|
3143
|
+
const tone = tr.holdRate == null ? (x) => x : tr.holdRate >= 0.9 ? pc.green : tr.holdRate >= 0.7 ? pc.yellow : pc.red;
|
|
3144
|
+
console.log(` ${String(a.agentId).padEnd(16)}${String(a.turns).padStart(6)}${pct(a.successRate).padStart(10)}${tone(held.padStart(10))}${String(tr.loops ?? 0).padStart(9)}${String(tr.shield ?? 0).padStart(8)} ${tr.costPerVerifiedUsd != null ? `$${Number(tr.costPerVerifiedUsd).toFixed(2)}` : "—"}`);
|
|
3145
|
+
}
|
|
3146
|
+
});
|
|
3147
|
+
program
|
|
3148
|
+
.command("stats")
|
|
3149
|
+
.description("today across every project: turns, how many finished cleanly, spend, busiest agent")
|
|
3150
|
+
.option("--days <n>", "look back this many days instead of just today", "1")
|
|
3151
|
+
.action(async (opts) => {
|
|
3152
|
+
const client = await ensureDaemon();
|
|
3153
|
+
const days = Math.max(1, Math.min(90, Number(opts.days) || 1));
|
|
3154
|
+
const since = new Date();
|
|
3155
|
+
since.setHours(0, 0, 0, 0);
|
|
3156
|
+
since.setDate(since.getDate() - (days - 1));
|
|
3157
|
+
const { projects } = await client.listProjects();
|
|
3158
|
+
let turns = 0, errors = 0, cost = 0;
|
|
3159
|
+
const byAgent = new Map();
|
|
3160
|
+
const rows = [];
|
|
3161
|
+
for (const p of projects) {
|
|
3162
|
+
const t = await client.turnStats(p.id, { since: since.getTime(), days: 7 }).catch(() => null);
|
|
3163
|
+
if (!t)
|
|
3164
|
+
continue;
|
|
3165
|
+
const pt = t.leaderboard.reduce((a, x) => a + x.turns, 0);
|
|
3166
|
+
if (!pt)
|
|
3167
|
+
continue;
|
|
3168
|
+
const pe = t.leaderboard.reduce((a, x) => a + x.errors, 0);
|
|
3169
|
+
const pc$ = t.leaderboard.reduce((a, x) => a + x.totalCostUsd, 0);
|
|
3170
|
+
turns += pt;
|
|
3171
|
+
errors += pe;
|
|
3172
|
+
cost += pc$;
|
|
3173
|
+
for (const x of t.leaderboard)
|
|
3174
|
+
byAgent.set(x.agentId, (byAgent.get(x.agentId) ?? 0) + x.turns);
|
|
3175
|
+
rows.push(` ${pc.bold(p.name.padEnd(18))} ${String(pt).padStart(4)} turns ${pe ? pc.red(`${pe} failed`) : pc.green("all clean")} ${fmtUsd(pc$)}`);
|
|
3176
|
+
}
|
|
3177
|
+
const label = days === 1 ? "today" : `the last ${days} days`;
|
|
3178
|
+
if (!turns)
|
|
3179
|
+
return void console.log(pc.dim(`no turns ${label}`));
|
|
3180
|
+
const busiest = [...byAgent.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
3181
|
+
const clean = Math.round(((turns - errors) / turns) * 100);
|
|
3182
|
+
console.log(pc.bold(`${label}: ${turns} turn${turns === 1 ? "" : "s"} · ${clean}% finished cleanly · ${fmtUsd(cost)} · busiest ${busiest[0]} (${busiest[1]})`));
|
|
3183
|
+
for (const r of rows)
|
|
3184
|
+
console.log(r);
|
|
3185
|
+
});
|
|
3186
|
+
program
|
|
3187
|
+
.command("completion")
|
|
3188
|
+
.description("print a shell completion script: loom completion zsh >> ~/.zshrc (or bash >> ~/.bashrc)")
|
|
3189
|
+
.argument("<shell>", "zsh or bash")
|
|
3190
|
+
.action((shell) => {
|
|
3191
|
+
const names = program.commands.map((c) => c.name()).filter((n) => n && n !== "completion").sort();
|
|
3192
|
+
if (shell === "bash") {
|
|
3193
|
+
console.log(`complete -W "${names.join(" ")} completion" loom`);
|
|
3194
|
+
}
|
|
3195
|
+
else if (shell === "zsh") {
|
|
3196
|
+
console.log(`_loom() { compadd -- ${names.join(" ")} completion; }\ncompdef _loom loom 2>/dev/null || { autoload -Uz compinit && compinit && compdef _loom loom; }`);
|
|
3197
|
+
}
|
|
3198
|
+
else
|
|
3199
|
+
fail(`unknown shell "${shell}" — zsh or bash`);
|
|
3200
|
+
});
|
|
637
3201
|
program.parseAsync().catch((err) => {
|
|
638
3202
|
console.error(pc.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
639
3203
|
process.exit(1);
|