stagent 0.10.0 → 0.11.1

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 (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  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 +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  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 +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. package/src/test/setup.ts +10 -0
@@ -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,
@@ -42,7 +47,7 @@ import {
42
47
  } from "./permission-bridge";
43
48
  import { isToolAllowed } from "@/lib/settings/permissions";
44
49
  import { getLaunchCwd, getWorkspaceContext } from "@/lib/environment/workspace-context";
45
- import { createStagentMcpServer } from "./stagent-tools";
50
+ import { createToolServer } from "./stagent-tools";
46
51
  import {
47
52
  getBrowserMcpServers,
48
53
  getBrowserAllowedToolPatterns,
@@ -53,6 +58,36 @@ import {
53
58
  isExaTool,
54
59
  isExaReadOnly,
55
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
+ }
56
91
 
57
92
  // ── Streaming input wrapper (required for MCP tools) ─────────────────
58
93
 
@@ -151,21 +186,43 @@ export async function* sendMessage(
151
186
  return;
152
187
  }
153
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
+
154
211
  // Route to Codex App Server for OpenAI models
155
- if (conversation.runtimeId === "openai-codex-app-server") {
212
+ if (target.effectiveRuntimeId === "openai-codex-app-server") {
156
213
  const { sendCodexMessage } = await import("./codex-engine");
157
- yield* sendCodexMessage(conversationId, userContent, signal);
214
+ yield* sendCodexMessage(conversationId, userContent, signal, target);
158
215
  return;
159
216
  }
160
217
 
161
218
  // Route to Ollama for local models
162
- if (conversation.runtimeId === "ollama") {
219
+ if (target.effectiveRuntimeId === "ollama") {
163
220
  const { sendOllamaMessage } = await import("./ollama-engine");
164
221
  yield* sendOllamaMessage(conversationId, userContent, signal);
165
222
  return;
166
223
  }
167
224
 
168
- const runtimeId = conversation.runtimeId;
225
+ const runtimeId = target.effectiveRuntimeId;
169
226
  const providerId = getProviderForRuntime(runtimeId);
170
227
 
171
228
  // Enforce budget before the turn
@@ -277,10 +334,11 @@ export async function* sendMessage(
277
334
 
278
335
  // Create in-process MCP server for Stagent CRUD tools
279
336
  const toolResults: ToolResultCapture[] = [];
280
- const stagentServer = createStagentMcpServer(
337
+ const stagentServer = createToolServer(
281
338
  conversation.projectId,
282
- (toolName, result) => { toolResults.push({ toolName, result }); }
283
- );
339
+ (toolName, result) => { toolResults.push({ toolName, result }); },
340
+ projectCwd,
341
+ ).asMcpServer();
284
342
 
285
343
  yield { type: "status", phase: "connecting", message: "Connecting to model..." };
286
344
 
@@ -300,7 +358,7 @@ export async function* sendMessage(
300
358
  const response = query({
301
359
  prompt: generatePrompt(fullPrompt),
302
360
  options: {
303
- model: conversation.modelId || undefined,
361
+ model: target.effectiveModelId || conversation.modelId || undefined,
304
362
  maxTurns,
305
363
  abortController,
306
364
  includePartialMessages: true,
@@ -312,7 +370,13 @@ export async function* sendMessage(
312
370
  if (stderrChunks.length > 50) stderrChunks.shift();
313
371
  },
314
372
  mcpServers: { stagent: stagentServer, ...browserServers, ...externalServers },
315
- 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],
316
380
  // @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
317
381
  canUseTool: async (
318
382
  toolName: string,
@@ -369,6 +433,32 @@ export async function* sendMessage(
369
433
  // Mutation browser tools fall through to permission check below
370
434
  }
371
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
+
372
462
  const isQuestion = toolName === "AskUserQuestion";
373
463
 
374
464
  // Layer 1: Check saved user permissions (skip for questions)
@@ -615,7 +705,11 @@ export async function* sendMessage(
615
705
 
616
706
  // Save usage metadata + quick access links + screenshot attachments
617
707
  const metadata = JSON.stringify({
618
- 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 } : {}),
619
713
  inputTokens: usage.inputTokens,
620
714
  outputTokens: usage.outputTokens,
621
715
  ...(quickAccess.length > 0 ? { quickAccess } : {}),
@@ -632,7 +726,7 @@ export async function* sendMessage(
632
726
  activityType: "chat_turn",
633
727
  runtimeId,
634
728
  providerId,
635
- modelId: usage.modelId ?? conversation.modelId ?? null,
729
+ modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
636
730
  inputTokens: usage.inputTokens ?? null,
637
731
  outputTokens: usage.outputTokens ?? null,
638
732
  totalTokens: usage.totalTokens ?? null,
@@ -695,7 +789,7 @@ export async function* sendMessage(
695
789
  activityType: "chat_turn",
696
790
  runtimeId,
697
791
  providerId,
698
- modelId: usage.modelId ?? conversation.modelId ?? null,
792
+ modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
699
793
  inputTokens: usage.inputTokens ?? null,
700
794
  outputTokens: usage.outputTokens ?? null,
701
795
  totalTokens: usage.totalTokens ?? null,
@@ -722,7 +816,7 @@ export async function* sendMessage(
722
816
  activityType: "chat_turn",
723
817
  runtimeId,
724
818
  providerId,
725
- modelId: usage.modelId ?? conversation.modelId ?? null,
819
+ modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
726
820
  inputTokens: usage.inputTokens ?? null,
727
821
  outputTokens: usage.outputTokens ?? null,
728
822
  totalTokens: usage.totalTokens ?? null,
@@ -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
+ }