@openparachute/agent 0.1.2 → 0.2.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/.parachute/module.json +124 -8
- package/LICENSE +2 -16
- package/README.md +118 -166
- package/package.json +35 -42
- package/scripts/spawn-agent.ts +371 -0
- package/src/_parked/interactive-spawn.test.ts +324 -0
- package/src/_parked/interactive-spawn.ts +701 -0
- package/src/agent-defs.test.ts +1504 -0
- package/src/agent-defs.ts +1702 -0
- package/src/agent-mcp-config.test.ts +115 -0
- package/src/agent-mcp-config.ts +115 -0
- package/src/agents.test.ts +360 -0
- package/src/agents.ts +379 -0
- package/src/auth.test.ts +46 -0
- package/src/auth.ts +140 -0
- package/src/backends/attached-queue.test.ts +376 -0
- package/src/backends/attached-queue.ts +372 -0
- package/src/backends/programmatic.test.ts +1715 -0
- package/src/backends/programmatic.ts +927 -0
- package/src/backends/registry.test.ts +1494 -0
- package/src/backends/registry.ts +1202 -0
- package/src/backends/stream-json.test.ts +570 -0
- package/src/backends/stream-json.ts +392 -0
- package/src/backends/types.ts +223 -0
- package/src/bridge.ts +417 -0
- package/src/channel-backend-wiring.test.ts +237 -0
- package/src/credentials.test.ts +274 -0
- package/src/credentials.ts +380 -0
- package/src/cron.test.ts +342 -0
- package/src/cron.ts +380 -0
- package/src/daemon-agent-def-api.test.ts +166 -0
- package/src/daemon-agent-defs-api.test.ts +953 -0
- package/src/daemon-agent-env-api.test.ts +338 -0
- package/src/daemon-attached-queue-store.test.ts +65 -0
- package/src/daemon-config-api.test.ts +962 -0
- package/src/daemon-jobs-api.test.ts +271 -0
- package/src/daemon-vault-chat.test.ts +250 -0
- package/src/daemon.test.ts +746 -0
- package/src/daemon.ts +3314 -0
- package/src/def-vaults.test.ts +136 -0
- package/src/def-vaults.ts +165 -0
- package/src/delivery-state.test.ts +110 -0
- package/src/delivery-state.ts +154 -0
- package/src/effective-env.test.ts +114 -0
- package/src/effective-env.ts +184 -0
- package/src/env-compat.ts +39 -0
- package/src/grants.test.ts +638 -0
- package/src/grants.ts +675 -0
- package/src/hub-jwt.test.ts +161 -0
- package/src/hub-jwt.ts +182 -0
- package/src/jobs.test.ts +245 -0
- package/src/jobs.ts +266 -0
- package/src/mcp-http.test.ts +265 -0
- package/src/mcp-http.ts +771 -0
- package/src/mint-token.test.ts +152 -0
- package/src/mint-token.ts +139 -0
- package/src/module-manifest.test.ts +158 -0
- package/src/oauth-discovery.ts +134 -0
- package/src/programmatic-wiring.test.ts +838 -0
- package/src/registry.test.ts +227 -0
- package/src/registry.ts +228 -0
- package/src/resolve-port.test.ts +64 -0
- package/src/routing.test.ts +184 -0
- package/src/routing.ts +76 -0
- package/src/runner.test.ts +506 -0
- package/src/runner.ts +255 -0
- package/src/sandbox/config.test.ts +150 -0
- package/src/sandbox/config.ts +102 -0
- package/src/sandbox/egress.test.ts +113 -0
- package/src/sandbox/egress.ts +123 -0
- package/src/sandbox/index.ts +180 -0
- package/src/sandbox/live-seatbelt.test.ts +277 -0
- package/src/sandbox/mounts.test.ts +154 -0
- package/src/sandbox/mounts.ts +133 -0
- package/src/sandbox/sandbox.test.ts +168 -0
- package/src/sandbox/types.ts +382 -0
- package/src/services-manifest.test.ts +106 -0
- package/src/services-manifest.ts +95 -0
- package/src/spa-serve.test.ts +116 -0
- package/src/spa-serve.ts +116 -0
- package/src/spawn-agent-cli.test.ts +172 -0
- package/src/spawn-agent.test.ts +1218 -0
- package/src/spawn-agent.ts +569 -0
- package/src/spawn-deps.test.ts +54 -0
- package/src/spawn-deps.ts +166 -0
- package/src/telegram/api.ts +153 -0
- package/src/terminal-assets.test.ts +50 -0
- package/src/terminal-assets.ts +79 -0
- package/src/terminal-ui.ts +305 -0
- package/src/terminal.test.ts +530 -0
- package/src/terminal.ts +458 -0
- package/src/transport.ts +270 -0
- package/src/transports/http-ui.test.ts +455 -0
- package/src/transports/http-ui.ts +201 -0
- package/src/transports/telegram.test.ts +174 -0
- package/src/transports/telegram.ts +426 -0
- package/src/transports/vault.test.ts +2011 -0
- package/src/transports/vault.ts +1790 -0
- package/src/ui-kit.test.ts +178 -0
- package/src/ui-kit.ts +402 -0
- package/tsconfig.json +8 -14
- package/web/ui/dist/assets/index-C-iWdFFV.css +1 -0
- package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
- package/web/ui/dist/index.html +15 -0
- package/web/ui/tsconfig.json +2 -1
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.json +0 -5
- package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
- package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
- package/.claude/skills/add-codex/SKILL.md +0 -161
- package/.claude/skills/add-dashboard/SKILL.md +0 -138
- package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
- package/.claude/skills/add-emacs/SKILL.md +0 -296
- package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
- package/.claude/skills/add-gchat/REMOVE.md +0 -6
- package/.claude/skills/add-gchat/SKILL.md +0 -92
- package/.claude/skills/add-gchat/VERIFY.md +0 -3
- package/.claude/skills/add-github/REMOVE.md +0 -6
- package/.claude/skills/add-github/SKILL.md +0 -148
- package/.claude/skills/add-github/VERIFY.md +0 -3
- package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
- package/.claude/skills/add-imessage/REMOVE.md +0 -6
- package/.claude/skills/add-imessage/SKILL.md +0 -113
- package/.claude/skills/add-imessage/VERIFY.md +0 -3
- package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
- package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
- package/.claude/skills/add-linear/REMOVE.md +0 -6
- package/.claude/skills/add-linear/SKILL.md +0 -168
- package/.claude/skills/add-linear/VERIFY.md +0 -3
- package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
- package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
- package/.claude/skills/add-matrix/REMOVE.md +0 -6
- package/.claude/skills/add-matrix/SKILL.md +0 -148
- package/.claude/skills/add-matrix/VERIFY.md +0 -3
- package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
- package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
- package/.claude/skills/add-opencode/SKILL.md +0 -229
- package/.claude/skills/add-parallel/SKILL.md +0 -290
- package/.claude/skills/add-resend/REMOVE.md +0 -6
- package/.claude/skills/add-resend/SKILL.md +0 -93
- package/.claude/skills/add-resend/VERIFY.md +0 -3
- package/.claude/skills/add-signal/REMOVE.md +0 -13
- package/.claude/skills/add-signal/SKILL.md +0 -318
- package/.claude/skills/add-signal/VERIFY.md +0 -5
- package/.claude/skills/add-slack/REMOVE.md +0 -6
- package/.claude/skills/add-slack/SKILL.md +0 -112
- package/.claude/skills/add-slack/VERIFY.md +0 -3
- package/.claude/skills/add-teams/REMOVE.md +0 -6
- package/.claude/skills/add-teams/SKILL.md +0 -207
- package/.claude/skills/add-teams/VERIFY.md +0 -3
- package/.claude/skills/add-vercel/SKILL.md +0 -147
- package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
- package/.claude/skills/add-webex/REMOVE.md +0 -6
- package/.claude/skills/add-webex/SKILL.md +0 -88
- package/.claude/skills/add-webex/VERIFY.md +0 -3
- package/.claude/skills/add-wechat/REMOVE.md +0 -49
- package/.claude/skills/add-wechat/SKILL.md +0 -170
- package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
- package/.claude/skills/add-whatsapp/SKILL.md +0 -264
- package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
- package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
- package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
- package/.claude/skills/claw/SKILL.md +0 -131
- package/.claude/skills/claw/scripts/claw +0 -374
- package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
- package/.claude/skills/customize/SKILL.md +0 -110
- package/.claude/skills/debug/SKILL.md +0 -349
- package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
- package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
- package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
- package/.claude/skills/init-first-agent/SKILL.md +0 -120
- package/.claude/skills/init-onecli/SKILL.md +0 -270
- package/.claude/skills/manage-channels/SKILL.md +0 -87
- package/.claude/skills/manage-mounts/SKILL.md +0 -47
- package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
- package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
- package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
- package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
- package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
- package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
- package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
- package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
- package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
- package/.claude/skills/update-skills/SKILL.md +0 -130
- package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
- package/.claude/skills/x-integration/SKILL.md +0 -417
- package/.claude/skills/x-integration/agent.ts +0 -243
- package/.claude/skills/x-integration/host.ts +0 -155
- package/.claude/skills/x-integration/lib/browser.ts +0 -148
- package/.claude/skills/x-integration/lib/config.ts +0 -62
- package/.claude/skills/x-integration/scripts/like.ts +0 -56
- package/.claude/skills/x-integration/scripts/post.ts +0 -66
- package/.claude/skills/x-integration/scripts/quote.ts +0 -80
- package/.claude/skills/x-integration/scripts/reply.ts +0 -74
- package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
- package/.claude/skills/x-integration/scripts/setup.ts +0 -87
- package/.github/CODEOWNERS +0 -10
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
- package/.github/workflows/bump-version.yml +0 -35
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/label-pr.yml +0 -40
- package/.github/workflows/update-tokens.yml +0 -43
- package/.husky/pre-commit +0 -1
- package/.mcp.json +0 -3
- package/.nvmrc +0 -1
- package/.prettierrc +0 -4
- package/CHANGELOG.md +0 -263
- package/CLAUDE.md +0 -307
- package/CODE_OF_CONDUCT.md +0 -128
- package/CONTRIBUTING.md +0 -159
- package/CONTRIBUTORS.md +0 -26
- package/LICENSE-NANOCLAW-MIT +0 -21
- package/README_ja.md +0 -194
- package/README_zh.md +0 -194
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +0 -25
- package/container/.dockerignore +0 -2
- package/container/CLAUDE.md +0 -21
- package/container/Dockerfile +0 -121
- package/container/agent-runner/bun.lock +0 -243
- package/container/agent-runner/package.json +0 -22
- package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
- package/container/agent-runner/src/config.ts +0 -55
- package/container/agent-runner/src/db/connection.ts +0 -267
- package/container/agent-runner/src/db/index.ts +0 -20
- package/container/agent-runner/src/db/messages-in.ts +0 -138
- package/container/agent-runner/src/db/messages-out.ts +0 -143
- package/container/agent-runner/src/db/session-routing.ts +0 -30
- package/container/agent-runner/src/db/session-state.test.ts +0 -100
- package/container/agent-runner/src/db/session-state.ts +0 -79
- package/container/agent-runner/src/destinations.ts +0 -135
- package/container/agent-runner/src/formatter.test.ts +0 -167
- package/container/agent-runner/src/formatter.ts +0 -260
- package/container/agent-runner/src/index.ts +0 -110
- package/container/agent-runner/src/integration.test.ts +0 -121
- package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
- package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
- package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
- package/container/agent-runner/src/mcp-tools/core.ts +0 -262
- package/container/agent-runner/src/mcp-tools/index.ts +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
- package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
- package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
- package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
- package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
- package/container/agent-runner/src/mcp-tools/server.ts +0 -54
- package/container/agent-runner/src/mcp-tools/types.ts +0 -6
- package/container/agent-runner/src/poll-loop.test.ts +0 -248
- package/container/agent-runner/src/poll-loop.ts +0 -437
- package/container/agent-runner/src/providers/claude.ts +0 -379
- package/container/agent-runner/src/providers/factory.test.ts +0 -19
- package/container/agent-runner/src/providers/factory.ts +0 -13
- package/container/agent-runner/src/providers/index.ts +0 -6
- package/container/agent-runner/src/providers/mock.ts +0 -77
- package/container/agent-runner/src/providers/provider-registry.ts +0 -33
- package/container/agent-runner/src/providers/types.ts +0 -82
- package/container/agent-runner/src/scheduling/task-script.ts +0 -121
- package/container/agent-runner/src/timezone.test.ts +0 -93
- package/container/agent-runner/src/timezone.ts +0 -107
- package/container/agent-runner/tsconfig.json +0 -14
- package/container/build.sh +0 -48
- package/container/entrypoint.sh +0 -16
- package/container/skills/agent-browser/SKILL.md +0 -159
- package/container/skills/frontend-engineer/SKILL.md +0 -157
- package/container/skills/self-customize/SKILL.md +0 -87
- package/container/skills/slack-formatting/SKILL.md +0 -94
- package/container/skills/vercel-cli/SKILL.md +0 -111
- package/container/skills/welcome/SKILL.md +0 -85
- package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
- package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
- package/docs/README.md +0 -25
- package/docs/SDK_DEEP_DIVE.md +0 -643
- package/docs/SECURITY.md +0 -162
- package/docs/agent-runner-details.md +0 -749
- package/docs/api-details.md +0 -365
- package/docs/architecture-diagram.html +0 -422
- package/docs/architecture-diagram.md +0 -215
- package/docs/architecture.md +0 -751
- package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
- package/docs/build-and-runtime.md +0 -80
- package/docs/cross-mount-stress/README.md +0 -112
- package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
- package/docs/cross-mount-stress/container-writer.mjs +0 -47
- package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
- package/docs/cross-mount-stress/host-writer.mjs +0 -47
- package/docs/db-central.md +0 -316
- package/docs/db-session.md +0 -183
- package/docs/db.md +0 -119
- package/docs/design/2026-04-29-vault-management-ui.md +0 -231
- package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
- package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
- package/docs/docker-sandboxes.md +0 -359
- package/docs/isolation-model.md +0 -88
- package/docs/ollama.md +0 -79
- package/docs/parachute-integration.md +0 -109
- package/docs/post-night-rebirth-reflections.md +0 -151
- package/eslint.config.js +0 -32
- package/pnpm-workspace.yaml +0 -8
- package/repo-tokens/README.md +0 -113
- package/repo-tokens/action.yml +0 -186
- package/repo-tokens/badge.svg +0 -23
- package/repo-tokens/examples/green.svg +0 -14
- package/repo-tokens/examples/red.svg +0 -14
- package/repo-tokens/examples/yellow-green.svg +0 -14
- package/repo-tokens/examples/yellow.svg +0 -14
- package/scripts/chat.ts +0 -101
- package/scripts/cleanup-sessions.sh +0 -150
- package/scripts/init-cli-agent.ts +0 -172
- package/scripts/init-first-agent.ts +0 -378
- package/scripts/parachute.ts +0 -158
- package/scripts/run-migrations.ts +0 -105
- package/scripts/sanity-live-poll.ts +0 -95
- package/scripts/seed-discord.ts +0 -80
- package/scripts/test-v2-agent.ts +0 -106
- package/scripts/test-v2-channel-e2e.ts +0 -265
- package/scripts/test-v2-host.ts +0 -184
- package/src/channels/adapter.ts +0 -214
- package/src/channels/api-translator.test.ts +0 -306
- package/src/channels/api-translator.ts +0 -214
- package/src/channels/ask-question.ts +0 -46
- package/src/channels/channel-registry.test.ts +0 -421
- package/src/channels/channel-registry.ts +0 -313
- package/src/channels/chat-sdk-bridge.test.ts +0 -84
- package/src/channels/chat-sdk-bridge.ts +0 -652
- package/src/channels/cli.ts +0 -276
- package/src/channels/discord.ts +0 -90
- package/src/channels/index.ts +0 -17
- package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
- package/src/channels/telegram-markdown-sanitize.ts +0 -55
- package/src/channels/telegram-pairing.test.ts +0 -254
- package/src/channels/telegram-pairing.ts +0 -339
- package/src/channels/telegram.ts +0 -279
- package/src/channels/trust-hint.test.ts +0 -48
- package/src/channels/trust-hint.ts +0 -75
- package/src/claude-md-compose.migrate.test.ts +0 -64
- package/src/claude-md-compose.ts +0 -205
- package/src/command-gate.ts +0 -63
- package/src/config.test.ts +0 -93
- package/src/config.ts +0 -128
- package/src/container-config.ts +0 -167
- package/src/container-runner.test.ts +0 -32
- package/src/container-runner.ts +0 -576
- package/src/container-runtime.test.ts +0 -269
- package/src/container-runtime.ts +0 -167
- package/src/db/_bun-sqlite-shim.ts +0 -88
- package/src/db/agent-activity.test.ts +0 -155
- package/src/db/agent-activity.ts +0 -121
- package/src/db/agent-groups.ts +0 -77
- package/src/db/connection.migrate.test.ts +0 -176
- package/src/db/connection.ts +0 -259
- package/src/db/db-v2.test.ts +0 -440
- package/src/db/dropped-messages.ts +0 -44
- package/src/db/index.ts +0 -40
- package/src/db/messaging-groups.ts +0 -252
- package/src/db/migrations/001-initial.ts +0 -112
- package/src/db/migrations/002-chat-sdk-state.ts +0 -36
- package/src/db/migrations/008-dropped-messages.ts +0 -27
- package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
- package/src/db/migrations/010-engage-modes.ts +0 -103
- package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
- package/src/db/migrations/012-channel-registration.ts +0 -48
- package/src/db/migrations/013-approval-render-metadata.ts +0 -27
- package/src/db/migrations/014-secrets.ts +0 -44
- package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
- package/src/db/migrations/016-secret-assignments.ts +0 -30
- package/src/db/migrations/017-agent-activity.ts +0 -40
- package/src/db/migrations/018-oauth-app-configs.ts +0 -34
- package/src/db/migrations/019-oauth-app-connections.ts +0 -48
- package/src/db/migrations/020-agent-app-connections.ts +0 -28
- package/src/db/migrations/021-pending-oauth-states.ts +0 -35
- package/src/db/migrations/022-app-connections-provider.ts +0 -25
- package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
- package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
- package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
- package/src/db/migrations/024-collapse-approvals.ts +0 -182
- package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
- package/src/db/migrations/025-secret-mode-check.ts +0 -49
- package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
- package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
- package/src/db/migrations/027-provider-credentials.ts +0 -41
- package/src/db/migrations/_test-helpers.ts +0 -41
- package/src/db/migrations/index.ts +0 -127
- package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
- package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
- package/src/db/migrations/module-approvals-title-options.ts +0 -40
- package/src/db/schema.ts +0 -258
- package/src/db/session-db.test.ts +0 -93
- package/src/db/session-db.ts +0 -325
- package/src/db/sessions.ts +0 -241
- package/src/delivery.test.ts +0 -148
- package/src/delivery.ts +0 -445
- package/src/env.ts +0 -74
- package/src/group-folder.test.ts +0 -35
- package/src/group-folder.ts +0 -44
- package/src/group-init.ts +0 -92
- package/src/host-core.test.ts +0 -456
- package/src/host-sweep.test.ts +0 -146
- package/src/host-sweep.ts +0 -287
- package/src/index.ts +0 -232
- package/src/install-slug.ts +0 -33
- package/src/log.test.ts +0 -81
- package/src/log.ts +0 -117
- package/src/mcp/http.ts +0 -72
- package/src/mcp/server.ts +0 -92
- package/src/mcp/stdio.ts +0 -51
- package/src/mcp/tools/activity.ts +0 -88
- package/src/mcp/tools/agent-groups.ts +0 -183
- package/src/mcp/tools/approvals.ts +0 -122
- package/src/mcp/tools/channels.test.ts +0 -126
- package/src/mcp/tools/channels.ts +0 -134
- package/src/mcp/tools/index.ts +0 -27
- package/src/mcp/tools/oauth.ts +0 -48
- package/src/mcp/tools/secrets.ts +0 -169
- package/src/mcp/tools/sessions.ts +0 -135
- package/src/mcp/types.ts +0 -51
- package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
- package/src/modules/agent-to-agent/agent-route.ts +0 -223
- package/src/modules/agent-to-agent/create-agent.ts +0 -127
- package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
- package/src/modules/agent-to-agent/index.ts +0 -22
- package/src/modules/agent-to-agent/write-destinations.ts +0 -59
- package/src/modules/approvals/agent.md +0 -45
- package/src/modules/approvals/index.ts +0 -21
- package/src/modules/approvals/picks.test.ts +0 -291
- package/src/modules/approvals/primitive.ts +0 -279
- package/src/modules/approvals/project.md +0 -27
- package/src/modules/approvals/response-handler.ts +0 -87
- package/src/modules/index.ts +0 -24
- package/src/modules/interactive/agent.md +0 -21
- package/src/modules/interactive/index.ts +0 -69
- package/src/modules/interactive/project.md +0 -12
- package/src/modules/mount-security/expand-path.test.ts +0 -82
- package/src/modules/mount-security/index.ts +0 -459
- package/src/modules/mount-security/migrate.test.ts +0 -91
- package/src/modules/permissions/access.ts +0 -28
- package/src/modules/permissions/channel-approval.test.ts +0 -389
- package/src/modules/permissions/channel-approval.ts +0 -188
- package/src/modules/permissions/db/agent-group-members.ts +0 -44
- package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
- package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
- package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
- package/src/modules/permissions/db/user-dms.ts +0 -58
- package/src/modules/permissions/db/user-roles.ts +0 -85
- package/src/modules/permissions/db/users.ts +0 -38
- package/src/modules/permissions/index.ts +0 -421
- package/src/modules/permissions/permissions.test.ts +0 -358
- package/src/modules/permissions/sender-approval.test.ts +0 -641
- package/src/modules/permissions/sender-approval.ts +0 -165
- package/src/modules/permissions/user-dm.ts +0 -200
- package/src/modules/provider-credentials/db.ts +0 -121
- package/src/modules/provider-credentials/index.ts +0 -12
- package/src/modules/provider-credentials/spawn.test.ts +0 -206
- package/src/modules/provider-credentials/spawn.ts +0 -114
- package/src/modules/scheduling/actions.ts +0 -113
- package/src/modules/scheduling/db.test.ts +0 -282
- package/src/modules/scheduling/db.ts +0 -148
- package/src/modules/scheduling/index.ts +0 -34
- package/src/modules/scheduling/recurrence.test.ts +0 -98
- package/src/modules/scheduling/recurrence.ts +0 -54
- package/src/modules/self-mod/agent.md +0 -30
- package/src/modules/self-mod/apply.ts +0 -85
- package/src/modules/self-mod/index.ts +0 -30
- package/src/modules/self-mod/project.md +0 -39
- package/src/modules/self-mod/request.ts +0 -91
- package/src/modules/typing/index.ts +0 -165
- package/src/oauth/agent-app-connections.ts +0 -103
- package/src/oauth/app-configs.test.ts +0 -64
- package/src/oauth/app-configs.ts +0 -114
- package/src/oauth/app-connections.test.ts +0 -109
- package/src/oauth/app-connections.ts +0 -178
- package/src/oauth/crypto.ts +0 -56
- package/src/oauth/flow.ts +0 -104
- package/src/oauth/providers/google.test.ts +0 -38
- package/src/oauth/providers/google.ts +0 -46
- package/src/oauth/providers/index.ts +0 -48
- package/src/oauth/state-store.test.ts +0 -54
- package/src/oauth/state-store.ts +0 -93
- package/src/parachute/README.md +0 -27
- package/src/parachute/create-agent.test.ts +0 -83
- package/src/parachute/create-agent.ts +0 -122
- package/src/parachute/group-status.test.ts +0 -165
- package/src/parachute/group-status.ts +0 -136
- package/src/parachute/types.ts +0 -41
- package/src/parachute/vault-mcp.test.ts +0 -251
- package/src/parachute/vault-mcp.ts +0 -232
- package/src/platform-id.test.ts +0 -104
- package/src/platform-id.ts +0 -109
- package/src/providers/index.ts +0 -6
- package/src/providers/provider-container-registry.ts +0 -58
- package/src/response-registry.ts +0 -45
- package/src/router.ts +0 -530
- package/src/secrets/crypto.test.ts +0 -45
- package/src/secrets/crypto.ts +0 -55
- package/src/secrets/index.ts +0 -461
- package/src/secrets/master-key.ts +0 -70
- package/src/secrets/secrets.test.ts +0 -651
- package/src/session-manager.attachments.test.ts +0 -171
- package/src/session-manager.dup-skip.test.ts +0 -173
- package/src/session-manager.migrate.test.ts +0 -59
- package/src/session-manager.ts +0 -451
- package/src/startup-bootstrap.test.ts +0 -226
- package/src/startup-bootstrap.ts +0 -207
- package/src/state-sqlite.ts +0 -182
- package/src/timezone.test.ts +0 -64
- package/src/timezone.ts +0 -37
- package/src/types.ts +0 -233
- package/src/web/auth.test.ts +0 -335
- package/src/web/auth.ts +0 -214
- package/src/web/discord-validate.test.ts +0 -77
- package/src/web/discord-validate.ts +0 -88
- package/src/web/hub-discovery.test.ts +0 -98
- package/src/web/hub-discovery.ts +0 -69
- package/src/web/routes/activity.ts +0 -106
- package/src/web/routes/agent-provider.test.ts +0 -282
- package/src/web/routes/agent-provider.ts +0 -309
- package/src/web/routes/approvals.ts +0 -185
- package/src/web/routes/apps.ts +0 -434
- package/src/web/routes/channels-mg-detail.test.ts +0 -324
- package/src/web/routes/channels-mga-detail.test.ts +0 -472
- package/src/web/routes/channels.ts +0 -311
- package/src/web/routes/oauth-providers.ts +0 -42
- package/src/web/routes/secrets.test.ts +0 -220
- package/src/web/routes/secrets.ts +0 -317
- package/src/web/routes/sessions.ts +0 -123
- package/src/web/routes/settings.test.ts +0 -106
- package/src/web/routes/settings.ts +0 -247
- package/src/web/routes/setup-status.ts +0 -205
- package/src/web/routes/vaults.test.ts +0 -389
- package/src/web/routes/vaults.ts +0 -225
- package/src/web/server-version.test.ts +0 -16
- package/src/web/server.ts +0 -1024
- package/src/web/services-manifest.test.ts +0 -148
- package/src/web/services-manifest.ts +0 -66
- package/src/web/static-serve.test.ts +0 -255
- package/src/web/static-serve.ts +0 -104
- package/src/web/telegram-validate.test.ts +0 -116
- package/src/web/telegram-validate.ts +0 -107
- package/src/web/vault-proxy.test.ts +0 -214
- package/src/web/vault-proxy.ts +0 -120
- package/src/web/wire-channel.ts +0 -181
- package/src/webhook-server.ts +0 -134
- package/vitest.config.ts +0 -18
- package/web/README.md +0 -63
- package/web/ui/index.html +0 -13
- package/web/ui/package.json +0 -35
- package/web/ui/pnpm-lock.yaml +0 -2164
- package/web/ui/scripts/verify-base.mjs +0 -31
- package/web/ui/src/App.tsx +0 -88
- package/web/ui/src/components/ActivityFeed.tsx +0 -444
- package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
- package/web/ui/src/components/AgentProviderCards.tsx +0 -220
- package/web/ui/src/components/CredentialForm.tsx +0 -214
- package/web/ui/src/components/ScopeGrants.tsx +0 -74
- package/web/ui/src/components/StatusDot.tsx +0 -43
- package/web/ui/src/components/VaultPicker.tsx +0 -127
- package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
- package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
- package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
- package/web/ui/src/components/setup/DoneStep.tsx +0 -49
- package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
- package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
- package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
- package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
- package/web/ui/src/components/setup/types.ts +0 -105
- package/web/ui/src/lib/api.test.ts +0 -410
- package/web/ui/src/lib/api.ts +0 -1248
- package/web/ui/src/lib/auth.test.ts +0 -352
- package/web/ui/src/lib/auth.ts +0 -405
- package/web/ui/src/lib/channel-adapters.ts +0 -136
- package/web/ui/src/main.tsx +0 -19
- package/web/ui/src/routes/ApprovalsList.tsx +0 -294
- package/web/ui/src/routes/Apps.tsx +0 -613
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
- package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
- package/web/ui/src/routes/ChannelsList.tsx +0 -158
- package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
- package/web/ui/src/routes/GroupDetail.tsx +0 -880
- package/web/ui/src/routes/GroupList.tsx +0 -187
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
- package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
- package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
- package/web/ui/src/routes/OAuthCallback.tsx +0 -56
- package/web/ui/src/routes/SecretsList.tsx +0 -942
- package/web/ui/src/routes/SessionsList.tsx +0 -220
- package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
- package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
- package/web/ui/src/routes/SetupWizard.tsx +0 -219
- package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
- package/web/ui/src/routes/VaultDetail.tsx +0 -960
- package/web/ui/src/routes/VaultsList.tsx +0 -295
- package/web/ui/src/routes/WireChannelPage.tsx +0 -413
- package/web/ui/src/styles.css +0 -608
- package/web/ui/src/test/setup.ts +0 -23
- package/web/ui/src/vite-env.d.ts +0 -10
- package/web/ui/vite.config.ts +0 -34
- package/web/ui/vitest.config.ts +0 -25
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-native agent definitions — an agent IS a `#agent/definition` note
|
|
3
|
+
* (design `2026-06-17-vault-native-agents.md`, Phase 4a).
|
|
4
|
+
*
|
|
5
|
+
* Instead of a `channels.json` entry + a `sessions/<name>/spec.json`, a
|
|
6
|
+
* vault-native agent is a single vault note: the note BODY is the system prompt,
|
|
7
|
+
* the note METADATA is the config. The module reads `#agent/definition` notes from
|
|
8
|
+
* a configured DEF-VAULT and, for each one, instantiates a live agent — a vault
|
|
9
|
+
* channel (so inbound/outbound notes flow) + a registered programmatic agent (so an
|
|
10
|
+
* inbound turn runs `claude -p`). Reactively: a note created/updated/deleted →
|
|
11
|
+
* reload that one agent.
|
|
12
|
+
*
|
|
13
|
+
* REUSE (the design's "near-stateless executor" point — this module is small
|
|
14
|
+
* because it stands on the existing machinery):
|
|
15
|
+
* - {@link AgentSpec} (sandbox/types.ts) stays the canonical in-memory shape; only
|
|
16
|
+
* its SOURCE moves from `spec.json` to a note. {@link parseAgentDef} is "note →
|
|
17
|
+
* AgentSpec".
|
|
18
|
+
* - `addChannelLive` (daemon.ts) brings up the vault channel — the SAME call the
|
|
19
|
+
* create-agent flow + boot use; injected here as {@link InstantiateDeps.ensureChannel}.
|
|
20
|
+
* - `setupProgrammaticSpawn` (agents.ts) persists `spec.json` (so the existing boot
|
|
21
|
+
* re-register + the per-turn deliver find the workspace) and `programmatic.register`
|
|
22
|
+
* registers the agent — injected as {@link InstantiateDeps.setupAndRegister}.
|
|
23
|
+
* - The def-vault's `vault:<name>:write` token (minted by the daemon the SAME way a
|
|
24
|
+
* channel/job token is — `mint-token.ts`) drives BOTH the def query and the status
|
|
25
|
+
* stamp; the vault REST encoding mirrors `VaultTransport`.
|
|
26
|
+
*
|
|
27
|
+
* SCOPE (4a only — OWN-VAULT). An agent defined in vault X is scoped to vault X: its
|
|
28
|
+
* conversation + jobs live there, and its minted vault token is for X. There is NO
|
|
29
|
+
* cross-vault / MCP / external-service connector, NO approval flow — that is 4b.
|
|
30
|
+
* A def MAY declare a `uses: […]` / connections field; we PARSE + SURFACE it (so the
|
|
31
|
+
* status note lists what it wants) but do NOT grant it. Secrets NEVER live in a note;
|
|
32
|
+
* the Claude OAuth token + any service creds stay in the local store and are injected
|
|
33
|
+
* at run time by the programmatic backend, exactly as today.
|
|
34
|
+
*
|
|
35
|
+
* STATUS (queryable liveness — the design's "lives in the field so an MCP side knows"):
|
|
36
|
+
* after resolving a def, the registry PATCHes the note's metadata `status`. In 4a
|
|
37
|
+
* (own-vault only) a successfully-instantiated agent is `enabled`; a def that declares
|
|
38
|
+
* external connections is `pending` (listing them) since 4b hasn't granted them yet —
|
|
39
|
+
* it still runs own-vault, the declared connections are simply absent until approved.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
type AgentSpec,
|
|
44
|
+
type AgentBackendKind,
|
|
45
|
+
type AgentMode,
|
|
46
|
+
type SystemPromptMode,
|
|
47
|
+
type AgentMount,
|
|
48
|
+
} from "./sandbox/types.ts";
|
|
49
|
+
import { AGENT_DEFINITION_TAG } from "./transports/vault.ts";
|
|
50
|
+
import {
|
|
51
|
+
parseWants,
|
|
52
|
+
connectionKey,
|
|
53
|
+
resolveConnectionStatus,
|
|
54
|
+
WantsParseError,
|
|
55
|
+
GrantsClient,
|
|
56
|
+
type ConnectionSpec,
|
|
57
|
+
} from "./grants.ts";
|
|
58
|
+
|
|
59
|
+
const DEFAULT_DEF_VAULT_URL = "http://127.0.0.1:1940";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Page cap for a def-vault list. The poll's removed-def diff now DEREGISTERS (a
|
|
63
|
+
* destructive teardown), so a list that hits this cap is treated as possibly-
|
|
64
|
+
* truncated — NOT a confident set — and the removal diff is skipped that pass (see
|
|
65
|
+
* {@link AgentDefRegistry.loadAll}'s truncation guard). 500 comfortably exceeds any
|
|
66
|
+
* realistic agent count; it exists so the teardown is safe by construction.
|
|
67
|
+
*/
|
|
68
|
+
const DEF_LIST_LIMIT = 500;
|
|
69
|
+
|
|
70
|
+
/** A slug: alphanumeric, dash, underscore — the agent name + wake-channel key. */
|
|
71
|
+
const NAME_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A def-vault the module reads `#agent/definition` notes from. The architecture is
|
|
75
|
+
* a LIST (default: one — the local `default` vault) so opening up multi-vault later
|
|
76
|
+
* is appending, not a refactor (design "Decided: multi-vault"). The token grants
|
|
77
|
+
* vault read (query defs) + write (stamp status + the agents' message/job notes),
|
|
78
|
+
* scoped to THIS vault only — an agent defined here reaches only this vault (4a).
|
|
79
|
+
*/
|
|
80
|
+
export interface DefVaultBinding {
|
|
81
|
+
/** Vault name (the `<vault>` path segment in the REST URL). */
|
|
82
|
+
vault: string;
|
|
83
|
+
/** REST base origin. Default `http://127.0.0.1:1940`. */
|
|
84
|
+
vaultUrl?: string;
|
|
85
|
+
/** A `vault:<name>:write` hub JWT (read + write), presented as Bearer. */
|
|
86
|
+
token: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The resolved status of a def after instantiation (stamped onto the note). */
|
|
90
|
+
export type AgentDefStatus = "enabled" | "pending" | "error";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-connection grant info surfaced to the ops UI (the MCP/connections panel) so it
|
|
94
|
+
* can render a status pill + drive the cookie→hub "Connect" without re-deriving the
|
|
95
|
+
* hub's grant id client-side (that divergence class already bit this codebase — the
|
|
96
|
+
* id MUST come from the hub). One entry per declared `wants:` connection.
|
|
97
|
+
*
|
|
98
|
+
* - `key` — the stable {@link connectionKey} (matches a `wants` entry).
|
|
99
|
+
* - `kind` — `vault` | `service` | `mcp` (the panel only acts on `mcp` today).
|
|
100
|
+
* - `target` — the connection target (for `mcp`, the remote https URL).
|
|
101
|
+
* - `status` — the hub grant's lifecycle as the hub reports it
|
|
102
|
+
* (`pending` | `approved` | `revoked` | `needs_consent`), or `pending` when no
|
|
103
|
+
* grant could be resolved (no grants client / a registration error).
|
|
104
|
+
* - `grantId` — the hub-assigned grant id (the Connect/approve key). Absent when no
|
|
105
|
+
* grant was registered/resolved (then the UI can't offer Connect — it shows a
|
|
106
|
+
* degraded hint instead).
|
|
107
|
+
*/
|
|
108
|
+
export interface ConnectionInfo {
|
|
109
|
+
key: string;
|
|
110
|
+
kind: "vault" | "service" | "mcp";
|
|
111
|
+
target: string;
|
|
112
|
+
status: string;
|
|
113
|
+
grantId?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The parse of one `#agent/definition` note: the canonical {@link AgentSpec} the
|
|
118
|
+
* registry instantiates, plus the note bookkeeping (its id for PATCH, the declared
|
|
119
|
+
* connections to surface, and any parse error).
|
|
120
|
+
*/
|
|
121
|
+
export interface ParsedAgentDef {
|
|
122
|
+
/** The vault note id/path — addresses the note for the status PATCH. */
|
|
123
|
+
noteId: string;
|
|
124
|
+
/** The agent name (= the wake channel + the spec name). */
|
|
125
|
+
name: string;
|
|
126
|
+
/** The canonical in-memory spec, ready for `programmatic.register`. */
|
|
127
|
+
spec: AgentSpec;
|
|
128
|
+
/**
|
|
129
|
+
* Declared cross-vault / MCP / external-service connections beyond the def-vault
|
|
130
|
+
* (the legacy `uses:` field — raw name strings). PARSED + surfaced in 4a; superseded
|
|
131
|
+
* by the structured `wants:` field in 4b. Kept for back-compat (a 4a-era note that
|
|
132
|
+
* declared `uses:` still surfaces its names) — but a note SHOULD use `wants:` (see
|
|
133
|
+
* {@link wants}). Empty = no legacy declarations.
|
|
134
|
+
*/
|
|
135
|
+
declaredConnections: string[];
|
|
136
|
+
/**
|
|
137
|
+
* Declared connections in the STRUCTURED 4b form (the `wants:` field) — vault /
|
|
138
|
+
* service / mcp connection specs the agent wants to reach beyond its def-vault
|
|
139
|
+
* (design 2026-06-17-agent-connectors-4b.md). REGISTERED as pending grants on
|
|
140
|
+
* instantiate + injected (when approved) at spawn — granting is operator-approved
|
|
141
|
+
* in the hub. Empty = own-vault only.
|
|
142
|
+
*/
|
|
143
|
+
wants: ConnectionSpec[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A failed parse — the note isn't a well-formed agent def. */
|
|
147
|
+
export class AgentDefParseError extends Error {
|
|
148
|
+
constructor(message: string) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.name = "AgentDefParseError";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A failed def WRITE (create/edit/delete) — carries an HTTP status the daemon route
|
|
156
|
+
* maps directly (400 validation, 404 unknown note, 409 name collision, 502 a
|
|
157
|
+
* write/instantiate failure). Distinct from {@link AgentDefParseError} (a note that's
|
|
158
|
+
* already in the vault but malformed).
|
|
159
|
+
*/
|
|
160
|
+
export class AgentDefWriteError extends Error {
|
|
161
|
+
constructor(
|
|
162
|
+
message: string,
|
|
163
|
+
readonly status: number,
|
|
164
|
+
) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = "AgentDefWriteError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Coerce a vault metadata value (the vault stores metadata as strings, but a note
|
|
172
|
+
* authored in another client may carry a real array/number) to a trimmed string.
|
|
173
|
+
*/
|
|
174
|
+
function metaStr(v: unknown): string | undefined {
|
|
175
|
+
if (typeof v === "string") {
|
|
176
|
+
const t = v.trim();
|
|
177
|
+
return t.length > 0 ? t : undefined;
|
|
178
|
+
}
|
|
179
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse a comma/space-separated list field OR a real array → a clean string[].
|
|
185
|
+
* Used for `egress` and `uses` (a note authored as YAML front-matter may carry
|
|
186
|
+
* either; a vault that stringifies arrays gives us the comma form).
|
|
187
|
+
*/
|
|
188
|
+
function metaList(v: unknown): string[] {
|
|
189
|
+
let parts: string[] = [];
|
|
190
|
+
if (Array.isArray(v)) {
|
|
191
|
+
parts = v.map((x) => (typeof x === "string" ? x : String(x)));
|
|
192
|
+
} else if (typeof v === "string") {
|
|
193
|
+
parts = v.split(/[,\s]+/);
|
|
194
|
+
}
|
|
195
|
+
return parts.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Best-effort, NON-throwing extraction of a def note's agent name (`metadata.name`),
|
|
200
|
+
* for tracking the seen set + the removed-def grant-GC diff (#96) — distinct from
|
|
201
|
+
* {@link parseAgentDef}, which validates + throws. Returns undefined when the note has
|
|
202
|
+
* no usable name (we then carry the prior last-known name forward). Does NOT slug-
|
|
203
|
+
* validate: a note that once instantiated already passed parse; tracking the raw name
|
|
204
|
+
* is enough to address its grants for the prune.
|
|
205
|
+
*/
|
|
206
|
+
function nameOfDefNote(note: { metadata?: Record<string, unknown> }): string | undefined {
|
|
207
|
+
return metaStr(note.metadata?.name);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse one `#agent/definition` note into a {@link ParsedAgentDef}. PURE — no I/O.
|
|
212
|
+
*
|
|
213
|
+
* Mapping (the design's "note shape"):
|
|
214
|
+
* - note BODY (`content`) → `spec.systemPrompt` (the agent's role, in prose).
|
|
215
|
+
* - `metadata.name` → `spec.name` (REQUIRED, slug) = the wake channel.
|
|
216
|
+
* - `metadata.backend` → `spec.backend` (default `programmatic`).
|
|
217
|
+
* - `metadata.mode` → `spec.mode` (default `single-threaded`; `multi-threaded`
|
|
218
|
+
* ok; the legacy aliases `resident`/`one-shot`/`per-thread` are DUAL-ACCEPTED and
|
|
219
|
+
* mapped silently). The note id → `spec.definition` (provenance).
|
|
220
|
+
* - `metadata.systemPromptMode` → `spec.systemPromptMode` (default `append`).
|
|
221
|
+
* - `metadata.workspace` → `spec.workspace` (optional absolute host cwd).
|
|
222
|
+
* - `metadata.filesystem` → `spec.filesystem` (`workspace` | `full`).
|
|
223
|
+
* - `metadata.network` → `spec.network` (`open` | `restricted`).
|
|
224
|
+
* - `metadata.egress` → `spec.egress` (host list, for `restricted`).
|
|
225
|
+
* - the def-vault binding → `spec.vault` (own-vault, `write`) — passed in, since
|
|
226
|
+
* the note never names which vault it lives in (it's defined BY being in it).
|
|
227
|
+
* - `metadata.uses` → `declaredConnections` (PARSED, NOT granted — 4b).
|
|
228
|
+
*
|
|
229
|
+
* `spec.channels` is `[name]` — the wake channel IS the agent name (the design's
|
|
230
|
+
* "agent ≡ channel" collapse). Throws {@link AgentDefParseError} on a missing/bad
|
|
231
|
+
* name (the registry skips that note + stamps `error`, rather than instantiating a
|
|
232
|
+
* malformed agent).
|
|
233
|
+
*
|
|
234
|
+
* SECRETS: a def declares creds BY REFERENCE only (`uses:`). We deliberately do NOT
|
|
235
|
+
* read any token/secret field off the note — secrets stay local. `credentialRef`
|
|
236
|
+
* stays the local Claude-credential selector (defaults to the wake channel) and is
|
|
237
|
+
* never sourced from the note.
|
|
238
|
+
*/
|
|
239
|
+
export function parseAgentDef(note: {
|
|
240
|
+
id?: string;
|
|
241
|
+
content?: string;
|
|
242
|
+
metadata?: Record<string, unknown>;
|
|
243
|
+
}, binding: { vault: string }): ParsedAgentDef {
|
|
244
|
+
const noteId = typeof note.id === "string" ? note.id : "";
|
|
245
|
+
if (!noteId) {
|
|
246
|
+
throw new AgentDefParseError("#agent/definition note has no id");
|
|
247
|
+
}
|
|
248
|
+
const meta = note.metadata ?? {};
|
|
249
|
+
|
|
250
|
+
const name = metaStr(meta.name);
|
|
251
|
+
if (!name) {
|
|
252
|
+
throw new AgentDefParseError(`#agent/definition note ${noteId} has no metadata.name`);
|
|
253
|
+
}
|
|
254
|
+
if (!NAME_SLUG_RE.test(name)) {
|
|
255
|
+
throw new AgentDefParseError(
|
|
256
|
+
`#agent/definition note ${noteId}: name "${name}" must be a slug (alphanumeric, dash, underscore)`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Backend — default programmatic (the reliable primary path). A vault-native def
|
|
261
|
+
// may select EITHER `programmatic` (the daemon runs `claude -p` turns) OR `attached`
|
|
262
|
+
// (the design 2026-06-18-channel-backend path — the turn is handled by a Claude Code
|
|
263
|
+
// session the operator connects — "attaches" — to the channel's MCP endpoint; the
|
|
264
|
+
// daemon runs no turn, the inbound notes accumulate as a durable queue). `interactive`
|
|
265
|
+
// (the retired tmux path) is REJECTED with a clear message (→ status:error on the
|
|
266
|
+
// note) rather than silently demoting — `attached` is what it was reaching for, done right.
|
|
267
|
+
//
|
|
268
|
+
// DUAL-READ the legacy backend VALUE, mapping silently (no operator-facing break, no
|
|
269
|
+
// migration of already-authored def notes / spec.json):
|
|
270
|
+
// legacy value → canonical value
|
|
271
|
+
// ──────────────────────────────
|
|
272
|
+
// channel → attached (the backend value was renamed `channel` → `attached`;
|
|
273
|
+
// the ROUTING KEY `channel` — metadata.channel, the
|
|
274
|
+
// `/mcp/<channel>` segment — is a SEPARATE concept, untouched)
|
|
275
|
+
let backend: AgentBackendKind = "programmatic";
|
|
276
|
+
const rawBackend = metaStr(meta.backend);
|
|
277
|
+
if (rawBackend !== undefined) {
|
|
278
|
+
if (rawBackend === "interactive") {
|
|
279
|
+
throw new AgentDefParseError(
|
|
280
|
+
`#agent/definition note ${noteId}: the "interactive" backend is retired — use ` +
|
|
281
|
+
`"programmatic" (daemon-run turns, the default) or "attached" (handled by a Claude ` +
|
|
282
|
+
`Code session you connect to the channel).`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
// DUAL-READ: the legacy backend value `"channel"` normalizes to `"attached"`.
|
|
286
|
+
const normalizedBackend = rawBackend === "channel" ? "attached" : rawBackend;
|
|
287
|
+
if (normalizedBackend !== "programmatic" && normalizedBackend !== "attached") {
|
|
288
|
+
throw new AgentDefParseError(
|
|
289
|
+
`#agent/definition note ${noteId}: backend must be "programmatic" or "attached"`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
backend = normalizedBackend;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Execution-lifecycle mode (the Phase-3 prerequisite). An agent is SINGLE-THREADED
|
|
296
|
+
// or MULTI-THREADED. Default `single-threaded` (= today: one persistent session per
|
|
297
|
+
// channel, resumed + persisted each turn). `multi-threaded` is thread-keyed — today
|
|
298
|
+
// (no inbound thread id yet) every fire mints a fresh thread (no resume, no persist).
|
|
299
|
+
// BOTH modes now materialize an `#agent/thread` note (the unified model
|
|
300
|
+
// `definition -> thread -> message`): single-threaded upserts ONE thread note per
|
|
301
|
+
// channel (named after the def, rolling summary + turn_count); multi-threaded writes
|
|
302
|
+
// one thread note per fire.
|
|
303
|
+
//
|
|
304
|
+
// DUAL-ACCEPT the legacy aliases, mapping silently (no operator-facing break, no
|
|
305
|
+
// migration of already-authored notes):
|
|
306
|
+
// legacy value → canonical value
|
|
307
|
+
// ─────────────────────────────────
|
|
308
|
+
// resident → single-threaded
|
|
309
|
+
// one-shot → multi-threaded (one-shot was just multi-threaded's degenerate
|
|
310
|
+
// first turn — the term retires)
|
|
311
|
+
// per-thread → multi-threaded (per-thread continuation is the DEFERRED
|
|
312
|
+
// increment of multi-threaded, not its own mode)
|
|
313
|
+
//
|
|
314
|
+
// Any OTHER value is rejected with a clear, actionable error (→ status:error on the
|
|
315
|
+
// note) rather than silently demoting (which would hide the operator's intent).
|
|
316
|
+
let mode: AgentMode = "single-threaded";
|
|
317
|
+
const rawMode = metaStr(meta.mode);
|
|
318
|
+
if (rawMode !== undefined) {
|
|
319
|
+
if (rawMode === "single-threaded" || rawMode === "resident") {
|
|
320
|
+
mode = "single-threaded";
|
|
321
|
+
} else if (
|
|
322
|
+
rawMode === "multi-threaded" ||
|
|
323
|
+
rawMode === "one-shot" ||
|
|
324
|
+
rawMode === "per-thread"
|
|
325
|
+
) {
|
|
326
|
+
mode = "multi-threaded";
|
|
327
|
+
} else {
|
|
328
|
+
throw new AgentDefParseError(
|
|
329
|
+
`#agent/definition note ${noteId}: mode must be "single-threaded" or "multi-threaded"`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const spec: AgentSpec = {
|
|
335
|
+
name,
|
|
336
|
+
channels: [name], // wake channel = the agent name (agent ≡ channel)
|
|
337
|
+
backend,
|
|
338
|
+
mode,
|
|
339
|
+
// The def note id — provenance carried into the `#agent/thread` note (BOTH modes;
|
|
340
|
+
// interim plain id string; typed link fields are a future vault feature).
|
|
341
|
+
definition: noteId,
|
|
342
|
+
// Own-vault binding (4a): the def-vault, write-scoped. NOT sourced from the note
|
|
343
|
+
// — it's the vault the note LIVES in (passed in by the caller).
|
|
344
|
+
vault: { name: binding.vault, access: "write" },
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// The note body IS the system prompt. A blank body → no system prompt (CC's
|
|
348
|
+
// default, untouched) rather than an empty `--append-system-prompt-file`.
|
|
349
|
+
const body = typeof note.content === "string" ? note.content.trim() : "";
|
|
350
|
+
if (body.length > 0) {
|
|
351
|
+
spec.systemPrompt = note.content!; // keep the untrimmed body (whitespace may matter in prose)
|
|
352
|
+
const mode = metaStr(meta.systemPromptMode);
|
|
353
|
+
if (mode !== undefined) {
|
|
354
|
+
if (mode !== "append" && mode !== "replace") {
|
|
355
|
+
throw new AgentDefParseError(
|
|
356
|
+
`#agent/definition note ${noteId}: systemPromptMode must be "append" or "replace"`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
spec.systemPromptMode = mode as SystemPromptMode;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Model (optional) — passed to `claude -p --model` by the programmatic backend.
|
|
364
|
+
// A CC alias (`opus`/`sonnet`/`haiku`) or a full id (`claude-opus-4-8`). We
|
|
365
|
+
// validate only the CHARSET (no membership list — models evolve), so a typo'd-
|
|
366
|
+
// but-wellformed value still reaches `--model` and the turn errors clearly,
|
|
367
|
+
// while a malformed value (spaces/control chars) fails fast as a def error.
|
|
368
|
+
const model = metaStr(meta.model);
|
|
369
|
+
if (model !== undefined && model.length > 0) {
|
|
370
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(model)) {
|
|
371
|
+
throw new AgentDefParseError(
|
|
372
|
+
`#agent/definition note ${noteId}: model "${model}" is not a valid model name (letters, numbers, dot, underscore, colon, dash)`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
spec.model = model;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Working directory (optional absolute host cwd). We do NOT statSync here (parse is
|
|
379
|
+
// pure + may run on a box where the dir is mounted differently); the spawn path's
|
|
380
|
+
// own checks apply when the turn runs.
|
|
381
|
+
const workspace = metaStr(meta.workspace);
|
|
382
|
+
if (workspace !== undefined) {
|
|
383
|
+
if (!workspace.startsWith("/")) {
|
|
384
|
+
throw new AgentDefParseError(
|
|
385
|
+
`#agent/definition note ${noteId}: workspace must be an absolute path (start with "/")`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
spec.workspace = workspace;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Filesystem read scope.
|
|
392
|
+
const filesystem = metaStr(meta.filesystem);
|
|
393
|
+
if (filesystem !== undefined) {
|
|
394
|
+
if (filesystem !== "workspace" && filesystem !== "full") {
|
|
395
|
+
throw new AgentDefParseError(
|
|
396
|
+
`#agent/definition note ${noteId}: filesystem must be "workspace" or "full"`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
spec.filesystem = filesystem;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Network egress mode + (under restricted) the additional host allowlist.
|
|
403
|
+
const network = metaStr(meta.network);
|
|
404
|
+
if (network !== undefined) {
|
|
405
|
+
if (network !== "open" && network !== "restricted") {
|
|
406
|
+
throw new AgentDefParseError(
|
|
407
|
+
`#agent/definition note ${noteId}: network must be "open" or "restricted"`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
spec.network = network;
|
|
411
|
+
}
|
|
412
|
+
const egress = metaList(meta.egress);
|
|
413
|
+
if (egress.length > 0) spec.egress = egress;
|
|
414
|
+
|
|
415
|
+
// Filesystem mounts — JSON-encoded array in metadata (the note can't carry a
|
|
416
|
+
// structured array natively in a string vault), parsed defensively. Optional; a
|
|
417
|
+
// malformed value is ignored (not fatal — mounts are an advanced knob).
|
|
418
|
+
const mounts = parseMounts(meta.mounts);
|
|
419
|
+
if (mounts.length > 0) spec.mounts = mounts;
|
|
420
|
+
|
|
421
|
+
// Declared connections beyond the def-vault (the legacy `uses:` field). PARSED +
|
|
422
|
+
// surfaced; never a secret — these are NAMES (`github`, `vault:research:read`).
|
|
423
|
+
const declaredConnections = metaList(meta.uses);
|
|
424
|
+
|
|
425
|
+
// STRUCTURED connection declarations (the 4b `wants:` field — design
|
|
426
|
+
// 2026-06-17-agent-connectors-4b.md). Comma-separated connection specs parsed into
|
|
427
|
+
// {@link ConnectionSpec}s. A MALFORMED `wants:` → the def is an ERROR (we re-throw
|
|
428
|
+
// as AgentDefParseError so the registry stamps status:error + doesn't half-
|
|
429
|
+
// instantiate, design §1). The def-vault is implicit — never appears in `wants:`.
|
|
430
|
+
let wants: ConnectionSpec[];
|
|
431
|
+
try {
|
|
432
|
+
wants = parseWants(meta.wants);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err instanceof WantsParseError) {
|
|
435
|
+
throw new AgentDefParseError(`#agent/definition note ${noteId}: ${err.message}`);
|
|
436
|
+
}
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { noteId, name, spec, declaredConnections, wants };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Parse a metadata `mounts` value (JSON array string or real array) → AgentMount[]. */
|
|
444
|
+
function parseMounts(v: unknown): AgentMount[] {
|
|
445
|
+
let arr: unknown;
|
|
446
|
+
if (typeof v === "string") {
|
|
447
|
+
const t = v.trim();
|
|
448
|
+
if (t.length === 0) return [];
|
|
449
|
+
try {
|
|
450
|
+
arr = JSON.parse(t);
|
|
451
|
+
} catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
arr = v;
|
|
456
|
+
}
|
|
457
|
+
if (!Array.isArray(arr)) return [];
|
|
458
|
+
const out: AgentMount[] = [];
|
|
459
|
+
for (const raw of arr) {
|
|
460
|
+
if (!raw || typeof raw !== "object") continue;
|
|
461
|
+
const m = raw as Record<string, unknown>;
|
|
462
|
+
if (typeof m.hostPath !== "string" || !m.hostPath.startsWith("/")) continue;
|
|
463
|
+
if (typeof m.mountPath !== "string" || !m.mountPath.startsWith("/")) continue;
|
|
464
|
+
if (m.mode !== "ro" && m.mode !== "rw") continue;
|
|
465
|
+
const mount: AgentMount = { hostPath: m.hostPath, mountPath: m.mountPath, mode: m.mode };
|
|
466
|
+
if (typeof m.shared === "string" && m.shared.length > 0) mount.shared = m.shared;
|
|
467
|
+
out.push(mount);
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolve the status a parsed def gets WITHOUT grant information — the fallback path
|
|
474
|
+
* (no grants client wired, e.g. hub not provisioned). Own-vault only → `enabled`; a
|
|
475
|
+
* def that declares ANY connection (legacy `uses:` names OR structured `wants:`) →
|
|
476
|
+
* `pending` (listing them) since nothing has been granted yet. The agent still runs
|
|
477
|
+
* own-vault either way; this is the queryable signal.
|
|
478
|
+
*
|
|
479
|
+
* When a grants client IS wired, the registry instead registers each `wants:`
|
|
480
|
+
* connection + resolves status from the hub's grant statuses
|
|
481
|
+
* (`resolveConnectionStatus` in grants.ts) — `enabled` only once every connection is
|
|
482
|
+
* approved. This pure function is the no-hub fallback + the legacy-`uses:` path.
|
|
483
|
+
*/
|
|
484
|
+
export function resolveDefStatus(def: ParsedAgentDef): {
|
|
485
|
+
status: AgentDefStatus;
|
|
486
|
+
pending?: string[];
|
|
487
|
+
} {
|
|
488
|
+
const pending = [
|
|
489
|
+
...def.declaredConnections,
|
|
490
|
+
...def.wants.map((c) => connectionKey(c)),
|
|
491
|
+
];
|
|
492
|
+
if (pending.length > 0) {
|
|
493
|
+
return { status: "pending", pending };
|
|
494
|
+
}
|
|
495
|
+
return { status: "enabled" };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A thin vault client for ONE def-vault — the def-query + the status-PATCH. Mirrors
|
|
500
|
+
* `VaultTransport`'s REST encoding (the `#` + `/` in a tag → `%23`/`%2F`; the note
|
|
501
|
+
* PATCH route is `PATCH /vault/<vault>/api/notes/<id>`). `fetchFn` is injectable so
|
|
502
|
+
* tests drive it with a recorder, deterministic, no global mock leak.
|
|
503
|
+
*/
|
|
504
|
+
export class DefVaultClient {
|
|
505
|
+
private readonly vault: string;
|
|
506
|
+
private readonly vaultUrl: string;
|
|
507
|
+
private readonly token: string;
|
|
508
|
+
private readonly fetchFn: typeof fetch;
|
|
509
|
+
|
|
510
|
+
constructor(binding: DefVaultBinding, fetchFn?: typeof fetch) {
|
|
511
|
+
if (!binding.vault) throw new Error("DefVaultClient: binding.vault is required");
|
|
512
|
+
if (!binding.token) throw new Error("DefVaultClient: binding.token is required");
|
|
513
|
+
this.vault = binding.vault;
|
|
514
|
+
this.vaultUrl = (binding.vaultUrl ?? DEFAULT_DEF_VAULT_URL).replace(/\/$/, "");
|
|
515
|
+
this.token = binding.token;
|
|
516
|
+
this.fetchFn = fetchFn ?? fetch;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The def-vault name (for routing reload events to the right client). */
|
|
520
|
+
get vaultName(): string {
|
|
521
|
+
return this.vault;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* List the `#agent/definition` notes in this vault. INDEX-FREE: queries by the
|
|
526
|
+
* exact tag (the leaf — we never rely on namespace prefix expansion) with
|
|
527
|
+
* `include_content=true` (we need the body = the system prompt). Throws on a
|
|
528
|
+
* non-ok vault response so the caller surfaces a clear error rather than a
|
|
529
|
+
* silently-empty agent set.
|
|
530
|
+
*/
|
|
531
|
+
async listDefNotes(opts?: { limit?: number }): Promise<
|
|
532
|
+
Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>
|
|
533
|
+
> {
|
|
534
|
+
const limit = opts?.limit ?? DEF_LIST_LIMIT;
|
|
535
|
+
const params = new URLSearchParams();
|
|
536
|
+
params.set("tag", AGENT_DEFINITION_TAG); // URLSearchParams encodes `#`→`%23`, `/`→`%2F`
|
|
537
|
+
params.set("include_content", "true");
|
|
538
|
+
params.set("limit", String(limit));
|
|
539
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
|
|
540
|
+
const res = await this.fetchFn(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
541
|
+
if (!res.ok) {
|
|
542
|
+
const detail = await res.text().catch(() => "");
|
|
543
|
+
throw new Error(`def-vault "${this.vault}": list defs failed (${res.status}) ${detail}`.trim());
|
|
544
|
+
}
|
|
545
|
+
let parsed: unknown;
|
|
546
|
+
try {
|
|
547
|
+
parsed = await res.json();
|
|
548
|
+
} catch (err) {
|
|
549
|
+
throw new Error(`def-vault "${this.vault}": list defs — bad JSON: ${(err as Error).message}`);
|
|
550
|
+
}
|
|
551
|
+
type RawNote = { id?: string; content?: string; metadata?: Record<string, unknown> };
|
|
552
|
+
const notes: RawNote[] = Array.isArray(parsed)
|
|
553
|
+
? (parsed as RawNote[])
|
|
554
|
+
: ((parsed as { notes?: RawNote[] })?.notes ?? []);
|
|
555
|
+
const out: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [];
|
|
556
|
+
for (const n of notes) {
|
|
557
|
+
if (typeof n.id === "string" && n.id) {
|
|
558
|
+
out.push({ id: n.id, content: n.content, metadata: n.metadata });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Fetch ONE note by id (for a created/updated reload). Null on 404/miss. */
|
|
565
|
+
async getNote(
|
|
566
|
+
id: string,
|
|
567
|
+
): Promise<{ id: string; content?: string; metadata?: Record<string, unknown> } | null> {
|
|
568
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}?include_content=true`;
|
|
569
|
+
const res = await this.fetchFn(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
570
|
+
if (res.status === 404) return null;
|
|
571
|
+
if (!res.ok) {
|
|
572
|
+
const detail = await res.text().catch(() => "");
|
|
573
|
+
throw new Error(`def-vault "${this.vault}": get note ${id} failed (${res.status}) ${detail}`.trim());
|
|
574
|
+
}
|
|
575
|
+
let parsed: unknown;
|
|
576
|
+
try {
|
|
577
|
+
parsed = await res.json();
|
|
578
|
+
} catch (err) {
|
|
579
|
+
throw new Error(`def-vault "${this.vault}": get note ${id} — bad JSON: ${(err as Error).message}`);
|
|
580
|
+
}
|
|
581
|
+
const n = (parsed ?? {}) as { id?: string; note?: { id?: string; content?: string; metadata?: Record<string, unknown> }; content?: string; metadata?: Record<string, unknown> };
|
|
582
|
+
const note = n.note ?? n;
|
|
583
|
+
if (typeof note.id !== "string" || !note.id) return null;
|
|
584
|
+
return { id: note.id, content: note.content, metadata: note.metadata };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Stamp the resolved status onto the def note's metadata. PATCH merges the changed
|
|
589
|
+
* fields (the vault merges metadata). `pending` is written as a comma-joined string
|
|
590
|
+
* when present (the vault stores metadata as strings) and CLEARED (empty string)
|
|
591
|
+
* otherwise, so a flip enabled→pending→enabled doesn't leave a stale list. Throws
|
|
592
|
+
* on a non-ok response; the caller logs + continues (status is best-effort — a
|
|
593
|
+
* failed stamp must not prevent the agent from running).
|
|
594
|
+
*/
|
|
595
|
+
async patchStatus(
|
|
596
|
+
noteId: string,
|
|
597
|
+
status: AgentDefStatus,
|
|
598
|
+
pending?: string[],
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
const metadata: Record<string, string> = { status };
|
|
601
|
+
// Always set `pending` (to the list, or empty) so it never goes stale across flips.
|
|
602
|
+
metadata.pending = pending && pending.length > 0 ? pending.join(", ") : "";
|
|
603
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
|
|
604
|
+
const res = await this.fetchFn(url, {
|
|
605
|
+
method: "PATCH",
|
|
606
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
607
|
+
// `force: true` satisfies the vault's mutation precondition (it 428s without
|
|
608
|
+
// `if_updated_at` or `force`). Safe: `status`/`pending` are the module's OWN
|
|
609
|
+
// authoritative derived fields, the body carries no content, and the vault
|
|
610
|
+
// MERGES metadata ({...existing, ...body.metadata}) so name/backend are kept.
|
|
611
|
+
// (Without this the status stamp silently 428'd — caught via live testing.)
|
|
612
|
+
body: JSON.stringify({ metadata, force: true }),
|
|
613
|
+
});
|
|
614
|
+
if (!res.ok) {
|
|
615
|
+
const detail = await res.text().catch(() => "");
|
|
616
|
+
throw new Error(`def-vault "${this.vault}": patch status ${noteId} failed (${res.status}) ${detail}`.trim());
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Create a `#agent/definition` note: body = the system prompt, metadata = the
|
|
622
|
+
* config, tagged the exact def tag (the same tag {@link listDefNotes} queries). The
|
|
623
|
+
* vault assigns the note id; we return the created note (id + content + metadata) so
|
|
624
|
+
* the caller can reload it into a live agent immediately. Throws on a non-ok vault
|
|
625
|
+
* response. The path defaults under `Agents/<name>` (a flat, predictable slug) so a
|
|
626
|
+
* vault surface groups them; the vault is free to relocate it.
|
|
627
|
+
*/
|
|
628
|
+
async createNote(args: {
|
|
629
|
+
content: string;
|
|
630
|
+
metadata: Record<string, string>;
|
|
631
|
+
path?: string;
|
|
632
|
+
}): Promise<{ id: string; content?: string; metadata?: Record<string, unknown> }> {
|
|
633
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes`;
|
|
634
|
+
const body: Record<string, unknown> = {
|
|
635
|
+
content: args.content,
|
|
636
|
+
tags: [AGENT_DEFINITION_TAG],
|
|
637
|
+
metadata: args.metadata,
|
|
638
|
+
};
|
|
639
|
+
if (args.path) body.path = args.path;
|
|
640
|
+
const res = await this.fetchFn(url, {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
643
|
+
body: JSON.stringify(body),
|
|
644
|
+
});
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
const detail = await res.text().catch(() => "");
|
|
647
|
+
throw new Error(`def-vault "${this.vault}": create def failed (${res.status}) ${detail}`.trim());
|
|
648
|
+
}
|
|
649
|
+
let parsed: unknown;
|
|
650
|
+
try {
|
|
651
|
+
parsed = await res.json();
|
|
652
|
+
} catch (err) {
|
|
653
|
+
throw new Error(`def-vault "${this.vault}": create def — bad JSON: ${(err as Error).message}`);
|
|
654
|
+
}
|
|
655
|
+
const n = (parsed ?? {}) as {
|
|
656
|
+
id?: string;
|
|
657
|
+
note?: { id?: string; content?: string; metadata?: Record<string, unknown> };
|
|
658
|
+
content?: string;
|
|
659
|
+
metadata?: Record<string, unknown>;
|
|
660
|
+
};
|
|
661
|
+
const note = n.note ?? n;
|
|
662
|
+
if (typeof note.id !== "string" || !note.id) {
|
|
663
|
+
throw new Error(`def-vault "${this.vault}": create def succeeded but response had no note id`);
|
|
664
|
+
}
|
|
665
|
+
return { id: note.id, content: note.content, metadata: note.metadata };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Edit an existing def note: update its body (system prompt) and/or merge metadata
|
|
670
|
+
* fields. `force: true` satisfies the vault's 428 mutation precondition (the module's
|
|
671
|
+
* own authoritative edit; the vault MERGES metadata so unspecified fields are kept).
|
|
672
|
+
* Only the provided fields are sent. Throws on a non-ok vault response.
|
|
673
|
+
*/
|
|
674
|
+
async patchNote(
|
|
675
|
+
noteId: string,
|
|
676
|
+
fields: { content?: string; metadata?: Record<string, string> },
|
|
677
|
+
): Promise<void> {
|
|
678
|
+
const body: Record<string, unknown> = { force: true };
|
|
679
|
+
if (fields.content !== undefined) body.content = fields.content;
|
|
680
|
+
if (fields.metadata !== undefined) body.metadata = fields.metadata;
|
|
681
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
|
|
682
|
+
const res = await this.fetchFn(url, {
|
|
683
|
+
method: "PATCH",
|
|
684
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
685
|
+
body: JSON.stringify(body),
|
|
686
|
+
});
|
|
687
|
+
if (!res.ok) {
|
|
688
|
+
const detail = await res.text().catch(() => "");
|
|
689
|
+
throw new Error(`def-vault "${this.vault}": patch def ${noteId} failed (${res.status}) ${detail}`.trim());
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Delete a def note by id. Throws on a non-ok vault response (404 IS ok — gone is gone). */
|
|
694
|
+
async deleteNote(noteId: string): Promise<void> {
|
|
695
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
|
|
696
|
+
const res = await this.fetchFn(url, {
|
|
697
|
+
method: "DELETE",
|
|
698
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
699
|
+
});
|
|
700
|
+
if (!res.ok && res.status !== 404) {
|
|
701
|
+
const detail = await res.text().catch(() => "");
|
|
702
|
+
throw new Error(`def-vault "${this.vault}": delete def ${noteId} failed (${res.status}) ${detail}`.trim());
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The side-effects the registry needs to bring a def to life, injected so the
|
|
709
|
+
* registry is unit-testable WITHOUT a daemon, a vault, a sandbox, or tmux.
|
|
710
|
+
*
|
|
711
|
+
* - {@link ensureChannel} — bring up (or replace) the vault channel for the agent's
|
|
712
|
+
* wake channel. The daemon wires this to `addChannelLive` with a vault
|
|
713
|
+
* `ChannelEntry` built from the def-vault binding (the SAME path create-agent +
|
|
714
|
+
* boot use). Awaited so the transport is live before we register the agent.
|
|
715
|
+
* - {@link setupAndRegister} — persist `spec.json` (so the existing boot
|
|
716
|
+
* re-register + per-turn deliver find the workspace) + register the programmatic
|
|
717
|
+
* agent. The daemon wires this to `setupProgrammaticSpawn` + `programmatic.register`.
|
|
718
|
+
* - {@link deregister} — tear an agent down by name (drop its programmatic
|
|
719
|
+
* registration). The daemon wires this to `programmatic.deregister`.
|
|
720
|
+
* - {@link removeChannel} — stop + drop the wake channel (on delete). The daemon
|
|
721
|
+
* wires this to `removeChannelLive`.
|
|
722
|
+
*/
|
|
723
|
+
export interface InstantiateDeps {
|
|
724
|
+
/** Bring up the vault channel for `name`, bound to `binding`. */
|
|
725
|
+
ensureChannel(name: string, binding: DefVaultBinding): Promise<void>;
|
|
726
|
+
/** Persist spec.json + register the programmatic agent for `spec`. */
|
|
727
|
+
setupAndRegister(spec: AgentSpec): Promise<void>;
|
|
728
|
+
/** Deregister the programmatic agent `name`. Returns whether one was registered. */
|
|
729
|
+
deregister(name: string): Promise<boolean>;
|
|
730
|
+
/** Stop + remove the wake channel `name`. Returns whether one existed. */
|
|
731
|
+
removeChannel(name: string): Promise<boolean>;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** The live record of an instantiated def (so a reload/delete can address it). */
|
|
735
|
+
interface LiveDef {
|
|
736
|
+
/** The def-vault this agent belongs to. */
|
|
737
|
+
vault: string;
|
|
738
|
+
/** The note id (the reload/delete key within a vault). */
|
|
739
|
+
noteId: string;
|
|
740
|
+
/** The agent name (= wake channel) — for channel/registry teardown. */
|
|
741
|
+
name: string;
|
|
742
|
+
/** The resolved status (for /health + observability). */
|
|
743
|
+
status: AgentDefStatus;
|
|
744
|
+
/** The agent backend the def selected (`programmatic` | `attached`). */
|
|
745
|
+
backend: AgentBackendKind;
|
|
746
|
+
/** The execution-lifecycle mode the def selected (`single-threaded` | `multi-threaded`). */
|
|
747
|
+
mode: AgentMode;
|
|
748
|
+
/** First ~200 chars of the system prompt (the note body) — a preview, NOT a secret. */
|
|
749
|
+
systemPromptPreview: string;
|
|
750
|
+
/** Declared connections still pending approval (the status `pending` list), if any. */
|
|
751
|
+
pending: string[];
|
|
752
|
+
/** Structured `wants:` connection keys (surfaced for the UI; never a secret). */
|
|
753
|
+
wants: string[];
|
|
754
|
+
/**
|
|
755
|
+
* Per-connection grant info (key, kind, target, hub grant status, grant id) — the
|
|
756
|
+
* source the connections/MCP panel renders + drives Connect from. One entry per
|
|
757
|
+
* declared `wants:` connection. Never a secret (status + id only, no token).
|
|
758
|
+
*/
|
|
759
|
+
connections: ConnectionInfo[];
|
|
760
|
+
/** The model the programmatic backend runs turns on (from `metadata.model`); unset = CC default. */
|
|
761
|
+
model?: string;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The detailed view of one live vault-native agent the `GET /api/agent-defs` route
|
|
766
|
+
* returns — everything a UI needs to render + edit it, NO secrets (no tokens). The
|
|
767
|
+
* channel == the agent name (agent ≡ channel); the vault is the def-vault.
|
|
768
|
+
*/
|
|
769
|
+
export interface AgentDefDetail {
|
|
770
|
+
/** The vault note id (the create/edit/delete key). */
|
|
771
|
+
noteId: string;
|
|
772
|
+
/** The agent name (= wake channel + spec name). */
|
|
773
|
+
name: string;
|
|
774
|
+
/** The agent backend (`programmatic` | `attached`). */
|
|
775
|
+
backend: AgentBackendKind;
|
|
776
|
+
/** The execution-lifecycle mode (`single-threaded` | `multi-threaded`). */
|
|
777
|
+
mode: AgentMode;
|
|
778
|
+
/** The def-vault this agent is defined in. */
|
|
779
|
+
vault: string;
|
|
780
|
+
/** The resolved liveness status (`enabled` | `pending` | `error`). */
|
|
781
|
+
status: AgentDefStatus;
|
|
782
|
+
/** Declared connections still pending approval (empty when none). */
|
|
783
|
+
pending: string[];
|
|
784
|
+
/** First ~200 chars of the system prompt (the note body) — a preview, NOT the full text. */
|
|
785
|
+
systemPromptPreview: string;
|
|
786
|
+
/** Structured `wants:` connection keys the agent declared (empty when own-vault only). */
|
|
787
|
+
wants: string[];
|
|
788
|
+
/**
|
|
789
|
+
* Per-connection grant info (key, kind, target, hub grant status, grant id) — the
|
|
790
|
+
* connections/MCP panel renders status pills + drives the cookie→hub Connect from
|
|
791
|
+
* this. Additive (a back-compat field; older clients ignore it). One entry per
|
|
792
|
+
* declared `wants:` connection. NO secrets (status + id, never a token).
|
|
793
|
+
*/
|
|
794
|
+
connections: ConnectionInfo[];
|
|
795
|
+
/** The model the programmatic backend runs turns on (e.g. `opus`); undefined = CC default. */
|
|
796
|
+
model?: string;
|
|
797
|
+
/** The wake channel inbound routes to this agent on (== name). */
|
|
798
|
+
channel: string;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** How many chars of the system prompt the detail preview surfaces. */
|
|
802
|
+
export const SYSTEM_PROMPT_PREVIEW_LEN = 200;
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* The FULL editable view of one live vault-native agent the `GET /api/agent-defs/<id>`
|
|
806
|
+
* route returns — everything the edit form needs to pre-fill, including the FULL system
|
|
807
|
+
* prompt (the whole note body, not the {@link AgentDefDetail} ~200-char preview). NO
|
|
808
|
+
* secrets (no tokens). The list endpoint deliberately returns only the preview (cheap +
|
|
809
|
+
* non-sensitive); this single-def fetch reads the note body fresh so an edit pre-fills
|
|
810
|
+
* the actual prompt rather than a truncation.
|
|
811
|
+
*/
|
|
812
|
+
export interface AgentDefFull {
|
|
813
|
+
/** The vault note id (the edit/delete key). */
|
|
814
|
+
noteId: string;
|
|
815
|
+
/** The agent name (= wake channel + spec name). */
|
|
816
|
+
name: string;
|
|
817
|
+
/** The agent backend (`programmatic` | `attached`). */
|
|
818
|
+
backend: AgentBackendKind;
|
|
819
|
+
/** The def-vault this agent is defined in. */
|
|
820
|
+
vault: string;
|
|
821
|
+
/** The execution-lifecycle mode (`single-threaded` | `multi-threaded`). */
|
|
822
|
+
mode: AgentMode;
|
|
823
|
+
/** Structured `wants:` connection keys the agent declared (empty when own-vault only). */
|
|
824
|
+
wants: string[];
|
|
825
|
+
/**
|
|
826
|
+
* Per-connection grant info (key, kind, target, hub grant status, grant id) — same
|
|
827
|
+
* additive field {@link AgentDefDetail} carries, so the edit view's connections panel
|
|
828
|
+
* can render status + drive Connect without a second fetch. NO secrets.
|
|
829
|
+
*/
|
|
830
|
+
connections: ConnectionInfo[];
|
|
831
|
+
/** The model the programmatic backend runs turns on (e.g. `opus`); undefined = CC default. */
|
|
832
|
+
model?: string;
|
|
833
|
+
/** The FULL system prompt — the whole note body (NOT truncated). */
|
|
834
|
+
systemPrompt: string;
|
|
835
|
+
/** The resolved liveness status (`enabled` | `pending` | `error`). */
|
|
836
|
+
status: AgentDefStatus;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The vault-native agent-def registry — reads `#agent/definition` notes from the
|
|
841
|
+
* configured def-vaults and keeps the live agent set in sync with them.
|
|
842
|
+
*
|
|
843
|
+
* Lifecycle (the design's reactive model):
|
|
844
|
+
* - {@link loadAll} (boot) — for each def-vault, list its defs + instantiate each.
|
|
845
|
+
* - {@link reload} (trigger / poll) — re-read ONE note: created/updated →
|
|
846
|
+
* re-instantiate; deleted (note gone) → deregister. Per-note granularity via the
|
|
847
|
+
* `vault + noteId → LiveDef` map.
|
|
848
|
+
* - {@link deregisterAllForVault} — drop a whole vault's agents (config change).
|
|
849
|
+
*
|
|
850
|
+
* Grant-GC (#96): the registry also keeps the hub's grant rows in sync with the live
|
|
851
|
+
* def set so a removed connection / a deleted def doesn't orphan an approved grant. On
|
|
852
|
+
* a CONFIDENT signal only — a clean successful instantiate (prune to the def's current
|
|
853
|
+
* `wants:` keys) or a CONFIRMED removal (deleted/404 → prune ALL) — it POSTs the hub's
|
|
854
|
+
* reconcile endpoint; a transient parse/list/fetch failure NEVER prunes (safety guard).
|
|
855
|
+
*
|
|
856
|
+
* Idempotent: re-instantiating the same name swaps the registration in place
|
|
857
|
+
* (`programmatic.register` + `addChannelLive` both replace-by-name), so an update is
|
|
858
|
+
* a clean re-instantiate, not a duplicate. A name collision ACROSS def-vaults (two
|
|
859
|
+
* vaults both defining `uni-dev`) is resolved last-writer-wins on the shared wake
|
|
860
|
+
* channel; we log it (the operator owns their vaults — 4a is own-box).
|
|
861
|
+
*/
|
|
862
|
+
export class AgentDefRegistry {
|
|
863
|
+
/** def-vault name → its client. */
|
|
864
|
+
private readonly clients = new Map<string, DefVaultClient>();
|
|
865
|
+
/** def-vault name → its binding (for `ensureChannel`). */
|
|
866
|
+
private readonly bindings = new Map<string, DefVaultBinding>();
|
|
867
|
+
/** `${vault}\u0000${noteId}` → the live record. */
|
|
868
|
+
private readonly live = new Map<string, LiveDef>();
|
|
869
|
+
/**
|
|
870
|
+
* Per-vault set of `#agent/definition` notes seen on the LAST CONFIDENT read —
|
|
871
|
+
* `noteId → agentName` — the prior-known set the removed-def diff (grant-GC, #96)
|
|
872
|
+
* compares against. ONLY mutated from a confident signal: a successful vault LIST
|
|
873
|
+
* (loadAll) or a confirmed single-note removal/instantiate (reload). A note's name is
|
|
874
|
+
* its parsed `metadata.name`; a present-but-parse-failing note KEEPS its last-known
|
|
875
|
+
* name (carry-forward) so a transient parse error never drops it from the tracked set
|
|
876
|
+
* — which would wrongly flag it as removed (safety guard, design "only prune from a
|
|
877
|
+
* confident live set"). Keyed `vault → (noteId → agentName)`.
|
|
878
|
+
*/
|
|
879
|
+
private readonly seenDefs = new Map<string, Map<string, string>>();
|
|
880
|
+
private readonly deps: InstantiateDeps;
|
|
881
|
+
/**
|
|
882
|
+
* The hub grants client (4b) — used to REGISTER each def's `wants:` connections as
|
|
883
|
+
* pending grants on instantiate + resolve status from the hub's grant statuses.
|
|
884
|
+
* Optional: null when the hub isn't provisioned yet (no manager bearer) — then the
|
|
885
|
+
* registry falls back to {@link resolveDefStatus} (pending if any connection is
|
|
886
|
+
* declared) and never registers, so the vault-native path still runs own-vault.
|
|
887
|
+
*/
|
|
888
|
+
private grants: GrantsClient | null;
|
|
889
|
+
|
|
890
|
+
constructor(
|
|
891
|
+
deps: InstantiateDeps,
|
|
892
|
+
opts?: { bindings?: DefVaultBinding[]; fetchFn?: typeof fetch; grants?: GrantsClient | null },
|
|
893
|
+
) {
|
|
894
|
+
this.deps = deps;
|
|
895
|
+
this.grants = opts?.grants ?? null;
|
|
896
|
+
for (const b of opts?.bindings ?? []) {
|
|
897
|
+
this.addVault(b, opts?.fetchFn);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Wire (or replace) the hub grants client — set once the manager bearer resolves
|
|
902
|
+
* at boot (the constructor runs before the operator token is read). */
|
|
903
|
+
setGrantsClient(grants: GrantsClient | null): void {
|
|
904
|
+
this.grants = grants;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Register a def-vault binding (additive — multi-vault is appending). */
|
|
908
|
+
addVault(binding: DefVaultBinding, fetchFn?: typeof fetch): void {
|
|
909
|
+
this.clients.set(binding.vault, new DefVaultClient(binding, fetchFn));
|
|
910
|
+
this.bindings.set(binding.vault, binding);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Remove a def-vault binding (the client + binding indexes). The caller
|
|
915
|
+
* ({@link deregisterAllForVault}) tears down the vault's live agents FIRST; this
|
|
916
|
+
* drops the registry's knowledge of the vault so a later `loadAll` no longer queries
|
|
917
|
+
* it. Idempotent. Does NOT touch the persisted `agent-vaults.json` — that's the
|
|
918
|
+
* daemon route's job (the registry has no file knowledge).
|
|
919
|
+
*/
|
|
920
|
+
removeVault(vault: string): void {
|
|
921
|
+
this.clients.delete(vault);
|
|
922
|
+
this.bindings.delete(vault);
|
|
923
|
+
this.seenDefs.delete(vault);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** The number of def-vaults bound (for /health + tests). */
|
|
927
|
+
get vaultCount(): number {
|
|
928
|
+
return this.clients.size;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** The sole bound def-vault's name, or undefined when not exactly one. Lets the
|
|
932
|
+
* reload webhook default `vault` when the install is single-vault (the common case). */
|
|
933
|
+
soleVaultName(): string | undefined {
|
|
934
|
+
if (this.clients.size !== 1) return undefined;
|
|
935
|
+
return [...this.clients.keys()][0];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The live instantiated defs (for /health + the agents list + tests). */
|
|
939
|
+
list(): ReadonlyArray<{ vault: string; noteId: string; name: string; status: AgentDefStatus }> {
|
|
940
|
+
return [...this.live.values()].map((d) => ({
|
|
941
|
+
vault: d.vault,
|
|
942
|
+
noteId: d.noteId,
|
|
943
|
+
name: d.name,
|
|
944
|
+
status: d.status,
|
|
945
|
+
}));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The live instantiated defs in the DETAILED `GET /api/agent-defs` shape — backend,
|
|
950
|
+
* vault, status, pending, the system-prompt PREVIEW (not the full body), wants, and
|
|
951
|
+
* the wake channel. NO secrets (no tokens). Sorted by name for a stable list.
|
|
952
|
+
*/
|
|
953
|
+
listDetailed(): AgentDefDetail[] {
|
|
954
|
+
return [...this.live.values()]
|
|
955
|
+
.map((d) => ({
|
|
956
|
+
noteId: d.noteId,
|
|
957
|
+
name: d.name,
|
|
958
|
+
backend: d.backend,
|
|
959
|
+
mode: d.mode,
|
|
960
|
+
vault: d.vault,
|
|
961
|
+
status: d.status,
|
|
962
|
+
pending: [...d.pending],
|
|
963
|
+
systemPromptPreview: d.systemPromptPreview,
|
|
964
|
+
wants: [...d.wants],
|
|
965
|
+
connections: d.connections.map((c) => ({ ...c })),
|
|
966
|
+
...(d.model ? { model: d.model } : {}),
|
|
967
|
+
channel: d.name, // agent ≡ channel.
|
|
968
|
+
}))
|
|
969
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Whether a def-vault by this name is configured (the write-path vault guard). */
|
|
973
|
+
hasVault(vault: string): boolean {
|
|
974
|
+
return this.clients.has(vault);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The configured def-vault names (for /api/agent-vaults + the write-path guard). */
|
|
978
|
+
vaultNames(): string[] {
|
|
979
|
+
return [...this.clients.keys()];
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The configured def-vault bindings VERBATIM (carrying their tokens) — for
|
|
984
|
+
* persisting the live set back to `agent-vaults.json` on an add (so a boot-minted
|
|
985
|
+
* default's real token is preserved, never clobbered to empty). INTERNAL: this
|
|
986
|
+
* carries SECRETS — never serialize it to the wire (the wire view is
|
|
987
|
+
* {@link vaultStatuses}). Returns copies so a caller can't mutate the registry's
|
|
988
|
+
* bindings in place.
|
|
989
|
+
*/
|
|
990
|
+
liveBindings(): DefVaultBinding[] {
|
|
991
|
+
return [...this.bindings.values()].map((b) => ({ ...b }));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* The configured def-vaults as a non-secret view — name + url + whether a token is
|
|
996
|
+
* present (NEVER the token VALUE). The `GET /api/agent-vaults` listing's source of
|
|
997
|
+
* truth (the live registry, not the on-disk file, so a boot-minted binding shows its
|
|
998
|
+
* token even before the file write lands). Sorted by name.
|
|
999
|
+
*/
|
|
1000
|
+
vaultStatuses(): Array<{ vault: string; url: string; tokenPresent: boolean }> {
|
|
1001
|
+
return [...this.bindings.values()]
|
|
1002
|
+
.map((b) => ({
|
|
1003
|
+
vault: b.vault,
|
|
1004
|
+
url: b.vaultUrl ?? DEFAULT_DEF_VAULT_URL,
|
|
1005
|
+
tokenPresent: typeof b.token === "string" && b.token.length > 0,
|
|
1006
|
+
}))
|
|
1007
|
+
.sort((a, b) => a.vault.localeCompare(b.vault));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Whether a note id is a CURRENTLY-LIVE def in a given vault — the write-path guard
|
|
1012
|
+
* for PATCH/DELETE so the routes only ever touch notes this module actually
|
|
1013
|
+
* instantiated as `#agent/definition` agents in a configured def-vault (not an
|
|
1014
|
+
* arbitrary note id an operator passes). Returns the live detail when it is, else
|
|
1015
|
+
* null.
|
|
1016
|
+
*/
|
|
1017
|
+
liveDef(vault: string, noteId: string): AgentDefDetail | null {
|
|
1018
|
+
const d = this.live.get(this.keyOf(vault, noteId));
|
|
1019
|
+
if (!d) return null;
|
|
1020
|
+
return {
|
|
1021
|
+
noteId: d.noteId,
|
|
1022
|
+
name: d.name,
|
|
1023
|
+
backend: d.backend,
|
|
1024
|
+
mode: d.mode,
|
|
1025
|
+
vault: d.vault,
|
|
1026
|
+
status: d.status,
|
|
1027
|
+
pending: [...d.pending],
|
|
1028
|
+
systemPromptPreview: d.systemPromptPreview,
|
|
1029
|
+
wants: [...d.wants],
|
|
1030
|
+
connections: d.connections.map((c) => ({ ...c })),
|
|
1031
|
+
...(d.model ? { model: d.model } : {}),
|
|
1032
|
+
channel: d.name,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Find a live def by note id across ALL configured vaults (PATCH/DELETE address
|
|
1037
|
+
* a note by id; the vault is resolved here). Returns the {vault, detail} or null.
|
|
1038
|
+
* AMBIGUITY GUARD (#106 review): if two configured def-vaults each vend a note at
|
|
1039
|
+
* the SAME id, picking the first match is non-deterministic — so throw a 409-class
|
|
1040
|
+
* {@link AgentDefWriteError} ("specify vault") rather than silently mutating one of
|
|
1041
|
+
* them. The single-match happy path is unchanged. */
|
|
1042
|
+
findLiveByNote(noteId: string): { vault: string; detail: AgentDefDetail } | null {
|
|
1043
|
+
const matches: string[] = [];
|
|
1044
|
+
for (const d of this.live.values()) {
|
|
1045
|
+
if (d.noteId === noteId) matches.push(d.vault);
|
|
1046
|
+
}
|
|
1047
|
+
if (matches.length === 0) return null;
|
|
1048
|
+
if (matches.length > 1) {
|
|
1049
|
+
throw new AgentDefWriteError(
|
|
1050
|
+
`note ${noteId} is a live agent definition in multiple def-vaults (${matches
|
|
1051
|
+
.sort()
|
|
1052
|
+
.join(", ")}); ambiguous note id across vaults — specify vault`,
|
|
1053
|
+
409,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
const vault = matches[0]!;
|
|
1057
|
+
return { vault, detail: this.liveDef(vault, noteId)! };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Fetch ONE live def's FULL editable view (the `GET /api/agent-defs/<id>` route) — the
|
|
1062
|
+
* same fields {@link liveDef} carries, but with the FULL system prompt read fresh from
|
|
1063
|
+
* the note body (the list/detail carries only the ~200-char preview, which can't pre-
|
|
1064
|
+
* fill an edit form). The note MUST be a currently-live def we instantiated in a
|
|
1065
|
+
* configured vault — same guard as the PATCH/DELETE write paths (resolves the vault via
|
|
1066
|
+
* {@link findLiveByNote}, so an unknown/non-def id → null and the route 404s; an
|
|
1067
|
+
* ambiguous-across-vaults id throws the 409-class {@link AgentDefWriteError}). NO
|
|
1068
|
+
* secrets — the body is the prompt, never a token. Returns null when the note isn't a
|
|
1069
|
+
* live def OR the vault no longer vends it (a delete that races the fetch).
|
|
1070
|
+
*/
|
|
1071
|
+
async getFullDef(noteId: string): Promise<AgentDefFull | null> {
|
|
1072
|
+
const found = this.findLiveByNote(noteId);
|
|
1073
|
+
if (!found) return null;
|
|
1074
|
+
const client = this.clients.get(found.vault);
|
|
1075
|
+
if (!client) return null;
|
|
1076
|
+
const note = await client.getNote(noteId);
|
|
1077
|
+
if (!note) return null;
|
|
1078
|
+
const detail = found.detail;
|
|
1079
|
+
return {
|
|
1080
|
+
noteId: detail.noteId,
|
|
1081
|
+
name: detail.name,
|
|
1082
|
+
backend: detail.backend,
|
|
1083
|
+
vault: detail.vault,
|
|
1084
|
+
mode: detail.mode,
|
|
1085
|
+
wants: [...detail.wants],
|
|
1086
|
+
connections: detail.connections.map((c) => ({ ...c })),
|
|
1087
|
+
...(detail.model ? { model: detail.model } : {}),
|
|
1088
|
+
systemPrompt: typeof note.content === "string" ? note.content : "",
|
|
1089
|
+
status: detail.status,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private keyOf(vault: string, noteId: string): string {
|
|
1094
|
+
return `${vault}\u0000${noteId}`;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Read all defs from every bound def-vault + instantiate each. Best-effort per
|
|
1099
|
+
* vault AND per note: a single vault's list failure (or one note's parse/instantiate
|
|
1100
|
+
* failure) is logged and never aborts the others, so one bad def can't sink the set.
|
|
1101
|
+
* Returns the count successfully instantiated.
|
|
1102
|
+
*
|
|
1103
|
+
* Removed-def convergence: after a CONFIDENT read (a successful, non-truncated list of
|
|
1104
|
+
* the vault's whole def set), diff the prior-known def set against it — any note that
|
|
1105
|
+
* was present and is now GONE has had its `#agent/definition` note deleted, so the agent
|
|
1106
|
+
* is TORN DOWN (deregistered + wake channel removed) and its grants pruned ALL
|
|
1107
|
+
* (`reconcileGrants(agent, [])`). This is the ONLY automatic path for a deletion — there
|
|
1108
|
+
* is no vault `deleted` trigger (see {@link pruneRemovedDefs}). Two guards keep the
|
|
1109
|
+
* teardown safe: a list FAILURE skips the diff (we `continue` BEFORE touching the prior
|
|
1110
|
+
* set) and a TRUNCATED list (>= the page cap) skips it too, so neither a transient vault
|
|
1111
|
+
* outage nor a partial page presents an under-set that wrongly tears down live agents.
|
|
1112
|
+
*/
|
|
1113
|
+
async loadAll(): Promise<number> {
|
|
1114
|
+
let count = 0;
|
|
1115
|
+
for (const [vault, client] of this.clients) {
|
|
1116
|
+
let notes: Awaited<ReturnType<DefVaultClient["listDefNotes"]>>;
|
|
1117
|
+
try {
|
|
1118
|
+
notes = await client.listDefNotes({ limit: DEF_LIST_LIMIT });
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.error(`agent-defs: listing defs from vault "${vault}" failed (continuing): ${(err as Error).message}`);
|
|
1121
|
+
// CONFIDENT-SET GUARD: a failed list is NOT a confident read — leave the prior
|
|
1122
|
+
// seen set untouched (no removed-def diff) so a hub/vault blip can't prune grants.
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
// TRUNCATION GUARD (the second way a read is non-confident): a list at the page cap
|
|
1126
|
+
// may be partial. The removed-def diff now performs a DESTRUCTIVE teardown
|
|
1127
|
+
// (pruneRemovedDefs deregisters), so a truncated read that omits the tail must NOT be
|
|
1128
|
+
// mistaken for deletions. Skip the diff + the seen-set rebuild (rebuilding from a
|
|
1129
|
+
// truncated list would drop the omitted tail and mis-flag it removed next pass); still
|
|
1130
|
+
// (re)instantiate what we got — instantiate only adds/updates, never tears down.
|
|
1131
|
+
// Practically unreachable at today's agent counts; the guard makes the teardown safe
|
|
1132
|
+
// by construction.
|
|
1133
|
+
// `< cap` ⇒ the result fit on one page → it cannot be truncated; `>= cap` is the
|
|
1134
|
+
// (possibly-)truncated case the `else` defers.
|
|
1135
|
+
const confident = notes.length < DEF_LIST_LIMIT;
|
|
1136
|
+
if (confident) {
|
|
1137
|
+
// Detect removed defs by diffing the prior seen set (noteId→name) against the ids
|
|
1138
|
+
// present now, BEFORE we mutate it.
|
|
1139
|
+
const presentIds = new Set(notes.map((n) => n.id));
|
|
1140
|
+
await this.pruneRemovedDefs(vault, presentIds);
|
|
1141
|
+
// Rebuild the seen set from this confident read (carry-forward last-known names for
|
|
1142
|
+
// notes that fail to parse, so a transient parse error doesn't drop them).
|
|
1143
|
+
this.rebuildSeenDefs(vault, notes);
|
|
1144
|
+
} else {
|
|
1145
|
+
console.warn(
|
|
1146
|
+
`agent-defs: def list for "${vault}" returned ${notes.length} notes (>= the ${DEF_LIST_LIMIT} ` +
|
|
1147
|
+
`page cap) — skipping the removed-def reconcile this pass to avoid a truncated-read teardown.`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
for (const note of notes) {
|
|
1151
|
+
if (await this.instantiate(vault, note)) count++;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return count;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Reconcile every def that was in the prior seen set for `vault` but is NOT in
|
|
1159
|
+
* `presentIds` (its note was deleted) — tear the agent DOWN (drop the live
|
|
1160
|
+
* programmatic registration + the wake channel) AND `reconcileGrants(agent, [])`
|
|
1161
|
+
* prune ALL its grants. Best-effort throughout; grant cleanup is a no-op without a
|
|
1162
|
+
* grants client. Called ONLY with a confident current id set (a successful,
|
|
1163
|
+
* non-truncated list); never on a list failure or a truncated read (see {@link loadAll}).
|
|
1164
|
+
*
|
|
1165
|
+
* Why the poll MUST deregister (not just prune grants): there is NO vault `deleted`
|
|
1166
|
+
* trigger — the hub's connection engine maps only `note.created`/`note.updated` to
|
|
1167
|
+
* vault-trigger verbs (parachute-hub `admin-connections` `eventToVaultEvents`), so a
|
|
1168
|
+
* def deleted out-of-band NEVER fires the reactive `reload(...,"deleted")` teardown.
|
|
1169
|
+
* This poll is the ONLY automatic convergence path for a deletion, so it must do the
|
|
1170
|
+
* SAME full teardown {@link confirmedRemoval} does, or a deleted agent stays live (an
|
|
1171
|
+
* orphan: gone from the vault, still answering messages) until the daemon restarts.
|
|
1172
|
+
*/
|
|
1173
|
+
private async pruneRemovedDefs(vault: string, presentIds: Set<string>): Promise<void> {
|
|
1174
|
+
const prior = this.seenDefs.get(vault);
|
|
1175
|
+
if (!prior) return; // first confident read of this vault — nothing to compare against.
|
|
1176
|
+
for (const [noteId, name] of prior) {
|
|
1177
|
+
if (presentIds.has(noteId)) continue; // still present — not a removal.
|
|
1178
|
+
// Confirmed removal: the def note is gone from a confident vault read. Tear the
|
|
1179
|
+
// agent + wake channel down, then prune its grants. (The seen-set entry is cleared
|
|
1180
|
+
// by the `rebuildSeenDefs` that runs right after this in `loadAll`.)
|
|
1181
|
+
await this.deregisterByNote(vault, noteId);
|
|
1182
|
+
await this.reconcileForRemovedAgent(name);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Rebuild the per-vault seen set from a confident list. Each present note maps
|
|
1188
|
+
* noteId→its parsed `metadata.name`; a note that fails to parse keeps its prior
|
|
1189
|
+
* last-known name (so a transient parse error doesn't drop it from the tracked set
|
|
1190
|
+
* and wrongly flag it removed next pass). A note that never had a name (parse-failed
|
|
1191
|
+
* on first sight) is tracked id-only (empty name) so it isn't re-detected as removed.
|
|
1192
|
+
*/
|
|
1193
|
+
private rebuildSeenDefs(
|
|
1194
|
+
vault: string,
|
|
1195
|
+
notes: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>,
|
|
1196
|
+
): void {
|
|
1197
|
+
const prior = this.seenDefs.get(vault);
|
|
1198
|
+
const next = new Map<string, string>();
|
|
1199
|
+
for (const note of notes) {
|
|
1200
|
+
const name = nameOfDefNote(note) ?? prior?.get(note.id) ?? "";
|
|
1201
|
+
next.set(note.id, name);
|
|
1202
|
+
}
|
|
1203
|
+
this.seenDefs.set(vault, next);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Reconcile a CONFIRMED-removed agent's grants away (prune ALL). Best-effort + no-op
|
|
1208
|
+
* without a grants client / without a known name.
|
|
1209
|
+
*
|
|
1210
|
+
* FIX 5 (PR #3) — make the failure NON-SILENT. A hub-unreachable reconcile used to be
|
|
1211
|
+
* caught + logged + ignored, ORPHANING the agent's approved grants on the hub (a
|
|
1212
|
+
* re-created same-named agent resurrects them). It's still BEST-EFFORT (we don't block
|
|
1213
|
+
* the note delete on grant cleanup — the def IS gone), but we now (a) `console.warn`
|
|
1214
|
+
* loudly AND (b) RETURN a structured signal so the caller can surface a PARTIAL success
|
|
1215
|
+
* (delete succeeded, grant cleanup didn't) rather than claiming a clean full success.
|
|
1216
|
+
* `skipped` = no grants client / no name (nothing to reconcile — a true no-op).
|
|
1217
|
+
*/
|
|
1218
|
+
private async reconcileForRemovedAgent(
|
|
1219
|
+
name: string,
|
|
1220
|
+
): Promise<{ ok: true; pruned: number } | { ok: false; error: string } | { skipped: true }> {
|
|
1221
|
+
if (!this.grants || !name) return { skipped: true };
|
|
1222
|
+
try {
|
|
1223
|
+
const { pruned } = await this.grants.reconcileGrants(name, []);
|
|
1224
|
+
if (pruned > 0) {
|
|
1225
|
+
console.log(`agent-defs: pruned ${pruned} stale grant(s) for removed agent "${name}".`);
|
|
1226
|
+
}
|
|
1227
|
+
return { ok: true, pruned };
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
const error = (err as Error).message;
|
|
1230
|
+
// NON-SILENT (FIX 5): a swallowed grant-GC failure orphans approved grants on the
|
|
1231
|
+
// hub. Warn loudly + return the failure so the delete path reports partial success.
|
|
1232
|
+
console.warn(
|
|
1233
|
+
`agent-defs: pruning grants for removed agent "${name}" FAILED — its approved hub ` +
|
|
1234
|
+
`grants may be ORPHANED (re-creating a same-named agent would resurrect them); ` +
|
|
1235
|
+
`the note delete still completed (best-effort grant cleanup): ${error}`,
|
|
1236
|
+
);
|
|
1237
|
+
return { ok: false, error };
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Reload ONE def by note id (the reactive path — a vault trigger / poll says this
|
|
1243
|
+
* note changed). Re-reads the note from its vault: present → (re)instantiate;
|
|
1244
|
+
* absent (deleted) → deregister. `event` is a hint from the trigger
|
|
1245
|
+
* (`created`/`updated`/`deleted`); we still re-read so a stale/racing event
|
|
1246
|
+
* resolves to ground truth (a "created" that was since deleted tears down, not up).
|
|
1247
|
+
*
|
|
1248
|
+
* Returns the resulting state so the webhook can ack meaningfully.
|
|
1249
|
+
*/
|
|
1250
|
+
async reload(
|
|
1251
|
+
vault: string,
|
|
1252
|
+
noteId: string,
|
|
1253
|
+
event?: "created" | "updated" | "deleted",
|
|
1254
|
+
): Promise<"instantiated" | "deregistered" | "skipped"> {
|
|
1255
|
+
const client = this.clients.get(vault);
|
|
1256
|
+
if (!client) {
|
|
1257
|
+
console.warn(`agent-defs: reload for unknown def-vault "${vault}" — ignoring.`);
|
|
1258
|
+
return "skipped";
|
|
1259
|
+
}
|
|
1260
|
+
// A delete event: the note is gone — tear down without a fetch (the GET would 404
|
|
1261
|
+
// anyway; skipping it is faster + avoids a confusing 404 log). A delete is a
|
|
1262
|
+
// CONFIRMED removal → prune the agent's grants (#96).
|
|
1263
|
+
if (event === "deleted") {
|
|
1264
|
+
await this.confirmedRemoval(vault, noteId);
|
|
1265
|
+
return "deregistered";
|
|
1266
|
+
}
|
|
1267
|
+
let note: Awaited<ReturnType<DefVaultClient["getNote"]>>;
|
|
1268
|
+
try {
|
|
1269
|
+
note = await client.getNote(noteId);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
console.error(`agent-defs: reload fetch of ${noteId} from "${vault}" failed: ${(err as Error).message}`);
|
|
1272
|
+
// A fetch FAILURE is NOT a confirmed removal — skip without pruning grants (safety
|
|
1273
|
+
// guard: never prune from an inconclusive read). The agent + grants stay intact.
|
|
1274
|
+
return "skipped";
|
|
1275
|
+
}
|
|
1276
|
+
if (!note) {
|
|
1277
|
+
// Re-read 404 says it's gone (deleted, or no longer carries the def tag we can
|
|
1278
|
+
// see) — a CONFIRMED removal → prune the agent's grants (#96).
|
|
1279
|
+
await this.confirmedRemoval(vault, noteId);
|
|
1280
|
+
return "deregistered";
|
|
1281
|
+
}
|
|
1282
|
+
return (await this.instantiate(vault, note)) ? "instantiated" : "skipped";
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// ---------------------------------------------------------------------------
|
|
1286
|
+
// Def write path (the v2 API layer) — create / edit / delete a `#agent/definition`
|
|
1287
|
+
// note in a configured def-vault, then reload it into a LIVE agent immediately (the
|
|
1288
|
+
// per-note reload, NOT the 60s poll). The daemon owns the def-vault write token
|
|
1289
|
+
// (def-vaults.ts); these methods drive its client. Validation is the registry's job
|
|
1290
|
+
// (vault configured, name slug, backend valid) so the daemon route stays thin.
|
|
1291
|
+
// ---------------------------------------------------------------------------
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Create a new `#agent/definition` note in `vault` (body = system prompt, metadata =
|
|
1295
|
+
* name/backend/wants/extra), then reload it so the agent is LIVE immediately (no
|
|
1296
|
+
* wait for the trigger or the poll). Returns the created def in the {@link
|
|
1297
|
+
* AgentDefDetail} shape. Throws {@link AgentDefWriteError} on a validation failure
|
|
1298
|
+
* (unknown vault, bad name, bad backend) or a write/reload failure.
|
|
1299
|
+
*/
|
|
1300
|
+
async createDef(args: {
|
|
1301
|
+
vault: string;
|
|
1302
|
+
name: string;
|
|
1303
|
+
backend: AgentBackendKind;
|
|
1304
|
+
systemPrompt: string;
|
|
1305
|
+
wants?: string;
|
|
1306
|
+
metadata?: Record<string, string>;
|
|
1307
|
+
}): Promise<AgentDefDetail> {
|
|
1308
|
+
const client = this.clients.get(args.vault);
|
|
1309
|
+
if (!client) {
|
|
1310
|
+
throw new AgentDefWriteError(`unknown def-vault "${args.vault}" (configure it first)`, 400);
|
|
1311
|
+
}
|
|
1312
|
+
if (!NAME_SLUG_RE.test(args.name)) {
|
|
1313
|
+
throw new AgentDefWriteError(
|
|
1314
|
+
`name "${args.name}" must be a slug (alphanumeric, dash, underscore)`,
|
|
1315
|
+
400,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
// DUAL-READ the legacy backend value `"channel"` → canonical `"attached"`, so a
|
|
1319
|
+
// caller (or a hand-driven API client) passing the pre-rename value still WRITES the
|
|
1320
|
+
// canonical value. The routing key `channel` is a separate concept, unchanged.
|
|
1321
|
+
const backend: AgentBackendKind =
|
|
1322
|
+
(args.backend as string) === "channel" ? "attached" : args.backend;
|
|
1323
|
+
if (backend !== "programmatic" && backend !== "attached") {
|
|
1324
|
+
throw new AgentDefWriteError(`backend must be "programmatic" or "attached"`, 400);
|
|
1325
|
+
}
|
|
1326
|
+
// A name collision with a live def (in ANY vault — the wake channel is shared) would
|
|
1327
|
+
// resurrect last-writer-wins on the channel; reject up front for a clean error.
|
|
1328
|
+
for (const d of this.live.values()) {
|
|
1329
|
+
if (d.name === args.name) {
|
|
1330
|
+
throw new AgentDefWriteError(
|
|
1331
|
+
`an agent named "${args.name}" already exists (note ${d.noteId} in "${d.vault}")`,
|
|
1332
|
+
409,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const metadata = this.buildDefMetadata({ ...args, backend });
|
|
1337
|
+
const created = await client.createNote({
|
|
1338
|
+
content: args.systemPrompt,
|
|
1339
|
+
metadata,
|
|
1340
|
+
path: `Agents/${args.name}`,
|
|
1341
|
+
});
|
|
1342
|
+
// Reload the just-created note → instantiate it LIVE now (the immediate path).
|
|
1343
|
+
await this.reload(args.vault, created.id, "created");
|
|
1344
|
+
const detail = this.liveDef(args.vault, created.id);
|
|
1345
|
+
if (!detail) {
|
|
1346
|
+
// Instantiation didn't take (a parse/instantiate failure stamps status:error on
|
|
1347
|
+
// the note + returns false). Surface that the note was written but isn't live.
|
|
1348
|
+
throw new AgentDefWriteError(
|
|
1349
|
+
`def note ${created.id} written to "${args.vault}" but failed to instantiate ` +
|
|
1350
|
+
`(check the note's status field for the error)`,
|
|
1351
|
+
502,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
return detail;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Edit an existing live def note (body and/or metadata), then reload it so the change
|
|
1359
|
+
* is LIVE immediately. The note MUST be a currently-live def we instantiated in a
|
|
1360
|
+
* configured vault (the daemon resolves the vault; we re-guard here). Returns the
|
|
1361
|
+
* updated detail. Throws {@link AgentDefWriteError} on a miss or a write/reload failure.
|
|
1362
|
+
*/
|
|
1363
|
+
async editDef(
|
|
1364
|
+
noteId: string,
|
|
1365
|
+
fields: { systemPrompt?: string; wants?: string; metadata?: Record<string, string> },
|
|
1366
|
+
): Promise<AgentDefDetail> {
|
|
1367
|
+
const found = this.findLiveByNote(noteId);
|
|
1368
|
+
if (!found) {
|
|
1369
|
+
throw new AgentDefWriteError(`note ${noteId} is not a live agent definition`, 404);
|
|
1370
|
+
}
|
|
1371
|
+
const client = this.clients.get(found.vault);
|
|
1372
|
+
if (!client) {
|
|
1373
|
+
throw new AgentDefWriteError(`unknown def-vault "${found.vault}"`, 400);
|
|
1374
|
+
}
|
|
1375
|
+
const patch: { content?: string; metadata?: Record<string, string> } = {};
|
|
1376
|
+
if (fields.systemPrompt !== undefined) patch.content = fields.systemPrompt;
|
|
1377
|
+
const metadata: Record<string, string> = { ...(fields.metadata ?? {}) };
|
|
1378
|
+
if (fields.wants !== undefined) metadata.wants = fields.wants;
|
|
1379
|
+
if (Object.keys(metadata).length > 0) patch.metadata = metadata;
|
|
1380
|
+
if (patch.content === undefined && patch.metadata === undefined) {
|
|
1381
|
+
throw new AgentDefWriteError(`nothing to edit (provide systemPrompt, wants, or metadata)`, 400);
|
|
1382
|
+
}
|
|
1383
|
+
await client.patchNote(noteId, patch);
|
|
1384
|
+
await this.reload(found.vault, noteId, "updated");
|
|
1385
|
+
const detail = this.liveDef(found.vault, noteId);
|
|
1386
|
+
if (!detail) {
|
|
1387
|
+
throw new AgentDefWriteError(
|
|
1388
|
+
`def note ${noteId} edited but failed to re-instantiate (check the note's status field)`,
|
|
1389
|
+
502,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
return detail;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Delete a live def note, then deregister the agent immediately. The note MUST be a
|
|
1397
|
+
* currently-live def we instantiated. Returns the (vault, name) of what was removed,
|
|
1398
|
+
* plus a `grantsReconciled` flag (FIX 5, PR #3) — `false` when the best-effort grant
|
|
1399
|
+
* cleanup FAILED so the caller can report a PARTIAL success rather than a clean one.
|
|
1400
|
+
*
|
|
1401
|
+
* ORDERING (FIX 4, PR #3) — the VAULT NOTE DELETE happens FIRST; only after it
|
|
1402
|
+
* SUCCEEDS do we deregister the live agent. So a vault-delete 502 throws here BEFORE
|
|
1403
|
+
* any in-memory teardown — the def stays REGISTERED (it reappears coherently on the
|
|
1404
|
+
* next poll), never orphaned (gone from memory but still in the vault, the confusing
|
|
1405
|
+
* half-state). This mirrors the `agent-vaults` removal path's "persist the durable
|
|
1406
|
+
* change first, then tear down in-memory state" discipline (daemon.ts #106). Throws
|
|
1407
|
+
* {@link AgentDefWriteError} on a miss; a vault-delete failure throws (un-torn-down).
|
|
1408
|
+
*/
|
|
1409
|
+
async deleteDef(
|
|
1410
|
+
noteId: string,
|
|
1411
|
+
): Promise<{ vault: string; name: string; grantsReconciled: boolean }> {
|
|
1412
|
+
const found = this.findLiveByNote(noteId);
|
|
1413
|
+
if (!found) {
|
|
1414
|
+
throw new AgentDefWriteError(`note ${noteId} is not a live agent definition`, 404);
|
|
1415
|
+
}
|
|
1416
|
+
const client = this.clients.get(found.vault);
|
|
1417
|
+
if (!client) {
|
|
1418
|
+
throw new AgentDefWriteError(`unknown def-vault "${found.vault}"`, 400);
|
|
1419
|
+
}
|
|
1420
|
+
// STEP 1 — delete the vault note FIRST (the durable change). A non-ok (non-404)
|
|
1421
|
+
// response throws out of here, BEFORE any deregister, so the in-memory def is left
|
|
1422
|
+
// intact (FIX 4): no orphan, the next poll re-converges. (404 is fine — gone is gone.)
|
|
1423
|
+
await client.deleteNote(noteId);
|
|
1424
|
+
// STEP 2 — the note is gone → tear the agent down + prune grants (the confirmed-
|
|
1425
|
+
// removal path). Capture the grant-reconcile outcome to surface a partial success.
|
|
1426
|
+
const reconcile = await this.confirmedRemoval(found.vault, noteId);
|
|
1427
|
+
const grantsReconciled = !("ok" in reconcile) || reconcile.ok === true;
|
|
1428
|
+
return { vault: found.vault, name: found.detail.name, grantsReconciled };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Build the metadata for a created/edited def note from the API inputs. `name` +
|
|
1433
|
+
* `backend` are the load-bearing config; `wants` is the comma-separated connection
|
|
1434
|
+
* list (omitted when empty); any extra `metadata` the caller passes is merged FIRST
|
|
1435
|
+
* so the explicit name/backend/wants win (the route can't override the validated
|
|
1436
|
+
* name/backend via the metadata bag). NEVER carries a token/secret — secrets stay
|
|
1437
|
+
* local (the parse path never reads creds off a note).
|
|
1438
|
+
*/
|
|
1439
|
+
private buildDefMetadata(args: {
|
|
1440
|
+
name: string;
|
|
1441
|
+
backend: AgentBackendKind;
|
|
1442
|
+
wants?: string;
|
|
1443
|
+
metadata?: Record<string, string>;
|
|
1444
|
+
}): Record<string, string> {
|
|
1445
|
+
const metadata: Record<string, string> = { ...(args.metadata ?? {}) };
|
|
1446
|
+
metadata.name = args.name;
|
|
1447
|
+
metadata.backend = args.backend;
|
|
1448
|
+
if (args.wants !== undefined && args.wants.trim().length > 0) {
|
|
1449
|
+
metadata.wants = args.wants;
|
|
1450
|
+
}
|
|
1451
|
+
return metadata;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* A CONFIRMED removed def (a `deleted` trigger, or a re-read 404): tear the agent
|
|
1456
|
+
* down AND prune ALL its grants (#96 grant-GC) so a deleted `#agent/definition` note
|
|
1457
|
+
* doesn't orphan live approved rows. The seen-set entry is cleared so a later loadAll
|
|
1458
|
+
* doesn't re-detect (and re-prune) the same removal. Reconcile is best-effort.
|
|
1459
|
+
*
|
|
1460
|
+
* Returns the grant-reconcile outcome (FIX 5, PR #3) so the API delete path can report
|
|
1461
|
+
* a PARTIAL success when grant cleanup failed (delete done, grants possibly orphaned).
|
|
1462
|
+
*/
|
|
1463
|
+
private async confirmedRemoval(
|
|
1464
|
+
vault: string,
|
|
1465
|
+
noteId: string,
|
|
1466
|
+
): Promise<{ ok: true; pruned: number } | { ok: false; error: string } | { skipped: true }> {
|
|
1467
|
+
// The grant holder name comes from the live record if present, else the last-known
|
|
1468
|
+
// name we tracked for this note (a def removed before it ever instantiated).
|
|
1469
|
+
const name =
|
|
1470
|
+
this.live.get(this.keyOf(vault, noteId))?.name ?? this.seenDefs.get(vault)?.get(noteId);
|
|
1471
|
+
await this.deregisterByNote(vault, noteId);
|
|
1472
|
+
this.seenDefs.get(vault)?.delete(noteId);
|
|
1473
|
+
if (!name) return { skipped: true };
|
|
1474
|
+
return this.reconcileForRemovedAgent(name);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Instantiate (or re-instantiate) one def note: parse → bring up the channel →
|
|
1479
|
+
* persist+register the agent → stamp status. Returns true on success. A parse
|
|
1480
|
+
* failure stamps `error` (so the note surfaces the problem) and returns false; an
|
|
1481
|
+
* instantiate failure is logged + returns false (the prior registration, if any,
|
|
1482
|
+
* is left intact — we don't tear down a working agent on a transient failure).
|
|
1483
|
+
*/
|
|
1484
|
+
private async instantiate(
|
|
1485
|
+
vault: string,
|
|
1486
|
+
note: { id: string; content?: string; metadata?: Record<string, unknown> },
|
|
1487
|
+
): Promise<boolean> {
|
|
1488
|
+
const binding = this.bindings.get(vault);
|
|
1489
|
+
const client = this.clients.get(vault);
|
|
1490
|
+
if (!binding || !client) return false;
|
|
1491
|
+
|
|
1492
|
+
let def: ParsedAgentDef;
|
|
1493
|
+
try {
|
|
1494
|
+
def = parseAgentDef(note, { vault });
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
console.error(`agent-defs: skipping malformed def ${note.id} in "${vault}": ${(err as Error).message}`);
|
|
1497
|
+
// Best-effort: surface the problem on the note itself.
|
|
1498
|
+
await client.patchStatus(note.id, "error").catch(() => {});
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
try {
|
|
1503
|
+
await this.deps.ensureChannel(def.name, binding);
|
|
1504
|
+
await this.deps.setupAndRegister(def.spec);
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
console.error(`agent-defs: instantiating "${def.name}" (${note.id} in "${vault}") failed: ${(err as Error).message}`);
|
|
1507
|
+
await client.patchStatus(note.id, "error").catch(() => {});
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Resolve status. 4b: when a grants client is wired AND the def declares `wants:`
|
|
1512
|
+
// connections, REGISTER each as a pending grant with the hub + derive status from
|
|
1513
|
+
// the hub's grant statuses (`enabled` only once every connection is approved).
|
|
1514
|
+
// Otherwise fall back to the pure {@link resolveDefStatus} (pending if anything is
|
|
1515
|
+
// declared, enabled if nothing is). Either way the agent ALREADY ran its own-vault
|
|
1516
|
+
// setup above — an unapproved connection is absent at spawn, never a failure here.
|
|
1517
|
+
const { status, pending, connections } = await this.resolveStatusWithGrants(def);
|
|
1518
|
+
const fullPrompt = def.spec.systemPrompt ?? "";
|
|
1519
|
+
const systemPromptPreview =
|
|
1520
|
+
fullPrompt.length > SYSTEM_PROMPT_PREVIEW_LEN
|
|
1521
|
+
? fullPrompt.slice(0, SYSTEM_PROMPT_PREVIEW_LEN)
|
|
1522
|
+
: fullPrompt;
|
|
1523
|
+
this.live.set(this.keyOf(vault, note.id), {
|
|
1524
|
+
vault,
|
|
1525
|
+
noteId: note.id,
|
|
1526
|
+
name: def.name,
|
|
1527
|
+
status,
|
|
1528
|
+
backend: def.spec.backend ?? "programmatic",
|
|
1529
|
+
mode: def.spec.mode ?? "single-threaded",
|
|
1530
|
+
systemPromptPreview,
|
|
1531
|
+
pending: pending ?? [],
|
|
1532
|
+
wants: def.wants.map((c) => connectionKey(c)),
|
|
1533
|
+
connections,
|
|
1534
|
+
...(def.spec.model ? { model: def.spec.model } : {}),
|
|
1535
|
+
});
|
|
1536
|
+
// Track this note in the per-vault seen set (a confident, freshly-parsed read) so the
|
|
1537
|
+
// removed-def diff (loadAll) and the reload-delete path both address it by name. This
|
|
1538
|
+
// covers the reload single-note path where loadAll's rebuild didn't run.
|
|
1539
|
+
this.recordSeen(vault, note.id, def.name);
|
|
1540
|
+
// Stamp status — best-effort: a failed stamp doesn't unmake the running agent.
|
|
1541
|
+
try {
|
|
1542
|
+
await client.patchStatus(note.id, status, pending);
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
console.warn(`agent-defs: status stamp for "${def.name}" failed (continuing): ${(err as Error).message}`);
|
|
1545
|
+
}
|
|
1546
|
+
// Grant-GC (#96): a CLEAN successful load is a confident live set, so prune any grant
|
|
1547
|
+
// the agent no longer declares — e.g. a `wants:` entry removed from the def. We send
|
|
1548
|
+
// the CURRENTLY-declared connection SPECS; the hub re-derives the keys with its own
|
|
1549
|
+
// connectionKey. SAFETY: only reached AFTER a successful parse + instantiate; a
|
|
1550
|
+
// parse/instantiate failure returns above WITHOUT reconciling, so a transient error
|
|
1551
|
+
// never presents a stale/empty live set that nukes approved grants.
|
|
1552
|
+
await this.reconcileLiveKeys(def);
|
|
1553
|
+
console.log(`agent-defs: instantiated "${def.name}" from ${note.id} in "${vault}" (status=${status}).`);
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** Record a note in the per-vault seen set (noteId → agent name) — a confident read. */
|
|
1558
|
+
private recordSeen(vault: string, noteId: string, name: string): void {
|
|
1559
|
+
let m = this.seenDefs.get(vault);
|
|
1560
|
+
if (!m) {
|
|
1561
|
+
m = new Map<string, string>();
|
|
1562
|
+
this.seenDefs.set(vault, m);
|
|
1563
|
+
}
|
|
1564
|
+
m.set(noteId, name);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Prune the agent's grants down to its CURRENTLY-declared connections (#96 grant-GC,
|
|
1569
|
+
* the clean-load case). POSTs reconcile with the live connection SPECS (`def.wants`);
|
|
1570
|
+
* the hub re-derives each key with its own connectionKey and tears down + removes every
|
|
1571
|
+
* grant NOT in that set (e.g. a removed want). A def with no `wants:` sends an empty
|
|
1572
|
+
* set, which prunes any leftover grant from a prior `wants:` it no longer declares.
|
|
1573
|
+
* Best-effort: no grants client → no-op; a reconcile failure logs a warning and never
|
|
1574
|
+
* throws out of the load path.
|
|
1575
|
+
*/
|
|
1576
|
+
private async reconcileLiveKeys(def: ParsedAgentDef): Promise<void> {
|
|
1577
|
+
if (!this.grants) return;
|
|
1578
|
+
// Pass the live connection SPECS (def.wants) — the hub derives the keys with
|
|
1579
|
+
// its own connectionKey. (Sending keys we computed via grants.ts connectionKey
|
|
1580
|
+
// would diverge from the hub's for service/tagged/mcp grants → wrong prunes.)
|
|
1581
|
+
try {
|
|
1582
|
+
const { pruned } = await this.grants.reconcileGrants(def.name, def.wants);
|
|
1583
|
+
if (pruned > 0) {
|
|
1584
|
+
console.log(`agent-defs: pruned ${pruned} stale grant(s) for "${def.name}".`);
|
|
1585
|
+
}
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
console.warn(
|
|
1588
|
+
`agent-defs: reconciling grants for "${def.name}" failed (continuing): ${(err as Error).message}`,
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Resolve a def's status, registering its `wants:` connections as PENDING grants
|
|
1595
|
+
* when a grants client is wired (4b). For each declared connection: `PUT
|
|
1596
|
+
* /admin/grants {agent, connection}` (idempotent upsert), collect the returned
|
|
1597
|
+
* status, then derive `enabled` (every connection approved) vs `pending` (listing
|
|
1598
|
+
* the unapproved connection keys). Legacy `uses:` names are appended to `pending`
|
|
1599
|
+
* (they have no grants flow — informational only).
|
|
1600
|
+
*
|
|
1601
|
+
* Best-effort + non-fatal: NO grants client, NO `wants:`, or a registration failure
|
|
1602
|
+
* all fall back to {@link resolveDefStatus} (a connection that couldn't register
|
|
1603
|
+
* counts as unapproved → the def is `pending`, not `error` — the agent still runs
|
|
1604
|
+
* own-vault, the operator can retry the hub). A single connection's PUT failing is
|
|
1605
|
+
* logged + that connection counts as unapproved; the others still register.
|
|
1606
|
+
*/
|
|
1607
|
+
private async resolveStatusWithGrants(
|
|
1608
|
+
def: ParsedAgentDef,
|
|
1609
|
+
): Promise<{ status: AgentDefStatus; pending?: string[]; connections: ConnectionInfo[] }> {
|
|
1610
|
+
if (!this.grants || def.wants.length === 0) {
|
|
1611
|
+
// No hub wiring / no structured connections → the pure fallback. The connections
|
|
1612
|
+
// list is still surfaced (status `pending`, NO grant id) so the ops panel can
|
|
1613
|
+
// list the agent's declared `mcp:` connections + show the degraded hint when
|
|
1614
|
+
// there's nothing to Connect against (no grant could be resolved here).
|
|
1615
|
+
const fallback = resolveDefStatus(def);
|
|
1616
|
+
const connections: ConnectionInfo[] = def.wants.map((c) => ({
|
|
1617
|
+
key: connectionKey(c),
|
|
1618
|
+
kind: c.kind,
|
|
1619
|
+
target: c.target,
|
|
1620
|
+
status: "pending",
|
|
1621
|
+
}));
|
|
1622
|
+
return { ...fallback, connections };
|
|
1623
|
+
}
|
|
1624
|
+
const grants = this.grants;
|
|
1625
|
+
const statusByKey = new Map<string, string>();
|
|
1626
|
+
// Per-connection grant info (id + status) for the ops panel — keyed by connectionKey
|
|
1627
|
+
// so it lines up with the def's wants. The grant id comes FROM the hub (registerGrant
|
|
1628
|
+
// is an idempotent upsert that echoes the existing grant's id + current status); we
|
|
1629
|
+
// never derive it client-side (the hub's id-slug impl must not be duplicated).
|
|
1630
|
+
const infoByKey = new Map<string, ConnectionInfo>();
|
|
1631
|
+
for (const conn of def.wants) {
|
|
1632
|
+
const key = connectionKey(conn);
|
|
1633
|
+
try {
|
|
1634
|
+
const rec = await grants.registerGrant(def.name, conn);
|
|
1635
|
+
statusByKey.set(key, rec.status);
|
|
1636
|
+
infoByKey.set(key, {
|
|
1637
|
+
key,
|
|
1638
|
+
kind: conn.kind,
|
|
1639
|
+
target: conn.target,
|
|
1640
|
+
status: rec.status,
|
|
1641
|
+
...(rec.id ? { grantId: rec.id } : {}),
|
|
1642
|
+
});
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
// A failed registration → the connection counts as unapproved (absent from
|
|
1645
|
+
// statusByKey). Never fatal — the agent runs own-vault; the operator retries.
|
|
1646
|
+
// Surface it with status `pending` + no grant id (the panel shows it un-Connectable).
|
|
1647
|
+
infoByKey.set(key, { key, kind: conn.kind, target: conn.target, status: "pending" });
|
|
1648
|
+
console.warn(
|
|
1649
|
+
`agent-defs: registering grant for "${def.name}" (${key}) failed ` +
|
|
1650
|
+
`(treating as pending): ${(err as Error).message}`,
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
const connections = def.wants.map(
|
|
1655
|
+
(c) =>
|
|
1656
|
+
infoByKey.get(connectionKey(c)) ?? {
|
|
1657
|
+
key: connectionKey(c),
|
|
1658
|
+
kind: c.kind,
|
|
1659
|
+
target: c.target,
|
|
1660
|
+
status: "pending",
|
|
1661
|
+
},
|
|
1662
|
+
);
|
|
1663
|
+
const resolved = resolveConnectionStatus(def.wants, statusByKey);
|
|
1664
|
+
// Surface legacy `uses:` names alongside the structured pending keys (no grant flow).
|
|
1665
|
+
const pending = [...(resolved.pending ?? []), ...def.declaredConnections];
|
|
1666
|
+
if (resolved.status === "enabled" && pending.length === 0) {
|
|
1667
|
+
return { status: "enabled", connections };
|
|
1668
|
+
}
|
|
1669
|
+
return { status: "pending", pending, connections };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** Tear down the agent for a given (vault, noteId): deregister + drop its channel. */
|
|
1673
|
+
private async deregisterByNote(vault: string, noteId: string): Promise<void> {
|
|
1674
|
+
const key = this.keyOf(vault, noteId);
|
|
1675
|
+
const rec = this.live.get(key);
|
|
1676
|
+
if (!rec) return; // never instantiated (a delete for a note we don't track) — no-op.
|
|
1677
|
+
this.live.delete(key);
|
|
1678
|
+
try {
|
|
1679
|
+
await this.deps.deregister(rec.name);
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
console.error(`agent-defs: deregistering "${rec.name}" failed (continuing): ${(err as Error).message}`);
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
await this.deps.removeChannel(rec.name);
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
console.error(`agent-defs: removing channel "${rec.name}" failed (continuing): ${(err as Error).message}`);
|
|
1687
|
+
}
|
|
1688
|
+
console.log(`agent-defs: deregistered "${rec.name}" (${noteId} in "${vault}").`);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/** Tear down every agent from a def-vault (e.g. the vault binding is removed). */
|
|
1692
|
+
async deregisterAllForVault(vault: string): Promise<void> {
|
|
1693
|
+
// Drop the seen-defs entry for this vault FIRST (reviewer nit): otherwise the
|
|
1694
|
+
// next confident loadAll would diff the now-unbound vault's stale entries as
|
|
1695
|
+
// "removed" and issue spurious reconcile(agent, []) prunes — but the binding
|
|
1696
|
+
// was dropped, the defs weren't deleted, so their grants must NOT be GC'd.
|
|
1697
|
+
this.seenDefs.delete(vault);
|
|
1698
|
+
for (const rec of [...this.live.values()]) {
|
|
1699
|
+
if (rec.vault === vault) await this.deregisterByNote(vault, rec.noteId);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|