stagent 0.9.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -44
- package/dist/cli.js +66 -18
- package/docs/.coverage-gaps.json +144 -56
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +20 -2
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +11 -2
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +14 -4
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +39 -2
- package/docs/journeys/personal-use.md +32 -8
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -33
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/documents/page.tsx +4 -1
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +8 -9
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +670 -49
- package/src/components/chat/chat-input.tsx +104 -10
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +790 -0
- package/src/components/chat/chat-shell.tsx +151 -401
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +266 -7
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +31 -2
- package/src/components/tasks/task-card.tsx +29 -3
- package/src/components/tasks/task-chip-bar.tsx +54 -1
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +217 -21
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +18 -0
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +64 -60
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +46 -24
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +146 -4
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +159 -18
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +7 -19
- package/src/lib/chat/stream-telemetry.ts +137 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +48 -1
- package/src/lib/chat/tool-catalog.ts +35 -4
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +41 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +89 -21
- package/src/lib/chat/tools/workflow-tools.ts +275 -32
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +10 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +17 -16
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +62 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +94 -23
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +54 -0
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +76 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +205 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +236 -17
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +16 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +212 -7
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/test/setup.ts +10 -0
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const DISMISSAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export interface DismissalStore {
|
|
4
|
+
read(): string | null;
|
|
5
|
+
write(value: string): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type DismissalMap = Record<string, Record<string, number>>;
|
|
9
|
+
|
|
10
|
+
export function loadDismissals(store: DismissalStore): DismissalMap {
|
|
11
|
+
const raw = store.read();
|
|
12
|
+
if (!raw) return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (parsed && typeof parsed === "object") return parsed as DismissalMap;
|
|
16
|
+
} catch {
|
|
17
|
+
// corrupt — fall through
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveDismissal(
|
|
23
|
+
store: DismissalStore,
|
|
24
|
+
conversationId: string,
|
|
25
|
+
skillId: string,
|
|
26
|
+
nowMs: number = Date.now()
|
|
27
|
+
): void {
|
|
28
|
+
const current = loadDismissals(store);
|
|
29
|
+
current[conversationId] = current[conversationId] ?? {};
|
|
30
|
+
current[conversationId][skillId] = nowMs;
|
|
31
|
+
try {
|
|
32
|
+
store.write(JSON.stringify(current));
|
|
33
|
+
} catch {
|
|
34
|
+
// silent — in-memory state won't persist
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function activeDismissedIds(
|
|
39
|
+
store: DismissalStore,
|
|
40
|
+
conversationId: string,
|
|
41
|
+
nowMs: number = Date.now()
|
|
42
|
+
): Set<string> {
|
|
43
|
+
const all = loadDismissals(store);
|
|
44
|
+
const conv = all[conversationId];
|
|
45
|
+
if (!conv) return new Set();
|
|
46
|
+
const out = new Set<string>();
|
|
47
|
+
for (const [skillId, ts] of Object.entries(conv)) {
|
|
48
|
+
if (nowMs - ts < DISMISSAL_TTL_MS) out.add(skillId);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Browser store adapter around localStorage for a given key. */
|
|
54
|
+
export function browserLocalStore(key: string): DismissalStore {
|
|
55
|
+
return {
|
|
56
|
+
read() {
|
|
57
|
+
if (typeof window === "undefined") return null;
|
|
58
|
+
try {
|
|
59
|
+
return window.localStorage.getItem(key);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
write(value) {
|
|
65
|
+
if (typeof window === "undefined") return;
|
|
66
|
+
try {
|
|
67
|
+
window.localStorage.setItem(key, value);
|
|
68
|
+
} catch {
|
|
69
|
+
// quota / disabled — silent
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/lib/chat/engine.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { db } from "@/lib/db";
|
|
|
3
3
|
import { projects, chatMessages } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
5
|
import { getAuthEnv } from "@/lib/settings/auth";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildClaudeSdkEnv,
|
|
8
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
9
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
10
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
11
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
7
12
|
import {
|
|
8
13
|
extractUsageSnapshot,
|
|
9
14
|
mergeUsageSnapshot,
|
|
@@ -21,6 +26,9 @@ import {
|
|
|
21
26
|
updateConversation,
|
|
22
27
|
} from "@/lib/data/chat";
|
|
23
28
|
import { buildChatContext, type MentionReference } from "./context-builder";
|
|
29
|
+
import { finalizeStreamingMessage } from "./reconcile";
|
|
30
|
+
import { recordTermination } from "./stream-telemetry";
|
|
31
|
+
import { registerChatStream, unregisterChatStream } from "./active-streams";
|
|
24
32
|
import {
|
|
25
33
|
detectEntities,
|
|
26
34
|
extractToolResultEntities,
|
|
@@ -39,7 +47,7 @@ import {
|
|
|
39
47
|
} from "./permission-bridge";
|
|
40
48
|
import { isToolAllowed } from "@/lib/settings/permissions";
|
|
41
49
|
import { getLaunchCwd, getWorkspaceContext } from "@/lib/environment/workspace-context";
|
|
42
|
-
import {
|
|
50
|
+
import { createToolServer } from "./stagent-tools";
|
|
43
51
|
import {
|
|
44
52
|
getBrowserMcpServers,
|
|
45
53
|
getBrowserAllowedToolPatterns,
|
|
@@ -50,6 +58,36 @@ import {
|
|
|
50
58
|
isExaTool,
|
|
51
59
|
isExaReadOnly,
|
|
52
60
|
} from "@/lib/agents/browser-mcp";
|
|
61
|
+
import { resolveChatExecutionTarget } from "@/lib/agents/runtime/execution-target";
|
|
62
|
+
|
|
63
|
+
// Re-exported from runtime/claude-sdk.ts so chat/engine.ts remains a stable
|
|
64
|
+
// import surface for the Phase 1a test suite. The canonical definitions
|
|
65
|
+
// live in the runtime module since task execution needs them too — see
|
|
66
|
+
// features/task-runtime-skill-parity.md Task 1.
|
|
67
|
+
export {
|
|
68
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
69
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
70
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
71
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pure auto-allow policy for SDK filesystem + Skill tools. Exposed for tests.
|
|
75
|
+
* Returns `{ behavior: "allow" }` for auto-allowed tools, or
|
|
76
|
+
* `{ behavior: "pending" }` to signal "route through permission flow".
|
|
77
|
+
* The real canUseTool in query() options uses the full side-channel bridge.
|
|
78
|
+
*/
|
|
79
|
+
export async function canUseToolForTest(
|
|
80
|
+
toolName: string,
|
|
81
|
+
_input: Record<string, unknown>
|
|
82
|
+
): Promise<ToolPermissionResponse | { behavior: "pending" }> {
|
|
83
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
84
|
+
return { behavior: "allow" };
|
|
85
|
+
}
|
|
86
|
+
if (toolName === "Skill") {
|
|
87
|
+
return { behavior: "allow" };
|
|
88
|
+
}
|
|
89
|
+
return { behavior: "pending" };
|
|
90
|
+
}
|
|
53
91
|
|
|
54
92
|
// ── Streaming input wrapper (required for MCP tools) ─────────────────
|
|
55
93
|
|
|
@@ -148,21 +186,43 @@ export async function* sendMessage(
|
|
|
148
186
|
return;
|
|
149
187
|
}
|
|
150
188
|
|
|
189
|
+
let target;
|
|
190
|
+
try {
|
|
191
|
+
target = await resolveChatExecutionTarget({
|
|
192
|
+
requestedRuntimeId: conversation.runtimeId,
|
|
193
|
+
requestedModelId: conversation.modelId,
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
yield {
|
|
197
|
+
type: "error",
|
|
198
|
+
message: error instanceof Error ? error.message : "No chat runtime is available",
|
|
199
|
+
};
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (target.fallbackApplied && target.fallbackReason) {
|
|
204
|
+
yield {
|
|
205
|
+
type: "status",
|
|
206
|
+
phase: "runtime_fallback",
|
|
207
|
+
message: target.fallbackReason,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
151
211
|
// Route to Codex App Server for OpenAI models
|
|
152
|
-
if (
|
|
212
|
+
if (target.effectiveRuntimeId === "openai-codex-app-server") {
|
|
153
213
|
const { sendCodexMessage } = await import("./codex-engine");
|
|
154
|
-
yield* sendCodexMessage(conversationId, userContent, signal);
|
|
214
|
+
yield* sendCodexMessage(conversationId, userContent, signal, target);
|
|
155
215
|
return;
|
|
156
216
|
}
|
|
157
217
|
|
|
158
218
|
// Route to Ollama for local models
|
|
159
|
-
if (
|
|
219
|
+
if (target.effectiveRuntimeId === "ollama") {
|
|
160
220
|
const { sendOllamaMessage } = await import("./ollama-engine");
|
|
161
221
|
yield* sendOllamaMessage(conversationId, userContent, signal);
|
|
162
222
|
return;
|
|
163
223
|
}
|
|
164
224
|
|
|
165
|
-
const runtimeId =
|
|
225
|
+
const runtimeId = target.effectiveRuntimeId;
|
|
166
226
|
const providerId = getProviderForRuntime(runtimeId);
|
|
167
227
|
|
|
168
228
|
// Enforce budget before the turn
|
|
@@ -250,6 +310,8 @@ export async function* sendMessage(
|
|
|
250
310
|
status: "streaming",
|
|
251
311
|
});
|
|
252
312
|
|
|
313
|
+
registerChatStream(conversationId);
|
|
314
|
+
|
|
253
315
|
// Create side channel for canUseTool → SSE bridge communication
|
|
254
316
|
const sideChannel = createSideChannel(conversationId);
|
|
255
317
|
|
|
@@ -272,10 +334,11 @@ export async function* sendMessage(
|
|
|
272
334
|
|
|
273
335
|
// Create in-process MCP server for Stagent CRUD tools
|
|
274
336
|
const toolResults: ToolResultCapture[] = [];
|
|
275
|
-
const stagentServer =
|
|
337
|
+
const stagentServer = createToolServer(
|
|
276
338
|
conversation.projectId,
|
|
277
|
-
(toolName, result) => { toolResults.push({ toolName, result }); }
|
|
278
|
-
|
|
339
|
+
(toolName, result) => { toolResults.push({ toolName, result }); },
|
|
340
|
+
projectCwd,
|
|
341
|
+
).asMcpServer();
|
|
279
342
|
|
|
280
343
|
yield { type: "status", phase: "connecting", message: "Connecting to model..." };
|
|
281
344
|
|
|
@@ -295,7 +358,7 @@ export async function* sendMessage(
|
|
|
295
358
|
const response = query({
|
|
296
359
|
prompt: generatePrompt(fullPrompt),
|
|
297
360
|
options: {
|
|
298
|
-
model: conversation.modelId || undefined,
|
|
361
|
+
model: target.effectiveModelId || conversation.modelId || undefined,
|
|
299
362
|
maxTurns,
|
|
300
363
|
abortController,
|
|
301
364
|
includePartialMessages: true,
|
|
@@ -307,7 +370,13 @@ export async function* sendMessage(
|
|
|
307
370
|
if (stderrChunks.length > 50) stderrChunks.shift();
|
|
308
371
|
},
|
|
309
372
|
mcpServers: { stagent: stagentServer, ...browserServers, ...externalServers },
|
|
310
|
-
allowedTools: [
|
|
373
|
+
allowedTools: [
|
|
374
|
+
"mcp__stagent__*",
|
|
375
|
+
...browserToolPatterns,
|
|
376
|
+
...externalToolPatterns,
|
|
377
|
+
...CLAUDE_SDK_ALLOWED_TOOLS,
|
|
378
|
+
],
|
|
379
|
+
settingSources: [...CLAUDE_SDK_SETTING_SOURCES],
|
|
311
380
|
// @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
|
|
312
381
|
canUseTool: async (
|
|
313
382
|
toolName: string,
|
|
@@ -364,6 +433,32 @@ export async function* sendMessage(
|
|
|
364
433
|
// Mutation browser tools fall through to permission check below
|
|
365
434
|
}
|
|
366
435
|
|
|
436
|
+
// SDK filesystem read-only tools: auto-allow (mirror browser/exa pattern)
|
|
437
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
438
|
+
emitSideChannelEvent(conversationId, {
|
|
439
|
+
type: "status",
|
|
440
|
+
phase: "tool_use",
|
|
441
|
+
message: `Filesystem: ${toolName.toLowerCase()}...`,
|
|
442
|
+
});
|
|
443
|
+
return { behavior: "allow", updatedInput: input };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Skill tool: auto-allow. Rationale: the Skill tool loads skills from
|
|
447
|
+
// ~/.claude/skills/ and .claude/skills/ — the same sources the Claude Code
|
|
448
|
+
// CLI trusts unconditionally. Any tool the skill subsequently invokes
|
|
449
|
+
// (Bash, Edit, etc.) goes through this same canUseTool check. The trust
|
|
450
|
+
// assumption here is identical to using `claude` directly; no new attack
|
|
451
|
+
// surface is introduced. See: features/chat-claude-sdk-skills.md, Error
|
|
452
|
+
// & Rescue Registry row "settingSources loads hostile skill".
|
|
453
|
+
if (toolName === "Skill") {
|
|
454
|
+
emitSideChannelEvent(conversationId, {
|
|
455
|
+
type: "status",
|
|
456
|
+
phase: "tool_use",
|
|
457
|
+
message: `Skill: ${(input as { skill?: string }).skill ?? "unknown"}...`,
|
|
458
|
+
});
|
|
459
|
+
return { behavior: "allow", updatedInput: input };
|
|
460
|
+
}
|
|
461
|
+
|
|
367
462
|
const isQuestion = toolName === "AskUserQuestion";
|
|
368
463
|
|
|
369
464
|
// Layer 1: Check saved user permissions (skip for questions)
|
|
@@ -610,7 +705,11 @@ export async function* sendMessage(
|
|
|
610
705
|
|
|
611
706
|
// Save usage metadata + quick access links + screenshot attachments
|
|
612
707
|
const metadata = JSON.stringify({
|
|
613
|
-
modelId: usage.modelId ?? conversation.modelId,
|
|
708
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId,
|
|
709
|
+
runtimeId,
|
|
710
|
+
requestedRuntimeId: target.requestedRuntimeId ?? conversation.runtimeId,
|
|
711
|
+
requestedModelId: target.requestedModelId ?? conversation.modelId,
|
|
712
|
+
...(target.fallbackReason ? { fallbackReason: target.fallbackReason } : {}),
|
|
614
713
|
inputTokens: usage.inputTokens,
|
|
615
714
|
outputTokens: usage.outputTokens,
|
|
616
715
|
...(quickAccess.length > 0 ? { quickAccess } : {}),
|
|
@@ -627,7 +726,7 @@ export async function* sendMessage(
|
|
|
627
726
|
activityType: "chat_turn",
|
|
628
727
|
runtimeId,
|
|
629
728
|
providerId,
|
|
630
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
729
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
631
730
|
inputTokens: usage.inputTokens ?? null,
|
|
632
731
|
outputTokens: usage.outputTokens ?? null,
|
|
633
732
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -636,6 +735,13 @@ export async function* sendMessage(
|
|
|
636
735
|
finishedAt: new Date(),
|
|
637
736
|
});
|
|
638
737
|
|
|
738
|
+
recordTermination({
|
|
739
|
+
reason: "stream.completed",
|
|
740
|
+
conversationId,
|
|
741
|
+
messageId: assistantMsg.id,
|
|
742
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
743
|
+
});
|
|
744
|
+
|
|
639
745
|
yield {
|
|
640
746
|
type: "done",
|
|
641
747
|
messageId: assistantMsg.id,
|
|
@@ -647,7 +753,27 @@ export async function* sendMessage(
|
|
|
647
753
|
|
|
648
754
|
// Enrich the error with stderr diagnostics when available
|
|
649
755
|
const stderrTail = stderrChunks.join("").trim();
|
|
650
|
-
const
|
|
756
|
+
const rawErrorMessage = diagnoseProcessError(rawMessage, stderrTail);
|
|
757
|
+
// Truncate at 4KB to prevent multi-MB stderr dumps bloating chat_messages
|
|
758
|
+
const errorMessage =
|
|
759
|
+
rawErrorMessage.length > 4096
|
|
760
|
+
? rawErrorMessage.slice(0, 4096) + "... (truncated)"
|
|
761
|
+
: rawErrorMessage;
|
|
762
|
+
|
|
763
|
+
// Telemetry: record BEFORE the yield below. If this code is reached
|
|
764
|
+
// via iterator abandonment (consumer broke the for-await and the
|
|
765
|
+
// generator's own yield throws GeneratorReturn), control would skip
|
|
766
|
+
// past any post-yield statement. Recording up front guarantees the
|
|
767
|
+
// event lands in the ring buffer regardless of whether the yield
|
|
768
|
+
// completes or aborts. Matches the same invariant we rely on for
|
|
769
|
+
// the success-path recordTermination before the done yield.
|
|
770
|
+
recordTermination({
|
|
771
|
+
reason: signal?.aborted ? "stream.aborted.signal" : "stream.finalized.error",
|
|
772
|
+
conversationId,
|
|
773
|
+
messageId: assistantMsg.id,
|
|
774
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
775
|
+
error: errorMessage.slice(0, 500),
|
|
776
|
+
});
|
|
651
777
|
|
|
652
778
|
if (fullText && fullText.length > 50) {
|
|
653
779
|
// Substantial content was already streamed — complete gracefully with warning
|
|
@@ -663,7 +789,7 @@ export async function* sendMessage(
|
|
|
663
789
|
activityType: "chat_turn",
|
|
664
790
|
runtimeId,
|
|
665
791
|
providerId,
|
|
666
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
792
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
667
793
|
inputTokens: usage.inputTokens ?? null,
|
|
668
794
|
outputTokens: usage.outputTokens ?? null,
|
|
669
795
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -674,10 +800,14 @@ export async function* sendMessage(
|
|
|
674
800
|
|
|
675
801
|
yield { type: "done", messageId: assistantMsg.id, quickAccess: [] };
|
|
676
802
|
} else {
|
|
677
|
-
// No meaningful content — show as error
|
|
803
|
+
// No meaningful content — show as error. Fallback chain ensures we
|
|
804
|
+
// never write an empty string even if both fullText and errorMessage
|
|
805
|
+
// happen to be blank.
|
|
678
806
|
await updateMessageContent(
|
|
679
807
|
assistantMsg.id,
|
|
680
|
-
fullText ||
|
|
808
|
+
fullText ||
|
|
809
|
+
errorMessage ||
|
|
810
|
+
"(Response failed — no error detail available.)"
|
|
681
811
|
);
|
|
682
812
|
await updateMessageStatus(assistantMsg.id, "error");
|
|
683
813
|
|
|
@@ -686,7 +816,7 @@ export async function* sendMessage(
|
|
|
686
816
|
activityType: "chat_turn",
|
|
687
817
|
runtimeId,
|
|
688
818
|
providerId,
|
|
689
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
819
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
690
820
|
inputTokens: usage.inputTokens ?? null,
|
|
691
821
|
outputTokens: usage.outputTokens ?? null,
|
|
692
822
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -698,6 +828,17 @@ export async function* sendMessage(
|
|
|
698
828
|
yield { type: "error", message: errorMessage };
|
|
699
829
|
}
|
|
700
830
|
} finally {
|
|
831
|
+
// Safety net: guarantee the placeholder row never remains in
|
|
832
|
+
// status='streaming' after the generator exits. Catches code paths that
|
|
833
|
+
// bypass the catch block — most notably async iterator abandonment, where
|
|
834
|
+
// a consumer `break`ing out of a `for await` loop triggers the generator's
|
|
835
|
+
// return() method and jumps straight here, skipping catch entirely.
|
|
836
|
+
try {
|
|
837
|
+
await finalizeStreamingMessage(assistantMsg.id, fullText);
|
|
838
|
+
} catch (finalizeErr) {
|
|
839
|
+
console.error("[chat] finalize safety net failed:", finalizeErr);
|
|
840
|
+
}
|
|
841
|
+
unregisterChatStream(conversationId);
|
|
701
842
|
cleanupConversation(conversationId);
|
|
702
843
|
}
|
|
703
844
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Hoist mutable state so the mock factories can read it.
|
|
4
|
+
const { mockState } = vi.hoisted(() => ({
|
|
5
|
+
mockState: {
|
|
6
|
+
stdout: "" as string,
|
|
7
|
+
execFileThrows: false as boolean | Error,
|
|
8
|
+
files: new Map<string, { size: number; mtimeMs: number }>(),
|
|
9
|
+
realpathMap: new Map<string, string>(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("node:child_process", () => {
|
|
14
|
+
const execFileSync = vi.fn(() => {
|
|
15
|
+
if (mockState.execFileThrows) {
|
|
16
|
+
throw mockState.execFileThrows instanceof Error
|
|
17
|
+
? mockState.execFileThrows
|
|
18
|
+
: new Error("git not available");
|
|
19
|
+
}
|
|
20
|
+
return mockState.stdout;
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
default: { execFileSync },
|
|
24
|
+
execFileSync,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("node:fs", () => {
|
|
29
|
+
const realpathSync = (p: string) => mockState.realpathMap.get(p) ?? p;
|
|
30
|
+
const statSync = (absPath: string) => {
|
|
31
|
+
const f = mockState.files.get(absPath);
|
|
32
|
+
if (!f) throw new Error(`ENOENT: ${absPath}`);
|
|
33
|
+
return { size: f.size, mtimeMs: f.mtimeMs };
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
default: { realpathSync, statSync },
|
|
37
|
+
realpathSync,
|
|
38
|
+
statSync,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
import { searchFiles } from "../search";
|
|
43
|
+
|
|
44
|
+
// Helper: all test files live under this fake cwd
|
|
45
|
+
const CWD = "/repo";
|
|
46
|
+
|
|
47
|
+
function file(relPath: string, size: number, mtimeMs: number) {
|
|
48
|
+
mockState.files.set(`${CWD}/${relPath}`, { size, mtimeMs });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockState.stdout = "";
|
|
53
|
+
mockState.execFileThrows = false;
|
|
54
|
+
mockState.files.clear();
|
|
55
|
+
mockState.realpathMap.clear();
|
|
56
|
+
mockState.realpathMap.set(CWD, CWD);
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("searchFiles", () => {
|
|
61
|
+
it("returns all files when query is empty, mtime-sorted newest first", () => {
|
|
62
|
+
mockState.stdout = ["src/a.ts", "src/b.ts", "src/c.ts", ""].join("\n");
|
|
63
|
+
file("src/a.ts", 100, 1_000);
|
|
64
|
+
file("src/b.ts", 200, 3_000);
|
|
65
|
+
file("src/c.ts", 300, 2_000);
|
|
66
|
+
|
|
67
|
+
const hits = searchFiles(CWD, "", 10);
|
|
68
|
+
expect(hits.map((h) => h.path)).toEqual(["src/b.ts", "src/c.ts", "src/a.ts"]);
|
|
69
|
+
expect(hits[0].sizeBytes).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ranks filename matches above directory-path matches", () => {
|
|
73
|
+
mockState.stdout = [
|
|
74
|
+
"src/schema/other.ts", // directory match for "schema"
|
|
75
|
+
"src/lib/db/schema.ts", // filename match for "schema"
|
|
76
|
+
""
|
|
77
|
+
].join("\n");
|
|
78
|
+
file("src/schema/other.ts", 100, 1_000);
|
|
79
|
+
file("src/lib/db/schema.ts", 100, 500); // older but should still rank first
|
|
80
|
+
|
|
81
|
+
const hits = searchFiles(CWD, "schema", 10);
|
|
82
|
+
expect(hits[0].path).toBe("src/lib/db/schema.ts");
|
|
83
|
+
expect(hits[1].path).toBe("src/schema/other.ts");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("performs case-insensitive substring match", () => {
|
|
87
|
+
mockState.stdout = ["src/Foo.TSX", "src/bar.ts", ""].join("\n");
|
|
88
|
+
file("src/Foo.TSX", 100, 1_000);
|
|
89
|
+
file("src/bar.ts", 100, 1_000);
|
|
90
|
+
|
|
91
|
+
const hits = searchFiles(CWD, "foo", 10);
|
|
92
|
+
expect(hits).toHaveLength(1);
|
|
93
|
+
expect(hits[0].path).toBe("src/Foo.TSX");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("respects limit cap", () => {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
for (let i = 0; i < 50; i++) {
|
|
99
|
+
const p = `src/file${i}.ts`;
|
|
100
|
+
lines.push(p);
|
|
101
|
+
file(p, 100, i * 10);
|
|
102
|
+
}
|
|
103
|
+
mockState.stdout = lines.join("\n");
|
|
104
|
+
|
|
105
|
+
const hits = searchFiles(CWD, "", 5);
|
|
106
|
+
expect(hits).toHaveLength(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns [] when execFileSync throws (not a git repo)", () => {
|
|
110
|
+
mockState.execFileThrows = new Error("not a git repository");
|
|
111
|
+
const hits = searchFiles(CWD, "anything", 10);
|
|
112
|
+
expect(hits).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips files that disappeared between ls-files and stat", () => {
|
|
116
|
+
mockState.stdout = ["src/exists.ts", "src/ghost.ts", ""].join("\n");
|
|
117
|
+
file("src/exists.ts", 100, 1_000);
|
|
118
|
+
// src/ghost.ts intentionally absent from the files map — statSync throws
|
|
119
|
+
|
|
120
|
+
const hits = searchFiles(CWD, "", 10);
|
|
121
|
+
expect(hits.map((h) => h.path)).toEqual(["src/exists.ts"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("excludes files that would resolve outside cwd (defense-in-depth)", () => {
|
|
125
|
+
// git ls-files should never emit such a path, but if it did we must reject.
|
|
126
|
+
mockState.stdout = ["../escape.ts", "src/ok.ts", ""].join("\n");
|
|
127
|
+
// Do NOT register the escape path in files — resolve() would point outside
|
|
128
|
+
// /repo, and the startsWith check in search.ts will discard it before
|
|
129
|
+
// statSync is even called.
|
|
130
|
+
file("src/ok.ts", 100, 1_000);
|
|
131
|
+
|
|
132
|
+
const hits = searchFiles(CWD, "", 10);
|
|
133
|
+
expect(hits.map((h) => h.path)).toEqual(["src/ok.ts"]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { realpathSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format a single `entityType: "file"` mention for Tier 3.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - `cwd` is resolved by the caller from a trusted source (active project's
|
|
9
|
+
* workingDirectory, else `getLaunchCwd()`) — NEVER from the mention itself.
|
|
10
|
+
* - The mention's `relPath` is treated as a relative path; any path that
|
|
11
|
+
* resolves outside `cwd` is rejected without opening the file.
|
|
12
|
+
*
|
|
13
|
+
* Size semantics (matches spec §3 "tiered expansion"):
|
|
14
|
+
* - < 8 KB: inline content inside a fenced code block with path header.
|
|
15
|
+
* - >= 8 KB and < MAX_SIZE: emit a short reference line so agents with a
|
|
16
|
+
* `Read` tool can fetch the file on demand; agents without one degrade
|
|
17
|
+
* gracefully ("I can't read large files on this runtime").
|
|
18
|
+
* - >= MAX_SIZE (50 MB): skip silently — pathological.
|
|
19
|
+
*
|
|
20
|
+
* Non-crashing by design: any read/stat failure becomes a short note in
|
|
21
|
+
* the output, not a thrown error that would break the whole prompt build.
|
|
22
|
+
*/
|
|
23
|
+
export function expandFileMention(relPath: string, cwd: string): string[] {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
|
|
26
|
+
let cwdReal: string;
|
|
27
|
+
try {
|
|
28
|
+
cwdReal = realpathSync(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
lines.push(`\n### File: ${relPath}`);
|
|
31
|
+
lines.push("(cwd does not exist)");
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const abs = resolve(cwdReal, relPath);
|
|
36
|
+
if (!abs.startsWith(cwdReal)) {
|
|
37
|
+
lines.push(`\n### File: ${relPath}`);
|
|
38
|
+
lines.push("(invalid path — escapes working directory)");
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let stat: { size: number };
|
|
43
|
+
try {
|
|
44
|
+
stat = statSync(abs);
|
|
45
|
+
} catch {
|
|
46
|
+
lines.push(`\n### File: ${relPath}`);
|
|
47
|
+
lines.push("(file not found at context-build time)");
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INLINE_LIMIT = 8 * 1024;
|
|
52
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
53
|
+
if (stat.size > MAX_SIZE) return []; // skip silently
|
|
54
|
+
|
|
55
|
+
if (stat.size < INLINE_LIMIT) {
|
|
56
|
+
let content: string;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(abs, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
lines.push(`\n### File: ${relPath}`);
|
|
61
|
+
lines.push("(file could not be read as UTF-8)");
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
const ext = relPath.split(".").pop() ?? "";
|
|
65
|
+
lines.push(`\n### File: ${relPath}`);
|
|
66
|
+
lines.push("```" + ext);
|
|
67
|
+
lines.push(content);
|
|
68
|
+
lines.push("```");
|
|
69
|
+
} else {
|
|
70
|
+
lines.push(
|
|
71
|
+
`\n### File (by reference): ${relPath} (${Math.round(stat.size / 1024)} KB)`
|
|
72
|
+
);
|
|
73
|
+
lines.push("Use the Read tool to load this file if you need its content.");
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve, basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface FileSearchHit {
|
|
6
|
+
/** Path relative to the resolved cwd. */
|
|
7
|
+
path: string;
|
|
8
|
+
sizeBytes: number;
|
|
9
|
+
/** mtime in epoch ms. */
|
|
10
|
+
mtime: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return up to `limit` files under `cwd` (respecting .gitignore) whose
|
|
15
|
+
* path or basename contains `query` (case-insensitive). Filename matches
|
|
16
|
+
* rank above directory-path matches; secondary sort by mtime desc.
|
|
17
|
+
*
|
|
18
|
+
* Uses `git ls-files --cached --others --exclude-standard` to honor
|
|
19
|
+
* .gitignore natively — matches the subprocess pattern already in use
|
|
20
|
+
* in `src/lib/environment/workspace-context.ts`. No npm dep required.
|
|
21
|
+
* Returns [] if `cwd` is not inside a git repo or git is unavailable.
|
|
22
|
+
*
|
|
23
|
+
* Security: the caller is responsible for server-resolving `cwd` from
|
|
24
|
+
* a trusted source (e.g., the active project's workingDirectory or
|
|
25
|
+
* `getLaunchCwd()`). Never pass a client-controlled path directly.
|
|
26
|
+
*/
|
|
27
|
+
export function searchFiles(
|
|
28
|
+
cwd: string,
|
|
29
|
+
query: string,
|
|
30
|
+
limit = 20
|
|
31
|
+
): FileSearchHit[] {
|
|
32
|
+
const cwdReal = realpathSync(cwd);
|
|
33
|
+
|
|
34
|
+
let stdout: string;
|
|
35
|
+
try {
|
|
36
|
+
stdout = execFileSync(
|
|
37
|
+
"git",
|
|
38
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
39
|
+
{
|
|
40
|
+
cwd: cwdReal,
|
|
41
|
+
encoding: "utf-8",
|
|
42
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
43
|
+
timeout: 3000,
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
// Not a git repo, or git missing, or timeout — degrade to empty list.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const q = query.trim().toLowerCase();
|
|
52
|
+
const hits: Array<FileSearchHit & { score: number }> = [];
|
|
53
|
+
|
|
54
|
+
for (const rel of stdout.split("\n")) {
|
|
55
|
+
if (!rel) continue;
|
|
56
|
+
// Defensive: ensure the resolved path stays within cwd. `git ls-files`
|
|
57
|
+
// should never emit such a path, but stat-ing anything outside cwd
|
|
58
|
+
// would bypass the .gitignore guarantee anyway.
|
|
59
|
+
const abs = resolve(cwdReal, rel);
|
|
60
|
+
if (!abs.startsWith(cwdReal)) continue;
|
|
61
|
+
|
|
62
|
+
const relLower = rel.toLowerCase();
|
|
63
|
+
const baseLower = basename(rel).toLowerCase();
|
|
64
|
+
let score: number;
|
|
65
|
+
if (q === "") {
|
|
66
|
+
score = 1;
|
|
67
|
+
} else if (baseLower.includes(q)) {
|
|
68
|
+
score = 3;
|
|
69
|
+
} else if (relLower.includes(q)) {
|
|
70
|
+
score = 2;
|
|
71
|
+
} else {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let sizeBytes = 0;
|
|
76
|
+
let mtime = 0;
|
|
77
|
+
try {
|
|
78
|
+
const s = statSync(abs);
|
|
79
|
+
sizeBytes = s.size;
|
|
80
|
+
mtime = s.mtimeMs;
|
|
81
|
+
} catch {
|
|
82
|
+
// File disappeared between ls-files and stat — skip.
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hits.push({ path: rel, sizeBytes, mtime, score });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
hits.sort((a, b) => {
|
|
90
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
91
|
+
return b.mtime - a.mtime;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return hits.slice(0, limit).map(({ path, sizeBytes, mtime }) => ({
|
|
95
|
+
path,
|
|
96
|
+
sizeBytes,
|
|
97
|
+
mtime,
|
|
98
|
+
}));
|
|
99
|
+
}
|