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
@@ -11,6 +11,7 @@ import { invalidateLatestScan, getLatestScan } from "@/lib/environment/data";
11
11
  import { db } from "@/lib/db";
12
12
  import { environmentArtifacts } from "@/lib/db/schema";
13
13
  import { eq, and } from "drizzle-orm";
14
+ import { getStagentProfilesDir } from "@/lib/utils/stagent-paths";
14
15
 
15
16
  /**
16
17
  * Builtins ship inside the repo at src/lib/agents/profiles/builtins/.
@@ -36,6 +37,12 @@ const SKILLS_DIR = path.join(
36
37
  "skills"
37
38
  );
38
39
 
40
+ /**
41
+ * Auto-promoted profiles (from environment discovery) are written here
42
+ * instead of SKILLS_DIR to avoid colliding with Claude Code's skill namespace.
43
+ */
44
+ const PROMOTED_PROFILES_DIR = getStagentProfilesDir();
45
+
39
46
  // ---------------------------------------------------------------------------
40
47
  // Cache
41
48
  // ---------------------------------------------------------------------------
@@ -43,37 +50,44 @@ const SKILLS_DIR = path.join(
43
50
  let profileCache: Map<string, AgentProfile> | null = null;
44
51
  let profileCacheSignature: string | null = null;
45
52
 
46
- function getSkillsDirectorySignature(): string {
47
- if (!fs.existsSync(SKILLS_DIR)) {
48
- return "missing";
49
- }
53
+ function getDirectorySignatureParts(baseDir: string): string[] {
54
+ if (!fs.existsSync(baseDir)) return [];
50
55
 
51
56
  const entries = fs
52
- .readdirSync(SKILLS_DIR, { withFileTypes: true })
57
+ .readdirSync(baseDir, { withFileTypes: true })
53
58
  .filter((entry) => entry.isDirectory())
54
59
  .sort((a, b) => a.name.localeCompare(b.name));
55
60
 
56
- const signatureParts: string[] = [];
61
+ const parts: string[] = [];
57
62
 
58
63
  for (const entry of entries) {
59
- const dir = path.join(SKILLS_DIR, entry.name);
64
+ const dir = path.join(baseDir, entry.name);
60
65
  const yamlPath = path.join(dir, "profile.yaml");
61
66
  const skillPath = path.join(dir, "SKILL.md");
62
67
 
63
- signatureParts.push(entry.name);
68
+ parts.push(entry.name);
64
69
 
65
70
  if (fs.existsSync(yamlPath)) {
66
71
  const stats = fs.statSync(yamlPath);
67
- signatureParts.push(`yaml:${stats.mtimeMs}:${stats.size}`);
72
+ parts.push(`yaml:${stats.mtimeMs}:${stats.size}`);
68
73
  }
69
74
 
70
75
  if (fs.existsSync(skillPath)) {
71
76
  const stats = fs.statSync(skillPath);
72
- signatureParts.push(`skill:${stats.mtimeMs}:${stats.size}`);
77
+ parts.push(`skill:${stats.mtimeMs}:${stats.size}`);
73
78
  }
74
79
  }
75
80
 
76
- return signatureParts.join("|");
81
+ return parts;
82
+ }
83
+
84
+ function getSkillsDirectorySignature(): string {
85
+ const skillsParts = getDirectorySignatureParts(SKILLS_DIR);
86
+ const promotedParts = getDirectorySignatureParts(PROMOTED_PROFILES_DIR);
87
+
88
+ if (skillsParts.length === 0 && promotedParts.length === 0) return "missing";
89
+
90
+ return [...skillsParts, "||promoted||", ...promotedParts].join("|");
77
91
  }
78
92
 
79
93
  // ---------------------------------------------------------------------------
@@ -118,6 +132,22 @@ function ensureBuiltins(): void {
118
132
  changed = true;
119
133
  }
120
134
 
135
+ if (
136
+ source.preferredRuntime !== undefined &&
137
+ target.preferredRuntime !== source.preferredRuntime
138
+ ) {
139
+ target.preferredRuntime = source.preferredRuntime;
140
+ changed = true;
141
+ }
142
+
143
+ if (
144
+ source.capabilityOverrides !== undefined &&
145
+ target.capabilityOverrides === undefined
146
+ ) {
147
+ target.capabilityOverrides = source.capabilityOverrides;
148
+ changed = true;
149
+ }
150
+
121
151
  if (changed) {
122
152
  fs.writeFileSync(targetYaml, yaml.dump(target));
123
153
  }
@@ -140,15 +170,16 @@ function ensureBuiltins(): void {
140
170
  // scanProfiles — read .claude/skills/*/profile.yaml, validate, pair w/ SKILL.md
141
171
  // ---------------------------------------------------------------------------
142
172
 
143
- function scanProfiles(): Map<string, AgentProfile> {
144
- const profiles = new Map<string, AgentProfile>();
145
-
146
- if (!fs.existsSync(SKILLS_DIR)) return profiles;
173
+ function scanProfilesFromDir(
174
+ baseDir: string,
175
+ profiles: Map<string, AgentProfile>
176
+ ): void {
177
+ if (!fs.existsSync(baseDir)) return;
147
178
 
148
- for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
179
+ for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
149
180
  if (!entry.isDirectory()) continue;
150
181
 
151
- const dir = path.join(SKILLS_DIR, entry.name);
182
+ const dir = path.join(baseDir, entry.name);
152
183
  const yamlPath = path.join(dir, "profile.yaml");
153
184
  const skillPath = path.join(dir, "SKILL.md");
154
185
 
@@ -206,7 +237,9 @@ function scanProfiles(): Map<string, AgentProfile> {
206
237
  tests: config.tests,
207
238
  importMeta: config.importMeta,
208
239
  supportedRuntimes: getSupportedRuntimes(config),
240
+ preferredRuntime: config.preferredRuntime,
209
241
  runtimeOverrides: config.runtimeOverrides,
242
+ capabilityOverrides: config.capabilityOverrides,
210
243
  origin,
211
244
  });
212
245
  } catch (err) {
@@ -214,6 +247,12 @@ function scanProfiles(): Map<string, AgentProfile> {
214
247
  }
215
248
  }
216
249
 
250
+ }
251
+
252
+ function scanProfiles(): Map<string, AgentProfile> {
253
+ const profiles = new Map<string, AgentProfile>();
254
+ scanProfilesFromDir(SKILLS_DIR, profiles);
255
+ scanProfilesFromDir(PROMOTED_PROFILES_DIR, profiles);
217
256
  return profiles;
218
257
  }
219
258
 
@@ -299,6 +338,28 @@ export function createProfile(config: ProfileConfig, skillMd: string): void {
299
338
  invalidateLatestScan();
300
339
  }
301
340
 
341
+ /**
342
+ * Create an auto-promoted profile in ~/.stagent/profiles/ (not ~/.claude/skills/).
343
+ * This avoids colliding with Claude Code's skill discovery namespace.
344
+ */
345
+ export function createPromotedProfile(config: ProfileConfig, skillMd: string): void {
346
+ const result = ProfileConfigSchema.safeParse(config);
347
+ if (!result.success) {
348
+ throw new Error(`Invalid profile: ${result.error.issues.map(i => i.message).join(", ")}`);
349
+ }
350
+
351
+ const dir = path.join(PROMOTED_PROFILES_DIR, config.id);
352
+ if (fs.existsSync(path.join(dir, "profile.yaml"))) {
353
+ throw new Error(`Profile "${config.id}" already exists`);
354
+ }
355
+
356
+ fs.mkdirSync(dir, { recursive: true });
357
+ fs.writeFileSync(path.join(dir, "profile.yaml"), yaml.dump(config));
358
+ fs.writeFileSync(path.join(dir, "SKILL.md"), skillMd);
359
+ reloadProfiles();
360
+ invalidateLatestScan();
361
+ }
362
+
302
363
  /** Update an existing custom profile (rejects builtins) */
303
364
  export function updateProfile(id: string, config: ProfileConfig, skillMd: string): void {
304
365
  if (isBuiltin(id)) {
@@ -310,8 +371,15 @@ export function updateProfile(id: string, config: ProfileConfig, skillMd: string
310
371
  throw new Error(`Invalid profile: ${result.error.issues.map(i => i.message).join(", ")}`);
311
372
  }
312
373
 
313
- const dir = path.join(SKILLS_DIR, id);
314
- if (!fs.existsSync(dir)) {
374
+ const skillsDir = path.join(SKILLS_DIR, id);
375
+ const promotedDir = path.join(PROMOTED_PROFILES_DIR, id);
376
+ const dir = fs.existsSync(skillsDir)
377
+ ? skillsDir
378
+ : fs.existsSync(promotedDir)
379
+ ? promotedDir
380
+ : null;
381
+
382
+ if (!dir) {
315
383
  throw new Error(`Profile "${id}" not found`);
316
384
  }
317
385
 
@@ -321,14 +389,21 @@ export function updateProfile(id: string, config: ProfileConfig, skillMd: string
321
389
  invalidateLatestScan();
322
390
  }
323
391
 
324
- /** Delete a custom profile (rejects builtins) */
392
+ /** Delete a custom profile (rejects builtins). Checks both user and promoted dirs. */
325
393
  export function deleteProfile(id: string): void {
326
394
  if (isBuiltin(id)) {
327
395
  throw new Error("Cannot delete built-in profiles");
328
396
  }
329
397
 
330
- const dir = path.join(SKILLS_DIR, id);
331
- if (!fs.existsSync(dir)) {
398
+ const skillsDir = path.join(SKILLS_DIR, id);
399
+ const promotedDir = path.join(PROMOTED_PROFILES_DIR, id);
400
+ const dir = fs.existsSync(skillsDir)
401
+ ? skillsDir
402
+ : fs.existsSync(promotedDir)
403
+ ? promotedDir
404
+ : null;
405
+
406
+ if (!dir) {
332
407
  throw new Error(`Profile "${id}" not found`);
333
408
  }
334
409
 
@@ -31,7 +31,13 @@ export interface ProfileRuntimeCapabilityOverride {
31
31
  export type ProfileScope = "builtin" | "user" | "project";
32
32
 
33
33
  /** How a profile entered the system — distinct from scope (where it lives). */
34
- export type ProfileOrigin = "manual" | "environment" | "import" | "ai-assist";
34
+ export type ProfileOrigin =
35
+ | "manual"
36
+ | "environment"
37
+ | "import"
38
+ | "ai-assist"
39
+ | "filesystem-project"
40
+ | "filesystem-user";
35
41
 
36
42
  export interface AgentProfile {
37
43
  id: string;
@@ -1,15 +1,12 @@
1
1
  import { listProfiles, getProfile } from "./profiles/registry";
2
2
  import { profileSupportsRuntime } from "./profiles/compatibility";
3
- import {
4
- executeTaskWithRuntime,
5
- resumeTaskWithRuntime,
6
- } from "./runtime";
7
3
  import {
8
4
  DEFAULT_AGENT_RUNTIME,
9
5
  SUPPORTED_AGENT_RUNTIMES,
10
6
  type AgentRuntimeId,
11
7
  } from "./runtime/catalog";
12
8
  import type { RoutingPreference } from "@/lib/constants/settings";
9
+ import { resumeTaskExecution, startTaskExecution } from "./task-dispatch";
13
10
 
14
11
  // ── Keyword signal maps for runtime scoring ──────────────────────────
15
12
 
@@ -217,12 +214,12 @@ export async function executeTaskWithAgent(
217
214
  taskId: string,
218
215
  agentType: string | null | undefined = DEFAULT_AGENT_RUNTIME
219
216
  ): Promise<void> {
220
- return executeTaskWithRuntime(taskId, agentType);
217
+ return startTaskExecution(taskId, { requestedRuntimeId: agentType });
221
218
  }
222
219
 
223
220
  export async function resumeTaskWithAgent(
224
221
  taskId: string,
225
222
  agentType: string | null | undefined = DEFAULT_AGENT_RUNTIME
226
223
  ): Promise<void> {
227
- return resumeTaskWithRuntime(taskId, agentType);
224
+ return resumeTaskExecution(taskId, { requestedRuntimeId: agentType });
228
225
  }
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_AGENT_RUNTIME,
4
4
  getRuntimeCapabilities,
5
5
  getRuntimeCatalogEntry,
6
+ getRuntimeFeatures,
6
7
  listRuntimeCatalog,
7
8
  resolveAgentRuntime,
8
9
  } from "@/lib/agents/runtime/catalog";
@@ -46,4 +47,133 @@ describe("runtime catalog", () => {
46
47
  expect(result).toBe("claude-code");
47
48
  warnSpy.mockRestore();
48
49
  });
50
+
51
+ it("exposes LLM-surface features via getRuntimeFeatures", () => {
52
+ const features = getRuntimeFeatures("claude-code");
53
+ expect(features.hasNativeSkills).toBe(true);
54
+ expect(features.hasProgressiveDisclosure).toBe(true);
55
+ expect(features.autoLoadsInstructions).toBe("CLAUDE.md");
56
+ expect(features.stagentInjectsSkills).toBe(false);
57
+ });
58
+
59
+ it("marks Ollama as requiring Stagent-injected skills", () => {
60
+ const features = getRuntimeFeatures("ollama");
61
+ expect(features.hasNativeSkills).toBe(false);
62
+ expect(features.stagentInjectsSkills).toBe(true);
63
+ expect(features.autoLoadsInstructions).toBeNull();
64
+ });
65
+
66
+ it("declares Codex auto-loads AGENTS.md", () => {
67
+ expect(getRuntimeFeatures("openai-codex-app-server").autoLoadsInstructions).toBe("AGENTS.md");
68
+ });
69
+
70
+ it("every runtime declares every feature key (exhaustiveness guard)", () => {
71
+ const runtimes = listRuntimeCatalog();
72
+ const expectedKeys: Array<keyof ReturnType<typeof getRuntimeFeatures>> = [
73
+ "hasNativeSkills",
74
+ "hasProgressiveDisclosure",
75
+ "hasFilesystemTools",
76
+ "hasBash",
77
+ "hasTodoWrite",
78
+ "hasSubagentDelegation",
79
+ "hasHooks",
80
+ "autoLoadsInstructions",
81
+ "stagentInjectsSkills",
82
+ "supportsSkillComposition",
83
+ "maxActiveSkills",
84
+ ];
85
+
86
+ // Guard against the "list grows stale" failure mode: if a new key is added
87
+ // to RuntimeFeatures but not to expectedKeys above, this catches it.
88
+ expect(expectedKeys.length).toBe(Object.keys(getRuntimeFeatures()).length);
89
+
90
+ for (const runtime of runtimes) {
91
+ for (const key of expectedKeys) {
92
+ expect(
93
+ runtime.features,
94
+ `${runtime.id} missing feature "${key}"`
95
+ ).toHaveProperty(key);
96
+ }
97
+ }
98
+ });
99
+
100
+ it("feature matrix snapshot matches declared values", () => {
101
+ // Guard against silent regressions: the declared feature matrix must match
102
+ // this snapshot exactly. Update intentionally when flipping a capability flag
103
+ // (and reference the spec change in the commit message).
104
+ const snapshot = listRuntimeCatalog().reduce<Record<string, unknown>>((acc, r) => {
105
+ acc[r.id] = r.features;
106
+ return acc;
107
+ }, {});
108
+
109
+ expect(snapshot).toMatchInlineSnapshot(`
110
+ {
111
+ "anthropic-direct": {
112
+ "autoLoadsInstructions": null,
113
+ "hasBash": false,
114
+ "hasFilesystemTools": false,
115
+ "hasHooks": false,
116
+ "hasNativeSkills": false,
117
+ "hasProgressiveDisclosure": false,
118
+ "hasSubagentDelegation": false,
119
+ "hasTodoWrite": false,
120
+ "maxActiveSkills": 3,
121
+ "stagentInjectsSkills": false,
122
+ "supportsSkillComposition": true,
123
+ },
124
+ "claude-code": {
125
+ "autoLoadsInstructions": "CLAUDE.md",
126
+ "hasBash": true,
127
+ "hasFilesystemTools": true,
128
+ "hasHooks": false,
129
+ "hasNativeSkills": true,
130
+ "hasProgressiveDisclosure": true,
131
+ "hasSubagentDelegation": false,
132
+ "hasTodoWrite": true,
133
+ "maxActiveSkills": 3,
134
+ "stagentInjectsSkills": false,
135
+ "supportsSkillComposition": true,
136
+ },
137
+ "ollama": {
138
+ "autoLoadsInstructions": null,
139
+ "hasBash": false,
140
+ "hasFilesystemTools": false,
141
+ "hasHooks": false,
142
+ "hasNativeSkills": false,
143
+ "hasProgressiveDisclosure": false,
144
+ "hasSubagentDelegation": false,
145
+ "hasTodoWrite": false,
146
+ "maxActiveSkills": 1,
147
+ "stagentInjectsSkills": true,
148
+ "supportsSkillComposition": false,
149
+ },
150
+ "openai-codex-app-server": {
151
+ "autoLoadsInstructions": "AGENTS.md",
152
+ "hasBash": true,
153
+ "hasFilesystemTools": true,
154
+ "hasHooks": false,
155
+ "hasNativeSkills": true,
156
+ "hasProgressiveDisclosure": true,
157
+ "hasSubagentDelegation": false,
158
+ "hasTodoWrite": true,
159
+ "maxActiveSkills": 3,
160
+ "stagentInjectsSkills": false,
161
+ "supportsSkillComposition": true,
162
+ },
163
+ "openai-direct": {
164
+ "autoLoadsInstructions": null,
165
+ "hasBash": false,
166
+ "hasFilesystemTools": false,
167
+ "hasHooks": false,
168
+ "hasNativeSkills": false,
169
+ "hasProgressiveDisclosure": false,
170
+ "hasSubagentDelegation": false,
171
+ "hasTodoWrite": false,
172
+ "maxActiveSkills": 3,
173
+ "stagentInjectsSkills": false,
174
+ "supportsSkillComposition": true,
175
+ },
176
+ }
177
+ `);
178
+ });
49
179
  });
@@ -0,0 +1,183 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { AgentRuntimeId } from "@/lib/agents/runtime/catalog";
3
+
4
+ const {
5
+ mockGetRuntimeSetupStates,
6
+ mockListConfiguredRuntimeIds,
7
+ mockGetRoutingPreference,
8
+ mockTestRuntimeConnection,
9
+ mockGetProfile,
10
+ mockProfileSupportsRuntime,
11
+ mockSuggestRuntime,
12
+ } = vi.hoisted(() => ({
13
+ mockGetRuntimeSetupStates: vi.fn(),
14
+ mockListConfiguredRuntimeIds: vi.fn(),
15
+ mockGetRoutingPreference: vi.fn(),
16
+ mockTestRuntimeConnection: vi.fn(),
17
+ mockGetProfile: vi.fn(),
18
+ mockProfileSupportsRuntime: vi.fn(),
19
+ mockSuggestRuntime: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@/lib/settings/runtime-setup", () => ({
23
+ getRuntimeSetupStates: mockGetRuntimeSetupStates,
24
+ listConfiguredRuntimeIds: mockListConfiguredRuntimeIds,
25
+ }));
26
+
27
+ vi.mock("@/lib/settings/routing", () => ({
28
+ getRoutingPreference: mockGetRoutingPreference,
29
+ }));
30
+
31
+ vi.mock("@/lib/agents/runtime/index", () => ({
32
+ testRuntimeConnection: mockTestRuntimeConnection,
33
+ }));
34
+
35
+ vi.mock("@/lib/agents/profiles/registry", () => ({
36
+ getProfile: mockGetProfile,
37
+ }));
38
+
39
+ vi.mock("@/lib/agents/profiles/compatibility", () => ({
40
+ profileSupportsRuntime: mockProfileSupportsRuntime,
41
+ }));
42
+
43
+ vi.mock("@/lib/agents/router", () => ({
44
+ suggestRuntime: mockSuggestRuntime,
45
+ }));
46
+
47
+ import {
48
+ RequestedModelUnavailableError,
49
+ resolveChatExecutionTarget,
50
+ resolveResumeExecutionTarget,
51
+ resolveTaskExecutionTarget,
52
+ } from "../execution-target";
53
+
54
+ function makeStates(configured: AgentRuntimeId[]) {
55
+ const all: AgentRuntimeId[] = [
56
+ "claude-code",
57
+ "openai-codex-app-server",
58
+ "anthropic-direct",
59
+ "openai-direct",
60
+ "ollama",
61
+ ];
62
+
63
+ return Object.fromEntries(
64
+ all.map((runtimeId) => [
65
+ runtimeId,
66
+ {
67
+ runtimeId,
68
+ configured: configured.includes(runtimeId),
69
+ },
70
+ ])
71
+ );
72
+ }
73
+
74
+ describe("execution target resolver", () => {
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ mockGetRuntimeSetupStates.mockResolvedValue(
78
+ makeStates(["claude-code", "openai-codex-app-server"])
79
+ );
80
+ mockListConfiguredRuntimeIds.mockReturnValue([
81
+ "claude-code",
82
+ "openai-codex-app-server",
83
+ ]);
84
+ mockGetRoutingPreference.mockResolvedValue("latency");
85
+ mockProfileSupportsRuntime.mockReturnValue(true);
86
+ mockSuggestRuntime.mockImplementation(
87
+ (
88
+ _title: string,
89
+ _description: string | undefined,
90
+ _profileId: string | undefined,
91
+ availableRuntimeIds: AgentRuntimeId[]
92
+ ) => ({
93
+ runtimeId: availableRuntimeIds[0],
94
+ reason: "test",
95
+ })
96
+ );
97
+ mockTestRuntimeConnection.mockImplementation((runtimeId: AgentRuntimeId) => {
98
+ if (runtimeId === "claude-code") {
99
+ return Promise.resolve({
100
+ connected: false,
101
+ error: "Claude Code process exited with code 1",
102
+ });
103
+ }
104
+ return Promise.resolve({ connected: true });
105
+ });
106
+ });
107
+
108
+ it("falls back from an unavailable requested task runtime to a compatible alternate", async () => {
109
+ mockGetProfile.mockReturnValue({
110
+ id: "upgrade-assistant",
111
+ allowedTools: ["Bash(git status)", "Read", "Write"],
112
+ });
113
+
114
+ const target = await resolveTaskExecutionTarget({
115
+ title: "Upgrade local branch",
116
+ description: "Merge upstream main safely",
117
+ requestedRuntimeId: "claude-code",
118
+ profileId: "upgrade-assistant",
119
+ });
120
+
121
+ expect(target.requestedRuntimeId).toBe("claude-code");
122
+ expect(target.effectiveRuntimeId).toBe("openai-codex-app-server");
123
+ expect(target.fallbackApplied).toBe(true);
124
+ expect(target.fallbackReason).toContain("Fell back to OpenAI Codex App Server");
125
+ });
126
+
127
+ it("auto-selects a healthy runtime when no task runtime was requested", async () => {
128
+ mockGetProfile.mockReturnValue({
129
+ id: "general",
130
+ allowedTools: [],
131
+ preferredRuntime: "anthropic-direct",
132
+ });
133
+ mockSuggestRuntime.mockReturnValue({
134
+ runtimeId: "openai-codex-app-server",
135
+ reason: "test",
136
+ });
137
+
138
+ const target = await resolveTaskExecutionTarget({
139
+ title: "Fix failing build",
140
+ description: "Debug and patch the repo",
141
+ profileId: "general",
142
+ });
143
+
144
+ expect(target.requestedRuntimeId).toBeNull();
145
+ expect(target.effectiveRuntimeId).toBe("openai-codex-app-server");
146
+ expect(target.fallbackApplied).toBe(false);
147
+ });
148
+
149
+ it("falls back chat turns to the mapped alternate model when the requested runtime is unavailable", async () => {
150
+ const target = await resolveChatExecutionTarget({
151
+ requestedRuntimeId: "claude-code",
152
+ requestedModelId: "sonnet",
153
+ });
154
+
155
+ expect(target.requestedRuntimeId).toBe("claude-code");
156
+ expect(target.effectiveRuntimeId).toBe("openai-codex-app-server");
157
+ expect(target.effectiveModelId).toBe("gpt-5.3-codex");
158
+ expect(target.fallbackApplied).toBe(true);
159
+ });
160
+
161
+ it("refuses resume when the last effective runtime is unavailable", async () => {
162
+ await expect(
163
+ resolveResumeExecutionTarget({
164
+ requestedRuntimeId: "claude-code",
165
+ effectiveRuntimeId: "claude-code",
166
+ })
167
+ ).rejects.toThrow("Claude Code process exited with code 1");
168
+ });
169
+
170
+ it("throws a named error when no chat runtime is healthy", async () => {
171
+ mockTestRuntimeConnection.mockResolvedValue({
172
+ connected: false,
173
+ error: "all down",
174
+ });
175
+
176
+ await expect(
177
+ resolveChatExecutionTarget({
178
+ requestedRuntimeId: "claude-code",
179
+ requestedModelId: "sonnet",
180
+ })
181
+ ).rejects.toBeInstanceOf(RequestedModelUnavailableError);
182
+ });
183
+ });
@@ -481,6 +481,14 @@ async function executeAnthropicDirectTask(taskId: string, isResume = false): Pro
481
481
  startedAt: usageState.startedAt,
482
482
  finishedAt: new Date(),
483
483
  });
484
+
485
+ await db
486
+ .update(tasks)
487
+ .set({
488
+ effectiveModelId: result.totalUsage.modelId ?? modelId,
489
+ updatedAt: new Date(),
490
+ })
491
+ .where(eq(tasks.id, taskId));
484
492
  } catch (err) {
485
493
  if (!abortController.signal.aborted) {
486
494
  const errorMsg = err instanceof Error ? err.message : String(err);