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
@@ -21,6 +21,51 @@ export interface RuntimeCapabilities {
21
21
  authHealthCheck: boolean;
22
22
  }
23
23
 
24
+ /**
25
+ * LLM-surface features that affect what the model sees and which tools/skills
26
+ * Stagent exposes to it. Distinct from RuntimeCapabilities above, which is
27
+ * adapter-plumbing concerns (can the adapter resume/cancel/etc.).
28
+ *
29
+ * Values reflect post-Phase-1 capability (what the runtime SDK *can* do),
30
+ * not current engagement (what `engine.ts` currently activates). Downstream
31
+ * features read this bag to decide rendering, filtering, and dispatch.
32
+ */
33
+ export interface RuntimeFeatures {
34
+ /** SDK provides a native skill-invocation tool (e.g. Claude SDK `Skill` tool). */
35
+ hasNativeSkills: boolean;
36
+ /** SDK loads skill metadata first, full SKILL.md on demand. */
37
+ hasProgressiveDisclosure: boolean;
38
+ /** Read/Grep/Glob/Edit/Write available as LLM tools. */
39
+ hasFilesystemTools: boolean;
40
+ /** Bash tool available (Stagent gates via permission bridge). */
41
+ hasBash: boolean;
42
+ /** TodoWrite tool available. */
43
+ hasTodoWrite: boolean;
44
+ /** Runtime supports delegating to sub-agents (e.g. Task tool). */
45
+ hasSubagentDelegation: boolean;
46
+ /** Runtime loads filesystem hooks (pre/post tool-use shell scripts). */
47
+ hasHooks: boolean;
48
+ /** Which project-level instructions file the runtime auto-loads, if any. */
49
+ autoLoadsInstructions: "CLAUDE.md" | "AGENTS.md" | null;
50
+ /**
51
+ * Runtime has no native skill support — Stagent must inject SKILL.md content
52
+ * into the system prompt to expose skills to the LLM.
53
+ */
54
+ stagentInjectsSkills: boolean;
55
+ /**
56
+ * Runtime supports composing multiple active skills in one conversation.
57
+ * When false, only one skill may be active at a time (Ollama: context
58
+ * budget too tight). When true, `activate_skill mode:"add"` is allowed
59
+ * up to `maxActiveSkills`.
60
+ */
61
+ supportsSkillComposition: boolean;
62
+ /**
63
+ * Maximum number of skills that may be simultaneously active. Enforced
64
+ * by the activate_skill tool. Ignored when supportsSkillComposition=false.
65
+ */
66
+ maxActiveSkills: number;
67
+ }
68
+
24
69
  export interface RuntimeModelConfig {
25
70
  /** Default model ID for this runtime */
26
71
  default: string;
@@ -34,6 +79,7 @@ export interface RuntimeCatalogEntry {
34
79
  description: string;
35
80
  providerId: "anthropic" | "openai" | "ollama";
36
81
  capabilities: RuntimeCapabilities;
82
+ features: RuntimeFeatures;
37
83
  /** Model catalog — default and supported model IDs for this runtime */
38
84
  models: RuntimeModelConfig;
39
85
  }
@@ -54,6 +100,19 @@ const RUNTIME_CATALOG: Record<AgentRuntimeId, RuntimeCatalogEntry> = {
54
100
  profileAssist: true,
55
101
  authHealthCheck: true,
56
102
  },
103
+ features: {
104
+ hasNativeSkills: true,
105
+ hasProgressiveDisclosure: true,
106
+ hasFilesystemTools: true,
107
+ hasBash: true,
108
+ hasTodoWrite: true,
109
+ hasSubagentDelegation: false, // Stagent task primitives replace SDK Task tool
110
+ hasHooks: false, // excluded per Q2
111
+ autoLoadsInstructions: "CLAUDE.md",
112
+ stagentInjectsSkills: false,
113
+ supportsSkillComposition: true,
114
+ maxActiveSkills: 3,
115
+ },
57
116
  models: {
58
117
  default: "sonnet",
59
118
  supported: ["haiku", "sonnet", "opus"],
@@ -74,6 +133,19 @@ const RUNTIME_CATALOG: Record<AgentRuntimeId, RuntimeCatalogEntry> = {
74
133
  profileAssist: false,
75
134
  authHealthCheck: true,
76
135
  },
136
+ features: {
137
+ hasNativeSkills: true,
138
+ hasProgressiveDisclosure: true,
139
+ hasFilesystemTools: true,
140
+ hasBash: true,
141
+ hasTodoWrite: true,
142
+ hasSubagentDelegation: false,
143
+ hasHooks: false,
144
+ autoLoadsInstructions: "AGENTS.md",
145
+ stagentInjectsSkills: false,
146
+ supportsSkillComposition: true,
147
+ maxActiveSkills: 3,
148
+ },
77
149
  models: {
78
150
  default: "gpt-5.4",
79
151
  supported: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"],
@@ -94,6 +166,21 @@ const RUNTIME_CATALOG: Record<AgentRuntimeId, RuntimeCatalogEntry> = {
94
166
  profileAssist: true,
95
167
  authHealthCheck: true,
96
168
  },
169
+ features: {
170
+ // Direct Messages API — no SDK-native skill machinery.
171
+ // Revisit when chat-claude-sdk-skills designs direct-API skill injection.
172
+ hasNativeSkills: false,
173
+ hasProgressiveDisclosure: false,
174
+ hasFilesystemTools: false,
175
+ hasBash: false,
176
+ hasTodoWrite: false,
177
+ hasSubagentDelegation: false,
178
+ hasHooks: false,
179
+ autoLoadsInstructions: null,
180
+ stagentInjectsSkills: false,
181
+ supportsSkillComposition: true,
182
+ maxActiveSkills: 3,
183
+ },
97
184
  models: {
98
185
  default: "claude-sonnet-4-20250514",
99
186
  supported: ["claude-haiku-4-5-20251001", "claude-sonnet-4-20250514", "claude-opus-4-20250514"],
@@ -114,6 +201,21 @@ const RUNTIME_CATALOG: Record<AgentRuntimeId, RuntimeCatalogEntry> = {
114
201
  profileAssist: false,
115
202
  authHealthCheck: true,
116
203
  },
204
+ features: {
205
+ // Direct Responses API — no SDK-native skill machinery.
206
+ // Revisit when chat-claude-sdk-skills designs direct-API skill injection.
207
+ hasNativeSkills: false,
208
+ hasProgressiveDisclosure: false,
209
+ hasFilesystemTools: false,
210
+ hasBash: false,
211
+ hasTodoWrite: false,
212
+ hasSubagentDelegation: false,
213
+ hasHooks: false,
214
+ autoLoadsInstructions: null,
215
+ stagentInjectsSkills: false,
216
+ supportsSkillComposition: true,
217
+ maxActiveSkills: 3,
218
+ },
117
219
  models: {
118
220
  default: "gpt-4.1",
119
221
  supported: ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"],
@@ -134,6 +236,19 @@ const RUNTIME_CATALOG: Record<AgentRuntimeId, RuntimeCatalogEntry> = {
134
236
  profileAssist: false,
135
237
  authHealthCheck: true,
136
238
  },
239
+ features: {
240
+ hasNativeSkills: false,
241
+ hasProgressiveDisclosure: false,
242
+ hasFilesystemTools: false,
243
+ hasBash: false,
244
+ hasTodoWrite: false, // Stagent MCP exposes todo tools separately
245
+ hasSubagentDelegation: false,
246
+ hasHooks: false,
247
+ autoLoadsInstructions: null,
248
+ stagentInjectsSkills: true,
249
+ supportsSkillComposition: false,
250
+ maxActiveSkills: 1,
251
+ },
137
252
  models: {
138
253
  default: "llama3",
139
254
  supported: [], // Dynamic — populated from Ollama API at runtime
@@ -157,6 +272,12 @@ export function getRuntimeCapabilities(
157
272
  return getRuntimeCatalogEntry(runtimeId).capabilities;
158
273
  }
159
274
 
275
+ export function getRuntimeFeatures(
276
+ runtimeId: AgentRuntimeId = DEFAULT_AGENT_RUNTIME
277
+ ): RuntimeFeatures {
278
+ return getRuntimeCatalogEntry(runtimeId).features;
279
+ }
280
+
160
281
  export function resolveAgentRuntime(runtimeId?: string | null): AgentRuntimeId {
161
282
  if (!runtimeId) return DEFAULT_AGENT_RUNTIME;
162
283
  if (isAgentRuntimeId(runtimeId)) return runtimeId;
@@ -1,3 +1,35 @@
1
+ // ─── Claude Agent SDK options shared by chat and task runtimes ──────
2
+ //
3
+ // Chat (src/lib/chat/engine.ts) and task (src/lib/agents/claude-agent.ts)
4
+ // both construct query() options for the `claude-code` runtime. These
5
+ // constants are the single source of truth so the two code paths cannot
6
+ // drift — a drift that would manifest as "skills work in chat but vanish
7
+ // in tasks on the same project." See features/task-runtime-skill-parity.md
8
+ // and features/chat-claude-sdk-skills.md.
9
+
10
+ export const CLAUDE_SDK_SETTING_SOURCES = ["user", "project"] as const;
11
+
12
+ export const CLAUDE_SDK_ALLOWED_TOOLS = [
13
+ "Skill",
14
+ "Read",
15
+ "Grep",
16
+ "Glob",
17
+ "Edit",
18
+ "Write",
19
+ "Bash",
20
+ "TodoWrite",
21
+ ] as const;
22
+
23
+ /**
24
+ * Filesystem tools safe to auto-allow without a permission prompt.
25
+ * Mirrors the existing browser/exa read-only auto-allow pattern.
26
+ */
27
+ export const CLAUDE_SDK_READ_ONLY_FS_TOOLS = new Set<string>([
28
+ "Read",
29
+ "Grep",
30
+ "Glob",
31
+ ]);
32
+
1
33
  /**
2
34
  * Build the environment for the Claude Agent SDK subprocess.
3
35
  *
@@ -0,0 +1,456 @@
1
+ import { getProfile } from "@/lib/agents/profiles/registry";
2
+ import { profileSupportsRuntime } from "@/lib/agents/profiles/compatibility";
3
+ import { suggestRuntime } from "@/lib/agents/router";
4
+ import {
5
+ DEFAULT_AGENT_RUNTIME,
6
+ getRuntimeCatalogEntry,
7
+ getRuntimeFeatures,
8
+ resolveAgentRuntime,
9
+ type AgentRuntimeId,
10
+ } from "./catalog";
11
+ import { testRuntimeConnection } from "./index";
12
+ import { getRoutingPreference } from "@/lib/settings/routing";
13
+ import { getRuntimeSetupStates, listConfiguredRuntimeIds } from "@/lib/settings/runtime-setup";
14
+ import { DEFAULT_CHAT_MODEL, getRuntimeForModel } from "@/lib/chat/types";
15
+
16
+ const FILESYSTEM_TOOL_NAMES = new Set([
17
+ "Read",
18
+ "Write",
19
+ "Edit",
20
+ "MultiEdit",
21
+ "Grep",
22
+ "Glob",
23
+ ]);
24
+
25
+ const CHAT_MODEL_FALLBACKS: Record<string, string[]> = {
26
+ haiku: ["gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.4"],
27
+ sonnet: ["gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"],
28
+ opus: ["gpt-5.4", "gpt-5.3-codex", "gpt-5.4-mini"],
29
+ "gpt-5.4-mini": ["haiku", "sonnet", "opus"],
30
+ "gpt-5.3-codex": ["sonnet", "haiku", "opus"],
31
+ "gpt-5.4": ["opus", "sonnet", "haiku"],
32
+ };
33
+
34
+ export class RuntimeUnavailableError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "RuntimeUnavailableError";
38
+ }
39
+ }
40
+
41
+ export class RequestedModelUnavailableError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = "RequestedModelUnavailableError";
45
+ }
46
+ }
47
+
48
+ export class NoCompatibleRuntimeError extends Error {
49
+ constructor(message: string) {
50
+ super(message);
51
+ this.name = "NoCompatibleRuntimeError";
52
+ }
53
+ }
54
+
55
+ export interface ResolvedExecutionTarget {
56
+ requestedRuntimeId: AgentRuntimeId | null;
57
+ effectiveRuntimeId: AgentRuntimeId;
58
+ requestedModelId: string | null;
59
+ effectiveModelId: string | null;
60
+ fallbackApplied: boolean;
61
+ fallbackReason: string | null;
62
+ }
63
+
64
+ type RuntimeRequirements = {
65
+ requiresBash: boolean;
66
+ requiresFilesystem: boolean;
67
+ };
68
+
69
+ type RuntimeAvailability = {
70
+ available: boolean;
71
+ reason: string | null;
72
+ };
73
+
74
+ function unique<T>(values: T[]): T[] {
75
+ return Array.from(new Set(values));
76
+ }
77
+
78
+ function getRuntimeLabel(runtimeId: AgentRuntimeId): string {
79
+ return getRuntimeCatalogEntry(runtimeId).label;
80
+ }
81
+
82
+ function detectRuntimeRequirements(profileId?: string | null): RuntimeRequirements {
83
+ const profile = profileId ? getProfile(profileId) : undefined;
84
+ const allowedTools = profile?.allowedTools ?? [];
85
+
86
+ const requiresBash = allowedTools.some(
87
+ (tool) => tool === "Bash" || tool.startsWith("Bash(")
88
+ );
89
+ const requiresFilesystem =
90
+ requiresBash ||
91
+ allowedTools.some((tool) => FILESYSTEM_TOOL_NAMES.has(tool));
92
+
93
+ return { requiresBash, requiresFilesystem };
94
+ }
95
+
96
+ function runtimeMeetsRequirements(
97
+ runtimeId: AgentRuntimeId,
98
+ requirements: RuntimeRequirements
99
+ ): boolean {
100
+ const features = getRuntimeFeatures(runtimeId);
101
+ if (requirements.requiresBash && !features.hasBash) {
102
+ return false;
103
+ }
104
+ if (requirements.requiresFilesystem && !features.hasFilesystemTools) {
105
+ return false;
106
+ }
107
+ return true;
108
+ }
109
+
110
+ function filterCompatibleRuntimes(
111
+ runtimeIds: AgentRuntimeId[],
112
+ profileId?: string | null
113
+ ): AgentRuntimeId[] {
114
+ if (!profileId) {
115
+ return runtimeIds;
116
+ }
117
+
118
+ const profile = getProfile(profileId);
119
+ if (!profile) {
120
+ return [];
121
+ }
122
+
123
+ return runtimeIds.filter((runtimeId) =>
124
+ profileSupportsRuntime(profile, runtimeId)
125
+ );
126
+ }
127
+
128
+ async function checkRuntimeAvailability(
129
+ runtimeId: AgentRuntimeId
130
+ ): Promise<RuntimeAvailability> {
131
+ const states = await getRuntimeSetupStates();
132
+ if (!states[runtimeId]?.configured) {
133
+ return {
134
+ available: false,
135
+ reason: `${getRuntimeLabel(runtimeId)} is not configured`,
136
+ };
137
+ }
138
+
139
+ try {
140
+ const connection = await testRuntimeConnection(runtimeId);
141
+ if (connection.connected) {
142
+ return { available: true, reason: null };
143
+ }
144
+ return {
145
+ available: false,
146
+ reason:
147
+ connection.error ??
148
+ `${getRuntimeLabel(runtimeId)} is unavailable`,
149
+ };
150
+ } catch (error) {
151
+ return {
152
+ available: false,
153
+ reason: error instanceof Error ? error.message : String(error),
154
+ };
155
+ }
156
+ }
157
+
158
+ async function getConfiguredCandidateRuntimes(
159
+ profileId?: string | null
160
+ ): Promise<AgentRuntimeId[]> {
161
+ const states = await getRuntimeSetupStates();
162
+ return filterCompatibleRuntimes(
163
+ listConfiguredRuntimeIds(states) as AgentRuntimeId[],
164
+ profileId
165
+ );
166
+ }
167
+
168
+ function buildTaskFallbackOrder(input: {
169
+ title: string;
170
+ description?: string | null;
171
+ profileId?: string | null;
172
+ requestedRuntimeId: AgentRuntimeId | null;
173
+ compatibleRuntimeIds: AgentRuntimeId[];
174
+ }): AgentRuntimeId[] {
175
+ const alternates = input.compatibleRuntimeIds.filter(
176
+ (runtimeId) => runtimeId !== input.requestedRuntimeId
177
+ );
178
+ if (alternates.length === 0) {
179
+ return [];
180
+ }
181
+
182
+ const preferred = suggestRuntime(
183
+ input.title,
184
+ input.description,
185
+ input.profileId,
186
+ alternates,
187
+ "quality"
188
+ ).runtimeId;
189
+
190
+ return unique([
191
+ preferred,
192
+ ...alternates.filter(
193
+ (runtimeId) =>
194
+ input.requestedRuntimeId != null &&
195
+ getRuntimeCatalogEntry(runtimeId).providerId ===
196
+ getRuntimeCatalogEntry(input.requestedRuntimeId).providerId
197
+ ),
198
+ ...alternates,
199
+ ]);
200
+ }
201
+
202
+ function buildRuntimeFallbackReason(input: {
203
+ requestedRuntimeId: AgentRuntimeId | null;
204
+ effectiveRuntimeId: AgentRuntimeId;
205
+ unavailableReason: string | null;
206
+ }): string | null {
207
+ if (!input.requestedRuntimeId) {
208
+ return null;
209
+ }
210
+
211
+ const requestedLabel = getRuntimeLabel(input.requestedRuntimeId);
212
+ const effectiveLabel = getRuntimeLabel(input.effectiveRuntimeId);
213
+ const reason = input.unavailableReason ?? `${requestedLabel} is unavailable`;
214
+ return `${reason}. Fell back to ${effectiveLabel}.`;
215
+ }
216
+
217
+ export async function resolveTaskExecutionTarget(input: {
218
+ title: string;
219
+ description?: string | null;
220
+ requestedRuntimeId?: string | null;
221
+ profileId?: string | null;
222
+ unavailableRuntimeIds?: string[];
223
+ unavailableReasons?: Record<string, string>;
224
+ }): Promise<ResolvedExecutionTarget> {
225
+ const requestedRuntimeId = input.requestedRuntimeId
226
+ ? resolveAgentRuntime(input.requestedRuntimeId)
227
+ : null;
228
+ const requirements = detectRuntimeRequirements(input.profileId);
229
+ const unavailableRuntimeIds = new Set(
230
+ (input.unavailableRuntimeIds ?? []).map((runtimeId) =>
231
+ resolveAgentRuntime(runtimeId)
232
+ )
233
+ );
234
+ const configuredCandidates = await getConfiguredCandidateRuntimes(input.profileId);
235
+ const compatibleCandidates = configuredCandidates.filter((runtimeId) =>
236
+ runtimeMeetsRequirements(runtimeId, requirements)
237
+ );
238
+ const launchableCandidates = compatibleCandidates.filter(
239
+ (runtimeId) => !unavailableRuntimeIds.has(runtimeId)
240
+ );
241
+
242
+ if (compatibleCandidates.length === 0) {
243
+ throw new NoCompatibleRuntimeError(
244
+ "No compatible configured runtime is available for this task."
245
+ );
246
+ }
247
+
248
+ if (requestedRuntimeId) {
249
+ if (
250
+ compatibleCandidates.includes(requestedRuntimeId) &&
251
+ !unavailableRuntimeIds.has(requestedRuntimeId) &&
252
+ (await checkRuntimeAvailability(requestedRuntimeId)).available
253
+ ) {
254
+ return {
255
+ requestedRuntimeId,
256
+ effectiveRuntimeId: requestedRuntimeId,
257
+ requestedModelId: null,
258
+ effectiveModelId: null,
259
+ fallbackApplied: false,
260
+ fallbackReason: null,
261
+ };
262
+ }
263
+
264
+ const availability = unavailableRuntimeIds.has(requestedRuntimeId)
265
+ ? {
266
+ available: false,
267
+ reason:
268
+ input.unavailableReasons?.[requestedRuntimeId] ??
269
+ `${getRuntimeLabel(requestedRuntimeId)} is temporarily unavailable`,
270
+ }
271
+ : compatibleCandidates.includes(requestedRuntimeId)
272
+ ? await checkRuntimeAvailability(requestedRuntimeId)
273
+ : {
274
+ available: false,
275
+ reason: `${getRuntimeLabel(requestedRuntimeId)} does not support this task/profile`,
276
+ };
277
+ const fallbackOrder = buildTaskFallbackOrder({
278
+ title: input.title,
279
+ description: input.description,
280
+ profileId: input.profileId,
281
+ requestedRuntimeId,
282
+ compatibleRuntimeIds: launchableCandidates,
283
+ });
284
+
285
+ for (const candidate of fallbackOrder) {
286
+ const candidateAvailability = await checkRuntimeAvailability(candidate);
287
+ if (candidateAvailability.available) {
288
+ return {
289
+ requestedRuntimeId,
290
+ effectiveRuntimeId: candidate,
291
+ requestedModelId: null,
292
+ effectiveModelId: null,
293
+ fallbackApplied: true,
294
+ fallbackReason: buildRuntimeFallbackReason({
295
+ requestedRuntimeId,
296
+ effectiveRuntimeId: candidate,
297
+ unavailableReason: availability.reason,
298
+ }),
299
+ };
300
+ }
301
+ }
302
+
303
+ throw new NoCompatibleRuntimeError(
304
+ availability.reason ??
305
+ `No healthy alternate runtime is available for ${getRuntimeLabel(requestedRuntimeId)}.`
306
+ );
307
+ }
308
+
309
+ const routingPreference = await getRoutingPreference();
310
+ const suggested = suggestRuntime(
311
+ input.title,
312
+ input.description,
313
+ input.profileId,
314
+ launchableCandidates,
315
+ routingPreference
316
+ ).runtimeId;
317
+ const autoOrder = unique([
318
+ suggested,
319
+ ...launchableCandidates,
320
+ ]);
321
+
322
+ for (const candidate of autoOrder) {
323
+ const availability = await checkRuntimeAvailability(candidate);
324
+ if (availability.available) {
325
+ return {
326
+ requestedRuntimeId: null,
327
+ effectiveRuntimeId: candidate,
328
+ requestedModelId: null,
329
+ effectiveModelId: null,
330
+ fallbackApplied: false,
331
+ fallbackReason: null,
332
+ };
333
+ }
334
+ }
335
+
336
+ throw new RuntimeUnavailableError(
337
+ "No healthy runtime is currently available to execute this task."
338
+ );
339
+ }
340
+
341
+ export async function resolveResumeExecutionTarget(input: {
342
+ requestedRuntimeId?: string | null;
343
+ effectiveRuntimeId?: string | null;
344
+ }): Promise<ResolvedExecutionTarget> {
345
+ const requestedRuntimeId = input.requestedRuntimeId
346
+ ? resolveAgentRuntime(input.requestedRuntimeId)
347
+ : null;
348
+ const resumeRuntimeId = input.effectiveRuntimeId
349
+ ? resolveAgentRuntime(input.effectiveRuntimeId)
350
+ : requestedRuntimeId ?? DEFAULT_AGENT_RUNTIME;
351
+ const availability = await checkRuntimeAvailability(resumeRuntimeId);
352
+
353
+ if (!availability.available) {
354
+ throw new RuntimeUnavailableError(
355
+ availability.reason ??
356
+ `${getRuntimeLabel(resumeRuntimeId)} is unavailable for resume. Use Retry for a fresh execution.`
357
+ );
358
+ }
359
+
360
+ return {
361
+ requestedRuntimeId,
362
+ effectiveRuntimeId: resumeRuntimeId,
363
+ requestedModelId: null,
364
+ effectiveModelId: null,
365
+ fallbackApplied: false,
366
+ fallbackReason: null,
367
+ };
368
+ }
369
+
370
+ function buildChatFallbackOrder(requestedModelId: string): string[] {
371
+ const fallbacks = CHAT_MODEL_FALLBACKS[requestedModelId] ?? [];
372
+ return unique([requestedModelId, ...fallbacks]);
373
+ }
374
+
375
+ function buildChatFallbackReason(input: {
376
+ requestedRuntimeId: AgentRuntimeId;
377
+ effectiveRuntimeId: AgentRuntimeId;
378
+ requestedModelId: string;
379
+ effectiveModelId: string;
380
+ unavailableReason: string | null;
381
+ }): string | null {
382
+ if (
383
+ input.requestedRuntimeId === input.effectiveRuntimeId &&
384
+ input.requestedModelId === input.effectiveModelId
385
+ ) {
386
+ return null;
387
+ }
388
+
389
+ const requestedLabel = `${input.requestedModelId} on ${getRuntimeLabel(input.requestedRuntimeId)}`;
390
+ const effectiveLabel = `${input.effectiveModelId} on ${getRuntimeLabel(input.effectiveRuntimeId)}`;
391
+ const reason = input.unavailableReason ?? `${requestedLabel} is unavailable`;
392
+ return `${reason}. Using ${effectiveLabel} for this turn.`;
393
+ }
394
+
395
+ export async function resolveChatExecutionTarget(input: {
396
+ requestedRuntimeId?: string | null;
397
+ requestedModelId?: string | null;
398
+ }): Promise<ResolvedExecutionTarget> {
399
+ const requestedModelId =
400
+ input.requestedModelId ??
401
+ (input.requestedRuntimeId
402
+ ? getRuntimeCatalogEntry(resolveAgentRuntime(input.requestedRuntimeId)).models.default
403
+ : DEFAULT_CHAT_MODEL);
404
+ const requestedRuntimeId = resolveAgentRuntime(
405
+ input.requestedRuntimeId ?? getRuntimeForModel(requestedModelId)
406
+ );
407
+
408
+ const modelOrder = buildChatFallbackOrder(requestedModelId);
409
+ let requestedAvailability: RuntimeAvailability | null = null;
410
+
411
+ for (const candidateModelId of modelOrder) {
412
+ const candidateRuntimeId = resolveAgentRuntime(
413
+ getRuntimeForModel(candidateModelId)
414
+ );
415
+ if (
416
+ candidateRuntimeId !== "claude-code" &&
417
+ candidateRuntimeId !== "openai-codex-app-server" &&
418
+ candidateRuntimeId !== "ollama"
419
+ ) {
420
+ continue;
421
+ }
422
+
423
+ const availability = await checkRuntimeAvailability(candidateRuntimeId);
424
+ if (
425
+ candidateRuntimeId === requestedRuntimeId &&
426
+ requestedAvailability === null
427
+ ) {
428
+ requestedAvailability = availability;
429
+ }
430
+ if (!availability.available) {
431
+ continue;
432
+ }
433
+
434
+ return {
435
+ requestedRuntimeId,
436
+ effectiveRuntimeId: candidateRuntimeId,
437
+ requestedModelId,
438
+ effectiveModelId: candidateModelId,
439
+ fallbackApplied:
440
+ candidateRuntimeId !== requestedRuntimeId ||
441
+ candidateModelId !== requestedModelId,
442
+ fallbackReason: buildChatFallbackReason({
443
+ requestedRuntimeId,
444
+ effectiveRuntimeId: candidateRuntimeId,
445
+ requestedModelId,
446
+ effectiveModelId: candidateModelId,
447
+ unavailableReason: requestedAvailability?.reason ?? null,
448
+ }),
449
+ };
450
+ }
451
+
452
+ throw new RequestedModelUnavailableError(
453
+ requestedAvailability?.reason ??
454
+ `No healthy runtime is available for ${requestedModelId}.`
455
+ );
456
+ }
@@ -2,6 +2,7 @@ import {
2
2
  DEFAULT_AGENT_RUNTIME,
3
3
  getRuntimeCapabilities,
4
4
  getRuntimeCatalogEntry,
5
+ getRuntimeFeatures,
5
6
  listRuntimeCatalog,
6
7
  resolveAgentRuntime,
7
8
  type AgentRuntimeId,
@@ -187,3 +188,6 @@ export async function testRuntimeConnection(
187
188
  }
188
189
  return adapter.testConnection();
189
190
  }
191
+
192
+ export { getRuntimeFeatures };
193
+ export type { RuntimeFeatures } from "./catalog";