@vellumai/assistant 0.10.1 → 0.10.2-dev.202606241651.2d2b40d
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/docs/workspace-tools.md +42 -33
- package/eslint-rules/cli-no-daemon-internals.js +6 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-delivery-contract.test.ts +91 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/src/guardian-delivery-contract.ts +48 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +14 -0
- package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +17 -0
- package/openapi.yaml +74 -1
- package/package.json +1 -1
- package/scripts/test.sh +36 -15
- package/src/__tests__/actor-token-service.test.ts +36 -14
- package/src/__tests__/agent-loop-override-profile.test.ts +1 -0
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
- package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
- package/src/__tests__/annotate-activity-metadata.test.ts +2 -0
- package/src/__tests__/annotate-risk-options.test.ts +2 -0
- package/src/__tests__/approval-cascade.test.ts +2 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +2 -0
- package/src/__tests__/btw-routes.test.ts +2 -0
- package/src/__tests__/build-persisted-content.test.ts +2 -0
- package/src/__tests__/call-controller.test.ts +19 -0
- package/src/__tests__/channel-guardian.test.ts +94 -58
- package/src/__tests__/channel-reply-delivery.test.ts +2 -0
- package/src/__tests__/compaction-events.test.ts +2 -0
- package/src/__tests__/compaction.benchmark.test.ts +2 -0
- package/src/__tests__/compactor-call-site-logging.test.ts +2 -0
- package/src/__tests__/compactor-low-watermark-cut.test.ts +2 -0
- package/src/__tests__/compactor-preserved-tail-count.test.ts +2 -0
- package/src/__tests__/compactor-summary-call-truncation.test.ts +2 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +2 -0
- package/src/__tests__/computer-use-tools.test.ts +13 -0
- package/src/__tests__/config-loader-backfill.test.ts +5 -1
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +31 -29
- package/src/__tests__/contacts-relay-reads.test.ts +13 -15
- package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-analysis-routes.test.ts +2 -0
- package/src/__tests__/conversation-app-control-lifecycle.test.ts +2 -0
- package/src/__tests__/conversation-confirmation-signals.test.ts +2 -0
- package/src/__tests__/conversation-history-web-search.test.ts +2 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +2 -0
- package/src/__tests__/conversation-load-history-stripped.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +2 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +2 -0
- package/src/__tests__/conversation-process-callsite.test.ts +2 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
- package/src/__tests__/conversation-queue.test.ts +91 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +14 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +14 -0
- package/src/__tests__/conversation-slash-queue.test.ts +2 -0
- package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
- package/src/__tests__/conversation-speed-override.test.ts +2 -0
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +65 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -0
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +47 -0
- package/src/__tests__/conversation-usage.test.ts +2 -0
- package/src/__tests__/conversation-workspace-cache-state.test.ts +2 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
- package/src/__tests__/credential-security-invariants.test.ts +0 -1
- package/src/__tests__/db-migration-rollback.test.ts +205 -171
- package/src/__tests__/db-test-helpers.ts +5 -4
- package/src/__tests__/deterministic-verification-control-plane.test.ts +4 -2
- package/src/__tests__/disk-pressure-guard.test.ts +41 -0
- package/src/__tests__/dm-persistence.test.ts +2 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +10 -5
- package/src/__tests__/events-dev-bypass-actor.test.ts +7 -1
- package/src/__tests__/filing-service.test.ts +2 -0
- package/src/__tests__/guardian-binding-drift-heal.test.ts +75 -10
- package/src/__tests__/guardian-dispatch.test.ts +95 -1
- package/src/__tests__/guardian-outbound-http.test.ts +13 -0
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +2 -0
- package/src/__tests__/helpers/channel-test-adapter.ts +1 -7
- package/src/__tests__/host-app-control-routes.test.ts +24 -30
- package/src/__tests__/host-bash-routes.test.ts +31 -41
- package/src/__tests__/host-browser-routes.test.ts +26 -32
- package/src/__tests__/host-cu-proxy.test.ts +299 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +25 -33
- package/src/__tests__/host-file-routes-targeted.test.ts +40 -52
- package/src/__tests__/host-transfer-routes-targeted.test.ts +31 -43
- package/src/__tests__/http-user-message-parity.test.ts +167 -8
- package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
- package/src/__tests__/invite-redemption-service.test.ts +43 -0
- package/src/__tests__/llm-context-normalization.test.ts +105 -0
- package/src/__tests__/llm-usage-store.test.ts +25 -0
- package/src/__tests__/media-stream-server-integration.test.ts +127 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +2 -0
- package/src/__tests__/messaging-send-tool.test.ts +2 -0
- package/src/__tests__/migration-import-from-url.test.ts +2 -2
- package/src/__tests__/native-web-search.test.ts +2 -0
- package/src/__tests__/non-member-access-request.test.ts +189 -17
- package/src/__tests__/notification-broadcaster.test.ts +4 -0
- package/src/__tests__/notification-decision-recipient-context.test.ts +33 -32
- package/src/__tests__/notification-deep-link.test.ts +6 -0
- package/src/__tests__/notification-guardian-path.test.ts +19 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +2 -0
- package/src/__tests__/pending-interactions-resolved-event.test.ts +7 -4
- package/src/__tests__/persistence-secret-redaction.test.ts +2 -0
- package/src/__tests__/plugin-bootstrap.test.ts +3 -73
- package/src/__tests__/plugin-route-contribution.test.ts +4 -17
- package/src/__tests__/plugin-tool-contribution.test.ts +3 -18
- package/src/__tests__/plugin-types.test.ts +0 -2
- package/src/__tests__/process-message-background-slack.test.ts +2 -0
- package/src/__tests__/process-message-display-content.test.ts +2 -0
- package/src/__tests__/provider-usage-tracking.test.ts +39 -0
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +2 -0
- package/src/__tests__/registry.test.ts +3 -0
- package/src/__tests__/relay-server.test.ts +694 -25
- package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
- package/src/__tests__/secret-ingress-http.test.ts +14 -0
- package/src/__tests__/send-endpoint-busy.test.ts +30 -8
- package/src/__tests__/skills.test.ts +44 -0
- package/src/__tests__/slack-inbound-verification.test.ts +47 -2
- package/src/__tests__/sse-actor-principal-guardian-source.test.ts +102 -0
- package/src/__tests__/steer-on-enqueue-question.test.ts +181 -0
- package/src/__tests__/stt-hints.test.ts +44 -13
- package/src/__tests__/subagent-detail.test.ts +27 -0
- package/src/__tests__/subagent-disposal.test.ts +65 -0
- package/src/__tests__/subagent-notify-parent.test.ts +2 -0
- package/src/__tests__/subagent-spawn-tool-fork.test.ts +2 -0
- package/src/__tests__/subagent-tools.test.ts +2 -0
- package/src/__tests__/suggestion-routes.test.ts +2 -0
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
- package/src/__tests__/tool-executor.test.ts +16 -11
- package/src/__tests__/tool-preview-lifecycle.test.ts +2 -0
- package/src/__tests__/tool-result-metadata-plumbing.test.ts +2 -0
- package/src/__tests__/tool-start-timestamp.test.ts +2 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +10 -10
- package/src/__tests__/twilio-routes.test.ts +96 -0
- package/src/__tests__/verification-control-plane-policy.test.ts +2 -0
- package/src/__tests__/web-search-backend-failure.test.ts +2 -0
- package/src/__tests__/workspace-tool-loader.test.ts +195 -2
- package/src/agent/loop-exclusive-tool.test.ts +150 -0
- package/src/agent/loop.ts +56 -0
- package/src/api/constants/sse-replay.ts +41 -0
- package/src/api/index.ts +6 -0
- package/src/api/responses/llm-request-log-entry.ts +25 -0
- package/src/api/responses/subagent-detail.ts +17 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +262 -4
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/guardian-dispatch.ts +10 -8
- package/src/calls/inbound-trust-reader.ts +17 -1
- package/src/calls/media-stream-server.ts +21 -0
- package/src/calls/relay-server.ts +167 -50
- package/src/calls/relay-setup-router.ts +37 -7
- package/src/calls/relay-verification.ts +4 -4
- package/src/calls/stt-hints.ts +9 -12
- package/src/calls/twilio-routes.ts +14 -4
- package/src/cli/commands/__tests__/cache.test.ts +8 -1
- package/src/cli/commands/cache.ts +194 -181
- package/src/cli/commands/db/__tests__/repair.test.ts +6 -5
- package/src/cli/commands/db/status.ts +37 -1
- package/src/cli/commands/mcp.ts +252 -218
- package/src/cli/commands/memory/__tests__/worker.test.ts +302 -0
- package/src/cli/commands/memory/index.ts +2 -0
- package/src/cli/commands/memory/worker.ts +175 -0
- package/src/cli/commands/plugins.ts +75 -3
- package/src/cli/lib/__tests__/install-from-github.test.ts +102 -0
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +160 -1
- package/src/cli/lib/list-installed-plugins.ts +179 -1
- package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +143 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +6 -1
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +27 -17
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +13 -3
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/loader.ts +36 -5
- package/src/config/schemas/__tests__/memory-v3.test.ts +1 -0
- package/src/config/schemas/memory-lifecycle.ts +12 -0
- package/src/config/schemas/memory-v3.ts +7 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/schemas/timeouts.ts +8 -0
- package/src/config/seed-inference-profiles.ts +14 -5
- package/src/config/skills.ts +27 -5
- package/src/contacts/__tests__/guardian-delivery-reader.test.ts +312 -0
- package/src/contacts/contacts-write.ts +3 -0
- package/src/contacts/guardian-delivery-reader.ts +223 -0
- package/src/daemon/conversation-agent-loop.ts +9 -0
- package/src/daemon/conversation-process.ts +39 -17
- package/src/daemon/conversation-surfaces.ts +8 -0
- package/src/daemon/conversation-tool-setup.ts +49 -16
- package/src/daemon/conversation.ts +21 -2
- package/src/daemon/disk-pressure-guard.ts +12 -2
- package/src/daemon/event-loop-watchdog.ts +28 -1
- package/src/daemon/external-plugins-bootstrap.ts +4 -34
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +25 -0
- package/src/daemon/handlers/__tests__/config-channels.test.ts +225 -0
- package/src/daemon/handlers/config-a2a.ts +6 -14
- package/src/daemon/handlers/config-channels.ts +78 -22
- package/src/daemon/handlers/conversations.ts +77 -0
- package/src/daemon/host-cu-proxy.ts +102 -11
- package/src/daemon/lifecycle.ts +4 -0
- package/src/daemon/memory-v2-startup.test.ts +72 -0
- package/src/daemon/memory-v2-startup.ts +87 -19
- package/src/daemon/server.ts +0 -4
- package/src/daemon/shutdown-handlers.ts +20 -0
- package/src/daemon/tool-setup-types.ts +9 -0
- package/src/ipc/__tests__/clients-list-ipc.test.ts +1 -1
- package/src/ipc/assistant-server.ts +2 -2
- package/src/memory/__tests__/301-create-watchdog-events.test.ts +110 -0
- package/src/memory/__tests__/memory-retrospective-job.test.ts +8 -0
- package/src/memory/__tests__/prompt-override.test.ts +192 -0
- package/src/memory/__tests__/watchdog-events-store.test.ts +161 -0
- package/src/memory/conversation-crud.ts +38 -0
- package/src/memory/db-connection.ts +22 -3
- package/src/memory/db-init.ts +36 -502
- package/src/memory/db-singleton.ts +6 -4
- package/src/memory/jobs-worker.ts +58 -0
- package/src/memory/llm-usage-store.ts +48 -20
- package/src/memory/memory-retrospective-job.ts +9 -8
- package/src/memory/migrations/014-backfill-inbox-thread-state.ts +13 -3
- package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -27
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +130 -56
- package/src/memory/migrations/300-add-processing-started-at.ts +30 -0
- package/src/memory/migrations/301-create-watchdog-events.ts +45 -0
- package/src/memory/migrations/__tests__/014-backfill-inbox-thread-state.test.ts +108 -0
- package/src/memory/migrations/__tests__/136-drop-assistant-id-columns.test.ts +82 -0
- package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +224 -0
- package/src/memory/migrations/__tests__/run-migrations.test.ts +2 -2
- package/src/memory/migrations/run-migrations.ts +90 -6
- package/src/memory/migrations/schema-introspection.ts +14 -0
- package/src/memory/migrations/validate-migration-state.ts +101 -66
- package/src/memory/prompt-override.ts +129 -0
- package/src/memory/schema/conversations.ts +9 -0
- package/src/memory/schema/infrastructure.ts +20 -0
- package/src/memory/steps.ts +573 -0
- package/src/memory/v2/__tests__/cli-command-store.test.ts +25 -0
- package/src/memory/v2/__tests__/skill-store.test.ts +80 -0
- package/src/memory/v2/cli-command-store.ts +75 -38
- package/src/memory/v2/prompts/consolidation.ts +13 -82
- package/src/memory/v2/prompts/router.ts +21 -93
- package/src/memory/v2/skill-store.ts +68 -31
- package/src/memory/watchdog-events-store.ts +87 -0
- package/src/memory/worker-control.ts +118 -0
- package/src/memory/worker-process.ts +72 -0
- package/src/notifications/__tests__/broadcaster.test.ts +16 -8
- package/src/notifications/__tests__/connected-channels.test.ts +114 -0
- package/src/notifications/__tests__/decision-engine.test.ts +78 -9
- package/src/notifications/__tests__/destination-resolver.test.ts +256 -0
- package/src/notifications/broadcaster.ts +8 -1
- package/src/notifications/decision-engine.ts +15 -7
- package/src/notifications/destination-resolver.ts +68 -24
- package/src/notifications/emit-signal.ts +39 -14
- package/src/onboarding/checkin-event.test.ts +220 -0
- package/src/onboarding/checkin-event.ts +321 -0
- package/src/onboarding/schedule-checkin.ts +190 -0
- package/src/permissions/question-prompter.test.ts +1 -1
- package/src/permissions/question-prompter.ts +7 -4
- package/src/plugin-api/index.ts +6 -6
- package/src/plugin-api/types.ts +3 -5
- package/src/plugin-api/vision-support.test.ts +28 -4
- package/src/plugin-api/vision-support.ts +66 -31
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +161 -0
- package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +106 -0
- package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +60 -0
- package/src/plugins/defaults/advisor/consult.ts +110 -6
- package/src/plugins/defaults/advisor/context-pack.ts +288 -0
- package/src/plugins/defaults/advisor/steering.ts +14 -2
- package/src/plugins/defaults/advisor/tools/advisor.ts +32 -5
- package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +47 -7
- package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +10 -11
- package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +12 -20
- package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +42 -11
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +11 -2
- package/src/plugins/defaults/memory-v3-shadow/pool-select.test.ts +146 -0
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +29 -1
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +8 -1
- package/src/plugins/mtime-cache.ts +7 -2
- package/src/plugins/types.ts +0 -2
- package/src/providers/anthropic/client.ts +5 -0
- package/src/providers/call-site-routing.ts +4 -0
- package/src/providers/model-catalog.ts +16 -0
- package/src/providers/openai/responses-provider.ts +5 -0
- package/src/providers/openrouter/client.ts +5 -0
- package/src/providers/provider-send-message.ts +4 -0
- package/src/providers/ratelimit.ts +4 -0
- package/src/providers/retry.ts +4 -0
- package/src/providers/types.ts +9 -0
- package/src/providers/usage-tracking.ts +4 -0
- package/src/runtime/__tests__/channel-verification-service.test.ts +133 -0
- package/src/runtime/__tests__/guardian-vellum-migration.test.ts +181 -0
- package/src/runtime/__tests__/is-guardian-bound-for-channel.test.ts +66 -0
- package/src/runtime/__tests__/local-principal-trust.test.ts +164 -0
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +335 -3
- package/src/runtime/access-request-helper.ts +19 -39
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/anchored-guardian.test.ts +156 -0
- package/src/runtime/anchored-guardian.ts +135 -0
- package/src/runtime/assistant-event-hub.ts +1 -1
- package/src/runtime/assistant-stream-state.ts +9 -2
- package/src/runtime/auth/__tests__/require-bound-guardian.test.ts +99 -0
- package/src/runtime/auth/require-bound-guardian.ts +21 -11
- package/src/runtime/channel-verification-service.ts +56 -31
- package/src/runtime/confirmation-request-guardian-bridge.ts +3 -3
- package/src/runtime/guardian-vellum-migration.ts +66 -7
- package/src/runtime/invite-redemption-service.ts +50 -18
- package/src/runtime/local-actor-identity.ts +76 -11
- package/src/runtime/local-principal-trust.ts +52 -0
- package/src/runtime/pending-interactions.ts +11 -1
- package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +56 -5
- package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/contact-routes.test.ts +212 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +93 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +215 -1
- package/src/runtime/routes/browser-routes.ts +1 -1
- package/src/runtime/routes/channel-verification-routes.ts +3 -3
- package/src/runtime/routes/contact-routes.ts +8 -32
- package/src/runtime/routes/conversation-cli-routes.ts +4 -5
- package/src/runtime/routes/conversation-list-routes.ts +4 -7
- package/src/runtime/routes/conversation-routes.ts +74 -81
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/global-search-routes.ts +3 -1
- package/src/runtime/routes/guardian-action-routes.ts +4 -5
- package/src/runtime/routes/host-app-control-routes.ts +5 -4
- package/src/runtime/routes/host-bash-routes.ts +5 -4
- package/src/runtime/routes/host-browser-routes.ts +9 -11
- package/src/runtime/routes/host-cu-routes.ts +5 -4
- package/src/runtime/routes/host-file-routes.ts +5 -4
- package/src/runtime/routes/host-transfer-routes.ts +6 -6
- package/src/runtime/routes/http-adapter.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +3 -2
- package/src/runtime/routes/inbound-message-handler.ts +5 -5
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +97 -5
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +61 -49
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +16 -4
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +7 -7
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +21 -8
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +14 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/llm-context-normalization.ts +71 -0
- package/src/runtime/routes/mcp-auth-routes.ts +38 -15
- package/src/runtime/routes/migration-rollback-routes.ts +4 -3
- package/src/runtime/routes/migration-routes.ts +4 -1
- package/src/runtime/routes/onboarding-checkin-routes.ts +86 -0
- package/src/runtime/routes/subagents-routes.ts +5 -0
- package/src/runtime/routes/surface-action-routes.ts +51 -55
- package/src/runtime/services/__tests__/conversation-serializer.test.ts +1 -0
- package/src/runtime/services/conversation-serializer.ts +7 -9
- package/src/runtime/tool-grant-request-helper.ts +3 -3
- package/src/runtime/trust-verdict-consumer.ts +85 -9
- package/src/runtime/verification-outbound-actions.ts +18 -18
- package/src/signals/user-message.ts +16 -0
- package/src/subagent/manager.ts +9 -0
- package/src/telemetry/types.ts +34 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
- package/src/telemetry/usage-telemetry-reporter.ts +87 -3
- package/src/tools/ask-question/ask-question-tool.test.ts +29 -0
- package/src/tools/ask-question/ask-question-tool.ts +13 -0
- package/src/tools/computer-use/definitions.ts +8 -2
- package/src/tools/executor.ts +4 -4
- package/src/tools/registry.ts +18 -0
- package/src/tools/tool-approval-handler.ts +1 -1
- package/src/tools/tool-defaults.ts +9 -2
- package/src/tools/types.ts +17 -2
- package/src/tools/workspace-tools/loader.ts +348 -244
- package/src/util/platform.ts +5 -0
- package/src/util/telemetry-db-path.ts +24 -0
- package/src/workspace/migrations/017-seed-persona-dirs.ts +3 -34
- package/src/workspace/migrations/019-scope-journal-to-guardian.ts +3 -24
- package/src/__tests__/workspace-tools-watcher-flag.test.ts +0 -70
- package/src/daemon/workspace-tools-watcher.ts +0 -328
- package/src/memory/migrations/registry.ts +0 -573
|
@@ -30,13 +30,30 @@
|
|
|
30
30
|
* → loadWorkspaceTools() ← this module (first scan)
|
|
31
31
|
* → loadUserPlugins()
|
|
32
32
|
* → bootstrapPlugins()
|
|
33
|
-
* → start file watcher ← hot register/unregister (no restart)
|
|
34
33
|
*
|
|
35
34
|
* Plugins load *after* the initial workspace-tool scan so the registry
|
|
36
35
|
* hands them a stable view of which workspace tools exist before any
|
|
37
|
-
* plugin code runs.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* plugin code runs.
|
|
37
|
+
*
|
|
38
|
+
* ## Reconcile on read, not on a watcher
|
|
39
|
+
*
|
|
40
|
+
* {@link loadWorkspaceTools} is idempotent and safe to call repeatedly:
|
|
41
|
+
* after the initial scan it reconciles the registry against on-disk
|
|
42
|
+
* state. Each call re-derives "given what's on disk right now under
|
|
43
|
+
* `tools/`, what registry state should the assistant be in?" and applies
|
|
44
|
+
* the delta — registering newly added tools, re-importing changed tools
|
|
45
|
+
* (mtime-gated, cache-busting via the per-import URL query string),
|
|
46
|
+
* unregistering deleted tools, stripping core tools when a `.removed`
|
|
47
|
+
* sentinel appears, and restoring them when it disappears.
|
|
48
|
+
*
|
|
49
|
+
* Instead of a long-lived filesystem watcher, the per-turn tool resolver
|
|
50
|
+
* (`createResolveToolsCallback` in `conversation-tool-setup.ts`) kicks this
|
|
51
|
+
* reconcile and then re-reads workspace tools from the registry — the same
|
|
52
|
+
* way it re-reads MCP tools — so a conversation picks up on-disk edits
|
|
53
|
+
* without a restart and without recreating the conversation. The "edit a
|
|
54
|
+
* file, see the change" loop closes on the next turn. Unchanged files are
|
|
55
|
+
* skipped via the mtime cache, so a no-op reconcile costs one `readdir`
|
|
56
|
+
* plus a `stat` per file and never re-imports.
|
|
40
57
|
*
|
|
41
58
|
* Per-tool isolation:
|
|
42
59
|
*
|
|
@@ -62,12 +79,19 @@
|
|
|
62
79
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
63
80
|
import { readFile } from "node:fs/promises";
|
|
64
81
|
import { extname, join } from "node:path";
|
|
65
|
-
import { pathToFileURL } from "node:url";
|
|
66
82
|
|
|
67
83
|
import { getLogger } from "../../util/logger.js";
|
|
68
84
|
import { getWorkspaceToolsDir } from "../../util/platform.js";
|
|
69
85
|
import { isProviderSafeToolName } from "../provider-tool-name.js";
|
|
70
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
getCoreToolOverride,
|
|
88
|
+
getTool,
|
|
89
|
+
getToolOwner,
|
|
90
|
+
registerWorkspaceTools,
|
|
91
|
+
removeCoreToolViaWorkspace,
|
|
92
|
+
restoreStrippedCoreTool,
|
|
93
|
+
unregisterWorkspaceTool,
|
|
94
|
+
} from "../registry.js";
|
|
71
95
|
import { finalizeTool } from "../tool-defaults.js";
|
|
72
96
|
import type {
|
|
73
97
|
RiskLevel,
|
|
@@ -139,14 +163,21 @@ function isValidToolFilenameStem(stem: string): boolean {
|
|
|
139
163
|
*/
|
|
140
164
|
function classifyEntry(
|
|
141
165
|
entry: string,
|
|
142
|
-
):
|
|
166
|
+
):
|
|
167
|
+
| { kind: "live"; stem: string; ext: LiveToolExtension }
|
|
168
|
+
| { kind: "removed"; stem: string }
|
|
169
|
+
| undefined {
|
|
143
170
|
const ext = extname(entry);
|
|
144
171
|
if (ext === REMOVED_EXTENSION) {
|
|
145
172
|
return { kind: "removed", stem: entry.slice(0, -REMOVED_EXTENSION.length) };
|
|
146
173
|
}
|
|
147
174
|
for (const candidate of LIVE_TOOL_EXTENSIONS) {
|
|
148
175
|
if (ext === candidate) {
|
|
149
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
kind: "live",
|
|
178
|
+
stem: entry.slice(0, -candidate.length),
|
|
179
|
+
ext: candidate,
|
|
180
|
+
};
|
|
150
181
|
}
|
|
151
182
|
}
|
|
152
183
|
return undefined;
|
|
@@ -162,7 +193,9 @@ interface LiveSelection {
|
|
|
162
193
|
shadowed: LiveToolExtension[];
|
|
163
194
|
}
|
|
164
195
|
|
|
165
|
-
function selectLiveExtension(
|
|
196
|
+
function selectLiveExtension(
|
|
197
|
+
extensions: Set<LiveToolExtension>,
|
|
198
|
+
): LiveSelection {
|
|
166
199
|
for (const candidate of LIVE_TOOL_EXTENSIONS) {
|
|
167
200
|
if (extensions.has(candidate)) {
|
|
168
201
|
const shadowed: LiveToolExtension[] = [];
|
|
@@ -192,14 +225,19 @@ function selectLiveExtension(extensions: Set<LiveToolExtension>): LiveSelection
|
|
|
192
225
|
* The tool still loads cleanly with these defaults — a broken tool must
|
|
193
226
|
* never block daemon boot. Always sets `category: "workspace"` so the
|
|
194
227
|
* registry can distinguish workspace overrides from other origins.
|
|
228
|
+
*
|
|
229
|
+
* The registered name is pinned to the filename stem (`name`), overriding
|
|
230
|
+
* any `name` field on the file's own export. This is the documented
|
|
231
|
+
* "filename stem is the tool name verbatim" contract — `finalizeTool`
|
|
232
|
+
* would otherwise prefer `tool.name` — and it keeps the registered name in
|
|
233
|
+
* lockstep with the stem the reconcile keys its mtime cache by, so a later
|
|
234
|
+
* delete of the file unregisters the right tool.
|
|
195
235
|
*/
|
|
196
|
-
function applyWorkspaceToolDefaults(
|
|
197
|
-
tool: ToolDefinition,
|
|
198
|
-
name: string,
|
|
199
|
-
): Tool {
|
|
236
|
+
function applyWorkspaceToolDefaults(tool: ToolDefinition, name: string): Tool {
|
|
200
237
|
const finalized = finalizeTool(
|
|
201
238
|
{
|
|
202
239
|
...tool,
|
|
240
|
+
name,
|
|
203
241
|
defaultRiskLevel:
|
|
204
242
|
tool.defaultRiskLevel ?? WORKSPACE_TOOL_DEFAULTS.defaultRiskLevel,
|
|
205
243
|
category: tool.category ?? "workspace",
|
|
@@ -221,10 +259,18 @@ function applyWorkspaceToolDefaults(
|
|
|
221
259
|
* module's default export, or `undefined` if the import times out, has
|
|
222
260
|
* no default export, or throws.
|
|
223
261
|
*
|
|
224
|
-
* A cache-busting `?v=<counter>` query string is appended so
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
262
|
+
* A cache-busting `?v=<counter>` query string is appended so a reconcile
|
|
263
|
+
* that re-imports a changed file picks up the new contents instead of the
|
|
264
|
+
* module bun already transpiled. The counter is per-call, so every import
|
|
265
|
+
* gets a fresh module identity.
|
|
266
|
+
*
|
|
267
|
+
* The specifier is the raw absolute path (not a `file://` URL): bun honors
|
|
268
|
+
* the `?v=` query for cache-busting on a bare absolute path but collapses
|
|
269
|
+
* it to the same cached module for a `file://` URL, which would silently
|
|
270
|
+
* serve stale source on re-import. Absolute paths (and embedded spaces)
|
|
271
|
+
* import cleanly; only `?`/`#` in the path would confuse the query, and
|
|
272
|
+
* tool stems are provider-safe so the directory prefix is the only place
|
|
273
|
+
* those could appear.
|
|
228
274
|
*
|
|
229
275
|
* All failure paths log with file attribution so operators can find the
|
|
230
276
|
* broken tool quickly.
|
|
@@ -235,7 +281,7 @@ async function importToolDefaultBounded(
|
|
|
235
281
|
entryPath: string,
|
|
236
282
|
timeoutMs: number,
|
|
237
283
|
): Promise<unknown> {
|
|
238
|
-
const url = `${
|
|
284
|
+
const url = `${entryPath}?v=${++importCounter}`;
|
|
239
285
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
240
286
|
try {
|
|
241
287
|
const timeoutSentinel = Symbol("workspace-tool-import-timeout");
|
|
@@ -285,7 +331,9 @@ async function importToolDefaultBounded(
|
|
|
285
331
|
* exist for declarative use cases — schema-only tool stubs, override
|
|
286
332
|
* placeholders, etc.
|
|
287
333
|
*/
|
|
288
|
-
async function readJsonToolSpec(
|
|
334
|
+
async function readJsonToolSpec(
|
|
335
|
+
entryPath: string,
|
|
336
|
+
): Promise<ToolDefinition | undefined> {
|
|
289
337
|
let raw: string;
|
|
290
338
|
try {
|
|
291
339
|
raw = await readFile(entryPath, "utf8");
|
|
@@ -326,53 +374,62 @@ export interface LoadWorkspaceToolsOptions {
|
|
|
326
374
|
}
|
|
327
375
|
|
|
328
376
|
/**
|
|
329
|
-
* Result of a {@link loadWorkspaceTools} call —
|
|
330
|
-
*
|
|
331
|
-
*
|
|
377
|
+
* Result of a {@link loadWorkspaceTools} call — the names workspace tools
|
|
378
|
+
* currently own and the core-tool names currently stripped via
|
|
379
|
+
* `<name>.removed` sentinels, reflecting the registry state after the
|
|
380
|
+
* reconcile applied its delta.
|
|
332
381
|
*/
|
|
333
382
|
export interface LoadWorkspaceToolsResult {
|
|
334
|
-
/** Tool names
|
|
383
|
+
/** Tool names currently registered as workspace tools. */
|
|
335
384
|
readonly registered: string[];
|
|
336
|
-
/**
|
|
385
|
+
/** Core-tool names currently stripped via `<name>.removed` sentinels. */
|
|
337
386
|
readonly removed: string[];
|
|
338
387
|
}
|
|
339
388
|
|
|
340
389
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* Invariants:
|
|
347
|
-
*
|
|
348
|
-
* - No-ops silently when the tools directory does not exist. A clean
|
|
349
|
-
* install with zero workspace tools must produce no log noise beyond
|
|
350
|
-
* the eventual "0 workspace tools registered" debug line.
|
|
351
|
-
* - Per-tool isolation: any single broken tool is logged and skipped
|
|
352
|
-
* without aborting the scan. The function resolves normally even when
|
|
353
|
-
* every candidate fails.
|
|
354
|
-
* - Idempotency is the registry's job: a second call without a
|
|
355
|
-
* preceding unregister will throw on the duplicate-workspace-tool
|
|
356
|
-
* check. Callers (daemon startup) are expected to call once; the file
|
|
357
|
-
* watcher uses the per-event entry points instead.
|
|
358
|
-
*
|
|
359
|
-
* Caller responsibilities:
|
|
360
|
-
*
|
|
361
|
-
* - Must be invoked between {@link initializeTools} and
|
|
362
|
-
* {@link loadUserPlugins}. Calling earlier risks racing core
|
|
363
|
-
* registrations; calling later means plugins see an incomplete
|
|
364
|
-
* registry and may register over a name a workspace tool will later
|
|
365
|
-
* try to own.
|
|
390
|
+
* What the loader last established on disk for a given stem. The mtime
|
|
391
|
+
* cache lets a repeat {@link loadWorkspaceTools} call skip re-importing a
|
|
392
|
+
* file that hasn't changed since the previous reconcile — a no-op
|
|
393
|
+
* reconcile costs one `readdir` plus a `stat` per file and never touches
|
|
394
|
+
* the registry.
|
|
366
395
|
*/
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
396
|
+
type ManagedEntry =
|
|
397
|
+
| { kind: "live"; ext: LiveToolExtension; mtimeMs: number }
|
|
398
|
+
| { kind: "removed" };
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Per-stem record of the workspace-tool state this module installed on the
|
|
402
|
+
* last reconcile. Module-level (process-wide) because the registry it
|
|
403
|
+
* mirrors is also process-wide. Reset between tests via
|
|
404
|
+
* {@link __resetWorkspaceToolCacheForTesting}.
|
|
405
|
+
*/
|
|
406
|
+
const managed = new Map<string, ManagedEntry>();
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The winning live file for a stem, resolved from the on-disk scan.
|
|
410
|
+
*/
|
|
411
|
+
interface DesiredLiveEntry {
|
|
412
|
+
readonly ext: LiveToolExtension;
|
|
413
|
+
readonly mtimeMs: number;
|
|
414
|
+
readonly path: string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Pure (no registry mutation) scan of `<workspaceDir>/tools/`. Resolves
|
|
419
|
+
* each stem to its winning live file (with mtime) and the set of stems
|
|
420
|
+
* carrying a `.removed` sentinel, applying the same validation and
|
|
421
|
+
* shadow/ambiguity rules the reconcile relies on. Returns empty maps when
|
|
422
|
+
* the directory is missing or unreadable.
|
|
423
|
+
*/
|
|
424
|
+
function scanWorkspaceToolsDir(toolsDir: string): {
|
|
425
|
+
desiredLive: Map<string, DesiredLiveEntry>;
|
|
426
|
+
removedStems: Set<string>;
|
|
427
|
+
} {
|
|
428
|
+
const desiredLive = new Map<string, DesiredLiveEntry>();
|
|
429
|
+
const removedStems = new Set<string>();
|
|
372
430
|
|
|
373
431
|
if (!existsSync(toolsDir)) {
|
|
374
|
-
|
|
375
|
-
return { registered: [], removed: [] };
|
|
432
|
+
return { desiredLive, removedStems };
|
|
376
433
|
}
|
|
377
434
|
|
|
378
435
|
let entries: string[];
|
|
@@ -383,16 +440,15 @@ export async function loadWorkspaceTools(
|
|
|
383
440
|
{ err, toolsDir },
|
|
384
441
|
"loadWorkspaceTools: failed to read tools directory — continuing with no workspace tools",
|
|
385
442
|
);
|
|
386
|
-
return {
|
|
443
|
+
return { desiredLive, removedStems };
|
|
387
444
|
}
|
|
388
445
|
|
|
389
446
|
// Group entries by stem so we can detect multi-extension shadowing
|
|
390
447
|
// (e.g. `foo.ts` + `foo.js` claiming the same name) before we kick off
|
|
391
|
-
// any imports. Each stem maps to
|
|
392
|
-
// are tracked
|
|
393
|
-
// live tool files (you don't strip AND register
|
|
394
|
-
const liveByStem = new Map<string,
|
|
395
|
-
const removedStems = new Set<string>();
|
|
448
|
+
// any imports. Each stem maps to its live extensions (with mtimes);
|
|
449
|
+
// .removed sentinels are tracked separately since they're mutually
|
|
450
|
+
// exclusive with live tool files (you don't strip AND register at once).
|
|
451
|
+
const liveByStem = new Map<string, Map<LiveToolExtension, number>>();
|
|
396
452
|
|
|
397
453
|
for (const entry of entries) {
|
|
398
454
|
const fullPath = join(toolsDir, entry);
|
|
@@ -429,10 +485,10 @@ export async function loadWorkspaceTools(
|
|
|
429
485
|
}
|
|
430
486
|
let extensions = liveByStem.get(classified.stem);
|
|
431
487
|
if (!extensions) {
|
|
432
|
-
extensions = new
|
|
488
|
+
extensions = new Map<LiveToolExtension, number>();
|
|
433
489
|
liveByStem.set(classified.stem, extensions);
|
|
434
490
|
}
|
|
435
|
-
extensions.
|
|
491
|
+
extensions.set(classified.ext, stats.mtimeMs);
|
|
436
492
|
}
|
|
437
493
|
|
|
438
494
|
// A stem cannot both be live AND removed. Operator intent is ambiguous;
|
|
@@ -448,226 +504,274 @@ export async function loadWorkspaceTools(
|
|
|
448
504
|
}
|
|
449
505
|
}
|
|
450
506
|
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
for (const stem of removedStems) {
|
|
455
|
-
try {
|
|
456
|
-
removeCoreToolViaWorkspace(stem);
|
|
457
|
-
removed.push(stem);
|
|
458
|
-
} catch (err) {
|
|
459
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
460
|
-
log.error(
|
|
461
|
-
{ err, stem },
|
|
462
|
-
`loadWorkspaceTools: failed to strip core tool "${stem}": ${message}`,
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Resolve each live stem to its winning entry, import it, and add to
|
|
468
|
-
// the registration batch. Multi-extension shadowing warns once per
|
|
469
|
-
// ignored sibling so the operator can clean up the redundant file.
|
|
470
|
-
const batch: Array<{ tool: Tool; workspacePath: string }> = [];
|
|
471
|
-
|
|
507
|
+
// Resolve each live stem to its winning extension. Multi-extension
|
|
508
|
+
// shadowing warns once per ignored sibling so the operator can clean up
|
|
509
|
+
// the redundant file.
|
|
472
510
|
for (const [stem, extensions] of liveByStem) {
|
|
473
|
-
const { ext: winningExt, shadowed } = selectLiveExtension(
|
|
511
|
+
const { ext: winningExt, shadowed } = selectLiveExtension(
|
|
512
|
+
new Set(extensions.keys()),
|
|
513
|
+
);
|
|
474
514
|
if (shadowed.length > 0) {
|
|
475
515
|
log.warn(
|
|
476
516
|
{ stem, winningExt, shadowed, toolsDir },
|
|
477
517
|
`loadWorkspaceTools: "${stem}" has multiple files (${[winningExt, ...shadowed].join(", ")}) — using ${winningExt} and ignoring the rest`,
|
|
478
518
|
);
|
|
479
519
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
} else {
|
|
486
|
-
const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
|
|
487
|
-
if (defaultExport === undefined) continue; // Failure already logged.
|
|
488
|
-
if (defaultExport === null || typeof defaultExport !== "object") {
|
|
489
|
-
log.error(
|
|
490
|
-
{ entryPath, type: typeof defaultExport },
|
|
491
|
-
`Workspace tool at ${entryPath} default export must be an object — skipping`,
|
|
492
|
-
);
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
toolSpec = defaultExport as ToolDefinition;
|
|
496
|
-
}
|
|
497
|
-
if (!toolSpec) continue;
|
|
498
|
-
|
|
499
|
-
const loaded = applyWorkspaceToolDefaults(toolSpec, stem);
|
|
500
|
-
batch.push({ tool: loaded, workspacePath: entryPath });
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (batch.length === 0) {
|
|
504
|
-
if (removed.length === 0) {
|
|
505
|
-
log.debug(
|
|
506
|
-
{ toolsDir },
|
|
507
|
-
"loadWorkspaceTools: no workspace tools to register or strip",
|
|
508
|
-
);
|
|
509
|
-
} else {
|
|
510
|
-
log.info(
|
|
511
|
-
{ toolsDir, removedCount: removed.length, removed },
|
|
512
|
-
`Stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"} via workspace .removed sentinels`,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
return { registered: [], removed };
|
|
520
|
+
desiredLive.set(stem, {
|
|
521
|
+
ext: winningExt,
|
|
522
|
+
mtimeMs: extensions.get(winningExt) ?? 0,
|
|
523
|
+
path: join(toolsDir, `${stem}${winningExt}`),
|
|
524
|
+
});
|
|
516
525
|
}
|
|
517
526
|
|
|
518
|
-
|
|
519
|
-
const accepted = registerWorkspaceTools(batch);
|
|
520
|
-
log.info(
|
|
521
|
-
{ count: accepted.length, toolsDir, removedCount: removed.length },
|
|
522
|
-
`Registered ${accepted.length} workspace tool${accepted.length === 1 ? "" : "s"}${removed.length > 0 ? ` (and stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"})` : ""}`,
|
|
523
|
-
);
|
|
524
|
-
return { registered: accepted.map((t) => t.name), removed };
|
|
525
|
-
} catch (err) {
|
|
526
|
-
// A throw from registerWorkspaceTools means a hard conflict (e.g.
|
|
527
|
-
// duplicate name in batch, lifecycle-order regression). The batch
|
|
528
|
-
// validation phase guarantees no partial application landed, so
|
|
529
|
-
// every workspace tool from this load attempt is absent from the
|
|
530
|
-
// registry. Surface the error loudly but do NOT rethrow — assistant
|
|
531
|
-
// startup must still complete.
|
|
532
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
533
|
-
log.error(
|
|
534
|
-
{ err, toolsDir, batchSize: batch.length },
|
|
535
|
-
`loadWorkspaceTools: registry rejected batch — ${message}`,
|
|
536
|
-
);
|
|
537
|
-
return { registered: [], removed };
|
|
538
|
-
}
|
|
527
|
+
return { desiredLive, removedStems };
|
|
539
528
|
}
|
|
540
529
|
|
|
541
|
-
// ─── Single-entry helpers for the file watcher ───────────────────────────────
|
|
542
|
-
//
|
|
543
|
-
// The watcher calls these on each fs event. The initial-scan path
|
|
544
|
-
// ({@link loadWorkspaceTools}) batches for transactional registration;
|
|
545
|
-
// the per-event path takes the simpler one-tool-at-a-time route since
|
|
546
|
-
// fs events arrive serially and the registry handles each as an
|
|
547
|
-
// atomic operation.
|
|
548
|
-
|
|
549
530
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* have already unregistered any prior workspace tool for the same name.
|
|
531
|
+
* Tear down any workspace-tool state this module owns for `stem`:
|
|
532
|
+
* unregister a live workspace tool (restoring a stashed core tool if the
|
|
533
|
+
* workspace tool overrode one), and restore a core tool previously
|
|
534
|
+
* stripped via a `.removed` sentinel. Both are no-ops when there is
|
|
535
|
+
* nothing to undo, so this is safe to call for any stem.
|
|
556
536
|
*/
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
): Promise<string | undefined> {
|
|
561
|
-
const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
|
|
562
|
-
const filename = entryPath.split("/").pop() ?? "";
|
|
563
|
-
const classified = classifyEntry(filename);
|
|
564
|
-
if (!classified || classified.kind !== "live") {
|
|
565
|
-
log.debug(
|
|
566
|
-
{ entryPath },
|
|
567
|
-
"loadSingleWorkspaceTool: file is not a live tool entry — skipping",
|
|
568
|
-
);
|
|
569
|
-
return undefined;
|
|
537
|
+
function teardownStem(stem: string): void {
|
|
538
|
+
if (getToolOwner(stem)?.kind === "workspace") {
|
|
539
|
+
unregisterWorkspaceTool(stem);
|
|
570
540
|
}
|
|
571
|
-
if (!
|
|
572
|
-
|
|
573
|
-
{ entryPath, stem: classified.stem },
|
|
574
|
-
`loadSingleWorkspaceTool: filename stem "${classified.stem}" is not a provider-safe tool name — skipping`,
|
|
575
|
-
);
|
|
576
|
-
return undefined;
|
|
541
|
+
if (getCoreToolOverride(stem) && !getTool(stem)) {
|
|
542
|
+
restoreStrippedCoreTool(stem);
|
|
577
543
|
}
|
|
544
|
+
}
|
|
578
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Import and finalize the winning live file for `stem`, returning the
|
|
548
|
+
* registry-ready {@link Tool} or `undefined` when the file fails to load
|
|
549
|
+
* (every failure is logged with file attribution and never thrown).
|
|
550
|
+
*/
|
|
551
|
+
async function loadDesiredLiveTool(
|
|
552
|
+
stem: string,
|
|
553
|
+
entry: DesiredLiveEntry,
|
|
554
|
+
importTimeoutMs: number,
|
|
555
|
+
): Promise<Tool | undefined> {
|
|
579
556
|
let toolSpec: ToolDefinition | undefined;
|
|
580
|
-
if (
|
|
581
|
-
toolSpec = await readJsonToolSpec(
|
|
557
|
+
if (entry.ext === ".json") {
|
|
558
|
+
toolSpec = await readJsonToolSpec(entry.path);
|
|
582
559
|
} else {
|
|
583
|
-
const defaultExport = await importToolDefaultBounded(
|
|
584
|
-
|
|
560
|
+
const defaultExport = await importToolDefaultBounded(
|
|
561
|
+
entry.path,
|
|
562
|
+
importTimeoutMs,
|
|
563
|
+
);
|
|
564
|
+
if (defaultExport === undefined) return undefined; // Failure already logged.
|
|
585
565
|
if (defaultExport === null || typeof defaultExport !== "object") {
|
|
586
566
|
log.error(
|
|
587
|
-
{ entryPath, type: typeof defaultExport },
|
|
588
|
-
`Workspace tool at ${
|
|
567
|
+
{ entryPath: entry.path, type: typeof defaultExport },
|
|
568
|
+
`Workspace tool at ${entry.path} default export must be an object — skipping`,
|
|
589
569
|
);
|
|
590
570
|
return undefined;
|
|
591
571
|
}
|
|
592
572
|
toolSpec = defaultExport as ToolDefinition;
|
|
593
573
|
}
|
|
594
574
|
if (!toolSpec) return undefined;
|
|
595
|
-
|
|
596
|
-
const loaded = applyWorkspaceToolDefaults(toolSpec, classified.stem);
|
|
597
|
-
try {
|
|
598
|
-
registerWorkspaceTools([{ tool: loaded, workspacePath: entryPath }]);
|
|
599
|
-
return classified.stem;
|
|
600
|
-
} catch (err) {
|
|
601
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
602
|
-
log.error(
|
|
603
|
-
{ err, entryPath },
|
|
604
|
-
`loadSingleWorkspaceTool: registry rejected "${classified.stem}": ${message}`,
|
|
605
|
-
);
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
575
|
+
return applyWorkspaceToolDefaults(toolSpec, stem);
|
|
608
576
|
}
|
|
609
577
|
|
|
610
578
|
/**
|
|
611
|
-
*
|
|
612
|
-
* it
|
|
579
|
+
* The currently-running reconcile, if any. Concurrent callers coalesce onto
|
|
580
|
+
* it so the per-turn fire-and-forget kicks from many conversations can't
|
|
581
|
+
* pile up or interleave their unregister/register sequences against the
|
|
582
|
+
* shared registry. Once it settles this is cleared, so a later caller (the
|
|
583
|
+
* next turn, or a sequential awaiter like boot/tests) starts a fresh scan.
|
|
613
584
|
*/
|
|
614
|
-
|
|
615
|
-
filename: string,
|
|
616
|
-
):
|
|
617
|
-
| { kind: "live"; stem: string; ext: LiveToolExtension }
|
|
618
|
-
| { kind: "removed"; stem: string }
|
|
619
|
-
| undefined {
|
|
620
|
-
return classifyEntry(filename);
|
|
621
|
-
}
|
|
585
|
+
let inflightReconcile: Promise<LoadWorkspaceToolsResult> | null = null;
|
|
622
586
|
|
|
623
587
|
/**
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
588
|
+
* Reconcile the registry's workspace-tool layer against
|
|
589
|
+
* `<workspaceDir>/tools/`.
|
|
590
|
+
*
|
|
591
|
+
* Idempotent and safe to call repeatedly: the first call registers every
|
|
592
|
+
* well-formed `<name>.{ts,js,json}` as a workspace tool and strips core
|
|
593
|
+
* tools named by `<name>.removed` sentinels; subsequent calls apply only
|
|
594
|
+
* the delta since the previous reconcile — registering added files,
|
|
595
|
+
* re-importing changed files (detected by mtime), unregistering deleted
|
|
596
|
+
* files, and restoring core tools whose sentinel was removed.
|
|
597
|
+
*
|
|
598
|
+
* Invariants:
|
|
599
|
+
*
|
|
600
|
+
* - No-ops to an empty registry footprint when the tools directory does
|
|
601
|
+
* not exist, tearing down anything a previous reconcile installed.
|
|
602
|
+
* - Per-tool isolation: any single broken tool is logged and skipped
|
|
603
|
+
* without aborting the reconcile. The function resolves normally even
|
|
604
|
+
* when every candidate fails.
|
|
605
|
+
* - Concurrency-safe: concurrent callers coalesce onto a single in-flight
|
|
606
|
+
* reconcile, so the unregister/register sequence for a changed tool never
|
|
607
|
+
* races another reconcile.
|
|
627
608
|
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
609
|
+
* Caller responsibilities:
|
|
610
|
+
*
|
|
611
|
+
* - The first call must run between {@link initializeTools} and
|
|
612
|
+
* {@link loadUserPlugins}. Calling earlier risks racing core
|
|
613
|
+
* registrations; calling later means plugins see an incomplete
|
|
614
|
+
* registry and may register over a name a workspace tool will later
|
|
615
|
+
* try to own. Later calls (driven by the per-turn tool resolver) are
|
|
616
|
+
* free to run any time — the reconcile only ever touches
|
|
617
|
+
* workspace-owned and core-stashed names.
|
|
632
618
|
*/
|
|
633
|
-
export function
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
)
|
|
637
|
-
|
|
638
|
-
|
|
619
|
+
export function loadWorkspaceTools(
|
|
620
|
+
options: LoadWorkspaceToolsOptions = {},
|
|
621
|
+
): Promise<LoadWorkspaceToolsResult> {
|
|
622
|
+
if (inflightReconcile) return inflightReconcile;
|
|
623
|
+
// `reconcileWorkspaceTools` never rejects (all failures are caught and
|
|
624
|
+
// logged); `.finally` clears the slot either way so the next caller scans
|
|
625
|
+
// fresh.
|
|
626
|
+
inflightReconcile = reconcileWorkspaceTools(options).finally(() => {
|
|
627
|
+
inflightReconcile = null;
|
|
628
|
+
});
|
|
629
|
+
return inflightReconcile;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function reconcileWorkspaceTools(
|
|
633
|
+
options: LoadWorkspaceToolsOptions,
|
|
634
|
+
): Promise<LoadWorkspaceToolsResult> {
|
|
635
|
+
const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
|
|
636
|
+
const toolsDir = getWorkspaceToolsDir();
|
|
637
|
+
|
|
638
|
+
const { desiredLive, removedStems } = scanWorkspaceToolsDir(toolsDir);
|
|
639
|
+
|
|
640
|
+
// Snapshot what we managed before so we can detect stems that vanished
|
|
641
|
+
// from disk entirely (present last time, absent now) and tear them down.
|
|
642
|
+
const prevManaged = new Map(managed);
|
|
643
|
+
|
|
644
|
+
// 1. Tear down stems we previously managed that disk no longer mentions
|
|
645
|
+
// (neither a live file nor a .removed sentinel). Stems still present
|
|
646
|
+
// are handled by the live/removed passes below.
|
|
647
|
+
for (const stem of prevManaged.keys()) {
|
|
648
|
+
if (!desiredLive.has(stem) && !removedStems.has(stem)) {
|
|
649
|
+
teardownStem(stem);
|
|
650
|
+
managed.delete(stem);
|
|
651
|
+
}
|
|
639
652
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
653
|
+
|
|
654
|
+
// 2. `.removed` sentinels — strip the named core tool. Unregister any
|
|
655
|
+
// prior workspace registration for the stem first so the strip path
|
|
656
|
+
// sees a core (or empty) baseline rather than a workspace override.
|
|
657
|
+
for (const stem of removedStems) {
|
|
658
|
+
if (getToolOwner(stem)?.kind === "workspace") {
|
|
659
|
+
unregisterWorkspaceTool(stem);
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
removeCoreToolViaWorkspace(stem);
|
|
663
|
+
managed.set(stem, { kind: "removed" });
|
|
664
|
+
} catch (err) {
|
|
665
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
666
|
+
log.error(
|
|
667
|
+
{ err, stem },
|
|
668
|
+
`loadWorkspaceTools: failed to strip core tool "${stem}": ${message}`,
|
|
669
|
+
);
|
|
670
|
+
managed.delete(stem);
|
|
671
|
+
}
|
|
649
672
|
}
|
|
650
673
|
|
|
651
|
-
|
|
652
|
-
|
|
674
|
+
// 3. Live tools — register new files, re-import changed files (mtime
|
|
675
|
+
// differs), skip unchanged ones. Each entry is imported and registered
|
|
676
|
+
// on its own so one broken or conflicting file cannot drop the others
|
|
677
|
+
// (per-tool isolation): the import is bounded/caught, and registration
|
|
678
|
+
// goes through registerWorkspaceTools one tool at a time. Stems are the
|
|
679
|
+
// map keys here, so there are no intra-reconcile duplicate names that a
|
|
680
|
+
// batch would otherwise need to validate.
|
|
681
|
+
for (const [stem, entry] of desiredLive) {
|
|
682
|
+
const prev = prevManaged.get(stem);
|
|
683
|
+
const unchanged =
|
|
684
|
+
prev?.kind === "live" &&
|
|
685
|
+
prev.ext === entry.ext &&
|
|
686
|
+
prev.mtimeMs === entry.mtimeMs &&
|
|
687
|
+
getToolOwner(stem)?.kind === "workspace";
|
|
688
|
+
if (unchanged) {
|
|
689
|
+
managed.set(stem, {
|
|
690
|
+
kind: "live",
|
|
691
|
+
ext: entry.ext,
|
|
692
|
+
mtimeMs: entry.mtimeMs,
|
|
693
|
+
});
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Changed, or a fresh import is needed. Import FIRST and only mutate the
|
|
698
|
+
// registry once we hold a valid tool, so a failed re-import leaves the
|
|
699
|
+
// previously-registered version in place rather than tearing it down.
|
|
700
|
+
const tool = await loadDesiredLiveTool(stem, entry, importTimeoutMs);
|
|
701
|
+
if (!tool) {
|
|
702
|
+
// Import failed (already logged). Leave any prior registration intact
|
|
703
|
+
// and keep the managed entry so a later fix re-imports cleanly.
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
653
706
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
707
|
+
// Drop any prior workspace registration so the loader re-registers
|
|
708
|
+
// cleanly, and restore a previously-stripped core tool so the override
|
|
709
|
+
// path sees the expected baseline (core present → stash + replace).
|
|
710
|
+
if (getToolOwner(stem)?.kind === "workspace") {
|
|
711
|
+
unregisterWorkspaceTool(stem);
|
|
712
|
+
}
|
|
713
|
+
if (getCoreToolOverride(stem) && !getTool(stem)) {
|
|
714
|
+
restoreStrippedCoreTool(stem);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
registerWorkspaceTools([{ tool, workspacePath: entry.path }]);
|
|
719
|
+
managed.set(stem, {
|
|
720
|
+
kind: "live",
|
|
721
|
+
ext: entry.ext,
|
|
722
|
+
mtimeMs: entry.mtimeMs,
|
|
723
|
+
});
|
|
724
|
+
} catch (err) {
|
|
725
|
+
// A throw means a hard conflict for this name (e.g. a plugin/MCP tool
|
|
726
|
+
// already owns it — a lifecycle-order regression). Surface it loudly,
|
|
727
|
+
// but do NOT rethrow and do NOT abort the other tools — startup /
|
|
728
|
+
// conversation reads must still complete.
|
|
729
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
730
|
+
log.error(
|
|
731
|
+
{ err, stem, toolsDir },
|
|
732
|
+
`loadWorkspaceTools: registry rejected "${stem}" — ${message}`,
|
|
733
|
+
);
|
|
734
|
+
managed.delete(stem);
|
|
661
735
|
}
|
|
662
736
|
}
|
|
663
737
|
|
|
664
|
-
|
|
665
|
-
|
|
738
|
+
// Derive the result from the final registry state so it reflects what
|
|
739
|
+
// actually landed rather than what we attempted.
|
|
740
|
+
const registered: string[] = [];
|
|
741
|
+
const removed: string[] = [];
|
|
742
|
+
for (const [stem, entry] of managed) {
|
|
743
|
+
if (entry.kind === "live" && getToolOwner(stem)?.kind === "workspace") {
|
|
744
|
+
registered.push(stem);
|
|
745
|
+
} else if (
|
|
746
|
+
entry.kind === "removed" &&
|
|
747
|
+
getCoreToolOverride(stem) &&
|
|
748
|
+
!getTool(stem)
|
|
749
|
+
) {
|
|
750
|
+
removed.push(stem);
|
|
751
|
+
}
|
|
666
752
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
753
|
+
|
|
754
|
+
if (registered.length === 0 && removed.length === 0) {
|
|
755
|
+
log.debug(
|
|
756
|
+
{ toolsDir },
|
|
757
|
+
"loadWorkspaceTools: no workspace tools registered or stripped",
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
log.info(
|
|
761
|
+
{ count: registered.length, toolsDir, removedCount: removed.length },
|
|
762
|
+
`Workspace tools reconciled: ${registered.length} registered${removed.length > 0 ? `, ${removed.length} core tool${removed.length === 1 ? "" : "s"} stripped` : ""}`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { registered, removed };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Test-only — drop the mtime cache and serialization chain so a fresh
|
|
771
|
+
* test starts from a clean reconcile baseline. The registry itself is
|
|
772
|
+
* reset separately via `__clearRegistryForTesting`.
|
|
773
|
+
*/
|
|
774
|
+
export function __resetWorkspaceToolCacheForTesting(): void {
|
|
775
|
+
managed.clear();
|
|
776
|
+
inflightReconcile = null;
|
|
673
777
|
}
|