pi-ui-extend 1.0.41 → 1.0.45

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 (236) hide show
  1. package/README.md +50 -13
  2. package/dist/app/app.d.ts +13 -0
  3. package/dist/app/app.js +158 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +4 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +57 -35
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +15 -1
  17. package/dist/app/commands/command-session-actions.js +9 -3
  18. package/dist/app/commands/reload-context-inventory.d.ts +11 -0
  19. package/dist/app/commands/reload-context-inventory.js +70 -0
  20. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  21. package/dist/app/extensions/extension-actions-controller.js +6 -0
  22. package/dist/app/extensions/subagent-catalog-state.d.ts +9 -0
  23. package/dist/app/extensions/subagent-catalog-state.js +23 -0
  24. package/dist/app/input/autocomplete-controller.js +61 -34
  25. package/dist/app/input/input-action-controller.d.ts +2 -0
  26. package/dist/app/input/input-action-controller.js +8 -1
  27. package/dist/app/input/input-controller.d.ts +2 -1
  28. package/dist/app/input/input-controller.js +7 -2
  29. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  30. package/dist/app/input/voice-controller.d.ts +41 -45
  31. package/dist/app/input/voice-controller.js +351 -419
  32. package/dist/app/model/model-usage-controller.js +17 -7
  33. package/dist/app/model/model-usage-status.d.ts +4 -1
  34. package/dist/app/model/model-usage-status.js +270 -46
  35. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  36. package/dist/app/popup/menu-items-controller.js +37 -21
  37. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  38. package/dist/app/popup/popup-action-controller.js +77 -24
  39. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  40. package/dist/app/popup/popup-menu-controller.js +239 -69
  41. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  42. package/dist/app/rendering/dcp-stats.js +40 -425
  43. package/dist/app/rendering/editor-panels.js +10 -4
  44. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  45. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  46. package/dist/app/rendering/render-controller.js +23 -2
  47. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  48. package/dist/app/rendering/status-line-renderer.js +41 -5
  49. package/dist/app/rendering/tab-line-renderer.js +26 -20
  50. package/dist/app/runtime.d.ts +12 -1
  51. package/dist/app/runtime.js +120 -12
  52. package/dist/app/screen/mouse-controller.d.ts +2 -0
  53. package/dist/app/screen/mouse-controller.js +17 -7
  54. package/dist/app/screen/status-controller.d.ts +4 -0
  55. package/dist/app/screen/status-controller.js +5 -0
  56. package/dist/app/session/lazy-session-manager.js +34 -0
  57. package/dist/app/session/session-event-controller.d.ts +1 -0
  58. package/dist/app/session/session-event-controller.js +10 -1
  59. package/dist/app/session/session-history.d.ts +1 -0
  60. package/dist/app/session/session-history.js +12 -1
  61. package/dist/app/session/session-lifecycle-controller.d.ts +7 -1
  62. package/dist/app/session/session-lifecycle-controller.js +16 -1
  63. package/dist/app/session/tabs-controller.d.ts +26 -1
  64. package/dist/app/session/tabs-controller.js +454 -147
  65. package/dist/app/subagents/subagents-files.js +60 -1
  66. package/dist/app/subagents/subagents-model.d.ts +1 -0
  67. package/dist/app/subagents/subagents-model.js +18 -2
  68. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  69. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  70. package/dist/app/types.d.ts +16 -1
  71. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  72. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  73. package/dist/app/workspace/workspace-undo.js +1 -0
  74. package/dist/bundled-extensions/question/index.js +9 -1
  75. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  76. package/dist/bundled-extensions/question/remote.js +33 -0
  77. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  78. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  79. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  80. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  81. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  82. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  83. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  84. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  85. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  86. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  87. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  88. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  89. package/dist/config.d.ts +21 -2
  90. package/dist/config.js +159 -32
  91. package/dist/default-pix-config.js +25 -5
  92. package/dist/schemas/index.d.ts +1 -0
  93. package/dist/schemas/index.js +1 -0
  94. package/dist/schemas/pi-tools-suite-schema.d.ts +88 -62
  95. package/dist/schemas/pi-tools-suite-schema.js +54 -83
  96. package/dist/schemas/pix-schema.d.ts +19 -2
  97. package/dist/schemas/pix-schema.js +47 -5
  98. package/dist/schemas/tasks-schema.d.ts +18 -0
  99. package/dist/schemas/tasks-schema.js +41 -0
  100. package/docs/concurrency.md +9 -1
  101. package/docs/desktop-mvp.md +22 -6
  102. package/docs/desktop-task-manager.md +148 -84
  103. package/docs/release.md +30 -6
  104. package/external/pi-tools-suite/README.md +197 -84
  105. package/external/pi-tools-suite/docs/evals.md +1 -1
  106. package/external/pi-tools-suite/docs/session-recovery.md +47 -14
  107. package/external/pi-tools-suite/docs/subagent-model-pools.md +48 -30
  108. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  109. package/external/pi-tools-suite/package.json +6 -1
  110. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  111. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  112. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  113. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  114. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  115. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  116. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  117. package/external/pi-tools-suite/src/async-subagents/agents/frontier-review.md +23 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  119. package/external/pi-tools-suite/src/async-subagents/agents/presets.jsonc +16 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/research.md +5 -3
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  127. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  128. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  129. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  130. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  131. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  132. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  133. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  134. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  135. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  136. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  137. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  138. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  139. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  140. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  141. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  142. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  143. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  144. package/external/pi-tools-suite/src/async-subagents/commands.ts +15 -71
  145. package/external/pi-tools-suite/src/async-subagents/core/activity.ts +33 -0
  146. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +5 -4
  147. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  148. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +8 -5
  149. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  150. package/external/pi-tools-suite/src/async-subagents/core/config.ts +160 -327
  151. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  152. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  153. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +21 -5
  154. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +40 -26
  155. package/external/pi-tools-suite/src/async-subagents/core/state.ts +40 -0
  156. package/external/pi-tools-suite/src/async-subagents/core/types.ts +7 -0
  157. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  158. package/external/pi-tools-suite/src/async-subagents/index.ts +69 -5
  159. package/external/pi-tools-suite/src/async-subagents/lib.ts +16 -9
  160. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +8 -5
  161. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +5 -4
  162. package/external/pi-tools-suite/src/async-subagents/types.ts +1 -0
  163. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  164. package/external/pi-tools-suite/src/config.ts +55 -1
  165. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  166. package/external/pi-tools-suite/src/context-gateway/config.ts +77 -3
  167. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  168. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +222 -0
  169. package/external/pi-tools-suite/src/context-gateway/index.ts +206 -19
  170. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +7 -7
  171. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +101 -28
  172. package/external/pi-tools-suite/src/context-gateway/types.ts +16 -3
  173. package/external/pi-tools-suite/src/context-inventory.ts +99 -0
  174. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  175. package/external/pi-tools-suite/src/dcp/auto-compress.ts +128 -49
  176. package/external/pi-tools-suite/src/dcp/commands.ts +32 -156
  177. package/external/pi-tools-suite/src/dcp/compress-tool.ts +177 -46
  178. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +6 -58
  179. package/external/pi-tools-suite/src/dcp/compression-preview.ts +9 -0
  180. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  181. package/external/pi-tools-suite/src/dcp/config.ts +114 -17
  182. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  183. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  184. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  185. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  186. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  187. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  188. package/external/pi-tools-suite/src/dcp/prompts.ts +83 -192
  189. package/external/pi-tools-suite/src/dcp/protected-continuity.ts +175 -0
  190. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +93 -3
  191. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +6 -10
  192. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  193. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -3
  194. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  195. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  196. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  197. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  198. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  199. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +78 -22
  200. package/external/pi-tools-suite/src/index.ts +7 -1
  201. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  202. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  203. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  204. package/external/pi-tools-suite/src/resource-registry/index.ts +2551 -0
  205. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  206. package/external/pi-tools-suite/src/shell-command-policy.ts +219 -0
  207. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  208. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  209. package/external/pi-tools-suite/src/tool-descriptions.ts +47 -19
  210. package/package.json +10 -9
  211. package/schemas/pi-tools-suite.json +466 -287
  212. package/schemas/pix.json +129 -11
  213. package/schemas/tasks.json +131 -0
  214. package/skills/simplify/SKILL.md +33 -5
  215. package/docs/desktop-markdown-media.md +0 -77
  216. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -177
  217. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +0 -102
  218. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  219. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +0 -54
  220. package/external/pi-tools-suite/src/skill-installer/index.ts +0 -333
  221. package/skills/playwright-cli/SKILL.md +0 -420
  222. package/skills/playwright-cli/references/element-attributes.md +0 -23
  223. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  224. package/skills/playwright-cli/references/request-mocking.md +0 -87
  225. package/skills/playwright-cli/references/running-code.md +0 -241
  226. package/skills/playwright-cli/references/session-management.md +0 -273
  227. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  228. package/skills/playwright-cli/references/storage-state.md +0 -290
  229. package/skills/playwright-cli/references/test-generation.md +0 -142
  230. package/skills/playwright-cli/references/tracing.md +0 -154
  231. package/skills/playwright-cli/references/video-recording.md +0 -147
  232. package/skills/spec-lite/SKILL.md +0 -140
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  234. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  235. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  236. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -1,5 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { isBlindModelRef, SubagentModelSelectionError, type ResolvedAgentTaskConfig, type SubagentConfig } from "./config.js";
3
+ import { isUiQaType } from "./browser-qa.js";
3
4
  import { isSessionModelUnavailable } from "./model-fallback.js";
4
5
 
5
6
  export interface SubagentModelRegistry {
@@ -17,7 +18,7 @@ export async function selectAvailableAgentModels(
17
18
  signal?: AbortSignal,
18
19
  ): Promise<ResolvedAgentTaskConfig> {
19
20
  if (signal?.aborted) throw new Error("Aborted");
20
- const needsImages = Boolean(resolved.task.imagePaths?.length) || resolved.task.subagentType === "browser-qa";
21
+ const needsImages = Boolean(resolved.task.imagePaths?.length) || isUiQaType(resolved.task.subagentType);
21
22
  if (!registry && !needsImages) return resolved; // Narrow offline/test callers.
22
23
  const candidates = [resolved.task.model, ...resolved.fallbackModels].filter((ref): ref is string => Boolean(ref));
23
24
  let registeredAvailable: Set<string> | undefined;
@@ -4,6 +4,15 @@ import { getRunRoot, hasAgentPrompt, hasLaunchedAgentPrompt, isDir, resolveRunDi
4
4
 
5
5
  export const SUBAGENT_REGISTRY_FILE = "registry.json";
6
6
 
7
+ const REGISTRY_LOCK_RETRY_MS = 10;
8
+ const REGISTRY_LOCK_TIMEOUT_MS = 2_000;
9
+ const REGISTRY_LOCK_STALE_MS = 30_000;
10
+
11
+ interface RegistryLockOwner {
12
+ pid: number;
13
+ token: string;
14
+ }
15
+
7
16
  export interface SubagentRegistryRun {
8
17
  runId: string;
9
18
  runDir: string;
@@ -62,45 +71,44 @@ export function loadSubagentRegistry(cwd: string): SubagentRegistry {
62
71
 
63
72
  export function saveSubagentRegistry(cwd: string, registry: SubagentRegistry): void {
64
73
  const registryPath = getSubagentRegistryPath(cwd);
65
- fs.mkdirSync(path.dirname(registryPath), { recursive: true });
66
- fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, "utf-8");
74
+ withRegistryLock(registryPath, () => writeRegistryAtomically(registryPath, registry));
67
75
  }
68
76
 
69
77
  export function recordSubagentRun(cwd: string, runDir: string, agentIds: string[]): SubagentRegistry {
70
- const registry = loadSubagentRegistry(cwd);
71
- const resolvedRunDir = path.resolve(runDir);
72
- const runId = path.basename(resolvedRunDir);
73
- const now = new Date().toISOString();
74
- const uniqueAgentIds = [...new Set(agentIds.filter((id) => id.trim().length > 0))];
75
- const previous = registry.runs[runId];
76
- registry.runs[runId] = {
77
- runId,
78
- runDir: resolvedRunDir,
79
- agentIds: uniqueAgentIds,
80
- createdAt: previous?.createdAt ?? now,
81
- updatedAt: now,
82
- };
83
- registry.latestRunId = runId;
84
- registry.latestRunDir = resolvedRunDir;
85
- for (const agentId of uniqueAgentIds) {
86
- registry.agents[agentId] = { agentId, runId, runDir: resolvedRunDir, updatedAt: now };
87
- }
88
- saveSubagentRegistry(cwd, registry);
89
- return registry;
78
+ return updateSubagentRegistry(cwd, (registry) => {
79
+ const resolvedRunDir = path.resolve(runDir);
80
+ const runId = path.basename(resolvedRunDir);
81
+ const now = new Date().toISOString();
82
+ const uniqueAgentIds = [...new Set(agentIds.filter((id) => id.trim().length > 0))];
83
+ const previous = registry.runs[runId];
84
+ registry.runs[runId] = {
85
+ runId,
86
+ runDir: resolvedRunDir,
87
+ agentIds: uniqueAgentIds,
88
+ createdAt: previous?.createdAt ?? now,
89
+ updatedAt: now,
90
+ };
91
+ registry.latestRunId = runId;
92
+ registry.latestRunDir = resolvedRunDir;
93
+ for (const agentId of uniqueAgentIds) {
94
+ registry.agents[agentId] = { agentId, runId, runDir: resolvedRunDir, updatedAt: now };
95
+ }
96
+ return registry;
97
+ });
90
98
  }
91
99
 
92
100
  export function removeSubagentRunsFromRegistry(cwd: string, runDirs: string[]): SubagentRegistry {
93
- const registry = loadSubagentRegistry(cwd);
94
- const removed = new Set(runDirs.map((runDir) => normalizePath(runDir)));
95
- for (const [runId, run] of Object.entries(registry.runs)) {
96
- if (removed.has(normalizePath(run.runDir))) delete registry.runs[runId];
97
- }
98
- for (const [agentId, agent] of Object.entries(registry.agents)) {
99
- if (removed.has(normalizePath(agent.runDir))) delete registry.agents[agentId];
100
- }
101
- refreshLatestRun(registry);
102
- saveSubagentRegistry(cwd, registry);
103
- return registry;
101
+ return updateSubagentRegistry(cwd, (registry) => {
102
+ const removed = new Set(runDirs.map((runDir) => normalizePath(runDir)));
103
+ for (const [runId, run] of Object.entries(registry.runs)) {
104
+ if (removed.has(normalizePath(run.runDir))) delete registry.runs[runId];
105
+ }
106
+ for (const [agentId, agent] of Object.entries(registry.agents)) {
107
+ if (removed.has(normalizePath(agent.runDir))) delete registry.agents[agentId];
108
+ }
109
+ refreshLatestRun(registry);
110
+ return registry;
111
+ });
104
112
  }
105
113
 
106
114
  export function resolveSubagentRunDir(cwd: string, runDir?: string): string {
@@ -157,6 +165,123 @@ function refreshLatestRun(registry: SubagentRegistry): void {
157
165
  delete registry.latestRunDir;
158
166
  }
159
167
 
168
+ function updateSubagentRegistry(cwd: string, update: (registry: SubagentRegistry) => SubagentRegistry): SubagentRegistry {
169
+ const registryPath = getSubagentRegistryPath(cwd);
170
+ return withRegistryLock(registryPath, () => {
171
+ const registry = loadSubagentRegistry(cwd);
172
+ const updated = update(registry);
173
+ writeRegistryAtomically(registryPath, updated);
174
+ return updated;
175
+ });
176
+ }
177
+
178
+ function withRegistryLock<T>(registryPath: string, operation: () => T): T {
179
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
180
+ const lockPath = `${registryPath}.lock`;
181
+ const owner = registryLockOwner();
182
+ const deadline = Date.now() + REGISTRY_LOCK_TIMEOUT_MS;
183
+ while (!createRegistryLock(lockPath, owner)) {
184
+ recoverStaleRegistryLock(lockPath, owner);
185
+ if (Date.now() >= deadline) {
186
+ throw new Error(`Timed out waiting for sub-agent registry lock at ${lockPath}.`);
187
+ }
188
+ sleep(REGISTRY_LOCK_RETRY_MS);
189
+ }
190
+ try {
191
+ return operation();
192
+ } finally {
193
+ releaseRegistryLock(lockPath, owner);
194
+ }
195
+ }
196
+
197
+ function writeRegistryAtomically(registryPath: string, registry: SubagentRegistry): void {
198
+ const temporaryPath = `${registryPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
199
+ try {
200
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, flag: "wx" });
201
+ fs.renameSync(temporaryPath, registryPath);
202
+ } finally {
203
+ fs.rmSync(temporaryPath, { force: true });
204
+ }
205
+ }
206
+
207
+ function registryLockOwner(): RegistryLockOwner {
208
+ return { pid: process.pid, token: `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` };
209
+ }
210
+
211
+ function createRegistryLock(lockPath: string, owner: RegistryLockOwner): boolean {
212
+ try {
213
+ fs.writeFileSync(lockPath, JSON.stringify(owner), { encoding: "utf-8", mode: 0o600, flag: "wx" });
214
+ return true;
215
+ } catch (error) {
216
+ if (errorCode(error) === "EEXIST") return false;
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ function recoverStaleRegistryLock(lockPath: string, owner: RegistryLockOwner): void {
222
+ let lockStat: fs.Stats;
223
+ try {
224
+ lockStat = fs.statSync(lockPath);
225
+ } catch (error) {
226
+ if (errorCode(error) === "ENOENT") return;
227
+ throw error;
228
+ }
229
+ const existingOwner = readRegistryLockOwner(lockPath);
230
+ const lockAgeMs = Date.now() - lockStat.mtimeMs;
231
+ if (existingOwner && isProcessAlive(existingOwner.pid)) return;
232
+ if (!existingOwner && lockAgeMs <= REGISTRY_LOCK_STALE_MS) return;
233
+
234
+ const quarantinePath = `${lockPath}.stale-${owner.token}`;
235
+ try {
236
+ fs.renameSync(lockPath, quarantinePath);
237
+ } catch (error) {
238
+ if (errorCode(error) === "ENOENT") return;
239
+ throw error;
240
+ }
241
+ try {
242
+ const movedStat = fs.statSync(quarantinePath);
243
+ if (movedStat.dev !== lockStat.dev || movedStat.ino !== lockStat.ino) return;
244
+ fs.rmSync(quarantinePath, { force: true });
245
+ } finally {
246
+ if (fs.existsSync(quarantinePath) && !fs.existsSync(lockPath)) {
247
+ fs.renameSync(quarantinePath, lockPath);
248
+ }
249
+ }
250
+ }
251
+
252
+ function releaseRegistryLock(lockPath: string, owner: RegistryLockOwner): void {
253
+ if (readRegistryLockOwner(lockPath)?.token === owner.token) fs.rmSync(lockPath, { force: true });
254
+ }
255
+
256
+ function readRegistryLockOwner(lockPath: string): RegistryLockOwner | undefined {
257
+ try {
258
+ const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
259
+ if (!isRecord(parsed) || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.token !== "string") return undefined;
260
+ return { pid: parsed.pid, token: parsed.token };
261
+ } catch {
262
+ return undefined;
263
+ }
264
+ }
265
+
266
+ function isProcessAlive(pid: number): boolean {
267
+ try {
268
+ process.kill(pid, 0);
269
+ return true;
270
+ } catch (error) {
271
+ return errorCode(error) !== "ESRCH";
272
+ }
273
+ }
274
+
275
+ function errorCode(error: unknown): string | undefined {
276
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
277
+ ? error.code
278
+ : undefined;
279
+ }
280
+
281
+ function sleep(ms: number): void {
282
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
283
+ }
284
+
160
285
  function looksLikeRunDir(runDir: string): boolean {
161
286
  if (isDir(path.join(runDir, "prompts"))) return true;
162
287
  try {
@@ -4,6 +4,8 @@ import type { AgentTask } from "./types.js";
4
4
  import {
5
5
  currentModelRef,
6
6
  defaultSubagentType,
7
+ filterSubagentConfigForParentModel,
8
+ normalizeSubagentType,
7
9
  resolveSubagentRoutingConfig,
8
10
  type ResolvedSubagentRoutingConfig,
9
11
  type SubagentConfig,
@@ -45,7 +47,7 @@ export class SubagentRoutingError extends Error {
45
47
  const ROUTER_SYSTEM_PROMPT = [
46
48
  "You route Pi async sub-agent tasks to the best configured subagentType.",
47
49
  "Choose exactly one allowed type for each task. Use the allowed type descriptions as the source of truth.",
48
- "Prefer a matching project specialist. Otherwise use research for reading/review and evidence, implement for code/docs/tests/UI changes, verify for running checks, browser-qa for real-browser testing. Oracle is a deliberate strong second opinion, not the default for difficult work.",
50
+ "Prefer a matching project specialist. Otherwise use research for reading/evidence or focused review questions, implement for code/docs/tests/UI changes, verify for running checks, ui-qa for real user-interface testing across browsers, TUIs, and desktop GUIs, and frontier-review for an independent post-implementation code review when that type is allowed. Oracle is a deliberate strong second opinion, not routine code review or the default for difficult work.",
49
51
  "Return only strict JSON with this shape: {\"routes\":[{\"id\":\"task-id\",\"subagentType\":\"type\"}]}",
50
52
  "Do not include markdown, comments, explanations, or unknown types.",
51
53
  ].join("\n");
@@ -58,14 +60,28 @@ export async function routeSubagentTasks(
58
60
  ): Promise<RoutedSubagentTasks> {
59
61
  if (signal?.aborted) throw new Error("Aborted");
60
62
  // Validate even the fast path: an explicit typo must not bypass profiles.
61
- tasks = tasks.map((task) => hasText(task.subagentType) && task.subagentType !== task.subagentType.trim()
62
- ? { ...task, subagentType: task.subagentType.trim() }
63
- : task);
63
+ tasks = tasks.map((task) => {
64
+ if (!hasText(task.subagentType)) return task;
65
+ const subagentType = normalizeSubagentType(task.subagentType, config);
66
+ return subagentType !== task.subagentType ? { ...task, subagentType } : task;
67
+ });
68
+ const parentModel = currentModelRef(ctx.model);
69
+ const effectiveConfig = filterSubagentConfigForParentModel(config, parentModel);
64
70
  const invalidTasks = tasks.filter((task) => hasText(task.subagentType)
65
71
  && !Object.prototype.hasOwnProperty.call(config.types, task.subagentType));
66
72
  if (invalidTasks.length > 0) {
67
- throw routingError(`Unknown subagentType: ${invalidTasks.map((task) => `${task.id}=${JSON.stringify(task.subagentType)}`).join(", ")}.`, invalidTasks, config);
73
+ throw routingError(`Unknown subagentType: ${invalidTasks.map((task) => `${task.id}=${JSON.stringify(task.subagentType)}`).join(", ")}.`, invalidTasks, effectiveConfig);
74
+ }
75
+ const unavailableTasks = tasks.filter((task) => hasText(task.subagentType)
76
+ && !Object.prototype.hasOwnProperty.call(effectiveConfig.types, task.subagentType));
77
+ if (unavailableTasks.length > 0) {
78
+ throw routingError(
79
+ `subagentType unavailable for parent model ${parentModel ?? "(unknown)"}: ${unavailableTasks.map((task) => `${task.id}=${JSON.stringify(task.subagentType)}`).join(", ")}.`,
80
+ unavailableTasks,
81
+ effectiveConfig,
82
+ );
68
83
  }
84
+ config = effectiveConfig;
69
85
  const autoTasks = tasks.filter((task) => !hasText(task.subagentType));
70
86
  if (autoTasks.length === 0) return { tasks, usedLlm: false, routes: {}, warnings: [] };
71
87
 
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { selectSuitableToolsForModel } from "../../lib/tool-args.js";
6
- import { BROWSER_QA_RUNNER_ENV, getBrowserQaRunnerPath } from "./browser-qa.js";
6
+ import { BROWSER_QA_RUNNER_ENV, getBrowserQaRunnerPath, getUiQaRunnerPath, isUiQaType, UI_QA_RUNNER_ENV } from "./browser-qa.js";
7
7
  import { validateBasename } from "./paths.js";
8
8
  import { getPiInvocation } from "./pi-invocation.js";
9
9
  import { writePromptFile } from "./prompt.js";
@@ -20,12 +20,11 @@ export interface SpawnAgentOptions {
20
20
  parentSession?: string;
21
21
  timeoutMs?: number;
22
22
  maxResultBytes?: number;
23
- /** Explicit skills to load after disabling normal skill discovery. */
24
- isolatedSkills?: string[];
25
23
  }
26
24
 
27
25
  export const DEFAULT_AGENT_TIMEOUT_MS = 30 * 60 * 1000;
28
26
  const BROWSER_QA_WORKSPACE_DIR = "browser-qa";
27
+ const UI_QA_WORKSPACE_DIR = "ui-qa";
29
28
  const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
30
29
  const AGENT_TIMEOUT_EXIT_CODE = 124;
31
30
  const AGENT_TIMEOUT_KILL_GRACE_MS = 5_000;
@@ -50,7 +49,7 @@ export function spawnAgent(
50
49
  validateBasename(task.id, "task.id");
51
50
  const agentDir = path.join(runDir, task.id);
52
51
  fs.mkdirSync(agentDir, { recursive: true });
53
- prepareBrowserQaWorkspace(agentDir, task.subagentType);
52
+ prepareUiQaWorkspace(agentDir, task.subagentType);
54
53
 
55
54
  // Clean previous state when reusing a run directory/agent id.
56
55
  for (const f of [
@@ -89,10 +88,9 @@ export function spawnAgent(
89
88
  // detached subprocesses do not depend on persisted local pi settings.
90
89
  const persistSessions = shouldPersistSubagentSessions();
91
90
  const sessionDir = persistSessions ? getAgentSessionDir(agentDir) : undefined;
92
- // QA instructions now live in the agent prompt, but normal skill discovery
93
- // must stay disabled even when no additional skill is configured.
94
- const isolateSkills = task.subagentType === "browser-qa" || Boolean(options.isolatedSkills?.length);
95
- const forwardedExtraArgs = isolateSkills ? withoutSkillArgs(extraArgs) : extraArgs;
91
+ // Sub-agent roles are self-contained. Never inherit/discover skills, and do
92
+ // not allow profile/user extra args to re-enable or inject them.
93
+ const forwardedExtraArgs = withoutSkillArgs(extraArgs);
96
94
  if (sessionDir) fs.mkdirSync(sessionDir, { recursive: true });
97
95
  const piArgs: string[] = ["--mode", "rpc"];
98
96
  if (sessionDir) piArgs.push("--session-dir", sessionDir);
@@ -101,16 +99,15 @@ export function spawnAgent(
101
99
  piArgs.push("--extension", getModelToolsExtensionPath());
102
100
  // Preserve `--no-extensions` unless this invocation explicitly selects an
103
101
  // Antigravity model. Environment/default models do not opt the provider in.
104
- if (usesAntigravityModel(task.model, forwardedExtraArgs)) {
102
+ const configuredModel = task.model || getEnvModel();
103
+ const selectedModel = resolveSelectedModel(configuredModel, forwardedExtraArgs);
104
+ const explicitModel = resolveSelectedModel(task.model, forwardedExtraArgs);
105
+ if (explicitModel?.startsWith("antigravity/") && explicitModel.length > "antigravity/".length) {
105
106
  piArgs.push("--extension", getAntigravityAuthExtensionPath());
106
107
  }
107
- if (isolateSkills) {
108
- piArgs.push("--no-skills");
109
- for (const skillPath of options.isolatedSkills ?? []) piArgs.push("--skill", skillPath);
110
- }
111
- const envModel = task.model || getEnvModel();
112
- if (envModel) piArgs.push("--model", envModel);
113
- const selectedTools = task.tools ? filterSubagentTools(selectSuitableToolsForModel(envModel, task.tools)) : undefined;
108
+ piArgs.push("--no-skills");
109
+ if (configuredModel) piArgs.push("--model", configuredModel);
110
+ const selectedTools = task.tools ? filterSubagentTools(selectSuitableToolsForModel(selectedModel, task.tools)) : undefined;
114
111
  if (selectedTools) {
115
112
  if (selectedTools.length > 0) piArgs.push("--tools", selectedTools.join(","));
116
113
  else piArgs.push("--no-tools");
@@ -119,6 +116,10 @@ export function spawnAgent(
119
116
 
120
117
  // User-supplied extra args (e.g. --thinking high)
121
118
  piArgs.push(...forwardedExtraArgs);
119
+ // The child always runs one explicitly selected model. Override persisted
120
+ // enabledModels (and any extra --models value) so isolated children do not
121
+ // resolve unrelated provider patterns before their limited extensions load.
122
+ if (selectedModel) piArgs.push("--models", selectedModel);
122
123
  // Keep recursive/interactive parent-only tools disabled even if explicit
123
124
  // sub-agent extraArgs load additional extensions or override --tools.
124
125
  piArgs.push("--extension", getSubagentToolGuardExtensionPath());
@@ -151,7 +152,7 @@ export function spawnAgent(
151
152
 
152
153
  const proc = spawn(invocation.command, invocation.args, {
153
154
  cwd,
154
- env: subagentEnvironment(process.env, task.subagentType === "browser-qa" ? agentDir : undefined),
155
+ env: subagentEnvironment(process.env, isUiQaType(task.subagentType) ? agentDir : undefined),
155
156
  stdio: ["pipe", "pipe", "pipe"],
156
157
  detached: process.platform !== "win32",
157
158
  });
@@ -477,8 +478,8 @@ function withoutSkillArgs(args: string[]): string[] {
477
478
  return filtered;
478
479
  }
479
480
 
480
- function usesAntigravityModel(taskModel: string | undefined, extraArgs: string[]): boolean {
481
- let model = taskModel?.trim() || undefined;
481
+ function resolveSelectedModel(configuredModel: string | undefined, extraArgs: string[]): string | undefined {
482
+ let model = configuredModel?.trim() || undefined;
482
483
  for (let index = 0; index < extraArgs.length; index += 1) {
483
484
  const arg = extraArgs[index];
484
485
  if (arg === "--model" || arg === "-m") {
@@ -490,7 +491,7 @@ function usesAntigravityModel(taskModel: string | undefined, extraArgs: string[]
490
491
  model = arg.slice("--model=".length).trim() || undefined;
491
492
  }
492
493
  }
493
- return Boolean(model?.startsWith("antigravity/") && model.length > "antigravity/".length);
494
+ return model;
494
495
  }
495
496
 
496
497
  function getModelToolsExtensionPath(): string {
@@ -696,10 +697,14 @@ function compactRpcEventForProgress(event: RpcEventRecord): Record<string, unkno
696
697
  toolName: typeof event.toolName === "string" ? event.toolName : undefined,
697
698
  });
698
699
  }
699
- if (event.type === "message_end") {
700
+ if (event.type === "message_start" || event.type === "message_end") {
700
701
  return stripUndefined({
701
702
  type: event.type,
702
- role: isRecord(event.message) && typeof event.message.role === "string" ? event.message.role : undefined,
703
+ role: typeof event.role === "string"
704
+ ? event.role
705
+ : isRecord(event.message) && typeof event.message.role === "string"
706
+ ? event.message.role
707
+ : undefined,
703
708
  stopReason: isRecord(event.message) && typeof event.message.stopReason === "string" ? event.message.stopReason : undefined,
704
709
  });
705
710
  }
@@ -817,21 +822,30 @@ function subagentEnvironment(env: NodeJS.ProcessEnv, agentDir?: string): NodeJS.
817
822
  };
818
823
  delete result[SUBAGENT_AGENT_DIR_ENV];
819
824
  delete result[BROWSER_QA_RUNNER_ENV];
825
+ delete result[UI_QA_RUNNER_ENV];
820
826
  if (agentDir) {
821
827
  result[SUBAGENT_AGENT_DIR_ENV] = fs.realpathSync(agentDir);
822
828
  result[BROWSER_QA_RUNNER_ENV] = getBrowserQaRunnerPath();
829
+ result[UI_QA_RUNNER_ENV] = getUiQaRunnerPath();
823
830
  }
824
831
  return result;
825
832
  }
826
833
 
827
- function prepareBrowserQaWorkspace(agentDir: string, subagentType: string | undefined): void {
828
- const workspace = path.join(agentDir, BROWSER_QA_WORKSPACE_DIR);
834
+ function prepareUiQaWorkspace(agentDir: string, subagentType: string | undefined): void {
835
+ const workspace = path.join(agentDir, UI_QA_WORKSPACE_DIR);
836
+ const browserWorkspace = path.join(agentDir, BROWSER_QA_WORKSPACE_DIR);
829
837
  fs.rmSync(workspace, { recursive: true, force: true });
830
- if (subagentType !== "browser-qa") return;
831
- const flows = path.join(workspace, "flows");
838
+ fs.rmSync(browserWorkspace, { recursive: true, force: true });
839
+ if (!isUiQaType(subagentType)) return;
840
+ const flows = path.join(browserWorkspace, "flows");
841
+ const uiFlows = path.join(workspace, "flows");
842
+ fs.mkdirSync(workspace, { recursive: true, mode: 0o700 });
843
+ fs.mkdirSync(uiFlows, { recursive: true, mode: 0o700 });
832
844
  fs.mkdirSync(flows, { recursive: true, mode: 0o700 });
833
845
  if (process.platform !== "win32") {
834
846
  fs.chmodSync(workspace, 0o700);
847
+ fs.chmodSync(uiFlows, 0o700);
848
+ fs.chmodSync(browserWorkspace, 0o700);
835
849
  fs.chmodSync(flows, 0o700);
836
850
  }
837
851
  }
@@ -1,10 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { activityFromProgressRecord } from "./activity.js";
3
4
  import { hasLaunchedAgentPrompt, isDir } from "./paths.js";
4
5
  import { readStructuredResult } from "./structured-result.js";
5
6
  import type { AgentResult, AgentState, RpcEventRecord, RunState } from "./types.js";
6
7
 
7
8
  const MAX_RPC_EVENT_LINE_CHARS = 1024 * 1024;
9
+ const LAST_ACTIVITY_TAIL_BYTES = 64 * 1024;
8
10
 
9
11
  interface AgentStateReadOptions {
10
12
  includeLineCounts?: boolean;
@@ -67,6 +69,9 @@ export function getAgentState(
67
69
  state.finishedAt = fs.readFileSync(finishedAtFile, "utf-8").trim();
68
70
  }
69
71
 
72
+ const lastActivity = readLastActivity(path.join(agentDir, "progress.jsonl"));
73
+ if (lastActivity) state.lastActivity = lastActivity;
74
+
70
75
  const retryPendingFile = path.join(agentDir, "retry_pending");
71
76
  const stopRequestedFile = path.join(agentDir, "stop_requested");
72
77
  if (fs.existsSync(retryPendingFile) && !fs.existsSync(stopRequestedFile) && state.status !== "running" && state.status !== "done") {
@@ -109,6 +114,41 @@ function readPid(pidFile: string): number | undefined {
109
114
  return isNaN(pid) ? undefined : pid;
110
115
  }
111
116
 
117
+ function readLastActivity(filePath: string): AgentState["lastActivity"] | undefined {
118
+ if (!fs.existsSync(filePath)) return undefined;
119
+ let fd: number | undefined;
120
+ try {
121
+ fd = fs.openSync(filePath, "r");
122
+ const size = fs.fstatSync(fd).size;
123
+ if (size <= 0) return undefined;
124
+ const start = Math.max(0, size - LAST_ACTIVITY_TAIL_BYTES);
125
+ const buffer = Buffer.allocUnsafe(size - start);
126
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, start);
127
+ if (bytesRead <= 0) return undefined;
128
+ const lines = buffer.toString("utf-8", 0, bytesRead).split(/\r?\n/);
129
+ if (start > 0) {
130
+ const previousByte = Buffer.allocUnsafe(1);
131
+ const previousBytesRead = fs.readSync(fd, previousByte, 0, 1, start - 1);
132
+ if (previousBytesRead === 1 && previousByte[0] !== 0x0a) lines.shift();
133
+ }
134
+ for (let index = lines.length - 1; index >= 0; index--) {
135
+ const line = lines[index]?.trim();
136
+ if (!line) continue;
137
+ try {
138
+ const activity = activityFromProgressRecord(JSON.parse(line) as unknown);
139
+ if (activity) return activity;
140
+ } catch {
141
+ // Ignore malformed or partially written tail lines and keep scanning.
142
+ }
143
+ }
144
+ } catch {
145
+ return undefined;
146
+ } finally {
147
+ if (fd !== undefined) fs.closeSync(fd);
148
+ }
149
+ return undefined;
150
+ }
151
+
112
152
  function hasRpcPromptFailure(eventsFile: string): boolean {
113
153
  if (!fs.existsSync(eventsFile)) return false;
114
154
  let fd: number | undefined;
@@ -39,6 +39,13 @@ export interface AgentState {
39
39
  eventLines?: number;
40
40
  /** How many times this agent has been retried (0 = first attempt). */
41
41
  retryCount?: number;
42
+ /** Most recent user-visible activity observed from the sub-agent RPC stream. */
43
+ lastActivity?: AgentActivity;
44
+ }
45
+
46
+ export interface AgentActivity {
47
+ label: string;
48
+ at: string;
42
49
  }
43
50
 
44
51
  /** Retry configuration for failed agents. */
@@ -59,40 +59,58 @@ export async function decideUltraworkAuto(
59
59
  if (!routing.enabled) return "none";
60
60
  if (signal?.aborted) throw new Error("Aborted");
61
61
 
62
- try {
63
- const resolved = await resolveClassifierModel(ctx, routing.model);
64
- if (!resolved) return "none";
65
-
66
- const response = await completeWithModelRegistry(
67
- ctx.modelRegistry,
68
- resolved.model,
69
- {
70
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
71
- messages: [
72
- {
73
- role: "user" as const,
74
- content: [{ type: "text" as const, text: buildClassifierPrompt(userText) }],
75
- timestamp: Date.now(),
76
- },
77
- ],
78
- },
79
- {
80
- apiKey: resolved.apiKey,
81
- headers: resolved.headers,
82
- env: resolved.env,
83
- cacheRetention: "none",
84
- maxRetries: routing.maxRetries,
85
- maxTokens: Math.min(routing.maxTokens, 32),
86
- signal,
87
- timeoutMs: routing.timeoutMs,
88
- },
89
- );
90
-
91
- return parseUltraworkAutoDecision(responseText(response));
92
- } catch (error) {
93
- if (signal?.aborted || isAbortError(error)) throw error;
94
- return "none";
62
+ const refs = [routing.model, ...routing.fallbackModels];
63
+ const parentModel = currentModelRef(ctx.model);
64
+ if (parentModel) refs.push(parentModel);
65
+ const seen = new Set<string>();
66
+
67
+ for (const ref of refs) {
68
+ const modelRef = ref?.trim();
69
+ if (!modelRef || seen.has(modelRef)) continue;
70
+ seen.add(modelRef);
71
+
72
+ let resolved: Awaited<ReturnType<typeof resolveModelRef>>;
73
+ try {
74
+ resolved = await resolveModelRef(ctx, modelRef);
75
+ } catch (error) {
76
+ if (signal?.aborted || isAbortError(error)) throw error;
77
+ continue;
78
+ }
79
+ if (!resolved) continue;
80
+
81
+ try {
82
+ const response = await completeWithModelRegistry(
83
+ ctx.modelRegistry,
84
+ resolved.model,
85
+ {
86
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
87
+ messages: [
88
+ {
89
+ role: "user" as const,
90
+ content: [{ type: "text" as const, text: buildClassifierPrompt(userText) }],
91
+ timestamp: Date.now(),
92
+ },
93
+ ],
94
+ },
95
+ {
96
+ apiKey: resolved.apiKey,
97
+ headers: resolved.headers,
98
+ env: resolved.env,
99
+ cacheRetention: "none",
100
+ maxRetries: routing.maxRetries,
101
+ maxTokens: Math.min(routing.maxTokens, 32),
102
+ signal,
103
+ timeoutMs: routing.timeoutMs,
104
+ },
105
+ );
106
+ if (signal?.aborted || response.stopReason === "aborted") throw new Error("Aborted");
107
+ if (response.stopReason === "error") continue;
108
+ return parseUltraworkAutoDecision(responseText(response));
109
+ } catch (error) {
110
+ if (signal?.aborted || isAbortError(error)) throw error;
111
+ }
95
112
  }
113
+ return "none";
96
114
  }
97
115
 
98
116
  export function parseUltraworkAutoDecision(raw: string): UltraworkAutoDecision {
@@ -128,18 +146,6 @@ function buildClassifierPrompt(userText: string): string {
128
146
  ].join("\n");
129
147
  }
130
148
 
131
- async function resolveClassifierModel(ctx: UltraworkAutoContext, modelRef: string): Promise<{
132
- model: Model<Api>;
133
- apiKey?: string;
134
- headers?: ProviderHeaders;
135
- env?: Record<string, string>;
136
- } | undefined> {
137
- const configured = await resolveModelRef(ctx, modelRef);
138
- if (configured) return configured;
139
- const parentModel = currentModelRef(ctx.model);
140
- return parentModel && parentModel !== modelRef ? resolveModelRef(ctx, parentModel) : undefined;
141
- }
142
-
143
149
  async function resolveModelRef(ctx: UltraworkAutoContext, modelRef: string): Promise<{
144
150
  model: Model<Api>;
145
151
  apiKey?: string;