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.
Files changed (396) hide show
  1. package/README.md +20 -44
  2. package/dist/cli.js +66 -18
  3. package/docs/.coverage-gaps.json +144 -56
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +20 -2
  12. package/docs/features/schedules.md +32 -4
  13. package/docs/features/settings.md +28 -5
  14. package/docs/features/shared-components.md +7 -3
  15. package/docs/features/tables.md +11 -2
  16. package/docs/features/tool-permissions.md +6 -2
  17. package/docs/features/workflows.md +14 -4
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +39 -2
  20. package/docs/journeys/personal-use.md +32 -8
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  25. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  26. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  27. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  28. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  29. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  30. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  31. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  32. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  33. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  34. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  35. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  36. package/next.config.mjs +1 -0
  37. package/package.json +3 -2
  38. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  39. package/src/app/analytics/page.tsx +1 -21
  40. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  41. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  42. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  43. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  44. package/src/app/api/chat/export/route.ts +52 -0
  45. package/src/app/api/chat/files/search/route.ts +50 -0
  46. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  47. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  48. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  49. package/src/app/api/environment/skills/route.ts +13 -0
  50. package/src/app/api/instance/config/route.ts +41 -0
  51. package/src/app/api/instance/init/route.ts +34 -0
  52. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  53. package/src/app/api/instance/upgrade/route.ts +96 -0
  54. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  55. package/src/app/api/memory/route.ts +0 -11
  56. package/src/app/api/notifications/route.ts +4 -2
  57. package/src/app/api/projects/[id]/route.ts +5 -155
  58. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  59. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  60. package/src/app/api/schedules/[id]/route.ts +9 -1
  61. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  62. package/src/app/api/schedules/route.ts +3 -12
  63. package/src/app/api/settings/chat/pins/route.ts +94 -0
  64. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  65. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  66. package/src/app/api/settings/environment/route.ts +26 -0
  67. package/src/app/api/settings/openai/login/route.ts +22 -0
  68. package/src/app/api/settings/openai/logout/route.ts +7 -0
  69. package/src/app/api/settings/openai/route.ts +21 -1
  70. package/src/app/api/settings/providers/route.ts +35 -8
  71. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  72. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  73. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  74. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  75. package/src/app/api/tasks/[id]/execute/route.ts +52 -33
  76. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  77. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  78. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  79. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  80. package/src/app/api/workspace/context/route.ts +2 -0
  81. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  82. package/src/app/chat/page.tsx +11 -0
  83. package/src/app/documents/page.tsx +4 -1
  84. package/src/app/inbox/page.tsx +12 -5
  85. package/src/app/layout.tsx +42 -21
  86. package/src/app/page.tsx +0 -2
  87. package/src/app/settings/page.tsx +8 -9
  88. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  89. package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
  90. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  91. package/src/components/chat/capability-banner.tsx +68 -0
  92. package/src/components/chat/chat-command-popover.tsx +670 -49
  93. package/src/components/chat/chat-input.tsx +104 -10
  94. package/src/components/chat/chat-message.tsx +12 -3
  95. package/src/components/chat/chat-session-provider.tsx +790 -0
  96. package/src/components/chat/chat-shell.tsx +151 -401
  97. package/src/components/chat/command-tab-bar.tsx +68 -0
  98. package/src/components/chat/conversation-template-picker.tsx +421 -0
  99. package/src/components/chat/help-dialog.tsx +39 -0
  100. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  101. package/src/components/chat/skill-row.tsx +147 -0
  102. package/src/components/documents/document-browser.tsx +37 -19
  103. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  104. package/src/components/instance/instance-section.tsx +382 -0
  105. package/src/components/instance/upgrade-badge.tsx +219 -0
  106. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  107. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  108. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  109. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  110. package/src/components/notifications/inbox-list.tsx +11 -2
  111. package/src/components/notifications/notification-item.tsx +56 -2
  112. package/src/components/notifications/pending-approval-host.tsx +56 -37
  113. package/src/components/notifications/permission-response-actions.tsx +155 -1
  114. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  115. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  116. package/src/components/schedules/schedule-form.tsx +31 -0
  117. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  118. package/src/components/settings/auth-method-selector.tsx +19 -4
  119. package/src/components/settings/auth-status-badge.tsx +28 -3
  120. package/src/components/settings/environment-section.tsx +102 -0
  121. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  122. package/src/components/settings/openai-runtime-section.tsx +7 -1
  123. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  124. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  125. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  126. package/src/components/shared/app-sidebar.tsx +4 -3
  127. package/src/components/shared/command-palette.tsx +266 -7
  128. package/src/components/shared/filter-hint.tsx +70 -0
  129. package/src/components/shared/filter-input.tsx +59 -0
  130. package/src/components/shared/saved-searches-manager.tsx +199 -0
  131. package/src/components/shared/theme-toggle.tsx +5 -24
  132. package/src/components/shared/workspace-indicator.tsx +61 -2
  133. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  134. package/src/components/tables/table-create-sheet.tsx +4 -0
  135. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  136. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  137. package/src/components/tables/table-spreadsheet.tsx +29 -5
  138. package/src/components/tables/table-toolbar.tsx +10 -1
  139. package/src/components/tasks/kanban-board.tsx +1 -0
  140. package/src/components/tasks/kanban-column.tsx +53 -14
  141. package/src/components/tasks/task-bento-grid.tsx +31 -2
  142. package/src/components/tasks/task-card.tsx +29 -3
  143. package/src/components/tasks/task-chip-bar.tsx +54 -1
  144. package/src/components/tasks/task-result-renderer.tsx +1 -1
  145. package/src/components/workflows/delay-step-body.tsx +109 -0
  146. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  147. package/src/components/workflows/loop-status-view.tsx +1 -1
  148. package/src/components/workflows/shared/step-result.tsx +78 -0
  149. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  150. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  151. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  152. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  153. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  154. package/src/components/workflows/workflow-form-view.tsx +133 -16
  155. package/src/components/workflows/workflow-status-view.tsx +30 -740
  156. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  157. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  158. package/src/hooks/use-active-skills.ts +110 -0
  159. package/src/hooks/use-chat-autocomplete.ts +120 -7
  160. package/src/hooks/use-enriched-skills.ts +19 -0
  161. package/src/hooks/use-pinned-entries.ts +104 -0
  162. package/src/hooks/use-recent-user-messages.ts +19 -0
  163. package/src/hooks/use-saved-searches.ts +142 -0
  164. package/src/instrumentation-node.ts +94 -0
  165. package/src/instrumentation.ts +4 -48
  166. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  167. package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
  168. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  169. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  170. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  171. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  172. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  173. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  174. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  175. package/src/lib/agents/claude-agent.ts +217 -21
  176. package/src/lib/agents/execution-manager.ts +0 -35
  177. package/src/lib/agents/handoff/bus.ts +2 -2
  178. package/src/lib/agents/learned-context.ts +0 -12
  179. package/src/lib/agents/learning-session.ts +18 -5
  180. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  181. package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
  182. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
  183. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
  184. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  185. package/src/lib/agents/profiles/registry.ts +18 -0
  186. package/src/lib/agents/profiles/types.ts +7 -1
  187. package/src/lib/agents/router.ts +3 -6
  188. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  189. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  190. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  191. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  192. package/src/lib/agents/runtime/catalog.ts +121 -0
  193. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  194. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  195. package/src/lib/agents/runtime/execution-target.ts +456 -0
  196. package/src/lib/agents/runtime/index.ts +4 -0
  197. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  198. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  199. package/src/lib/agents/runtime/openai-codex.ts +64 -60
  200. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  201. package/src/lib/agents/runtime/types.ts +8 -0
  202. package/src/lib/agents/task-dispatch.ts +220 -0
  203. package/src/lib/agents/tool-permissions.ts +16 -1
  204. package/src/lib/book/chapter-mapping.ts +11 -0
  205. package/src/lib/book/content.ts +10 -0
  206. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  207. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  208. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  209. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  210. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  211. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  212. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  213. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  214. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  215. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  216. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  217. package/src/lib/chat/__tests__/types.test.ts +28 -0
  218. package/src/lib/chat/active-skills.ts +31 -0
  219. package/src/lib/chat/active-streams.ts +27 -0
  220. package/src/lib/chat/clean-filter-input.ts +30 -0
  221. package/src/lib/chat/codex-engine.ts +46 -24
  222. package/src/lib/chat/command-tabs.ts +61 -0
  223. package/src/lib/chat/context-builder.ts +146 -4
  224. package/src/lib/chat/dismissals.ts +73 -0
  225. package/src/lib/chat/engine.ts +159 -18
  226. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  227. package/src/lib/chat/files/expand-mention.ts +76 -0
  228. package/src/lib/chat/files/search.ts +99 -0
  229. package/src/lib/chat/reconcile.ts +117 -0
  230. package/src/lib/chat/skill-composition.ts +210 -0
  231. package/src/lib/chat/skill-conflict.ts +105 -0
  232. package/src/lib/chat/stagent-tools.ts +7 -19
  233. package/src/lib/chat/stream-telemetry.ts +137 -0
  234. package/src/lib/chat/suggested-prompts.ts +28 -1
  235. package/src/lib/chat/system-prompt.ts +48 -1
  236. package/src/lib/chat/tool-catalog.ts +35 -4
  237. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  238. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  239. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  240. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  241. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  242. package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
  243. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
  244. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  245. package/src/lib/chat/tools/document-tools.ts +29 -13
  246. package/src/lib/chat/tools/helpers.ts +41 -0
  247. package/src/lib/chat/tools/notification-tools.ts +9 -5
  248. package/src/lib/chat/tools/profile-tools.ts +120 -23
  249. package/src/lib/chat/tools/project-tools.ts +33 -0
  250. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  251. package/src/lib/chat/tools/skill-tools.ts +183 -0
  252. package/src/lib/chat/tools/table-tools.ts +71 -0
  253. package/src/lib/chat/tools/task-tools.ts +89 -21
  254. package/src/lib/chat/tools/workflow-tools.ts +275 -32
  255. package/src/lib/chat/types.ts +15 -0
  256. package/src/lib/constants/settings.ts +10 -18
  257. package/src/lib/data/__tests__/clear.test.ts +56 -2
  258. package/src/lib/data/clear.ts +17 -16
  259. package/src/lib/data/delete-project.ts +171 -0
  260. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  261. package/src/lib/db/bootstrap.ts +62 -16
  262. package/src/lib/db/index.ts +5 -0
  263. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  264. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  265. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  266. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  267. package/src/lib/db/migrations/meta/_journal.json +21 -0
  268. package/src/lib/db/schema.ts +94 -23
  269. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  270. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  271. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  272. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  273. package/src/lib/environment/data.ts +9 -0
  274. package/src/lib/environment/list-skills.ts +176 -0
  275. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  276. package/src/lib/environment/parsers/skill.ts +26 -5
  277. package/src/lib/environment/profile-generator.ts +54 -0
  278. package/src/lib/environment/skill-enrichment.ts +106 -0
  279. package/src/lib/environment/skill-recommendations.ts +66 -0
  280. package/src/lib/environment/workspace-context.ts +13 -1
  281. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  282. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  283. package/src/lib/filters/parse.ts +86 -0
  284. package/src/lib/import/dedup.ts +4 -54
  285. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  286. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  287. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  288. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  289. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  290. package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
  291. package/src/lib/instance/bootstrap.ts +270 -0
  292. package/src/lib/instance/detect.ts +49 -0
  293. package/src/lib/instance/fingerprint.ts +76 -0
  294. package/src/lib/instance/git-ops.ts +95 -0
  295. package/src/lib/instance/settings.ts +61 -0
  296. package/src/lib/instance/types.ts +77 -0
  297. package/src/lib/instance/upgrade-poller.ts +205 -0
  298. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  299. package/src/lib/notifications/visibility.ts +33 -0
  300. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  301. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  302. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  303. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  304. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  305. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  306. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  307. package/src/lib/schedules/collision-check.ts +105 -0
  308. package/src/lib/schedules/config.ts +53 -0
  309. package/src/lib/schedules/scheduler.ts +236 -17
  310. package/src/lib/schedules/slot-claim.ts +105 -0
  311. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  312. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  313. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  314. package/src/lib/settings/openai-auth.ts +105 -10
  315. package/src/lib/settings/openai-login-manager.ts +260 -0
  316. package/src/lib/settings/runtime-setup.ts +14 -4
  317. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  318. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  319. package/src/lib/tables/enrichment-planner.ts +454 -0
  320. package/src/lib/tables/enrichment.ts +328 -0
  321. package/src/lib/tables/query-builder.ts +5 -2
  322. package/src/lib/tables/trigger-evaluator.ts +3 -2
  323. package/src/lib/theme.ts +71 -0
  324. package/src/lib/usage/ledger.ts +2 -18
  325. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  326. package/src/lib/util/similarity.ts +77 -0
  327. package/src/lib/utils/format-timestamp.ts +24 -0
  328. package/src/lib/utils/stagent-paths.ts +12 -0
  329. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  330. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  331. package/src/lib/validators/blueprint.ts +70 -9
  332. package/src/lib/validators/profile.ts +2 -2
  333. package/src/lib/validators/settings.ts +3 -1
  334. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  335. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  336. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  337. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  338. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  339. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  340. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  341. package/src/lib/workflows/blueprints/types.ts +16 -2
  342. package/src/lib/workflows/delay.ts +106 -0
  343. package/src/lib/workflows/engine.ts +212 -7
  344. package/src/lib/workflows/loop-executor.ts +349 -24
  345. package/src/lib/workflows/post-action.ts +91 -0
  346. package/src/lib/workflows/types.ts +166 -1
  347. package/src/test/setup.ts +10 -0
  348. package/src/app/api/license/checkout/route.ts +0 -28
  349. package/src/app/api/license/portal/route.ts +0 -26
  350. package/src/app/api/license/route.ts +0 -89
  351. package/src/app/api/license/usage/route.ts +0 -63
  352. package/src/app/api/marketplace/browse/route.ts +0 -15
  353. package/src/app/api/marketplace/import/route.ts +0 -28
  354. package/src/app/api/marketplace/publish/route.ts +0 -40
  355. package/src/app/api/onboarding/email/route.ts +0 -53
  356. package/src/app/api/settings/telemetry/route.ts +0 -14
  357. package/src/app/api/sync/export/route.ts +0 -54
  358. package/src/app/api/sync/restore/route.ts +0 -37
  359. package/src/app/api/sync/sessions/route.ts +0 -24
  360. package/src/app/auth/callback/route.ts +0 -73
  361. package/src/app/marketplace/page.tsx +0 -19
  362. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  363. package/src/components/marketplace/blueprint-card.tsx +0 -61
  364. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  365. package/src/components/onboarding/email-capture-card.tsx +0 -104
  366. package/src/components/settings/activation-form.tsx +0 -95
  367. package/src/components/settings/cloud-account-section.tsx +0 -147
  368. package/src/components/settings/cloud-sync-section.tsx +0 -155
  369. package/src/components/settings/subscription-section.tsx +0 -410
  370. package/src/components/settings/telemetry-section.tsx +0 -80
  371. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  372. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  373. package/src/components/shared/upgrade-banner.tsx +0 -112
  374. package/src/hooks/use-supabase-auth.ts +0 -79
  375. package/src/lib/billing/email.ts +0 -54
  376. package/src/lib/billing/products.ts +0 -80
  377. package/src/lib/billing/stripe.ts +0 -101
  378. package/src/lib/cloud/supabase-browser.ts +0 -32
  379. package/src/lib/cloud/supabase-client.ts +0 -56
  380. package/src/lib/license/__tests__/features.test.ts +0 -56
  381. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  382. package/src/lib/license/__tests__/manager.test.ts +0 -64
  383. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  384. package/src/lib/license/cloud-validation.ts +0 -60
  385. package/src/lib/license/features.ts +0 -44
  386. package/src/lib/license/key-format.ts +0 -101
  387. package/src/lib/license/limit-check.ts +0 -111
  388. package/src/lib/license/limit-queries.ts +0 -51
  389. package/src/lib/license/manager.ts +0 -345
  390. package/src/lib/license/notifications.ts +0 -59
  391. package/src/lib/license/tier-limits.ts +0 -71
  392. package/src/lib/marketplace/marketplace-client.ts +0 -107
  393. package/src/lib/sync/cloud-sync.ts +0 -235
  394. package/src/lib/telemetry/conversion-events.ts +0 -71
  395. package/src/lib/telemetry/queue.ts +0 -122
  396. 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
+ }
@@ -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 { buildClaudeSdkEnv } from "@/lib/agents/runtime/claude-sdk";
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 { createStagentMcpServer } from "./stagent-tools";
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 (conversation.runtimeId === "openai-codex-app-server") {
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 (conversation.runtimeId === "ollama") {
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 = conversation.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 = createStagentMcpServer(
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: ["mcp__stagent__*", ...browserToolPatterns, ...externalToolPatterns],
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 errorMessage = diagnoseProcessError(rawMessage, stderrTail);
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 || errorMessage
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
+ }