@openparachute/agent 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +124 -8
- package/LICENSE +2 -16
- package/README.md +118 -166
- package/package.json +35 -42
- package/scripts/spawn-agent.ts +371 -0
- package/src/_parked/interactive-spawn.test.ts +324 -0
- package/src/_parked/interactive-spawn.ts +701 -0
- package/src/agent-defs.test.ts +1504 -0
- package/src/agent-defs.ts +1702 -0
- package/src/agent-mcp-config.test.ts +115 -0
- package/src/agent-mcp-config.ts +115 -0
- package/src/agents.test.ts +360 -0
- package/src/agents.ts +379 -0
- package/src/auth.test.ts +46 -0
- package/src/auth.ts +140 -0
- package/src/backends/attached-queue.test.ts +376 -0
- package/src/backends/attached-queue.ts +372 -0
- package/src/backends/programmatic.test.ts +1715 -0
- package/src/backends/programmatic.ts +927 -0
- package/src/backends/registry.test.ts +1494 -0
- package/src/backends/registry.ts +1202 -0
- package/src/backends/stream-json.test.ts +570 -0
- package/src/backends/stream-json.ts +392 -0
- package/src/backends/types.ts +223 -0
- package/src/bridge.ts +417 -0
- package/src/channel-backend-wiring.test.ts +237 -0
- package/src/credentials.test.ts +274 -0
- package/src/credentials.ts +380 -0
- package/src/cron.test.ts +342 -0
- package/src/cron.ts +380 -0
- package/src/daemon-agent-def-api.test.ts +166 -0
- package/src/daemon-agent-defs-api.test.ts +953 -0
- package/src/daemon-agent-env-api.test.ts +338 -0
- package/src/daemon-attached-queue-store.test.ts +65 -0
- package/src/daemon-config-api.test.ts +962 -0
- package/src/daemon-jobs-api.test.ts +271 -0
- package/src/daemon-vault-chat.test.ts +250 -0
- package/src/daemon.test.ts +746 -0
- package/src/daemon.ts +3314 -0
- package/src/def-vaults.test.ts +136 -0
- package/src/def-vaults.ts +165 -0
- package/src/delivery-state.test.ts +110 -0
- package/src/delivery-state.ts +154 -0
- package/src/effective-env.test.ts +114 -0
- package/src/effective-env.ts +184 -0
- package/src/env-compat.ts +39 -0
- package/src/grants.test.ts +638 -0
- package/src/grants.ts +675 -0
- package/src/hub-jwt.test.ts +161 -0
- package/src/hub-jwt.ts +182 -0
- package/src/jobs.test.ts +245 -0
- package/src/jobs.ts +266 -0
- package/src/mcp-http.test.ts +265 -0
- package/src/mcp-http.ts +771 -0
- package/src/mint-token.test.ts +152 -0
- package/src/mint-token.ts +139 -0
- package/src/module-manifest.test.ts +158 -0
- package/src/oauth-discovery.ts +134 -0
- package/src/programmatic-wiring.test.ts +838 -0
- package/src/registry.test.ts +227 -0
- package/src/registry.ts +228 -0
- package/src/resolve-port.test.ts +64 -0
- package/src/routing.test.ts +184 -0
- package/src/routing.ts +76 -0
- package/src/runner.test.ts +506 -0
- package/src/runner.ts +255 -0
- package/src/sandbox/config.test.ts +150 -0
- package/src/sandbox/config.ts +102 -0
- package/src/sandbox/egress.test.ts +113 -0
- package/src/sandbox/egress.ts +123 -0
- package/src/sandbox/index.ts +180 -0
- package/src/sandbox/live-seatbelt.test.ts +277 -0
- package/src/sandbox/mounts.test.ts +154 -0
- package/src/sandbox/mounts.ts +133 -0
- package/src/sandbox/sandbox.test.ts +168 -0
- package/src/sandbox/types.ts +382 -0
- package/src/services-manifest.test.ts +106 -0
- package/src/services-manifest.ts +95 -0
- package/src/spa-serve.test.ts +116 -0
- package/src/spa-serve.ts +116 -0
- package/src/spawn-agent-cli.test.ts +172 -0
- package/src/spawn-agent.test.ts +1218 -0
- package/src/spawn-agent.ts +569 -0
- package/src/spawn-deps.test.ts +54 -0
- package/src/spawn-deps.ts +166 -0
- package/src/telegram/api.ts +153 -0
- package/src/terminal-assets.test.ts +50 -0
- package/src/terminal-assets.ts +79 -0
- package/src/terminal-ui.ts +305 -0
- package/src/terminal.test.ts +530 -0
- package/src/terminal.ts +458 -0
- package/src/transport.ts +270 -0
- package/src/transports/http-ui.test.ts +455 -0
- package/src/transports/http-ui.ts +201 -0
- package/src/transports/telegram.test.ts +174 -0
- package/src/transports/telegram.ts +426 -0
- package/src/transports/vault.test.ts +2011 -0
- package/src/transports/vault.ts +1790 -0
- package/src/ui-kit.test.ts +178 -0
- package/src/ui-kit.ts +402 -0
- package/tsconfig.json +8 -14
- package/web/ui/dist/assets/index-C-iWdFFV.css +1 -0
- package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
- package/web/ui/dist/index.html +15 -0
- package/web/ui/tsconfig.json +2 -1
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.json +0 -5
- package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
- package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
- package/.claude/skills/add-codex/SKILL.md +0 -161
- package/.claude/skills/add-dashboard/SKILL.md +0 -138
- package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
- package/.claude/skills/add-emacs/SKILL.md +0 -296
- package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
- package/.claude/skills/add-gchat/REMOVE.md +0 -6
- package/.claude/skills/add-gchat/SKILL.md +0 -92
- package/.claude/skills/add-gchat/VERIFY.md +0 -3
- package/.claude/skills/add-github/REMOVE.md +0 -6
- package/.claude/skills/add-github/SKILL.md +0 -148
- package/.claude/skills/add-github/VERIFY.md +0 -3
- package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
- package/.claude/skills/add-imessage/REMOVE.md +0 -6
- package/.claude/skills/add-imessage/SKILL.md +0 -113
- package/.claude/skills/add-imessage/VERIFY.md +0 -3
- package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
- package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
- package/.claude/skills/add-linear/REMOVE.md +0 -6
- package/.claude/skills/add-linear/SKILL.md +0 -168
- package/.claude/skills/add-linear/VERIFY.md +0 -3
- package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
- package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
- package/.claude/skills/add-matrix/REMOVE.md +0 -6
- package/.claude/skills/add-matrix/SKILL.md +0 -148
- package/.claude/skills/add-matrix/VERIFY.md +0 -3
- package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
- package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
- package/.claude/skills/add-opencode/SKILL.md +0 -229
- package/.claude/skills/add-parallel/SKILL.md +0 -290
- package/.claude/skills/add-resend/REMOVE.md +0 -6
- package/.claude/skills/add-resend/SKILL.md +0 -93
- package/.claude/skills/add-resend/VERIFY.md +0 -3
- package/.claude/skills/add-signal/REMOVE.md +0 -13
- package/.claude/skills/add-signal/SKILL.md +0 -318
- package/.claude/skills/add-signal/VERIFY.md +0 -5
- package/.claude/skills/add-slack/REMOVE.md +0 -6
- package/.claude/skills/add-slack/SKILL.md +0 -112
- package/.claude/skills/add-slack/VERIFY.md +0 -3
- package/.claude/skills/add-teams/REMOVE.md +0 -6
- package/.claude/skills/add-teams/SKILL.md +0 -207
- package/.claude/skills/add-teams/VERIFY.md +0 -3
- package/.claude/skills/add-vercel/SKILL.md +0 -147
- package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
- package/.claude/skills/add-webex/REMOVE.md +0 -6
- package/.claude/skills/add-webex/SKILL.md +0 -88
- package/.claude/skills/add-webex/VERIFY.md +0 -3
- package/.claude/skills/add-wechat/REMOVE.md +0 -49
- package/.claude/skills/add-wechat/SKILL.md +0 -170
- package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
- package/.claude/skills/add-whatsapp/SKILL.md +0 -264
- package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
- package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
- package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
- package/.claude/skills/claw/SKILL.md +0 -131
- package/.claude/skills/claw/scripts/claw +0 -374
- package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
- package/.claude/skills/customize/SKILL.md +0 -110
- package/.claude/skills/debug/SKILL.md +0 -349
- package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
- package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
- package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
- package/.claude/skills/init-first-agent/SKILL.md +0 -120
- package/.claude/skills/init-onecli/SKILL.md +0 -270
- package/.claude/skills/manage-channels/SKILL.md +0 -87
- package/.claude/skills/manage-mounts/SKILL.md +0 -47
- package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
- package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
- package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
- package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
- package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
- package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
- package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
- package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
- package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
- package/.claude/skills/update-skills/SKILL.md +0 -130
- package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
- package/.claude/skills/x-integration/SKILL.md +0 -417
- package/.claude/skills/x-integration/agent.ts +0 -243
- package/.claude/skills/x-integration/host.ts +0 -155
- package/.claude/skills/x-integration/lib/browser.ts +0 -148
- package/.claude/skills/x-integration/lib/config.ts +0 -62
- package/.claude/skills/x-integration/scripts/like.ts +0 -56
- package/.claude/skills/x-integration/scripts/post.ts +0 -66
- package/.claude/skills/x-integration/scripts/quote.ts +0 -80
- package/.claude/skills/x-integration/scripts/reply.ts +0 -74
- package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
- package/.claude/skills/x-integration/scripts/setup.ts +0 -87
- package/.github/CODEOWNERS +0 -10
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
- package/.github/workflows/bump-version.yml +0 -35
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/label-pr.yml +0 -40
- package/.github/workflows/update-tokens.yml +0 -43
- package/.husky/pre-commit +0 -1
- package/.mcp.json +0 -3
- package/.nvmrc +0 -1
- package/.prettierrc +0 -4
- package/CHANGELOG.md +0 -263
- package/CLAUDE.md +0 -307
- package/CODE_OF_CONDUCT.md +0 -128
- package/CONTRIBUTING.md +0 -159
- package/CONTRIBUTORS.md +0 -26
- package/LICENSE-NANOCLAW-MIT +0 -21
- package/README_ja.md +0 -194
- package/README_zh.md +0 -194
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +0 -25
- package/container/.dockerignore +0 -2
- package/container/CLAUDE.md +0 -21
- package/container/Dockerfile +0 -121
- package/container/agent-runner/bun.lock +0 -243
- package/container/agent-runner/package.json +0 -22
- package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
- package/container/agent-runner/src/config.ts +0 -55
- package/container/agent-runner/src/db/connection.ts +0 -267
- package/container/agent-runner/src/db/index.ts +0 -20
- package/container/agent-runner/src/db/messages-in.ts +0 -138
- package/container/agent-runner/src/db/messages-out.ts +0 -143
- package/container/agent-runner/src/db/session-routing.ts +0 -30
- package/container/agent-runner/src/db/session-state.test.ts +0 -100
- package/container/agent-runner/src/db/session-state.ts +0 -79
- package/container/agent-runner/src/destinations.ts +0 -135
- package/container/agent-runner/src/formatter.test.ts +0 -167
- package/container/agent-runner/src/formatter.ts +0 -260
- package/container/agent-runner/src/index.ts +0 -110
- package/container/agent-runner/src/integration.test.ts +0 -121
- package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
- package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
- package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
- package/container/agent-runner/src/mcp-tools/core.ts +0 -262
- package/container/agent-runner/src/mcp-tools/index.ts +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
- package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
- package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
- package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
- package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
- package/container/agent-runner/src/mcp-tools/server.ts +0 -54
- package/container/agent-runner/src/mcp-tools/types.ts +0 -6
- package/container/agent-runner/src/poll-loop.test.ts +0 -248
- package/container/agent-runner/src/poll-loop.ts +0 -437
- package/container/agent-runner/src/providers/claude.ts +0 -379
- package/container/agent-runner/src/providers/factory.test.ts +0 -19
- package/container/agent-runner/src/providers/factory.ts +0 -13
- package/container/agent-runner/src/providers/index.ts +0 -6
- package/container/agent-runner/src/providers/mock.ts +0 -77
- package/container/agent-runner/src/providers/provider-registry.ts +0 -33
- package/container/agent-runner/src/providers/types.ts +0 -82
- package/container/agent-runner/src/scheduling/task-script.ts +0 -121
- package/container/agent-runner/src/timezone.test.ts +0 -93
- package/container/agent-runner/src/timezone.ts +0 -107
- package/container/agent-runner/tsconfig.json +0 -14
- package/container/build.sh +0 -48
- package/container/entrypoint.sh +0 -16
- package/container/skills/agent-browser/SKILL.md +0 -159
- package/container/skills/frontend-engineer/SKILL.md +0 -157
- package/container/skills/self-customize/SKILL.md +0 -87
- package/container/skills/slack-formatting/SKILL.md +0 -94
- package/container/skills/vercel-cli/SKILL.md +0 -111
- package/container/skills/welcome/SKILL.md +0 -85
- package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
- package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
- package/docs/README.md +0 -25
- package/docs/SDK_DEEP_DIVE.md +0 -643
- package/docs/SECURITY.md +0 -162
- package/docs/agent-runner-details.md +0 -749
- package/docs/api-details.md +0 -365
- package/docs/architecture-diagram.html +0 -422
- package/docs/architecture-diagram.md +0 -215
- package/docs/architecture.md +0 -751
- package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
- package/docs/build-and-runtime.md +0 -80
- package/docs/cross-mount-stress/README.md +0 -112
- package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
- package/docs/cross-mount-stress/container-writer.mjs +0 -47
- package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
- package/docs/cross-mount-stress/host-writer.mjs +0 -47
- package/docs/db-central.md +0 -316
- package/docs/db-session.md +0 -183
- package/docs/db.md +0 -119
- package/docs/design/2026-04-29-vault-management-ui.md +0 -231
- package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
- package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
- package/docs/docker-sandboxes.md +0 -359
- package/docs/isolation-model.md +0 -88
- package/docs/ollama.md +0 -79
- package/docs/parachute-integration.md +0 -109
- package/docs/post-night-rebirth-reflections.md +0 -151
- package/eslint.config.js +0 -32
- package/pnpm-workspace.yaml +0 -8
- package/repo-tokens/README.md +0 -113
- package/repo-tokens/action.yml +0 -186
- package/repo-tokens/badge.svg +0 -23
- package/repo-tokens/examples/green.svg +0 -14
- package/repo-tokens/examples/red.svg +0 -14
- package/repo-tokens/examples/yellow-green.svg +0 -14
- package/repo-tokens/examples/yellow.svg +0 -14
- package/scripts/chat.ts +0 -101
- package/scripts/cleanup-sessions.sh +0 -150
- package/scripts/init-cli-agent.ts +0 -172
- package/scripts/init-first-agent.ts +0 -378
- package/scripts/parachute.ts +0 -158
- package/scripts/run-migrations.ts +0 -105
- package/scripts/sanity-live-poll.ts +0 -95
- package/scripts/seed-discord.ts +0 -80
- package/scripts/test-v2-agent.ts +0 -106
- package/scripts/test-v2-channel-e2e.ts +0 -265
- package/scripts/test-v2-host.ts +0 -184
- package/src/channels/adapter.ts +0 -214
- package/src/channels/api-translator.test.ts +0 -306
- package/src/channels/api-translator.ts +0 -214
- package/src/channels/ask-question.ts +0 -46
- package/src/channels/channel-registry.test.ts +0 -421
- package/src/channels/channel-registry.ts +0 -313
- package/src/channels/chat-sdk-bridge.test.ts +0 -84
- package/src/channels/chat-sdk-bridge.ts +0 -652
- package/src/channels/cli.ts +0 -276
- package/src/channels/discord.ts +0 -90
- package/src/channels/index.ts +0 -17
- package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
- package/src/channels/telegram-markdown-sanitize.ts +0 -55
- package/src/channels/telegram-pairing.test.ts +0 -254
- package/src/channels/telegram-pairing.ts +0 -339
- package/src/channels/telegram.ts +0 -279
- package/src/channels/trust-hint.test.ts +0 -48
- package/src/channels/trust-hint.ts +0 -75
- package/src/claude-md-compose.migrate.test.ts +0 -64
- package/src/claude-md-compose.ts +0 -205
- package/src/command-gate.ts +0 -63
- package/src/config.test.ts +0 -93
- package/src/config.ts +0 -128
- package/src/container-config.ts +0 -167
- package/src/container-runner.test.ts +0 -32
- package/src/container-runner.ts +0 -576
- package/src/container-runtime.test.ts +0 -269
- package/src/container-runtime.ts +0 -167
- package/src/db/_bun-sqlite-shim.ts +0 -88
- package/src/db/agent-activity.test.ts +0 -155
- package/src/db/agent-activity.ts +0 -121
- package/src/db/agent-groups.ts +0 -77
- package/src/db/connection.migrate.test.ts +0 -176
- package/src/db/connection.ts +0 -259
- package/src/db/db-v2.test.ts +0 -440
- package/src/db/dropped-messages.ts +0 -44
- package/src/db/index.ts +0 -40
- package/src/db/messaging-groups.ts +0 -252
- package/src/db/migrations/001-initial.ts +0 -112
- package/src/db/migrations/002-chat-sdk-state.ts +0 -36
- package/src/db/migrations/008-dropped-messages.ts +0 -27
- package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
- package/src/db/migrations/010-engage-modes.ts +0 -103
- package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
- package/src/db/migrations/012-channel-registration.ts +0 -48
- package/src/db/migrations/013-approval-render-metadata.ts +0 -27
- package/src/db/migrations/014-secrets.ts +0 -44
- package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
- package/src/db/migrations/016-secret-assignments.ts +0 -30
- package/src/db/migrations/017-agent-activity.ts +0 -40
- package/src/db/migrations/018-oauth-app-configs.ts +0 -34
- package/src/db/migrations/019-oauth-app-connections.ts +0 -48
- package/src/db/migrations/020-agent-app-connections.ts +0 -28
- package/src/db/migrations/021-pending-oauth-states.ts +0 -35
- package/src/db/migrations/022-app-connections-provider.ts +0 -25
- package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
- package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
- package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
- package/src/db/migrations/024-collapse-approvals.ts +0 -182
- package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
- package/src/db/migrations/025-secret-mode-check.ts +0 -49
- package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
- package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
- package/src/db/migrations/027-provider-credentials.ts +0 -41
- package/src/db/migrations/_test-helpers.ts +0 -41
- package/src/db/migrations/index.ts +0 -127
- package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
- package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
- package/src/db/migrations/module-approvals-title-options.ts +0 -40
- package/src/db/schema.ts +0 -258
- package/src/db/session-db.test.ts +0 -93
- package/src/db/session-db.ts +0 -325
- package/src/db/sessions.ts +0 -241
- package/src/delivery.test.ts +0 -148
- package/src/delivery.ts +0 -445
- package/src/env.ts +0 -74
- package/src/group-folder.test.ts +0 -35
- package/src/group-folder.ts +0 -44
- package/src/group-init.ts +0 -92
- package/src/host-core.test.ts +0 -456
- package/src/host-sweep.test.ts +0 -146
- package/src/host-sweep.ts +0 -287
- package/src/index.ts +0 -232
- package/src/install-slug.ts +0 -33
- package/src/log.test.ts +0 -81
- package/src/log.ts +0 -117
- package/src/mcp/http.ts +0 -72
- package/src/mcp/server.ts +0 -92
- package/src/mcp/stdio.ts +0 -51
- package/src/mcp/tools/activity.ts +0 -88
- package/src/mcp/tools/agent-groups.ts +0 -183
- package/src/mcp/tools/approvals.ts +0 -122
- package/src/mcp/tools/channels.test.ts +0 -126
- package/src/mcp/tools/channels.ts +0 -134
- package/src/mcp/tools/index.ts +0 -27
- package/src/mcp/tools/oauth.ts +0 -48
- package/src/mcp/tools/secrets.ts +0 -169
- package/src/mcp/tools/sessions.ts +0 -135
- package/src/mcp/types.ts +0 -51
- package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
- package/src/modules/agent-to-agent/agent-route.ts +0 -223
- package/src/modules/agent-to-agent/create-agent.ts +0 -127
- package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
- package/src/modules/agent-to-agent/index.ts +0 -22
- package/src/modules/agent-to-agent/write-destinations.ts +0 -59
- package/src/modules/approvals/agent.md +0 -45
- package/src/modules/approvals/index.ts +0 -21
- package/src/modules/approvals/picks.test.ts +0 -291
- package/src/modules/approvals/primitive.ts +0 -279
- package/src/modules/approvals/project.md +0 -27
- package/src/modules/approvals/response-handler.ts +0 -87
- package/src/modules/index.ts +0 -24
- package/src/modules/interactive/agent.md +0 -21
- package/src/modules/interactive/index.ts +0 -69
- package/src/modules/interactive/project.md +0 -12
- package/src/modules/mount-security/expand-path.test.ts +0 -82
- package/src/modules/mount-security/index.ts +0 -459
- package/src/modules/mount-security/migrate.test.ts +0 -91
- package/src/modules/permissions/access.ts +0 -28
- package/src/modules/permissions/channel-approval.test.ts +0 -389
- package/src/modules/permissions/channel-approval.ts +0 -188
- package/src/modules/permissions/db/agent-group-members.ts +0 -44
- package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
- package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
- package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
- package/src/modules/permissions/db/user-dms.ts +0 -58
- package/src/modules/permissions/db/user-roles.ts +0 -85
- package/src/modules/permissions/db/users.ts +0 -38
- package/src/modules/permissions/index.ts +0 -421
- package/src/modules/permissions/permissions.test.ts +0 -358
- package/src/modules/permissions/sender-approval.test.ts +0 -641
- package/src/modules/permissions/sender-approval.ts +0 -165
- package/src/modules/permissions/user-dm.ts +0 -200
- package/src/modules/provider-credentials/db.ts +0 -121
- package/src/modules/provider-credentials/index.ts +0 -12
- package/src/modules/provider-credentials/spawn.test.ts +0 -206
- package/src/modules/provider-credentials/spawn.ts +0 -114
- package/src/modules/scheduling/actions.ts +0 -113
- package/src/modules/scheduling/db.test.ts +0 -282
- package/src/modules/scheduling/db.ts +0 -148
- package/src/modules/scheduling/index.ts +0 -34
- package/src/modules/scheduling/recurrence.test.ts +0 -98
- package/src/modules/scheduling/recurrence.ts +0 -54
- package/src/modules/self-mod/agent.md +0 -30
- package/src/modules/self-mod/apply.ts +0 -85
- package/src/modules/self-mod/index.ts +0 -30
- package/src/modules/self-mod/project.md +0 -39
- package/src/modules/self-mod/request.ts +0 -91
- package/src/modules/typing/index.ts +0 -165
- package/src/oauth/agent-app-connections.ts +0 -103
- package/src/oauth/app-configs.test.ts +0 -64
- package/src/oauth/app-configs.ts +0 -114
- package/src/oauth/app-connections.test.ts +0 -109
- package/src/oauth/app-connections.ts +0 -178
- package/src/oauth/crypto.ts +0 -56
- package/src/oauth/flow.ts +0 -104
- package/src/oauth/providers/google.test.ts +0 -38
- package/src/oauth/providers/google.ts +0 -46
- package/src/oauth/providers/index.ts +0 -48
- package/src/oauth/state-store.test.ts +0 -54
- package/src/oauth/state-store.ts +0 -93
- package/src/parachute/README.md +0 -27
- package/src/parachute/create-agent.test.ts +0 -83
- package/src/parachute/create-agent.ts +0 -122
- package/src/parachute/group-status.test.ts +0 -165
- package/src/parachute/group-status.ts +0 -136
- package/src/parachute/types.ts +0 -41
- package/src/parachute/vault-mcp.test.ts +0 -251
- package/src/parachute/vault-mcp.ts +0 -232
- package/src/platform-id.test.ts +0 -104
- package/src/platform-id.ts +0 -109
- package/src/providers/index.ts +0 -6
- package/src/providers/provider-container-registry.ts +0 -58
- package/src/response-registry.ts +0 -45
- package/src/router.ts +0 -530
- package/src/secrets/crypto.test.ts +0 -45
- package/src/secrets/crypto.ts +0 -55
- package/src/secrets/index.ts +0 -461
- package/src/secrets/master-key.ts +0 -70
- package/src/secrets/secrets.test.ts +0 -651
- package/src/session-manager.attachments.test.ts +0 -171
- package/src/session-manager.dup-skip.test.ts +0 -173
- package/src/session-manager.migrate.test.ts +0 -59
- package/src/session-manager.ts +0 -451
- package/src/startup-bootstrap.test.ts +0 -226
- package/src/startup-bootstrap.ts +0 -207
- package/src/state-sqlite.ts +0 -182
- package/src/timezone.test.ts +0 -64
- package/src/timezone.ts +0 -37
- package/src/types.ts +0 -233
- package/src/web/auth.test.ts +0 -335
- package/src/web/auth.ts +0 -214
- package/src/web/discord-validate.test.ts +0 -77
- package/src/web/discord-validate.ts +0 -88
- package/src/web/hub-discovery.test.ts +0 -98
- package/src/web/hub-discovery.ts +0 -69
- package/src/web/routes/activity.ts +0 -106
- package/src/web/routes/agent-provider.test.ts +0 -282
- package/src/web/routes/agent-provider.ts +0 -309
- package/src/web/routes/approvals.ts +0 -185
- package/src/web/routes/apps.ts +0 -434
- package/src/web/routes/channels-mg-detail.test.ts +0 -324
- package/src/web/routes/channels-mga-detail.test.ts +0 -472
- package/src/web/routes/channels.ts +0 -311
- package/src/web/routes/oauth-providers.ts +0 -42
- package/src/web/routes/secrets.test.ts +0 -220
- package/src/web/routes/secrets.ts +0 -317
- package/src/web/routes/sessions.ts +0 -123
- package/src/web/routes/settings.test.ts +0 -106
- package/src/web/routes/settings.ts +0 -247
- package/src/web/routes/setup-status.ts +0 -205
- package/src/web/routes/vaults.test.ts +0 -389
- package/src/web/routes/vaults.ts +0 -225
- package/src/web/server-version.test.ts +0 -16
- package/src/web/server.ts +0 -1024
- package/src/web/services-manifest.test.ts +0 -148
- package/src/web/services-manifest.ts +0 -66
- package/src/web/static-serve.test.ts +0 -255
- package/src/web/static-serve.ts +0 -104
- package/src/web/telegram-validate.test.ts +0 -116
- package/src/web/telegram-validate.ts +0 -107
- package/src/web/vault-proxy.test.ts +0 -214
- package/src/web/vault-proxy.ts +0 -120
- package/src/web/wire-channel.ts +0 -181
- package/src/webhook-server.ts +0 -134
- package/vitest.config.ts +0 -18
- package/web/README.md +0 -63
- package/web/ui/index.html +0 -13
- package/web/ui/package.json +0 -35
- package/web/ui/pnpm-lock.yaml +0 -2164
- package/web/ui/scripts/verify-base.mjs +0 -31
- package/web/ui/src/App.tsx +0 -88
- package/web/ui/src/components/ActivityFeed.tsx +0 -444
- package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
- package/web/ui/src/components/AgentProviderCards.tsx +0 -220
- package/web/ui/src/components/CredentialForm.tsx +0 -214
- package/web/ui/src/components/ScopeGrants.tsx +0 -74
- package/web/ui/src/components/StatusDot.tsx +0 -43
- package/web/ui/src/components/VaultPicker.tsx +0 -127
- package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
- package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
- package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
- package/web/ui/src/components/setup/DoneStep.tsx +0 -49
- package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
- package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
- package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
- package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
- package/web/ui/src/components/setup/types.ts +0 -105
- package/web/ui/src/lib/api.test.ts +0 -410
- package/web/ui/src/lib/api.ts +0 -1248
- package/web/ui/src/lib/auth.test.ts +0 -352
- package/web/ui/src/lib/auth.ts +0 -405
- package/web/ui/src/lib/channel-adapters.ts +0 -136
- package/web/ui/src/main.tsx +0 -19
- package/web/ui/src/routes/ApprovalsList.tsx +0 -294
- package/web/ui/src/routes/Apps.tsx +0 -613
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
- package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
- package/web/ui/src/routes/ChannelsList.tsx +0 -158
- package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
- package/web/ui/src/routes/GroupDetail.tsx +0 -880
- package/web/ui/src/routes/GroupList.tsx +0 -187
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
- package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
- package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
- package/web/ui/src/routes/OAuthCallback.tsx +0 -56
- package/web/ui/src/routes/SecretsList.tsx +0 -942
- package/web/ui/src/routes/SessionsList.tsx +0 -220
- package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
- package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
- package/web/ui/src/routes/SetupWizard.tsx +0 -219
- package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
- package/web/ui/src/routes/VaultDetail.tsx +0 -960
- package/web/ui/src/routes/VaultsList.tsx +0 -295
- package/web/ui/src/routes/WireChannelPage.tsx +0 -413
- package/web/ui/src/styles.css +0 -608
- package/web/ui/src/test/setup.ts +0 -23
- package/web/ui/src/vite-env.d.ts +0 -10
- package/web/ui/vite.config.ts +0 -34
- package/web/ui/vitest.config.ts +0 -25
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon-level PROGRAMMATIC-AGENT registry + per-channel serial queue — the
|
|
3
|
+
* wiring that makes the {@link ProgrammaticBackend} usable end-to-end (design
|
|
4
|
+
* 2026-06-16-pluggable-agent-backend.md, the wiring follow-up to PR #73).
|
|
5
|
+
*
|
|
6
|
+
* A programmatic agent has NO resident process (unlike the interactive tmux
|
|
7
|
+
* backend). It is just: a registered handle (workspace + spec + persisted
|
|
8
|
+
* session_id) and a per-channel serial worker. An inbound message for a registered
|
|
9
|
+
* channel is ENQUEUED here; the worker drains the queue ONE turn at a time, FIFO,
|
|
10
|
+
* running a single `claude -p --resume <sid>` turn per message and posting the
|
|
11
|
+
* reply back as an outbound `#agent/message/outbound` note.
|
|
12
|
+
*
|
|
13
|
+
* ── The serial-queue contract (HARD requirement — reviewer contract) ─────────────
|
|
14
|
+
* Each agent processes turns ONE AT A TIME, FIFO. There is NEVER two concurrent
|
|
15
|
+
* `claude -p` for the same channel/session — that would FORK the conversation (two
|
|
16
|
+
* turns resuming the same session_id, racing the session-id store). New inbound
|
|
17
|
+
* while a turn runs is queued; the worker drains in arrival order. This is enforced
|
|
18
|
+
* structurally: a single in-flight promise chain per agent (`#draining`), not a
|
|
19
|
+
* lock the caller must remember to take.
|
|
20
|
+
*
|
|
21
|
+
* ── Outbound (design step 5) ─────────────────────────────────────────────────────
|
|
22
|
+
* On a `deliver()` result that is `ok: true` AND `reply` is non-empty, the worker
|
|
23
|
+
* writes an outbound note via the injected {@link WriteOutbound} callback — which
|
|
24
|
+
* the daemon wires to the channel transport's `reply()` (the SAME vault-transport
|
|
25
|
+
* outbound path the interactive `reply` tool uses, so it's durable + shows in the
|
|
26
|
+
* chat UI). An EMPTY reply writes NO note (reviewer contract — `reply` can be `""`).
|
|
27
|
+
* On `ok: false` the error is logged and the turn is DROPPED (no infinite loop, no
|
|
28
|
+
* retry — a failed turn's session id is already persisted by the backend, so the
|
|
29
|
+
* next message resumes the conversation). The outbound write goes through `reply()`,
|
|
30
|
+
* which tags the note `#agent/message/outbound` — the vault inbound trigger keys on
|
|
31
|
+
* `#agent/message/inbound` only, so writing the reply CANNOT re-trigger the inbound
|
|
32
|
+
* webhook (no loop).
|
|
33
|
+
*
|
|
34
|
+
* ── Thread note (the UNIFIED model: definition -> thread -> message) ───────────────
|
|
35
|
+
* BOTH execution-lifecycle modes now MATERIALIZE a `#agent/thread` note (the structural
|
|
36
|
+
* unification — everything is a thread; a "run" was always a thread with one turn). It is
|
|
37
|
+
* the PRIMARY record of the turn, written BEFORE the additive outbound (the c34db03
|
|
38
|
+
* ordering, now uniform) so the record survives an outbound failure. The MODE governs the
|
|
39
|
+
* thread's identity (resolved transport-side): `single-threaded` upserts ONE thread note
|
|
40
|
+
* per channel (named after the def, rolling turn_count + cumulative usage),
|
|
41
|
+
* `multi-threaded` writes one thread note per fire. The thread note carries
|
|
42
|
+
* `['#agent/thread']` EXACTLY — never a message tag — so it can never wake a session.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { AgentSpec, AgentMode } from "../sandbox/types.ts";
|
|
46
|
+
import { normalizeChannel } from "../sandbox/types.ts";
|
|
47
|
+
import type { AgentBackend, AgentHandle, InterimTurnEvent, TurnSession } from "./types.ts";
|
|
48
|
+
import type { InboundAttachment } from "../transport.ts";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The streaming-view sink (design 2026-06-16 build item #1): the daemon wires this
|
|
52
|
+
* to push a turn's interim progress (assistant text chunks + tool_use) to the
|
|
53
|
+
* channel's live chat subscribers (the per-channel turn-event SSE). The registry's
|
|
54
|
+
* worker calls it per channel as the turn runs, plus a synthesized `done`/`error`
|
|
55
|
+
* lifecycle event so the live view can finalize cleanly even on an empty/failed
|
|
56
|
+
* turn. ADDITIVE: when omitted the worker behaves exactly as before (no live view).
|
|
57
|
+
*/
|
|
58
|
+
export type TurnEventSink = (channel: string, event: TurnLifecycleEvent) => void;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A per-channel turn event the daemon fans out to the live chat. It's the backend's
|
|
62
|
+
* {@link InterimTurnEvent} (text / tool / init) PLUS two registry-synthesized
|
|
63
|
+
* lifecycle events that bracket every turn so the UI never gets stuck "working":
|
|
64
|
+
* - `done` — the turn finished; `reply` is the final outbound text (empty when the
|
|
65
|
+
* turn produced no text). The UI finalizes the live bubble.
|
|
66
|
+
* - `error` — the turn failed; `error` is the reason. The UI resolves the live view
|
|
67
|
+
* to an error state rather than leaving a hung spinner.
|
|
68
|
+
*/
|
|
69
|
+
export type TurnLifecycleEvent =
|
|
70
|
+
| InterimTurnEvent
|
|
71
|
+
| { kind: "done"; reply: string }
|
|
72
|
+
| { kind: "error"; error: string };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write an outbound reply for a channel — the seam the registry posts a turn's
|
|
76
|
+
* reply through. The daemon wires this to the channel transport's `reply()` (a
|
|
77
|
+
* VaultTransport writes a `#agent/message/outbound` note). `inReplyTo` threads the
|
|
78
|
+
* reply to the inbound note id when one is known.
|
|
79
|
+
*
|
|
80
|
+
* RETURN: optionally the written outbound note's id (`{ id }`) — the agent-to-agent
|
|
81
|
+
* callback uses it as the `source_message` an orchestrator pulls the full reply from.
|
|
82
|
+
* Returning `void` (or `{}`) is fine — the callback then just omits `source_message`
|
|
83
|
+
* (the sender still learns the turn finished; it has the `source_thread` to pull from).
|
|
84
|
+
* Kept BACK-COMPAT: every existing `async () => {}` recorder still satisfies this (`void`
|
|
85
|
+
* is a member of the union). A write failure is still surfaced as a throw (the registry's
|
|
86
|
+
* retry/record logic depends on the throw, not the return).
|
|
87
|
+
*/
|
|
88
|
+
export type WriteOutbound = (
|
|
89
|
+
channel: string,
|
|
90
|
+
reply: string,
|
|
91
|
+
inReplyTo?: string,
|
|
92
|
+
/**
|
|
93
|
+
* The per-turn thread id this reply belongs to — the explicit definition→thread→message
|
|
94
|
+
* link the outbound note carries (stamped into `metadata.thread`). For multi-threaded it
|
|
95
|
+
* IS the per-fire thread note's leaf (an exact link); for single-threaded it's a per-turn
|
|
96
|
+
* correlation id (the note's stable deterministic leaf is the def name — single-threaded
|
|
97
|
+
* outbound→note linkage by the stable path is a follow-up). INBOUND-note stamping is
|
|
98
|
+
* deferred (those notes are externally written; see the PR notes).
|
|
99
|
+
*/
|
|
100
|
+
threadId?: string,
|
|
101
|
+
) => Promise<{ id?: string } | void>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One turn's input to materializing a `#agent/thread` note (the UNIFIED model
|
|
105
|
+
* `definition -> thread -> message`) — the data the registry hands {@link WriteThread}.
|
|
106
|
+
* BOTH execution-lifecycle modes materialize a thread note (the structural unification:
|
|
107
|
+
* everything is a thread; a "run" was always a thread with one turn). Mirrors {@link
|
|
108
|
+
* ThreadRecord} in transport.ts; kept local here so the registry doesn't import the
|
|
109
|
+
* transport layer.
|
|
110
|
+
*/
|
|
111
|
+
export interface ThreadNote {
|
|
112
|
+
channel: string;
|
|
113
|
+
/**
|
|
114
|
+
* The agent/def name — single-threaded's thread is "named after the definition" (the
|
|
115
|
+
* transport sanitizes it into the deterministic upsert path). Falls back to the channel.
|
|
116
|
+
*/
|
|
117
|
+
name?: string;
|
|
118
|
+
/** The `#agent/definition` note id (provenance; plain id string). */
|
|
119
|
+
definition?: string;
|
|
120
|
+
/** The mode the turn ran under — governs thread identity + whether the note upserts. */
|
|
121
|
+
mode: AgentMode;
|
|
122
|
+
/**
|
|
123
|
+
* Outcome / lifecycle state after THIS write — `working` (the start-ensure, written
|
|
124
|
+
* BEFORE the turn: input shown, no reply yet), `ok` (success), or `error` (failed).
|
|
125
|
+
* `working` is only valid alongside `phase: "start"`.
|
|
126
|
+
*/
|
|
127
|
+
status: "ok" | "error" | "working";
|
|
128
|
+
/** The inbound text handed to the turn (the `-p` prompt). */
|
|
129
|
+
input: string;
|
|
130
|
+
/** The reply on success, the failure reason on error, or "" while `working`. */
|
|
131
|
+
output: string;
|
|
132
|
+
/**
|
|
133
|
+
* The Claude session UUID for this turn — the transport persists it to the thread
|
|
134
|
+
* note's `metadata.session` (the thread≡session record), so the NEXT turn can
|
|
135
|
+
* `--resume` it. Set ONLY on the `end` record, and ONLY from the session claude
|
|
136
|
+
* actually ECHOED (`result.sessionId`, captured from the init/result event). A turn
|
|
137
|
+
* that never established a session (claude exited before creating one) persists NONE
|
|
138
|
+
* — and a single-threaded prior session is preserved by the transport — so the next
|
|
139
|
+
* turn resolves a fresh create and SELF-HEALS rather than `--resume`ing a phantom id
|
|
140
|
+
* (which would brick the channel: "No conversation found" is non-transient → no retry).
|
|
141
|
+
* NOT set on the `start`-ensure (it runs before claude, so no session exists yet).
|
|
142
|
+
*/
|
|
143
|
+
session?: string;
|
|
144
|
+
/** ISO start/end of the turn (a start-ensure does not advance the thread's last_turn_at). */
|
|
145
|
+
started_at: string;
|
|
146
|
+
ended_at: string;
|
|
147
|
+
/** Optional token/cost usage for observability. */
|
|
148
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalCostUsd?: number };
|
|
149
|
+
/**
|
|
150
|
+
* MULTI-threaded only: a stable per-TURN thread id (the per-fire note's leaf). The same
|
|
151
|
+
* id on a re-record (the outbound-failure status flip) reuses the SAME note instead of
|
|
152
|
+
* minting a duplicate. Single-threaded ignores it (deterministic name leaf).
|
|
153
|
+
*/
|
|
154
|
+
threadId?: string;
|
|
155
|
+
/**
|
|
156
|
+
* Re-record of the SAME turn — single-threaded keeps `turn_count` (the turn was already
|
|
157
|
+
* counted by the first record); no effect on multi-threaded.
|
|
158
|
+
*/
|
|
159
|
+
sameTurn?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* The lifecycle PHASE of this write (thread-as-container). `"start"` = the WORKING-ENSURE
|
|
162
|
+
* before the turn (status `working`, turn_count UNCHANGED — no turn completed yet);
|
|
163
|
+
* `"end"` (DEFAULT when absent) = the final record after the turn (turn_count increments
|
|
164
|
+
* on the `end` write). So a turn is counted EXACTLY ONCE — on `end`, never on `start`.
|
|
165
|
+
*/
|
|
166
|
+
phase?: "start" | "end";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Materialize a `#agent/thread` note for a completed turn — the seam the registry posts a
|
|
171
|
+
* thread note through. The daemon wires this to the channel transport's `writeThread()` (a
|
|
172
|
+
* VaultTransport writes a `#agent/thread` note). Called for BOTH modes now (the structural
|
|
173
|
+
* unification — every turn materializes a thread note): single-threaded upserts one note
|
|
174
|
+
* per channel, multi-threaded writes one per fire. A write failure is the implementation's
|
|
175
|
+
* to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
|
|
176
|
+
* the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
|
|
177
|
+
*/
|
|
178
|
+
export type WriteThread = (thread: ThreadNote) => Promise<void>;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A callback delivered back to a SENDER's channel when a turn it requested finishes —
|
|
182
|
+
* the agent-to-agent request/response substrate ("reply_to"). The daemon wires this to
|
|
183
|
+
* write a NEW `#agent/message/inbound` note to the `channel` (so it wakes the sender
|
|
184
|
+
* through the normal inbound path), carrying the {@link CallbackMeta} contract.
|
|
185
|
+
*
|
|
186
|
+
* The content is a brief NOTIFICATION + a LINK to the result (NOT the full reply
|
|
187
|
+
* duplicated) — the orchestrator reads `source_message`/`source_thread` off the metadata
|
|
188
|
+
* and PULLS the full result if it wants (the user's explicit choice: summary + link,
|
|
189
|
+
* orchestrator pulls — cleaner + a better security boundary than fan-out duplication).
|
|
190
|
+
*
|
|
191
|
+
* LOOP SAFETY (load-bearing): the callback note this writes carries the INBOUND tags so
|
|
192
|
+
* it routes, but the daemon's wiring MUST NOT put a `reply_to` on it — a callback is
|
|
193
|
+
* TERMINAL, so handling one can never auto-trigger another callback (no ping-pong).
|
|
194
|
+
*
|
|
195
|
+
* A write failure is the implementation's to surface (the registry logs whatever it
|
|
196
|
+
* throws); a callback NEVER re-runs the turn. Optional on the registry — when unwired (no
|
|
197
|
+
* vault-backed channels), reply_to is silently inert.
|
|
198
|
+
*/
|
|
199
|
+
export type WriteCallback = (channel: string, content: string, meta: CallbackMeta) => Promise<void>;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The METADATA CONTRACT a callback inbound note carries (design
|
|
203
|
+
* 2026-06-20-agent-callbacks.md). The daemon's {@link WriteCallback} wiring stamps these
|
|
204
|
+
* onto the new `#agent/message/inbound` note's `metadata` (the vault stores them as
|
|
205
|
+
* strings). The orchestrator reads `source_message` / `source_thread` to PULL the full
|
|
206
|
+
* result. Deliberately a SUMMARY + LINK, never the duplicated reply body.
|
|
207
|
+
*/
|
|
208
|
+
export interface CallbackMeta {
|
|
209
|
+
/**
|
|
210
|
+
* `"true"` — the marker that distinguishes a callback inbound from an ordinary one, so
|
|
211
|
+
* an orchestrator's turn can tell "a sub-task finished" from "a new request arrived".
|
|
212
|
+
*/
|
|
213
|
+
callback: "true";
|
|
214
|
+
/** The terminal outcome of the requested turn — `ok` (succeeded) or `error` (failed). */
|
|
215
|
+
status: "ok" | "error";
|
|
216
|
+
/** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
|
|
217
|
+
source_channel: string;
|
|
218
|
+
/**
|
|
219
|
+
* The per-turn thread id the drain minted. RESOLVABILITY DIFFERS BY MODE:
|
|
220
|
+
* - multi-threaded: this IS the per-fire note's leaf — the orchestrator can pull the
|
|
221
|
+
* thread note at `Threads/<channel>/<source_thread>`.
|
|
222
|
+
* - single-threaded: this is a per-turn CORRELATION id, NOT the note leaf (the
|
|
223
|
+
* single-threaded note lives at the deterministic `Threads/<channel>/<name>`), so it
|
|
224
|
+
* is NOT directly resolvable. Use `source_message` as the reliable pull-link for a
|
|
225
|
+
* single-threaded recipient. Making this a resolvable thread id for both modes
|
|
226
|
+
* (widen the writeThread seam to return the written note id) is tracked as a
|
|
227
|
+
* follow-up (parachute-agent#124).
|
|
228
|
+
*/
|
|
229
|
+
source_thread: string;
|
|
230
|
+
/**
|
|
231
|
+
* The recipient's OUTBOUND reply note id, when the turn produced (and delivered) a
|
|
232
|
+
* reply. The orchestrator pulls the full reply text from here. ABSENT when there was no
|
|
233
|
+
* reply (an error turn, or an empty/tool-only turn) — the callback still fires so the
|
|
234
|
+
* orchestrator learns the turn is done; it just has no reply note to pull.
|
|
235
|
+
*/
|
|
236
|
+
source_message?: string;
|
|
237
|
+
/** The sender's opaque correlation id, echoed verbatim when one was set. Omitted otherwise. */
|
|
238
|
+
correlation_id?: string;
|
|
239
|
+
/**
|
|
240
|
+
* The depth of THIS callback = the incoming message's depth + 1. The sender's turn,
|
|
241
|
+
* woken by this callback, inherits it; if that turn delegates onward, the chain's depth
|
|
242
|
+
* keeps climbing toward {@link MAX_DELEGATION_DEPTH}.
|
|
243
|
+
*/
|
|
244
|
+
delegation_depth: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The hard ceiling on delegation HOPS — the depth loop guard (design
|
|
249
|
+
* 2026-06-20-agent-callbacks.md §loop-safety). An inbound message arriving at or past this
|
|
250
|
+
* depth delivers NO callback (logged), which BOUNDS any chain even if the no-`reply_to`-on-
|
|
251
|
+
* callback rule were somehow circumvented. 8 is generous for real orchestration trees
|
|
252
|
+
* (an orchestrator → workers → sub-workers fan-out is 2-3 deep) while still finite.
|
|
253
|
+
*/
|
|
254
|
+
export const MAX_DELEGATION_DEPTH = 8;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Cap on the per-channel PENDING-INBOUND queue (the agent#121 pre-registration buffer).
|
|
258
|
+
* A buffer that grows without bound is a memory-leak / DoS footgun if a channel's agent
|
|
259
|
+
* never comes up; past the cap the OLDEST pending message is dropped (FIFO eviction) with
|
|
260
|
+
* a loud log — bounded loss is better than unbounded growth, and the durable inbound notes
|
|
261
|
+
* still exist in the vault for the agent to re-read once it's live.
|
|
262
|
+
*/
|
|
263
|
+
export const PENDING_INBOUND_CAP = 50;
|
|
264
|
+
|
|
265
|
+
/** How many times the outbound write is RETRIED on a transient failure (agent — PR #3
|
|
266
|
+
* FIX 1) before giving up. Total attempts = 1 + this. */
|
|
267
|
+
export const OUTBOUND_MAX_RETRIES = 2;
|
|
268
|
+
/** Base backoff (ms) between outbound retries — grows linearly (attempt 1 → BASE, 2 → 2×BASE). */
|
|
269
|
+
export const OUTBOUND_RETRY_BASE_MS = 250;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Classify an outbound-write error as TRANSIENT (worth retrying) vs PERMANENT (a real
|
|
273
|
+
* rejection). The VaultTransport's `reply()` throws `Error` whose message embeds the
|
|
274
|
+
* HTTP status as `(NNN)` for a non-ok vault response, or a raw network/fetch rejection
|
|
275
|
+
* (no status) when the vault is unreachable. So:
|
|
276
|
+
* - a parseable 5xx (502/503/504/…) → TRANSIENT (a vault blip; retry).
|
|
277
|
+
* - NO parseable status (a network error, DNS, connection refused) → TRANSIENT.
|
|
278
|
+
* - a parseable 4xx (400/401/403/409/…) → PERMANENT (a real rejection — auth, bad
|
|
279
|
+
* request; retrying just re-fails). Do NOT retry these.
|
|
280
|
+
* This keeps the retry to the case the audit flagged (a transient vault 5xx silently
|
|
281
|
+
* losing the reply) without papering over a genuine 4xx rejection.
|
|
282
|
+
*/
|
|
283
|
+
export function isTransientOutboundError(err: unknown): boolean {
|
|
284
|
+
const msg = (err as Error)?.message ?? "";
|
|
285
|
+
const m = msg.match(/\((\d{3})\)/);
|
|
286
|
+
if (!m) return true; // no HTTP status → a network/connection error → transient.
|
|
287
|
+
const status = Number(m[1]);
|
|
288
|
+
return status >= 500 && status <= 599; // 5xx transient; 4xx permanent.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Sleep helper for the outbound retry backoff (injectable-free; small + bounded). */
|
|
292
|
+
function delay(ms: number): Promise<void> {
|
|
293
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A queued inbound message awaiting its serial turn. */
|
|
297
|
+
export interface QueuedMessage {
|
|
298
|
+
/** The inbound text handed to the `claude -p` turn as the prompt. */
|
|
299
|
+
content: string;
|
|
300
|
+
/** The inbound note id (if known), threaded into the outbound reply's `in_reply_to`. */
|
|
301
|
+
inReplyTo?: string;
|
|
302
|
+
// ── AGENT-TO-AGENT CALLBACK ROUTING ("reply_to") ───────────────────────────────
|
|
303
|
+
// These ride from the inbound note's metadata (a SENDING agent stamps them when it
|
|
304
|
+
// writes an inbound note to THIS channel via the vault), through `contextFor.emit`
|
|
305
|
+
// (daemon.ts, which flattens note.metadata into `meta`), onto this queue item, so the
|
|
306
|
+
// drain can deliver a CALLBACK to the originating channel when this turn finishes.
|
|
307
|
+
/**
|
|
308
|
+
* The SENDER's channel name — where to deliver a callback when this turn completes
|
|
309
|
+
* (BOTH ok and error). A single-threaded agent's channel ↔ thread is 1:1, so an
|
|
310
|
+
* orchestrator knows its own channel = its def name and stamps it here when it writes
|
|
311
|
+
* the inbound note to the recipient. Absent → NO callback (a normal, non-orchestrated
|
|
312
|
+
* turn). A callback note itself NEVER carries `reply_to` (it's terminal — that is the
|
|
313
|
+
* primary loop guard; see {@link MAX_DELEGATION_DEPTH}).
|
|
314
|
+
*/
|
|
315
|
+
replyTo?: string;
|
|
316
|
+
/**
|
|
317
|
+
* An OPAQUE id the sender uses to match a callback to the request it fired (it may
|
|
318
|
+
* have N sub-tasks in flight). Echoed verbatim onto the callback metadata; the daemon
|
|
319
|
+
* never interprets it. Absent → omitted from the callback.
|
|
320
|
+
*/
|
|
321
|
+
correlationId?: string;
|
|
322
|
+
/**
|
|
323
|
+
* How many delegation HOPS deep this message is (0 = a top-level human/runner turn).
|
|
324
|
+
* Incremented on each callback hop; bounds runaway chains. A message arriving at or
|
|
325
|
+
* past {@link MAX_DELEGATION_DEPTH} delivers NO callback (the depth loop guard). The
|
|
326
|
+
* vault stores metadata as STRINGS, so daemon.ts coerces `metadata.delegation_depth`
|
|
327
|
+
* to a finite integer before it lands here; a missing/garbage value reads as 0.
|
|
328
|
+
*/
|
|
329
|
+
delegationDepth?: number;
|
|
330
|
+
/**
|
|
331
|
+
* Files attached to this inbound message (Phase 1: inbound file attachments → the
|
|
332
|
+
* programmatic turn). Threaded transport → daemon → `deliver`; the programmatic
|
|
333
|
+
* backend stages each into the agent's private session workspace so the turn can
|
|
334
|
+
* `Read` it. Absent/empty → no attachments (today's behavior unchanged).
|
|
335
|
+
*/
|
|
336
|
+
attachments?: InboundAttachment[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A registered programmatic agent's live status (surfaced in /health + the list). */
|
|
340
|
+
export type ProgrammaticAgentState = "idle" | "working" | "queued";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One registered programmatic agent. Holds the backend handle, its serial queue +
|
|
344
|
+
* in-flight worker, and a tiny bit of observable state (`working` + queue depth).
|
|
345
|
+
*/
|
|
346
|
+
export interface ProgrammaticAgentHandle {
|
|
347
|
+
/** The agent slug (the spec name). */
|
|
348
|
+
name: string;
|
|
349
|
+
/** The wake channel this agent serves (the first channel of its spec). */
|
|
350
|
+
channel: string;
|
|
351
|
+
/** The spec the agent was registered from (carries the workspace/sandbox policy). */
|
|
352
|
+
spec: AgentSpec;
|
|
353
|
+
/** The backend's opaque handle (passed to `deliver`/`stop`/`status`). */
|
|
354
|
+
backendHandle: AgentHandle;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The daemon's registry of programmatic agents + their per-channel serial queues.
|
|
359
|
+
*
|
|
360
|
+
* Keyed by CHANNEL (the wake channel) so inbound routing — which only knows the
|
|
361
|
+
* channel — is an O(1) lookup. A second index by NAME backs the lifecycle ops
|
|
362
|
+
* (`deregister`, mutual-exclusion check). One instance per daemon, constructed at
|
|
363
|
+
* boot; injectable deps (`backend`, `writeOutbound`) so tests drive it with a fake
|
|
364
|
+
* backend + a recorder, no real `claude -p` or vault.
|
|
365
|
+
*/
|
|
366
|
+
export class ProgrammaticAgentRegistry {
|
|
367
|
+
/** channel → handle (the inbound-routing index). */
|
|
368
|
+
private readonly byChannel = new Map<string, ProgrammaticAgentHandle>();
|
|
369
|
+
/** name → channel (the lifecycle index; an agent has exactly one wake channel). */
|
|
370
|
+
private readonly nameToChannel = new Map<string, string>();
|
|
371
|
+
/** channel → FIFO queue of pending messages. */
|
|
372
|
+
private readonly queues = new Map<string, QueuedMessage[]>();
|
|
373
|
+
/** channel → the in-flight drain promise (its presence == a worker is running). */
|
|
374
|
+
private readonly draining = new Map<string, Promise<void>>();
|
|
375
|
+
/**
|
|
376
|
+
* channel → FIFO queue of PENDING-INBOUND messages that arrived BEFORE a live
|
|
377
|
+
* programmatic agent was registered for the channel (the agent#121 fix). The daemon
|
|
378
|
+
* OWNS these — it must never drop an inbound it can't yet process (the vault trigger
|
|
379
|
+
* acks success on the daemon's 200 and never retries, so a drop is permanent). On
|
|
380
|
+
* {@link register} the channel's pending queue is DRAINED into the normal {@link
|
|
381
|
+
* enqueue} path, so the queued turns run in arrival order once the agent is live.
|
|
382
|
+
* IN-MEMORY only (v1): a daemon restart loses pending, which is fine — the durable
|
|
383
|
+
* inbound notes still exist in the vault and `loadAll` + the 60s def-poll reconverge.
|
|
384
|
+
*/
|
|
385
|
+
private readonly pending = new Map<string, QueuedMessage[]>();
|
|
386
|
+
/**
|
|
387
|
+
* Channels EXPECTED to gain a live programmatic agent (a def maps here / the
|
|
388
|
+
* instantiate path has started bringing one up) — the gate for {@link queuePending}.
|
|
389
|
+
* Only an EXPECTED channel queues a pre-registration inbound; a genuinely unknown
|
|
390
|
+
* channel (nothing maps to it) is logged + dropped (there's nothing to deliver to).
|
|
391
|
+
* Marked by {@link expectChannel} (the def-instantiation path) and by {@link register}
|
|
392
|
+
* itself; cleared by {@link unexpectChannel} (deregister/teardown).
|
|
393
|
+
*/
|
|
394
|
+
private readonly expectedChannels = new Set<string>();
|
|
395
|
+
|
|
396
|
+
private readonly backend: AgentBackend;
|
|
397
|
+
private readonly writeOutbound: WriteOutbound;
|
|
398
|
+
/** Optional thread-note sink — materialize an `#agent/thread` note (BOTH modes). */
|
|
399
|
+
private readonly writeThread?: WriteThread;
|
|
400
|
+
/**
|
|
401
|
+
* Optional callback sink — deliver an agent-to-agent callback to a sender's channel on
|
|
402
|
+
* turn completion (the "reply_to" substrate). Unwired → reply_to is silently inert.
|
|
403
|
+
*/
|
|
404
|
+
private readonly writeCallback?: WriteCallback;
|
|
405
|
+
/** Optional streaming-view sink — push interim + lifecycle turn events per channel. */
|
|
406
|
+
private readonly onTurnEvent?: TurnEventSink;
|
|
407
|
+
/**
|
|
408
|
+
* Optional pre-turn session read — the persisted Claude session UUID for a
|
|
409
|
+
* single-threaded agent's thread note (the daemon wires this to the channel
|
|
410
|
+
* transport's `readThreadSession`). Read in {@link drain} so a single-threaded turn
|
|
411
|
+
* 2+ `--resume`s its prior conversation. Unwired (or no prior) → every turn creates a
|
|
412
|
+
* fresh session. Multi-threaded NEVER consults it (each fire is a fresh thread).
|
|
413
|
+
*/
|
|
414
|
+
private readonly readSession?: (channel: string, name: string) => Promise<string | undefined>;
|
|
415
|
+
/**
|
|
416
|
+
* Optional session CLEAR — wipe a single-threaded agent's persisted thread-note session
|
|
417
|
+
* so its next turn starts a FRESH claude conversation (the per-agent restart). The daemon
|
|
418
|
+
* wires this to the channel transport's `clearThreadSession`. Called by {@link resetSession}.
|
|
419
|
+
* Unwired → reset is a clean no-op beyond returning that the agent exists.
|
|
420
|
+
*/
|
|
421
|
+
private readonly clearSession?: (channel: string, name: string) => Promise<void>;
|
|
422
|
+
/** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
|
|
423
|
+
private readonly outboundRetryBaseMs: number;
|
|
424
|
+
|
|
425
|
+
constructor(deps: {
|
|
426
|
+
backend: AgentBackend;
|
|
427
|
+
writeOutbound: WriteOutbound;
|
|
428
|
+
writeThread?: WriteThread;
|
|
429
|
+
writeCallback?: WriteCallback;
|
|
430
|
+
onTurnEvent?: TurnEventSink;
|
|
431
|
+
/** Read the persisted thread-note session UUID (single-threaded resume). */
|
|
432
|
+
readSession?: (channel: string, name: string) => Promise<string | undefined>;
|
|
433
|
+
/** Clear the persisted thread-note session (the per-agent restart / reset). */
|
|
434
|
+
clearSession?: (channel: string, name: string) => Promise<void>;
|
|
435
|
+
/** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
|
|
436
|
+
outboundRetryBaseMs?: number;
|
|
437
|
+
}) {
|
|
438
|
+
this.backend = deps.backend;
|
|
439
|
+
this.writeOutbound = deps.writeOutbound;
|
|
440
|
+
if (deps.writeThread) this.writeThread = deps.writeThread;
|
|
441
|
+
if (deps.writeCallback) this.writeCallback = deps.writeCallback;
|
|
442
|
+
if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
|
|
443
|
+
if (deps.readSession) this.readSession = deps.readSession;
|
|
444
|
+
if (deps.clearSession) this.clearSession = deps.clearSession;
|
|
445
|
+
this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Emit a turn event to the streaming-view sink, swallowing any throw — a live-view
|
|
450
|
+
* push must NEVER break the serial worker (the durable note path is what matters).
|
|
451
|
+
* A no-op when no sink is wired.
|
|
452
|
+
*/
|
|
453
|
+
private emitTurnEvent(channel: string, event: TurnLifecycleEvent): void {
|
|
454
|
+
if (!this.onTurnEvent) return;
|
|
455
|
+
try {
|
|
456
|
+
this.onTurnEvent(channel, event);
|
|
457
|
+
} catch {
|
|
458
|
+
// A dead-stream / sink fault must not strand the queue.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The wake channel for a spec (its first channel, normalized). */
|
|
463
|
+
private static channelOf(spec: AgentSpec): string {
|
|
464
|
+
if (spec.channels.length === 0) {
|
|
465
|
+
throw new Error(`programmatic registry: spec "${spec.name}" declares no channels`);
|
|
466
|
+
}
|
|
467
|
+
return normalizeChannel(spec.channels[0]!).name;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Is a programmatic agent registered for this channel? (the inbound-routing check) */
|
|
471
|
+
hasChannel(channel: string): boolean {
|
|
472
|
+
return this.byChannel.has(channel);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Is a programmatic agent registered under this name? (the mutual-exclusion check) */
|
|
476
|
+
hasName(name: string): boolean {
|
|
477
|
+
return this.nameToChannel.has(name);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The registered handle for a channel, or undefined. */
|
|
481
|
+
getByChannel(channel: string): ProgrammaticAgentHandle | undefined {
|
|
482
|
+
return this.byChannel.get(channel);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The registered handle for a name, or undefined. */
|
|
486
|
+
getByName(name: string): ProgrammaticAgentHandle | undefined {
|
|
487
|
+
const channel = this.nameToChannel.get(name);
|
|
488
|
+
return channel === undefined ? undefined : this.byChannel.get(channel);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** All registered handles (for /health + the GET /api/agents list). */
|
|
492
|
+
list(): ProgrammaticAgentHandle[] {
|
|
493
|
+
return [...this.byChannel.values()];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The live status of an agent: `working` while a turn is in flight, `queued`
|
|
498
|
+
* (with the pending count) when messages are waiting, else `idle`. Used by
|
|
499
|
+
* /health + the agents list to render `programmatic · idle|working|queued:N`.
|
|
500
|
+
*/
|
|
501
|
+
statusOf(channel: string): { state: ProgrammaticAgentState; queued: number } {
|
|
502
|
+
const queued = this.queues.get(channel)?.length ?? 0;
|
|
503
|
+
if (this.draining.has(channel)) {
|
|
504
|
+
// A worker is in flight. If there are messages waiting BEHIND the in-flight
|
|
505
|
+
// one, report queued:N (N = waiting, not counting the in-flight turn); else
|
|
506
|
+
// working. `queued` is the queue length, which excludes the message currently
|
|
507
|
+
// being processed (it's shifted off before the turn runs).
|
|
508
|
+
return queued > 0 ? { state: "queued", queued } : { state: "working", queued: 0 };
|
|
509
|
+
}
|
|
510
|
+
return { state: "idle", queued: 0 };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Register a programmatic agent from its spec — lightweight: validate, build the
|
|
515
|
+
* backend handle (no resident process), index it by channel + name. The caller
|
|
516
|
+
* (the spawn path) has already set up the workspace + .mcp.json + credentials +
|
|
517
|
+
* spec.json. Idempotent-replace: re-registering the same name swaps the handle
|
|
518
|
+
* (the boot re-register + a re-spawn both land here).
|
|
519
|
+
*
|
|
520
|
+
* Returns the registered handle. Throws if the spec declares no channels (a
|
|
521
|
+
* programmatic agent must have a wake channel to route inbound to).
|
|
522
|
+
*/
|
|
523
|
+
async register(spec: AgentSpec): Promise<ProgrammaticAgentHandle> {
|
|
524
|
+
const channel = ProgrammaticAgentRegistry.channelOf(spec);
|
|
525
|
+
// Replace any prior registration for this name (a re-spawn / boot re-register).
|
|
526
|
+
const priorChannel = this.nameToChannel.get(spec.name);
|
|
527
|
+
if (priorChannel !== undefined && priorChannel !== channel) {
|
|
528
|
+
// The name moved to a different wake channel — drop the old channel index.
|
|
529
|
+
// An in-flight drain on the old channel self-terminates (it re-reads
|
|
530
|
+
// `byChannel`, now empty for that channel); we drop its `draining` flag too so
|
|
531
|
+
// the entry doesn't leak until that promise happens to settle. Also drop the old
|
|
532
|
+
// channel's EXPECTED mark + any stranded pending buffer — nothing routes there now,
|
|
533
|
+
// so a residual mark/buffer would leak (reviewer nit; defense-in-depth — the normal
|
|
534
|
+
// flow only ever expects the NEW channel before this register).
|
|
535
|
+
this.byChannel.delete(priorChannel);
|
|
536
|
+
this.queues.delete(priorChannel);
|
|
537
|
+
this.draining.delete(priorChannel);
|
|
538
|
+
this.expectedChannels.delete(priorChannel);
|
|
539
|
+
this.pending.delete(priorChannel);
|
|
540
|
+
}
|
|
541
|
+
const backendHandle = await this.backend.start(spec);
|
|
542
|
+
const handle: ProgrammaticAgentHandle = {
|
|
543
|
+
name: spec.name,
|
|
544
|
+
channel,
|
|
545
|
+
spec,
|
|
546
|
+
backendHandle,
|
|
547
|
+
};
|
|
548
|
+
this.byChannel.set(channel, handle);
|
|
549
|
+
this.nameToChannel.set(spec.name, channel);
|
|
550
|
+
// The channel now has a live agent — it's no longer merely "expected" (the gate that
|
|
551
|
+
// let pre-registration inbound queue pending); the live byChannel index is the truth now.
|
|
552
|
+
this.expectedChannels.delete(channel);
|
|
553
|
+
// REPLAY-ON-REGISTER (agent#121): drain any inbound that arrived BEFORE this agent was
|
|
554
|
+
// live — they were buffered in the pending queue (never dropped). Feed them through the
|
|
555
|
+
// NORMAL enqueue path, in arrival order (FIFO), so the queued turns run exactly as if
|
|
556
|
+
// they'd arrived after registration. enqueue() requires the channel to be in byChannel,
|
|
557
|
+
// which it now is. Do this AFTER the indexes are set so enqueue routes correctly.
|
|
558
|
+
this.drainPending(channel);
|
|
559
|
+
return handle;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Mark a channel as EXPECTED to gain a live programmatic agent — the gate that lets an
|
|
564
|
+
* inbound arriving BEFORE registration be QUEUED PENDING instead of dropped (agent#121).
|
|
565
|
+
* The def-instantiation path calls this BEFORE it brings the channel + agent up, so the
|
|
566
|
+
* narrow desync window (channel live, agent not yet registered) buffers rather than loses.
|
|
567
|
+
* Idempotent. {@link register} also marks-then-clears it; {@link unexpectChannel} clears it
|
|
568
|
+
* on teardown.
|
|
569
|
+
*/
|
|
570
|
+
expectChannel(channel: string): void {
|
|
571
|
+
this.expectedChannels.add(channel);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Drop a channel's EXPECTED mark + any buffered pending inbound — called on teardown
|
|
576
|
+
* (deregister) of an agent that will NOT come back, so a stale def can't leave inbound
|
|
577
|
+
* stranded in the pending buffer forever. (deregister of a still-expected agent that WILL
|
|
578
|
+
* re-register should NOT call this — only a genuine removal.)
|
|
579
|
+
*/
|
|
580
|
+
unexpectChannel(channel: string): void {
|
|
581
|
+
this.expectedChannels.delete(channel);
|
|
582
|
+
this.pending.delete(channel);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* QUEUE an inbound that arrived before a live programmatic agent exists for the channel
|
|
587
|
+
* (agent#121). Returns:
|
|
588
|
+
* - `"queued"` — the channel is EXPECTED (a def maps here / instantiation in flight); the
|
|
589
|
+
* message is buffered (FIFO, capped at {@link PENDING_INBOUND_CAP}) and
|
|
590
|
+
* will replay on {@link register}. The daemon now OWNS it (never dropped).
|
|
591
|
+
* - `"unknown"` — nothing maps to this channel (not expected, not registered): there is
|
|
592
|
+
* nothing to deliver to, so the caller logs + drops (still 200 — the vault
|
|
593
|
+
* must not retry into a permanent `_pending_at` stall).
|
|
594
|
+
*
|
|
595
|
+
* NOTE: a channel with a LIVE agent never reaches here — {@link enqueue} handles it. This is
|
|
596
|
+
* strictly the pre-registration / desync buffer.
|
|
597
|
+
*/
|
|
598
|
+
queuePending(channel: string, msg: QueuedMessage): "queued" | "unknown" {
|
|
599
|
+
if (!this.expectedChannels.has(channel)) return "unknown";
|
|
600
|
+
const queue = this.pending.get(channel) ?? [];
|
|
601
|
+
queue.push(msg);
|
|
602
|
+
// Bounded buffer: past the cap, evict the OLDEST (FIFO) so we keep the most recent
|
|
603
|
+
// context and never grow unbounded. Loud log — a capped pending queue means an agent
|
|
604
|
+
// isn't coming up in time (a real operational signal), and the dropped message is still
|
|
605
|
+
// durable in the vault for the agent to re-read once live.
|
|
606
|
+
if (queue.length > PENDING_INBOUND_CAP) {
|
|
607
|
+
queue.shift();
|
|
608
|
+
console.warn(
|
|
609
|
+
`parachute-agent: pending-inbound queue for channel "${channel}" hit the cap ` +
|
|
610
|
+
`(${PENDING_INBOUND_CAP}) — dropped the oldest buffered message (still durable in ` +
|
|
611
|
+
`the vault). The programmatic agent for this channel is not coming up in time.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
this.pending.set(channel, queue);
|
|
615
|
+
return "queued";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Drain a channel's PENDING-INBOUND buffer into the live serial queue — called by
|
|
620
|
+
* {@link register} once the agent is live. FIFO: the oldest pending inbound is enqueued
|
|
621
|
+
* first, so the buffered turns run in arrival order. A no-op when the buffer is empty.
|
|
622
|
+
*/
|
|
623
|
+
private drainPending(channel: string): void {
|
|
624
|
+
const buffered = this.pending.get(channel);
|
|
625
|
+
if (!buffered || buffered.length === 0) return;
|
|
626
|
+
this.pending.delete(channel);
|
|
627
|
+
console.log(
|
|
628
|
+
`parachute-agent: replaying ${buffered.length} pending inbound message(s) for ` +
|
|
629
|
+
`channel "${channel}" now that its programmatic agent is registered.`,
|
|
630
|
+
);
|
|
631
|
+
for (const msg of buffered) {
|
|
632
|
+
// enqueue() routes to the serial worker (the channel is now in byChannel). FIFO order
|
|
633
|
+
// is preserved by iterating the buffer oldest-first.
|
|
634
|
+
this.enqueue(channel, msg);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** How many inbound are buffered pending for a channel (tests + /health observability). */
|
|
639
|
+
pendingCount(channel: string): number {
|
|
640
|
+
return this.pending.get(channel)?.length ?? 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Is a channel currently marked EXPECTED (the pending-queue gate)? (tests) */
|
|
644
|
+
isExpected(channel: string): boolean {
|
|
645
|
+
return this.expectedChannels.has(channel);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Deregister a programmatic agent by NAME — drop its indexes + queue and clear
|
|
650
|
+
* its backend session (so a future re-spawn starts a fresh conversation). An
|
|
651
|
+
* in-flight turn is NOT cancelled (a `claude -p` turn is a fire-once subprocess;
|
|
652
|
+
* we just stop routing new inbound to it). Returns whether one was registered.
|
|
653
|
+
*/
|
|
654
|
+
async deregister(name: string): Promise<boolean> {
|
|
655
|
+
const channel = this.nameToChannel.get(name);
|
|
656
|
+
if (channel === undefined) return false;
|
|
657
|
+
const handle = this.byChannel.get(channel);
|
|
658
|
+
this.byChannel.delete(channel);
|
|
659
|
+
this.nameToChannel.delete(name);
|
|
660
|
+
this.queues.delete(channel);
|
|
661
|
+
// Clear the EXPECTED mark + any buffered pending inbound for this channel too —
|
|
662
|
+
// the agent is gone, so a pending message has nothing to drain into and would
|
|
663
|
+
// strand forever (and the next register would replay stale messages). The daemon's
|
|
664
|
+
// teardown wrapper also calls unexpectChannel, but clearing it here makes direct
|
|
665
|
+
// registry callers safe too (the reviewer's latent-footgun nit).
|
|
666
|
+
this.expectedChannels.delete(channel);
|
|
667
|
+
this.pending.delete(channel);
|
|
668
|
+
// Tear down the backend handle (the programmatic `stop` is a no-op — there's no
|
|
669
|
+
// process to kill, and the session now lives on the durable thread note, not a
|
|
670
|
+
// backend store). Deregister deliberately does NOT clear the thread-note session:
|
|
671
|
+
// re-registering the same agent should resume its conversation. Wiping continuity is
|
|
672
|
+
// an explicit RESET (`resetSession`), not a side effect of teardown.
|
|
673
|
+
if (handle) {
|
|
674
|
+
try {
|
|
675
|
+
await this.backend.stop(handle.backendHandle);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
console.error(
|
|
678
|
+
`parachute-agent: programmatic backend.stop for "${name}" failed (continuing): ${(err as Error).message}`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Reset a programmatic agent's conversation — clear the persisted session on its
|
|
687
|
+
* `#agent/thread` note (via the wired `clearSession` → the transport's
|
|
688
|
+
* `clearThreadSession`) so the next message starts a FRESH claude conversation, WITHOUT
|
|
689
|
+
* deregistering it. This is what the per-session restart endpoint maps to for a
|
|
690
|
+
* programmatic agent (the interactive restart's "kill + re-spawn" has no analog — there's
|
|
691
|
+
* no process; continuity is the thread-note session, not a backend store). With the next
|
|
692
|
+
* turn's `readSession` finding no session, it resolves a fresh `--session-id` create.
|
|
693
|
+
* Best-effort: a clear failure is logged, never thrown. Returns whether an agent was
|
|
694
|
+
* registered under that name.
|
|
695
|
+
*/
|
|
696
|
+
async resetSession(name: string): Promise<boolean> {
|
|
697
|
+
const handle = this.getByName(name);
|
|
698
|
+
if (!handle) return false;
|
|
699
|
+
try {
|
|
700
|
+
await this.clearSession?.(handle.channel, handle.spec.name);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
console.error(
|
|
703
|
+
`parachute-agent: programmatic session reset for "${name}" failed: ${(err as Error).message}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* ENQUEUE an inbound message for the channel's programmatic agent and ensure the
|
|
711
|
+
* serial worker is draining. A no-op (returns false) when no programmatic agent
|
|
712
|
+
* is registered for the channel — the caller falls back to the normal push path.
|
|
713
|
+
*
|
|
714
|
+
* The worker is a single in-flight promise chain per channel (`#draining`): if one
|
|
715
|
+
* is already running, this just appends to the queue and the running worker picks
|
|
716
|
+
* it up; otherwise it starts a new drain. Concurrency is impossible by
|
|
717
|
+
* construction — there is at most ONE drain promise per channel at a time, and the
|
|
718
|
+
* drain processes the queue strictly in order.
|
|
719
|
+
*/
|
|
720
|
+
enqueue(channel: string, msg: QueuedMessage): boolean {
|
|
721
|
+
if (!this.byChannel.has(channel)) return false;
|
|
722
|
+
const queue = this.queues.get(channel) ?? [];
|
|
723
|
+
queue.push(msg);
|
|
724
|
+
this.queues.set(channel, queue);
|
|
725
|
+
// Start the worker if it isn't already running. The drain promise's PRESENCE in
|
|
726
|
+
// `draining` is the "a worker is running" flag — set it synchronously before any
|
|
727
|
+
// await so a second enqueue in the same tick can't start a second worker.
|
|
728
|
+
if (!this.draining.has(channel)) {
|
|
729
|
+
const p = this.drain(channel).finally(() => {
|
|
730
|
+
this.draining.delete(channel);
|
|
731
|
+
});
|
|
732
|
+
this.draining.set(channel, p);
|
|
733
|
+
}
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Drain a channel's queue ONE turn at a time, FIFO, until empty. Each iteration
|
|
739
|
+
* shifts the oldest message, runs ONE `deliver()` turn, and posts a non-empty
|
|
740
|
+
* `ok` reply as an outbound note. Never two concurrent turns — the loop awaits each
|
|
741
|
+
* `deliver()` before shifting the next. Re-checks the queue after each turn so a
|
|
742
|
+
* message enqueued mid-turn is drained in the same run (no missed wake).
|
|
743
|
+
*
|
|
744
|
+
* Failure handling: a `deliver` that returns `{ ok: false }` is LOGGED and dropped
|
|
745
|
+
* (no retry, no loop — the design's "do NOT infinite-loop" contract). A throw from
|
|
746
|
+
* `deliver` (it shouldn't — the contract is failure-as-value) is caught so one bad
|
|
747
|
+
* turn can't kill the worker / strand the rest of the queue. An outbound-write
|
|
748
|
+
* failure is logged; the turn still counts as drained (the reply is durable-or-not
|
|
749
|
+
* at the transport's discretion; we don't re-run the turn, which would fork).
|
|
750
|
+
*/
|
|
751
|
+
private async drain(channel: string): Promise<void> {
|
|
752
|
+
for (;;) {
|
|
753
|
+
const queue = this.queues.get(channel);
|
|
754
|
+
if (!queue || queue.length === 0) return;
|
|
755
|
+
const handle = this.byChannel.get(channel);
|
|
756
|
+
if (!handle) return; // deregistered mid-drain — stop.
|
|
757
|
+
const msg = queue.shift()!;
|
|
758
|
+
|
|
759
|
+
// The UNIFIED model (the structural unification): BOTH modes materialize a
|
|
760
|
+
// `#agent/thread` note — everything is a thread; a "run" was always a thread with one
|
|
761
|
+
// turn. The MODE difference is the thread's identity (resolved transport-side):
|
|
762
|
+
// single-threaded upserts ONE note per channel (rolling turn_count + usage),
|
|
763
|
+
// multi-threaded writes one note per fire. Read the mode off the spec so the
|
|
764
|
+
// thread note carries it (it's the indexed query axis + governs the upsert).
|
|
765
|
+
const startedAt = new Date().toISOString();
|
|
766
|
+
// A stable per-TURN thread id, passed to every recordThread for this turn. For
|
|
767
|
+
// multi-threaded it's the per-fire note's leaf, so a re-record (the outbound-failure
|
|
768
|
+
// status flip below) updates the SAME note instead of minting a duplicate; single-
|
|
769
|
+
// threaded ignores it (deterministic name leaf). One uuid per turn.
|
|
770
|
+
const turnThreadId = crypto.randomUUID();
|
|
771
|
+
|
|
772
|
+
// RESOLVE THE SESSION (the thread≡session record — the daemon owns the uuid). A
|
|
773
|
+
// single-threaded agent RESUMES the session persisted on its deterministic thread
|
|
774
|
+
// note (when one exists); the first turn (no prior) and EVERY multi-threaded fire
|
|
775
|
+
// CREATE a fresh session with a new uuid (`--session-id`). The backend just runs the
|
|
776
|
+
// turn with this {@link TurnSession}; it reads no session store.
|
|
777
|
+
const multiThreaded = (handle.spec.mode ?? "single-threaded") === "multi-threaded";
|
|
778
|
+
let resumeId: string | undefined;
|
|
779
|
+
if (!multiThreaded && this.readSession) {
|
|
780
|
+
resumeId = await this.readSession(handle.channel, handle.spec.name);
|
|
781
|
+
}
|
|
782
|
+
const turnSession: TurnSession = resumeId
|
|
783
|
+
? { id: resumeId, resume: true }
|
|
784
|
+
: { id: crypto.randomUUID(), resume: false };
|
|
785
|
+
|
|
786
|
+
// ── THREAD-AS-CONTAINER (the user's model: definition -> thread -> message). ENSURE
|
|
787
|
+
// the thread note in a `working` state BEFORE the turn runs, so the thread is visible
|
|
788
|
+
// the moment processing starts (status `working` → `ok`/`error`), not only as a
|
|
789
|
+
// by-product of a completed turn. The SAME per-turn thread id ties this start-ensure
|
|
790
|
+
// to the end-record below: single-threaded UPSERTS its deterministic note (and the
|
|
791
|
+
// end-record overwrites it `working` → `ok`/`error`); multi-threaded CREATES the
|
|
792
|
+
// per-fire note (and the end-record updates the SAME note via `turnThreadId`).
|
|
793
|
+
//
|
|
794
|
+
// turn_count is NOT touched here. `phase: "start"` tells the transport to write
|
|
795
|
+
// `turn_count = prior` (UNCHANGED — no turn has completed) and NOT advance
|
|
796
|
+
// `last_turn_at`. The turn is counted EXACTLY ONCE, on the `end` record below — so
|
|
797
|
+
// start+end never double-count. Best-effort: a start-ensure write failure is logged
|
|
798
|
+
// (inside recordThread) and the turn STILL runs — a missing/stale working note must
|
|
799
|
+
// never strand the queue or skip the turn.
|
|
800
|
+
await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
|
|
801
|
+
threadId: turnThreadId,
|
|
802
|
+
phase: "start",
|
|
803
|
+
// NO session on the start-ensure: it runs BEFORE claude, so claude may never
|
|
804
|
+
// establish a session this turn. Persisting `turnSession.id` here would brick the
|
|
805
|
+
// next turn (it'd `--resume` an id for a conversation that never existed →
|
|
806
|
+
// non-transient "No conversation found" → no retry). We persist a session ONLY on
|
|
807
|
+
// the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
|
|
808
|
+
// single-threaded resume turn the prior session is preserved by writeThread anyway.
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
let result;
|
|
812
|
+
try {
|
|
813
|
+
// Forward each interim event to the streaming-view sink (keyed by channel)
|
|
814
|
+
// as the turn runs — the "watch it work" live progress. The sink swallows
|
|
815
|
+
// its own throws (emitTurnEvent), so a dead live stream can't break the turn.
|
|
816
|
+
result = await this.backend.deliver(
|
|
817
|
+
handle.backendHandle,
|
|
818
|
+
msg.content,
|
|
819
|
+
turnSession,
|
|
820
|
+
(e) => this.emitTurnEvent(channel, e),
|
|
821
|
+
// Phase 1: inbound attachments → the programmatic backend stages them into the
|
|
822
|
+
// agent's private workspace so the turn can Read them. Absent/empty → no staging.
|
|
823
|
+
msg.attachments,
|
|
824
|
+
);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
// The backend contract is failure-as-VALUE, never a throw — but defend so a
|
|
827
|
+
// surprise throw can't kill the worker and strand the queue. Resolve the live
|
|
828
|
+
// view to an error state (no stuck "working" spinner).
|
|
829
|
+
const reason = (err as Error).message;
|
|
830
|
+
console.error(
|
|
831
|
+
`parachute-agent: programmatic turn for channel "${channel}" threw ` +
|
|
832
|
+
`(should be a value): ${reason}`,
|
|
833
|
+
);
|
|
834
|
+
// BOTH modes materialize a thread note even on a (defensive-catch) failure — the
|
|
835
|
+
// thread note captures the turn outcome, so a failed turn is still a queryable
|
|
836
|
+
// `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
|
|
837
|
+
// a per-fire note).
|
|
838
|
+
await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
|
|
839
|
+
threadId: turnThreadId,
|
|
840
|
+
phase: "end",
|
|
841
|
+
// No `result` (the backend threw) → NO session to persist. We never write a
|
|
842
|
+
// session claude didn't echo (FIX 2): persisting an unestablished uuid would
|
|
843
|
+
// brick the next turn's `--resume`. A single-threaded prior session is preserved
|
|
844
|
+
// by writeThread; otherwise the next turn self-heals with a fresh create.
|
|
845
|
+
});
|
|
846
|
+
this.emitTurnEvent(channel, { kind: "error", error: reason });
|
|
847
|
+
// Post a user-facing failure note so the channel shows SOMETHING (not a silent
|
|
848
|
+
// no-reply) — best-effort.
|
|
849
|
+
await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
|
|
850
|
+
// CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
|
|
851
|
+
// hang waiting forever. No outbound note was produced, so no `source_message`.
|
|
852
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (!result.ok) {
|
|
857
|
+
// Logged + dropped — no retry, no loop. The backend already persisted the
|
|
858
|
+
// session id (a turn can fail after establishing a session), so the next
|
|
859
|
+
// message resumes the conversation. Resolve the live view to an error state.
|
|
860
|
+
console.warn(
|
|
861
|
+
`parachute-agent: programmatic turn for channel "${channel}" failed: ${result.error}`,
|
|
862
|
+
);
|
|
863
|
+
// BOTH modes record the failed turn (status:error) on the thread note so a failure
|
|
864
|
+
// always leaves a queryable trace (single-threaded upserts the rolling thread,
|
|
865
|
+
// marking it errored; multi-threaded writes a per-fire status:error note).
|
|
866
|
+
await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
|
|
867
|
+
threadId: turnThreadId,
|
|
868
|
+
phase: "end",
|
|
869
|
+
// Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
|
|
870
|
+
// establishing a session (claude emitted it in the init/result event) → resume
|
|
871
|
+
// it next turn. A turn that failed BEFORE establishing one echoes none →
|
|
872
|
+
// `result.sessionId` is undefined → we persist nothing → the next turn
|
|
873
|
+
// self-heals with a fresh create (no brick). NEVER fall back to `turnSession.id`.
|
|
874
|
+
...(result.sessionId ? { session: result.sessionId } : {}),
|
|
875
|
+
});
|
|
876
|
+
this.emitTurnEvent(channel, { kind: "error", error: result.error });
|
|
877
|
+
// Post a user-facing failure note so the channel shows SOMETHING (not a silent
|
|
878
|
+
// no-reply) — best-effort.
|
|
879
|
+
await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
|
|
880
|
+
// CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
|
|
881
|
+
// sub-task failed and can react. No delivered reply, so no `source_message`.
|
|
882
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// The THREAD NOTE comes FIRST — it is the PRIMARY record of the turn (status:ok now
|
|
887
|
+
// that the turn succeeded), so it must survive even if the ADDITIVE outbound transcript
|
|
888
|
+
// write below fails (that path `continue`s past here). Writing it before the outbound
|
|
889
|
+
// (the c34db03 ordering — now applied UNIFORMLY to both modes) guarantees the turn's
|
|
890
|
+
// record survives an outbound failure: single-threaded upserts the rolling thread,
|
|
891
|
+
// multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
|
|
892
|
+
// logged + the turn still resolves (we never re-run a `claude -p` turn — that would
|
|
893
|
+
// burn quota for a duplicate).
|
|
894
|
+
await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
|
|
895
|
+
threadId: turnThreadId,
|
|
896
|
+
phase: "end",
|
|
897
|
+
// Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
|
|
898
|
+
// conversation — the thread≡session record. A successful turn always echoes an id;
|
|
899
|
+
// the guard keeps the "only an established session" invariant uniform.
|
|
900
|
+
...(result.sessionId ? { session: result.sessionId } : {}),
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// The outbound reply — the channel-transcript delivery (the chat bubble). It is
|
|
904
|
+
// ADDITIVE to the primary thread-note record already written above (for BOTH modes).
|
|
905
|
+
// Empty reply → NO note (reviewer contract — `reply` can be ""): a turn that produced
|
|
906
|
+
// no text (e.g. tool-only work) leaves the chat clean.
|
|
907
|
+
//
|
|
908
|
+
// `sourceMessage` — the delivered outbound note id, captured for the callback's
|
|
909
|
+
// `source_message` so an orchestrator can PULL the full reply text. Stays undefined
|
|
910
|
+
// for an empty/tool-only turn (no note) — the callback still fires (status:ok), it
|
|
911
|
+
// just has no reply note to point at (the orchestrator pulls from `source_thread`).
|
|
912
|
+
let sourceMessage: string | undefined;
|
|
913
|
+
if (result.reply && result.reply.length > 0) {
|
|
914
|
+
const delivered = await this.deliverOutboundWithRetry(
|
|
915
|
+
channel,
|
|
916
|
+
result.reply,
|
|
917
|
+
msg.inReplyTo,
|
|
918
|
+
turnThreadId,
|
|
919
|
+
);
|
|
920
|
+
if (delivered.ok) sourceMessage = delivered.noteId;
|
|
921
|
+
if (!delivered.ok) {
|
|
922
|
+
// FIX 1 (PR #3) — the SCARY one. The reply was PRODUCED but, after the bounded
|
|
923
|
+
// retry, still NOT persisted to the transcript (a persistent vault 5xx / network
|
|
924
|
+
// fault, or a real 4xx rejection). We must NOT leave a clean `status:ok` record
|
|
925
|
+
// claiming the reply landed when it didn't:
|
|
926
|
+
// 1. RE-RECORD the thread note as `status:error` so the durable thread record
|
|
927
|
+
// reflects the UN-DELIVERED reply (overwrites the optimistic `ok` upsert for
|
|
928
|
+
// single-threaded; writes/overwrites the per-fire note for multi-threaded).
|
|
929
|
+
// 2. Resolve the live view to ERROR (not `done`) so the UI doesn't drop the
|
|
930
|
+
// in-progress bubble + poll for a note that isn't there (PR #83 nit).
|
|
931
|
+
// We do NOT re-run the `claude -p` turn (that forks/burns quota) — the reply text
|
|
932
|
+
// is preserved IN the error thread note's output for an operator to recover.
|
|
933
|
+
console.error(
|
|
934
|
+
`parachute-agent: programmatic outbound write for channel "${channel}" failed ` +
|
|
935
|
+
`after ${OUTBOUND_MAX_RETRIES} retries: ${delivered.error}`,
|
|
936
|
+
);
|
|
937
|
+
// RE-RECORD the SAME turn as status:error — reuse the per-turn thread id +
|
|
938
|
+
// `sameTurn` so this updates the note the `ok` record above just wrote (one
|
|
939
|
+
// note, no turn_count double-count) rather than minting a duplicate / advancing
|
|
940
|
+
// the count (the FIX-1 re-record bug the reviewer caught).
|
|
941
|
+
await this.recordThread(
|
|
942
|
+
handle,
|
|
943
|
+
msg,
|
|
944
|
+
"error",
|
|
945
|
+
`reply produced but NOT delivered (outbound write failed: ${delivered.error}). ` +
|
|
946
|
+
`Undelivered reply text: ${result.reply}`,
|
|
947
|
+
startedAt,
|
|
948
|
+
result.usage,
|
|
949
|
+
{
|
|
950
|
+
threadId: turnThreadId,
|
|
951
|
+
sameTurn: true,
|
|
952
|
+
phase: "end",
|
|
953
|
+
// The turn DID establish a session (it produced a reply) — keep the ECHOED id
|
|
954
|
+
// on the note so the next turn resumes, even though the outbound transcript
|
|
955
|
+
// write failed. Only claude's echoed id (FIX 2), never the passed uuid.
|
|
956
|
+
...(result.sessionId ? { session: result.sessionId } : {}),
|
|
957
|
+
},
|
|
958
|
+
);
|
|
959
|
+
this.emitTurnEvent(channel, {
|
|
960
|
+
kind: "error",
|
|
961
|
+
error: `reply produced but not saved: ${delivered.error}`,
|
|
962
|
+
});
|
|
963
|
+
// CALLBACK as status:error — the reply was produced but NOT delivered, so the
|
|
964
|
+
// turn did not truly succeed; the orchestrator must learn that. No `source_message`
|
|
965
|
+
// (the outbound note never landed); the undelivered text lives in the error thread
|
|
966
|
+
// note for an operator to recover.
|
|
967
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Resolve the live view: `done` carries the final reply text (empty when the
|
|
973
|
+
// turn produced none) so the UI finalizes the in-progress bubble — the durable
|
|
974
|
+
// note (written above) is what actually persists; this just ends the spinner.
|
|
975
|
+
// Reached only when the outbound write SUCCEEDED, or there was no reply to write
|
|
976
|
+
// (empty/tool-only turn → clean resolve, no note expected).
|
|
977
|
+
this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
|
|
978
|
+
// CALLBACK on success — the turn finished cleanly (status:ok). `sourceMessage` is the
|
|
979
|
+
// delivered reply note (when there was one) the orchestrator pulls the full result from.
|
|
980
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Deliver an agent-to-agent CALLBACK to the originating channel when a requested turn
|
|
986
|
+
* finishes — the request/response substrate ("reply_to"). Called at EVERY terminal point
|
|
987
|
+
* of the drain (success, failure-as-value, defensive-catch throw, AND outbound-delivery
|
|
988
|
+
* failure), because an orchestrator must learn the outcome whether the sub-task succeeded
|
|
989
|
+
* OR failed — a hung orchestrator waiting on a dropped failure is the worst outcome.
|
|
990
|
+
*
|
|
991
|
+
* GUARDS (in order; each is a hard precondition — failing any one is a clean no-op):
|
|
992
|
+
* 1. No {@link WriteCallback} wired → reply_to is inert (a daemon with no vault channels).
|
|
993
|
+
* 2. No `replyTo` on the originating message → an ordinary, non-orchestrated turn. This
|
|
994
|
+
* is THE common case + the first loop guard: a CALLBACK note is written WITHOUT a
|
|
995
|
+
* `reply_to` (see the daemon's wiring), so handling a callback can NEVER itself emit a
|
|
996
|
+
* callback — no ping-pong, structurally.
|
|
997
|
+
* 3. `delegationDepth >= MAX_DELEGATION_DEPTH` → the DEPTH loop guard. Even if guard 2
|
|
998
|
+
* were somehow defeated, this bounds any chain to a finite hop count. We LOG loudly
|
|
999
|
+
* (a hit is a real signal — a delegation tree ran away or a cycle formed) and drop the
|
|
1000
|
+
* callback. The turn itself already ran + recorded; only the onward notification stops.
|
|
1001
|
+
*
|
|
1002
|
+
* The callback CONTENT is a brief NOTIFICATION + a LINK (never the duplicated reply) — the
|
|
1003
|
+
* orchestrator reads `source_thread`/`source_message` off the metadata and PULLS the full
|
|
1004
|
+
* result (the user's explicit choice). METADATA is the {@link CallbackMeta} contract, with
|
|
1005
|
+
* `delegation_depth` = incoming + 1 so the chain's depth climbs each hop.
|
|
1006
|
+
*
|
|
1007
|
+
* Best-effort like the other sinks: a write failure is LOGGED, never thrown — a callback
|
|
1008
|
+
* failure must NOT strand the per-channel drain or re-run the (already-completed) turn.
|
|
1009
|
+
*/
|
|
1010
|
+
private async maybeDeliverCallback(
|
|
1011
|
+
handle: ProgrammaticAgentHandle,
|
|
1012
|
+
msg: QueuedMessage,
|
|
1013
|
+
turnThreadId: string,
|
|
1014
|
+
status: "ok" | "error",
|
|
1015
|
+
sourceMessage?: string,
|
|
1016
|
+
): Promise<void> {
|
|
1017
|
+
// Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
|
|
1018
|
+
if (!this.writeCallback) return;
|
|
1019
|
+
if (!msg.replyTo) return;
|
|
1020
|
+
|
|
1021
|
+
// Guard 3 (DEPTH): bound the delegation chain. The INCOMING depth (default 0) is how
|
|
1022
|
+
// deep this message already is; the callback we'd write is one hop deeper. If the
|
|
1023
|
+
// incoming message is already at/over the ceiling, stop — do not deliver.
|
|
1024
|
+
const incomingDepth = msg.delegationDepth ?? 0;
|
|
1025
|
+
if (incomingDepth >= MAX_DELEGATION_DEPTH) {
|
|
1026
|
+
console.warn(
|
|
1027
|
+
`parachute-agent: delegation depth ${incomingDepth} >= MAX (${MAX_DELEGATION_DEPTH}) for ` +
|
|
1028
|
+
`channel "${handle.channel}" → NOT delivering a callback to "${msg.replyTo}" (loop guard). ` +
|
|
1029
|
+
`A delegation chain ran away or a cycle formed; the turn ran + recorded normally.`,
|
|
1030
|
+
);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// The brief notification + link. NOT the full reply (the orchestrator pulls it).
|
|
1035
|
+
const verb = status === "ok" ? "finished (ok)" : "finished with an error";
|
|
1036
|
+
const content =
|
|
1037
|
+
`[callback] ${handle.channel} ${verb} — see source_message / source_thread in this ` +
|
|
1038
|
+
`note's metadata to pull the full result.`;
|
|
1039
|
+
|
|
1040
|
+
// The metadata contract. delegation_depth = incoming + 1 (this hop). correlation_id +
|
|
1041
|
+
// source_message are echoed/included only when present. The daemon's WriteCallback
|
|
1042
|
+
// wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
|
|
1043
|
+
// — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
|
|
1044
|
+
const meta: CallbackMeta = {
|
|
1045
|
+
callback: "true",
|
|
1046
|
+
status,
|
|
1047
|
+
source_channel: handle.channel,
|
|
1048
|
+
source_thread: turnThreadId,
|
|
1049
|
+
...(sourceMessage ? { source_message: sourceMessage } : {}),
|
|
1050
|
+
...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
|
|
1051
|
+
delegation_depth: String(incomingDepth + 1),
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
await this.writeCallback(msg.replyTo, content, meta);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
console.error(
|
|
1058
|
+
`parachute-agent: delivering callback to channel "${msg.replyTo}" failed ` +
|
|
1059
|
+
`(continuing — the turn already completed + recorded): ${(err as Error).message}`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Materialize the `#agent/thread` note for a completed turn — the UNIFIED model, written
|
|
1066
|
+
* for BOTH modes (the structural unification). A no-op when no {@link WriteThread} sink is
|
|
1067
|
+
* wired (a channel with no durable store). The timing is captured by the caller
|
|
1068
|
+
* (`startedAt` before the turn; `ended_at` is now). The MODE rides on the note so the
|
|
1069
|
+
* transport resolves the thread identity (single-threaded upserts one note per channel,
|
|
1070
|
+
* multi-threaded writes one per fire) + the upsert aggregation. The thread `name` is the
|
|
1071
|
+
* agent name (single-threaded's thread is "named after the definition"). Best-effort: a
|
|
1072
|
+
* write failure is LOGGED, never thrown out — a missing thread note must not strand the
|
|
1073
|
+
* queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
|
|
1074
|
+
*/
|
|
1075
|
+
private async recordThread(
|
|
1076
|
+
handle: ProgrammaticAgentHandle,
|
|
1077
|
+
msg: QueuedMessage,
|
|
1078
|
+
status: "ok" | "error" | "working",
|
|
1079
|
+
output: string,
|
|
1080
|
+
startedAt: string,
|
|
1081
|
+
usage: ThreadNote["usage"],
|
|
1082
|
+
opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
|
|
1083
|
+
): Promise<void> {
|
|
1084
|
+
if (!this.writeThread) return;
|
|
1085
|
+
const thread: ThreadNote = {
|
|
1086
|
+
channel: handle.channel,
|
|
1087
|
+
name: handle.spec.name,
|
|
1088
|
+
...(handle.spec.definition ? { definition: handle.spec.definition } : {}),
|
|
1089
|
+
mode: handle.spec.mode ?? "single-threaded",
|
|
1090
|
+
status,
|
|
1091
|
+
input: msg.content,
|
|
1092
|
+
output,
|
|
1093
|
+
started_at: startedAt,
|
|
1094
|
+
ended_at: new Date().toISOString(),
|
|
1095
|
+
...(usage ? { usage } : {}),
|
|
1096
|
+
// The Claude session UUID for this turn — persisted to the thread note so the next
|
|
1097
|
+
// turn `--resume`s it (the thread≡session record). The transport preserves a prior
|
|
1098
|
+
// single-threaded session when a write carries none.
|
|
1099
|
+
...(opts.session ? { session: opts.session } : {}),
|
|
1100
|
+
// The per-turn thread id (stable across an ok→error re-record) + the same-turn flag,
|
|
1101
|
+
// so a re-record updates the SAME note without minting a duplicate (multi) or
|
|
1102
|
+
// double-counting turn_count (single).
|
|
1103
|
+
...(opts.threadId ? { threadId: opts.threadId } : {}),
|
|
1104
|
+
...(opts.sameTurn ? { sameTurn: true } : {}),
|
|
1105
|
+
// The lifecycle phase — `start` (working-ensure, no turn counted) vs `end` (final
|
|
1106
|
+
// record, turn counted). Absent → `end` at the transport (back-compat).
|
|
1107
|
+
...(opts.phase ? { phase: opts.phase } : {}),
|
|
1108
|
+
};
|
|
1109
|
+
try {
|
|
1110
|
+
await this.writeThread(thread);
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
console.error(
|
|
1113
|
+
`parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
|
|
1114
|
+
`(continuing): ${(err as Error).message}`,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Deliver the outbound reply with a BOUNDED retry on a TRANSIENT failure (FIX 1, PR
|
|
1121
|
+
* #3). A vault 5xx / network blip during the outbound write used to silently lose the
|
|
1122
|
+
* reply (the turn resolved, the thread note said "ok", but the chat bubble never
|
|
1123
|
+
* landed). We retry up to {@link OUTBOUND_MAX_RETRIES} times with a small linear
|
|
1124
|
+
* backoff on a transient error ({@link isTransientOutboundError}: a 5xx or a
|
|
1125
|
+
* no-status network error). A PERMANENT error (a 4xx — a real rejection) does NOT
|
|
1126
|
+
* retry. Returns `{ ok: true }` once the write lands, or `{ ok: false, error }` after
|
|
1127
|
+
* exhausting the retries / on a permanent failure — the caller then records the turn
|
|
1128
|
+
* as un-delivered + surfaces it (never claims a clean success). We NEVER re-run the
|
|
1129
|
+
* `claude -p` turn here (that would fork the conversation / burn quota); only the
|
|
1130
|
+
* idempotent outbound WRITE is retried.
|
|
1131
|
+
*/
|
|
1132
|
+
private async deliverOutboundWithRetry(
|
|
1133
|
+
channel: string,
|
|
1134
|
+
reply: string,
|
|
1135
|
+
inReplyTo?: string,
|
|
1136
|
+
threadId?: string,
|
|
1137
|
+
): Promise<{ ok: true; noteId?: string } | { ok: false; error: string }> {
|
|
1138
|
+
let lastError = "";
|
|
1139
|
+
for (let attempt = 0; attempt <= OUTBOUND_MAX_RETRIES; attempt++) {
|
|
1140
|
+
try {
|
|
1141
|
+
// Capture the written note id (when the seam returns one) so the caller can
|
|
1142
|
+
// point a callback's `source_message` at the delivered reply. A void return →
|
|
1143
|
+
// no id (the callback then omits source_message — still fires).
|
|
1144
|
+
const written = await this.writeOutbound(channel, reply, inReplyTo, threadId);
|
|
1145
|
+
return { ok: true, ...(written && written.id ? { noteId: written.id } : {}) };
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
lastError = (err as Error).message;
|
|
1148
|
+
const transient = isTransientOutboundError(err);
|
|
1149
|
+
const more = attempt < OUTBOUND_MAX_RETRIES;
|
|
1150
|
+
if (!transient || !more) {
|
|
1151
|
+
// A permanent (4xx) error never retries; a transient one that exhausted the
|
|
1152
|
+
// budget falls through to the failure return below.
|
|
1153
|
+
if (!transient) {
|
|
1154
|
+
console.warn(
|
|
1155
|
+
`parachute-agent: outbound write for channel "${channel}" failed with a ` +
|
|
1156
|
+
`non-transient error (not retrying): ${lastError}`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return { ok: false, error: lastError };
|
|
1160
|
+
}
|
|
1161
|
+
// Transient + retries remain — back off (linear) and try again.
|
|
1162
|
+
console.warn(
|
|
1163
|
+
`parachute-agent: outbound write for channel "${channel}" transient failure ` +
|
|
1164
|
+
`(attempt ${attempt + 1}/${OUTBOUND_MAX_RETRIES + 1}), retrying: ${lastError}`,
|
|
1165
|
+
);
|
|
1166
|
+
await delay(this.outboundRetryBaseMs * (attempt + 1));
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return { ok: false, error: lastError };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Post a brief, user-facing FAILURE note to the channel when a turn doesn't complete
|
|
1174
|
+
* (the backend's transient-retry is exhausted, or a non-transient error). A silent
|
|
1175
|
+
* no-reply reads as "nothing came through" — this makes the failure visible in the
|
|
1176
|
+
* transcript, with the reason. Best-effort: a failed failure-note write is logged,
|
|
1177
|
+
* never thrown (it must not break the drain). Reuses the bounded outbound-write retry.
|
|
1178
|
+
*/
|
|
1179
|
+
private async postFailureNote(
|
|
1180
|
+
channel: string,
|
|
1181
|
+
inReplyTo: string | undefined,
|
|
1182
|
+
threadId: string,
|
|
1183
|
+
reason: string,
|
|
1184
|
+
): Promise<void> {
|
|
1185
|
+
const short = reason.length > 240 ? `${reason.slice(0, 240)}…` : reason;
|
|
1186
|
+
const text =
|
|
1187
|
+
`⚠️ I couldn't complete that — the turn failed: ${short}\n\n` +
|
|
1188
|
+
`This is often temporary; please try again in a moment.`;
|
|
1189
|
+
try {
|
|
1190
|
+
const delivered = await this.deliverOutboundWithRetry(channel, text, inReplyTo, threadId);
|
|
1191
|
+
if (!delivered.ok) {
|
|
1192
|
+
console.error(
|
|
1193
|
+
`parachute-agent: failure note for channel "${channel}" not delivered: ${delivered.error}`,
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
console.error(
|
|
1198
|
+
`parachute-agent: posting failure note for channel "${channel}" threw (continuing): ${(err as Error).message}`,
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|