@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/daemon/runtime.js
CHANGED
|
@@ -1,57 +1,278 @@
|
|
|
1
|
-
|
|
2
|
-
* ProjectRuntime — one live project inside the daemon: its event log, its
|
|
3
|
-
* agents, and its baton. All mutations flow through here so the log stays
|
|
4
|
-
* the single source of truth.
|
|
5
|
-
*/
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
6
2
|
import fs from "node:fs";
|
|
7
3
|
import path from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
4
|
+
import { createAgent, isWithdrawnKind, knownAgentKinds, tierForKind } from "../adapters/index.js";
|
|
5
|
+
import { ModelAdapter } from "../adapters/model.js";
|
|
6
|
+
import { ADES, detectAdes } from "../core/ades.js";
|
|
7
|
+
import { BatonManager } from "../core/baton.js";
|
|
8
|
+
import { Brain } from "../core/brain.js";
|
|
9
|
+
import { helperText } from "../core/provider-text.js";
|
|
10
|
+
import { ConversationStore } from "../core/conversations.js";
|
|
11
|
+
import * as checkpoints from "../core/checkpoint.js";
|
|
16
12
|
import { renderProjection } from "../core/distill.js";
|
|
17
|
-
import {
|
|
13
|
+
import { EventLog } from "../core/eventlog.js";
|
|
14
|
+
import { ensureBranch, addWorktree as gitAddWorktree, readOut, worktreePath } from "../core/git.js";
|
|
15
|
+
import { logbook } from "../core/logbook.js";
|
|
16
|
+
import { probeMcpServer, probeMcpServers, writeMcpSession } from "../core/mcp.js";
|
|
17
|
+
import { NativeUsage } from "./runtime/native-usage.js";
|
|
18
18
|
import { notify } from "../core/notify.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
19
|
+
import { OrchestraEngine } from "../core/orchestra.js";
|
|
20
|
+
import { CrewEngine } from "../core/crew.js";
|
|
21
|
+
import { LoopWatch, nudgeText } from "../core/loops.js";
|
|
22
|
+
import { commandOf, dangerOf } from "../core/shield.js";
|
|
23
|
+
import { QuotaRelay } from "./runtime/relay.js";
|
|
24
|
+
import { ProofRunner } from "./proof.js";
|
|
25
|
+
import { NightShift } from "./shift.js";
|
|
26
|
+
import { isPermissionMode, permissionFor, unsupportedReason } from "../core/permissions.js";
|
|
27
|
+
import { startPreviewProxy } from "../core/preview-proxy.js";
|
|
28
|
+
import { PromptQueue } from "../core/prompt-queue.js";
|
|
29
|
+
import { newId, projectLoomDir, readProjectConfig, readProjectState, writeMemoryFile, writeProjectConfig, writeProjectState, } from "../core/registry.js";
|
|
30
|
+
import { RouteEngine, resolveSteps } from "../core/routes.js";
|
|
31
|
+
import { SemanticIndex } from "../core/semantic.js";
|
|
32
|
+
import { Servers } from "../core/servers.js";
|
|
33
|
+
import { SkillInstallError, installSkillFromDir, installSkillFromGit, } from "../core/skill-install.js";
|
|
34
|
+
import { buildSkillsBlock, discoverSkillRoots, loadSkills, } from "../core/skills.js";
|
|
22
35
|
import { suggestHandoff } from "../core/suggestions.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
36
|
+
import { cappedPermission } from "../core/team-policy.js";
|
|
37
|
+
import { capCrewPermission, DEFAULT_CREW_POLICY } from "../core/crew-policy.js";
|
|
38
|
+
import { describeMerge, mergeAgentWork } from "../core/worktree-merge.js";
|
|
39
|
+
import { workingTree } from "../core/worktree.js";
|
|
40
|
+
import { decisionStats, extractDecisions, normalizeStoredDecision, } from "../observability/decisions.js";
|
|
41
|
+
import { turnTraceId } from "../observability/index.js";
|
|
42
|
+
import { GIT_DELIVERIES, MAIN_CHAT, isAdapter } from "../types.js";
|
|
43
|
+
import { MAX_FANOUT, activityLine, configMtimeOf, withLoomAskTimeout } from './runtime-support.js';
|
|
44
|
+
import { RuntimeAccounting } from './runtime/accounting.js';
|
|
45
|
+
import { RuntimeAgents } from './runtime/agents.js';
|
|
46
|
+
import { RuntimeBriefings } from './runtime/briefings.js';
|
|
47
|
+
import { RuntimeQueue } from './runtime/queue.js';
|
|
48
|
+
import { RuntimeTurns } from './runtime/turns.js';
|
|
49
|
+
import { ContinuityEngine } from "../core/continuity/engine.js";
|
|
50
|
+
import { ContinuityError } from "../core/continuity/contracts.js";
|
|
51
|
+
import { HarnessMonitor, isNativeKind } from "../core/continuity/capabilities.js";
|
|
52
|
+
import { ProviderAgent } from "../providers/agent.js";
|
|
53
|
+
import { LiveDeltaThrottle } from "../providers/live.js";
|
|
54
|
+
import { AdapterBase } from "../adapters/base.js";
|
|
55
|
+
export { BudgetExceededError, CLOCK_TICK_MS, LOOM_ASK_TIMEOUT_MESSAGE, LOOM_ASK_TIMEOUT_MS, LoomAskTimeoutError, QuarantinedError, activityLine, planModeBriefing, relativeToProject, withLoomAskTimeout } from './runtime-support.js';
|
|
56
|
+
/**
|
|
57
|
+
* Priority and due date on a card: set when given, cleared by null, refused
|
|
58
|
+
* in words when they don't make sense.
|
|
59
|
+
*/
|
|
60
|
+
function applyCardMeta(task, p) {
|
|
61
|
+
if (p.priority !== undefined) {
|
|
62
|
+
if (p.priority === null || p.priority === "")
|
|
63
|
+
delete task.priority;
|
|
64
|
+
else if (p.priority === "high" || p.priority === "medium" || p.priority === "low")
|
|
65
|
+
task.priority = p.priority;
|
|
66
|
+
else
|
|
67
|
+
throw new Error("priority is high, medium or low");
|
|
68
|
+
}
|
|
69
|
+
if (p.due !== undefined) {
|
|
70
|
+
if (p.due === null || p.due === "")
|
|
71
|
+
delete task.due;
|
|
72
|
+
else if (/^\d{4}-\d{2}-\d{2}$/.test(p.due) && !Number.isNaN(new Date(`${p.due}T00:00:00`).getTime()))
|
|
73
|
+
task.due = p.due;
|
|
74
|
+
else
|
|
75
|
+
throw new Error("due is a date: YYYY-MM-DD");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** A model agent's sampling settings, for its status (empty for everything else). */
|
|
79
|
+
function sampling(cfg) {
|
|
80
|
+
if (cfg.kind !== "model")
|
|
81
|
+
return {};
|
|
82
|
+
const o = (cfg.options ?? {});
|
|
83
|
+
const out = {};
|
|
84
|
+
if (typeof o.temperature === "number")
|
|
85
|
+
out.temperature = o.temperature;
|
|
86
|
+
if (typeof o.maxTokens === "number")
|
|
87
|
+
out.maxTokens = o.maxTokens;
|
|
88
|
+
return { sampling: out };
|
|
89
|
+
}
|
|
25
90
|
export class ProjectRuntime {
|
|
91
|
+
accounting;
|
|
92
|
+
briefings;
|
|
93
|
+
queueCoordinator;
|
|
94
|
+
turns;
|
|
26
95
|
info;
|
|
27
96
|
config;
|
|
28
97
|
log;
|
|
98
|
+
conversations;
|
|
29
99
|
baton;
|
|
30
100
|
routes;
|
|
101
|
+
/** One orchestrator, many parallel workers — see core/orchestra.ts. */
|
|
102
|
+
orchestra;
|
|
103
|
+
/** Agent Teams: crews of agents with roles, one goal at a time (core/crew.ts). */
|
|
104
|
+
crews;
|
|
105
|
+
/** Loop watch: an agent stuck re-running the same failing command (core/loops.ts). */
|
|
106
|
+
loops = new LoopWatch();
|
|
107
|
+
/** agent × chat already nudged since a person last spoke there: a second loop is only reported. */
|
|
108
|
+
nudged = new Set();
|
|
109
|
+
/** agent × chat × command already flagged this turn by the shield. */
|
|
110
|
+
shielded = new Set();
|
|
111
|
+
/** Quota Relay: an agent out of quota mid-task hands its work to the next. */
|
|
112
|
+
relay;
|
|
113
|
+
/** Proof: checks an agent's "done" against what ran and a clean re-run of the tests. */
|
|
114
|
+
proofs;
|
|
115
|
+
/** Night Shift: a line of tasks run while you're away, then one report (daemon/shift.ts). */
|
|
116
|
+
shifts;
|
|
117
|
+
/** Adapter kinds installed on this machine, probed once at open. */
|
|
118
|
+
installedKinds = [];
|
|
31
119
|
/** Memory as units — see core/brain.ts. Reads and writes through `log`. */
|
|
32
120
|
brain;
|
|
33
|
-
|
|
34
|
-
|
|
121
|
+
continuity = null;
|
|
122
|
+
/** Native harness reachability, polled while native continuity is on. */
|
|
123
|
+
harnesses;
|
|
124
|
+
/** Latest context and usage-limit readings from native harnesses. */
|
|
125
|
+
nativeUsage = new NativeUsage();
|
|
126
|
+
agentLifecycle = new RuntimeAgents();
|
|
127
|
+
get agents() { return this.agentLifecycle.agents; }
|
|
35
128
|
configMtime = 0;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
129
|
+
journalFailure = null;
|
|
130
|
+
/** What you've lined up, run one at a time — see core/prompt-queue.ts. */
|
|
131
|
+
queue;
|
|
132
|
+
/** This project's dev servers — see core/servers.ts. */
|
|
133
|
+
servers;
|
|
134
|
+
/** One preview proxy per server — see core/preview-proxy.ts. */
|
|
135
|
+
proxies = new Map();
|
|
136
|
+
serverListeners = new Set();
|
|
137
|
+
streamListeners = new Set();
|
|
138
|
+
liveListeners = new Set();
|
|
139
|
+
/** Streamed text and tool progress from provider agents, coalesced for clients. Not persisted. */
|
|
140
|
+
live = new LiveDeltaThrottle((d) => { for (const cb of this.liveListeners)
|
|
141
|
+
cb(d); });
|
|
42
142
|
constructor(info, config, log) {
|
|
43
143
|
this.info = info;
|
|
44
144
|
this.config = config;
|
|
45
145
|
this.log = log;
|
|
146
|
+
if (config.brain?.continuity === true)
|
|
147
|
+
this.continuity = new ContinuityEngine(log, info.id);
|
|
148
|
+
this.harnesses = new HarnessMonitor(() => this.config.agents.filter(a => a.enabled !== false && isNativeKind(a.kind)).map(a => ({ id: a.id, kind: a.kind, options: this.policyOptions(a) })), (id, next, previous) => {
|
|
149
|
+
if (!next.available)
|
|
150
|
+
logbook.warn("harness", `${id} CLI is not reachable — native turns are refused until it answers`, next.error, info.id);
|
|
151
|
+
else if (previous && !previous.available)
|
|
152
|
+
logbook.info("harness", `${id} CLI is reachable again (${next.version ?? "unknown version"})`, undefined, info.id);
|
|
153
|
+
});
|
|
154
|
+
if (this.continuity)
|
|
155
|
+
this.harnesses.start();
|
|
156
|
+
// The last readings survive a restart: replay the recent reports.
|
|
157
|
+
for (const e of log.list({ kinds: ["status", "run_complete", "error"], limit: 500 }))
|
|
158
|
+
this.nativeUsage.observe(e);
|
|
159
|
+
this.conversations = new ConversationStore(info.dir);
|
|
160
|
+
const runtime = this;
|
|
161
|
+
this.accounting = new RuntimeAccounting({
|
|
162
|
+
get log() { return runtime.log; },
|
|
163
|
+
appendIfOpen: (...args) => this.appendIfOpen(...args),
|
|
164
|
+
get info() { return runtime.info; },
|
|
165
|
+
});
|
|
166
|
+
this.briefings = new RuntimeBriefings({
|
|
167
|
+
get info() { return runtime.info; },
|
|
168
|
+
get config() { return runtime.config; },
|
|
169
|
+
get log() { return runtime.log; },
|
|
170
|
+
get teamBrain() { return runtime.teamBrain; },
|
|
171
|
+
get brain() { return runtime.brain; },
|
|
172
|
+
activeSkillsBlock: (...args) => this.activeSkillsBlock(...args),
|
|
173
|
+
get turnChat() { return runtime.turns.turnChat; },
|
|
174
|
+
// a provider/model (ollama/qwen3:4b, openrouter/pool:free) runs there; anything else is a Claude alias
|
|
175
|
+
extractionEngine: (model) => (prompt) => helperText(model, `${prompt.system}\n\n${prompt.user}`, { timeoutMs: 60_000 }),
|
|
176
|
+
renderProjection: (input) => renderProjection(input, this.config.projection),
|
|
177
|
+
createSemanticIndex: () => new SemanticIndex(path.join(this.info.dir, ".loom")),
|
|
178
|
+
noteMemoriesUsed: (ids) => this.noteMemoriesUsed(ids),
|
|
179
|
+
});
|
|
180
|
+
this.queueCoordinator = new RuntimeQueue({
|
|
181
|
+
get queue() { return runtime.queue; },
|
|
182
|
+
get agents() { return runtime.agents; },
|
|
183
|
+
routeState: (...args) => this.routeState(...args),
|
|
184
|
+
validHolder: (...args) => this.validHolder(...args),
|
|
185
|
+
get orchestra() { return runtime.orchestra; },
|
|
186
|
+
get config() { return runtime.config; },
|
|
187
|
+
get busySince() { return runtime.turns.busySince; },
|
|
188
|
+
get closed() { return runtime.closed; },
|
|
189
|
+
appendIfOpen: (...args) => this.appendIfOpen(...args),
|
|
190
|
+
startRoute: (...args) => this.startRoute(...args),
|
|
191
|
+
handoff: (...args) => this.handoff(...args),
|
|
192
|
+
sendMessage: (...args) => this.sendMessage(...args),
|
|
193
|
+
});
|
|
194
|
+
this.turns = new RuntimeTurns({
|
|
195
|
+
get continuity() { return runtime.continuity; },
|
|
196
|
+
harness: (id) => this.harnesses.ensure(id),
|
|
197
|
+
kickQueue: () => this.kickQueue(),
|
|
198
|
+
nativeOptions: (id) => this.policyOptions(this.config.agents.find(a => a.id === id)),
|
|
199
|
+
chatExists: (chat) => this.conversations.chats().some(c => c.id === chat),
|
|
200
|
+
staleTurnMs: ProjectRuntime.STALE_TURN_MS,
|
|
201
|
+
get closed() { return runtime.closed; },
|
|
202
|
+
agentDir: (...args) => this.agentDir(...args),
|
|
203
|
+
get log() { return runtime.log; },
|
|
204
|
+
get info() { return runtime.info; },
|
|
205
|
+
extractMemory: (...args) => this.extractMemory(...args),
|
|
206
|
+
get config() { return runtime.config; },
|
|
207
|
+
chatBinding: (...args) => this.chatBinding(...args),
|
|
208
|
+
validHolder: (...args) => this.validHolder(...args),
|
|
209
|
+
defaultAdapterId: (...args) => this.defaultAdapterId(...args),
|
|
210
|
+
agent: (...args) => this.agent(...args),
|
|
211
|
+
enforceQuarantine: (...args) => {
|
|
212
|
+
if (this.journalFailure)
|
|
213
|
+
throw this.journalFailure;
|
|
214
|
+
this.enforceQuarantine(...args);
|
|
215
|
+
},
|
|
216
|
+
enforceBudget: (...args) => this.enforceBudget(...args),
|
|
217
|
+
get teamPolicy() { return runtime.teamPolicy; },
|
|
218
|
+
get baton() { return runtime.baton; },
|
|
219
|
+
releaseQuestionHold: (...args) => this.releaseQuestionHold(...args),
|
|
220
|
+
openQuestion: (agentId) => this.openQuestions.get(agentId),
|
|
221
|
+
answerQuestion: (...args) => this.answerQuestion(...args),
|
|
222
|
+
get queue() { return runtime.queue; },
|
|
223
|
+
get routes() { return runtime.routes; },
|
|
224
|
+
ensureStarted: (...args) => this.ensureStarted(...args),
|
|
225
|
+
isCurrentAgent: (agent) => !this.closed && this.agents.get(agent.id) === agent,
|
|
226
|
+
dispatchFailed: (agent, chat, error) => {
|
|
227
|
+
if (this.closed || this.agents.get(agent.id) !== agent)
|
|
228
|
+
return;
|
|
229
|
+
if (!this.continuity || !isAdapter(agent) || !agent.busy())
|
|
230
|
+
this.turns.busySince.delete(agent.id);
|
|
231
|
+
const event = this.log.append({ kind: "error", agentId: agent.id, chat,
|
|
232
|
+
payload: { message: error instanceof Error ? error.message : String(error) } });
|
|
233
|
+
this.afterAgentEvent(event);
|
|
234
|
+
this.kickQueue();
|
|
235
|
+
},
|
|
236
|
+
consumePendingBriefing: (...args) => this.consumePendingBriefing(...args),
|
|
237
|
+
activeSkillsBlock: (...args) => this.activeSkillsBlock(...args),
|
|
238
|
+
agentInstructions: (id) => this.agentInstructions(id),
|
|
239
|
+
healthyMcps: (...args) => this.healthyMcps(...args),
|
|
240
|
+
appendIfOpen: (...args) => this.appendIfOpen(...args),
|
|
241
|
+
get agents() { return runtime.agents; },
|
|
242
|
+
handoff: (...args) => this.handoff(...args),
|
|
243
|
+
get pendingBriefings() { return runtime.briefings.pendingBriefings; },
|
|
244
|
+
});
|
|
245
|
+
// What each agent has typed since its last finished message, so a window
|
|
246
|
+
// opened (or reloaded) mid-reply can show the reply so far, not just the
|
|
247
|
+
// words that arrive after it. A finished message supersedes its typing;
|
|
248
|
+
// a turn's end drops it.
|
|
249
|
+
log.onEvent((e) => {
|
|
250
|
+
const s = e.agentId ? this.liveSoFar.get(e.agentId) : undefined;
|
|
251
|
+
if (!s || !e.agentId)
|
|
252
|
+
return;
|
|
253
|
+
const p = e.payload;
|
|
254
|
+
if (e.kind === "message") {
|
|
255
|
+
if (p.reasoning)
|
|
256
|
+
s.think = "";
|
|
257
|
+
else
|
|
258
|
+
s.text = "";
|
|
259
|
+
}
|
|
260
|
+
else if (e.kind === "run_complete" || e.kind === "error" || e.kind === "needs_input" ||
|
|
261
|
+
(e.kind === "status" && (p.state === "interrupted" || p.state === "stopped"))) {
|
|
262
|
+
this.liveSoFar.delete(e.agentId);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
46
265
|
this.baton = new BatonManager(info.dir, log);
|
|
47
266
|
this.brain = new Brain(log);
|
|
267
|
+
void this.briefings.configureSemantic(!this.continuity && config.brain?.semantic === true);
|
|
48
268
|
// Same path as addAgent: an agent added at runtime must behave exactly like
|
|
49
269
|
// one that was here at open, and two copies of this loop would drift.
|
|
50
270
|
// (An agent streams events long after send() returns and has no idea which
|
|
51
271
|
// conversation prompted it — spawnAgent tags them with the chat that started
|
|
52
272
|
// the turn, so a reply lands where the question was asked.)
|
|
53
273
|
for (const agentCfg of config.agents)
|
|
54
|
-
|
|
274
|
+
if (agentCfg.enabled !== false)
|
|
275
|
+
this.spawnAgent(agentCfg);
|
|
55
276
|
this.routes = new RouteEngine({
|
|
56
277
|
projectName: info.name,
|
|
57
278
|
projectDir: info.dir,
|
|
@@ -60,19 +281,207 @@ export class ProjectRuntime {
|
|
|
60
281
|
handoff: (to) => this.handoff(to, { source: "route" }),
|
|
61
282
|
send: (text, agentId) => this.sendMessage(text, agentId, { source: "route" }),
|
|
62
283
|
interrupt: () => this.interrupt({ source: "route" }),
|
|
63
|
-
costTotal: () => this.costs.totalUsd,
|
|
284
|
+
costTotal: () => this.accounting.costs.totalUsd,
|
|
285
|
+
turnFacts: (agentId) => this.turnFacts(agentId),
|
|
64
286
|
isAdapterId: (id) => {
|
|
65
287
|
const agent = this.agents.get(id);
|
|
66
288
|
return Boolean(agent && isAdapter(agent));
|
|
67
289
|
},
|
|
68
290
|
});
|
|
291
|
+
this.orchestra = new OrchestraEngine({
|
|
292
|
+
projectId: info.id,
|
|
293
|
+
projectName: info.name,
|
|
294
|
+
projectDir: info.dir,
|
|
295
|
+
roster: () => this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter" && !isWithdrawnKind(a.kind)),
|
|
296
|
+
installedKinds: () => this.installedKinds,
|
|
297
|
+
makeAgent: (cfg, dir) => {
|
|
298
|
+
if (this.continuity)
|
|
299
|
+
throw new ContinuityError("unsupported", "parallel orchestra execution is outside sequential native continuity");
|
|
300
|
+
const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: info.id } }, dir);
|
|
301
|
+
if (!isAdapter(agent))
|
|
302
|
+
throw new Error(`"${cfg.id}" is a bridge — it cannot run orchestra work`);
|
|
303
|
+
return agent;
|
|
304
|
+
},
|
|
305
|
+
append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
|
|
306
|
+
// A task thread is pinned to its worker, so the sidebar says who it is
|
|
307
|
+
// and a reply there reaches that agent. A worker that can't be pinned
|
|
308
|
+
// (none can't, today — but a roster can change mid-run) still gets its
|
|
309
|
+
// thread, just unpinned.
|
|
310
|
+
createChat: (title, opts) => {
|
|
311
|
+
try {
|
|
312
|
+
return this.createChat(title, opts?.agentId ? { agentId: opts.agentId } : {});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return this.createChat(title);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// Asked before a run is told to answer in a thread: an id from a client
|
|
319
|
+
// is a claim about this machine, and Main is real without being stored.
|
|
320
|
+
chatExists: (id) => this.conversations.chats().some((c) => c.id === id),
|
|
321
|
+
briefingFor: async (query, agentId, files) => [
|
|
322
|
+
this.activeSkillsBlock(),
|
|
323
|
+
await this.brainBriefFor({ query, agent: agentId, limit: 6 }),
|
|
324
|
+
this.teamBrain?.context(files ?? []) ?? "",
|
|
325
|
+
]
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
.join("\n\n"),
|
|
328
|
+
gate: (agentId) => {
|
|
329
|
+
if (this.continuity)
|
|
330
|
+
throw new ContinuityError("unsupported", "parallel orchestra execution is outside sequential native continuity");
|
|
331
|
+
this.enforceQuarantine(agentId);
|
|
332
|
+
this.enforceBudget(agentId);
|
|
333
|
+
},
|
|
334
|
+
observe: (event) => this.trackCost(event),
|
|
335
|
+
stream: (f) => this.liveText(f),
|
|
336
|
+
gitDelivery: () => this.config.git?.delivery ?? "none",
|
|
337
|
+
goalBudgetUsd: () => this.config.budgets?.perGoalUsd ?? null,
|
|
338
|
+
maxConcurrentGoals: () => this.config.maxConcurrentGoals ?? null,
|
|
339
|
+
member: () => this.memberLogin,
|
|
340
|
+
coordinator: () => this.coordinator,
|
|
341
|
+
});
|
|
342
|
+
this.relay = new QuotaRelay({
|
|
343
|
+
config: () => this.config,
|
|
344
|
+
busy: (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && a.busy()) || this.turns.busySince.has(id); },
|
|
345
|
+
turnChat: (id) => this.turns.turnChat.get(id),
|
|
346
|
+
available: async (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && (await a.available())); },
|
|
347
|
+
interrupt: (chat) => this.interrupt({ chat, source: "route" }),
|
|
348
|
+
handoff: (to) => this.handoff(to, { source: "route" }),
|
|
349
|
+
holder: () => this.validHolder(),
|
|
350
|
+
setBriefing: (id, text) => {
|
|
351
|
+
const prior = this.briefings.pendingBriefings.get(id);
|
|
352
|
+
this.briefings.pendingBriefings.set(id, [prior, text].filter(Boolean).join("\n"));
|
|
353
|
+
},
|
|
354
|
+
send: (text, id, chat) => this.sendMessage(text, id, { chat, source: "route" }),
|
|
355
|
+
enqueue: (input) => this.enqueue(input),
|
|
356
|
+
list: (opts) => this.log.list(opts),
|
|
357
|
+
append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
|
|
358
|
+
warn: (message, detail) => logbook.warn("relay", message, String(detail), this.info.id),
|
|
359
|
+
});
|
|
360
|
+
this.proofs = new ProofRunner({
|
|
361
|
+
projectId: info.id,
|
|
362
|
+
dir: info.dir,
|
|
363
|
+
config: () => this.config.proof ?? {},
|
|
364
|
+
list: (opts) => this.log.list(opts),
|
|
365
|
+
append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
|
|
366
|
+
});
|
|
367
|
+
this.shifts = new NightShift({
|
|
368
|
+
dir: info.dir,
|
|
369
|
+
hasAgent: (id) => this.config.agents.some((a) => a.id === id),
|
|
370
|
+
createChat: (title, agentId) => this.createChat(title, agentId ? { agentId } : {}),
|
|
371
|
+
enqueue: (input) => this.enqueue(input),
|
|
372
|
+
queued: () => this.queue.snapshot().items,
|
|
373
|
+
removeQueued: (id) => this.queue.remove(id),
|
|
374
|
+
busy: () => this.turns.busySince.size > 0 || [...this.agents.values()].some((a) => isAdapter(a) && a.busy()),
|
|
375
|
+
proofsBusy: () => this.proofs.busy,
|
|
376
|
+
resumeAfterRestart: () => {
|
|
377
|
+
const q = this.queue.snapshot();
|
|
378
|
+
if (!q.paused || !q.reason?.startsWith("the daemon restarted"))
|
|
379
|
+
return;
|
|
380
|
+
this.queue.setPaused(false);
|
|
381
|
+
void this.drainPromptQueue();
|
|
382
|
+
},
|
|
383
|
+
list: (opts) => this.log.list(opts),
|
|
384
|
+
append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
|
|
385
|
+
warn: (message, detail) => logbook.warn("shift", message, String(detail), this.info.id),
|
|
386
|
+
});
|
|
387
|
+
this.crews = new CrewEngine({
|
|
388
|
+
projectId: info.id,
|
|
389
|
+
projectName: info.name,
|
|
390
|
+
projectDir: info.dir,
|
|
391
|
+
crews: () => this.config.crews ?? [],
|
|
392
|
+
saveCrews: (crews) => {
|
|
393
|
+
if (crews.length)
|
|
394
|
+
this.config.crews = crews;
|
|
395
|
+
else
|
|
396
|
+
delete this.config.crews;
|
|
397
|
+
this.saveConfig();
|
|
398
|
+
},
|
|
399
|
+
roster: () => this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter" && !isWithdrawnKind(a.kind)),
|
|
400
|
+
makeAgent: (cfg, dir) => {
|
|
401
|
+
const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: info.id } }, dir);
|
|
402
|
+
if (!isAdapter(agent))
|
|
403
|
+
throw new Error(`"${cfg.id}" is a bridge — it can't be on a crew`);
|
|
404
|
+
return agent;
|
|
405
|
+
},
|
|
406
|
+
append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
|
|
407
|
+
createChat: (title, opts) => this.createChat(title, opts?.agentId ? { agentId: opts.agentId } : {}),
|
|
408
|
+
// The decorated sidebar list calls crews.list() to discover groups.
|
|
409
|
+
// Using it to validate a crew's channel recurses back into crew state.
|
|
410
|
+
chatExists: (id) => this.conversations.chats().some((c) => c.id === id),
|
|
411
|
+
native: () => this.continuity,
|
|
412
|
+
nativeOptions: (cfg) => this.policyOptions(cfg),
|
|
413
|
+
nativeHealth: (id) => this.harnesses.ensure(id),
|
|
414
|
+
coordinator: () => this.coordinator,
|
|
415
|
+
policy: () => this.teamPolicy,
|
|
416
|
+
spentToday: (crew) => this.crewSpendToday(crew),
|
|
417
|
+
gitDelivery: () => this.config.git?.delivery ?? "none",
|
|
418
|
+
// agent-asserted, so below a human's word, and private to the crew until promoted
|
|
419
|
+
remember: (m) => {
|
|
420
|
+
this.brain.add({ kind: m.kind, text: m.text, scope: { crew: m.crewId }, confidence: 0.85, evidence: m.text,
|
|
421
|
+
provenance: { agentId: m.agentId, eventId: m.eventId, ts: Date.now() } });
|
|
422
|
+
},
|
|
423
|
+
briefingFor: async (query, agentId, crewId) => [this.activeSkillsBlock(), await this.brainBriefFor({ query, agent: agentId, crew: crewId, limit: 6 }), this.teamBrain?.context([]) ?? ""]
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.join("\n\n"),
|
|
426
|
+
gate: (agentId) => {
|
|
427
|
+
this.enforceQuarantine(agentId);
|
|
428
|
+
this.enforceBudget(agentId);
|
|
429
|
+
},
|
|
430
|
+
observe: (event) => this.trackCost(event),
|
|
431
|
+
stream: (f) => this.liveText(f),
|
|
432
|
+
createTask: (input) => this.createTask(input),
|
|
433
|
+
updateTask: (id, patch) => this.updateTask(id, patch),
|
|
434
|
+
member: () => this.memberLogin,
|
|
435
|
+
});
|
|
436
|
+
this.queue = new PromptQueue(path.join(projectLoomDir(info.dir), "queue.json"), (q) => {
|
|
437
|
+
for (const cb of this.queueCoordinator.queueListeners)
|
|
438
|
+
cb(q);
|
|
439
|
+
this.watchClockConditions(q);
|
|
440
|
+
});
|
|
441
|
+
this.servers = new Servers({
|
|
442
|
+
projectDir: info.dir,
|
|
443
|
+
configs: () => this.config.servers ?? [],
|
|
444
|
+
onChange: (name, status) => {
|
|
445
|
+
for (const cb of this.serverListeners)
|
|
446
|
+
cb({ kind: "state", name, status });
|
|
447
|
+
// A server that died while an agent worked against it is the answer to
|
|
448
|
+
// "why is the page blank?" — it belongs in the thread, not only a pane.
|
|
449
|
+
if (status.state === "crashed") {
|
|
450
|
+
this.appendIfOpen({
|
|
451
|
+
kind: "status",
|
|
452
|
+
payload: { state: "server_crashed", server: name, exitCode: status.exitCode },
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
onLine: (name, line) => {
|
|
457
|
+
for (const cb of this.serverListeners)
|
|
458
|
+
cb({ kind: "line", name, line });
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
// a goal or a route that ends frees the head of the queue
|
|
462
|
+
log.onEvent((e) => {
|
|
463
|
+
if (e.kind === "orchestra" || e.kind === "route_completed" || e.kind === "route_failed")
|
|
464
|
+
this.kickQueue();
|
|
465
|
+
});
|
|
69
466
|
}
|
|
70
467
|
static async open(info) {
|
|
71
468
|
const config = readProjectConfig(info.dir);
|
|
72
|
-
if (!config)
|
|
469
|
+
if (!config) {
|
|
470
|
+
// Say which: a folder that's gone (moved, deleted, a temp dir a reboot
|
|
471
|
+
// cleared) is a different fix from a folder that was never initialised.
|
|
472
|
+
if (!fs.existsSync(info.dir))
|
|
473
|
+
throw new Error(`this project's folder is gone: ${info.dir} — it was moved or deleted`);
|
|
73
474
|
throw new Error(`project at ${info.dir} has no .loom/config.json — run loom init`);
|
|
475
|
+
}
|
|
74
476
|
const log = await EventLog.open(projectLoomDir(info.dir));
|
|
75
|
-
|
|
477
|
+
let rt;
|
|
478
|
+
try {
|
|
479
|
+
rt = new ProjectRuntime(info, config, log);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
log.close();
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
76
485
|
rt.configMtime = configMtimeOf(info.dir);
|
|
77
486
|
rt.rehydrateCosts();
|
|
78
487
|
// Pull each connected ADE's native memory into the shared brain on open.
|
|
@@ -82,49 +491,42 @@ export class ProjectRuntime {
|
|
|
82
491
|
catch {
|
|
83
492
|
// Memory import is best-effort; never block opening a project.
|
|
84
493
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.costs.totalUsd += usd;
|
|
104
|
-
entry.usd += usd;
|
|
105
|
-
this.costsByAgent.set(agentId, entry);
|
|
494
|
+
// Watch the project's MCP servers, if it has any — see mcpHealth.
|
|
495
|
+
rt.startMcpHealthLoop();
|
|
496
|
+
void detectAdes()
|
|
497
|
+
.then((found) => (rt.installedKinds = Object.keys(found).filter((k) => found[k])))
|
|
498
|
+
.catch(() => { });
|
|
499
|
+
// Worktree-per-agent: prepare each adapter's checkout and respawn it there.
|
|
500
|
+
// Safe pre-start — agents are constructed lazily-started, so replacing the
|
|
501
|
+
// instance before its first turn loses nothing.
|
|
502
|
+
if (config.git?.worktreePerAgent) {
|
|
503
|
+
for (const cfg of config.agents) {
|
|
504
|
+
if (cfg.enabled === false)
|
|
505
|
+
continue;
|
|
506
|
+
const live = rt.agents.get(cfg.id);
|
|
507
|
+
if (!live || live.capabilities.tier !== "adapter")
|
|
508
|
+
continue;
|
|
509
|
+
await rt.ensureAgentWorktree(cfg.id);
|
|
510
|
+
await rt.agentLifecycle.retire(cfg.id);
|
|
511
|
+
rt.spawnAgent(cfg);
|
|
106
512
|
}
|
|
107
513
|
}
|
|
108
|
-
|
|
109
|
-
const ms = Number(event.payload.durationMs ?? 0);
|
|
110
|
-
this.costs.turns += 1;
|
|
111
|
-
this.costs.totalMs += ms;
|
|
112
|
-
entry.turns += 1;
|
|
113
|
-
entry.ms += ms;
|
|
114
|
-
this.costsByAgent.set(agentId, entry);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
costSummary() {
|
|
118
|
-
const byAgent = [...this.costsByAgent.entries()]
|
|
119
|
-
.map(([agentId, c]) => ({ agentId, ...c }))
|
|
120
|
-
.sort((a, b) => b.usd - a.usd || b.turns - a.turns);
|
|
121
|
-
return {
|
|
122
|
-
totalUsd: this.costs.totalUsd,
|
|
123
|
-
turns: this.costs.turns,
|
|
124
|
-
totalMs: this.costs.totalMs,
|
|
125
|
-
byAgent,
|
|
126
|
-
};
|
|
514
|
+
return rt;
|
|
127
515
|
}
|
|
516
|
+
rehydrateCosts() { return this.accounting.rehydrateCosts(); }
|
|
517
|
+
trackCost(event) { return this.accounting.trackCost(event); }
|
|
518
|
+
costSummary() { return this.accounting.costSummary(); }
|
|
519
|
+
budgets() { return this.accounting.budgets(); }
|
|
520
|
+
setBudget(agentId, usdPerDay) { return this.accounting.setBudget(agentId, usdPerDay); }
|
|
521
|
+
spendTodayFor(agentId, now = Date.now()) { return this.accounting.spendTodayFor(agentId, now); }
|
|
522
|
+
costSeries(days = 30, now = Date.now()) { return this.accounting.costSeries(days, now); }
|
|
523
|
+
budgetStatus(now = Date.now()) { return this.accounting.budgetStatus(now); }
|
|
524
|
+
enforceQuarantine(agentId) { return this.accounting.enforceQuarantine(agentId); }
|
|
525
|
+
enforceBudget(agentId, now = Date.now()) { return this.accounting.enforceBudget(agentId, now); }
|
|
526
|
+
liftBudgetPause(agentId, now = Date.now()) { return this.accounting.liftBudgetPause(agentId, now); }
|
|
527
|
+
quarantined() { return this.accounting.quarantined(); }
|
|
528
|
+
quarantine(agentId, reason, displaced, now = Date.now()) { return this.accounting.quarantine(agentId, reason, displaced, now); }
|
|
529
|
+
unquarantine(agentId) { return this.accounting.unquarantine(agentId); }
|
|
128
530
|
/** Has .loom/config.json changed since this runtime was opened? */
|
|
129
531
|
configStale() {
|
|
130
532
|
return configMtimeOf(this.info.dir) > this.configMtime;
|
|
@@ -136,6 +538,64 @@ export class ProjectRuntime {
|
|
|
136
538
|
* on an agent's turn to finish. Nothing is torn down: a role is a name, not
|
|
137
539
|
* a capability, so no adapter needs restarting.
|
|
138
540
|
*/
|
|
541
|
+
/** An agent's picture: a small PNG/JPEG/WebP data URL, or null to clear. */
|
|
542
|
+
setAgentAvatar(agentId, dataUrl) {
|
|
543
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
544
|
+
if (!cfg)
|
|
545
|
+
return null;
|
|
546
|
+
if (dataUrl === null || dataUrl === "")
|
|
547
|
+
delete cfg.avatar;
|
|
548
|
+
else {
|
|
549
|
+
if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(dataUrl))
|
|
550
|
+
throw new Error("a picture is a PNG, JPEG or WebP image");
|
|
551
|
+
if (dataUrl.length > 120_000)
|
|
552
|
+
throw new Error("that picture is too big — Loom keeps them small (about 96px)");
|
|
553
|
+
cfg.avatar = dataUrl;
|
|
554
|
+
}
|
|
555
|
+
this.saveConfig();
|
|
556
|
+
return { id: agentId, avatar: cfg.avatar ?? null };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Ask an agent whether it's ready — installed, signed in, model listed —
|
|
560
|
+
* without sending it a prompt (see AdapterBase.selfCheck).
|
|
561
|
+
*/
|
|
562
|
+
async checkAgent(agentId) {
|
|
563
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
564
|
+
if (!cfg)
|
|
565
|
+
return null;
|
|
566
|
+
const started = Date.now();
|
|
567
|
+
if (cfg.enabled === false)
|
|
568
|
+
return { ok: false, ms: 0, checks: [{ name: "switched on", ok: false, detail: "this agent is switched off in the roster" }] };
|
|
569
|
+
const live = this.agents.get(agentId);
|
|
570
|
+
let checks;
|
|
571
|
+
if (live && live instanceof AdapterBase)
|
|
572
|
+
checks = await live.selfCheck();
|
|
573
|
+
else if (live) {
|
|
574
|
+
const ok = await live.available();
|
|
575
|
+
checks = [{ name: "available", ok, detail: ok ? "running" : "not available on this machine" }];
|
|
576
|
+
}
|
|
577
|
+
else
|
|
578
|
+
checks = [{ name: "loaded", ok: false, detail: "Loom hasn't loaded this agent — is its CLI installed?" }];
|
|
579
|
+
return { ok: checks.every((c) => c.ok), ms: Date.now() - started, checks };
|
|
580
|
+
}
|
|
581
|
+
/** Standing instructions for an agent; empty clears them. */
|
|
582
|
+
setAgentInstructions(agentId, text) {
|
|
583
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
584
|
+
if (!cfg)
|
|
585
|
+
return null;
|
|
586
|
+
const clean = text.trim().slice(0, 4000);
|
|
587
|
+
if (clean)
|
|
588
|
+
cfg.instructions = clean;
|
|
589
|
+
else
|
|
590
|
+
delete cfg.instructions;
|
|
591
|
+
this.saveConfig();
|
|
592
|
+
return { id: agentId, instructions: clean };
|
|
593
|
+
}
|
|
594
|
+
/** The block an agent's standing instructions ride in, ahead of its turn. */
|
|
595
|
+
agentInstructions(agentId) {
|
|
596
|
+
const text = this.config.agents.find((a) => a.id === agentId)?.instructions?.trim();
|
|
597
|
+
return text ? `Standing instructions for you in this project, from the person you work for:\n${text}\n` : "";
|
|
598
|
+
}
|
|
139
599
|
setAgentRole(agentId, role) {
|
|
140
600
|
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
141
601
|
if (!cfg)
|
|
@@ -160,30 +620,115 @@ export class ProjectRuntime {
|
|
|
160
620
|
if (wantsDefault && defaultId && !this.config.agents.some((a) => a.id === defaultId)) {
|
|
161
621
|
throw new Error(`no agent "${defaultId}" in this project`);
|
|
162
622
|
}
|
|
163
|
-
if (patch.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
623
|
+
if (patch.git?.delivery !== undefined && !GIT_DELIVERIES.includes(patch.git.delivery))
|
|
624
|
+
throw new Error(`git.delivery must be one of ${GIT_DELIVERIES.join(", ")}`);
|
|
625
|
+
const previousConfig = { ...this.config }, previousContinuity = this.continuity;
|
|
626
|
+
const changingContinuity = typeof patch.brain?.continuity === "boolean" && patch.brain.continuity !== Boolean(this.config.brain?.continuity);
|
|
627
|
+
try {
|
|
628
|
+
if (patch.brain) {
|
|
629
|
+
if (changingContinuity) {
|
|
630
|
+
if (this.anyBusy() || this.continuity?.store.activeReceipts().length || this.queue.length)
|
|
631
|
+
throw new ContinuityError("conflict", "finish or reconcile native turns before changing continuity mode; also clear queued prompts");
|
|
632
|
+
if (patch.brain.continuity) {
|
|
633
|
+
this.continuity = new ContinuityEngine(this.log, this.info.id);
|
|
634
|
+
this.harnesses.start();
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
this.continuity?.store.releaseOwner();
|
|
638
|
+
this.continuity = null;
|
|
639
|
+
this.harnesses.stop();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const b = { ...(this.config.brain ?? {}) };
|
|
643
|
+
if (typeof patch.brain.continuity === "boolean")
|
|
644
|
+
b.continuity = patch.brain.continuity;
|
|
645
|
+
if (patch.brain.extractor === "auto" || patch.brain.extractor === "off")
|
|
646
|
+
b.extractor = patch.brain.extractor;
|
|
647
|
+
if (typeof patch.brain.semantic === "boolean") {
|
|
648
|
+
if (patch.brain.semantic)
|
|
649
|
+
b.semantic = true;
|
|
650
|
+
else
|
|
651
|
+
delete b.semantic;
|
|
652
|
+
}
|
|
653
|
+
if (typeof patch.brain.model === "string")
|
|
654
|
+
b.model = patch.brain.model.trim() || undefined;
|
|
655
|
+
this.config.brain = b;
|
|
656
|
+
}
|
|
657
|
+
if (patch.projection) {
|
|
658
|
+
const pr = { ...(this.config.projection ?? {}) };
|
|
659
|
+
if (patch.projection.mode === "template" || patch.projection.mode === "llm")
|
|
660
|
+
pr.mode = patch.projection.mode;
|
|
661
|
+
if (typeof patch.projection.model === "string")
|
|
662
|
+
pr.model = patch.projection.model.trim() || undefined;
|
|
663
|
+
this.config.projection = pr;
|
|
664
|
+
}
|
|
665
|
+
if (wantsDefault) {
|
|
666
|
+
// empty clears it; a real value was checked against the roster above
|
|
667
|
+
if (!defaultId)
|
|
668
|
+
delete this.config.defaultAgent;
|
|
669
|
+
else
|
|
670
|
+
this.config.defaultAgent = defaultId;
|
|
671
|
+
}
|
|
672
|
+
if (patch.git) {
|
|
673
|
+
const g = { ...(this.config.git ?? {}) };
|
|
674
|
+
for (const k of [
|
|
675
|
+
"commitPerTurn",
|
|
676
|
+
"branchPerTask",
|
|
677
|
+
"worktreePerAgent",
|
|
678
|
+
"mergeOnHandoff",
|
|
679
|
+
]) {
|
|
680
|
+
if (typeof patch.git[k] === "boolean") {
|
|
681
|
+
if (patch.git[k])
|
|
682
|
+
g[k] = true;
|
|
683
|
+
else
|
|
684
|
+
delete g[k];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (patch.git.delivery !== undefined) {
|
|
688
|
+
if (patch.git.delivery === "none")
|
|
689
|
+
delete g.delivery;
|
|
690
|
+
else
|
|
691
|
+
g.delivery = patch.git.delivery;
|
|
692
|
+
}
|
|
693
|
+
if (Object.keys(g).length)
|
|
694
|
+
this.config.git = g;
|
|
695
|
+
else
|
|
696
|
+
delete this.config.git;
|
|
697
|
+
// worktreePerAgent takes effect for agents spawned from here on; the
|
|
698
|
+
// Settings screen says so rather than pretending it's instant.
|
|
699
|
+
}
|
|
700
|
+
if (patch.safety) {
|
|
701
|
+
if (typeof patch.safety.snapshotBeforeRoutes === "boolean") {
|
|
702
|
+
if (patch.safety.snapshotBeforeRoutes)
|
|
703
|
+
this.config.safety = { snapshotBeforeRoutes: true };
|
|
704
|
+
else
|
|
705
|
+
delete this.config.safety;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
this.saveConfig();
|
|
185
709
|
}
|
|
186
|
-
|
|
710
|
+
catch (error) {
|
|
711
|
+
if (this.continuity !== previousContinuity) {
|
|
712
|
+
this.continuity?.store.releaseOwner();
|
|
713
|
+
this.continuity = previousContinuity;
|
|
714
|
+
previousContinuity?.store.claimOwner();
|
|
715
|
+
if (previousContinuity)
|
|
716
|
+
this.harnesses.start();
|
|
717
|
+
else
|
|
718
|
+
this.harnesses.stop();
|
|
719
|
+
}
|
|
720
|
+
for (const key of Object.keys(this.config))
|
|
721
|
+
delete this.config[key];
|
|
722
|
+
Object.assign(this.config, previousConfig);
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
if (changingContinuity) {
|
|
726
|
+
this.briefings.cancelExtraction();
|
|
727
|
+
this.turns.turnText.clear();
|
|
728
|
+
this.briefings.pendingBriefings.clear();
|
|
729
|
+
}
|
|
730
|
+
if (changingContinuity || typeof patch.brain?.semantic === "boolean")
|
|
731
|
+
void this.briefings.configureSemantic(!this.continuity && this.config.brain?.semantic === true);
|
|
187
732
|
return this.config;
|
|
188
733
|
}
|
|
189
734
|
/**
|
|
@@ -196,13 +741,23 @@ export class ProjectRuntime {
|
|
|
196
741
|
settings() {
|
|
197
742
|
return {
|
|
198
743
|
brain: {
|
|
744
|
+
continuity: Boolean(this.config.brain?.continuity),
|
|
199
745
|
extractor: this.config.brain?.extractor === "off" ? "off" : "auto",
|
|
200
746
|
model: this.config.brain?.model ?? "",
|
|
747
|
+
semantic: Boolean(this.config.brain?.semantic),
|
|
201
748
|
},
|
|
202
749
|
projection: {
|
|
203
750
|
mode: this.config.projection?.mode === "llm" ? "llm" : "template",
|
|
204
751
|
model: this.config.projection?.model ?? "",
|
|
205
752
|
},
|
|
753
|
+
git: {
|
|
754
|
+
commitPerTurn: Boolean(this.config.git?.commitPerTurn),
|
|
755
|
+
branchPerTask: Boolean(this.config.git?.branchPerTask),
|
|
756
|
+
worktreePerAgent: Boolean(this.config.git?.worktreePerAgent),
|
|
757
|
+
mergeOnHandoff: Boolean(this.config.git?.mergeOnHandoff),
|
|
758
|
+
delivery: this.config.git?.delivery ?? "none",
|
|
759
|
+
},
|
|
760
|
+
safety: { snapshotBeforeRoutes: Boolean(this.config.safety?.snapshotBeforeRoutes) },
|
|
206
761
|
defaultAgent: this.config.defaultAgent ?? "",
|
|
207
762
|
agents: this.config.agents.map((a) => ({ id: a.id, kind: a.kind, role: a.role })),
|
|
208
763
|
};
|
|
@@ -222,13 +777,40 @@ export class ProjectRuntime {
|
|
|
222
777
|
if (!knownAgentKinds().includes(kind)) {
|
|
223
778
|
throw new Error(`unknown agent kind "${kind}" (known: ${knownAgentKinds().join(", ")})`);
|
|
224
779
|
}
|
|
225
|
-
|
|
780
|
+
// Known is not the same as offered. This is the endpoint the "add agent"
|
|
781
|
+
// rail drives, and the rail only ever lists ADES. Accepting a kind that no
|
|
782
|
+
// view offers meant a withdrawn agent could be put in a roster by guessing
|
|
783
|
+
// its name, and then sat there unadvertised and unexplained. Refuse it
|
|
784
|
+
// here, and name what replaced it.
|
|
785
|
+
if (isWithdrawnKind(kind)) {
|
|
786
|
+
const replacement = kind === "antigravity" ? ' — use "antigravity-cli"' : "";
|
|
787
|
+
throw new Error(`"${kind}" is no longer offered${replacement}`);
|
|
788
|
+
}
|
|
789
|
+
// A second session of the same kind is a feature, not a mistake.
|
|
790
|
+
//
|
|
791
|
+
// The roster has always been keyed by instance id with kind alongside it, so
|
|
792
|
+
// two Claude Code sessions in one project were representable — but adding
|
|
793
|
+
// one threw, because the id defaulted to the kind and the kind was taken. A
|
|
794
|
+
// caller who names the instance gets that name; a caller who doesn't gets
|
|
795
|
+
// the next free suffix, so "add another" needs no ceremony. Both sessions
|
|
796
|
+
// read and write the one project brain: memory import dedupes by file path,
|
|
797
|
+
// and units are project-scoped, so nothing has to change for them to share.
|
|
798
|
+
const explicit = opts.id?.trim().slice(0, 40);
|
|
799
|
+
if (explicit && this.config.agents.some((a) => a.id === explicit)) {
|
|
800
|
+
throw new Error(`"${explicit}" is already in this project`);
|
|
801
|
+
}
|
|
802
|
+
const id = explicit || this.nextInstanceId(kind);
|
|
226
803
|
if (!id)
|
|
227
804
|
throw new Error("an agent needs an id");
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const cfg = {
|
|
805
|
+
// A `model` agent with no model is an agent that refuses every turn, so
|
|
806
|
+
// the options that make it work are settable as it's added rather than in
|
|
807
|
+
// a second step nobody is told about.
|
|
808
|
+
const cfg = {
|
|
809
|
+
id,
|
|
810
|
+
kind,
|
|
811
|
+
role: (opts.role ?? kind).trim().slice(0, 40) || kind,
|
|
812
|
+
...(opts.options && Object.keys(opts.options).length ? { options: opts.options } : {}),
|
|
813
|
+
};
|
|
232
814
|
// Build it before saving. A config entry with no live agent behind it makes
|
|
233
815
|
// status() throw the moment anything asks — this.agent(id) doesn't find it —
|
|
234
816
|
// so the project 500s on every poll and the roster you just changed becomes
|
|
@@ -239,6 +821,73 @@ export class ProjectRuntime {
|
|
|
239
821
|
this.saveConfig();
|
|
240
822
|
return cfg;
|
|
241
823
|
}
|
|
824
|
+
/**
|
|
825
|
+
* The next free instance id for a kind: `codex`, then `codex-2`, `codex-3`.
|
|
826
|
+
*
|
|
827
|
+
* The bare kind stays the first instance's id so existing projects, configs
|
|
828
|
+
* and route specs that name `codex` keep meaning what they meant.
|
|
829
|
+
*/
|
|
830
|
+
nextInstanceId(kind) {
|
|
831
|
+
const taken = new Set(this.config.agents.map((a) => a.id));
|
|
832
|
+
if (!taken.has(kind))
|
|
833
|
+
return kind;
|
|
834
|
+
for (let n = 2; n < 100; n++) {
|
|
835
|
+
const candidate = `${kind}-${n}`;
|
|
836
|
+
if (!taken.has(candidate))
|
|
837
|
+
return candidate;
|
|
838
|
+
}
|
|
839
|
+
throw new Error(`too many ${kind} sessions in this project`);
|
|
840
|
+
}
|
|
841
|
+
/** How many instances of each kind the roster holds. */
|
|
842
|
+
instanceCounts() {
|
|
843
|
+
const counts = {};
|
|
844
|
+
for (const a of this.config.agents)
|
|
845
|
+
counts[a.kind] = (counts[a.kind] ?? 0) + 1;
|
|
846
|
+
return counts;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Where this agent works: its own worktree when the project opted in, the
|
|
850
|
+
* shared tree otherwise.
|
|
851
|
+
*
|
|
852
|
+
* With git.worktreePerAgent on, each adapter gets a sibling checkout on its
|
|
853
|
+
* own branch (agent/<id>), so two agents editing at once cannot collide in
|
|
854
|
+
* the filesystem. The trade is stated where it's decided: merging is
|
|
855
|
+
* MANUAL in this version — the branches are ordinary git branches and
|
|
856
|
+
* `git merge agent/<id>` is the handoff of record. Auto-merge on baton
|
|
857
|
+
* handoff is a different feature with different failure modes (conflicts
|
|
858
|
+
* mid-handoff), deliberately not smuggled in here.
|
|
859
|
+
*/
|
|
860
|
+
agentDirs = new Map();
|
|
861
|
+
agentDir(agentId) {
|
|
862
|
+
return this.agentDirs.get(agentId) ?? this.info.dir;
|
|
863
|
+
}
|
|
864
|
+
async ensureAgentWorktree(agentId) {
|
|
865
|
+
const existing = this.agentDirs.get(agentId);
|
|
866
|
+
if (existing)
|
|
867
|
+
return existing;
|
|
868
|
+
const wt = worktreePath(this.info.dir, `agent-${agentId}`);
|
|
869
|
+
if (!fs.existsSync(wt)) {
|
|
870
|
+
try {
|
|
871
|
+
await gitAddWorktree(this.info.dir, {
|
|
872
|
+
slug: `agent-${agentId}`,
|
|
873
|
+
newBranch: `agent/${agentId}`,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
// Branch already exists from a previous run — attach to it instead.
|
|
878
|
+
try {
|
|
879
|
+
await gitAddWorktree(this.info.dir, { slug: `agent-${agentId}`, branch: `agent/${agentId}` });
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
logbook.warn("git", `worktree for ${agentId} could not be created — falling back to the shared tree`, String(err), this.info.id);
|
|
883
|
+
this.agentDirs.set(agentId, this.info.dir);
|
|
884
|
+
return this.info.dir;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
this.agentDirs.set(agentId, wt);
|
|
889
|
+
return wt;
|
|
890
|
+
}
|
|
242
891
|
/**
|
|
243
892
|
* Create one agent and subscribe to it, exactly as the constructor does.
|
|
244
893
|
*
|
|
@@ -247,18 +896,117 @@ export class ProjectRuntime {
|
|
|
247
896
|
* there when the project opened.
|
|
248
897
|
*/
|
|
249
898
|
spawnAgent(cfg) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
agent.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
899
|
+
// The project id rides along so an adapter can file approvals ("always
|
|
900
|
+
// ask") against the right project — see core/approvals.ts.
|
|
901
|
+
const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: this.info.id } }, this.agentDir(cfg.id));
|
|
902
|
+
// What this turn has typed since its last finished message. A turn that
|
|
903
|
+
// is stopped (or dies) mid-reply never sends that message, and the words
|
|
904
|
+
// you watched arrive would vanish — so they're kept, marked partial.
|
|
905
|
+
// Provider agents keep their own buffers (see providers/ingestion.ts).
|
|
906
|
+
let typed = "";
|
|
907
|
+
const keepsPartial = !(agent instanceof ProviderAgent);
|
|
908
|
+
agent.onStream?.((d) => {
|
|
909
|
+
if (!d.reasoning)
|
|
910
|
+
typed += d.text;
|
|
911
|
+
this.liveText({ agentId: agent.id, chat: this.turns.turnChat.get(agent.id) ?? MAIN_CHAT, ...d });
|
|
912
|
+
});
|
|
913
|
+
this.agentLifecycle.install(agent, (e) => {
|
|
914
|
+
try {
|
|
915
|
+
const runId = typeof e.payload.loomRunId === "string" ? e.payload.loomRunId : undefined;
|
|
916
|
+
const liveRun = runId ? this.continuity?.isLiveRun(runId) : true;
|
|
917
|
+
const chat = (runId ? this.continuity?.eventChat(runId) : undefined) ?? this.turns.turnChat.get(agent.id);
|
|
918
|
+
let payload = e.payload;
|
|
919
|
+
// Enrich the completed turn so its gen_ai span carries system + model +
|
|
920
|
+
// cost (adapters only put tokens on run_complete). The kind is known
|
|
921
|
+
// here; the model prefers what the adapter actually used, else the
|
|
922
|
+
// configured override; the cost is the turn_cost stashed a moment ago.
|
|
923
|
+
const p = e.payload;
|
|
924
|
+
if (liveRun && e.kind === "status" && p.state === "turn_cost") {
|
|
925
|
+
const usd = Number(p.costUsd ?? 0);
|
|
926
|
+
if (usd > 0)
|
|
927
|
+
this.turns.pendingCost.set(agent.id, usd);
|
|
928
|
+
}
|
|
929
|
+
else if (liveRun && e.kind === "run_complete") {
|
|
930
|
+
const model = (typeof p.model === "string" && p.model) ||
|
|
931
|
+
(typeof cfg.options?.model === "string" ? cfg.options.model : undefined);
|
|
932
|
+
const cost = this.turns.pendingCost.get(agent.id);
|
|
933
|
+
this.turns.pendingCost.delete(agent.id);
|
|
934
|
+
payload = {
|
|
935
|
+
...p,
|
|
936
|
+
adapter: cfg.kind,
|
|
937
|
+
...(model ? { model } : {}),
|
|
938
|
+
...(cost !== undefined ? { costUsd: cost } : {}),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
// An agent that stops to ask you something is the whole reason Loom
|
|
942
|
+
// exists: the next queued prompt would answer a question you never saw,
|
|
943
|
+
// so the queue waits for you instead. Answering goes out immediately —
|
|
944
|
+
// a paused queue holds what's lined up, not what you type now.
|
|
945
|
+
if (liveRun && e.kind === "needs_input")
|
|
946
|
+
this.holdQueueFor(agent.id);
|
|
947
|
+
// A question asked through the agent's tool blocks its turn until answered: remember it,
|
|
948
|
+
// so a reply typed in the chat reaches it (turns.ts) and status stays honest.
|
|
949
|
+
if (e.kind === "needs_input" && typeof p.requestId === "string" && p.responseMode !== "message") {
|
|
950
|
+
const ids = Array.isArray(p.questions) ? p.questions.map((q, i) => String(q.id ?? i)) : [];
|
|
951
|
+
this.openQuestions.set(agent.id, { requestId: p.requestId, chat: chat ?? MAIN_CHAT, ids });
|
|
952
|
+
}
|
|
953
|
+
if (e.kind === "status" && p.state === "question_answered" && this.openQuestions.get(agent.id)?.requestId === p.requestId) {
|
|
954
|
+
this.openQuestions.delete(agent.id);
|
|
955
|
+
}
|
|
956
|
+
// Any terminal event stops the stale-session clock — a turn that ended in
|
|
957
|
+
// an error is over, not hung.
|
|
958
|
+
const turnOver = e.kind === "run_complete" || e.kind === "error" || (e.kind === "status" && p.state === "interrupted");
|
|
959
|
+
if (turnOver && !this.continuity) {
|
|
960
|
+
this.turns.busySince.delete(agent.id);
|
|
961
|
+
}
|
|
962
|
+
if (turnOver)
|
|
963
|
+
this.openQuestions.delete(agent.id);
|
|
964
|
+
if (keepsPartial) {
|
|
965
|
+
if (e.kind === "message" && !p.reasoning)
|
|
966
|
+
typed = "";
|
|
967
|
+
if (turnOver && typed.trim()) {
|
|
968
|
+
this.log.append({ kind: "message", agentId: agent.id, ...(chat ? { chat } : {}), payload: { text: typed, partial: true } });
|
|
969
|
+
}
|
|
970
|
+
if (turnOver)
|
|
971
|
+
typed = "";
|
|
972
|
+
}
|
|
973
|
+
const event = this.log.append({
|
|
974
|
+
kind: e.kind,
|
|
975
|
+
agentId: agent.id,
|
|
976
|
+
...(chat ? { chat } : {}),
|
|
977
|
+
payload,
|
|
978
|
+
});
|
|
979
|
+
this.continuity?.ingest(event);
|
|
980
|
+
this.nativeUsage.observe(event);
|
|
981
|
+
if (liveRun)
|
|
982
|
+
this.afterAgentEvent(event);
|
|
983
|
+
if (liveRun && e.kind === "message" && p.proposedPlan === true)
|
|
984
|
+
this.saveProposedPlan(agent.id, chat, String(p.text ?? ""));
|
|
985
|
+
if (turnOver && !this.continuity)
|
|
986
|
+
this.kickQueue();
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
// A failed durable ingest is a project fault, not a disposable UI
|
|
990
|
+
// observer. Stop foreground work and keep its receipt uncertain.
|
|
991
|
+
this.journalFailure = new ContinuityError("recovery_required", "project journal ingest failed; native execution was interrupted; reopen and reconcile before continuing");
|
|
992
|
+
if (isAdapter(agent) && agent.busy())
|
|
993
|
+
void agent.interrupt().catch(() => { });
|
|
994
|
+
}
|
|
261
995
|
});
|
|
996
|
+
if (agent instanceof ProviderAgent) {
|
|
997
|
+
agent.onLive((d) => {
|
|
998
|
+
if (this.closed || this.agents.get(agent.id) !== agent)
|
|
999
|
+
return;
|
|
1000
|
+
this.live.push(d);
|
|
1001
|
+
// The same text feeds the thread's live reply (the `stream` frames and
|
|
1002
|
+
// the reload-safe snapshot), as every other agent's does.
|
|
1003
|
+
if (d.streamKind === "assistant_text" || d.streamKind === "reasoning_text" || d.streamKind === "reasoning_summary_text") {
|
|
1004
|
+
this.liveText({ agentId: agent.id, chat: d.chat, text: d.delta, ...(d.streamKind === "assistant_text" ? {} : { reasoning: true }) });
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
agent.onLiveItem((i) => { if (!this.closed && this.agents.get(agent.id) === agent)
|
|
1008
|
+
this.live.pushItem(i); });
|
|
1009
|
+
}
|
|
262
1010
|
return agent;
|
|
263
1011
|
}
|
|
264
1012
|
/**
|
|
@@ -277,7 +1025,7 @@ export class ProjectRuntime {
|
|
|
277
1025
|
throw new Error(`"${agentId}" holds the baton — hand it to someone else first`);
|
|
278
1026
|
}
|
|
279
1027
|
const live = this.agents.get(agentId);
|
|
280
|
-
if (live && isAdapter(live) && live.busy()) {
|
|
1028
|
+
if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
|
|
281
1029
|
throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
|
|
282
1030
|
}
|
|
283
1031
|
// Its events stay in the log: the history happened, and a roster change
|
|
@@ -285,8 +1033,7 @@ export class ProjectRuntime {
|
|
|
285
1033
|
this.config.agents = this.config.agents.filter((a) => a.id !== agentId);
|
|
286
1034
|
this.saveConfig();
|
|
287
1035
|
if (live) {
|
|
288
|
-
void
|
|
289
|
-
this.agents.delete(agentId);
|
|
1036
|
+
void this.agentLifecycle.retire(agentId);
|
|
290
1037
|
}
|
|
291
1038
|
return { removed: agentId };
|
|
292
1039
|
}
|
|
@@ -294,40 +1041,114 @@ export class ProjectRuntime {
|
|
|
294
1041
|
* Point an agent at a different model.
|
|
295
1042
|
*
|
|
296
1043
|
* The model is read once, when the adapter is constructed (createAgent hands
|
|
297
|
-
* it cfg.options), so changing it means building a fresh
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* the process out from under a running turn would strand it.
|
|
1044
|
+
* it cfg.options), so changing it means building a fresh adapter instance.
|
|
1045
|
+
* Native resume data stays persisted; each adapter decides whether its harness
|
|
1046
|
+
* can reuse it. Refused while a turn is preparing or running, because swapping
|
|
1047
|
+
* the process out from under that turn would strand it.
|
|
302
1048
|
*
|
|
303
1049
|
* An empty model clears the override, so the CLI falls back to its own default
|
|
304
1050
|
* — the honest "Default" the picker offers.
|
|
305
1051
|
*/
|
|
306
|
-
setAgentModel(agentId, model) {
|
|
1052
|
+
setAgentModel(agentId, model, provider) {
|
|
307
1053
|
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
308
1054
|
if (!cfg)
|
|
309
1055
|
throw new Error(`unknown agent "${agentId}"`);
|
|
310
1056
|
const live = this.agents.get(agentId);
|
|
311
|
-
if (live && isAdapter(live) && live.busy()) {
|
|
1057
|
+
if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
|
|
312
1058
|
throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then switch models`);
|
|
313
1059
|
}
|
|
314
|
-
|
|
1060
|
+
// OpenRouter ids run long ("provider/family-size-variant:free"); 80 cut
|
|
1061
|
+
// real ones off mid-name.
|
|
1062
|
+
const next = model.trim().slice(0, 200);
|
|
315
1063
|
const options = { ...(cfg.options ?? {}) };
|
|
316
1064
|
if (next)
|
|
317
1065
|
options.model = next;
|
|
318
1066
|
else
|
|
319
1067
|
delete options.model;
|
|
1068
|
+
// A model agent's pick names its provider too (the list spans them all).
|
|
1069
|
+
if (cfg.kind === "model" && provider?.trim())
|
|
1070
|
+
options.provider = provider.trim().toLowerCase();
|
|
320
1071
|
cfg.options = options;
|
|
321
1072
|
// Rebuild so the new model actually takes: stop the old process, spawn a
|
|
322
1073
|
// replacement subscribed exactly as the constructor's loop does.
|
|
323
1074
|
if (live) {
|
|
324
|
-
void
|
|
325
|
-
this.agents.delete(agentId);
|
|
1075
|
+
void this.agentLifecycle.retire(agentId);
|
|
326
1076
|
}
|
|
327
1077
|
this.spawnAgent(cfg);
|
|
328
1078
|
this.saveConfig();
|
|
329
1079
|
return cfg;
|
|
330
1080
|
}
|
|
1081
|
+
/**
|
|
1082
|
+
* A model agent's sampling: temperature (0–2) and the reply's token cap.
|
|
1083
|
+
* Null clears one back to the provider's default. Rebuilt like a model
|
|
1084
|
+
* switch, because the adapter reads them when it's built.
|
|
1085
|
+
*/
|
|
1086
|
+
setAgentSampling(agentId, s) {
|
|
1087
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
1088
|
+
if (!cfg)
|
|
1089
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
1090
|
+
if (cfg.kind !== "model")
|
|
1091
|
+
throw new Error(`"${agentId}" is a ${cfg.kind} — sampling is set in its own CLI, not here`);
|
|
1092
|
+
const live = this.agents.get(agentId);
|
|
1093
|
+
if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId)))
|
|
1094
|
+
throw new Error(`"${agentId}" is mid-turn — wait for it to finish`);
|
|
1095
|
+
const options = { ...(cfg.options ?? {}) };
|
|
1096
|
+
if (s.temperature !== undefined) {
|
|
1097
|
+
if (s.temperature === null)
|
|
1098
|
+
delete options.temperature;
|
|
1099
|
+
else if (!Number.isFinite(s.temperature) || s.temperature < 0 || s.temperature > 2)
|
|
1100
|
+
throw new Error("temperature must be between 0 and 2");
|
|
1101
|
+
else
|
|
1102
|
+
options.temperature = Math.round(s.temperature * 100) / 100;
|
|
1103
|
+
}
|
|
1104
|
+
if (s.maxTokens !== undefined) {
|
|
1105
|
+
if (s.maxTokens === null)
|
|
1106
|
+
delete options.maxTokens;
|
|
1107
|
+
else if (!Number.isInteger(s.maxTokens) || s.maxTokens < 16 || s.maxTokens > 200_000)
|
|
1108
|
+
throw new Error("max tokens must be a whole number from 16 to 200000");
|
|
1109
|
+
else
|
|
1110
|
+
options.maxTokens = s.maxTokens;
|
|
1111
|
+
}
|
|
1112
|
+
cfg.options = options;
|
|
1113
|
+
if (live)
|
|
1114
|
+
void this.agentLifecycle.retire(agentId);
|
|
1115
|
+
this.spawnAgent(cfg);
|
|
1116
|
+
this.saveConfig();
|
|
1117
|
+
return cfg;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Choose how much an agent may do without asking: bypass | auto | ask.
|
|
1121
|
+
*
|
|
1122
|
+
* Same shape as setAgentModel — the mode is read when the adapter is built
|
|
1123
|
+
* (it becomes CLI flags, or the env of opencode's server), so the agent is
|
|
1124
|
+
* rebuilt. Refused mid-turn for the same reason.
|
|
1125
|
+
*/
|
|
1126
|
+
setAgentPermissions(agentId, mode) {
|
|
1127
|
+
if (!isPermissionMode(mode))
|
|
1128
|
+
throw new Error(`permissions must be bypass, auto or ask`);
|
|
1129
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
1130
|
+
if (!cfg)
|
|
1131
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
1132
|
+
const why = unsupportedReason(cfg.kind, mode);
|
|
1133
|
+
if (why)
|
|
1134
|
+
throw new Error(`"${mode}" isn't available for ${cfg.kind}: ${why}`);
|
|
1135
|
+
// D38: a team-shared project can't go looser than loom.team.json allows.
|
|
1136
|
+
if (this.teamPolicy && cappedPermission(this.teamPolicy, mode, true) !== mode) {
|
|
1137
|
+
throw new Error(`team policy caps permissions at "${this.teamPolicy.permissions.ceiling}" on this repo (loom.team.json)`);
|
|
1138
|
+
}
|
|
1139
|
+
const live = this.agents.get(agentId);
|
|
1140
|
+
if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
|
|
1141
|
+
throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then change its permissions`);
|
|
1142
|
+
}
|
|
1143
|
+
cfg.options = { ...(cfg.options ?? {}), permissions: mode };
|
|
1144
|
+
if (live) {
|
|
1145
|
+
void this.agentLifecycle.retire(agentId);
|
|
1146
|
+
}
|
|
1147
|
+
if (cfg.enabled !== false)
|
|
1148
|
+
this.spawnAgent(cfg);
|
|
1149
|
+
this.saveConfig();
|
|
1150
|
+
return cfg;
|
|
1151
|
+
}
|
|
331
1152
|
/** Write the roster, without tripping our own staleness check. */
|
|
332
1153
|
saveConfig() {
|
|
333
1154
|
writeProjectConfig(this.info.dir, this.config);
|
|
@@ -335,6 +1156,279 @@ export class ProjectRuntime {
|
|
|
335
1156
|
// schedule a pointless reload
|
|
336
1157
|
this.configMtime = configMtimeOf(this.info.dir);
|
|
337
1158
|
}
|
|
1159
|
+
// ── Skills (SKILL.md context blocks injected into the briefing) ──
|
|
1160
|
+
/**
|
|
1161
|
+
* Where skills live — every layout, not just ours.
|
|
1162
|
+
*
|
|
1163
|
+
* See core/skills.ts#discoverSkillRoots for the list and the precedence. This
|
|
1164
|
+
* used to be two hardcoded directories, which meant a machine with sixty
|
|
1165
|
+
* skills in `~/.claude` reported the one that shipped in this repo.
|
|
1166
|
+
*/
|
|
1167
|
+
skillRoots() {
|
|
1168
|
+
return discoverSkillRoots(this.info.dir, path.join(process.cwd(), "skills"));
|
|
1169
|
+
}
|
|
1170
|
+
/** The directory this project's own skills are installed into. */
|
|
1171
|
+
ownSkillsDir() {
|
|
1172
|
+
return path.join(this.info.dir, "skills");
|
|
1173
|
+
}
|
|
1174
|
+
/** All available skills with this project's enabled state. */
|
|
1175
|
+
getSkills() {
|
|
1176
|
+
return loadSkills(this.skillRoots(), this.config.skills ?? {});
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* The catalog the picker renders: every discoverable skill, without the body.
|
|
1180
|
+
*
|
|
1181
|
+
* The bodies are the entire point of a skill and also the reason this exists
|
|
1182
|
+
* separately from `getSkills()` — a machine with sixty installed skills has
|
|
1183
|
+
* megabytes of markdown, and a list screen needs none of it.
|
|
1184
|
+
*
|
|
1185
|
+
* `installed` means "this file is inside the project directory", which is the
|
|
1186
|
+
* same question `removeSkill` asks: those are the only ones Loom put there
|
|
1187
|
+
* and the only ones it may delete.
|
|
1188
|
+
*/
|
|
1189
|
+
skillsCatalog() {
|
|
1190
|
+
const own = this.ownSkillsDir();
|
|
1191
|
+
return this.getSkills().map((s) => ({
|
|
1192
|
+
id: s.id,
|
|
1193
|
+
name: s.name,
|
|
1194
|
+
description: s.description,
|
|
1195
|
+
enabled: s.enabled,
|
|
1196
|
+
origin: s.origin,
|
|
1197
|
+
source: s.source,
|
|
1198
|
+
installed: path.resolve(s.source) === path.resolve(own),
|
|
1199
|
+
}));
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Install a skill from a local directory or a git remote, into this project.
|
|
1203
|
+
*
|
|
1204
|
+
* Throws SkillInstallError for anything the user can fix (bad URL, unsafe id,
|
|
1205
|
+
* a name that already exists) — the route turns those into a 400 with the
|
|
1206
|
+
* message, because "invalid input" tells you nothing when the real answer is
|
|
1207
|
+
* "that repo has no SKILL.md in it".
|
|
1208
|
+
*/
|
|
1209
|
+
async installSkill(input) {
|
|
1210
|
+
const gitUrl = String(input.gitUrl ?? "").trim();
|
|
1211
|
+
const dir = String(input.dir ?? "").trim();
|
|
1212
|
+
if (gitUrl && dir)
|
|
1213
|
+
throw new SkillInstallError("give either gitUrl or dir, not both");
|
|
1214
|
+
if (gitUrl)
|
|
1215
|
+
return installSkillFromGit(gitUrl, this.info.dir, { force: input.force === true });
|
|
1216
|
+
if (dir)
|
|
1217
|
+
return installSkillFromDir(dir, this.info.dir, { force: input.force === true });
|
|
1218
|
+
throw new SkillInstallError("nothing to install — pass gitUrl or dir");
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Delete a project-installed skill from disk.
|
|
1222
|
+
*
|
|
1223
|
+
* Refused for anything outside the project. A skill in `~/.claude/skills` is
|
|
1224
|
+
* the user's, shared with every other tool they run, and a project-scoped
|
|
1225
|
+
* "remove" button that reached out and deleted it would be a data-loss bug
|
|
1226
|
+
* dressed as a feature. The refusal says where the skill actually lives so
|
|
1227
|
+
* the answer ("go delete it yourself, here") is in the message.
|
|
1228
|
+
*
|
|
1229
|
+
* The enabled flag goes with it: leaving `skills[id] = true` in config for a
|
|
1230
|
+
* directory that no longer exists means the next turn's briefing silently
|
|
1231
|
+
* loses a block the config still claims is on.
|
|
1232
|
+
*/
|
|
1233
|
+
removeSkill(id) {
|
|
1234
|
+
const skill = this.getSkills().find((s) => s.id === id);
|
|
1235
|
+
if (!skill)
|
|
1236
|
+
throw new SkillInstallError(`no skill "${id}"`);
|
|
1237
|
+
const own = this.ownSkillsDir();
|
|
1238
|
+
if (path.resolve(skill.source) !== path.resolve(own)) {
|
|
1239
|
+
throw new SkillInstallError(`"${id}" lives in ${skill.source}, outside this project — Loom didn't install it and won't delete it`);
|
|
1240
|
+
}
|
|
1241
|
+
const dir = path.join(own, skill.id);
|
|
1242
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1243
|
+
if (this.config.skills?.[skill.id])
|
|
1244
|
+
this.setSkillEnabled(skill.id, false);
|
|
1245
|
+
return { removed: true, id: skill.id, path: dir };
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Switch one agent off (or back on) without taking it out of the roster.
|
|
1249
|
+
*
|
|
1250
|
+
* Refused while it holds the baton or is mid-turn, for the same reason
|
|
1251
|
+
* `removeAgent` is: stopping it there would strand the lock on something that
|
|
1252
|
+
* no longer exists, and the thread would show a turn nothing is running. The
|
|
1253
|
+
* refusal names the fix rather than just saying no.
|
|
1254
|
+
*
|
|
1255
|
+
* Off really means off — the agent is stopped and dropped from the live map,
|
|
1256
|
+
* not merely hidden. A roster entry that still answers is the kind of "off"
|
|
1257
|
+
* that costs money.
|
|
1258
|
+
*/
|
|
1259
|
+
setAgentEnabled(agentId, enabled) {
|
|
1260
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
1261
|
+
if (!cfg)
|
|
1262
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
1263
|
+
const on = enabled !== false;
|
|
1264
|
+
if (!on) {
|
|
1265
|
+
if (this.validHolder() === agentId) {
|
|
1266
|
+
throw new Error(`"${agentId}" holds the baton — hand it off before switching it off`);
|
|
1267
|
+
}
|
|
1268
|
+
const live = this.agents.get(agentId);
|
|
1269
|
+
if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
|
|
1270
|
+
throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
cfg.enabled = on;
|
|
1274
|
+
this.saveConfig();
|
|
1275
|
+
const live = this.agents.get(agentId);
|
|
1276
|
+
if (on && !live) {
|
|
1277
|
+
this.spawnAgent(cfg); // bring it back to life
|
|
1278
|
+
}
|
|
1279
|
+
else if (!on && live) {
|
|
1280
|
+
void this.agentLifecycle.retire(agentId);
|
|
1281
|
+
}
|
|
1282
|
+
this.log.append({ kind: on ? "agent_join" : "agent_leave", agentId, payload: { enabled: on } });
|
|
1283
|
+
return { id: agentId, enabled: on };
|
|
1284
|
+
}
|
|
1285
|
+
/** Enable/disable one skill for this project; returns the new enabled map. */
|
|
1286
|
+
setSkillEnabled(id, on) {
|
|
1287
|
+
const skills = { ...(this.config.skills ?? {}) };
|
|
1288
|
+
if (on)
|
|
1289
|
+
skills[id] = true;
|
|
1290
|
+
else
|
|
1291
|
+
delete skills[id];
|
|
1292
|
+
this.config.skills = skills;
|
|
1293
|
+
this.saveConfig();
|
|
1294
|
+
return skills;
|
|
1295
|
+
}
|
|
1296
|
+
/** The ACTIVE SKILLS block to prepend to a briefing, or "" when none are on. */
|
|
1297
|
+
activeSkillsBlock() {
|
|
1298
|
+
return buildSkillsBlock(this.getSkills());
|
|
1299
|
+
}
|
|
1300
|
+
// ── MCP servers ──
|
|
1301
|
+
static DEFAULT_MCPS = [
|
|
1302
|
+
{ name: "GitHub", url: "", description: "issues, PRs, code search", icon: "github" },
|
|
1303
|
+
{ name: "Supabase", url: "", description: "query, schema, migrations", icon: "database" },
|
|
1304
|
+
{ name: "SigNoz", url: "", description: "traces, metrics, alerts", icon: "chart" },
|
|
1305
|
+
{ name: "Linear", url: "", description: "issues, projects, cycles", icon: "linear" },
|
|
1306
|
+
{ name: "Slack", url: "", description: "messages, channels, users", icon: "slack" },
|
|
1307
|
+
{ name: "Filesystem", url: "", description: "read/write local files", icon: "folder" },
|
|
1308
|
+
];
|
|
1309
|
+
/** Configured MCP servers, merged over the built-in suggestions (deduped by name). */
|
|
1310
|
+
getMcps() {
|
|
1311
|
+
const saved = this.config.mcps ?? [];
|
|
1312
|
+
const byName = new Map(ProjectRuntime.DEFAULT_MCPS.map((m) => [m.name, { ...m }]));
|
|
1313
|
+
for (const m of saved)
|
|
1314
|
+
byName.set(m.name, { ...(byName.get(m.name) ?? {}), ...m });
|
|
1315
|
+
return [...byName.values()];
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The same list, with `connected` MEASURED rather than assumed.
|
|
1319
|
+
*
|
|
1320
|
+
* The old reading of that field was "a url is typed into this row", which the
|
|
1321
|
+
* UI rendered as a green "connected" badge — a claim about a live connection
|
|
1322
|
+
* made without ever opening one. Here every configured URL gets a bounded
|
|
1323
|
+
* probe (see core/mcp.ts) and the answer is whatever came back. Rows with no
|
|
1324
|
+
* URL aren't probed and report false, because "not configured" is not
|
|
1325
|
+
* "connected".
|
|
1326
|
+
*/
|
|
1327
|
+
// -------------------------------------------------------------------------
|
|
1328
|
+
// MCP health
|
|
1329
|
+
// -------------------------------------------------------------------------
|
|
1330
|
+
/**
|
|
1331
|
+
* Live health per configured MCP server, from the background poll.
|
|
1332
|
+
*
|
|
1333
|
+
* A server that died used to stay listed as connected, and its tools failed
|
|
1334
|
+
* silently mid-turn — the agent just found them gone. The poll notices the
|
|
1335
|
+
* transition, says so in the Console, and while a server is down it is left
|
|
1336
|
+
* out of turn injection entirely: an absent tool the agent never saw beats a
|
|
1337
|
+
* present tool that throws. When the server answers again it is included
|
|
1338
|
+
* again automatically — that is the whole reconnect story for per-turn
|
|
1339
|
+
* config files; there is no persistent connection to rebuild.
|
|
1340
|
+
*/
|
|
1341
|
+
mcpHealth = new Map();
|
|
1342
|
+
mcpTimer = null;
|
|
1343
|
+
/** Poll every configured server once; log the transitions. */
|
|
1344
|
+
async pollMcpHealth(timeoutMs = 2_000) {
|
|
1345
|
+
const mcps = this.getMcps().filter((m) => m.enabledForSession !== false);
|
|
1346
|
+
for (const m of mcps) {
|
|
1347
|
+
const url = String(m.url ?? "").trim();
|
|
1348
|
+
if (!url)
|
|
1349
|
+
continue; // stdio servers have no probe-able endpoint
|
|
1350
|
+
const up = await probeMcpServer(url, timeoutMs).catch(() => false);
|
|
1351
|
+
const prev = this.mcpHealth.get(m.name);
|
|
1352
|
+
const failures = up ? 0 : (prev?.failures ?? 0) + 1;
|
|
1353
|
+
this.mcpHealth.set(m.name, { up, failures, probedAt: Date.now() });
|
|
1354
|
+
if (prev && prev.up && !up) {
|
|
1355
|
+
logbook.error("mcp", `"${m.name}" stopped answering — its tools are withheld from turns until it returns`, url, this.info.id);
|
|
1356
|
+
}
|
|
1357
|
+
else if (prev && !prev.up && up) {
|
|
1358
|
+
logbook.info("mcp", `"${m.name}" is back — its tools rejoin the next turn`, undefined, this.info.id);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
// Forget servers that were removed from config.
|
|
1362
|
+
const names = new Set(mcps.map((m) => m.name));
|
|
1363
|
+
for (const k of [...this.mcpHealth.keys()])
|
|
1364
|
+
if (!names.has(k))
|
|
1365
|
+
this.mcpHealth.delete(k);
|
|
1366
|
+
}
|
|
1367
|
+
mcpHealthReport() {
|
|
1368
|
+
return Object.fromEntries(this.mcpHealth);
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* The servers a turn should carry: everything not known-down.
|
|
1372
|
+
*
|
|
1373
|
+
* Unknown (never probed — a fresh daemon, a just-added server) passes
|
|
1374
|
+
* through; refusing a server nobody has measured would block first use on a
|
|
1375
|
+
* poll that hasn't run yet. Only a measured, repeated failure withholds.
|
|
1376
|
+
*/
|
|
1377
|
+
healthyMcps() {
|
|
1378
|
+
return (this.config.mcps ?? []).filter((m) => {
|
|
1379
|
+
const h = this.mcpHealth.get(m.name);
|
|
1380
|
+
return !h || h.up || h.failures < 2;
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
startMcpHealthLoop(intervalMs = Number(process.env.LOOM_MCP_POLL_MS) || 60_000) {
|
|
1384
|
+
if (this.mcpTimer || !(this.config.mcps ?? []).length)
|
|
1385
|
+
return;
|
|
1386
|
+
this.mcpTimer = setInterval(() => {
|
|
1387
|
+
void this.pollMcpHealth().catch(() => { });
|
|
1388
|
+
}, intervalMs);
|
|
1389
|
+
this.mcpTimer.unref?.();
|
|
1390
|
+
}
|
|
1391
|
+
async getMcpsProbed(timeoutMs = 2_000) {
|
|
1392
|
+
const mcps = this.getMcps();
|
|
1393
|
+
const reachable = await probeMcpServers(mcps, timeoutMs).catch(() => ({}));
|
|
1394
|
+
const probedAt = Date.now();
|
|
1395
|
+
return mcps.map((m) => ({
|
|
1396
|
+
...m,
|
|
1397
|
+
connected: reachable[m.name] ?? false,
|
|
1398
|
+
...(m.name in reachable ? { probedAt } : {}),
|
|
1399
|
+
}));
|
|
1400
|
+
}
|
|
1401
|
+
/** Add or update one MCP (by name); persists only the real (non-default) fields. */
|
|
1402
|
+
upsertMcp(mcp) {
|
|
1403
|
+
const saved = (this.config.mcps ?? []).filter((m) => m.name !== mcp.name);
|
|
1404
|
+
saved.push(mcp);
|
|
1405
|
+
this.config.mcps = saved;
|
|
1406
|
+
this.saveConfig();
|
|
1407
|
+
return this.getMcps();
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Remove a configured MCP server by name.
|
|
1411
|
+
*
|
|
1412
|
+
* `removed` is false when nothing was configured under that name, which the
|
|
1413
|
+
* route answers with a 404 rather than a cheerful 200 — "deleted a thing that
|
|
1414
|
+
* wasn't there" is the kind of success that hides a typo in a server name.
|
|
1415
|
+
*
|
|
1416
|
+
* Note what this does to a name that is also one of the built-in suggestion
|
|
1417
|
+
* rows (GitHub, Slack, …): the *configuration* goes, and the suggestion comes
|
|
1418
|
+
* back with an empty url, because those rows aren't installed servers, they're
|
|
1419
|
+
* placeholders getMcps() merges in. That is the honest outcome — the server is
|
|
1420
|
+
* gone, and what's left is an offer to add one — but a caller diffing the list
|
|
1421
|
+
* for the name will still find it, so it should compare `url`/`command`.
|
|
1422
|
+
*/
|
|
1423
|
+
removeMcp(name) {
|
|
1424
|
+
const saved = this.config.mcps ?? [];
|
|
1425
|
+
const kept = saved.filter((m) => m.name !== name);
|
|
1426
|
+
if (kept.length === saved.length)
|
|
1427
|
+
return { removed: false, mcps: this.getMcps() };
|
|
1428
|
+
this.config.mcps = kept;
|
|
1429
|
+
this.saveConfig();
|
|
1430
|
+
return { removed: true, mcps: this.getMcps() };
|
|
1431
|
+
}
|
|
338
1432
|
// -------------------------------------------------------------------------
|
|
339
1433
|
// Chats — several conversations, one brain
|
|
340
1434
|
// -------------------------------------------------------------------------
|
|
@@ -346,51 +1440,263 @@ export class ProjectRuntime {
|
|
|
346
1440
|
*/
|
|
347
1441
|
chats() {
|
|
348
1442
|
const stored = readProjectState(this.info.dir).chats ?? [];
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1443
|
+
let last;
|
|
1444
|
+
try {
|
|
1445
|
+
last = this.log.lastReplyIds();
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
last = new Map(); // an unread dot is a nicety; the list must still load
|
|
1449
|
+
}
|
|
1450
|
+
const main = stored.find((c) => c.id === MAIN_CHAT);
|
|
1451
|
+
const groups = this.chatGroups();
|
|
1452
|
+
return this.conversations.chats().map((c) => {
|
|
1453
|
+
// main is stored only once it has stars or ratings to keep
|
|
1454
|
+
const withMain = c.id === MAIN_CHAT && main
|
|
1455
|
+
? { ...c, ...(main.starred ? { starred: main.starred } : {}), ...(main.ratings ? { ratings: main.ratings } : {}) }
|
|
1456
|
+
: c;
|
|
1457
|
+
const g = c.id === MAIN_CHAT ? undefined : groups.get(c.id);
|
|
1458
|
+
const withGroup = g ? { ...withMain, group: g } : withMain;
|
|
1459
|
+
return last.has(c.id) ? { ...withGroup, lastReplyId: last.get(c.id) } : withGroup;
|
|
1460
|
+
});
|
|
353
1461
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
1462
|
+
/** Which run, race, crew or import made each thread (ChatInfo.group). */
|
|
1463
|
+
chatGroups() {
|
|
1464
|
+
const out = new Map();
|
|
1465
|
+
try {
|
|
1466
|
+
for (const run of this.orchestra.list()) {
|
|
1467
|
+
const g = { kind: run.race ? "race" : "orchestra", id: run.id, title: run.goal, status: run.status, at: run.createdAt };
|
|
1468
|
+
// a run that answered in a thread you already had leaves that thread alone
|
|
1469
|
+
if (!run.inPlace && run.chat)
|
|
1470
|
+
out.set(run.chat, g);
|
|
1471
|
+
for (const t of run.tasks)
|
|
1472
|
+
if (t.chat)
|
|
1473
|
+
out.set(t.chat, g);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
catch { /* no runs yet */ }
|
|
1477
|
+
try {
|
|
1478
|
+
for (const crew of this.crews.list()) {
|
|
1479
|
+
const g = { kind: "crew", id: crew.id, title: crew.name, ...(crew.state.goal ? { status: crew.state.goal.status } : {}), at: crew.state.goal?.startedAt ?? 0 };
|
|
1480
|
+
if (crew.state.channel)
|
|
1481
|
+
out.set(crew.state.channel, g);
|
|
1482
|
+
for (const chat of Object.values(crew.state.threads ?? {}))
|
|
1483
|
+
out.set(chat, g);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
catch { /* no crews yet */ }
|
|
1487
|
+
for (const chat of Object.values(readProjectState(this.info.dir).imports ?? {})) {
|
|
1488
|
+
if (!out.has(chat))
|
|
1489
|
+
out.set(chat, { kind: "import", id: "imports", title: "Imported", at: 0 });
|
|
1490
|
+
}
|
|
1491
|
+
return out;
|
|
366
1492
|
}
|
|
367
|
-
|
|
1493
|
+
/** Pin, archive or file a thread. Main is always first and always there. */
|
|
1494
|
+
setChatFlags(id, flags) {
|
|
368
1495
|
if (id === MAIN_CHAT)
|
|
369
|
-
return null;
|
|
1496
|
+
return null;
|
|
370
1497
|
const state = readProjectState(this.info.dir);
|
|
371
1498
|
const chat = (state.chats ?? []).find((c) => c.id === id);
|
|
372
1499
|
if (!chat)
|
|
373
1500
|
return null;
|
|
374
|
-
|
|
1501
|
+
for (const k of ["pinned", "archived"]) {
|
|
1502
|
+
if (flags[k] === undefined)
|
|
1503
|
+
continue;
|
|
1504
|
+
if (flags[k])
|
|
1505
|
+
chat[k] = true;
|
|
1506
|
+
else
|
|
1507
|
+
delete chat[k];
|
|
1508
|
+
}
|
|
1509
|
+
if (flags.folder !== undefined) {
|
|
1510
|
+
const name = String(flags.folder ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
1511
|
+
if (name)
|
|
1512
|
+
chat.folder = name;
|
|
1513
|
+
else
|
|
1514
|
+
delete chat.folder;
|
|
1515
|
+
}
|
|
375
1516
|
writeProjectState(this.info.dir, state);
|
|
376
1517
|
return chat;
|
|
377
1518
|
}
|
|
1519
|
+
/** Rate a reply up or down (0 clears). Main included. */
|
|
1520
|
+
rateMessage(chatId, eventId, agent, v) {
|
|
1521
|
+
if (!Number.isInteger(eventId) || eventId <= 0 || !agent)
|
|
1522
|
+
return null;
|
|
1523
|
+
const state = readProjectState(this.info.dir);
|
|
1524
|
+
state.chats = state.chats ?? [];
|
|
1525
|
+
let chat = state.chats.find((c) => c.id === chatId);
|
|
1526
|
+
if (!chat) {
|
|
1527
|
+
if (chatId !== MAIN_CHAT)
|
|
1528
|
+
return null;
|
|
1529
|
+
chat = { id: MAIN_CHAT, title: "Main", createdAt: 0 };
|
|
1530
|
+
state.chats.push(chat);
|
|
1531
|
+
}
|
|
1532
|
+
const ratings = { ...(chat.ratings ?? {}) };
|
|
1533
|
+
if (v === 1 || v === -1)
|
|
1534
|
+
ratings[String(eventId)] = { v, agent };
|
|
1535
|
+
else
|
|
1536
|
+
delete ratings[String(eventId)];
|
|
1537
|
+
const keys = Object.keys(ratings);
|
|
1538
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - 500)))
|
|
1539
|
+
delete ratings[k]; // bounded
|
|
1540
|
+
if (Object.keys(ratings).length)
|
|
1541
|
+
chat.ratings = ratings;
|
|
1542
|
+
else
|
|
1543
|
+
delete chat.ratings;
|
|
1544
|
+
writeProjectState(this.info.dir, state);
|
|
1545
|
+
return chat.ratings ?? {};
|
|
1546
|
+
}
|
|
1547
|
+
/** Every rating in every thread, per agent: how many up, how many down. */
|
|
1548
|
+
ratingsByAgent() {
|
|
1549
|
+
const out = {};
|
|
1550
|
+
for (const c of readProjectState(this.info.dir).chats ?? []) {
|
|
1551
|
+
for (const r of Object.values(c.ratings ?? {})) {
|
|
1552
|
+
const a = (out[r.agent] ??= { up: 0, down: 0 });
|
|
1553
|
+
if (r.v === 1)
|
|
1554
|
+
a.up++;
|
|
1555
|
+
else
|
|
1556
|
+
a.down++;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return out;
|
|
1560
|
+
}
|
|
1561
|
+
/** Star or unstar a message in a thread (main included). */
|
|
1562
|
+
starMessage(chatId, eventId, on) {
|
|
1563
|
+
if (!Number.isInteger(eventId) || eventId <= 0)
|
|
1564
|
+
return null;
|
|
1565
|
+
const state = readProjectState(this.info.dir);
|
|
1566
|
+
state.chats = state.chats ?? [];
|
|
1567
|
+
let chat = state.chats.find((c) => c.id === chatId);
|
|
1568
|
+
if (!chat) {
|
|
1569
|
+
if (chatId !== MAIN_CHAT)
|
|
1570
|
+
return null;
|
|
1571
|
+
// Main isn't stored until it has something to remember
|
|
1572
|
+
chat = { id: MAIN_CHAT, title: "Main", createdAt: 0 };
|
|
1573
|
+
state.chats.push(chat);
|
|
1574
|
+
}
|
|
1575
|
+
const set = (chat.starred ?? []).filter((n) => n !== eventId);
|
|
1576
|
+
if (on)
|
|
1577
|
+
set.push(eventId);
|
|
1578
|
+
chat.starred = set.slice(-200);
|
|
1579
|
+
if (!chat.starred.length)
|
|
1580
|
+
delete chat.starred;
|
|
1581
|
+
writeProjectState(this.info.dir, state);
|
|
1582
|
+
return chat.starred ?? [];
|
|
1583
|
+
}
|
|
1584
|
+
createChat(title, opts = {}) {
|
|
1585
|
+
const bound = opts.agentId ? this.bindable(opts.agentId, opts.model) : {};
|
|
1586
|
+
return this.conversations.createChat(title, bound);
|
|
1587
|
+
}
|
|
378
1588
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
1589
|
+
* May this thread be bound to this agent (and model)?
|
|
1590
|
+
*
|
|
1591
|
+
* A model bound to an agent that bakes its model into a spawned process
|
|
1592
|
+
* would be a setting that silently did nothing, so it is refused here —
|
|
1593
|
+
* where the person can act on it — rather than dropped at send time.
|
|
383
1594
|
*/
|
|
1595
|
+
bindable(agentId, model) {
|
|
1596
|
+
const agent = this.agents.get(agentId);
|
|
1597
|
+
if (!agent)
|
|
1598
|
+
throw new Error(`no agent "${agentId}" in this project`);
|
|
1599
|
+
if (!isAdapter(agent))
|
|
1600
|
+
throw new Error(`"${agentId}" is a bridge — it can't answer in a thread`);
|
|
1601
|
+
if (model && !this.switchesModelPerTurn(agentId)) {
|
|
1602
|
+
throw new Error(`"${agentId}" can't change model per thread — pin the model on the agent instead (loom model ${agentId} ${model}), or use a "model" agent`);
|
|
1603
|
+
}
|
|
1604
|
+
return { agentId, ...(model ? { model } : {}) };
|
|
1605
|
+
}
|
|
1606
|
+
/** Can this agent be handed a different model for one turn? */
|
|
1607
|
+
switchesModelPerTurn(agentId) {
|
|
1608
|
+
const kind = this.config.agents.find((a) => a.id === agentId)?.kind;
|
|
1609
|
+
return kind === "model" || Boolean(this.continuity && kind && isNativeKind(kind));
|
|
1610
|
+
}
|
|
1611
|
+
/** Bind (or unbind) who answers in a thread. */
|
|
1612
|
+
setChatAgent(id, agentId, model) {
|
|
1613
|
+
// Preserve Main's error before validating an optional target.
|
|
1614
|
+
if (id !== MAIN_CHAT && agentId)
|
|
1615
|
+
this.bindable(agentId, model);
|
|
1616
|
+
return this.conversations.setChatAgent(id, agentId, model);
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Ask several models the same thing at once, each in its own thread.
|
|
1620
|
+
*
|
|
1621
|
+
* With free quota, asking five models costs what asking one costs, and
|
|
1622
|
+
* "which of these is right" is a judgement a person makes in ten seconds.
|
|
1623
|
+
* So: one prompt, one thread per model, all running at the same time.
|
|
1624
|
+
*
|
|
1625
|
+
* The agents are TRANSIENT — built for the ask, not added to the roster.
|
|
1626
|
+
* Adding five agents to .loom/config.json to ask five questions would leave
|
|
1627
|
+
* the project's roster as a record of everything anyone ever compared. What
|
|
1628
|
+
* stays behind is the threads, which are the part worth keeping.
|
|
1629
|
+
*/
|
|
1630
|
+
async askModels(text, picks, opts = {}) {
|
|
1631
|
+
if (!picks.length)
|
|
1632
|
+
throw new Error("name at least one model");
|
|
1633
|
+
if (picks.length > MAX_FANOUT) {
|
|
1634
|
+
throw new Error(`that's ${picks.length} models — ${MAX_FANOUT} at a time is the limit`);
|
|
1635
|
+
}
|
|
1636
|
+
const brief = opts.briefing === false ? "" : await this.brainBriefFor({ query: text, limit: 6 });
|
|
1637
|
+
const started = [];
|
|
1638
|
+
for (const pick of picks) {
|
|
1639
|
+
const provider = pick.provider ?? "openrouter";
|
|
1640
|
+
// The model IS the name here: a thread labelled "ask-3" tells you
|
|
1641
|
+
// nothing, and this is a view where which model said what is the point.
|
|
1642
|
+
const short = pick.model.split("/").pop() ?? pick.model;
|
|
1643
|
+
const chat = this.createChat(`${opts.title ?? "ask"} · ${short}`.slice(0, 60));
|
|
1644
|
+
const agentId = `ask:${pick.model}`;
|
|
1645
|
+
this.log.append({
|
|
1646
|
+
kind: "message",
|
|
1647
|
+
chat: chat.id,
|
|
1648
|
+
payload: { text, author: "user", ask: { model: pick.model, provider } },
|
|
1649
|
+
});
|
|
1650
|
+
const agent = new ModelAdapter(agentId, this.info.dir, { provider, model: pick.model });
|
|
1651
|
+
// Its events land in the log tagged with its own thread, exactly as a
|
|
1652
|
+
// roster agent's do — which is what makes the answers readable later.
|
|
1653
|
+
const offLive = agent.onStream((d) => this.liveText({ agentId, chat: chat.id, ...d }));
|
|
1654
|
+
const offLog = agent.onEvent((e) => {
|
|
1655
|
+
this.appendIfOpen({ ...e, agentId, chat: chat.id });
|
|
1656
|
+
});
|
|
1657
|
+
const off = () => {
|
|
1658
|
+
offLive();
|
|
1659
|
+
offLog();
|
|
1660
|
+
};
|
|
1661
|
+
void agent
|
|
1662
|
+
.send({ text, ...(brief ? { briefing: brief } : {}) })
|
|
1663
|
+
.catch((err) => {
|
|
1664
|
+
this.appendIfOpen({
|
|
1665
|
+
kind: "error",
|
|
1666
|
+
agentId,
|
|
1667
|
+
chat: chat.id,
|
|
1668
|
+
payload: { message: String(err instanceof Error ? err.message : err) },
|
|
1669
|
+
});
|
|
1670
|
+
})
|
|
1671
|
+
.finally(() => off());
|
|
1672
|
+
started.push({ chat: chat.id, model: pick.model, provider, agentId });
|
|
1673
|
+
}
|
|
1674
|
+
this.log.append({
|
|
1675
|
+
kind: "status",
|
|
1676
|
+
payload: { state: "asked_models", models: picks.map((p) => p.model), chats: started.map((s) => s.chat) },
|
|
1677
|
+
});
|
|
1678
|
+
return started;
|
|
1679
|
+
}
|
|
1680
|
+
/** What a thread has pinned, if anything. */
|
|
1681
|
+
chatBinding(chat) {
|
|
1682
|
+
if (!chat || chat === MAIN_CHAT)
|
|
1683
|
+
return {};
|
|
1684
|
+
const found = this.chats().find((c) => c.id === chat);
|
|
1685
|
+
if (!found?.agentId)
|
|
1686
|
+
return {};
|
|
1687
|
+
// A thread pinned to an agent that has since left the roster falls back to
|
|
1688
|
+
// the baton rather than failing: the conversation is still readable, and
|
|
1689
|
+
// the alternative is a thread nobody can type in.
|
|
1690
|
+
if (!this.agents.has(found.agentId))
|
|
1691
|
+
return {};
|
|
1692
|
+
return { agentId: found.agentId, ...(found.model ? { model: found.model } : {}) };
|
|
1693
|
+
}
|
|
1694
|
+
renameChat(id, title) { return this.conversations.renameChat(id, title); }
|
|
384
1695
|
deleteChat(id) {
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
state.chats = (state.chats ?? []).filter((c) => c.id !== id);
|
|
390
|
-
if (state.chats.length === before)
|
|
391
|
-
return false;
|
|
392
|
-
writeProjectState(this.info.dir, state);
|
|
393
|
-
return true;
|
|
1696
|
+
if (this.continuity && ([...this.turns.busySince.keys()].some(agent => this.turns.turnChat.get(agent) === id) ||
|
|
1697
|
+
this.queue.snapshot().items.some(item => item.chat === id)))
|
|
1698
|
+
throw new ContinuityError("conflict", "finish or remove queued/native turns before deleting their chat");
|
|
1699
|
+
return this.conversations.deleteChat(id);
|
|
394
1700
|
}
|
|
395
1701
|
// -------------------------------------------------------------------------
|
|
396
1702
|
// Board tasks — the cards you write yourself
|
|
@@ -400,32 +1706,175 @@ export class ProjectRuntime {
|
|
|
400
1706
|
}
|
|
401
1707
|
createTask(input) {
|
|
402
1708
|
const state = readProjectState(this.info.dir);
|
|
1709
|
+
const blockedBy = this.validBlockers(state.tasks ?? [], input.blockedBy);
|
|
403
1710
|
const task = {
|
|
404
1711
|
id: newId(4),
|
|
405
1712
|
title: input.title.trim().slice(0, 200),
|
|
406
1713
|
column: input.column ?? "working",
|
|
407
1714
|
...(input.agent ? { agent: input.agent } : {}),
|
|
1715
|
+
...(blockedBy.length ? { blockedBy } : {}),
|
|
408
1716
|
createdAt: Date.now(),
|
|
409
1717
|
};
|
|
1718
|
+
applyCardMeta(task, input);
|
|
410
1719
|
state.tasks = [...(state.tasks ?? []), task];
|
|
411
1720
|
writeProjectState(this.info.dir, state);
|
|
412
1721
|
return task;
|
|
413
1722
|
}
|
|
1723
|
+
/** Only blockers that exist. A link to a deleted card blocks nothing forever. */
|
|
1724
|
+
validBlockers(tasks, ids) {
|
|
1725
|
+
if (!ids?.length)
|
|
1726
|
+
return [];
|
|
1727
|
+
const known = new Set(tasks.map((t) => t.id));
|
|
1728
|
+
return [...new Set(ids)].filter((id) => known.has(id));
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* A card is blocked while any of its blockers is not yet `ready`.
|
|
1732
|
+
*
|
|
1733
|
+
* `ready` is the board's own definition of done — the last column. Deleted
|
|
1734
|
+
* blockers don't count (validBlockers keeps them out, and a stale link that
|
|
1735
|
+
* survived a race reads as done rather than blocking forever).
|
|
1736
|
+
*/
|
|
1737
|
+
taskBlockers(id) {
|
|
1738
|
+
const tasks = readProjectState(this.info.dir).tasks ?? [];
|
|
1739
|
+
const task = tasks.find((t) => t.id === id);
|
|
1740
|
+
if (!task?.blockedBy?.length)
|
|
1741
|
+
return [];
|
|
1742
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
1743
|
+
return task.blockedBy
|
|
1744
|
+
.map((bid) => byId.get(bid))
|
|
1745
|
+
.filter((b) => Boolean(b) && b.column !== "ready")
|
|
1746
|
+
.map((b) => ({ id: b.id, title: b.title, column: b.column }));
|
|
1747
|
+
}
|
|
1748
|
+
/** task/<id>-<slug>: stable id first so a retitle doesn't orphan the branch. */
|
|
1749
|
+
/**
|
|
1750
|
+
* What opening a PR for this card would push, and what it would run.
|
|
1751
|
+
*
|
|
1752
|
+
* Asked before anything happens, because pushing publishes: the person sees
|
|
1753
|
+
* the branch, the commits and the exact command, and only then decides.
|
|
1754
|
+
*/
|
|
1755
|
+
async taskPrPlan(id) {
|
|
1756
|
+
const task = (readProjectState(this.info.dir).tasks ?? []).find((t) => t.id === id);
|
|
1757
|
+
if (!task)
|
|
1758
|
+
throw new Error(`no card "${id}"`);
|
|
1759
|
+
const branch = this.taskBranchName(task);
|
|
1760
|
+
const base = (await readOut(this.info.dir, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]))
|
|
1761
|
+
.replace(/^origin\//, "")
|
|
1762
|
+
.trim() || "main";
|
|
1763
|
+
const exists = await readOut(this.info.dir, ["rev-parse", "--verify", "--quiet", branch]);
|
|
1764
|
+
if (!exists.trim()) {
|
|
1765
|
+
return { branch, base, commits: [], files: [], command: "", ready: false, why: `there's no ${branch} branch yet` };
|
|
1766
|
+
}
|
|
1767
|
+
const log = await readOut(this.info.dir, ["log", "--oneline", `${base}..${branch}`]);
|
|
1768
|
+
const commits = log.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1769
|
+
const diff = await readOut(this.info.dir, ["diff", "--name-only", `${base}...${branch}`]);
|
|
1770
|
+
const files = diff.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1771
|
+
const command = `gh pr create --head ${branch} --base ${base} --title ${JSON.stringify(task.title)} --body ""`;
|
|
1772
|
+
return {
|
|
1773
|
+
branch,
|
|
1774
|
+
base,
|
|
1775
|
+
commits,
|
|
1776
|
+
files,
|
|
1777
|
+
command,
|
|
1778
|
+
ready: commits.length > 0,
|
|
1779
|
+
...(commits.length ? {} : { why: `${branch} has nothing ${base} doesn't` }),
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
/** Push the branch and open the PR — only ever from an explicit click. */
|
|
1783
|
+
async openTaskPr(id) {
|
|
1784
|
+
const task = (readProjectState(this.info.dir).tasks ?? []).find((t) => t.id === id);
|
|
1785
|
+
if (!task)
|
|
1786
|
+
throw new Error(`no card "${id}"`);
|
|
1787
|
+
const plan = await this.taskPrPlan(id);
|
|
1788
|
+
if (!plan.ready)
|
|
1789
|
+
throw new Error(plan.why ?? "there's nothing to open a PR for");
|
|
1790
|
+
await readOut(this.info.dir, ["push", "-u", "origin", plan.branch]);
|
|
1791
|
+
const out = await readOut(this.info.dir, [], {
|
|
1792
|
+
cmd: "gh",
|
|
1793
|
+
args: ["pr", "create", "--head", plan.branch, "--base", plan.base, "--title", task.title, "--body", ""],
|
|
1794
|
+
});
|
|
1795
|
+
const url = (out.match(/https:\/\/\S+/) ?? [""])[0];
|
|
1796
|
+
if (!url)
|
|
1797
|
+
throw new Error(out.trim().slice(0, 300) || "gh didn't return a PR url");
|
|
1798
|
+
this.appendIfOpen({ kind: "status", payload: { state: "task_pr", task: task.id, branch: plan.branch, url } });
|
|
1799
|
+
return { url, branch: plan.branch };
|
|
1800
|
+
}
|
|
1801
|
+
taskBranchName(task) {
|
|
1802
|
+
const slug = task.title
|
|
1803
|
+
.toLowerCase()
|
|
1804
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1805
|
+
.replace(/^-+|-+$/g, "")
|
|
1806
|
+
.slice(0, 40);
|
|
1807
|
+
return `task/${task.id}${slug ? `-${slug}` : ""}`;
|
|
1808
|
+
}
|
|
414
1809
|
/** Move or retitle a card. Yours, so this is the real state — not a hint. */
|
|
415
1810
|
updateTask(id, patch) {
|
|
416
1811
|
const state = readProjectState(this.info.dir);
|
|
417
1812
|
const task = (state.tasks ?? []).find((t) => t.id === id);
|
|
418
1813
|
if (!task)
|
|
419
1814
|
return null;
|
|
1815
|
+
applyCardMeta(task, patch);
|
|
1816
|
+
// Opt-in: dragging a card to Working checks out its branch; reaching
|
|
1817
|
+
// Review logs the PR command rather than running it — pushing publishes,
|
|
1818
|
+
// and publishing implicitly is a line Loom doesn't cross even under a
|
|
1819
|
+
// flag. The branch name leads with the stable id so a retitle doesn't
|
|
1820
|
+
// orphan it. Failures (not a repo, dirty tree) land in the Console; the
|
|
1821
|
+
// drag itself always succeeds — the board must not refuse to reflect
|
|
1822
|
+
// reality because git had opinions.
|
|
1823
|
+
if (this.config.git?.branchPerTask && patch.column && patch.column !== task.column) {
|
|
1824
|
+
const branch = this.taskBranchName(task);
|
|
1825
|
+
if (patch.column === "working") {
|
|
1826
|
+
void ensureBranch(this.info.dir, branch)
|
|
1827
|
+
.then(({ created }) => this.appendIfOpen({
|
|
1828
|
+
kind: "status",
|
|
1829
|
+
payload: { state: "task_branch", task: task.id, branch, created },
|
|
1830
|
+
}))
|
|
1831
|
+
.catch((err) => logbook.warn("git", `couldn't switch to ${branch}`, String(err), this.info.id));
|
|
1832
|
+
}
|
|
1833
|
+
else if (patch.column === "in-review") {
|
|
1834
|
+
logbook.info("git", `"${task.title}" reached review — open the PR with: gh pr create --head ${branch}`, undefined, this.info.id);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
420
1837
|
if (patch.title !== undefined)
|
|
421
1838
|
task.title = patch.title.trim().slice(0, 200) || task.title;
|
|
422
1839
|
if (patch.column !== undefined)
|
|
423
1840
|
task.column = patch.column;
|
|
424
1841
|
if (patch.agent !== undefined)
|
|
425
1842
|
task.agent = patch.agent;
|
|
1843
|
+
for (const k of ["crew", "goal", "stage", "claimedBy"])
|
|
1844
|
+
if (patch[k] !== undefined)
|
|
1845
|
+
task[k] = String(patch[k]).slice(0, 80);
|
|
1846
|
+
if (patch.blockedBy !== undefined) {
|
|
1847
|
+
// Cycles refused at write: A→B→A makes both unbecomable forever, and the
|
|
1848
|
+
// person who typed it is the one who can pick which link was wrong.
|
|
1849
|
+
const next = this.validBlockers(state.tasks ?? [], patch.blockedBy).filter((b) => b !== id);
|
|
1850
|
+
if (this.wouldCycle(state.tasks ?? [], id, next)) {
|
|
1851
|
+
throw new Error("that dependency would make a cycle — nothing in it could ever start");
|
|
1852
|
+
}
|
|
1853
|
+
if (next.length)
|
|
1854
|
+
task.blockedBy = next;
|
|
1855
|
+
else
|
|
1856
|
+
delete task.blockedBy;
|
|
1857
|
+
}
|
|
426
1858
|
writeProjectState(this.info.dir, state);
|
|
427
1859
|
return task;
|
|
428
1860
|
}
|
|
1861
|
+
/** Would `id` depending on `blockers` create a loop back to `id`? */
|
|
1862
|
+
wouldCycle(tasks, id, blockers) {
|
|
1863
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
1864
|
+
const stack = [...blockers];
|
|
1865
|
+
const seen = new Set();
|
|
1866
|
+
while (stack.length) {
|
|
1867
|
+
const cur = stack.pop();
|
|
1868
|
+
if (cur === id)
|
|
1869
|
+
return true;
|
|
1870
|
+
if (seen.has(cur))
|
|
1871
|
+
continue;
|
|
1872
|
+
seen.add(cur);
|
|
1873
|
+
for (const b of byId.get(cur)?.blockedBy ?? [])
|
|
1874
|
+
stack.push(b);
|
|
1875
|
+
}
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
429
1878
|
deleteTask(id) {
|
|
430
1879
|
const state = readProjectState(this.info.dir);
|
|
431
1880
|
const before = (state.tasks ?? []).length;
|
|
@@ -437,6 +1886,10 @@ export class ProjectRuntime {
|
|
|
437
1886
|
}
|
|
438
1887
|
/** Any adapter mid-turn? (Hot reloads are deferred while work is in flight.) */
|
|
439
1888
|
anyBusy() {
|
|
1889
|
+
// A live orchestra run counts: reloading the project would close it, and
|
|
1890
|
+
// closing aborts the run — a config edit must not kill a fleet mid-flight.
|
|
1891
|
+
if (this.orchestra.active() || this.routes.isActive() || this.turns.busySince.size > 0 || this.crews.list().some(c => c.busy))
|
|
1892
|
+
return true;
|
|
440
1893
|
return [...this.agents.values()].some((a) => isAdapter(a) && a.busy());
|
|
441
1894
|
}
|
|
442
1895
|
agent(id) {
|
|
@@ -446,198 +1899,107 @@ export class ProjectRuntime {
|
|
|
446
1899
|
return agent;
|
|
447
1900
|
}
|
|
448
1901
|
async ensureStarted(agentId) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
await agent.start();
|
|
452
|
-
this.startedAgents.add(agentId);
|
|
453
|
-
}
|
|
454
|
-
return agent;
|
|
1902
|
+
this.agent(agentId); // preserve the public unknown-agent error
|
|
1903
|
+
return this.agentLifecycle.start(agentId);
|
|
455
1904
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
this.extractMemory(agentId, []);
|
|
464
|
-
return;
|
|
1905
|
+
checkpoints() { return this.turns.checkpoints(); }
|
|
1906
|
+
async rewind(id) { return this.turns.rewind(id); }
|
|
1907
|
+
/** Put one file back as a checkpoint had it (see checkpoints.restoreFile). */
|
|
1908
|
+
async rewindFile(id, file) {
|
|
1909
|
+
const busy = [...this.turns.busySince.keys()];
|
|
1910
|
+
if (busy.length) {
|
|
1911
|
+
throw new Error(`${busy.join(", ")} ${busy.length === 1 ? "is" : "are"} mid-turn — stop the turn first, or the file changes underneath it`);
|
|
465
1912
|
}
|
|
466
|
-
this.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
agentId,
|
|
473
|
-
payload: {
|
|
474
|
-
files: diff.files,
|
|
475
|
-
added: diff.added,
|
|
476
|
-
removed: diff.removed,
|
|
477
|
-
patch: diff.patch,
|
|
478
|
-
truncated: diff.truncated,
|
|
479
|
-
},
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
// Learn from the turn once we know which files it touched — the files
|
|
483
|
-
// sharpen candidate retrieval. Runs after the diff so recentTurnFiles
|
|
484
|
-
// isn't needed; the files are right here.
|
|
485
|
-
this.extractMemory(agentId, (diff?.files ?? []).map((f) => f.path));
|
|
486
|
-
})
|
|
487
|
-
.catch(() => this.extractMemory(agentId, []));
|
|
1913
|
+
const out = await checkpoints.restoreFile(this.info.dir, id, file);
|
|
1914
|
+
this.log.append({
|
|
1915
|
+
kind: "checkpoint",
|
|
1916
|
+
payload: { id: out.restored.id, label: `${out.path} only`, at: Date.now(), reason: "rewound", files: 1, undo: out.undo.id, path: out.path },
|
|
1917
|
+
});
|
|
1918
|
+
return out;
|
|
488
1919
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
* Fire-and-forget on purpose. A slow or missing extractor must never delay
|
|
493
|
-
* anything — extractFromTurn already swallows engine failures, and this is
|
|
494
|
-
* void-ed so even an unexpected throw can't escape into the event pipeline.
|
|
495
|
-
* Off entirely when config says so; a no-op when Claude isn't available.
|
|
496
|
-
*/
|
|
1920
|
+
async turnFacts(agentId) { return this.turns.turnFacts(agentId); }
|
|
1921
|
+
captureTurnDiff(agentId) { return this.turns.captureTurnDiff(agentId); }
|
|
1922
|
+
async commitTurn(agentId, files) { return this.turns.commitTurn(agentId, files); }
|
|
497
1923
|
extractMemory(agentId, files) {
|
|
498
|
-
if (this.
|
|
499
|
-
|
|
500
|
-
const chat = this.turnChat.get(agentId) ?? MAIN_CHAT;
|
|
501
|
-
const turn = this.gatherTurnText(chat);
|
|
502
|
-
if (turn.length < 40)
|
|
503
|
-
return; // nothing substantial to learn from
|
|
504
|
-
const model = this.config.brain?.model ?? "haiku";
|
|
505
|
-
const engine = (p) => claudeText(`${p.system}\n\n${p.user}`, { model, timeoutMs: 60_000 });
|
|
506
|
-
void extractFromTurn(this.brain, turn, {
|
|
507
|
-
engine,
|
|
508
|
-
agentId,
|
|
509
|
-
chat,
|
|
510
|
-
...(files.length ? { files } : {}),
|
|
511
|
-
eventId: this.log.lastId(),
|
|
512
|
-
})
|
|
513
|
-
.then((res) => {
|
|
514
|
-
const learned = res.added.length + res.updated.length + res.forgotten.length;
|
|
515
|
-
if (learned > 0) {
|
|
516
|
-
this.log.append({
|
|
517
|
-
kind: "status",
|
|
518
|
-
payload: {
|
|
519
|
-
state: "brain_extract",
|
|
520
|
-
agentId,
|
|
521
|
-
added: res.added.length,
|
|
522
|
-
updated: res.updated.length,
|
|
523
|
-
forgotten: res.forgotten.length,
|
|
524
|
-
},
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
})
|
|
528
|
-
.catch(() => { });
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* The transcript of the most recent turn in a chat: from the last human
|
|
532
|
-
* message to now — the user's ask and what the agent did in reply.
|
|
533
|
-
*/
|
|
534
|
-
gatherTurnText(chat) {
|
|
535
|
-
const events = this.log.list({ chat, limit: 40 });
|
|
536
|
-
let start = 0;
|
|
537
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
538
|
-
if (events[i].kind === "message" && !events[i].agentId) {
|
|
539
|
-
start = i;
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
const lines = [];
|
|
544
|
-
for (const e of events.slice(start)) {
|
|
545
|
-
const p = e.payload;
|
|
546
|
-
if (e.kind === "message") {
|
|
547
|
-
lines.push(`${e.agentId ?? "user"}: ${String(p.text ?? "").slice(0, 2000)}`);
|
|
548
|
-
}
|
|
549
|
-
else if (e.kind === "tool_call") {
|
|
550
|
-
lines.push(`[${e.agentId} used ${String(p.tool ?? "a tool")}] ${String(p.summary ?? "")}`.trim());
|
|
551
|
-
}
|
|
552
|
-
else if (e.kind === "file_edit") {
|
|
553
|
-
lines.push(`[${e.agentId} edited ${String(p.path ?? "")}]`);
|
|
554
|
-
}
|
|
555
|
-
else if (e.kind === "decision") {
|
|
556
|
-
lines.push(`decision: ${String(p.text ?? "")}`);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return lines.join("\n").trim();
|
|
1924
|
+
if (!this.continuity)
|
|
1925
|
+
this.briefings.extractMemory(agentId, files);
|
|
560
1926
|
}
|
|
561
1927
|
workingTree() {
|
|
562
1928
|
return workingTree(this.info.dir);
|
|
563
1929
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
return
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const query = events
|
|
584
|
-
.filter((e) => e.kind === "message")
|
|
585
|
-
.slice(-8)
|
|
586
|
-
.map((e) => String(e.payload.text ?? ""))
|
|
587
|
-
.join(" ");
|
|
588
|
-
const files = [
|
|
589
|
-
...new Set(events
|
|
590
|
-
.filter((e) => e.kind === "turn_diff")
|
|
591
|
-
.flatMap((e) => {
|
|
592
|
-
// turn_diff stores ChangedFile[] ({status, path}); older events or
|
|
593
|
-
// other shapes may carry bare strings. Normalise to paths.
|
|
594
|
-
const raw = e.payload.files ?? [];
|
|
595
|
-
return raw.map((f) => (typeof f === "string" ? f : (f?.path ?? ""))).filter(Boolean);
|
|
596
|
-
})),
|
|
597
|
-
].slice(-20);
|
|
598
|
-
if (!query.trim() && !files.length)
|
|
599
|
-
return "";
|
|
600
|
-
const hits = retrieve(this.brain, {
|
|
601
|
-
...(query.trim() ? { query } : {}),
|
|
602
|
-
...(files.length ? { files } : {}),
|
|
603
|
-
agent: agentId,
|
|
604
|
-
minConfidence: CONFIDENCE_FLOOR,
|
|
605
|
-
limit: 14,
|
|
606
|
-
});
|
|
607
|
-
return compileBrief(hits.map((h) => h.memory));
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Pull each ADE's native memory into the shared log. Idempotent — a source
|
|
611
|
-
* whose content hasn't changed since its last import is skipped, so this is
|
|
612
|
-
* safe to call on connect, on demand, or on a timer.
|
|
613
|
-
*/
|
|
614
|
-
importMemories() {
|
|
615
|
-
const seen = new Map(); // file -> last imported hash
|
|
616
|
-
for (const e of this.log.list({ kinds: ["memory_import"] })) {
|
|
617
|
-
seen.set(String(e.payload.file), String(e.payload.hash));
|
|
618
|
-
}
|
|
619
|
-
const sources = [];
|
|
620
|
-
let imported = 0;
|
|
621
|
-
for (const block of this.importedMemory()) {
|
|
622
|
-
const hash = hashContent(block.content);
|
|
623
|
-
if (seen.get(block.file) === hash)
|
|
624
|
-
continue;
|
|
625
|
-
this.log.append({
|
|
626
|
-
kind: "memory_import",
|
|
627
|
-
agentId: block.agentId,
|
|
628
|
-
payload: { file: block.file, kind: block.kind, chars: block.content.length, hash },
|
|
629
|
-
});
|
|
630
|
-
sources.push(block.file);
|
|
631
|
-
imported += 1;
|
|
1930
|
+
unifiedMemory() { return this.briefings.unifiedMemory(); }
|
|
1931
|
+
brainBrief(opts) { return this.briefings.brainBrief(opts); }
|
|
1932
|
+
// How often each memory made it into a prompt — the Brain tab's "most
|
|
1933
|
+
// used". Counted where briefs are compiled, so it's what agents actually
|
|
1934
|
+
// got, not what a search happened to return. Written at most every few
|
|
1935
|
+
// seconds; a lost count on a crash costs nothing.
|
|
1936
|
+
memoryUse = null;
|
|
1937
|
+
memoryUseTimer = null;
|
|
1938
|
+
memoryUseFile() {
|
|
1939
|
+
return path.join(this.info.dir, ".loom", "memory-usage.json");
|
|
1940
|
+
}
|
|
1941
|
+
memoryUsage() {
|
|
1942
|
+
if (!this.memoryUse) {
|
|
1943
|
+
try {
|
|
1944
|
+
this.memoryUse = JSON.parse(fs.readFileSync(this.memoryUseFile(), "utf8"));
|
|
1945
|
+
}
|
|
1946
|
+
catch {
|
|
1947
|
+
this.memoryUse = {};
|
|
1948
|
+
}
|
|
632
1949
|
}
|
|
633
|
-
return
|
|
1950
|
+
return this.memoryUse;
|
|
1951
|
+
}
|
|
1952
|
+
noteMemoriesUsed(ids) {
|
|
1953
|
+
if (!ids.length)
|
|
1954
|
+
return;
|
|
1955
|
+
const use = this.memoryUsage();
|
|
1956
|
+
const now = Date.now();
|
|
1957
|
+
for (const id of new Set(ids))
|
|
1958
|
+
use[id] = { n: (use[id]?.n ?? 0) + 1, at: now };
|
|
1959
|
+
if (this.memoryUseTimer)
|
|
1960
|
+
return;
|
|
1961
|
+
this.memoryUseTimer = setTimeout(() => {
|
|
1962
|
+
this.memoryUseTimer = null;
|
|
1963
|
+
try {
|
|
1964
|
+
fs.writeFileSync(this.memoryUseFile(), JSON.stringify(this.memoryUse));
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
/* a count that didn't save is not worth a failed turn */
|
|
1968
|
+
}
|
|
1969
|
+
}, 3000);
|
|
1970
|
+
this.memoryUseTimer.unref?.();
|
|
634
1971
|
}
|
|
1972
|
+
async brainBriefFor(opts) { return this.briefings.brainBriefFor(opts); }
|
|
1973
|
+
async searchBrain(opts) { return this.briefings.searchBrain(opts); }
|
|
1974
|
+
importMemories() { return this.briefings.importMemories(); }
|
|
635
1975
|
/** Fire-and-notify hooks + routing + suggested handoffs, off the log. */
|
|
636
1976
|
afterAgentEvent(event) {
|
|
637
1977
|
this.trackCost(event);
|
|
638
|
-
this.
|
|
1978
|
+
this.shield(event);
|
|
1979
|
+
const loop = this.loops.observe(event);
|
|
1980
|
+
if (loop)
|
|
1981
|
+
void this.onLoop(loop).catch((err) => logbook.warn("loops", "couldn't act on a loop", String(err), this.info.id));
|
|
1982
|
+
// an agent out of quota mid-task hands its work on (core/quota.ts, runtime/relay.ts)
|
|
1983
|
+
this.relay.observe(event);
|
|
1984
|
+
if (event.kind === "run_complete")
|
|
1985
|
+
this.shifts.poke();
|
|
1986
|
+
// The turn's diff is started before routing hears the turn ended: a step
|
|
1987
|
+
// condition reads those numbers, and a route that advanced first would
|
|
1988
|
+
// read the turn before this one.
|
|
639
1989
|
if (event.kind === "run_complete" && event.agentId) {
|
|
640
1990
|
this.captureTurnDiff(event.agentId);
|
|
1991
|
+
// A reply that claims the tests (or build, or lint) pass gets checked —
|
|
1992
|
+
// against what the turn ran, and a clean re-run — without being asked.
|
|
1993
|
+
if (!event.payload.error && this.proofs.wantsAuto(event))
|
|
1994
|
+
void this.proofs.prove(event.chat ?? MAIN_CHAT, { at: event.id, auto: true }).catch((err) => logbook.warn("proof", "couldn't check the turn", String(err), this.info.id));
|
|
1995
|
+
if (!this.continuity)
|
|
1996
|
+
void this.captureAgentDecisions(event.agentId).catch(() => { });
|
|
1997
|
+
}
|
|
1998
|
+
this.routes.handleAgentEvent(event);
|
|
1999
|
+
// Accumulate the turn's prose so decisions can be mined when it completes.
|
|
2000
|
+
if (!this.continuity && event.kind === "message" && event.agentId && !event.payload.reasoning) {
|
|
2001
|
+
const prev = this.turns.turnText.get(event.agentId) ?? "";
|
|
2002
|
+
this.turns.turnText.set(event.agentId, `${prev}\n${String(event.payload.text ?? "")}`.slice(-8000));
|
|
641
2003
|
}
|
|
642
2004
|
if (event.kind === "needs_input") {
|
|
643
2005
|
notify({
|
|
@@ -652,7 +2014,7 @@ export class ProjectRuntime {
|
|
|
652
2014
|
});
|
|
653
2015
|
}
|
|
654
2016
|
else if (event.kind === "message") {
|
|
655
|
-
if (event.agentId)
|
|
2017
|
+
if (event.agentId && !this.continuity)
|
|
656
2018
|
this.captureDecisions(event);
|
|
657
2019
|
if (!this.routes.isActive()) {
|
|
658
2020
|
// A route drives its own handoffs — suggestions would be noise.
|
|
@@ -677,6 +2039,119 @@ export class ProjectRuntime {
|
|
|
677
2039
|
});
|
|
678
2040
|
}
|
|
679
2041
|
}
|
|
2042
|
+
// ── Structured agent decisions (Observatory Decision Explorer + Replay) ──
|
|
2043
|
+
decisionsFile() {
|
|
2044
|
+
return path.join(projectLoomDir(this.info.dir), "decisions.json");
|
|
2045
|
+
}
|
|
2046
|
+
/** An agent's declared role from config (planner/builder/reviewer/…), else its kind. */
|
|
2047
|
+
agentRole(agentId) {
|
|
2048
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
2049
|
+
return cfg?.role ?? cfg?.kind ?? "agent";
|
|
2050
|
+
}
|
|
2051
|
+
/** How many turns this agent has completed (for the decision's turnIndex). */
|
|
2052
|
+
turnCountFor(agentId) {
|
|
2053
|
+
return this.log.count({ kinds: ["run_complete"], agentId });
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* All captured decisions for this project, newest first, in today's shape —
|
|
2057
|
+
* see normalizeStoredDecision for what that does to records written before
|
|
2058
|
+
* a decision had to say where its confidence came from.
|
|
2059
|
+
*/
|
|
2060
|
+
getDecisions() {
|
|
2061
|
+
try {
|
|
2062
|
+
const raw = fs.readFileSync(this.decisionsFile(), "utf8");
|
|
2063
|
+
const arr = JSON.parse(raw);
|
|
2064
|
+
if (!Array.isArray(arr))
|
|
2065
|
+
return [];
|
|
2066
|
+
return arr.map(normalizeStoredDecision).sort((a, b) => b.timestamp - a.timestamp);
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
return [];
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
decisionStats() {
|
|
2073
|
+
return decisionStats(this.getDecisions());
|
|
2074
|
+
}
|
|
2075
|
+
/** Append decisions to the persisted store (kept oldest→newest on disk). */
|
|
2076
|
+
storeDecisions(decisions) {
|
|
2077
|
+
if (!decisions.length)
|
|
2078
|
+
return;
|
|
2079
|
+
const existing = this.getDecisions().sort((a, b) => a.timestamp - b.timestamp);
|
|
2080
|
+
const all = [...existing, ...decisions].slice(-1000); // bound the file
|
|
2081
|
+
try {
|
|
2082
|
+
fs.mkdirSync(projectLoomDir(this.info.dir), { recursive: true });
|
|
2083
|
+
fs.writeFileSync(this.decisionsFile(), JSON.stringify(all, null, 2));
|
|
2084
|
+
}
|
|
2085
|
+
catch {
|
|
2086
|
+
/* best-effort: decisions are an enrichment, never break the loop */
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/** Mine decisions from a completed turn, persist them, surface on the Timeline. */
|
|
2090
|
+
async captureAgentDecisions(agentId) {
|
|
2091
|
+
const turnText = this.turns.turnText.get(agentId) ?? "";
|
|
2092
|
+
this.turns.turnText.delete(agentId);
|
|
2093
|
+
if (turnText.trim().length < 100)
|
|
2094
|
+
return;
|
|
2095
|
+
const lastTurn = this.log.list({ kinds: ["run_complete"], agentId, limit: 1 })[0];
|
|
2096
|
+
const p = (lastTurn?.payload ?? {});
|
|
2097
|
+
let filesChanged = [];
|
|
2098
|
+
try {
|
|
2099
|
+
// stderr is dropped, not inherited: a project that isn't a repo yet, or
|
|
2100
|
+
// one with no commits, makes git print a usage wall on every completed
|
|
2101
|
+
// turn. The failure is already handled — it must not also be shouted
|
|
2102
|
+
// into the daemon's console.
|
|
2103
|
+
filesChanged = execSync("git diff --name-only HEAD", {
|
|
2104
|
+
cwd: this.info.dir,
|
|
2105
|
+
encoding: "utf8",
|
|
2106
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2107
|
+
})
|
|
2108
|
+
.trim().split("\n").filter(Boolean);
|
|
2109
|
+
}
|
|
2110
|
+
catch { /* not a git repo, or nothing changed */ }
|
|
2111
|
+
// The trace this turn's spans went out under, when telemetry is on.
|
|
2112
|
+
// Undefined otherwise — see observability/index.ts#turnTraceId; a decision
|
|
2113
|
+
// that can't link to a trace must carry no trace id rather than "".
|
|
2114
|
+
const traceId = turnTraceId(agentId);
|
|
2115
|
+
const decisions = await extractDecisions({
|
|
2116
|
+
agentId,
|
|
2117
|
+
agentRole: this.agentRole(agentId),
|
|
2118
|
+
projectId: this.info.id,
|
|
2119
|
+
chatId: this.turns.turnChat.get(agentId) ?? MAIN_CHAT,
|
|
2120
|
+
turnIndex: this.turnCountFor(agentId),
|
|
2121
|
+
...(traceId ? { traceId } : {}),
|
|
2122
|
+
...(lastTurn ? { turnId: String(lastTurn.id) } : {}),
|
|
2123
|
+
turnText,
|
|
2124
|
+
// The turn's totals, carried as the TURN's — not divided between the
|
|
2125
|
+
// decisions mined from it, and not stamped on each as if each cost this.
|
|
2126
|
+
turnTokensUsed: Number(p.inputTokens ?? 0) + Number(p.outputTokens ?? 0),
|
|
2127
|
+
turnCostUsd: Number(p.costUsd ?? 0),
|
|
2128
|
+
durationMs: Number(p.durationMs ?? 0),
|
|
2129
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
2130
|
+
filesChanged,
|
|
2131
|
+
});
|
|
2132
|
+
if (this.closed || !decisions.length)
|
|
2133
|
+
return;
|
|
2134
|
+
this.storeDecisions(decisions);
|
|
2135
|
+
// Surface each on the Timeline / snapshots via a status event (no new EventKind,
|
|
2136
|
+
// and no collision with the brain's memory `decision` events). `source` rides
|
|
2137
|
+
// along so a renderer can say where the confidence came from — or that there
|
|
2138
|
+
// isn't one, which is what a heuristic decision carries.
|
|
2139
|
+
for (const d of decisions) {
|
|
2140
|
+
this.log.append({
|
|
2141
|
+
kind: "status",
|
|
2142
|
+
agentId: d.agentId,
|
|
2143
|
+
...(d.chatId && d.chatId !== MAIN_CHAT ? { chat: d.chatId } : {}),
|
|
2144
|
+
payload: {
|
|
2145
|
+
state: "agent_decision",
|
|
2146
|
+
decisionId: d.id,
|
|
2147
|
+
title: d.title,
|
|
2148
|
+
category: d.category,
|
|
2149
|
+
source: d.source,
|
|
2150
|
+
...(d.confidence !== undefined ? { confidence: d.confidence } : {}),
|
|
2151
|
+
},
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
680
2155
|
/**
|
|
681
2156
|
* The persisted holder, unless it refers to an agent that has since been
|
|
682
2157
|
* removed from the config — ghost holders are cleared, not fatal.
|
|
@@ -724,10 +2199,10 @@ export class ProjectRuntime {
|
|
|
724
2199
|
throw new Error(`agent "${agentId}" can be watched but not driven`);
|
|
725
2200
|
}
|
|
726
2201
|
await this.ensureStarted(agentId);
|
|
727
|
-
this.turnChat.set(agentId, chat);
|
|
2202
|
+
this.turns.turnChat.set(agentId, chat);
|
|
728
2203
|
this.log.append({ kind: "message", chat, agentId, payload: { text, author: "user" } });
|
|
729
2204
|
try {
|
|
730
|
-
const reply = await bridge.ask(text);
|
|
2205
|
+
const reply = await withLoomAskTimeout(bridge.ask(text));
|
|
731
2206
|
return { agentId, reply };
|
|
732
2207
|
}
|
|
733
2208
|
catch (err) {
|
|
@@ -738,47 +2213,525 @@ export class ProjectRuntime {
|
|
|
738
2213
|
throw err;
|
|
739
2214
|
}
|
|
740
2215
|
}
|
|
2216
|
+
// ── the prompt queue (core/prompt-queue.ts) ──
|
|
2217
|
+
/**
|
|
2218
|
+
* A proxy in front of one server, so its page can talk to the app.
|
|
2219
|
+
*
|
|
2220
|
+
* One per server, reused while it points at the same place — restarting a
|
|
2221
|
+
* dev server on the same port keeps the preview URL stable, which matters
|
|
2222
|
+
* because the iframe is pointed at it.
|
|
2223
|
+
*/
|
|
2224
|
+
async previewProxy(name, target) {
|
|
2225
|
+
const live = this.proxies.get(name);
|
|
2226
|
+
if (live && live.target === target)
|
|
2227
|
+
return live;
|
|
2228
|
+
if (live)
|
|
2229
|
+
await live.close().catch(() => { });
|
|
2230
|
+
const proxy = await startPreviewProxy(target);
|
|
2231
|
+
this.proxies.set(name, proxy);
|
|
2232
|
+
return proxy;
|
|
2233
|
+
}
|
|
2234
|
+
/** Live server state and output, for the socket. Returns unsubscribe. */
|
|
2235
|
+
/** Replies as they're written, for the socket. Returns unsubscribe. */
|
|
2236
|
+
onStream(cb) {
|
|
2237
|
+
this.streamListeners.add(cb);
|
|
2238
|
+
return () => this.streamListeners.delete(cb);
|
|
2239
|
+
}
|
|
2240
|
+
/** The replies being typed right now in one thread (or all of them). */
|
|
2241
|
+
liveNow(chat) {
|
|
2242
|
+
const out = [];
|
|
2243
|
+
for (const [agentId, s] of this.liveSoFar) {
|
|
2244
|
+
if (chat !== undefined && s.chat !== chat)
|
|
2245
|
+
continue;
|
|
2246
|
+
if (s.text || s.think)
|
|
2247
|
+
out.push({ agentId, ...s });
|
|
2248
|
+
}
|
|
2249
|
+
return out;
|
|
2250
|
+
}
|
|
2251
|
+
liveSoFar = new Map();
|
|
2252
|
+
liveText(f) {
|
|
2253
|
+
if (this.closed)
|
|
2254
|
+
return;
|
|
2255
|
+
let s = this.liveSoFar.get(f.agentId);
|
|
2256
|
+
if (!s || s.chat !== f.chat)
|
|
2257
|
+
this.liveSoFar.set(f.agentId, (s = { chat: f.chat, text: "", think: "" }));
|
|
2258
|
+
const off = f.reasoning ? s.think.length : s.text.length;
|
|
2259
|
+
if (f.reasoning)
|
|
2260
|
+
s.think += f.text;
|
|
2261
|
+
else
|
|
2262
|
+
s.text += f.text;
|
|
2263
|
+
// a runaway reply can't grow the buffer without bound
|
|
2264
|
+
if (s.text.length > 400_000)
|
|
2265
|
+
s.text = "";
|
|
2266
|
+
if (s.think.length > 400_000)
|
|
2267
|
+
s.think = "";
|
|
2268
|
+
const framed = { ...f, off };
|
|
2269
|
+
for (const cb of this.streamListeners) {
|
|
2270
|
+
try {
|
|
2271
|
+
cb(framed);
|
|
2272
|
+
}
|
|
2273
|
+
catch {
|
|
2274
|
+
// a viewer that breaks must not break the turn
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
/** Streamed text and tool progress as a turn produces them; what finishes lands in the log. */
|
|
2279
|
+
onLiveDelta(cb) {
|
|
2280
|
+
this.liveListeners.add(cb);
|
|
2281
|
+
return () => this.liveListeners.delete(cb);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Answer a structured question an agent is waiting on (a needs_input event
|
|
2285
|
+
* with a requestId). The turn carries on with the answer.
|
|
2286
|
+
*/
|
|
2287
|
+
async answerQuestion(agentId, chat, requestId, answers) {
|
|
2288
|
+
// a crew teammate or orchestra worker runs as its own instance; its card names that instance
|
|
2289
|
+
const agent = (this.agents.get(agentId) ?? this.crews?.liveAgent?.(agentId) ?? this.orchestra?.liveAgent?.(agentId));
|
|
2290
|
+
if (!agent || typeof agent.respondToUserInput !== "function")
|
|
2291
|
+
throw new Error(`agent "${agentId}" can't take answers to questions`);
|
|
2292
|
+
this.releaseQuestionHold(agentId);
|
|
2293
|
+
await agent.respondToUserInput(chat, requestId, answers);
|
|
2294
|
+
if (this.openQuestions.get(agentId)?.requestId === requestId)
|
|
2295
|
+
this.openQuestions.delete(agentId);
|
|
2296
|
+
}
|
|
2297
|
+
/** Structured questions agents are blocked on, by agent. */
|
|
2298
|
+
openQuestions = new Map();
|
|
2299
|
+
/** Compact an agent's native context for a chat now, instead of waiting for the harness to. */
|
|
2300
|
+
async compactAgent(agentId, chat = MAIN_CHAT) {
|
|
2301
|
+
const agent = this.agents.get(agentId);
|
|
2302
|
+
if (!(agent instanceof ProviderAgent))
|
|
2303
|
+
throw new Error(`agent "${agentId}" can't be compacted from Loom`);
|
|
2304
|
+
if (agent.busy() || this.turns.busySince.has(agentId))
|
|
2305
|
+
throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then compact`);
|
|
2306
|
+
await this.ensureStarted(agentId);
|
|
2307
|
+
this.turns.turnChat.set(agentId, chat);
|
|
2308
|
+
await agent.compact(chat);
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* A plan turn on a provider agent runs in the agent's own plan mode, which
|
|
2312
|
+
* changes nothing; Loom keeps the plan it proposes under plans/, as its plan
|
|
2313
|
+
* mode always has.
|
|
2314
|
+
*/
|
|
2315
|
+
saveProposedPlan(agentId, chat, markdown) {
|
|
2316
|
+
if (!markdown.trim())
|
|
2317
|
+
return;
|
|
2318
|
+
try {
|
|
2319
|
+
const heading = /^#+\s*(.+)$/m.exec(markdown)?.[1] ?? markdown.split("\n")[0] ?? "plan";
|
|
2320
|
+
const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "plan";
|
|
2321
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
2322
|
+
const dir = path.join(this.agentDir(agentId), "plans");
|
|
2323
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2324
|
+
let file = path.join(dir, `${day}-${slug}.md`);
|
|
2325
|
+
for (let n = 2; fs.existsSync(file); n++)
|
|
2326
|
+
file = path.join(dir, `${day}-${slug}-${n}.md`);
|
|
2327
|
+
fs.writeFileSync(file, `---\ntitle: ${JSON.stringify(heading.trim())}\nstatus: proposed\nagent: ${agentId}\n---\n\n${markdown.trim()}\n`);
|
|
2328
|
+
this.log.append({ kind: "status", agentId, ...(chat ? { chat } : {}),
|
|
2329
|
+
payload: { state: "plan_saved", path: path.relative(this.agentDir(agentId), file) } });
|
|
2330
|
+
}
|
|
2331
|
+
catch (error) {
|
|
2332
|
+
logbook.warn("plan", `could not save ${agentId}'s proposed plan`, String(error), this.info.id);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
onServerEvent(cb) {
|
|
2336
|
+
this.serverListeners.add(cb);
|
|
2337
|
+
return () => this.serverListeners.delete(cb);
|
|
2338
|
+
}
|
|
2339
|
+
onQueueChange(cb) { return this.queueCoordinator.onQueueChange(cb); }
|
|
2340
|
+
enqueue(input) {
|
|
2341
|
+
if (this.continuity) {
|
|
2342
|
+
this.queue.assertCanAdd(input);
|
|
2343
|
+
if (!this.chats().some(c => c.id === (input.chat ?? MAIN_CHAT)))
|
|
2344
|
+
throw new ContinuityError("invalid", "conversation is missing or deleted");
|
|
2345
|
+
const bound = this.chatBinding(input.chat);
|
|
2346
|
+
if (input.target?.kind === "orchestra")
|
|
2347
|
+
throw new ContinuityError("unsupported", "parallel orchestra is outside sequential native continuity");
|
|
2348
|
+
const target = input.target?.kind === "agent" ? input.target.agentId : bound.agentId ?? this.validHolder() ?? this.defaultAdapterId();
|
|
2349
|
+
const cfg = this.config.agents.find(a => a.id === target);
|
|
2350
|
+
if (!cfg || !isNativeKind(cfg.kind))
|
|
2351
|
+
throw new ContinuityError("unsupported", "queued native continuity needs a supported harness (Codex, Claude Code or OpenCode)");
|
|
2352
|
+
const captured = this.continuity.capture({ id: newId(16), text: input.text, conversationId: input.chat ?? MAIN_CHAT,
|
|
2353
|
+
agentInstanceId: target, source: input.source ?? "user", model: bound.agentId === target ? bound.model ?? null : null,
|
|
2354
|
+
plan: Boolean(input.plan), targetAddedTokens: 6000 });
|
|
2355
|
+
input = { ...input, target: { kind: "agent", agentId: target }, continuity: { requestId: captured.request.id, model: captured.request.model } };
|
|
2356
|
+
}
|
|
2357
|
+
return this.queueCoordinator.enqueue(input);
|
|
2358
|
+
}
|
|
2359
|
+
editQueued(itemId, patch) {
|
|
2360
|
+
if (this.continuity && (patch.text !== undefined || patch.target !== undefined || patch.plan !== undefined))
|
|
2361
|
+
throw new ContinuityError("unsupported", "native queued request revisions require durable supersession; remove the unsent entry and submit a new request instead");
|
|
2362
|
+
return this.queueCoordinator.editQueued(itemId, patch);
|
|
2363
|
+
}
|
|
2364
|
+
queueBlocker(item) { return this.queueCoordinator.queueBlocker(item); }
|
|
2365
|
+
watchClockConditions(q) { return this.queueCoordinator.watchClockConditions(q); }
|
|
2366
|
+
holdQueueFor(agentId) {
|
|
2367
|
+
// a night shift doesn't wait on a question: it stays open for you in its chat (and the report), and the next task goes
|
|
2368
|
+
if (this.shifts.active)
|
|
2369
|
+
return;
|
|
2370
|
+
return this.queueCoordinator.holdQueueFor(agentId);
|
|
2371
|
+
}
|
|
2372
|
+
releaseQuestionHold(agentId) { return this.queueCoordinator.releaseQuestionHold(agentId); }
|
|
2373
|
+
kickQueue() { return this.queueCoordinator.kickQueue(); }
|
|
2374
|
+
async drainPromptQueue() { return this.queueCoordinator.drainPromptQueue(); }
|
|
741
2375
|
async sendMessage(text, agentId, opts = {}) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
2376
|
+
// a person speaking in a chat is a new start: nobody there is "stuck" any more
|
|
2377
|
+
if ((opts.source ?? "user") === "user") {
|
|
2378
|
+
const chat = opts.chat ?? MAIN_CHAT;
|
|
2379
|
+
this.loops.reset(chat);
|
|
2380
|
+
for (const k of [...this.nudged])
|
|
2381
|
+
if (k.endsWith(`\u0000${chat}`))
|
|
2382
|
+
this.nudged.delete(k);
|
|
748
2383
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
2384
|
+
// a prompt for an agent that's out of quota goes to whoever is carrying its work
|
|
2385
|
+
const target = agentId ?? this.validHolder() ?? undefined;
|
|
2386
|
+
if (target && this.relay.holds(target)) {
|
|
2387
|
+
const routed = await this.relay.route(target, opts.chat ?? MAIN_CHAT);
|
|
2388
|
+
if (routed !== target)
|
|
2389
|
+
agentId = routed;
|
|
2390
|
+
}
|
|
2391
|
+
return this.turns.sendMessage(text, agentId, opts);
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* An agent is re-running the same failing command (or the same call over and
|
|
2395
|
+
* over). Say so in its chat — a card with Stop / Nudge / Hand off — and push;
|
|
2396
|
+
* with watchdog.auto "nudge", also stop the turn and tell it to step back.
|
|
2397
|
+
*/
|
|
2398
|
+
async onLoop(loop) {
|
|
2399
|
+
// on a night shift nobody is watching: nudge by default (an explicit setting still wins)
|
|
2400
|
+
const set = this.config.watchdog?.auto ?? (this.shifts.active ? "nudge" : "notify");
|
|
2401
|
+
if (set === "off")
|
|
2402
|
+
return;
|
|
2403
|
+
// nudge once; if it's still going in circles after that, it's yours to decide
|
|
2404
|
+
const key = `${loop.agentId}\u0000${loop.chat}`;
|
|
2405
|
+
const mode = set === "nudge" && !this.nudged.has(key) ? "nudge" : "notify";
|
|
2406
|
+
if (mode === "nudge")
|
|
2407
|
+
this.nudged.add(key);
|
|
2408
|
+
this.log.append({ kind: "status", agentId: loop.agentId, ...(loop.chat !== MAIN_CHAT ? { chat: loop.chat } : {}), payload: {
|
|
2409
|
+
state: "loop_detected", loop: loop.kind, call: loop.call, count: loop.count, ...(loop.output ? { output: loop.output } : {}),
|
|
2410
|
+
...(mode === "nudge" ? { nudged: true } : {}),
|
|
2411
|
+
text: loop.kind === "failing" ? `${loop.agentId} ran \`${loop.call}\` ${loop.count} times this turn, failing each time`
|
|
2412
|
+
: `${loop.agentId} made the same call ${loop.count} times this turn: \`${loop.call}\``,
|
|
2413
|
+
} });
|
|
2414
|
+
if (mode === "nudge")
|
|
2415
|
+
await this.actOnLoop(loop.chat, loop.agentId, "nudge", undefined, loop);
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Blast-Radius Shield (core/shield.ts): a command an agent ran that can't be
|
|
2419
|
+
* taken back — a force-push, a reset --hard, a recursive delete outside the
|
|
2420
|
+
* project, a dropped table. Said at once, in its chat and as a push; with
|
|
2421
|
+
* shield.auto "stop" (the default on a night shift) the turn is stopped too.
|
|
2422
|
+
*/
|
|
2423
|
+
shield(event) {
|
|
2424
|
+
if (!event.agentId)
|
|
2425
|
+
return;
|
|
2426
|
+
const chat = event.chat ?? MAIN_CHAT;
|
|
2427
|
+
if (event.kind === "run_complete") {
|
|
2428
|
+
for (const k of [...this.shielded])
|
|
2429
|
+
if (k.startsWith(`${event.agentId}\u0000${chat}\u0000`))
|
|
2430
|
+
this.shielded.delete(k);
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
if (event.kind !== "tool_call")
|
|
2434
|
+
return;
|
|
2435
|
+
const command = commandOf(event.payload);
|
|
2436
|
+
if (!command)
|
|
2437
|
+
return;
|
|
2438
|
+
const mode = this.config.shield?.auto ?? (this.shifts.active ? "stop" : "notify");
|
|
2439
|
+
if (mode === "off")
|
|
2440
|
+
return;
|
|
2441
|
+
const danger = dangerOf(command, this.agentDir(event.agentId));
|
|
2442
|
+
if (!danger)
|
|
2443
|
+
return;
|
|
2444
|
+
const key = `${event.agentId}\u0000${chat}\u0000${command}`;
|
|
2445
|
+
if (this.shielded.has(key))
|
|
2446
|
+
return;
|
|
2447
|
+
this.shielded.add(key);
|
|
2448
|
+
const stop = mode === "stop";
|
|
2449
|
+
this.log.append({ kind: "status", agentId: event.agentId, ...(chat !== MAIN_CHAT ? { chat } : {}), payload: {
|
|
2450
|
+
state: "shield_tripped", command: command.slice(0, 500), what: danger.what, why: danger.why, ...(stop ? { stopped: true } : {}),
|
|
2451
|
+
text: `${event.agentId} ran ${danger.what}: \`${command.replace(/\s+/g, " ").slice(0, 160)}\` — ${danger.why}`,
|
|
2452
|
+
} });
|
|
2453
|
+
if (stop)
|
|
2454
|
+
void this.interrupt({ chat, source: "route" }).catch((err) => logbook.warn("shield", "couldn't stop the turn", String(err), this.info.id));
|
|
2455
|
+
}
|
|
2456
|
+
/** The loop card's buttons: stop the turn, nudge the agent, or hand the task to another agent. */
|
|
2457
|
+
async actOnLoop(chat, agentId, action, to, loop) {
|
|
2458
|
+
const card = loop ?? this.lastLoop(chat, agentId);
|
|
2459
|
+
await this.interrupt({ chat, source: "route" });
|
|
2460
|
+
if (action === "stop")
|
|
2461
|
+
return { done: "stopped" };
|
|
2462
|
+
if (action === "nudge") {
|
|
2463
|
+
if (!card)
|
|
2464
|
+
throw new Error("no loop to nudge about in this chat");
|
|
2465
|
+
await this.turns.sendMessage(nudgeText(card), agentId, { chat, source: "route" });
|
|
2466
|
+
return { done: "nudged" };
|
|
2467
|
+
}
|
|
2468
|
+
if (!to || to === agentId)
|
|
2469
|
+
throw new Error("hand it to which agent?");
|
|
2470
|
+
const request = [...this.log.list({ chat, limit: 200 })].reverse().find((e) => e.kind === "message" && !e.agentId && String(e.payload.text ?? "").trim() && !String(e.payload.text).startsWith("[Loom]"));
|
|
2471
|
+
if (!request)
|
|
2472
|
+
throw new Error("couldn't find the request this chat was working on");
|
|
2473
|
+
await this.handoff(to, { source: "user" });
|
|
2474
|
+
const prior = this.briefings.pendingBriefings.get(to);
|
|
2475
|
+
this.briefings.pendingBriefings.set(to, [prior, `[Loom] "${agentId}" got stuck on this${card ? `, running \`${card.call}\` ${card.count} times (${card.kind})` : ""}. Its changes are in the working tree: read them, find what it kept getting wrong, and finish the task a different way.`].filter(Boolean).join("\n"));
|
|
2476
|
+
await this.turns.sendMessage(String(request.payload.text), to, { chat, source: "route" });
|
|
2477
|
+
return { done: "handed off", to };
|
|
2478
|
+
}
|
|
2479
|
+
lastLoop(chat, agentId) {
|
|
2480
|
+
const e = [...this.log.list({ chat, kinds: ["status"], limit: 100 })].reverse().find((x) => x.payload.state === "loop_detected" && x.agentId === agentId);
|
|
2481
|
+
if (!e)
|
|
2482
|
+
return undefined;
|
|
2483
|
+
return { agentId, chat, kind: e.payload.loop === "repeating" ? "repeating" : "failing", call: String(e.payload.call ?? ""), count: Number(e.payload.count ?? 0) };
|
|
2484
|
+
}
|
|
2485
|
+
// -------------------------------------------------------------------------
|
|
2486
|
+
// Named routes
|
|
2487
|
+
// -------------------------------------------------------------------------
|
|
2488
|
+
/**
|
|
2489
|
+
* Define or replace a named route.
|
|
2490
|
+
*
|
|
2491
|
+
* Routes were config you hand-edited: `ship` was built at init and everything
|
|
2492
|
+
* else meant opening .loom/config.json. Validated against the CURRENT roster
|
|
2493
|
+
* before saving — a route that names an agent this project doesn't have would
|
|
2494
|
+
* sit in the file looking runnable and fail at its first step. Saving through
|
|
2495
|
+
* here puts the team's pipeline in a version-controllable file instead of one
|
|
2496
|
+
* person's shell history.
|
|
2497
|
+
*/
|
|
2498
|
+
saveRoute(name, steps) {
|
|
2499
|
+
const clean = name.trim().slice(0, 40);
|
|
2500
|
+
if (!clean)
|
|
2501
|
+
throw new Error("a route needs a name");
|
|
2502
|
+
resolveSteps(steps, this.config, (id) => {
|
|
2503
|
+
const live = this.agents.get(id);
|
|
2504
|
+
return Boolean(live && isAdapter(live));
|
|
2505
|
+
});
|
|
2506
|
+
this.config.routes = { ...(this.config.routes ?? {}), [clean]: steps };
|
|
2507
|
+
this.saveConfig();
|
|
2508
|
+
return this.config.routes;
|
|
2509
|
+
}
|
|
2510
|
+
deleteRoute(name) {
|
|
2511
|
+
const routes = { ...(this.config.routes ?? {}) };
|
|
2512
|
+
if (!(name in routes))
|
|
2513
|
+
throw new Error(`no route named "${name}"`);
|
|
2514
|
+
delete routes[name];
|
|
2515
|
+
this.config.routes = routes;
|
|
2516
|
+
this.saveConfig();
|
|
2517
|
+
return routes;
|
|
2518
|
+
}
|
|
2519
|
+
// -------------------------------------------------------------------------
|
|
2520
|
+
// Snapshots
|
|
2521
|
+
// -------------------------------------------------------------------------
|
|
2522
|
+
/**
|
|
2523
|
+
* Checkpoint the project's Loom-owned state: brain, board, config.
|
|
2524
|
+
*
|
|
2525
|
+
* For the moment before letting a fleet loose on something — a restorable
|
|
2526
|
+
* "before". Deliberately NOT the working tree (git owns files and does it
|
|
2527
|
+
* better) and NOT the event log (history is what happened; a restore that
|
|
2528
|
+
* rewrote history would be a lie with a timestamp). Restoring brings back
|
|
2529
|
+
* what the project knew, what was on the board, and how it was configured.
|
|
2530
|
+
*/
|
|
2531
|
+
snapshot() {
|
|
2532
|
+
return {
|
|
2533
|
+
format: "loom-snapshot",
|
|
2534
|
+
version: 1,
|
|
2535
|
+
project: this.info.name,
|
|
2536
|
+
takenAt: Date.now(),
|
|
2537
|
+
brain: this.brain.export(this.info.name),
|
|
2538
|
+
tasks: readProjectState(this.info.dir).tasks ?? [],
|
|
2539
|
+
config: this.config,
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Restore a snapshot. Config and board are replaced (that is what "restore"
|
|
2544
|
+
* means for state you own); the brain is MERGED through the same dedupe as
|
|
2545
|
+
* import, because memory is an append-only fold — a restore that silently
|
|
2546
|
+
* forgot what was learned since the snapshot would be data loss wearing a
|
|
2547
|
+
* seatbelt. What it knew then comes back; what it learned since stays.
|
|
2548
|
+
*/
|
|
2549
|
+
restore(snap) {
|
|
2550
|
+
if (snap?.format !== "loom-snapshot")
|
|
2551
|
+
throw new Error("not a loom snapshot");
|
|
2552
|
+
const brain = this.brain.import(snap.brain, {
|
|
2553
|
+
agentId: "restore",
|
|
2554
|
+
eventId: this.log.lastId(),
|
|
2555
|
+
ts: Date.now(),
|
|
2556
|
+
});
|
|
2557
|
+
const state = readProjectState(this.info.dir);
|
|
2558
|
+
writeProjectState(this.info.dir, { ...state, tasks: snap.tasks });
|
|
2559
|
+
// `config` is readonly by reference and shared with every live subsystem,
|
|
2560
|
+
// so restore mutates its contents rather than swapping the object.
|
|
2561
|
+
for (const k of Object.keys(this.config)) {
|
|
2562
|
+
if (!(k in snap.config))
|
|
2563
|
+
delete this.config[k];
|
|
2564
|
+
}
|
|
2565
|
+
Object.assign(this.config, snap.config);
|
|
2566
|
+
this.saveConfig();
|
|
2567
|
+
// Reconcile the live agents with the restored roster, the same way
|
|
2568
|
+
// addAgent/removeAgent would have: drop what's gone, spawn what's missing.
|
|
2569
|
+
const wanted = new Map(this.config.agents.map((a) => [a.id, a]));
|
|
2570
|
+
for (const [id, live] of [...this.agents]) {
|
|
2571
|
+
if (!wanted.has(id)) {
|
|
2572
|
+
void this.agentLifecycle.retire(id);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
for (const cfg of this.config.agents) {
|
|
2576
|
+
if (!this.agents.has(cfg.id) && cfg.enabled !== false)
|
|
2577
|
+
this.spawnAgent(cfg);
|
|
2578
|
+
}
|
|
2579
|
+
this.log.append({
|
|
2580
|
+
kind: "status",
|
|
2581
|
+
payload: { state: "restored", takenAt: snap.takenAt, tasks: snap.tasks.length },
|
|
2582
|
+
});
|
|
2583
|
+
return { brain, tasks: snap.tasks.length };
|
|
2584
|
+
}
|
|
2585
|
+
/** Turns older than this are presumed hung. Generous: real turns run long. */
|
|
2586
|
+
static STALE_TURN_MS = 10 * 60 * 1000;
|
|
2587
|
+
staleSessions(now = Date.now()) { return this.turns.staleSessions(now); }
|
|
2588
|
+
/**
|
|
2589
|
+
* Put a hung session out of its misery and bring up a fresh one.
|
|
2590
|
+
*
|
|
2591
|
+
* Interrupt first — a process that responds to that wasn't hung, and gets to
|
|
2592
|
+
* finish dying cleanly — then stop, drop, and respawn from config, exactly as
|
|
2593
|
+
* a cold start would. The baton is released if the corpse held it, because a
|
|
2594
|
+
* lock owned by a session that no longer exists refuses everyone forever.
|
|
2595
|
+
*/
|
|
2596
|
+
async reapSession(agentId) {
|
|
2597
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
2598
|
+
if (!cfg)
|
|
2599
|
+
throw new Error(`unknown agent "${agentId}" in project "${this.info.name}"`);
|
|
2600
|
+
const live = this.agents.get(agentId);
|
|
2601
|
+
if (live && isAdapter(live)) {
|
|
2602
|
+
await live.interrupt().catch(() => { });
|
|
2603
|
+
}
|
|
2604
|
+
await this.agentLifecycle.retire(agentId);
|
|
2605
|
+
this.turns.busySince.delete(agentId);
|
|
2606
|
+
if (this.validHolder() === agentId)
|
|
2607
|
+
this.baton.release(agentId);
|
|
2608
|
+
this.spawnAgent(cfg);
|
|
2609
|
+
this.log.append({
|
|
2610
|
+
kind: "status",
|
|
2611
|
+
agentId,
|
|
2612
|
+
payload: { state: "session_reaped", reason: "stale or hung session respawned" },
|
|
2613
|
+
});
|
|
2614
|
+
return { respawned: true };
|
|
2615
|
+
}
|
|
2616
|
+
// -------------------------------------------------------------------------
|
|
2617
|
+
// Sub-agents
|
|
2618
|
+
// -------------------------------------------------------------------------
|
|
2619
|
+
/**
|
|
2620
|
+
* How many subtasks may run at once in this project.
|
|
2621
|
+
*
|
|
2622
|
+
* A fan-out is the point of the feature and also the way to melt the machine:
|
|
2623
|
+
* each child is a real CLI process with its own model calls. The cap is per
|
|
2624
|
+
* project rather than global so one busy project can't starve the others.
|
|
2625
|
+
*/
|
|
2626
|
+
static MAX_CONCURRENT_SUBTASKS = 4;
|
|
2627
|
+
/** In-flight subtasks, by the id minted when they started. */
|
|
2628
|
+
subtasks = new Map();
|
|
2629
|
+
/** Subtasks currently running, for the status payload and the cap. */
|
|
2630
|
+
liveSubtasks() {
|
|
2631
|
+
return [...this.subtasks.entries()].map(([id, s]) => ({
|
|
2632
|
+
id,
|
|
2633
|
+
parent: s.parent,
|
|
2634
|
+
agentId: s.agentId,
|
|
2635
|
+
task: s.task,
|
|
2636
|
+
}));
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Run a subtask on a child agent, alongside the parent's turn.
|
|
2640
|
+
*
|
|
2641
|
+
* A turn used to be all-or-nothing: one agent, one prompt, one result. "Audit
|
|
2642
|
+
* these twelve files" wanted twelve cheap readers, and the only way to get
|
|
2643
|
+
* them was twelve sequential turns, each one taking the baton off the last.
|
|
2644
|
+
*
|
|
2645
|
+
* What makes a child a child rather than another turn:
|
|
2646
|
+
*
|
|
2647
|
+
* - **It never touches the baton.** The parent still holds it, so a fan-out
|
|
2648
|
+
* cannot steal the conversation from the agent that started it, and the
|
|
2649
|
+
* human's next message still lands where they expect.
|
|
2650
|
+
* - **Its briefing is narrowed.** It gets the task and the project's shape,
|
|
2651
|
+
* not the parent's whole thread. Handing a child the full history is how
|
|
2652
|
+
* you pay twice for context the parent already read.
|
|
2653
|
+
* - **Its result is attributed and parented**, so the thread can indent it
|
|
2654
|
+
* under the turn that asked rather than interleaving it as a peer.
|
|
2655
|
+
*
|
|
2656
|
+
* Everything it learns still lands in the one project brain — a child that
|
|
2657
|
+
* discovered something and took it to the grave would be worse than no child.
|
|
2658
|
+
*/
|
|
2659
|
+
async spawnSubAgent(parentAgentId, opts) {
|
|
2660
|
+
if (this.continuity)
|
|
2661
|
+
throw new ContinuityError("unsupported", "parallel subagents are outside sequential native continuity; finish this turn or use legacy mode");
|
|
2662
|
+
const task = opts.task?.trim();
|
|
2663
|
+
if (!task)
|
|
2664
|
+
throw new Error("a subtask needs a task");
|
|
2665
|
+
// A child's briefing is deliberately narrow; a 100KB "task" is the parent
|
|
2666
|
+
// smuggling its whole context through the one field that was scoped.
|
|
2667
|
+
if (task.length > 10_000) {
|
|
2668
|
+
throw new Error("a subtask brief tops out at 10k chars — hand files, not transcripts");
|
|
752
2669
|
}
|
|
753
|
-
|
|
754
|
-
|
|
2670
|
+
// The parent has to be a real agent in this project, so the thread can
|
|
2671
|
+
// indent under something that exists.
|
|
2672
|
+
this.agent(parentAgentId);
|
|
2673
|
+
const child = this.agent(opts.agentId);
|
|
2674
|
+
if (!isAdapter(child)) {
|
|
2675
|
+
throw new Error(`agent "${opts.agentId}" is a bridge (read-only) — it cannot run a subtask`);
|
|
755
2676
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
//
|
|
760
|
-
|
|
2677
|
+
if (this.subtasks.size >= ProjectRuntime.MAX_CONCURRENT_SUBTASKS) {
|
|
2678
|
+
throw new Error(`${ProjectRuntime.MAX_CONCURRENT_SUBTASKS} subtasks are already running in this project`);
|
|
2679
|
+
}
|
|
2680
|
+
// Same gates as a turn. A child that ignored the budget would be a hole in
|
|
2681
|
+
// the ceiling the parent is standing under.
|
|
2682
|
+
this.enforceQuarantine(opts.agentId);
|
|
2683
|
+
this.enforceBudget(opts.agentId);
|
|
2684
|
+
const id = newId(5);
|
|
2685
|
+
const chat = opts.chat ?? MAIN_CHAT;
|
|
2686
|
+
this.subtasks.set(id, {
|
|
2687
|
+
parent: parentAgentId,
|
|
2688
|
+
agentId: opts.agentId,
|
|
2689
|
+
task,
|
|
2690
|
+
startedAt: Date.now(),
|
|
2691
|
+
});
|
|
761
2692
|
this.log.append({
|
|
762
|
-
kind: "
|
|
2693
|
+
kind: "subtask_started",
|
|
2694
|
+
agentId: opts.agentId,
|
|
763
2695
|
chat,
|
|
764
|
-
payload: {
|
|
765
|
-
});
|
|
766
|
-
await this.ensureStarted(target);
|
|
767
|
-
const pendingBriefing = this.consumePendingBriefing(target);
|
|
768
|
-
const input = pendingBriefing ? { text, briefing: pendingBriefing } : { text };
|
|
769
|
-
// Snapshot the tree so this prompt's changes can be attributed to it.
|
|
770
|
-
this.preTurnTree.set(target, await porcelainStatus(this.info.dir));
|
|
771
|
-
// Fire-and-notify: the turn runs in the background; progress streams
|
|
772
|
-
// into the log and completion lands as run_complete.
|
|
773
|
-
void agent.send(input).catch((err) => {
|
|
774
|
-
this.log.append({
|
|
775
|
-
kind: "error",
|
|
776
|
-
agentId: target,
|
|
777
|
-
payload: { message: String(err instanceof Error ? err.message : err) },
|
|
778
|
-
});
|
|
2696
|
+
payload: { subtaskId: id, parent: parentAgentId, task },
|
|
779
2697
|
});
|
|
780
|
-
|
|
2698
|
+
await this.ensureStarted(opts.agentId);
|
|
2699
|
+
const mcp = child.capabilities.mcp ? writeMcpSession(this.healthyMcps()) : null;
|
|
2700
|
+
const input = {
|
|
2701
|
+
text: task,
|
|
2702
|
+
chat,
|
|
2703
|
+
briefing: this.subtaskBriefing(parentAgentId, opts.agentId, task),
|
|
2704
|
+
...(mcp ? { mcp: { configPath: mcp.configPath, servers: mcp.servers } } : {}),
|
|
2705
|
+
};
|
|
2706
|
+
void child
|
|
2707
|
+
.send(input)
|
|
2708
|
+
.then(() => {
|
|
2709
|
+
this.subtasks.delete(id);
|
|
2710
|
+
this.appendIfOpen({
|
|
2711
|
+
kind: "subtask_done",
|
|
2712
|
+
agentId: opts.agentId,
|
|
2713
|
+
chat,
|
|
2714
|
+
payload: { subtaskId: id, parent: parentAgentId, task },
|
|
2715
|
+
});
|
|
2716
|
+
})
|
|
2717
|
+
.catch((err) => {
|
|
2718
|
+
this.subtasks.delete(id);
|
|
2719
|
+
this.appendIfOpen({
|
|
2720
|
+
kind: "subtask_failed",
|
|
2721
|
+
agentId: opts.agentId,
|
|
2722
|
+
chat,
|
|
2723
|
+
payload: {
|
|
2724
|
+
subtaskId: id,
|
|
2725
|
+
parent: parentAgentId,
|
|
2726
|
+
task,
|
|
2727
|
+
message: String(err instanceof Error ? err.message : err),
|
|
2728
|
+
},
|
|
2729
|
+
});
|
|
2730
|
+
})
|
|
2731
|
+
.finally(() => mcp?.cleanup());
|
|
2732
|
+
return { id, agentId: opts.agentId };
|
|
781
2733
|
}
|
|
2734
|
+
subtaskBriefing(parent, childId, task) { return this.briefings.subtaskBriefing(parent, childId, task); }
|
|
782
2735
|
defaultAdapterId() {
|
|
783
2736
|
const cfg = (this.config.defaultAgent &&
|
|
784
2737
|
this.config.agents.find((a) => a.id === this.config.defaultAgent)) ||
|
|
@@ -787,114 +2740,108 @@ export class ProjectRuntime {
|
|
|
787
2740
|
throw new Error(`project "${this.info.name}" has no full-duplex adapters`);
|
|
788
2741
|
return cfg.id;
|
|
789
2742
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
// -------------------------------------------------------------------------
|
|
793
|
-
/** Briefings are injected with the first turn after a handoff. */
|
|
794
|
-
pendingBriefings = new Map();
|
|
795
|
-
consumePendingBriefing(agentId) {
|
|
796
|
-
const briefing = this.pendingBriefings.get(agentId);
|
|
797
|
-
this.pendingBriefings.delete(agentId);
|
|
798
|
-
return briefing;
|
|
799
|
-
}
|
|
2743
|
+
consumePendingBriefing(agentId) { return this.briefings.consumePendingBriefing(agentId); }
|
|
2744
|
+
async retryTurn(toAgentId) { return this.turns.retryTurn(toAgentId); }
|
|
800
2745
|
/**
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
2746
|
+
* Carry the outgoing agent's branch into the incoming agent's worktree.
|
|
2747
|
+
*
|
|
2748
|
+
* Only with both `worktreePerAgent` and `mergeOnHandoff` on, only when the
|
|
2749
|
+
* two really are separate checkouts, and never when it would have to guess:
|
|
2750
|
+
* see core/worktree-merge.ts for what it refuses. The outcome is a value so
|
|
2751
|
+
* the handoff event and the briefing can both say what happened.
|
|
804
2752
|
*/
|
|
2753
|
+
async mergeForHandoff(from, to) {
|
|
2754
|
+
if (!this.config.git?.worktreePerAgent || !this.config.git?.mergeOnHandoff)
|
|
2755
|
+
return null;
|
|
2756
|
+
const into = this.agentDir(to);
|
|
2757
|
+
const source = this.agentDir(from);
|
|
2758
|
+
if (into === this.info.dir || source === into)
|
|
2759
|
+
return null;
|
|
2760
|
+
try {
|
|
2761
|
+
return await mergeAgentWork({
|
|
2762
|
+
into,
|
|
2763
|
+
branch: `agent/${from}`,
|
|
2764
|
+
sourceDir: source,
|
|
2765
|
+
message: `Loom: ${from} → ${to}`,
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
catch (err) {
|
|
2769
|
+
// A merge that can't run must not take the handoff down with it.
|
|
2770
|
+
logbook.warn("git", `merge on handoff ${from} → ${to} failed`, String(err), this.info.id);
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
805
2774
|
async handoff(to, opts = {}) {
|
|
2775
|
+
if (this.continuity?.store.activeReceipts().some(r => r.execution === "unknown"))
|
|
2776
|
+
throw new ContinuityError("recovery_required", "reconcile uncertain native writers before handoff or merge");
|
|
806
2777
|
const target = this.agent(to);
|
|
807
2778
|
if (!isAdapter(target)) {
|
|
808
2779
|
throw new Error(`cannot hand the baton to "${to}" — bridges are read-only by design`);
|
|
809
2780
|
}
|
|
2781
|
+
// Handing the baton to an agent that can't afford a turn is the same
|
|
2782
|
+
// refusal as sending it one — and it has to be caught HERE, before the
|
|
2783
|
+
// current holder is interrupted, or a route would strand the baton on an
|
|
2784
|
+
// agent that will refuse every prompt it gets.
|
|
2785
|
+
this.enforceQuarantine(to);
|
|
2786
|
+
this.enforceBudget(to);
|
|
810
2787
|
if ((opts.source ?? "user") === "user")
|
|
811
2788
|
this.routes.onManualHandoff();
|
|
812
2789
|
// Audit trail: snapshot the outgoing holder's working-tree state into the
|
|
813
2790
|
// handoff event, so "who left what uncommitted" is always answerable.
|
|
814
2791
|
let handoffMeta = { projected: true };
|
|
815
2792
|
const holder = this.validHolder();
|
|
2793
|
+
let merge = null;
|
|
816
2794
|
if (holder && holder !== to) {
|
|
817
2795
|
const current = this.agent(holder);
|
|
818
2796
|
if (isAdapter(current)) {
|
|
2797
|
+
if (this.continuity && this.turns.busySince.has(holder) && !current.busy())
|
|
2798
|
+
throw new ContinuityError("conflict", "outgoing turn is still preparing or finalizing; wait or stop before interrupt-switching");
|
|
819
2799
|
if (current.busy())
|
|
820
2800
|
await current.interrupt();
|
|
821
2801
|
const diff = await current.diff().catch(() => "");
|
|
822
2802
|
if (diff)
|
|
823
2803
|
handoffMeta = { ...handoffMeta, dirty: true, diff: diff.slice(0, 2000) };
|
|
824
2804
|
}
|
|
2805
|
+
// After the outgoing agent has stopped (its last commit is in), before
|
|
2806
|
+
// the briefing is written — so the briefing can carry the result.
|
|
2807
|
+
merge = await this.mergeForHandoff(holder, to);
|
|
2808
|
+
if (merge)
|
|
2809
|
+
handoffMeta = { ...handoffMeta, merge };
|
|
825
2810
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
// falling back to the template so a broken Claude never blocks a handoff.
|
|
839
|
-
const distillStart = Date.now();
|
|
840
|
-
const rendered = await renderProjection(input, this.config.projection);
|
|
841
|
-
// Phase 3: the memories relevant to the work in flight, retrieved and
|
|
842
|
-
// compiled — this is the part the recency-window projection can't do. Query
|
|
843
|
-
// is the recent conversation plus the files recent turns touched; scoped to
|
|
844
|
-
// this chat and to the incoming agent; low-confidence memories are held back
|
|
845
|
-
// from injection (they're still visible in the Brain tab).
|
|
846
|
-
const brainBrief = this.retrieveBrief(events, to);
|
|
847
|
-
// Append the unified cross-ADE memory so the incoming agent sees the
|
|
848
|
-
// whole brain, not just this project's log.
|
|
849
|
-
const unified = this.unifiedMemory();
|
|
850
|
-
const parts = [rendered.content];
|
|
851
|
-
if (brainBrief)
|
|
852
|
-
parts.push(brainBrief);
|
|
853
|
-
if (unified.sources.length > 0)
|
|
854
|
-
parts.push(unified.document);
|
|
855
|
-
const enriched = parts.join("\n\n---\n");
|
|
856
|
-
await target.injectMemory(enriched);
|
|
857
|
-
writeMemoryFile(this.info.dir, to, enriched); // idempotent with default impl
|
|
858
|
-
this.pendingBriefings.set(to, buildBriefing(input));
|
|
859
|
-
if (rendered.mode === "llm") {
|
|
860
|
-
this.log.append({
|
|
861
|
-
kind: "status",
|
|
862
|
-
payload: { state: "projection", mode: "llm", ms: Date.now() - distillStart },
|
|
863
|
-
});
|
|
2811
|
+
const bridgeIds = this.config.agents.map((cfg) => cfg.id).filter((id) => {
|
|
2812
|
+
const agent = this.agents.get(id);
|
|
2813
|
+
return agent && !isAdapter(agent) && id !== to;
|
|
2814
|
+
});
|
|
2815
|
+
const mergeNote = merge ? describeMerge(merge, holder ?? "the previous agent", to) : "";
|
|
2816
|
+
if (this.continuity) {
|
|
2817
|
+
// New context is assembled from the selected chat at dispatch, never
|
|
2818
|
+
// from global history or private bridge memory on a picker click.
|
|
2819
|
+
this.briefings.pendingBriefings.delete(to);
|
|
2820
|
+
const { from } = this.baton.handoff(to, { ...handoffMeta, projected: false, continuity: 1 });
|
|
2821
|
+
await this.ensureStarted(to);
|
|
2822
|
+
return { from, ...(merge ? { merge } : {}) };
|
|
864
2823
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const bridgeBrief = this.retrieveBrief(events, cfg.id);
|
|
876
|
-
const bridgeView = bridgeBrief
|
|
877
|
-
? `${buildProjection({ ...input, targetAgentId: cfg.id })}\n\n---\n${bridgeBrief}`
|
|
878
|
-
: buildProjection({ ...input, targetAgentId: cfg.id });
|
|
879
|
-
await bystander.injectMemory(bridgeView).catch(() => { });
|
|
2824
|
+
const prepared = await this.briefings.prepareHandoff(to, holder, mergeNote, bridgeIds);
|
|
2825
|
+
if (this.closed || this.agents.get(to) !== target)
|
|
2826
|
+
throw new Error("handoff target is no longer active");
|
|
2827
|
+
await target.injectMemory(prepared.memory);
|
|
2828
|
+
if (this.closed || this.agents.get(to) !== target)
|
|
2829
|
+
throw new Error("handoff target is no longer active");
|
|
2830
|
+
writeMemoryFile(this.info.dir, to, prepared.memory);
|
|
2831
|
+
this.briefings.pendingBriefings.set(to, prepared.briefing);
|
|
2832
|
+
if (prepared.mode === "llm") {
|
|
2833
|
+
this.log.append({ kind: "status", payload: { state: "projection", mode: "llm", ms: prepared.elapsedMs } });
|
|
880
2834
|
}
|
|
2835
|
+
for (const bridge of prepared.bridges) {
|
|
2836
|
+
await this.agents.get(bridge.agentId)?.injectMemory(bridge.memory).catch(() => { });
|
|
2837
|
+
}
|
|
2838
|
+
if (this.closed || this.agents.get(to) !== target)
|
|
2839
|
+
throw new Error("handoff target is no longer active");
|
|
881
2840
|
const { from } = this.baton.handoff(to, handoffMeta);
|
|
882
2841
|
await this.ensureStarted(to);
|
|
883
|
-
return { from };
|
|
884
|
-
}
|
|
885
|
-
async interrupt(opts = {}) {
|
|
886
|
-
if ((opts.source ?? "user") === "user")
|
|
887
|
-
this.routes.onManualInterrupt();
|
|
888
|
-
const holder = this.validHolder();
|
|
889
|
-
if (!holder)
|
|
890
|
-
return { interrupted: null };
|
|
891
|
-
const agent = this.agent(holder);
|
|
892
|
-
if (isAdapter(agent) && agent.busy()) {
|
|
893
|
-
await agent.interrupt();
|
|
894
|
-
return { interrupted: holder };
|
|
895
|
-
}
|
|
896
|
-
return { interrupted: null };
|
|
2842
|
+
return { from, ...(merge ? { merge } : {}) };
|
|
897
2843
|
}
|
|
2844
|
+
async interrupt(opts = {}) { return this.turns.interrupt(opts); }
|
|
898
2845
|
// -------------------------------------------------------------------------
|
|
899
2846
|
// Routing
|
|
900
2847
|
// -------------------------------------------------------------------------
|
|
@@ -904,7 +2851,42 @@ export class ProjectRuntime {
|
|
|
904
2851
|
* list of agent ids/roles. Undefined → the "ship" route if defined, else
|
|
905
2852
|
* every adapter in config order.
|
|
906
2853
|
*/
|
|
2854
|
+
/**
|
|
2855
|
+
* Opt-in: checkpoint before a route starts.
|
|
2856
|
+
*
|
|
2857
|
+
* A route is exactly the moment you let a fleet loose — several agents,
|
|
2858
|
+
* several turns, no human between hops. With safety.snapshotBeforeRoutes on,
|
|
2859
|
+
* the brain+board+config land in .loom/snapshots/pre-route-<ts>.json first,
|
|
2860
|
+
* so `loom restore` has a "before" without anyone having remembered to take
|
|
2861
|
+
* one. Bounded: the newest five are kept, older ones pruned — a safety net,
|
|
2862
|
+
* not an archive.
|
|
2863
|
+
*/
|
|
2864
|
+
snapshotBeforeRoute() {
|
|
2865
|
+
if (!this.config.safety?.snapshotBeforeRoutes)
|
|
2866
|
+
return;
|
|
2867
|
+
try {
|
|
2868
|
+
const dir = path.join(projectLoomDir(this.info.dir), "snapshots");
|
|
2869
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2870
|
+
const file = path.join(dir, `pre-route-${Date.now()}.json`);
|
|
2871
|
+
fs.writeFileSync(file, JSON.stringify(this.snapshot(), null, 2) + "\n");
|
|
2872
|
+
const old = fs
|
|
2873
|
+
.readdirSync(dir)
|
|
2874
|
+
.filter((f) => f.startsWith("pre-route-"))
|
|
2875
|
+
.sort()
|
|
2876
|
+
.slice(0, -5);
|
|
2877
|
+
for (const f of old)
|
|
2878
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
2879
|
+
logbook.info("routes", `snapshot taken before the route — ${path.basename(file)}`, undefined, this.info.id);
|
|
2880
|
+
}
|
|
2881
|
+
catch (err) {
|
|
2882
|
+
// A safety net that blocks the route it protects is worse than none.
|
|
2883
|
+
logbook.warn("routes", "pre-route snapshot failed", String(err), this.info.id);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
907
2886
|
async startRoute(opts) {
|
|
2887
|
+
if (this.continuity)
|
|
2888
|
+
throw new ContinuityError("unsupported", "autonomous route continuity is not verified; select a native agent directly");
|
|
2889
|
+
this.snapshotBeforeRoute();
|
|
908
2890
|
if (typeof opts.spec === "string" && opts.spec.trim() === "auto") {
|
|
909
2891
|
return this.routes.startDynamic(opts.task, {
|
|
910
2892
|
...(opts.router ? { router: opts.router } : {}),
|
|
@@ -955,18 +2937,61 @@ export class ProjectRuntime {
|
|
|
955
2937
|
async status() {
|
|
956
2938
|
const holder = this.validHolder();
|
|
957
2939
|
const agents = await Promise.all(this.config.agents.map(async (cfg) => {
|
|
958
|
-
|
|
2940
|
+
// The picker shows a tick next to the active model; "" means the
|
|
2941
|
+
// adapter's own default, which is the honest baseline.
|
|
2942
|
+
const model = cfg.options?.model ?? "";
|
|
2943
|
+
const live = this.agents.get(cfg.id);
|
|
2944
|
+
if (!live) {
|
|
2945
|
+
// Switched off: still in the roster, just not spawned. Its tier has
|
|
2946
|
+
// to come from somewhere other than the instance, and switching an
|
|
2947
|
+
// agent off must not change what it *is*. ADES first (the catalog
|
|
2948
|
+
// people pick from), then the factory registry for the kinds ADES
|
|
2949
|
+
// deliberately omits. Defaulting to "adapter" would be wrong for
|
|
2950
|
+
// exactly those: a disabled bridge would advertise itself as an
|
|
2951
|
+
// adapter to every surface that filters on this field.
|
|
2952
|
+
const spec = ADES.find((a) => a.kind === cfg.kind);
|
|
2953
|
+
const tier = spec?.tier ?? tierForKind(cfg.kind) ?? "adapter";
|
|
2954
|
+
return {
|
|
2955
|
+
id: cfg.id,
|
|
2956
|
+
kind: cfg.kind,
|
|
2957
|
+
role: cfg.role,
|
|
2958
|
+
tier,
|
|
2959
|
+
available: false,
|
|
2960
|
+
busy: false,
|
|
2961
|
+
holdsBaton: false,
|
|
2962
|
+
model,
|
|
2963
|
+
permissions: permissionFor(cfg.kind, cfg.options),
|
|
2964
|
+
...(cfg.instructions ? { instructions: cfg.instructions } : {}),
|
|
2965
|
+
...sampling(cfg),
|
|
2966
|
+
...(cfg.avatar ? { avatar: cfg.avatar } : {}),
|
|
2967
|
+
// "not spawned" is not "switched off". An agent whose CLI is missing
|
|
2968
|
+
// is still enabled in config, and reporting it as disabled made the
|
|
2969
|
+
// project-settings toggle render off — clicking it then wrote the
|
|
2970
|
+
// value it already had, so the agent could never be turned back on.
|
|
2971
|
+
enabled: cfg.enabled !== false,
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
959
2974
|
return {
|
|
960
2975
|
id: cfg.id,
|
|
961
2976
|
kind: cfg.kind,
|
|
962
2977
|
role: cfg.role,
|
|
963
|
-
tier:
|
|
964
|
-
available:
|
|
965
|
-
|
|
2978
|
+
tier: live.capabilities.tier,
|
|
2979
|
+
available: this.continuity && isNativeKind(cfg.kind)
|
|
2980
|
+
? (await this.harnesses.ensure(cfg.id)).available
|
|
2981
|
+
: await live.available().catch(() => false),
|
|
2982
|
+
...(this.continuity && isNativeKind(cfg.kind) ? { cliVersion: this.harnesses.get(cfg.id)?.version ?? null } : {}),
|
|
2983
|
+
busy: isAdapter(live) ? live.busy() || Boolean(this.continuity && this.turns.busySince.has(cfg.id)) : false,
|
|
2984
|
+
// which chat that turn is in, so a client shows Stop only where it applies
|
|
2985
|
+
...(isAdapter(live) && (live.busy() || this.turns.busySince.has(cfg.id)) ? { chat: this.turns.turnChat.get(cfg.id) ?? MAIN_CHAT } : {}),
|
|
966
2986
|
holdsBaton: holder === cfg.id,
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
2987
|
+
model,
|
|
2988
|
+
permissions: permissionFor(cfg.kind, cfg.options),
|
|
2989
|
+
enabled: true,
|
|
2990
|
+
...(cfg.instructions ? { instructions: cfg.instructions } : {}),
|
|
2991
|
+
...sampling(cfg),
|
|
2992
|
+
...(cfg.avatar ? { avatar: cfg.avatar } : {}),
|
|
2993
|
+
...(isNativeKind(cfg.kind) ? { context: this.nativeUsage.context(cfg.id), limits: this.nativeUsage.limitsFor(cfg.kind) } : {}),
|
|
2994
|
+
...((f) => (f ? { forecast: f } : {}))(this.accounting.forecast(cfg.id)),
|
|
970
2995
|
};
|
|
971
2996
|
}));
|
|
972
2997
|
const recent = this.log.list({ limit: 50 });
|
|
@@ -975,11 +3000,16 @@ export class ProjectRuntime {
|
|
|
975
3000
|
.reverse()
|
|
976
3001
|
.find((e) => e.kind === "message" && !e.agentId);
|
|
977
3002
|
const lastNeedsInput = [...recent].reverse().find((e) => e.kind === "needs_input");
|
|
978
|
-
const
|
|
3003
|
+
const reqId = lastNeedsInput && lastNeedsInput.payload.requestId;
|
|
3004
|
+
// a question answered on its card (no message typed) is answered all the same
|
|
3005
|
+
const answeredOnCard = Boolean(reqId && recent.some((e) => e.id > lastNeedsInput.id && e.kind === "status" &&
|
|
3006
|
+
e.payload.state === "question_answered" && e.payload.requestId === reqId));
|
|
3007
|
+
const needsInput = Boolean(lastNeedsInput && !answeredOnCard && (!lastUserMsg || lastNeedsInput.id > lastUserMsg.id));
|
|
979
3008
|
return {
|
|
980
3009
|
id: this.info.id,
|
|
981
3010
|
name: this.info.name,
|
|
982
3011
|
dir: this.info.dir,
|
|
3012
|
+
...(this.continuity ? { continuity: true } : {}),
|
|
983
3013
|
holder,
|
|
984
3014
|
agents,
|
|
985
3015
|
lastEvent,
|
|
@@ -990,27 +3020,278 @@ export class ProjectRuntime {
|
|
|
990
3020
|
chats: this.chats(),
|
|
991
3021
|
route: this.routes.state(),
|
|
992
3022
|
routeNames: ["auto", ...Object.keys(this.config.routes ?? {})],
|
|
993
|
-
costUsd: this.costs.totalUsd,
|
|
3023
|
+
costUsd: this.accounting.costs.totalUsd,
|
|
3024
|
+
// Paused agents belong in the status payload, not only in state on disk.
|
|
3025
|
+
// Without this the UI cannot show that an alert has taken an agent out of
|
|
3026
|
+
// rotation — the pause was real and completely invisible, which reads as
|
|
3027
|
+
// "the self-heal did nothing".
|
|
3028
|
+
quarantine: this.quarantined(),
|
|
3029
|
+
orchestra: this.orchestraSummary(),
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* What every agent in this project is doing right now — for the fleet view.
|
|
3034
|
+
*
|
|
3035
|
+
* One row per roster agent (its thread, whether it's mid-turn, its last
|
|
3036
|
+
* step) and one per orchestra task (worker sessions don't live in the
|
|
3037
|
+
* roster). "Last step" is the newest event from that agent in that thread,
|
|
3038
|
+
* summarised to a line: the tool it ran, the file it touched, what it said.
|
|
3039
|
+
*/
|
|
3040
|
+
activity() {
|
|
3041
|
+
const recent = this.log.list({ limit: 400 });
|
|
3042
|
+
const chats = new Map(this.chats().map((c) => [c.id, c.title]));
|
|
3043
|
+
const lastOf = (agentId, chat) => {
|
|
3044
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
3045
|
+
const e = recent[i];
|
|
3046
|
+
if (e.agentId !== agentId)
|
|
3047
|
+
continue;
|
|
3048
|
+
if (chat !== undefined && (e.chat ?? MAIN_CHAT) !== chat)
|
|
3049
|
+
continue;
|
|
3050
|
+
return { kind: e.kind, ts: e.ts, chat: e.chat ?? MAIN_CHAT, line: activityLine(e) };
|
|
3051
|
+
}
|
|
3052
|
+
return null;
|
|
3053
|
+
};
|
|
3054
|
+
const agents = this.config.agents.map((cfg) => {
|
|
3055
|
+
const live = this.agents.get(cfg.id);
|
|
3056
|
+
const chat = this.turns.turnChat.get(cfg.id);
|
|
3057
|
+
const last = lastOf(cfg.id, chat);
|
|
3058
|
+
return {
|
|
3059
|
+
id: cfg.id,
|
|
3060
|
+
kind: cfg.kind,
|
|
3061
|
+
role: cfg.role,
|
|
3062
|
+
busy: Boolean(live && isAdapter(live) && (live.busy() || this.turns.busySince.has(cfg.id))),
|
|
3063
|
+
since: this.turns.busySince.get(cfg.id) ?? null,
|
|
3064
|
+
permissions: permissionFor(cfg.kind, cfg.options),
|
|
3065
|
+
holdsBaton: this.validHolder() === cfg.id,
|
|
3066
|
+
chat: chat ?? last?.chat ?? null,
|
|
3067
|
+
chatTitle: chats.get(chat ?? last?.chat ?? "") ?? null,
|
|
3068
|
+
last,
|
|
3069
|
+
};
|
|
3070
|
+
});
|
|
3071
|
+
const run = this.orchestra.active() ?? this.orchestra.list()[0];
|
|
3072
|
+
const orchestra = run
|
|
3073
|
+
? {
|
|
3074
|
+
id: run.id,
|
|
3075
|
+
goal: run.goal,
|
|
3076
|
+
status: run.status,
|
|
3077
|
+
chat: run.chat,
|
|
3078
|
+
orchestrator: run.orchestrator.agent,
|
|
3079
|
+
tasks: run.tasks.map((t) => ({
|
|
3080
|
+
id: t.id,
|
|
3081
|
+
title: t.title,
|
|
3082
|
+
agent: t.agent,
|
|
3083
|
+
status: t.status,
|
|
3084
|
+
chat: t.chat,
|
|
3085
|
+
attempts: t.attempts,
|
|
3086
|
+
files: t.files?.length ?? 0,
|
|
3087
|
+
last: lastOf(t.agent, t.chat),
|
|
3088
|
+
})),
|
|
3089
|
+
}
|
|
3090
|
+
: null;
|
|
3091
|
+
return {
|
|
3092
|
+
project: { id: this.info.id, name: this.info.name },
|
|
3093
|
+
agents,
|
|
3094
|
+
orchestra,
|
|
3095
|
+
crews: this.crews.list().filter(c => c.state.goal && !["completed", "failed", "stopped"].includes(c.state.goal.status)).map(c => ({
|
|
3096
|
+
id: c.id, name: c.name, goal: c.state.goal.text, status: c.state.goal.status, channel: c.state.channel, active: c.active,
|
|
3097
|
+
cards: c.state.goal.cards.map(card => ({ id: card.id, title: card.title, stage: card.stage, teammate: card.builder })),
|
|
3098
|
+
})),
|
|
3099
|
+
subtasks: this.liveSubtasks(),
|
|
3100
|
+
route: this.routes.state(),
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
/** The team member running this daemon (set by Team Link), for commit trailers. */
|
|
3104
|
+
memberLogin = null;
|
|
3105
|
+
/** Loom Teams, Phase 2: this project's team coordinator (set by Team Link). */
|
|
3106
|
+
coordinator = null;
|
|
3107
|
+
/** Loom Teams, Phase 3: this project's share of the team brain (set by Team Link). */
|
|
3108
|
+
teamBrain = null;
|
|
3109
|
+
/** The effective loom.team.json while shared with a team (D37); null when solo. */
|
|
3110
|
+
teamPolicy = null;
|
|
3111
|
+
crewSpendLedger = { day: "", after: 0, total: 0, crews: new Map() };
|
|
3112
|
+
crewSpendToday(crew) {
|
|
3113
|
+
const day = new Date().toISOString().slice(0, 10), start = Date.parse(`${day}T00:00:00Z`);
|
|
3114
|
+
if (this.crewSpendLedger.day !== day)
|
|
3115
|
+
this.crewSpendLedger = { day, after: 0, total: 0, crews: new Map() };
|
|
3116
|
+
const ledger = this.crewSpendLedger;
|
|
3117
|
+
// a new day starts its ledger at the day's first event, not at the first event ever written
|
|
3118
|
+
for (const event of this.log.list({ kinds: ["status"], since: ledger.after, ...(ledger.after === 0 ? { sinceTs: start } : {}) })) {
|
|
3119
|
+
const id = event.payload.crew?.crewId;
|
|
3120
|
+
const cost = Number(event.payload.costUsd);
|
|
3121
|
+
if (id && event.ts >= start && event.payload.state === "turn_cost" && Number.isFinite(cost) && cost > 0) {
|
|
3122
|
+
ledger.total += cost;
|
|
3123
|
+
ledger.crews.set(id, (ledger.crews.get(id) ?? 0) + cost);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
ledger.after = this.log.lastId();
|
|
3127
|
+
return crew ? ledger.crews.get(crew) ?? 0 : ledger.total;
|
|
3128
|
+
}
|
|
3129
|
+
/**
|
|
3130
|
+
* An agent's options under the team policy (D38): its permission mode capped
|
|
3131
|
+
* at the ceiling. Plan mode can't be known when an agent is built, so
|
|
3132
|
+
* `bypassRequiresPlan` treats it as off — the stricter reading.
|
|
3133
|
+
*/
|
|
3134
|
+
policyOptions(cfg) {
|
|
3135
|
+
const opts = { ...(cfg.options ?? {}) };
|
|
3136
|
+
if (this.runnerMode) {
|
|
3137
|
+
// A runner is its own trust tier (D70): agents bypass inside the sandbox,
|
|
3138
|
+
// capped by the team's runners.permissions rather than the laptop ceiling.
|
|
3139
|
+
const wanted = opts.permissions ? permissionFor(cfg.kind, opts) : "bypass";
|
|
3140
|
+
const ceiling = this.teamPolicy?.runners.permissions ?? "bypass";
|
|
3141
|
+
const rank = { ask: 0, auto: 1, bypass: 2 };
|
|
3142
|
+
opts.permissions = rank[wanted] > rank[ceiling] ? ceiling : wanted;
|
|
3143
|
+
if (this.teamPolicy)
|
|
3144
|
+
opts.permissions = capCrewPermission(this.teamPolicy.crews ?? DEFAULT_CREW_POLICY, cfg.role, opts.permissions);
|
|
3145
|
+
if (this.teamPolicy) {
|
|
3146
|
+
const reason = unsupportedReason(cfg.kind, opts.permissions);
|
|
3147
|
+
if (reason)
|
|
3148
|
+
throw new Error(`team policy requires ${opts.permissions} for ${cfg.id}, but ${cfg.kind} cannot enforce it: ${reason}`);
|
|
3149
|
+
}
|
|
3150
|
+
return opts;
|
|
3151
|
+
}
|
|
3152
|
+
if (!this.teamPolicy)
|
|
3153
|
+
return opts;
|
|
3154
|
+
const wanted = permissionFor(cfg.kind, opts);
|
|
3155
|
+
const capped = cappedPermission(this.teamPolicy, wanted, false);
|
|
3156
|
+
const roleCap = capCrewPermission(this.teamPolicy.crews ?? DEFAULT_CREW_POLICY, cfg.role, capped);
|
|
3157
|
+
const unsupported = unsupportedReason(cfg.kind, roleCap);
|
|
3158
|
+
if (unsupported)
|
|
3159
|
+
throw new Error(`team policy requires ${roleCap} for ${cfg.id}, but ${cfg.kind} cannot enforce it: ${unsupported}`);
|
|
3160
|
+
if (roleCap !== wanted)
|
|
3161
|
+
opts.permissions = roleCap;
|
|
3162
|
+
return opts;
|
|
3163
|
+
}
|
|
3164
|
+
/** Phase 5: this project is a runner's workspace for one goal (D70). */
|
|
3165
|
+
runnerMode = false;
|
|
3166
|
+
/** Adapter kinds usable on this machine: the roster's plus installed CLIs. */
|
|
3167
|
+
usableKinds() {
|
|
3168
|
+
const roster = this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter").map((a) => a.kind);
|
|
3169
|
+
return [...new Set([...roster, ...this.installedKinds])];
|
|
3170
|
+
}
|
|
3171
|
+
/**
|
|
3172
|
+
* One question to one agent, outside any thread: the Phase 4 review (D60).
|
|
3173
|
+
* Runs in `dir` (a throwaway directory, so a reviewer that forgets it's
|
|
3174
|
+
* read-only can't touch the project), returns everything it said, and
|
|
3175
|
+
* counts its cost like any turn.
|
|
3176
|
+
*/
|
|
3177
|
+
async askAgent(kind, dir, text, opts = {}) {
|
|
3178
|
+
const cfg = this.config.agents.find((a) => a.kind === kind) ?? { id: `${kind}-review`, kind, role: "reviewer" };
|
|
3179
|
+
const agent = createAgent({ ...cfg, id: `${cfg.id}-review`, options: { ...this.policyOptions(cfg), loomProject: this.info.id } }, dir);
|
|
3180
|
+
if (!isAdapter(agent))
|
|
3181
|
+
throw new Error(`"${kind}" can't answer questions headless`);
|
|
3182
|
+
let said = "";
|
|
3183
|
+
const off = agent.onEvent((e) => {
|
|
3184
|
+
const p = e.payload;
|
|
3185
|
+
if (e.kind === "message" && !p.reasoning && p.role !== "user")
|
|
3186
|
+
said += `\n${String(p.text ?? "")}`;
|
|
3187
|
+
if (e.kind === "status" && p.state === "turn_cost") {
|
|
3188
|
+
this.trackCost({ id: -1, ts: Date.now(), kind: e.kind, agentId: agent.id, payload: p });
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
const timeout = opts.timeoutMs ?? 15 * 60_000;
|
|
3192
|
+
let timer;
|
|
3193
|
+
try {
|
|
3194
|
+
await agent.start();
|
|
3195
|
+
await Promise.race([
|
|
3196
|
+
agent.send({ text }),
|
|
3197
|
+
new Promise((_, reject) => {
|
|
3198
|
+
timer = setTimeout(() => reject(new Error(`${kind} didn't answer within ${Math.round(timeout / 60_000)} min`)), timeout);
|
|
3199
|
+
}),
|
|
3200
|
+
]);
|
|
3201
|
+
return said.trim();
|
|
3202
|
+
}
|
|
3203
|
+
finally {
|
|
3204
|
+
if (timer)
|
|
3205
|
+
clearTimeout(timer);
|
|
3206
|
+
off();
|
|
3207
|
+
if (agent.busy())
|
|
3208
|
+
await agent.interrupt().catch(() => { });
|
|
3209
|
+
await agent.stop().catch(() => { });
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
/** Share this project with a team, or record an explicit opt-out (null). */
|
|
3213
|
+
setTeam(share) {
|
|
3214
|
+
this.config.team = share ? { teamId: share.teamId, repo: share.repo } : { optOut: true };
|
|
3215
|
+
this.saveConfig();
|
|
3216
|
+
}
|
|
3217
|
+
chatOf(agentId) { return this.turns.chatOf(agentId); }
|
|
3218
|
+
/** The live (or latest) orchestra run, compact — for status payloads. */
|
|
3219
|
+
orchestraSummary() {
|
|
3220
|
+
const run = this.orchestra.active() ?? this.orchestra.list()[0];
|
|
3221
|
+
if (!run)
|
|
3222
|
+
return null;
|
|
3223
|
+
return {
|
|
3224
|
+
id: run.id,
|
|
3225
|
+
goal: run.goal,
|
|
3226
|
+
status: run.status,
|
|
3227
|
+
chat: run.chat,
|
|
3228
|
+
// Whether that thread is the run's own or one it borrowed \u2014 a borrowed
|
|
3229
|
+
// one stops answering for the run when the run ends.
|
|
3230
|
+
...(run.inPlace ? { inPlace: true } : {}),
|
|
3231
|
+
orchestrator: run.orchestrator.agent,
|
|
3232
|
+
tasks: run.tasks.length,
|
|
3233
|
+
done: run.tasks.filter((t) => t.status === "done").length,
|
|
3234
|
+
running: run.tasks.filter((t) => t.status === "running").length,
|
|
3235
|
+
/**
|
|
3236
|
+
* One row per task, small enough to ride on every status poll.
|
|
3237
|
+
*
|
|
3238
|
+
* `tasks` above is a count, which is all the tab dot needed. It left
|
|
3239
|
+
* every other surface unable to answer questions the daemon knows the
|
|
3240
|
+
* answer to: which agent owns this thread (a task thread used to claim
|
|
3241
|
+
* to be whoever held the baton), whether a thread is still running, and
|
|
3242
|
+
* where a task's work went.
|
|
3243
|
+
*/
|
|
3244
|
+
threads: run.tasks.map((t) => ({
|
|
3245
|
+
id: t.id,
|
|
3246
|
+
title: t.title,
|
|
3247
|
+
chat: t.chat,
|
|
3248
|
+
agent: t.agent,
|
|
3249
|
+
kind: t.kind,
|
|
3250
|
+
status: t.status,
|
|
3251
|
+
})),
|
|
994
3252
|
};
|
|
995
3253
|
}
|
|
996
3254
|
async close() {
|
|
997
|
-
|
|
998
|
-
|
|
3255
|
+
await this.orchestra.shutdown().catch(() => { });
|
|
3256
|
+
this.relay.stop();
|
|
3257
|
+
this.shifts.close();
|
|
3258
|
+
await this.crews.shutdown().catch(() => { });
|
|
3259
|
+
this.closed = true;
|
|
3260
|
+
this.harnesses.stop();
|
|
3261
|
+
this.live.close();
|
|
3262
|
+
this.briefings.close();
|
|
3263
|
+
if (this.mcpTimer) {
|
|
3264
|
+
clearInterval(this.mcpTimer);
|
|
3265
|
+
this.mcpTimer = null;
|
|
999
3266
|
}
|
|
1000
|
-
this.
|
|
3267
|
+
await this.agentLifecycle.close();
|
|
3268
|
+
// A dev server outlives the daemon that started it unless we say otherwise,
|
|
3269
|
+
// and an orphan holding port 3000 is a bad thing to leave behind.
|
|
3270
|
+
if (this.queueCoordinator.clockTimer) {
|
|
3271
|
+
clearInterval(this.queueCoordinator.clockTimer);
|
|
3272
|
+
this.queueCoordinator.clockTimer = null;
|
|
3273
|
+
}
|
|
3274
|
+
await this.servers.closeAll().catch(() => { });
|
|
3275
|
+
for (const proxy of this.proxies.values())
|
|
3276
|
+
await proxy.close().catch(() => { });
|
|
3277
|
+
this.proxies.clear();
|
|
1001
3278
|
this.brain.close(); // unsubscribes before the log drops its listeners
|
|
1002
3279
|
this.log.close();
|
|
1003
3280
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
3281
|
+
closed = false;
|
|
3282
|
+
/**
|
|
3283
|
+
* Append unless the runtime has been closed.
|
|
3284
|
+
*
|
|
3285
|
+
* The async tails need this: a subtask completion or a turn error can resolve
|
|
3286
|
+
* after close() — stopping an agent does not cancel a promise already in
|
|
3287
|
+
* flight — and appending then throws into an unhandled rejection against a
|
|
3288
|
+
* sqlite handle that no longer exists. The record is not lost so much as
|
|
3289
|
+
* meaningless: the project this event belonged to is gone from memory.
|
|
3290
|
+
*/
|
|
3291
|
+
appendIfOpen(event) {
|
|
3292
|
+
if (this.closed)
|
|
3293
|
+
return;
|
|
3294
|
+
this.log.append(event);
|
|
1014
3295
|
}
|
|
1015
3296
|
}
|
|
1016
3297
|
//# sourceMappingURL=runtime.js.map
|