stagent 0.10.0 → 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 (170) hide show
  1. package/README.md +15 -2
  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/index.md +1 -1
  18. package/docs/journeys/developer.md +25 -2
  19. package/docs/journeys/personal-use.md +12 -5
  20. package/docs/journeys/power-user.md +45 -14
  21. package/docs/journeys/work-use.md +17 -8
  22. package/docs/manifest.json +15 -15
  23. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  24. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  25. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  27. package/next.config.mjs +1 -0
  28. package/package.json +1 -1
  29. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  30. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  31. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  32. package/src/app/api/chat/export/route.ts +52 -0
  33. package/src/app/api/chat/files/search/route.ts +50 -0
  34. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  35. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  36. package/src/app/api/environment/skills/route.ts +13 -0
  37. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  38. package/src/app/api/settings/chat/pins/route.ts +94 -0
  39. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  40. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  41. package/src/app/api/settings/environment/route.ts +26 -0
  42. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  43. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  44. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  45. package/src/app/documents/page.tsx +4 -1
  46. package/src/app/settings/page.tsx +2 -0
  47. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  48. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  49. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  50. package/src/components/chat/capability-banner.tsx +68 -0
  51. package/src/components/chat/chat-command-popover.tsx +668 -47
  52. package/src/components/chat/chat-input.tsx +103 -8
  53. package/src/components/chat/chat-message.tsx +12 -3
  54. package/src/components/chat/chat-session-provider.tsx +73 -3
  55. package/src/components/chat/chat-shell.tsx +62 -3
  56. package/src/components/chat/command-tab-bar.tsx +68 -0
  57. package/src/components/chat/conversation-template-picker.tsx +421 -0
  58. package/src/components/chat/help-dialog.tsx +39 -0
  59. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  60. package/src/components/chat/skill-row.tsx +147 -0
  61. package/src/components/documents/document-browser.tsx +37 -19
  62. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  63. package/src/components/notifications/permission-response-actions.tsx +155 -1
  64. package/src/components/settings/environment-section.tsx +102 -0
  65. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  66. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  67. package/src/components/shared/command-palette.tsx +262 -2
  68. package/src/components/shared/filter-hint.tsx +70 -0
  69. package/src/components/shared/filter-input.tsx +59 -0
  70. package/src/components/shared/saved-searches-manager.tsx +199 -0
  71. package/src/components/tasks/task-bento-grid.tsx +12 -2
  72. package/src/components/tasks/task-card.tsx +3 -0
  73. package/src/components/tasks/task-chip-bar.tsx +30 -1
  74. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  75. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  76. package/src/hooks/use-active-skills.ts +110 -0
  77. package/src/hooks/use-chat-autocomplete.ts +120 -7
  78. package/src/hooks/use-enriched-skills.ts +19 -0
  79. package/src/hooks/use-pinned-entries.ts +104 -0
  80. package/src/hooks/use-recent-user-messages.ts +19 -0
  81. package/src/hooks/use-saved-searches.ts +142 -0
  82. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  83. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  84. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  85. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  86. package/src/lib/agents/claude-agent.ts +105 -46
  87. package/src/lib/agents/handoff/bus.ts +2 -2
  88. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  89. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  90. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  91. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  92. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  93. package/src/lib/agents/profiles/registry.ts +18 -0
  94. package/src/lib/agents/profiles/types.ts +7 -1
  95. package/src/lib/agents/router.ts +3 -6
  96. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  97. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  98. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  99. package/src/lib/agents/runtime/catalog.ts +121 -0
  100. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  101. package/src/lib/agents/runtime/execution-target.ts +456 -0
  102. package/src/lib/agents/runtime/index.ts +4 -0
  103. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  104. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  105. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  106. package/src/lib/agents/task-dispatch.ts +220 -0
  107. package/src/lib/agents/tool-permissions.ts +16 -1
  108. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  109. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  110. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  111. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  112. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  113. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  114. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  115. package/src/lib/chat/__tests__/types.test.ts +28 -0
  116. package/src/lib/chat/active-skills.ts +31 -0
  117. package/src/lib/chat/clean-filter-input.ts +30 -0
  118. package/src/lib/chat/codex-engine.ts +30 -7
  119. package/src/lib/chat/command-tabs.ts +61 -0
  120. package/src/lib/chat/context-builder.ts +141 -1
  121. package/src/lib/chat/dismissals.ts +73 -0
  122. package/src/lib/chat/engine.ts +109 -15
  123. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  124. package/src/lib/chat/files/expand-mention.ts +76 -0
  125. package/src/lib/chat/files/search.ts +99 -0
  126. package/src/lib/chat/skill-composition.ts +210 -0
  127. package/src/lib/chat/skill-conflict.ts +105 -0
  128. package/src/lib/chat/stagent-tools.ts +6 -19
  129. package/src/lib/chat/stream-telemetry.ts +9 -4
  130. package/src/lib/chat/system-prompt.ts +22 -0
  131. package/src/lib/chat/tool-catalog.ts +33 -3
  132. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  133. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  134. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  135. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  136. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  137. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  138. package/src/lib/chat/tools/helpers.ts +2 -0
  139. package/src/lib/chat/tools/profile-tools.ts +120 -23
  140. package/src/lib/chat/tools/skill-tools.ts +183 -0
  141. package/src/lib/chat/tools/task-tools.ts +6 -2
  142. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  143. package/src/lib/chat/types.ts +15 -0
  144. package/src/lib/constants/settings.ts +2 -0
  145. package/src/lib/data/clear.ts +2 -6
  146. package/src/lib/db/bootstrap.ts +17 -0
  147. package/src/lib/db/schema.ts +26 -0
  148. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  149. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  150. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  151. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  152. package/src/lib/environment/data.ts +9 -0
  153. package/src/lib/environment/list-skills.ts +176 -0
  154. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  155. package/src/lib/environment/parsers/skill.ts +26 -5
  156. package/src/lib/environment/profile-generator.ts +54 -0
  157. package/src/lib/environment/skill-enrichment.ts +106 -0
  158. package/src/lib/environment/skill-recommendations.ts +66 -0
  159. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  160. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  161. package/src/lib/filters/parse.ts +86 -0
  162. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  163. package/src/lib/instance/fingerprint.ts +7 -9
  164. package/src/lib/instance/upgrade-poller.ts +53 -1
  165. package/src/lib/schedules/scheduler.ts +4 -4
  166. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  167. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  168. package/src/lib/workflows/blueprints/types.ts +6 -0
  169. package/src/lib/workflows/engine.ts +5 -3
  170. package/src/test/setup.ts +10 -0
@@ -5,6 +5,8 @@ import { getMessages } from "@/lib/data/chat";
5
5
  import { getProfile } from "@/lib/agents/profiles/registry";
6
6
  import { STAGENT_SYSTEM_PROMPT } from "./system-prompt";
7
7
  import type { WorkspaceContext } from "@/lib/environment/workspace-context";
8
+ import { expandFileMention } from "./files/expand-mention";
9
+ import { conversations } from "@/lib/db/schema";
8
10
 
9
11
  // ── Token budget constants ─────────────────────────────────────────────
10
12
 
@@ -50,6 +52,121 @@ function buildTier0(
50
52
  return parts.join("\n");
51
53
  }
52
54
 
55
+ // ── Active skill injection (Ollama-first, runtime-agnostic) ────────────
56
+
57
+ /**
58
+ * Token budget for a conversation-bound skill's SKILL.md content.
59
+ *
60
+ * Per spec §7.1: 1000-4000 tokens typical, with 300 tokens of index/
61
+ * metadata on top. We cap at ~4000 tokens (≈16K chars) so a large skill
62
+ * can't blow out a small-context local model. Single-active-skill is
63
+ * enforced at the MCP-tool layer.
64
+ */
65
+ const ACTIVE_SKILL_BUDGET = 4_000;
66
+
67
+ interface ActiveSkillSection {
68
+ name: string;
69
+ text: string;
70
+ }
71
+
72
+ function renderActiveSkillSections(
73
+ kept: ActiveSkillSection[],
74
+ omitted: ActiveSkillSection[]
75
+ ): string {
76
+ if (kept.length === 0) return "";
77
+
78
+ const parts: string[] = [];
79
+ if (omitted.length > 0) {
80
+ const label = omitted.length === 1 ? "skill" : "skills";
81
+ parts.push(
82
+ `## Active Skill Note\nOmitted ${omitted.length} older active ${label} to fit the prompt budget: ${omitted
83
+ .map((section) => section.name)
84
+ .join(", ")}.`
85
+ );
86
+ }
87
+ parts.push(...kept.map((section) => section.text));
88
+ return parts.join("\n\n---\n\n");
89
+ }
90
+
91
+ /**
92
+ * Build the "Active Skill" section of the system prompt, if one is bound
93
+ * to the conversation via `conversations.active_skill_id`. Returns "" for
94
+ * conversations without an active skill.
95
+ *
96
+ * Primary use case: Ollama has no SDK-native skill support, so this is
97
+ * how SKILL.md reaches a local model. Claude and Codex runtimes can
98
+ * also bind a skill via this path alongside their native Skill tools.
99
+ *
100
+ * See `features/chat-ollama-native-skills.md`.
101
+ */
102
+ async function buildActiveSkill(conversationId: string): Promise<string> {
103
+ const row = await db
104
+ .select({
105
+ activeSkillId: conversations.activeSkillId,
106
+ activeSkillIds: conversations.activeSkillIds,
107
+ runtimeId: conversations.runtimeId,
108
+ })
109
+ .from(conversations)
110
+ .where(eq(conversations.id, conversationId))
111
+ .get();
112
+
113
+ // Merge legacy single-active + new composed array. Dynamic import to
114
+ // avoid loading the chat tools module on the hot path / risk import
115
+ // cycles per the runtime-catalog smoke-test budget rule in MEMORY.md.
116
+ const { mergeActiveSkillIds } = await import("@/lib/chat/active-skills");
117
+ const merged = mergeActiveSkillIds(row?.activeSkillId, row?.activeSkillIds);
118
+ if (merged.length === 0) return "";
119
+
120
+ // Composition (any entry in the new activeSkillIds column) is an
121
+ // explicit user opt-in to override the SDK-native default. Without
122
+ // this carve-out, composed skills would silently no-op on Claude/
123
+ // Codex where stagentInjectsSkills=false. When only the legacy
124
+ // activeSkillId is set, fall back to the original capability gate
125
+ // (Ollama-only injection).
126
+ const isComposed = (row?.activeSkillIds?.length ?? 0) > 0;
127
+
128
+ if (!isComposed && row?.runtimeId) {
129
+ try {
130
+ const { getRuntimeFeatures } = await import("@/lib/agents/runtime/catalog");
131
+ const features = getRuntimeFeatures(
132
+ row.runtimeId as Parameters<typeof getRuntimeFeatures>[0]
133
+ );
134
+ if (!features.stagentInjectsSkills) return "";
135
+ } catch {
136
+ // Unknown runtime — fall through and inject (safer default than
137
+ // silently dropping the skill on an unrecognized runtime id).
138
+ }
139
+ }
140
+
141
+ // Dynamic import keeps the scanner + fs dependency off the hot path for
142
+ // conversations that don't have an active skill (the common case).
143
+ const { getSkill } = await import("@/lib/environment/list-skills");
144
+ const sections: ActiveSkillSection[] = [];
145
+ for (const id of merged) {
146
+ const skill = getSkill(id);
147
+ if (!skill) continue;
148
+ sections.push({
149
+ name: skill.name,
150
+ text: `## Active Skill: ${skill.name}\n\n${skill.content}`,
151
+ });
152
+ }
153
+ if (sections.length === 0) return "";
154
+
155
+ const kept = [...sections];
156
+ const omitted: ActiveSkillSection[] = [];
157
+ while (
158
+ kept.length > 1 &&
159
+ estimateTokens(renderActiveSkillSections(kept, omitted)) > ACTIVE_SKILL_BUDGET
160
+ ) {
161
+ const oldest = kept.shift();
162
+ if (oldest) omitted.push(oldest);
163
+ }
164
+
165
+ const combined = renderActiveSkillSections(kept, omitted);
166
+ if (estimateTokens(combined) <= ACTIVE_SKILL_BUDGET) return combined;
167
+ return truncateToTokenBudget(combined, ACTIVE_SKILL_BUDGET);
168
+ }
169
+
53
170
  // ── Tier 1: Conversation history ───────────────────────────────────────
54
171
 
55
172
  interface HistoryMessage {
@@ -278,6 +395,23 @@ async function buildTier3(mentions: MentionReference[]): Promise<string> {
278
395
  }
279
396
  break;
280
397
  }
398
+ case "file": {
399
+ // `entityId` is a relative path scoped to the active project's
400
+ // workingDirectory (preferred) or the stagent launch cwd (fallback).
401
+ // Security is enforced inside expandFileMention — the caller cannot
402
+ // influence cwd.
403
+ const { getLaunchCwd } = await import("@/lib/environment/workspace-context");
404
+ let cwd = getLaunchCwd();
405
+ // If the mention has a known project context in scope, prefer the
406
+ // project's workingDirectory. We don't have it at this scope today,
407
+ // so launch cwd is the safe default — matches the API route.
408
+ // (Future: plumb projectId into buildTier3 so file expansion honors
409
+ // per-project cwds exactly the same way as the search API.)
410
+ void cwd;
411
+ cwd = getLaunchCwd();
412
+ parts.push(...expandFileMention(mention.entityId, cwd));
413
+ break;
414
+ }
281
415
  }
282
416
  }
283
417
 
@@ -304,16 +438,22 @@ export async function buildChatContext(opts: {
304
438
  workspace?: WorkspaceContext | null;
305
439
  mentions?: MentionReference[];
306
440
  }): Promise<ChatContext> {
307
- const [history, tier2, tier3] = await Promise.all([
441
+ const [history, tier2, tier3, activeSkill] = await Promise.all([
308
442
  buildTier1(opts.conversationId),
309
443
  buildTier2(opts.projectId),
310
444
  buildTier3(opts.mentions ?? []),
445
+ buildActiveSkill(opts.conversationId),
311
446
  ]);
312
447
 
313
448
  const tier0 = buildTier0(opts.projectName, opts.workspace);
314
449
 
315
450
  const systemParts = [tier0];
316
451
 
452
+ // Active skill (from conversations.active_skill_id) sits right below
453
+ // Tier 0 so its instructions carry the most weight. Empty string when
454
+ // no skill is bound — common case.
455
+ if (activeSkill) systemParts.push(activeSkill);
456
+
317
457
  if (tier3) systemParts.push(tier3);
318
458
  if (tier2) systemParts.push(tier2);
319
459
 
@@ -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,
@@ -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
+ }