@openparachute/agent 0.1.2 → 0.2.0
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 +32 -43
- 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/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,1790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault transport for parachute-agent.
|
|
3
|
+
*
|
|
4
|
+
* A channel backed by `#agent/message` notes in a Parachute vault. The vault
|
|
5
|
+
* becomes the persistence layer + the inter-module event bus; the channel is the
|
|
6
|
+
* adapter that wakes a session on a new note and writes the session's reply back
|
|
7
|
+
* as a note.
|
|
8
|
+
*
|
|
9
|
+
* TAG NAMESPACE (`#agent/*`, module-owned — design
|
|
10
|
+
* `2026-06-17-vault-native-agents.md`). The `#agent` prefix is owned entirely by
|
|
11
|
+
* the agent module: every vault object the module manages hangs off it —
|
|
12
|
+
* `#agent/definition` (the agent def), `#agent/message{,/inbound,/outbound}` (a
|
|
13
|
+
* conversation turn), `#agent/job` (a scheduled trigger). We WRITE and READ only
|
|
14
|
+
* the `#agent/message*` tags — the channel→agent data-model rename CONTRACT phase
|
|
15
|
+
* dropped the legacy `#channel-message*` and interim `#agent-message*` dual-read
|
|
16
|
+
* (no surviving old-tagged data to recognize).
|
|
17
|
+
*
|
|
18
|
+
* ROUTING KEY (`metadata.agent`). Every note this module writes carries the routing
|
|
19
|
+
* key under `metadata.agent` ONLY — the CONTRACT phase of the channel→agent rename
|
|
20
|
+
* dropped the `metadata.channel` dual-write. The vault inbound trigger keys on
|
|
21
|
+
* `has_metadata:["agent"]`. The `noteAgentKey` helper still READS `agent ?? channel`
|
|
22
|
+
* as a tolerance fallback so a stray in-flight note written by an older build during
|
|
23
|
+
* the live cutover still routes — read-only, no longer written.
|
|
24
|
+
*
|
|
25
|
+
* How it differs from telegram / http-ui — the "external party" is the vault:
|
|
26
|
+
* - Inbound (human → session): a vault trigger POSTs the daemon's
|
|
27
|
+
* `/api/vault/inbound` webhook when a new `#agent/message/inbound` note
|
|
28
|
+
* appears; the daemon resolves the channel from `note.metadata.agent` (via
|
|
29
|
+
* `noteAgentKey`) and calls this transport's `ingestInbound(note)`, which
|
|
30
|
+
* `ctx.emit(...)`s → routes to the bridge / MCP session subscribed to that
|
|
31
|
+
* channel and wakes it.
|
|
32
|
+
* - Outbound (session → human): when the session calls the `reply` tool, the
|
|
33
|
+
* bridge POSTs `/api/reply {channel,...}`; the daemon dispatches to this
|
|
34
|
+
* transport's `reply()`, which writes a `#agent/message/outbound` note via
|
|
35
|
+
* the vault REST API (`POST <vaultUrl>/vault/<vault>/api/notes`).
|
|
36
|
+
*
|
|
37
|
+
* Tagging model — two ORTHOGONAL axes (this was a footgun; read carefully).
|
|
38
|
+
* In a Parachute vault a slash in a tag NAME is a namespace convention only —
|
|
39
|
+
* it implies NOTHING about query inheritance. `query-notes { tag: "X" }` matches
|
|
40
|
+
* descendants by the `tags.parent_names` graph, which is declared explicitly via
|
|
41
|
+
* `update-tag`, NOT inferred from the name. So a note tagged ONLY
|
|
42
|
+
* `#agent/message/inbound` is INVISIBLE to a `tag: "#agent/message"` query
|
|
43
|
+
* unless that inheritance was separately declared. We don't want to depend on
|
|
44
|
+
* per-vault schema setup, so every note carries BOTH tags literally:
|
|
45
|
+
* - the parent `#agent/message` — the QUERYABLE membership tag (a UI lists a
|
|
46
|
+
* channel's whole transcript, both directions, with one `tag: "#agent/message"`
|
|
47
|
+
* + `metadata.channel` query, because the parent is literally present);
|
|
48
|
+
* - a directional child — the trigger DISCRIMINATOR (`#agent/message/inbound`
|
|
49
|
+
* on inbound, `#agent/message/outbound` on outbound).
|
|
50
|
+
*
|
|
51
|
+
* Loop avoidance (load-bearing). An outbound reply is itself an `#agent/message`
|
|
52
|
+
* note; if the trigger fired on it the session would wake on its own reply forever.
|
|
53
|
+
* The vault trigger predicate does EXACT tag membership, so it's keyed on the
|
|
54
|
+
* inbound child only — `tags: ["#agent/message/inbound"]` — which an outbound
|
|
55
|
+
* note (parent + `/outbound`) never carries, so a reply can't wake its own session.
|
|
56
|
+
* As belt-and-suspenders, `ingestInbound` also drops any note tagged
|
|
57
|
+
* `#agent/message/outbound` (or `direction: "outbound"`) — so even a mis-wired
|
|
58
|
+
* trigger can never wake us on our own reply.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import type {
|
|
62
|
+
Transport,
|
|
63
|
+
TransportContext,
|
|
64
|
+
ReplyArgs,
|
|
65
|
+
ThreadRecord,
|
|
66
|
+
CallbackMetadata,
|
|
67
|
+
InboundAttachment,
|
|
68
|
+
} from "../transport.ts";
|
|
69
|
+
|
|
70
|
+
/** The safe basename of a (possibly path-ful, possibly untrusted) string — the LAST
|
|
71
|
+
* path segment, with traversal markers stripped. Used to derive a display `filename`
|
|
72
|
+
* from an attachment `path`. The backend re-sanitizes before staging; this is just a
|
|
73
|
+
* reasonable default for the surfaced hint. */
|
|
74
|
+
function basenameOf(p: string): string {
|
|
75
|
+
// Split on both slash flavors, take the last non-empty segment, drop `..`.
|
|
76
|
+
const parts = p.split(/[/\\]+/).filter((s) => s.length > 0 && s !== "..");
|
|
77
|
+
return parts.length > 0 ? parts[parts.length - 1]! : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Config for a vault transport instance (from the channel registry entry). */
|
|
81
|
+
export interface VaultTransportConfig {
|
|
82
|
+
/** Vault name (the `<vault>` path segment in the REST URL). */
|
|
83
|
+
vault: string;
|
|
84
|
+
/** REST base origin. Default `http://127.0.0.1:1940`. */
|
|
85
|
+
vaultUrl?: string;
|
|
86
|
+
/** A `vault:<name>:write` hub JWT, presented as Bearer when writing replies. */
|
|
87
|
+
token: string;
|
|
88
|
+
/**
|
|
89
|
+
* Shared secret the inbound webhook must present (validated by the daemon),
|
|
90
|
+
* for the DEPRECATED `?secret=` back-compat path. OPTIONAL — a JWT-only channel
|
|
91
|
+
* (the frictionless-setup default, provisioned by the hub with NO shared
|
|
92
|
+
* secret) configures none, and the webhook handler authenticates it via the
|
|
93
|
+
* hub-JWT path instead. When absent, the `?secret=` fallback can never succeed
|
|
94
|
+
* for this channel (nothing to validate against → 401).
|
|
95
|
+
*/
|
|
96
|
+
webhookSecret?: string;
|
|
97
|
+
/** Optional path prefix for written notes. Default `channel`. */
|
|
98
|
+
notePathPrefix?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The note shape the daemon hands `ingestInbound` (a subset of the trigger payload). */
|
|
102
|
+
export interface InboundNote {
|
|
103
|
+
id: string;
|
|
104
|
+
content?: string;
|
|
105
|
+
/** The note's tags — carries `#agent/message/{inbound,outbound}` for loop avoidance. */
|
|
106
|
+
tags?: string[];
|
|
107
|
+
metadata?: Record<string, unknown>;
|
|
108
|
+
/**
|
|
109
|
+
* The note's attachments, if the trigger payload carried them inline (vault's
|
|
110
|
+
* `send: "json"` webhook includes `note.attachments` — each `{ id, path, mimeType, ... }`).
|
|
111
|
+
* A FAST-PATH: when present + non-empty, `ingestInbound` uses these directly and skips
|
|
112
|
+
* the REST attachment-list fetch. When absent, `ingestInbound` does NOT fetch (the
|
|
113
|
+
* daemon always forwards the inline list when the note has one — Phase 1).
|
|
114
|
+
*/
|
|
115
|
+
attachments?: Array<{ id?: string; path?: string; mimeType?: string }>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A scheduled-job note as read back from the vault (design
|
|
120
|
+
* `2026-06-17-runner-scheduled-agent-turns.md`). The runner's vault-native job
|
|
121
|
+
* store maps these to/from the `Job` type in `jobs.ts`. `content` is the message
|
|
122
|
+
* to inject; the schedule + bookkeeping live in `metadata` (all string-typed in
|
|
123
|
+
* the vault — `enabled` is "true"/"false"; `nextRunAt` is NEVER persisted, it's
|
|
124
|
+
* recomputed in memory by the runner). The note `id` (or path) addresses it for
|
|
125
|
+
* PATCH/DELETE.
|
|
126
|
+
*/
|
|
127
|
+
export interface JobNote {
|
|
128
|
+
/**
|
|
129
|
+
* The operator-facing job id — the SLUG the operator typed (carried in
|
|
130
|
+
* `metadata.jobId`). This is what the UI displays, what addresses the job in the
|
|
131
|
+
* `/api/jobs/:id` routes, and what stamps `runner:<jobId>` provenance. Falls back
|
|
132
|
+
* to `noteId` for a legacy note written without the metadata field.
|
|
133
|
+
*/
|
|
134
|
+
id: string;
|
|
135
|
+
/** The vault note id/path — addresses the note for PATCH / DELETE I/O. */
|
|
136
|
+
noteId: string;
|
|
137
|
+
/** The message text to inject as the inbound note when this job fires. */
|
|
138
|
+
message: string;
|
|
139
|
+
/** Target channel (routes the job to its vault transport). */
|
|
140
|
+
channel: string;
|
|
141
|
+
/** 5-field cron expression. */
|
|
142
|
+
cron: string;
|
|
143
|
+
/** IANA timezone, if set. */
|
|
144
|
+
tz?: string;
|
|
145
|
+
/** Whether the runner considers this job. */
|
|
146
|
+
enabled: boolean;
|
|
147
|
+
/** ISO timestamp the job was created. */
|
|
148
|
+
createdAt?: string;
|
|
149
|
+
/** ISO timestamp of the most recent fire. */
|
|
150
|
+
lastRunAt?: string;
|
|
151
|
+
/** "ok" / "error: …" from the most recent fire. */
|
|
152
|
+
lastStatus?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The metadata payload written for a job note (all string-typed, per the vault). */
|
|
156
|
+
export interface JobNoteMetadata {
|
|
157
|
+
/** The operator-facing slug (so the displayed id survives the vault's note-id assignment). */
|
|
158
|
+
jobId: string;
|
|
159
|
+
/** The routing key — written under `metadata.agent` only (the channel→agent CONTRACT). */
|
|
160
|
+
agent: string;
|
|
161
|
+
cron: string;
|
|
162
|
+
tz?: string;
|
|
163
|
+
/** "true" | "false" — the vault stores metadata as strings. */
|
|
164
|
+
enabled: string;
|
|
165
|
+
createdAt: string;
|
|
166
|
+
lastRunAt?: string;
|
|
167
|
+
lastStatus?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One message in a channel transcript, as the built-in chat renders it. This is
|
|
172
|
+
* the transport-neutral shape `loadTranscript` produces from the vault notes; the
|
|
173
|
+
* daemon's `GET /api/channels/<ch>/messages` returns `{ messages: ChannelMessage[] }`.
|
|
174
|
+
*
|
|
175
|
+
* `direction` drives the chat's bubble placement: `inbound` (human → session) is
|
|
176
|
+
* "you" (right), `outbound` (session → human) is "them" (left) — mirroring the
|
|
177
|
+
* Telegram/vault transport meaning, NOT the chat's local point of view.
|
|
178
|
+
*/
|
|
179
|
+
export interface ChannelMessage {
|
|
180
|
+
/** The vault note id — the chat dedups its poll by this. */
|
|
181
|
+
id: string;
|
|
182
|
+
/** The message body (the note content). */
|
|
183
|
+
text: string;
|
|
184
|
+
/** `inbound` = human→session ("you"); `outbound` = session→human ("them"). */
|
|
185
|
+
direction: "inbound" | "outbound";
|
|
186
|
+
/** Who authored it (metadata.sender), e.g. "operator" / "session" / "aaron". */
|
|
187
|
+
sender: string;
|
|
188
|
+
/** ISO timestamp (metadata.ts) — the transcript is sorted ascending by this. */
|
|
189
|
+
ts: string;
|
|
190
|
+
/** The inbound note id this reply threads to, when present (outbound only). */
|
|
191
|
+
inReplyTo?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The claim status carried on an `#agent/message/inbound` note for a CHANNEL-backend
|
|
196
|
+
* agent (design 2026-06-18-channel-backend.md "Claim/ack durability"). The vault is
|
|
197
|
+
* the source of truth — the status lives on the note so a claim survives a daemon
|
|
198
|
+
* restart and a handled message is never re-presented.
|
|
199
|
+
*
|
|
200
|
+
* - `pending` — unhandled; waiting for a connected session to claim it.
|
|
201
|
+
* - `in-flight` — claimed by a session (`next-message`); `claimedAt` stamps when.
|
|
202
|
+
* Auto-released back to `pending` after a TTL (the daemon sweep) so a
|
|
203
|
+
* crashed session can't strand the queue.
|
|
204
|
+
* - `handled` — replied to; the outbound note is written. Never re-presented.
|
|
205
|
+
*
|
|
206
|
+
* NOTE: programmatic-backend inbound notes do NOT use this field — their turn runs
|
|
207
|
+
* synchronously in the serial worker; status is meaningful only on the channel path.
|
|
208
|
+
*/
|
|
209
|
+
export type InboundStatus = "pending" | "in-flight" | "handled";
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One inbound queue item for an ATTACHED-backend agent — an `#agent/message/inbound`
|
|
213
|
+
* note as the {@link AttachedQueueRegistry} reads it. Carries the claim `status` +
|
|
214
|
+
* `claimedAt` (for the TTL sweep) alongside the message text + threading id.
|
|
215
|
+
*/
|
|
216
|
+
export interface InboundQueueNote {
|
|
217
|
+
/** The vault note id — addresses the note for the status PATCH + threads the reply. */
|
|
218
|
+
id: string;
|
|
219
|
+
/** The message text the connected session works on. */
|
|
220
|
+
text: string;
|
|
221
|
+
/** Who authored it (metadata.sender). */
|
|
222
|
+
sender: string;
|
|
223
|
+
/** ISO timestamp (metadata.ts) — the queue is ordered ascending by this (oldest first). */
|
|
224
|
+
ts: string;
|
|
225
|
+
/** The claim status (`pending` when the field is absent — a fresh inbound). */
|
|
226
|
+
status: InboundStatus;
|
|
227
|
+
/** ISO timestamp the note was claimed (set with `in-flight`); used by the TTL sweep. */
|
|
228
|
+
claimedAt?: string;
|
|
229
|
+
/**
|
|
230
|
+
* The note's vault `updated_at` (the last-seen revision). Threaded through so a
|
|
231
|
+
* claim can use it as the `if_updated_at` compare-and-swap precondition (agent#101):
|
|
232
|
+
* two concurrent `claimNext` reads see the SAME `updated_at`; the first claim PATCH
|
|
233
|
+
* advances it, so the second's precondition fails (vault 409) and it re-lists rather
|
|
234
|
+
* than double-claiming. Absent when the vault response omitted it.
|
|
235
|
+
*/
|
|
236
|
+
updatedAt?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const DEFAULT_VAULT_URL = "http://127.0.0.1:1940";
|
|
240
|
+
const DEFAULT_PATH_PREFIX = "channel";
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Thrown by {@link VaultTransport.setInboundStatus} when a compare-and-swap claim
|
|
244
|
+
* (an `ifUpdatedAt` precondition) FAILED — the note changed since it was read, so
|
|
245
|
+
* another writer won the race (agent#101). The vault returns **409** (`error_type:
|
|
246
|
+
* "conflict"`) for a STALE `if_updated_at`, and **428** (`precondition_required`) when
|
|
247
|
+
* the precondition is absent; we treat both as "lost the claim race" so the caller
|
|
248
|
+
* (the channel queue's `claimNext`) re-lists and tries the next pending message rather
|
|
249
|
+
* than double-claiming. Distinct from a generic write error (any other non-ok status),
|
|
250
|
+
* which still throws a plain Error.
|
|
251
|
+
*/
|
|
252
|
+
export class InboundClaimConflictError extends Error {
|
|
253
|
+
constructor(
|
|
254
|
+
readonly id: string,
|
|
255
|
+
readonly status: number,
|
|
256
|
+
) {
|
|
257
|
+
super(`vault transport: inbound claim ${id} lost the CAS race (${status})`);
|
|
258
|
+
this.name = "InboundClaimConflictError";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Parent tag (NEW, namespaced) — carried LITERALLY on every note WE write; query
|
|
262
|
+
* this + metadata.channel to see BOTH directions of a channel (the slash children
|
|
263
|
+
* are namespace, not inheritance). */
|
|
264
|
+
const AGENT_MESSAGE_TAG = "#agent/message";
|
|
265
|
+
/** Inbound child (NEW) — the vault trigger fires on this exact tag (never matches outbound → no loop). */
|
|
266
|
+
const AGENT_MESSAGE_INBOUND_TAG = "#agent/message/inbound";
|
|
267
|
+
/** Outbound child (NEW) — replies carry this; the trigger's exact-match predicate excludes it. */
|
|
268
|
+
const AGENT_MESSAGE_OUTBOUND_TAG = "#agent/message/outbound";
|
|
269
|
+
|
|
270
|
+
/** Metadata key carrying the channel-queue claim status (design 2026-06-18). */
|
|
271
|
+
const STATUS_META_KEY = "status";
|
|
272
|
+
/** Metadata key carrying the ISO timestamp an inbound was claimed (for the TTL sweep). */
|
|
273
|
+
const CLAIMED_AT_META_KEY = "claimedAt";
|
|
274
|
+
|
|
275
|
+
/** The agent (routing) key carried on a vault note's metadata. Reads the canonical
|
|
276
|
+
* `agent` field, falling back to the legacy `channel` field as a read-only TOLERANCE
|
|
277
|
+
* for any in-flight note written by an older build during the live cutover. New writes
|
|
278
|
+
* carry `agent` only (the channel→agent CONTRACT dropped the `channel` dual-write). */
|
|
279
|
+
export function noteAgentKey(meta: Record<string, unknown> | undefined | null): string | undefined {
|
|
280
|
+
const a = meta?.agent;
|
|
281
|
+
if (typeof a === "string" && a) return a;
|
|
282
|
+
const c = meta?.channel;
|
|
283
|
+
return typeof c === "string" && c ? c : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Coerce a raw `status` metadata value to an {@link InboundStatus}. The vault stores
|
|
288
|
+
* metadata as strings; an absent / empty / unrecognized value reads as `pending` (the
|
|
289
|
+
* safe default — a fresh inbound the trigger just created carries no status, and an
|
|
290
|
+
* unknown value shouldn't strand the note). Only the two non-default states need an
|
|
291
|
+
* explicit value.
|
|
292
|
+
*/
|
|
293
|
+
function coerceInboundStatus(v: unknown): InboundStatus {
|
|
294
|
+
if (v === "in-flight" || v === "handled") return v;
|
|
295
|
+
return "pending";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Coerce a raw metadata value (the vault stores metadata as STRINGS) to a finite number,
|
|
300
|
+
* defaulting to 0. Used to roll up a single-threaded thread's cumulative aggregates
|
|
301
|
+
* (`turn_count`, token/cost usage) read back from the prior note.
|
|
302
|
+
*/
|
|
303
|
+
function numFromMeta(v: unknown): number {
|
|
304
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
305
|
+
return Number.isFinite(n) ? n : 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build the `#agent/thread` note BODY — the rolling SUMMARY of the thread (design
|
|
310
|
+
* 2026-06-18: "hold a summary of this thread in the content; maybe another agent
|
|
311
|
+
* facilitates that"). The MODULE writes a useful default, STRUCTURED (`## Summary` /
|
|
312
|
+
* `## Latest turn`) so the `## Summary` section is the slot a future summarizer agent is
|
|
313
|
+
* EARMARKED to own/enrich; the `## Latest turn` block + the metadata roll-up are always
|
|
314
|
+
* module-owned. The same shape serves both modes (multi-threaded = one turn).
|
|
315
|
+
*
|
|
316
|
+
* v1 LIMITATION — the module OVERWRITES the `## Summary` section every turn. This function
|
|
317
|
+
* REGENERATES the whole body from scratch using only the rolled-up aggregates (passed in
|
|
318
|
+
* from `prior.metadata`); it NEVER reads `prior.content`. So a summarizer agent's
|
|
319
|
+
* enrichment of `## Summary` would be CLOBBERED on the next turn. Summarizer-agent
|
|
320
|
+
* enrichment needs a read-prior-content → merge path (preserve a summarizer-owned section
|
|
321
|
+
* across the regenerate), which is DEFERRED. Until then, "may own" means EARMARKED, not
|
|
322
|
+
* PRESERVED.
|
|
323
|
+
*/
|
|
324
|
+
function buildThreadSummaryBody(t: {
|
|
325
|
+
name: string;
|
|
326
|
+
mode: string;
|
|
327
|
+
turnCount: number;
|
|
328
|
+
status: "ok" | "error" | "working";
|
|
329
|
+
lastTurnAt: string;
|
|
330
|
+
input: string;
|
|
331
|
+
output: string;
|
|
332
|
+
}): string {
|
|
333
|
+
const turns = t.turnCount === 1 ? "1 turn" : `${t.turnCount} turns`;
|
|
334
|
+
// `## Summary` is EARMARKED for a future summarizer agent — but v1 OVERWRITES it every
|
|
335
|
+
// turn (this body is fully regenerated from metadata; `prior.content` is never read), so
|
|
336
|
+
// it's a module-owned default for now, NOT a preserved slot (see the function doc).
|
|
337
|
+
// The `## Latest turn` block + the metadata roll-up are always module-owned.
|
|
338
|
+
//
|
|
339
|
+
// WORKING (the thread-as-container start-ensure, before the turn finishes): show the input
|
|
340
|
+
// and a clear "awaiting reply" state — NEVER print a fake reply. The thread is visible the
|
|
341
|
+
// moment processing starts; the end-record overwrites this body with the real ok/error
|
|
342
|
+
// reply once the turn completes.
|
|
343
|
+
if (t.status === "working") {
|
|
344
|
+
// No turn has COMPLETED yet, so don't print a (confusing) "0 turns" count — the prior
|
|
345
|
+
// completed count rides in the metadata for queries; the body just says it's working.
|
|
346
|
+
const priorTurns = t.turnCount === 0 ? "first turn" : `${turns} so far`;
|
|
347
|
+
const auto = `${t.mode} thread for ${t.name} — working on the ${t.turnCount === 0 ? "first turn" : "next turn"} (${priorTurns}, awaiting reply).`;
|
|
348
|
+
return (
|
|
349
|
+
`## Summary\n\n${auto}\n\n` +
|
|
350
|
+
`## Latest turn\n\n` +
|
|
351
|
+
`**Input:** ${t.input}\n\n` +
|
|
352
|
+
`**Status:** working — awaiting reply.\n`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const auto = `${t.mode} thread for ${t.name} — ${turns}, last ${t.status} at ${t.lastTurnAt}.`;
|
|
356
|
+
const turnHeading = t.status === "ok" ? "Reply" : "Error";
|
|
357
|
+
return (
|
|
358
|
+
`## Summary\n\n${auto}\n\n` +
|
|
359
|
+
`## Latest turn\n\n` +
|
|
360
|
+
`**Input:** ${t.input}\n\n` +
|
|
361
|
+
`**${turnHeading}:** ${t.output}\n`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The module-owned root namespace tag. Declared (with the three children rolling up
|
|
367
|
+
* to it via `parent_names`) so a human `tag:#agent` query expands to EVERYTHING the
|
|
368
|
+
* module owns — definitions, messages, jobs. The module itself never queries by this
|
|
369
|
+
* (it always queries the exact leaf tag); it exists for the nice human rollup, per
|
|
370
|
+
* the design's namespacing decision.
|
|
371
|
+
*/
|
|
372
|
+
export const AGENT_ROOT_TAG = "#agent";
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Agent-definition tag — a vault-native agent IS a `#agent/definition` note (design
|
|
376
|
+
* `2026-06-17-vault-native-agents.md`). The note BODY is the system prompt; the note
|
|
377
|
+
* METADATA is the config (name, backend, workspace, isolation, the def-vault binding).
|
|
378
|
+
* The module reads these notes from a def-vault and instantiates each as a live agent.
|
|
379
|
+
*/
|
|
380
|
+
export const AGENT_DEFINITION_TAG = "#agent/definition";
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Scheduled-job tag — the runner's vault-native job store (design
|
|
384
|
+
* `2026-06-17-runner-scheduled-agent-turns.md`). A job IS a vault note carrying
|
|
385
|
+
* this parent tag; queryable + durable + surface-renderable, exactly like
|
|
386
|
+
* `#agent/message`. Introduced in Phase 2 as the flat `#agent-job`; moved into the
|
|
387
|
+
* `#agent/*` namespace (`#agent/job`) by the vault-native-agents work (Phase 4a).
|
|
388
|
+
*/
|
|
389
|
+
export const AGENT_JOB_TAG = "#agent/job";
|
|
390
|
+
/** Default path prefix under which job notes are written: `Channels/<ch>/jobs/<id>`. */
|
|
391
|
+
const JOB_PATH_PREFIX = "Channels";
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Thread tag — the UNIFIED model: `definition -> thread -> message`. EVERYTHING is a
|
|
395
|
+
* thread; a `#agent/thread` note is the durable, queryable record of one conversation
|
|
396
|
+
* thread, written for BOTH execution-lifecycle modes (the structural unification —
|
|
397
|
+
* "a run was always a thread with one turn"). The note BODY is a rolling SUMMARY of the
|
|
398
|
+
* thread (a future summarizer agent may own/enrich the `## Summary` slot — module-owned
|
|
399
|
+
* in v1); metadata = `{ agent, definition, mode, status, started_at, last_turn_at,
|
|
400
|
+
* turn_count, usage }` (`agent` is the routing key — the channel→agent CONTRACT).
|
|
401
|
+
* The INDEXED string fields (`status`, `definition`, `mode`) make
|
|
402
|
+
* "all failed threads" / "all threads of agent X" / "all multi-threaded threads"
|
|
403
|
+
* operator-queryable. `definition` is a plain note-id string for now (interim — typed
|
|
404
|
+
* link fields are a future vault feature).
|
|
405
|
+
*
|
|
406
|
+
* The MODE difference is the thread's IDENTITY (path leaf) + whether it upserts:
|
|
407
|
+
* - `single-threaded` — exactly ONE thread note per channel, at the DETERMINISTIC stable
|
|
408
|
+
* path `Threads/<safeChannel>/<safeName>` ("named after the definition"), UPSERTED in
|
|
409
|
+
* place across turns (turn_count increments, usage accumulates).
|
|
410
|
+
* - `multi-threaded` — one thread note per fire, at `Threads/<safeChannel>/<uuid>` (today
|
|
411
|
+
* one fire = one thread = one note; turn_count = 1; usage = this turn's). No upsert.
|
|
412
|
+
*
|
|
413
|
+
* The note carries `['#agent/thread']` EXACTLY — NOT a message tag, NOT the inbound
|
|
414
|
+
* child — so it can never wake a session (no loop).
|
|
415
|
+
*/
|
|
416
|
+
export const AGENT_THREAD_TAG = "#agent/thread";
|
|
417
|
+
/** Default path prefix under which thread notes are written: `Threads/<ch>/<leaf>`. */
|
|
418
|
+
const THREAD_PATH_PREFIX = "Threads";
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The tag schema this module manages in any vault it's connected to.
|
|
422
|
+
*
|
|
423
|
+
* This is the declarative complement to the "tag both parent + child" fail-safe
|
|
424
|
+
* in `reply()` / inbound writes. A slash in a Parachute tag NAME is namespace-only
|
|
425
|
+
* — it carries NO query inheritance. Inheritance is the `parent_names` graph,
|
|
426
|
+
* declared via the vault's tag-schema API. We declare the full `#agent/*`
|
|
427
|
+
* namespace rollup (design `2026-06-17-vault-native-agents.md`):
|
|
428
|
+
* - `#agent/definition` → parent `#agent`
|
|
429
|
+
* - `#agent/message` → parent `#agent`
|
|
430
|
+
* - `#agent/message/inbound` → parent `#agent/message`
|
|
431
|
+
* - `#agent/message/outbound` → parent `#agent/message`
|
|
432
|
+
* - `#agent/job` → parent `#agent`
|
|
433
|
+
* so a human `tag:#agent` query rolls up to EVERYTHING the module owns, and
|
|
434
|
+
* `tag:#agent/message` rolls up to both directions — without the module's own
|
|
435
|
+
* exact-leaf queries depending on per-vault schema.
|
|
436
|
+
*
|
|
437
|
+
* The channel→agent rename CONTRACT dropped the prior `#agent-message*` (interim) and
|
|
438
|
+
* `#channel-message*` (legacy) schema entries — there's no surviving old-tagged data
|
|
439
|
+
* to keep their inheritance declared for.
|
|
440
|
+
*
|
|
441
|
+
* This matches the vault's "clients bring their own tag schema" principle: the
|
|
442
|
+
* WRITING module provisions its own tag schema at connect-time. It's MODULE-OWNED
|
|
443
|
+
* DATA (not inline calls) so it's the seam for a future module-protocol
|
|
444
|
+
* "tag schemas this module manages" declaration — changing this constant changes
|
|
445
|
+
* exactly what `ensureSchema()` provisions.
|
|
446
|
+
*
|
|
447
|
+
* `ensureSchema()` upserts each entry; the "tag both" floor in the note writes
|
|
448
|
+
* stays as the fail-safe so the channel works even if this declaration never lands.
|
|
449
|
+
*/
|
|
450
|
+
export const AGENT_VAULT_TAG_SCHEMA: ReadonlyArray<{
|
|
451
|
+
name: string;
|
|
452
|
+
description?: string;
|
|
453
|
+
parent_names?: string[];
|
|
454
|
+
/**
|
|
455
|
+
* Indexed metadata field declarations (the vault's `update-tag` `fields` shape) —
|
|
456
|
+
* `{ <field>: { type, indexed } }`. Declared so the field gets a generated column +
|
|
457
|
+
* index, making it queryable via metadata operator objects. Used by `#agent/thread`
|
|
458
|
+
* (status/definition/mode) so an operator can query "all failed threads" / "all threads
|
|
459
|
+
* of agent X" / "all multi-threaded threads".
|
|
460
|
+
*/
|
|
461
|
+
fields?: Record<string, { type: "string" | "boolean" | "integer"; indexed?: boolean }>;
|
|
462
|
+
}> = [
|
|
463
|
+
{
|
|
464
|
+
name: AGENT_ROOT_TAG,
|
|
465
|
+
description: "The agent module's namespace root — rolls up definitions, messages, and jobs.",
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
name: AGENT_DEFINITION_TAG,
|
|
469
|
+
parent_names: [AGENT_ROOT_TAG],
|
|
470
|
+
description: "A vault-native agent definition — body is the system prompt, metadata is the config.",
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: AGENT_MESSAGE_TAG,
|
|
474
|
+
parent_names: [AGENT_ROOT_TAG],
|
|
475
|
+
description: "A message in a Parachute channel (parent of /inbound + /outbound).",
|
|
476
|
+
// Declare the canonical `agent` routing key indexed so agent-keyed queries are
|
|
477
|
+
// indexed. (Transcript filtering itself stays client-side / index-free.)
|
|
478
|
+
fields: {
|
|
479
|
+
agent: { type: "string", indexed: true },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: AGENT_MESSAGE_INBOUND_TAG,
|
|
484
|
+
parent_names: [AGENT_MESSAGE_TAG],
|
|
485
|
+
description: "Human→session message; the vault trigger fires on this.",
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: AGENT_MESSAGE_OUTBOUND_TAG,
|
|
489
|
+
parent_names: [AGENT_MESSAGE_TAG],
|
|
490
|
+
description: "Session→human reply.",
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: AGENT_JOB_TAG,
|
|
494
|
+
parent_names: [AGENT_ROOT_TAG],
|
|
495
|
+
description: "A scheduled job — the runner injects this note's message on its cron schedule.",
|
|
496
|
+
// Indexed query axes so an operator/agent can find jobs by target + state (mirrors
|
|
497
|
+
// the #agent/thread axes). All stored as strings (the vault stores metadata as
|
|
498
|
+
// strings; `enabled` is "true"/"false"):
|
|
499
|
+
// - agent → "all jobs targeting agent X"
|
|
500
|
+
// - enabled → "active jobs" (enabled:"true") vs paused ("false")
|
|
501
|
+
// - lastStatus → "jobs whose last run errored"
|
|
502
|
+
// The full field set is `JobNoteMetadata` in src/jobs.ts (design
|
|
503
|
+
// 2026-06-17-runner-scheduled-agent-turns); the schema is permissive, so the other
|
|
504
|
+
// job fields (jobId/cron/tz/createdAt/lastRunAt) ride as undeclared metadata.
|
|
505
|
+
fields: {
|
|
506
|
+
// The canonical `agent` routing key, indexed for "all jobs targeting agent X".
|
|
507
|
+
agent: { type: "string", indexed: true },
|
|
508
|
+
enabled: { type: "string", indexed: true },
|
|
509
|
+
lastStatus: { type: "string", indexed: true },
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: AGENT_THREAD_TAG,
|
|
514
|
+
parent_names: [AGENT_ROOT_TAG],
|
|
515
|
+
description:
|
|
516
|
+
"A thread record (definition -> thread -> message) — body is a rolling summary, metadata is the thread state. Written for BOTH modes.",
|
|
517
|
+
// The three indexed query axes carry over from the run record VERBATIM — an operator
|
|
518
|
+
// can query threads by outcome / agent / mode:
|
|
519
|
+
// - status → "all failed threads" (status:error)
|
|
520
|
+
// - definition → "all threads of agent X" (the def note id)
|
|
521
|
+
// - mode → "all multi-threaded threads"
|
|
522
|
+
fields: {
|
|
523
|
+
// The canonical `agent` routing key, indexed (mirrors #agent/message + #agent/job).
|
|
524
|
+
agent: { type: "string", indexed: true },
|
|
525
|
+
status: { type: "string", indexed: true },
|
|
526
|
+
definition: { type: "string", indexed: true },
|
|
527
|
+
mode: { type: "string", indexed: true },
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The vault trigger the hub registers to wake this channel on inbound notes.
|
|
534
|
+
*
|
|
535
|
+
* This is MODULE-OWNED DATA: the channel owns the shape of the trigger it needs,
|
|
536
|
+
* rather than the hub hardcoding it. The hub fetches this template (via
|
|
537
|
+
* `GET /.parachute/config` → `triggerTemplate`), substitutes the channel name
|
|
538
|
+
* into the placeholders, fills the webhook origin + the `action.auth.bearer`
|
|
539
|
+
* (an `agent:send` hub JWT, per the keystone vault PR's `action.auth.bearer`
|
|
540
|
+
* support), and registers it through the vault's runtime trigger-registration API.
|
|
541
|
+
*
|
|
542
|
+
* Placeholders the hub substitutes:
|
|
543
|
+
* - `<channel>` in `name` → the channel name (e.g. `channel_inbound_eng`);
|
|
544
|
+
* - `<hub-origin>` in `action.webhook` → the hub's public origin.
|
|
545
|
+
* The hub also injects `action.auth.bearer` (not in the template — it's a secret
|
|
546
|
+
* the hub mints).
|
|
547
|
+
*
|
|
548
|
+
* The predicate matches a NEW inbound note (`#agent/message/inbound`) that
|
|
549
|
+
* carries an `agent` metadata field (the routing key, post channel→agent CONTRACT)
|
|
550
|
+
* and hasn't been rendered yet. Loop avoidance is by the inbound CHILD tag: an
|
|
551
|
+
* outbound (reply) note carries `#agent/message/outbound`, never the inbound child,
|
|
552
|
+
* so it never fires this. (The trigger `name` and the `channel_inbound_rendered_at`
|
|
553
|
+
* marker are internal plumbing — kept STABLE so re-registration updates the existing
|
|
554
|
+
* trigger in place rather than orphaning one.)
|
|
555
|
+
*/
|
|
556
|
+
export const AGENT_VAULT_TRIGGER_TEMPLATE = {
|
|
557
|
+
name: "channel_inbound_<channel>", // hub substitutes the channel name
|
|
558
|
+
events: ["created"],
|
|
559
|
+
when: {
|
|
560
|
+
tags: ["#agent/message/inbound"],
|
|
561
|
+
has_metadata: ["agent"],
|
|
562
|
+
missing_metadata: ["channel_inbound_rendered_at"],
|
|
563
|
+
},
|
|
564
|
+
action: {
|
|
565
|
+
webhook: "<hub-origin>/agent/api/vault/inbound", // hub fills origin + the auth.bearer
|
|
566
|
+
send: "json",
|
|
567
|
+
},
|
|
568
|
+
} as const;
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The vault trigger that keeps vault-native agent DEFINITIONS in sync (design
|
|
572
|
+
* `2026-06-17-vault-native-agents.md`, Phase 4a). On a `#agent/definition` note
|
|
573
|
+
* created/updated/deleted, the hub POSTs the def-reload webhook; the daemon reloads
|
|
574
|
+
* that ONE agent (created/updated → re-instantiate; deleted → deregister). MODULE-
|
|
575
|
+
* OWNED DATA — the module declares the trigger it needs; the hub fills the origin +
|
|
576
|
+
* the `action.auth.bearer` (a minted `agent:send` token, the same auth as the inbound
|
|
577
|
+
* trigger). One trigger per def-vault (no per-note placeholder — the predicate is the
|
|
578
|
+
* whole `#agent/definition` tag). A poll fallback covers vaults without trigger support.
|
|
579
|
+
*/
|
|
580
|
+
export const AGENT_DEF_VAULT_TRIGGER_TEMPLATE = {
|
|
581
|
+
name: "agent_def_reload",
|
|
582
|
+
events: ["created", "updated", "deleted"],
|
|
583
|
+
when: {
|
|
584
|
+
tags: ["#agent/definition"],
|
|
585
|
+
},
|
|
586
|
+
action: {
|
|
587
|
+
webhook: "<hub-origin>/agent/api/vault/agent-def", // hub fills origin + the auth.bearer
|
|
588
|
+
send: "json",
|
|
589
|
+
},
|
|
590
|
+
} as const;
|
|
591
|
+
|
|
592
|
+
export class VaultTransport implements Transport {
|
|
593
|
+
readonly kind = "vault";
|
|
594
|
+
|
|
595
|
+
private ctx: TransportContext | undefined;
|
|
596
|
+
private readonly vault: string;
|
|
597
|
+
private readonly vaultUrl: string;
|
|
598
|
+
private readonly token: string;
|
|
599
|
+
/**
|
|
600
|
+
* Shared secret the daemon validates on the inbound webhook (read by the
|
|
601
|
+
* daemon), for the DEPRECATED `?secret=` path only. Optional — absent on a
|
|
602
|
+
* JWT-only channel, in which case the `?secret=` fallback can never authorize
|
|
603
|
+
* this channel (the daemon treats an absent/empty configured secret as
|
|
604
|
+
* never-matching). The hub-JWT path doesn't read it at all.
|
|
605
|
+
*/
|
|
606
|
+
readonly webhookSecret?: string;
|
|
607
|
+
private readonly pathPrefix: string;
|
|
608
|
+
|
|
609
|
+
constructor(config: VaultTransportConfig) {
|
|
610
|
+
if (!config.vault) {
|
|
611
|
+
throw new Error("VaultTransport: config.vault (vault name) is required");
|
|
612
|
+
}
|
|
613
|
+
if (!config.token) {
|
|
614
|
+
throw new Error("VaultTransport: config.token (vault:<name>:write JWT) is required");
|
|
615
|
+
}
|
|
616
|
+
// webhookSecret is OPTIONAL — a JWT-only channel (the frictionless-setup
|
|
617
|
+
// default) needs none. The webhook is authenticated via the hub-JWT path;
|
|
618
|
+
// the `?secret=` fallback simply can't succeed for a channel with no secret.
|
|
619
|
+
this.vault = config.vault;
|
|
620
|
+
this.vaultUrl = (config.vaultUrl ?? DEFAULT_VAULT_URL).replace(/\/$/, "");
|
|
621
|
+
this.token = config.token;
|
|
622
|
+
this.webhookSecret = config.webhookSecret;
|
|
623
|
+
this.pathPrefix = (config.notePathPrefix ?? DEFAULT_PATH_PREFIX).replace(/\/$/, "");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Stable identity of the backing vault (origin + name) — NOT the transport
|
|
628
|
+
* instance. Many channels each construct their OWN VaultTransport pointing at the
|
|
629
|
+
* SAME vault; callers that must query a vault once (e.g. the job-store's `listAll`)
|
|
630
|
+
* dedup by THIS key, not by object identity, or the same notes come back once per
|
|
631
|
+
* channel that shares the vault.
|
|
632
|
+
*/
|
|
633
|
+
vaultKey(): string {
|
|
634
|
+
return `${this.vaultUrl}::${this.vault}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async start(ctx: TransportContext): Promise<void> {
|
|
638
|
+
this.ctx = ctx;
|
|
639
|
+
// Declare the tag schema this module manages in the connected vault. Strictly
|
|
640
|
+
// best-effort: `ensureSchema` swallows all of its own errors, so an unreachable
|
|
641
|
+
// vault or a failing PUT can NEVER block (or reject out of) `start()`. The
|
|
642
|
+
// "tag both parent + child" floor in the note writes is the fail-safe, so the
|
|
643
|
+
// channel works even if this declaration never lands. Fire-and-forget — no
|
|
644
|
+
// reason to delay the channel coming up on a schema upsert.
|
|
645
|
+
void this.ensureSchema();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// -------------------------------------------------------------------------
|
|
649
|
+
// Schema declaration — provision this module's tag inheritance at connect-time.
|
|
650
|
+
// -------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Idempotently upsert `AGENT_VAULT_TAG_SCHEMA` into the connected vault via
|
|
654
|
+
* the vault's tag-schema REST API. The vault route is
|
|
655
|
+
* PUT /vault/<vault>/api/tags/:name
|
|
656
|
+
* where `:name` is matched by `subpath.match(/^\/([^/]+)$/)` then
|
|
657
|
+
* `decodeURIComponent`'d (parachute-vault `src/routes.ts` handleTags, the
|
|
658
|
+
* "Routes with tag name" block + `routing.ts` `apiPath.startsWith("/tags")`).
|
|
659
|
+
* Because the route matches a SINGLE path segment (`[^/]+`, no literal slash)
|
|
660
|
+
* and decodes it, the tag name — which contains BOTH `#` and `/`
|
|
661
|
+
* (`#agent/message/inbound`) — must be `encodeURIComponent`'d so the `#`
|
|
662
|
+
* becomes `%23` and the `/` becomes `%2F`; the route then decodes that back to
|
|
663
|
+
* the literal name. A bare `/` in the URL would fail the `[^/]+` match → 404,
|
|
664
|
+
* silently dropping the declaration. The PUT body is `{ description?, parent_names? }`.
|
|
665
|
+
*
|
|
666
|
+
* Best-effort + non-fatal by contract: every failure is caught and `console.warn`'d,
|
|
667
|
+
* never thrown — the tag-both write floor is the fallback.
|
|
668
|
+
*/
|
|
669
|
+
async ensureSchema(): Promise<void> {
|
|
670
|
+
for (const entry of AGENT_VAULT_TAG_SCHEMA) {
|
|
671
|
+
try {
|
|
672
|
+
// Single-segment, percent-encoded name: `#agent/message/inbound` →
|
|
673
|
+
// `%23agent%2Fmessage%2Finbound`. The vault decodes it back to the literal.
|
|
674
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/tags/${encodeURIComponent(entry.name)}`;
|
|
675
|
+
const body: {
|
|
676
|
+
description?: string;
|
|
677
|
+
parent_names?: string[];
|
|
678
|
+
fields?: Record<string, { type: "string" | "boolean" | "integer"; indexed?: boolean }>;
|
|
679
|
+
} = {};
|
|
680
|
+
if (entry.description !== undefined) body.description = entry.description;
|
|
681
|
+
if (entry.parent_names !== undefined) body.parent_names = entry.parent_names;
|
|
682
|
+
if (entry.fields !== undefined) body.fields = entry.fields;
|
|
683
|
+
|
|
684
|
+
const res = await fetch(url, {
|
|
685
|
+
method: "PUT",
|
|
686
|
+
headers: {
|
|
687
|
+
"content-type": "application/json",
|
|
688
|
+
authorization: `Bearer ${this.token}`,
|
|
689
|
+
},
|
|
690
|
+
body: JSON.stringify(body),
|
|
691
|
+
});
|
|
692
|
+
if (!res.ok) {
|
|
693
|
+
const detail = await res.text().catch(() => "");
|
|
694
|
+
console.warn(
|
|
695
|
+
`vault transport: tag-schema upsert for ${entry.name} failed (${res.status}) ${detail}`.trim(),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
} catch (err) {
|
|
699
|
+
// Vault unreachable / fetch rejected — non-fatal, the tag-both floor covers us.
|
|
700
|
+
console.warn(
|
|
701
|
+
`vault transport: tag-schema upsert for ${entry.name} errored: ${(err as Error).message}`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async stop(): Promise<void> {
|
|
708
|
+
// Nothing to release — inbound arrives via the daemon's webhook, not a poll.
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The channel name this transport is bound to (after start). */
|
|
712
|
+
private get channel(): string {
|
|
713
|
+
if (!this.ctx) throw new Error("vault transport: not started");
|
|
714
|
+
return this.ctx.channel;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// -------------------------------------------------------------------------
|
|
718
|
+
// Outbound — the session → vault direction. Write an OUTBOUND note.
|
|
719
|
+
// -------------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
async reply(args: ReplyArgs): Promise<{ sent: string[] }> {
|
|
722
|
+
const channel = this.channel;
|
|
723
|
+
const ts = new Date().toISOString();
|
|
724
|
+
const id = crypto.randomUUID();
|
|
725
|
+
// Sanitize the channel segment so an operator-configured name with a slash
|
|
726
|
+
// can't reshape the vault path hierarchy (the channel/prefix are operator
|
|
727
|
+
// config, not external input, but keep the path a flat, predictable slug).
|
|
728
|
+
const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
729
|
+
const path = `${this.pathPrefix}/${safeChannel}/${id}`;
|
|
730
|
+
|
|
731
|
+
const metadata: Record<string, string> = {
|
|
732
|
+
// The routing key — written under `metadata.agent` ONLY (the channel→agent
|
|
733
|
+
// CONTRACT dropped the `channel` dual-write). `noteAgentKey` still reads
|
|
734
|
+
// `agent ?? channel` as a tolerance fallback for any in-flight straggler.
|
|
735
|
+
agent: channel,
|
|
736
|
+
// `direction` stays as a human/UI convenience field. The loop-avoidance
|
|
737
|
+
// source of truth is now the `#agent/message/outbound` TAG below — the
|
|
738
|
+
// trigger fires on the inbound child tag only, so this note never wakes us.
|
|
739
|
+
direction: "outbound",
|
|
740
|
+
sender: "session",
|
|
741
|
+
ts,
|
|
742
|
+
};
|
|
743
|
+
// Thread the reply to the inbound note id when the bridge passes it through.
|
|
744
|
+
const inReplyTo = args.meta?.in_reply_to;
|
|
745
|
+
if (inReplyTo) metadata.in_reply_to = inReplyTo;
|
|
746
|
+
// The explicit definition→thread→message link: stamp the outbound note with its thread
|
|
747
|
+
// id (the programmatic worker passes the per-turn thread id through `meta.thread`). For a
|
|
748
|
+
// multi-threaded turn this IS the per-fire `#agent/thread` note's leaf; for a
|
|
749
|
+
// single-threaded turn it's a per-turn correlation id. INBOUND-note stamping is deferred
|
|
750
|
+
// (those notes are written externally, before the turn knows its thread).
|
|
751
|
+
const threadId = args.meta?.thread;
|
|
752
|
+
if (threadId) metadata.thread = threadId;
|
|
753
|
+
|
|
754
|
+
const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
|
|
755
|
+
method: "POST",
|
|
756
|
+
headers: {
|
|
757
|
+
"content-type": "application/json",
|
|
758
|
+
authorization: `Bearer ${this.token}`,
|
|
759
|
+
},
|
|
760
|
+
body: JSON.stringify({
|
|
761
|
+
content: args.text ?? "",
|
|
762
|
+
path,
|
|
763
|
+
// Parent (queryable membership) + directional child (trigger discriminator).
|
|
764
|
+
// Both literal — the slash child is NOT queryable under the parent on its own.
|
|
765
|
+
tags: [AGENT_MESSAGE_TAG, AGENT_MESSAGE_OUTBOUND_TAG],
|
|
766
|
+
metadata,
|
|
767
|
+
}),
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
if (!res.ok) {
|
|
771
|
+
const detail = await res.text().catch(() => "");
|
|
772
|
+
throw new Error(
|
|
773
|
+
`vault transport: write reply failed (${res.status}) ${detail}`.trim(),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// The vault returns the created note; surface its id. Fall back to the id we
|
|
778
|
+
// proposed in the path if the response shape is unexpected.
|
|
779
|
+
let noteId: string = id;
|
|
780
|
+
try {
|
|
781
|
+
const created = (await res.json()) as { id?: string; note?: { id?: string } };
|
|
782
|
+
noteId = created?.id ?? created?.note?.id ?? id;
|
|
783
|
+
} catch {
|
|
784
|
+
// Non-JSON / empty body — keep the proposed id.
|
|
785
|
+
}
|
|
786
|
+
return { sent: [noteId] };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The DETERMINISTIC path of a single-threaded agent's ONE thread note —
|
|
791
|
+
* `Threads/<safeChannel>/<safeName>` (named after the def). The single shared
|
|
792
|
+
* source of truth for that path so {@link writeThread} (the upsert) and
|
|
793
|
+
* {@link readThreadSession} (the pre-turn session read) can never disagree on
|
|
794
|
+
* where the note lives. Sanitizes both segments to a flat, predictable slug.
|
|
795
|
+
*
|
|
796
|
+
* COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
|
|
797
|
+
* on the same channel would share this note. Acceptable because the registry enforces
|
|
798
|
+
* ONE agent per channel (byChannel index), so the collision can't arise in practice.
|
|
799
|
+
*/
|
|
800
|
+
private singleThreadedPath(channel: string, name: string): string {
|
|
801
|
+
const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
802
|
+
const safeName = (name ?? channel).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
803
|
+
return `${THREAD_PATH_PREFIX}/${safeChannel}/${safeName}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Materialize a `#agent/thread` note for ONE completed turn — the UNIFIED model
|
|
808
|
+
* (`definition -> thread -> message`). Written for BOTH execution-lifecycle modes
|
|
809
|
+
* (the structural unification): EVERYTHING is a thread, a "run" was always a thread
|
|
810
|
+
* with one turn. The note BODY is a rolling SUMMARY of the thread; the metadata is the
|
|
811
|
+
* thread state. The INDEXED fields (`status`/`definition`/`mode`) make threads
|
|
812
|
+
* operator-queryable. This note carries `['#agent/thread']` EXACTLY — NOT a
|
|
813
|
+
* `#agent/message`, NO inbound child — so it can never wake a session (no loop).
|
|
814
|
+
*
|
|
815
|
+
* The MODE governs the thread's IDENTITY + whether it upserts:
|
|
816
|
+
* - `single-threaded` — ONE thread note per channel at the DETERMINISTIC stable path
|
|
817
|
+
* `Threads/<safeChannel>/<safeName>` (named after the definition). It UPSERTS in
|
|
818
|
+
* place across turns: we READ the existing note first, then write the rolled-up
|
|
819
|
+
* aggregates (`turn_count` incremented, cumulative `usage`, original `started_at`).
|
|
820
|
+
* - `multi-threaded` — one thread note PER FIRE at `Threads/<safeChannel>/<uuid>`
|
|
821
|
+
* (today one fire = one thread; turn_count = 1; usage = this turn's). NO upsert.
|
|
822
|
+
*
|
|
823
|
+
* SAFETY of the read-modify-write for single-threaded: the drain is SERIAL per channel
|
|
824
|
+
* AND single-threaded is one-thread-per-channel today, so there's no concurrent writer
|
|
825
|
+
* to lose an update against. WHEN CONTINUATION brings concurrent threads per channel,
|
|
826
|
+
* switch to re-deriving aggregates from the `#agent/message` children or a vault
|
|
827
|
+
* atomic-merge, to avoid lost-update.
|
|
828
|
+
*
|
|
829
|
+
* Best-effort caller-side: a throw is surfaced to the registry, which logs it (a missing
|
|
830
|
+
* thread note never re-runs the turn — same "don't retry" posture as outbound).
|
|
831
|
+
*/
|
|
832
|
+
async writeThread(thread: ThreadRecord): Promise<{ sent: string[] }> {
|
|
833
|
+
const safeChannel = thread.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
834
|
+
const singleThreaded = thread.mode === "single-threaded";
|
|
835
|
+
|
|
836
|
+
// IDENTITY by mode (HARD CONSTRAINT 3 — the path leaf IS the thread's identity; no
|
|
837
|
+
// ambiguous `thread_id` metadata field). single-threaded: a DETERMINISTIC leaf named
|
|
838
|
+
// after the def (the agent/spec name, sanitized) so the SAME note upserts across turns.
|
|
839
|
+
// multi-threaded: a fresh uuid per fire (one fire = one thread = one note today).
|
|
840
|
+
// COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
|
|
841
|
+
// on the same channel would upsert each other's thread note. Acceptable because the
|
|
842
|
+
// registry enforces ONE agent per channel (byChannel index), so the collision can't
|
|
843
|
+
// arise in practice.
|
|
844
|
+
// Multi-threaded leaf: a per-FIRE id. Reuse the caller's `threadId` when given (a
|
|
845
|
+
// re-record of the same turn — e.g. the outbound-failure status flip — targets the
|
|
846
|
+
// SAME per-fire note instead of minting a duplicate); else mint a fresh one. Single-
|
|
847
|
+
// threaded uses the DETERMINISTIC path (named after the def) so the one-per-channel
|
|
848
|
+
// note upserts — computed via {@link singleThreadedPath} so writeThread and
|
|
849
|
+
// readThreadSession agree on exactly where the note lives.
|
|
850
|
+
const path = singleThreaded
|
|
851
|
+
? this.singleThreadedPath(thread.channel, thread.name ?? thread.channel)
|
|
852
|
+
: `${THREAD_PATH_PREFIX}/${safeChannel}/${thread.threadId ?? crypto.randomUUID()}`;
|
|
853
|
+
|
|
854
|
+
// For single-threaded UPSERT, read the existing thread note (by its deterministic
|
|
855
|
+
// path) to roll up the aggregates. SAFE because the drain is serial per channel and
|
|
856
|
+
// single-threaded is one-thread-per-channel today (see the method doc) — there's no
|
|
857
|
+
// concurrent writer to lose an update against.
|
|
858
|
+
// WHEN CONTINUATION brings concurrent threads per channel, switch to re-deriving
|
|
859
|
+
// aggregates from the #agent/message children or a vault atomic-merge, to avoid
|
|
860
|
+
// lost-update.
|
|
861
|
+
let priorTurnCount = 0;
|
|
862
|
+
let priorInputTokens = 0;
|
|
863
|
+
let priorOutputTokens = 0;
|
|
864
|
+
let priorCostUsd = 0;
|
|
865
|
+
let priorStartedAt: string | undefined;
|
|
866
|
+
let priorLastTurnAt: string | undefined;
|
|
867
|
+
let priorSession: string | undefined;
|
|
868
|
+
if (singleThreaded) {
|
|
869
|
+
const prior = await this.readThreadNote(path);
|
|
870
|
+
if (prior) {
|
|
871
|
+
priorTurnCount = numFromMeta(prior.metadata?.turn_count);
|
|
872
|
+
priorInputTokens = numFromMeta(prior.metadata?.input_tokens);
|
|
873
|
+
priorOutputTokens = numFromMeta(prior.metadata?.output_tokens);
|
|
874
|
+
priorCostUsd = numFromMeta(prior.metadata?.total_cost_usd);
|
|
875
|
+
if (typeof prior.metadata?.started_at === "string" && prior.metadata.started_at) {
|
|
876
|
+
priorStartedAt = prior.metadata.started_at;
|
|
877
|
+
}
|
|
878
|
+
if (typeof prior.metadata?.last_turn_at === "string" && prior.metadata.last_turn_at) {
|
|
879
|
+
priorLastTurnAt = prior.metadata.last_turn_at;
|
|
880
|
+
}
|
|
881
|
+
// The persisted Claude session UUID — captured so a write that carries NO
|
|
882
|
+
// session (a start-phase working-ensure) PRESERVES it across the upsert rather
|
|
883
|
+
// than dropping continuity (the thread≡session record).
|
|
884
|
+
if (typeof prior.metadata?.session === "string" && prior.metadata.session) {
|
|
885
|
+
priorSession = prior.metadata.session;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── THREAD-AS-CONTAINER turn_count discipline (the no-double-count invariant) ─────────
|
|
891
|
+
// `phase: "start"` is the WORKING-ENSURE written BEFORE the turn — NO turn has completed
|
|
892
|
+
// yet, so it must NOT advance turn_count: single-threaded writes `turn_count = prior`
|
|
893
|
+
// (UNCHANGED), multi-threaded writes 0 (the per-fire note is being created mid-turn).
|
|
894
|
+
// `phase: "end"` (or absent — back-compat) is the FINAL record AFTER the turn, which is
|
|
895
|
+
// where the turn is COUNTED: single-threaded increments `prior + 1` (UNLESS `sameTurn`,
|
|
896
|
+
// the ok→error outbound-failure re-record, which keeps the already-counted value), and
|
|
897
|
+
// multi-threaded is 1 (one fire = one thread = one turn). So across the start+end pair a
|
|
898
|
+
// turn is counted EXACTLY ONCE (on `end`) — never double-counted.
|
|
899
|
+
const isStart = thread.phase === "start";
|
|
900
|
+
let turnCount: number;
|
|
901
|
+
if (isStart) {
|
|
902
|
+
turnCount = singleThreaded ? priorTurnCount : 0;
|
|
903
|
+
} else if (singleThreaded) {
|
|
904
|
+
turnCount = thread.sameTurn ? priorTurnCount : priorTurnCount + 1;
|
|
905
|
+
} else {
|
|
906
|
+
turnCount = 1;
|
|
907
|
+
}
|
|
908
|
+
// `started_at` is set ONCE on create (preserve the prior on upsert). `last_turn_at`
|
|
909
|
+
// advances only when a turn COMPLETES (the `end` write); a `start` working-ensure leaves
|
|
910
|
+
// it at the prior value (single) or empty (multi-create — no turn has completed yet).
|
|
911
|
+
const startedAt = priorStartedAt ?? thread.started_at;
|
|
912
|
+
const lastTurnAt = isStart ? (priorLastTurnAt ?? "") : thread.ended_at;
|
|
913
|
+
|
|
914
|
+
// Cumulative usage: single-threaded SUMS this turn into the prior totals; multi-threaded
|
|
915
|
+
// carries just this turn's (one fire = one thread).
|
|
916
|
+
const inputTokens =
|
|
917
|
+
(singleThreaded ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
|
|
918
|
+
const outputTokens =
|
|
919
|
+
(singleThreaded ? priorOutputTokens : 0) + (thread.usage?.outputTokens ?? 0);
|
|
920
|
+
const costUsd = (singleThreaded ? priorCostUsd : 0) + (thread.usage?.totalCostUsd ?? 0);
|
|
921
|
+
|
|
922
|
+
// Indexed string fields (queryable) + the thread-state observability fields. The
|
|
923
|
+
// vault stores metadata as strings; numbers are stringified.
|
|
924
|
+
const metadata: Record<string, string> = {
|
|
925
|
+
// The routing key — `metadata.agent` ONLY (the channel→agent CONTRACT).
|
|
926
|
+
agent: thread.channel,
|
|
927
|
+
mode: thread.mode,
|
|
928
|
+
status: thread.status,
|
|
929
|
+
started_at: startedAt,
|
|
930
|
+
turn_count: String(turnCount),
|
|
931
|
+
};
|
|
932
|
+
// `last_turn_at` is only meaningful once a turn has COMPLETED. A `start` working-ensure on
|
|
933
|
+
// a brand-new thread (no prior turn) has no last-turn time yet → omit it rather than
|
|
934
|
+
// stamp an empty string (which would index as a present-but-blank value).
|
|
935
|
+
if (lastTurnAt) metadata.last_turn_at = lastTurnAt;
|
|
936
|
+
if (thread.definition) metadata.definition = thread.definition;
|
|
937
|
+
// The thread≡session record: persist the Claude session UUID onto the note so the
|
|
938
|
+
// NEXT turn can `--resume` it. Prefer the session this write carries; else (a write
|
|
939
|
+
// with no session, e.g. a start-phase working-ensure) PRESERVE the prior single-
|
|
940
|
+
// threaded note's session so an upsert never drops continuity. Multi-threaded carries
|
|
941
|
+
// its own per-fire session each write (no preserve — each fire is a fresh thread).
|
|
942
|
+
const session = thread.session ?? (singleThreaded ? priorSession : undefined);
|
|
943
|
+
if (session) metadata.session = session;
|
|
944
|
+
// Usage is always present once a turn carried it OR we accumulated any — emit the
|
|
945
|
+
// running totals so a query sees cumulative cost for the thread.
|
|
946
|
+
if (singleThreaded || thread.usage) {
|
|
947
|
+
if (inputTokens) metadata.input_tokens = String(inputTokens);
|
|
948
|
+
if (outputTokens) metadata.output_tokens = String(outputTokens);
|
|
949
|
+
// Round the accumulated cost to 9 decimals before serializing — summing floats
|
|
950
|
+
// (e.g. 0.1 + 0.2) accrues IEEE-754 drift, so a naive String() yields
|
|
951
|
+
// "0.30000000000000004". 9 decimals covers sub-cent costs without losing precision.
|
|
952
|
+
if (costUsd) metadata.total_cost_usd = String(Math.round(costUsd * 1e9) / 1e9);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const body = buildThreadSummaryBody({
|
|
956
|
+
name: thread.name ?? thread.channel,
|
|
957
|
+
mode: thread.mode,
|
|
958
|
+
turnCount,
|
|
959
|
+
status: thread.status,
|
|
960
|
+
lastTurnAt,
|
|
961
|
+
input: thread.input,
|
|
962
|
+
output: thread.output,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Upsert by path via PATCH + `if_missing: "create"` (vault#309) — NOT POST. POST
|
|
966
|
+
// /api/notes 409s `path_conflict` on an existing path (it does not upsert), so a
|
|
967
|
+
// single-threaded thread note would create on turn 1 and 409 on every turn after.
|
|
968
|
+
// PATCH-by-path is the real upsert: the vault resolves the (decoded) path, UPDATES it
|
|
969
|
+
// when present (single-threaded turn 2+: content replaced, metadata merged) or CREATES
|
|
970
|
+
// it when missing (turn 1, and every multi-threaded fresh-uuid fire). `force: true`
|
|
971
|
+
// satisfies the vault's 428 mutation precondition (mirrors `setInboundStatus`). The
|
|
972
|
+
// path is one URL segment (percent-encoded `/`); the route `decodeURIComponent`s it.
|
|
973
|
+
// The `tags` array is consumed ONLY by the create branch. VERIFIED against the vault
|
|
974
|
+
// (`routes.ts`): the PATCH UPDATE branch reads `tags.add` / `tags.remove` (the delta
|
|
975
|
+
// shape), NOT a plain `tags` array — so sending `tags: [AGENT_THREAD_TAG]` here is
|
|
976
|
+
// INERT on update (the note's existing tag is preserved untouched) and only takes
|
|
977
|
+
// effect on the if_missing:create branch. So the single tag is set once at create and
|
|
978
|
+
// preserved across every subsequent upsert (HARD CONSTRAINT 4 — loop-safe single tag).
|
|
979
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
|
|
980
|
+
const res = await fetch(url, {
|
|
981
|
+
method: "PATCH",
|
|
982
|
+
headers: {
|
|
983
|
+
"content-type": "application/json",
|
|
984
|
+
authorization: `Bearer ${this.token}`,
|
|
985
|
+
},
|
|
986
|
+
body: JSON.stringify({
|
|
987
|
+
content: body,
|
|
988
|
+
path,
|
|
989
|
+
tags: [AGENT_THREAD_TAG],
|
|
990
|
+
metadata,
|
|
991
|
+
if_missing: "create",
|
|
992
|
+
force: true,
|
|
993
|
+
}),
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
if (!res.ok) {
|
|
997
|
+
const detail = await res.text().catch(() => "");
|
|
998
|
+
throw new Error(`vault transport: write thread note failed (${res.status}) ${detail}`.trim());
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
let noteId: string = path;
|
|
1002
|
+
try {
|
|
1003
|
+
const created = (await res.json()) as { id?: string; note?: { id?: string } };
|
|
1004
|
+
noteId = created?.id ?? created?.note?.id ?? path;
|
|
1005
|
+
} catch {
|
|
1006
|
+
// Non-JSON / empty body — keep the path as the addressable id.
|
|
1007
|
+
}
|
|
1008
|
+
return { sent: [noteId] };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Read a single thread note by its deterministic PATH (the single-threaded upsert
|
|
1013
|
+
* read-back). The vault's `GET .../api/notes/<id-or-path>` resolves a note by id OR
|
|
1014
|
+
* path; we percent-encode the path's `/` so it's one URL segment. Returns the note
|
|
1015
|
+
* (metadata + content) or undefined when it doesn't exist yet (a 404 on the first
|
|
1016
|
+
* turn) or the vault is unreachable — the caller treats "no prior" as turn_count 0.
|
|
1017
|
+
* Throws on an UNEXPECTED non-ok response (not 404) so a misconfig surfaces.
|
|
1018
|
+
*/
|
|
1019
|
+
private async readThreadNote(
|
|
1020
|
+
path: string,
|
|
1021
|
+
): Promise<{ metadata?: Record<string, unknown>; content?: string } | undefined> {
|
|
1022
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
|
|
1023
|
+
let res: Response;
|
|
1024
|
+
try {
|
|
1025
|
+
res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
// Vault unreachable — treat as "no prior" (we'll create fresh; aggregates reset).
|
|
1028
|
+
// SURFACE it: a flaky vault silently resetting a thread's turn_count/usage is a
|
|
1029
|
+
// data-quality bug we want visible in logs. Still return undefined so the upsert
|
|
1030
|
+
// proceeds (don't strand the queue on a transient network blip).
|
|
1031
|
+
console.warn(
|
|
1032
|
+
`parachute-agent: readThreadNote network error — thread aggregates reset for ${path}: ${(err as Error).message}`,
|
|
1033
|
+
);
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
if (res.status === 404) return undefined; // first turn — note doesn't exist yet.
|
|
1037
|
+
if (!res.ok) {
|
|
1038
|
+
const detail = await res.text().catch(() => "");
|
|
1039
|
+
throw new Error(`vault transport: read thread note failed (${res.status}) ${detail}`.trim());
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
const parsed = (await res.json()) as unknown;
|
|
1043
|
+
// Tolerate a bare note object OR a `{ note: {...} }` envelope OR a 1-element array.
|
|
1044
|
+
if (Array.isArray(parsed)) {
|
|
1045
|
+
return parsed[0] as { metadata?: Record<string, unknown>; content?: string } | undefined;
|
|
1046
|
+
}
|
|
1047
|
+
const obj = parsed as { note?: unknown; metadata?: unknown; content?: unknown };
|
|
1048
|
+
if (obj.note && typeof obj.note === "object") {
|
|
1049
|
+
return obj.note as { metadata?: Record<string, unknown>; content?: string };
|
|
1050
|
+
}
|
|
1051
|
+
return obj as { metadata?: Record<string, unknown>; content?: string };
|
|
1052
|
+
} catch {
|
|
1053
|
+
// Bad JSON — treat as no prior (don't strand the write on a parse hiccup).
|
|
1054
|
+
return undefined;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** The persisted Claude session UUID for a single-threaded agent's deterministic
|
|
1059
|
+
* thread note, or undefined if none yet (first turn). Read before a turn so the
|
|
1060
|
+
* daemon can --resume it. */
|
|
1061
|
+
async readThreadSession(channel: string, name: string): Promise<string | undefined> {
|
|
1062
|
+
const prior = await this.readThreadNote(this.singleThreadedPath(channel, name));
|
|
1063
|
+
const s = prior?.metadata?.session;
|
|
1064
|
+
return typeof s === "string" && s ? s : undefined;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Clear a single-threaded agent's persisted session so its next turn starts a
|
|
1068
|
+
* fresh Claude conversation (the per-agent restart). No-op if no thread note yet. */
|
|
1069
|
+
async clearThreadSession(channel: string, name: string): Promise<void> {
|
|
1070
|
+
const path = this.singleThreadedPath(channel, name);
|
|
1071
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
|
|
1072
|
+
const res = await fetch(url, {
|
|
1073
|
+
method: "PATCH",
|
|
1074
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
1075
|
+
body: JSON.stringify({ metadata: { session: "" }, force: true }),
|
|
1076
|
+
});
|
|
1077
|
+
if (res.status === 404) return; // no thread yet = already fresh
|
|
1078
|
+
if (!res.ok) {
|
|
1079
|
+
const detail = await res.text().catch(() => "");
|
|
1080
|
+
throw new Error(`vault transport: clear thread session failed (${res.status}) ${detail}`.trim());
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// react / edit / download: vault has no reactions; v1 is reply-only. Omitted.
|
|
1085
|
+
|
|
1086
|
+
// -------------------------------------------------------------------------
|
|
1087
|
+
// Transcript — read the durable store the chat + Telegram + any vault surface
|
|
1088
|
+
// all share. The chat polls this; on send it writes an inbound note (below).
|
|
1089
|
+
// -------------------------------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Read this channel's whole transcript (both directions) from the vault and
|
|
1093
|
+
* map it to `ChannelMessage[]`, sorted ascending by `ts`.
|
|
1094
|
+
*
|
|
1095
|
+
* The query is the canonical "list a channel's transcript" shape from the
|
|
1096
|
+
* tagging model: the parent message tag (carried literally on every note) + a
|
|
1097
|
+
* routing-key filter (`noteAgentKey(meta) == <this channel>`). Because the parent
|
|
1098
|
+
* is on every note, this returns BOTH inbound and outbound — the slash children are
|
|
1099
|
+
* namespace, not query inheritance, so we never key off them here.
|
|
1100
|
+
*
|
|
1101
|
+
* GET <vaultUrl>/vault/<vault>/api/notes
|
|
1102
|
+
* ?tag=%23agent%2Fmessage (the `#` + `/` MUST be percent-encoded)
|
|
1103
|
+
* &include_content=true (we need the bodies)
|
|
1104
|
+
* &limit=<n> (default 200)
|
|
1105
|
+
*
|
|
1106
|
+
* The vault returns a bare JSON array of note objects ({id, content, tags,
|
|
1107
|
+
* metadata, ...}). Direction comes from `metadata.direction`, falling back to
|
|
1108
|
+
* the inbound/outbound CHILD tag if the metadata field is missing. On a non-ok
|
|
1109
|
+
* vault response we throw with a clear message — the daemon route maps it to an
|
|
1110
|
+
* error the chat surfaces (no silent empty transcript).
|
|
1111
|
+
*/
|
|
1112
|
+
async loadTranscript(opts?: { limit?: number }): Promise<ChannelMessage[]> {
|
|
1113
|
+
const channel = this.channel;
|
|
1114
|
+
const limit = opts?.limit ?? 200;
|
|
1115
|
+
// Query by the parent TAG only and filter to this channel CLIENT-SIDE. We do
|
|
1116
|
+
// NOT use the `?metadata={channel:{eq:...}}` operator filter: an operator
|
|
1117
|
+
// query on `channel` requires that field to be declared `indexed: true` in the
|
|
1118
|
+
// vault's tag schema, which we can't assume (the vault returns HTTP 400
|
|
1119
|
+
// FIELD_NOT_INDEXED otherwise). Tagging-both + client-side filter is the
|
|
1120
|
+
// module's index-free floor (same philosophy as the tag-both write) — it works
|
|
1121
|
+
// on any vault with no per-vault schema setup. (Declaring the channel field
|
|
1122
|
+
// indexed is a future scale optimization, not a requirement.)
|
|
1123
|
+
//
|
|
1124
|
+
// Because the tag query returns notes across ALL channels, OVERFETCH so this
|
|
1125
|
+
// channel's recent history isn't crowded out by other channels' interleaved
|
|
1126
|
+
// notes, then keep the most recent `limit` for this channel below.
|
|
1127
|
+
const fetchLimit = Math.min(Math.max(limit * 4, 500), 2000);
|
|
1128
|
+
|
|
1129
|
+
type RawNote = {
|
|
1130
|
+
id?: string;
|
|
1131
|
+
content?: string;
|
|
1132
|
+
tags?: string[];
|
|
1133
|
+
metadata?: Record<string, unknown>;
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
// Fetch one parent tag's notes; throws with a clear message on a non-ok vault
|
|
1137
|
+
// response or bad JSON (the daemon maps it to a surfaced error — no silent
|
|
1138
|
+
// empty transcript).
|
|
1139
|
+
const fetchByTag = async (tag: string): Promise<RawNote[]> => {
|
|
1140
|
+
const params = new URLSearchParams();
|
|
1141
|
+
params.set("tag", tag); // URLSearchParams encodes `#` → `%23`
|
|
1142
|
+
params.set("include_content", "true");
|
|
1143
|
+
params.set("limit", String(fetchLimit));
|
|
1144
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
|
|
1145
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
1146
|
+
if (!res.ok) {
|
|
1147
|
+
const detail = await res.text().catch(() => "");
|
|
1148
|
+
throw new Error(
|
|
1149
|
+
`vault transport: load transcript failed (${res.status}) ${detail}`.trim(),
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
const parsed = (await res.json()) as unknown;
|
|
1154
|
+
// The structured-query route returns a bare array; tolerate a `{notes:[]}`
|
|
1155
|
+
// envelope too in case a future shape wraps it.
|
|
1156
|
+
return Array.isArray(parsed)
|
|
1157
|
+
? (parsed as RawNote[])
|
|
1158
|
+
: ((parsed as { notes?: RawNote[] })?.notes ?? []);
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
throw new Error(
|
|
1161
|
+
`vault transport: load transcript — bad JSON from vault: ${(err as Error).message}`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
// Query the single `#agent/message` parent tag (the channel→agent CONTRACT
|
|
1167
|
+
// dropped the legacy `#channel-message` / interim `#agent-message` union).
|
|
1168
|
+
const notes = await fetchByTag(AGENT_MESSAGE_TAG);
|
|
1169
|
+
|
|
1170
|
+
const messages: ChannelMessage[] = [];
|
|
1171
|
+
for (const note of notes) {
|
|
1172
|
+
if (typeof note.id !== "string" || !note.id) continue;
|
|
1173
|
+
const meta = note.metadata ?? {};
|
|
1174
|
+
// Client-side routing-key filter (see the index-free note above): keep only
|
|
1175
|
+
// notes whose routing key matches this channel (`noteAgentKey` reads `agent`).
|
|
1176
|
+
if (noteAgentKey(meta) !== channel) continue;
|
|
1177
|
+
const tags = note.tags ?? [];
|
|
1178
|
+
// Direction: prefer the explicit metadata field; fall back to the outbound child tag.
|
|
1179
|
+
let direction: "inbound" | "outbound";
|
|
1180
|
+
if (meta.direction === "inbound" || meta.direction === "outbound") {
|
|
1181
|
+
direction = meta.direction;
|
|
1182
|
+
} else if (tags.includes(AGENT_MESSAGE_OUTBOUND_TAG)) {
|
|
1183
|
+
direction = "outbound";
|
|
1184
|
+
} else {
|
|
1185
|
+
// Default to inbound (a human message) when neither signal is present —
|
|
1186
|
+
// it renders as "you", the safe default for an unlabeled note.
|
|
1187
|
+
direction = "inbound";
|
|
1188
|
+
}
|
|
1189
|
+
const msg: ChannelMessage = {
|
|
1190
|
+
id: note.id,
|
|
1191
|
+
text: typeof note.content === "string" ? note.content : "",
|
|
1192
|
+
direction,
|
|
1193
|
+
sender: typeof meta.sender === "string" ? meta.sender : "",
|
|
1194
|
+
ts: typeof meta.ts === "string" ? meta.ts : "",
|
|
1195
|
+
};
|
|
1196
|
+
if (typeof meta.in_reply_to === "string") msg.inReplyTo = meta.in_reply_to;
|
|
1197
|
+
messages.push(msg);
|
|
1198
|
+
}
|
|
1199
|
+
// Ascending by ts; notes with no ts sort first (stable, deterministic).
|
|
1200
|
+
messages.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
1201
|
+
// Keep the most recent `limit` for this channel (we overfetched the tag).
|
|
1202
|
+
return messages.length > limit ? messages.slice(messages.length - limit) : messages;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Write a human→session INBOUND note — the chat's "send". This mirrors
|
|
1207
|
+
* `reply()` exactly except the tags + direction: the inbound CHILD tag
|
|
1208
|
+
* (`#agent/message/inbound`) is what the vault trigger fires on, so writing
|
|
1209
|
+
* this note WAKES the subscribed session via the existing vault trigger. We do
|
|
1210
|
+
* NOT also `ctx.emit` — that would double-wake (one wake from the trigger, one
|
|
1211
|
+
* from here). The trigger is the single wake path; this is purely the write.
|
|
1212
|
+
*
|
|
1213
|
+
* Returns the created note id so the chat can dedup its optimistic local echo
|
|
1214
|
+
* against the same id when the note round-trips through the next poll.
|
|
1215
|
+
*/
|
|
1216
|
+
async writeInbound(
|
|
1217
|
+
text: string,
|
|
1218
|
+
sender?: string,
|
|
1219
|
+
/**
|
|
1220
|
+
* Extra metadata to STAMP onto the inbound note (e.g. the agent-to-agent callback
|
|
1221
|
+
* contract). Merged AFTER the base fields but BEFORE the non-overridable invariants
|
|
1222
|
+
* (`agent`/`direction` always win — an inbound note must route + be inbound). A caller
|
|
1223
|
+
* must NEVER pass `reply_to` here for a CALLBACK note (the terminal-callback loop guard);
|
|
1224
|
+
* see {@link writeCallback}.
|
|
1225
|
+
*/
|
|
1226
|
+
extraMeta?: Record<string, string>,
|
|
1227
|
+
): Promise<{ id: string }> {
|
|
1228
|
+
const channel = this.channel;
|
|
1229
|
+
const ts = new Date().toISOString();
|
|
1230
|
+
const id = crypto.randomUUID();
|
|
1231
|
+
const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1232
|
+
const path = `${this.pathPrefix}/${safeChannel}/${id}`;
|
|
1233
|
+
|
|
1234
|
+
const metadata: Record<string, string> = {
|
|
1235
|
+
// Caller-supplied extra fields first, so the invariants below cannot be clobbered.
|
|
1236
|
+
...(extraMeta ?? {}),
|
|
1237
|
+
// The routing key under `metadata.agent` ONLY (the channel→agent CONTRACT
|
|
1238
|
+
// dropped the `channel` dual-write). This is the inbound path the vault trigger
|
|
1239
|
+
// fires on — the trigger keys on `has_metadata:["agent"]` to match it.
|
|
1240
|
+
agent: channel,
|
|
1241
|
+
direction: "inbound",
|
|
1242
|
+
sender: sender ?? "operator",
|
|
1243
|
+
ts,
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
|
|
1247
|
+
method: "POST",
|
|
1248
|
+
headers: {
|
|
1249
|
+
"content-type": "application/json",
|
|
1250
|
+
authorization: `Bearer ${this.token}`,
|
|
1251
|
+
},
|
|
1252
|
+
body: JSON.stringify({
|
|
1253
|
+
content: text,
|
|
1254
|
+
path,
|
|
1255
|
+
// Parent (queryable membership) + inbound child (the trigger discriminator
|
|
1256
|
+
// that wakes the session). Both literal — the child alone is invisible to
|
|
1257
|
+
// a `tag:#agent/message` query.
|
|
1258
|
+
tags: [AGENT_MESSAGE_TAG, AGENT_MESSAGE_INBOUND_TAG],
|
|
1259
|
+
metadata,
|
|
1260
|
+
}),
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
if (!res.ok) {
|
|
1264
|
+
const detail = await res.text().catch(() => "");
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`vault transport: write inbound failed (${res.status}) ${detail}`.trim(),
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let noteId: string = id;
|
|
1271
|
+
try {
|
|
1272
|
+
const created = (await res.json()) as { id?: string; note?: { id?: string } };
|
|
1273
|
+
noteId = created?.id ?? created?.note?.id ?? id;
|
|
1274
|
+
} catch {
|
|
1275
|
+
// Non-JSON / empty body — keep the proposed id.
|
|
1276
|
+
}
|
|
1277
|
+
return { id: noteId };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Inject an inbound message AUTHORED BY THE RUNNER (a scheduled job firing) —
|
|
1282
|
+
* design `2026-06-17-runner-scheduled-agent-turns.md`. This is the runner's
|
|
1283
|
+
* ONLY seam into the transport: a scheduled job is "an automated human," so
|
|
1284
|
+
* firing it = writing an inbound note exactly like a human typing in chat. The
|
|
1285
|
+
* existing vault trigger → agent-turn → outbound flow does the rest; the runner
|
|
1286
|
+
* never touches the turn.
|
|
1287
|
+
*
|
|
1288
|
+
* Mechanically this is `writeInbound` with runner provenance: BOTH the parent
|
|
1289
|
+
* `#agent/message` (queryable) and the inbound child `#agent/message/inbound`
|
|
1290
|
+
* (the trigger discriminator that wakes the session), `direction: "inbound"`,
|
|
1291
|
+
* and `sender` defaulting to a `runner:<jobId>` marker so the transcript shows
|
|
1292
|
+
* who authored it. We deliberately do NOT stamp `channel_inbound_rendered_at`
|
|
1293
|
+
* (so the trigger fires), and we do NOT `ctx.emit` (the trigger is the single
|
|
1294
|
+
* wake path — emitting too would double-wake). Reuses the channel's existing
|
|
1295
|
+
* `vault:<name>:write` token — the runner mints nothing and adds no authority.
|
|
1296
|
+
*
|
|
1297
|
+
* Returns the created note id (for logging / the "run now" response). Kept a
|
|
1298
|
+
* thin wrapper over `writeInbound` so the inbound write path has ONE
|
|
1299
|
+
* implementation; only the default sender differs.
|
|
1300
|
+
*/
|
|
1301
|
+
async injectInbound(opts: { content: string; sender?: string }): Promise<{ id: string }> {
|
|
1302
|
+
return this.writeInbound(opts.content, opts.sender ?? "runner");
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Write an agent-to-agent CALLBACK as an INBOUND note on THIS channel — the "reply_to"
|
|
1307
|
+
* substrate. A recipient agent's drain, on turn completion, calls this on the SENDER's
|
|
1308
|
+
* channel transport (resolved by the daemon's buildWriteCallback) so the sender is woken
|
|
1309
|
+
* with a completion notification through the NORMAL inbound path: this writes a
|
|
1310
|
+
* `#agent/message/inbound` note (parent + inbound child tags), the vault trigger fires,
|
|
1311
|
+
* webhooks back, and the daemon routes it to the sender's agent — exactly like a human's
|
|
1312
|
+
* chat send. The callback `content` is a brief notification + link; the metadata is the
|
|
1313
|
+
* {@link CallbackMetadata} contract (`source_*` for the orchestrator to PULL the result).
|
|
1314
|
+
*
|
|
1315
|
+
* LOOP GUARD (structural): we stamp the callback metadata but NEVER a `reply_to` — a
|
|
1316
|
+
* callback is terminal, so handling it can't auto-trigger another callback. We defensively
|
|
1317
|
+
* STRIP any `reply_to` from the incoming meta to make that invariant impossible to violate
|
|
1318
|
+
* even if a caller mistakenly supplied one. `sender` is a `callback:<source_channel>`
|
|
1319
|
+
* marker so the transcript shows who/what authored it.
|
|
1320
|
+
*
|
|
1321
|
+
* Reuses {@link writeInbound} (the one inbound-write implementation), passing the callback
|
|
1322
|
+
* fields as its `extraMeta`. Returns the written note id.
|
|
1323
|
+
*/
|
|
1324
|
+
async writeCallback(content: string, meta: CallbackMetadata): Promise<{ sent: string[] }> {
|
|
1325
|
+
// Defense-in-depth: never let a `reply_to` ride on a callback note (the terminal-callback
|
|
1326
|
+
// loop guard). The CallbackMetadata type has no reply_to, but we strip explicitly in case
|
|
1327
|
+
// a future caller widens the shape — a callback that carries reply_to would ping-pong.
|
|
1328
|
+
const { reply_to: _stripReplyTo, ...safe } = meta as CallbackMetadata & { reply_to?: string };
|
|
1329
|
+
void _stripReplyTo;
|
|
1330
|
+
const extraMeta: Record<string, string> = {
|
|
1331
|
+
callback: safe.callback,
|
|
1332
|
+
status: safe.status,
|
|
1333
|
+
source_channel: safe.source_channel,
|
|
1334
|
+
source_thread: safe.source_thread,
|
|
1335
|
+
delegation_depth: safe.delegation_depth,
|
|
1336
|
+
...(safe.source_message ? { source_message: safe.source_message } : {}),
|
|
1337
|
+
...(safe.correlation_id ? { correlation_id: safe.correlation_id } : {}),
|
|
1338
|
+
};
|
|
1339
|
+
const { id } = await this.writeInbound(content, `callback:${safe.source_channel}`, extraMeta);
|
|
1340
|
+
return { sent: [id] };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// -------------------------------------------------------------------------
|
|
1344
|
+
// Channel-queue inbound notes — the durable queue a CHANNEL-backend agent's
|
|
1345
|
+
// connected session pulls from (design 2026-06-18-channel-backend.md). The
|
|
1346
|
+
// inbound `#agent/message/inbound` notes themselves ARE the queue; the claim
|
|
1347
|
+
// `status` (pending | in-flight | handled) lives on each note so the vault is
|
|
1348
|
+
// the source of truth (restart-safe). These methods own the vault I/O (URL +
|
|
1349
|
+
// token + encoding) so the AttachedQueueRegistry stays storage-agnostic — the
|
|
1350
|
+
// same separation jobs.ts has from the job-note I/O. The channel's existing
|
|
1351
|
+
// `vault:<name>:write` token covers GET + the status PATCH; no new mint.
|
|
1352
|
+
// -------------------------------------------------------------------------
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* List THIS channel's INBOUND queue notes (the `#agent/message/inbound` notes),
|
|
1356
|
+
* ascending by `ts` (oldest first), carrying the claim `status`/`claimedAt`/`updatedAt`.
|
|
1357
|
+
* The query is index-free, mirroring {@link loadTranscript}: query by the inbound
|
|
1358
|
+
* CHILD tag (we want inbound only — outbound replies are not queue items) and
|
|
1359
|
+
* filter to this channel CLIENT-SIDE on `metadata.channel` (we don't assume a
|
|
1360
|
+
* `channel` index). A note with NO `status` field reads as `pending` (a fresh
|
|
1361
|
+
* inbound the trigger just created). Throws on a non-ok vault response so the
|
|
1362
|
+
* caller surfaces a clear error rather than a silently-empty queue.
|
|
1363
|
+
*
|
|
1364
|
+
* QUEUE-CAP TRUNCATION FIX (agent#103). Over time `handled` notes accumulate; the
|
|
1365
|
+
* tag query is capped (the vault limit), so once enough `handled` notes precede the
|
|
1366
|
+
* still-`pending` ones, the pending notes fall OUTSIDE the cap and are never claimed
|
|
1367
|
+
* (a silently-stuck queue). The vault's `status` metadata isn't indexed (we can't
|
|
1368
|
+
* assume a per-vault schema), so we can't filter `status:pending` server-side. So we
|
|
1369
|
+
* EXCLUDE `handled` notes CLIENT-SIDE — the live queue is only `pending` + `in-flight`
|
|
1370
|
+
* — and additionally REQUEST the cap descending (newest first) so when the raw note
|
|
1371
|
+
* count itself exceeds the cap, it's the OLDEST `handled` notes that get dropped, never
|
|
1372
|
+
* a recent `pending`. The two together keep the actionable queue (pending + in-flight)
|
|
1373
|
+
* intact regardless of how many `handled` notes have piled up. (Declaring `status`
|
|
1374
|
+
* indexed for a true server-side `status != handled` filter is a future scale
|
|
1375
|
+
* optimization, not a correctness requirement.)
|
|
1376
|
+
*/
|
|
1377
|
+
async listInboundQueue(opts?: { limit?: number }): Promise<InboundQueueNote[]> {
|
|
1378
|
+
const channel = this.channel;
|
|
1379
|
+
const limit = opts?.limit ?? 200;
|
|
1380
|
+
// Overfetch (the tag query spans all channels) then keep this channel's items.
|
|
1381
|
+
const fetchLimit = Math.min(Math.max(limit * 4, 500), 2000);
|
|
1382
|
+
const params = new URLSearchParams();
|
|
1383
|
+
params.set("tag", AGENT_MESSAGE_INBOUND_TAG); // → %23agent%2Fmessage%2Finbound
|
|
1384
|
+
params.set("include_content", "true");
|
|
1385
|
+
params.set("limit", String(fetchLimit));
|
|
1386
|
+
// NEWEST-first at the vault (default order_by is `updated_at`) so a hard cap drops
|
|
1387
|
+
// the OLDEST notes (the long-settled `handled` ones), never a recent pending. We
|
|
1388
|
+
// re-sort ascending below for the queue. The vault param is `sort` (asc|desc).
|
|
1389
|
+
params.set("sort", "desc");
|
|
1390
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
|
|
1391
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
1392
|
+
if (!res.ok) {
|
|
1393
|
+
const detail = await res.text().catch(() => "");
|
|
1394
|
+
throw new Error(`vault transport: list inbound queue failed (${res.status}) ${detail}`.trim());
|
|
1395
|
+
}
|
|
1396
|
+
type RawNote = {
|
|
1397
|
+
id?: string;
|
|
1398
|
+
content?: string;
|
|
1399
|
+
metadata?: Record<string, unknown>;
|
|
1400
|
+
updated_at?: string;
|
|
1401
|
+
updatedAt?: string;
|
|
1402
|
+
};
|
|
1403
|
+
let notes: RawNote[];
|
|
1404
|
+
try {
|
|
1405
|
+
const parsed = (await res.json()) as unknown;
|
|
1406
|
+
notes = Array.isArray(parsed)
|
|
1407
|
+
? (parsed as RawNote[])
|
|
1408
|
+
: ((parsed as { notes?: RawNote[] })?.notes ?? []);
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
throw new Error(
|
|
1411
|
+
`vault transport: list inbound queue — bad JSON from vault: ${(err as Error).message}`,
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
const out: InboundQueueNote[] = [];
|
|
1415
|
+
for (const note of notes) {
|
|
1416
|
+
if (typeof note.id !== "string" || !note.id) continue;
|
|
1417
|
+
const meta = note.metadata ?? {};
|
|
1418
|
+
if (noteAgentKey(meta) !== channel) continue; // client-side filter (index-free); noteAgentKey reads `agent` (channel fallback for stragglers).
|
|
1419
|
+
const status = coerceInboundStatus(meta[STATUS_META_KEY]);
|
|
1420
|
+
// Drop `handled` notes — they are not queue items (#103). Only pending + in-flight
|
|
1421
|
+
// make up the actionable queue; counting/returning handled would let them crowd
|
|
1422
|
+
// the live queue out of the cap.
|
|
1423
|
+
if (status === "handled") continue;
|
|
1424
|
+
const updatedAt =
|
|
1425
|
+
typeof note.updated_at === "string"
|
|
1426
|
+
? note.updated_at
|
|
1427
|
+
: typeof note.updatedAt === "string"
|
|
1428
|
+
? note.updatedAt
|
|
1429
|
+
: undefined;
|
|
1430
|
+
out.push({
|
|
1431
|
+
id: note.id,
|
|
1432
|
+
text: typeof note.content === "string" ? note.content : "",
|
|
1433
|
+
sender: typeof meta.sender === "string" ? meta.sender : "",
|
|
1434
|
+
ts: typeof meta.ts === "string" ? meta.ts : "",
|
|
1435
|
+
status,
|
|
1436
|
+
...(typeof meta[CLAIMED_AT_META_KEY] === "string"
|
|
1437
|
+
? { claimedAt: meta[CLAIMED_AT_META_KEY] as string }
|
|
1438
|
+
: {}),
|
|
1439
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
// Ascending by ts; blank-ts notes sort first (stable, deterministic).
|
|
1443
|
+
out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
1444
|
+
return out;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* PATCH an inbound note's claim status (+ optionally `claimedAt`), by note id.
|
|
1449
|
+
* Sends ONLY the changed metadata; the vault MERGES it, so the channel/direction/
|
|
1450
|
+
* sender/ts are preserved. Passing `claimedAt: null` CLEARS the field (written as
|
|
1451
|
+
* an empty string) — used on release/handled so a stale claim timestamp doesn't
|
|
1452
|
+
* linger.
|
|
1453
|
+
*
|
|
1454
|
+
* COMPARE-AND-SWAP (agent#101). When `ifUpdatedAt` is given, the PATCH carries
|
|
1455
|
+
* `if_updated_at` (the note's last-seen `updated_at`) as the vault's optimistic-
|
|
1456
|
+
* concurrency precondition instead of `force: true` — so a CLAIM only lands if the
|
|
1457
|
+
* note hasn't changed since it was read. A STALE precondition (another session
|
|
1458
|
+
* already claimed it) makes the vault return **409** (`conflict`); an ABSENT one (if
|
|
1459
|
+
* the note carried no `updated_at` to send) would 428 — either way we throw
|
|
1460
|
+
* {@link InboundClaimConflictError} so the caller re-lists and skips to the next
|
|
1461
|
+
* pending message rather than double-claiming. When `ifUpdatedAt` is OMITTED (the
|
|
1462
|
+
* release/handled/sweep paths, which are last-write-wins by design) the PATCH uses
|
|
1463
|
+
* `force: true` as before. Any OTHER non-ok status throws a plain Error.
|
|
1464
|
+
*/
|
|
1465
|
+
async setInboundStatus(
|
|
1466
|
+
id: string,
|
|
1467
|
+
status: InboundStatus,
|
|
1468
|
+
claimedAt?: string | null,
|
|
1469
|
+
ifUpdatedAt?: string,
|
|
1470
|
+
): Promise<void> {
|
|
1471
|
+
const metadata: Record<string, string> = { [STATUS_META_KEY]: status };
|
|
1472
|
+
if (claimedAt !== undefined) {
|
|
1473
|
+
metadata[CLAIMED_AT_META_KEY] = claimedAt === null ? "" : claimedAt;
|
|
1474
|
+
}
|
|
1475
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`;
|
|
1476
|
+
// CAS when an `ifUpdatedAt` precondition is supplied; otherwise last-write-wins via
|
|
1477
|
+
// `force` (the prior behavior, kept for release/handled/sweep).
|
|
1478
|
+
const body =
|
|
1479
|
+
ifUpdatedAt !== undefined
|
|
1480
|
+
? { metadata, if_updated_at: ifUpdatedAt }
|
|
1481
|
+
: { metadata, force: true };
|
|
1482
|
+
const res = await fetch(url, {
|
|
1483
|
+
method: "PATCH",
|
|
1484
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
1485
|
+
body: JSON.stringify(body),
|
|
1486
|
+
});
|
|
1487
|
+
if (!res.ok) {
|
|
1488
|
+
// 409 (stale precondition) / 428 (precondition required) on a CAS attempt = the
|
|
1489
|
+
// claim race was lost → a typed conflict the caller re-lists on.
|
|
1490
|
+
if (ifUpdatedAt !== undefined && (res.status === 409 || res.status === 428)) {
|
|
1491
|
+
throw new InboundClaimConflictError(id, res.status);
|
|
1492
|
+
}
|
|
1493
|
+
const detail = await res.text().catch(() => "");
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`vault transport: set inbound status ${id} failed (${res.status}) ${detail}`.trim(),
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// -------------------------------------------------------------------------
|
|
1501
|
+
// Scheduled-job notes — the runner's VAULT-NATIVE job store (design
|
|
1502
|
+
// 2026-06-17). A job IS a `#agent/job` note in THIS channel's vault. These
|
|
1503
|
+
// methods own the vault I/O (URL + token + encoding) so jobs.ts stays a thin,
|
|
1504
|
+
// storage-agnostic facade — token handling lives in ONE place (the transport),
|
|
1505
|
+
// mirroring loadTranscript / writeInbound. The channel's existing
|
|
1506
|
+
// `vault:<name>:write` token covers all of GET/POST/PATCH/DELETE — no new mint.
|
|
1507
|
+
// -------------------------------------------------------------------------
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* List the scheduled-job notes in THIS channel's vault. Queries by the parent
|
|
1511
|
+
* `#agent/job` tag (URLSearchParams encodes `#`→`%23`, `/`→`%2F`) and returns ALL job
|
|
1512
|
+
* notes in the vault — the CALLER filters by `metadata.channel` (same index-free
|
|
1513
|
+
* pattern as loadTranscript; we don't assume a `channel` index exists). Throws
|
|
1514
|
+
* on a non-ok vault response so the API surfaces a clear error rather than a
|
|
1515
|
+
* silently-empty list.
|
|
1516
|
+
*/
|
|
1517
|
+
async listJobNotes(opts?: { limit?: number }): Promise<JobNote[]> {
|
|
1518
|
+
const limit = opts?.limit ?? 500;
|
|
1519
|
+
const params = new URLSearchParams();
|
|
1520
|
+
params.set("tag", AGENT_JOB_TAG); // → %23agent%2Fjob
|
|
1521
|
+
params.set("include_content", "true");
|
|
1522
|
+
params.set("limit", String(limit));
|
|
1523
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
|
|
1524
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
1525
|
+
if (!res.ok) {
|
|
1526
|
+
const detail = await res.text().catch(() => "");
|
|
1527
|
+
throw new Error(`vault transport: list jobs failed (${res.status}) ${detail}`.trim());
|
|
1528
|
+
}
|
|
1529
|
+
let notes: Array<{ id?: string; content?: string; metadata?: Record<string, unknown> }>;
|
|
1530
|
+
try {
|
|
1531
|
+
const parsed = (await res.json()) as unknown;
|
|
1532
|
+
notes = Array.isArray(parsed)
|
|
1533
|
+
? (parsed as typeof notes)
|
|
1534
|
+
: ((parsed as { notes?: typeof notes })?.notes ?? []);
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
throw new Error(`vault transport: list jobs — bad JSON from vault: ${(err as Error).message}`);
|
|
1537
|
+
}
|
|
1538
|
+
const jobs: JobNote[] = [];
|
|
1539
|
+
for (const note of notes) {
|
|
1540
|
+
if (typeof note.id !== "string" || !note.id) continue;
|
|
1541
|
+
const m = note.metadata ?? {};
|
|
1542
|
+
const channel = noteAgentKey(m) ?? ""; // routing key via noteAgentKey (`agent`, channel fallback for stragglers).
|
|
1543
|
+
const cron = typeof m.cron === "string" ? m.cron : "";
|
|
1544
|
+
if (!channel || !cron) continue; // not a well-formed job note; skip.
|
|
1545
|
+
// The operator-facing id is the slug in `metadata.jobId`; fall back to the
|
|
1546
|
+
// note id for a note written before that field existed.
|
|
1547
|
+
const slug = typeof m.jobId === "string" && m.jobId ? m.jobId : note.id;
|
|
1548
|
+
const job: JobNote = {
|
|
1549
|
+
id: slug,
|
|
1550
|
+
noteId: note.id,
|
|
1551
|
+
message: typeof note.content === "string" ? note.content : "",
|
|
1552
|
+
channel,
|
|
1553
|
+
cron,
|
|
1554
|
+
// The vault stores metadata as strings; "false" (and only "false") disables.
|
|
1555
|
+
enabled: String(m.enabled) !== "false",
|
|
1556
|
+
};
|
|
1557
|
+
if (typeof m.tz === "string" && m.tz) job.tz = m.tz;
|
|
1558
|
+
if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
|
|
1559
|
+
if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
|
|
1560
|
+
if (typeof m.lastStatus === "string") job.lastStatus = m.lastStatus;
|
|
1561
|
+
jobs.push(job);
|
|
1562
|
+
}
|
|
1563
|
+
return jobs;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Create OR replace a job note at a deterministic path (`Channels/<ch>/jobs/<id>`)
|
|
1568
|
+
* so an upsert by the same job id overwrites in place. The vault upserts by path
|
|
1569
|
+
* on POST. Returns the created/updated note id. `nextRunAt` is NEVER written
|
|
1570
|
+
* (recomputed in memory by the runner).
|
|
1571
|
+
*/
|
|
1572
|
+
async upsertJobNote(job: {
|
|
1573
|
+
id: string;
|
|
1574
|
+
message: string;
|
|
1575
|
+
channel: string;
|
|
1576
|
+
cron: string;
|
|
1577
|
+
tz?: string;
|
|
1578
|
+
enabled: boolean;
|
|
1579
|
+
createdAt: string;
|
|
1580
|
+
lastRunAt?: string;
|
|
1581
|
+
lastStatus?: string;
|
|
1582
|
+
}): Promise<{ id: string }> {
|
|
1583
|
+
const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1584
|
+
const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1585
|
+
const path = `${JOB_PATH_PREFIX}/${safeChannel}/jobs/${safeId}`;
|
|
1586
|
+
const metadata: JobNoteMetadata = {
|
|
1587
|
+
jobId: job.id, // the operator-facing slug, so it survives the vault's note-id assignment.
|
|
1588
|
+
// The routing key under `metadata.agent` ONLY (the channel→agent CONTRACT).
|
|
1589
|
+
agent: job.channel,
|
|
1590
|
+
cron: job.cron,
|
|
1591
|
+
enabled: job.enabled ? "true" : "false",
|
|
1592
|
+
createdAt: job.createdAt,
|
|
1593
|
+
};
|
|
1594
|
+
if (job.tz) metadata.tz = job.tz;
|
|
1595
|
+
if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
|
|
1596
|
+
if (job.lastStatus) metadata.lastStatus = job.lastStatus;
|
|
1597
|
+
|
|
1598
|
+
const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
|
|
1599
|
+
method: "POST",
|
|
1600
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
1601
|
+
body: JSON.stringify({ content: job.message, path, tags: [AGENT_JOB_TAG], metadata }),
|
|
1602
|
+
});
|
|
1603
|
+
if (!res.ok) {
|
|
1604
|
+
const detail = await res.text().catch(() => "");
|
|
1605
|
+
throw new Error(`vault transport: write job failed (${res.status}) ${detail}`.trim());
|
|
1606
|
+
}
|
|
1607
|
+
let noteId = path;
|
|
1608
|
+
try {
|
|
1609
|
+
const created = (await res.json()) as { id?: string; note?: { id?: string } };
|
|
1610
|
+
noteId = created?.id ?? created?.note?.id ?? path;
|
|
1611
|
+
} catch {
|
|
1612
|
+
// Non-JSON / empty body — keep the path as the addressable id.
|
|
1613
|
+
}
|
|
1614
|
+
return { id: noteId };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* PATCH a job note's bookkeeping metadata (lastRunAt / lastStatus) after a fire,
|
|
1619
|
+
* by note id. We send ONLY the changed metadata fields; the vault merges them.
|
|
1620
|
+
* Best-effort on the runner's side (a failed status-write is logged, not fatal),
|
|
1621
|
+
* so this throws and the caller decides — the runner swallows it.
|
|
1622
|
+
*/
|
|
1623
|
+
async patchJobNote(
|
|
1624
|
+
id: string,
|
|
1625
|
+
fields: { lastRunAt?: string; lastStatus?: string; enabled?: boolean },
|
|
1626
|
+
): Promise<void> {
|
|
1627
|
+
const metadata: Record<string, string> = {};
|
|
1628
|
+
if (fields.lastRunAt !== undefined) metadata.lastRunAt = fields.lastRunAt;
|
|
1629
|
+
if (fields.lastStatus !== undefined) metadata.lastStatus = fields.lastStatus;
|
|
1630
|
+
if (fields.enabled !== undefined) metadata.enabled = fields.enabled ? "true" : "false";
|
|
1631
|
+
const res = await fetch(
|
|
1632
|
+
`${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`,
|
|
1633
|
+
{
|
|
1634
|
+
method: "PATCH",
|
|
1635
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
1636
|
+
// `force: true` satisfies the vault's mutation precondition (428 without
|
|
1637
|
+
// `if_updated_at`/`force`). Safe: lastRunAt/lastStatus/enabled are the
|
|
1638
|
+
// runner's OWN bookkeeping fields, no content in the body, and the vault
|
|
1639
|
+
// MERGES metadata so the job's cron/message/etc. are preserved. (Without
|
|
1640
|
+
// this the runner's status-write silently 428'd.)
|
|
1641
|
+
body: JSON.stringify({ metadata, force: true }),
|
|
1642
|
+
},
|
|
1643
|
+
);
|
|
1644
|
+
if (!res.ok) {
|
|
1645
|
+
const detail = await res.text().catch(() => "");
|
|
1646
|
+
throw new Error(`vault transport: patch job failed (${res.status}) ${detail}`.trim());
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** Delete a job note by id. Throws on a non-ok vault response. */
|
|
1651
|
+
async deleteJobNote(id: string): Promise<void> {
|
|
1652
|
+
const res = await fetch(
|
|
1653
|
+
`${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`,
|
|
1654
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${this.token}` } },
|
|
1655
|
+
);
|
|
1656
|
+
if (!res.ok) {
|
|
1657
|
+
const detail = await res.text().catch(() => "");
|
|
1658
|
+
throw new Error(`vault transport: delete job failed (${res.status}) ${detail}`.trim());
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// -------------------------------------------------------------------------
|
|
1663
|
+
// Inbound — the daemon's webhook hands us a new inbound note to deliver.
|
|
1664
|
+
// -------------------------------------------------------------------------
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Fetch the attachment list for an inbound note from the vault REST API
|
|
1668
|
+
* (`GET <vaultUrl>/vault/<vault>/api/notes/<id>/attachments`, Bearer the channel's
|
|
1669
|
+
* existing vault token). Returns the surfaced {@link InboundAttachment} refs (one per
|
|
1670
|
+
* vault attachment that carries a usable `path`), or `[]` on ANY failure (best-effort —
|
|
1671
|
+
* a missing/unreachable attachment list must NEVER drop the inbound message; the turn
|
|
1672
|
+
* still runs with the text). The note id is percent-encoded as one path segment.
|
|
1673
|
+
*
|
|
1674
|
+
* Phase 1: the bytes are NOT fetched here — the programmatic backend stages them from
|
|
1675
|
+
* `<vaultUrl>/.../api/storage/<path>` into the agent's private workspace. This method
|
|
1676
|
+
* only surfaces the refs (path/mimeType/filename).
|
|
1677
|
+
*/
|
|
1678
|
+
async fetchInboundAttachments(noteId: string): Promise<InboundAttachment[]> {
|
|
1679
|
+
const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}/attachments`;
|
|
1680
|
+
let res: Response;
|
|
1681
|
+
try {
|
|
1682
|
+
res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
console.warn(
|
|
1685
|
+
`parachute-agent: fetch attachments for inbound note ${noteId} errored (proceeding ` +
|
|
1686
|
+
`with text only): ${(err as Error).message}`,
|
|
1687
|
+
);
|
|
1688
|
+
return [];
|
|
1689
|
+
}
|
|
1690
|
+
if (!res.ok) {
|
|
1691
|
+
const detail = await res.text().catch(() => "");
|
|
1692
|
+
console.warn(
|
|
1693
|
+
`parachute-agent: fetch attachments for inbound note ${noteId} failed (${res.status}) ` +
|
|
1694
|
+
`${detail} — proceeding with text only`.trim(),
|
|
1695
|
+
);
|
|
1696
|
+
return [];
|
|
1697
|
+
}
|
|
1698
|
+
let raw: unknown;
|
|
1699
|
+
try {
|
|
1700
|
+
raw = await res.json();
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
console.warn(
|
|
1703
|
+
`parachute-agent: fetch attachments for inbound note ${noteId} — bad JSON (proceeding ` +
|
|
1704
|
+
`with text only): ${(err as Error).message}`,
|
|
1705
|
+
);
|
|
1706
|
+
return [];
|
|
1707
|
+
}
|
|
1708
|
+
// Tolerate a bare array OR an `{ attachments: [...] }` envelope.
|
|
1709
|
+
const list: Array<{ path?: unknown; mimeType?: unknown; mime_type?: unknown }> = Array.isArray(raw)
|
|
1710
|
+
? (raw as typeof list)
|
|
1711
|
+
: (((raw as { attachments?: unknown }).attachments as typeof list | undefined) ?? []);
|
|
1712
|
+
const out: InboundAttachment[] = [];
|
|
1713
|
+
for (const a of list) {
|
|
1714
|
+
const path = typeof a.path === "string" ? a.path : "";
|
|
1715
|
+
if (!path) continue; // no storage path → nothing to fetch later; skip.
|
|
1716
|
+
const mimeType =
|
|
1717
|
+
typeof a.mimeType === "string"
|
|
1718
|
+
? a.mimeType
|
|
1719
|
+
: typeof a.mime_type === "string"
|
|
1720
|
+
? a.mime_type
|
|
1721
|
+
: "application/octet-stream";
|
|
1722
|
+
out.push({ path, mimeType, filename: basenameOf(path) });
|
|
1723
|
+
}
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Deliver an inbound `#agent/message/inbound` note onto this channel: emit it
|
|
1729
|
+
* so the subscribed bridge / MCP session wakes. Called by the daemon's
|
|
1730
|
+
* `/api/vault/inbound` webhook after it has resolved the channel.
|
|
1731
|
+
*
|
|
1732
|
+
* Belt-and-suspenders over the trigger predicate: a note tagged outbound
|
|
1733
|
+
* (`#agent/message/outbound`) OR explicitly `direction: "outbound"` is IGNORED —
|
|
1734
|
+
* we never wake on our own reply, even if a mis-wired trigger delivers one.
|
|
1735
|
+
*
|
|
1736
|
+
* ATTACHMENTS (Phase 1). When the note carries attachments inline (the vault
|
|
1737
|
+
* `send: "json"` trigger payload includes `note.attachments`), we fetch the
|
|
1738
|
+
* authoritative attachment list (REST) and surface the refs on the emitted
|
|
1739
|
+
* {@link InboundMessage.attachments} so the programmatic backend can stage the
|
|
1740
|
+
* bytes for the turn. The fetch is best-effort: a failure logs + the message is
|
|
1741
|
+
* still emitted with the text (never dropped). When the note has NO attachments
|
|
1742
|
+
* inline, NO fetch happens and emit is SYNCHRONOUS (today's behavior unchanged) —
|
|
1743
|
+
* the only async path is the attachments-present case.
|
|
1744
|
+
*/
|
|
1745
|
+
async ingestInbound(note: InboundNote): Promise<void> {
|
|
1746
|
+
if (!this.ctx) throw new Error("vault transport: not started");
|
|
1747
|
+
const meta = note.metadata ?? {};
|
|
1748
|
+
const tags = note.tags ?? [];
|
|
1749
|
+
if (tags.includes(AGENT_MESSAGE_OUTBOUND_TAG) || meta.direction === "outbound") {
|
|
1750
|
+
return; // our own reply — never wake on it.
|
|
1751
|
+
}
|
|
1752
|
+
// Flatten the note's metadata into the inbound meta (string-valued), then
|
|
1753
|
+
// stamp our own provenance fields. `source`/`note_id`/`direction` are set
|
|
1754
|
+
// explicitly so they win over anything in the note's metadata.
|
|
1755
|
+
const flatMeta: Record<string, string> = {};
|
|
1756
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
1757
|
+
flatMeta[k] = typeof v === "string" ? v : String(v);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Only reach for the attachment list when the inline payload signals there ARE
|
|
1761
|
+
// attachments — so the no-attachment path emits WITHOUT a network round-trip (and
|
|
1762
|
+
// stays synchronous-before-await, preserving the existing fire-and-forget callers).
|
|
1763
|
+
const hasInline =
|
|
1764
|
+
Array.isArray(note.attachments) &&
|
|
1765
|
+
note.attachments.some((a) => typeof a?.path === "string" && a.path.length > 0);
|
|
1766
|
+
let attachments: InboundAttachment[] = [];
|
|
1767
|
+
if (hasInline) {
|
|
1768
|
+
attachments = await this.fetchInboundAttachments(note.id);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
this.ctx.emit({
|
|
1772
|
+
// `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
|
|
1773
|
+
// note metadata) — left as the channel name. The routing key rides in `meta.agent`.
|
|
1774
|
+
channel: this.ctx.channel,
|
|
1775
|
+
content: note.content ?? "",
|
|
1776
|
+
meta: {
|
|
1777
|
+
...flatMeta,
|
|
1778
|
+
// The routing key on the in-memory event meta under `agent` ONLY (the
|
|
1779
|
+
// channel→agent CONTRACT dropped the `channel` dual-write).
|
|
1780
|
+
agent: this.ctx.channel,
|
|
1781
|
+
source: "vault",
|
|
1782
|
+
note_id: note.id,
|
|
1783
|
+
sender: typeof meta.sender === "string" ? meta.sender : "",
|
|
1784
|
+
direction: "inbound",
|
|
1785
|
+
},
|
|
1786
|
+
source: "vault",
|
|
1787
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|