@rexvane/piweb 0.3.15 → 0.3.16

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 (230) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +9 -6
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +6 -6
  5. package/.next/react-loadable-manifest.json +16 -4
  6. package/.next/routes-manifest.json +20 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page.js +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +1 -1
  17. package/.next/server/app/_not-found.rsc +7 -4
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +7 -4
  19. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -2
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +5 -2
  21. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  22. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/[id]/route.js +1 -1
  24. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/new/route.js +1 -1
  28. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  29. package/.next/server/app/api/config/route.js +1 -1
  30. package/.next/server/app/api/config/route.js.nft.json +1 -1
  31. package/.next/server/app/api/files/route.js +1 -1
  32. package/.next/server/app/api/files/route.js.nft.json +1 -1
  33. package/.next/server/app/api/git/route.js +2 -2
  34. package/.next/server/app/api/git/route.js.nft.json +1 -1
  35. package/.next/server/app/api/growth/route.js +1 -1
  36. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  37. package/.next/server/app/api/health/route.js +1 -1
  38. package/.next/server/app/api/models/route.js +1 -1
  39. package/.next/server/app/api/models/route.js.nft.json +1 -1
  40. package/.next/server/app/api/pi-settings/route.js +1 -1
  41. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  42. package/.next/server/app/api/plugins/route.js +1 -1
  43. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  44. package/.next/server/app/api/prompts/route.js +1 -1
  45. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  46. package/.next/server/app/api/security/route.js +1 -1
  47. package/.next/server/app/api/security/route.js.nft.json +1 -1
  48. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  49. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  50. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  51. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  52. package/.next/server/app/api/sessions/import/route.js +3 -3
  53. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  54. package/.next/server/app/api/sessions/route.js +1 -1
  55. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  56. package/.next/server/app/api/skills/route.js +2 -2
  57. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  58. package/.next/server/app/api/swarm/[id]/route.js +1 -0
  59. package/.next/server/app/api/swarm/[id]/route.js.nft.json +1 -0
  60. package/.next/server/app/api/swarm/[id]/route_client-reference-manifest.js +1 -0
  61. package/.next/server/app/api/swarm/route.js +1 -0
  62. package/.next/server/app/api/swarm/route.js.nft.json +1 -0
  63. package/.next/server/app/api/swarm/route_client-reference-manifest.js +1 -0
  64. package/.next/server/app/api/tests/route.js +2 -0
  65. package/.next/server/app/api/tests/route.js.nft.json +1 -0
  66. package/.next/server/app/api/tests/route_client-reference-manifest.js +1 -0
  67. package/.next/server/app/api/update/route.js +2 -2
  68. package/.next/server/app/api/update/route.js.nft.json +1 -1
  69. package/.next/server/app/api/version/route.js +1 -1
  70. package/.next/server/app/api/version/route.js.nft.json +1 -1
  71. package/.next/server/app/api/web-auth/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js +1 -1
  73. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  74. package/.next/server/app/icon.svg/route.js +1 -1
  75. package/.next/server/app/index.html +1 -1
  76. package/.next/server/app/index.rsc +9 -6
  77. package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -4
  78. package/.next/server/app/index.segments/_full.segment.rsc +9 -6
  79. package/.next/server/app/index.segments/_tree.segment.rsc +5 -2
  80. package/.next/server/app/login/page.js +1 -1
  81. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/login.html +1 -1
  83. package/.next/server/app/login.rsc +8 -5
  84. package/.next/server/app/login.segments/_full.segment.rsc +8 -5
  85. package/.next/server/app/login.segments/_tree.segment.rsc +5 -2
  86. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +6 -3
  87. package/.next/server/app/page.js +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +9 -6
  90. package/.next/server/chunks/496.js +1 -1
  91. package/.next/server/chunks/532.js +1 -1
  92. package/.next/server/chunks/686.js +6 -2
  93. package/.next/server/chunks/757.js +1 -0
  94. package/.next/server/chunks/775.js +3 -0
  95. package/.next/server/middleware-build-manifest.js +1 -1
  96. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  97. package/.next/server/pages/404.html +1 -1
  98. package/.next/server/pages/500.html +1 -1
  99. package/.next/server/server-reference-manifest.json +1 -1
  100. package/.next/static/0t1jBUAuVaTFTXxRyIo3q/_buildManifest.js +1 -0
  101. package/.next/static/chunks/2608.43c58236c9cce8e6.js +1 -0
  102. package/.next/static/chunks/{2716.a9e808190a4e2424.js → 2716.4ec42df1d51354c9.js} +1 -1
  103. package/.next/static/chunks/4632.62139b565b657aac.js +1 -0
  104. package/.next/static/chunks/{4673.d3d0a7f2599080fb.js → 4673.7796104395deb8d7.js} +1 -1
  105. package/.next/static/chunks/4686-17f21a006739a596.js +1 -0
  106. package/.next/static/chunks/7577.10af9bb661652da8.js +1 -0
  107. package/.next/static/chunks/8866.38ec4ac4ee5a1b74.js +1 -0
  108. package/.next/static/chunks/app/_global-error/page-5148173b3f55e415.js +1 -0
  109. package/.next/static/chunks/app/api/agent/[id]/events/route-5148173b3f55e415.js +1 -0
  110. package/.next/static/chunks/app/api/agent/[id]/route-5148173b3f55e415.js +1 -0
  111. package/.next/static/chunks/app/api/agent/[id]/tree/route-5148173b3f55e415.js +1 -0
  112. package/.next/static/chunks/app/api/agent/new/route-5148173b3f55e415.js +1 -0
  113. package/.next/static/chunks/app/api/config/route-5148173b3f55e415.js +1 -0
  114. package/.next/static/chunks/app/api/files/route-5148173b3f55e415.js +1 -0
  115. package/.next/static/chunks/app/api/git/route-5148173b3f55e415.js +1 -0
  116. package/.next/static/chunks/app/api/growth/route-5148173b3f55e415.js +1 -0
  117. package/.next/static/chunks/app/api/health/route-5148173b3f55e415.js +1 -0
  118. package/.next/static/chunks/app/api/models/route-5148173b3f55e415.js +1 -0
  119. package/.next/static/chunks/app/api/pi-settings/route-5148173b3f55e415.js +1 -0
  120. package/.next/static/chunks/app/api/plugins/route-5148173b3f55e415.js +1 -0
  121. package/.next/static/chunks/app/api/prompts/route-5148173b3f55e415.js +1 -0
  122. package/.next/static/chunks/app/api/security/route-5148173b3f55e415.js +1 -0
  123. package/.next/static/chunks/app/api/sessions/[id]/export/route-5148173b3f55e415.js +1 -0
  124. package/.next/static/chunks/app/api/sessions/[id]/route-5148173b3f55e415.js +1 -0
  125. package/.next/static/chunks/app/api/sessions/import/route-5148173b3f55e415.js +1 -0
  126. package/.next/static/chunks/app/api/sessions/route-5148173b3f55e415.js +1 -0
  127. package/.next/static/chunks/app/api/skills/route-5148173b3f55e415.js +1 -0
  128. package/.next/static/chunks/app/api/swarm/[id]/route-5148173b3f55e415.js +1 -0
  129. package/.next/static/chunks/app/api/swarm/route-5148173b3f55e415.js +1 -0
  130. package/.next/static/chunks/app/api/tests/route-5148173b3f55e415.js +1 -0
  131. package/.next/static/chunks/app/api/update/route-5148173b3f55e415.js +1 -0
  132. package/.next/static/chunks/app/api/version/route-5148173b3f55e415.js +1 -0
  133. package/.next/static/chunks/app/api/web-auth/route-5148173b3f55e415.js +1 -0
  134. package/.next/static/chunks/app/api/workspaces/route-5148173b3f55e415.js +1 -0
  135. package/.next/static/chunks/app/page-14379b8060d5cf07.js +1 -0
  136. package/.next/static/chunks/next/dist/client/components/builtin/app-error-5148173b3f55e415.js +1 -0
  137. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-5148173b3f55e415.js +1 -0
  138. package/.next/static/chunks/next/dist/client/components/builtin/not-found-5148173b3f55e415.js +1 -0
  139. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-5148173b3f55e415.js +1 -0
  140. package/.next/static/chunks/webpack-d1a69ad9edb38b11.js +1 -0
  141. package/.next/static/css/d0a0162cb481abe8.css +1 -0
  142. package/.next/types/app/api/swarm/[id]/route.ts +353 -0
  143. package/.next/types/app/api/swarm/route.ts +353 -0
  144. package/.next/types/app/api/tests/route.ts +353 -0
  145. package/.next/types/routes.d.ts +4 -1
  146. package/.next/types/validator.ts +27 -0
  147. package/README.md +10 -0
  148. package/package.json +4 -3
  149. package/scripts/pick-folder.ps1 +11 -2
  150. package/scripts/release.mjs +11 -31
  151. package/scripts/run-test-file.mjs +34 -0
  152. package/scripts/verify-release-config.mjs +24 -0
  153. package/src/app/api/config/route.ts +4 -1
  154. package/src/app/api/security/route.ts +8 -3
  155. package/src/app/api/swarm/[id]/route.ts +28 -0
  156. package/src/app/api/swarm/route.ts +24 -0
  157. package/src/app/api/tests/route.ts +27 -0
  158. package/src/app/api/update/route.ts +8 -2
  159. package/src/app/globals.css +777 -0
  160. package/src/app/layout.tsx +13 -6
  161. package/src/components/AppShell.tsx +505 -199
  162. package/src/components/ChatInput.tsx +153 -26
  163. package/src/components/ChatWindow.tsx +46 -40
  164. package/src/components/ContextMeter.tsx +11 -17
  165. package/src/components/FileViewer.tsx +1 -1
  166. package/src/components/GitPanel.tsx +13 -11
  167. package/src/components/ModelSelector.tsx +19 -6
  168. package/src/components/PiMark.tsx +5 -38
  169. package/src/components/ProjectPanel.tsx +1 -0
  170. package/src/components/PromptPanel.tsx +132 -51
  171. package/src/components/SessionImportSection.tsx +17 -17
  172. package/src/components/SessionSidebar.tsx +159 -57
  173. package/src/components/SettingsPanel.tsx +330 -193
  174. package/src/components/SwarmCoordinatorPod.tsx +1021 -0
  175. package/src/components/TestInspectorPod.tsx +549 -0
  176. package/src/components/icons.tsx +23 -0
  177. package/src/hooks/usePiWeb.ts +112 -8
  178. package/src/i18n.tsx +59 -3
  179. package/src/lib/agent-manager.ts +180 -12
  180. package/src/lib/command-validation.ts +9 -1
  181. package/src/lib/delegation.ts +93 -0
  182. package/src/lib/managed-niubash.ts +8 -4
  183. package/src/lib/pi.ts +3 -2
  184. package/src/lib/runtime-activity.ts +27 -0
  185. package/src/lib/session-import/index.ts +5 -0
  186. package/src/lib/session-import/lock.ts +25 -0
  187. package/src/lib/session-import/writer.ts +8 -3
  188. package/src/lib/skills-service.ts +16 -9
  189. package/src/lib/swarm-service.ts +371 -0
  190. package/src/lib/test-run-service.ts +122 -0
  191. package/src/lib/theme.ts +68 -1
  192. package/src/lib/trajectory.ts +1 -0
  193. package/src/lib/types.ts +14 -0
  194. package/src/lib/workflow.ts +55 -0
  195. package/.next/static/DdBwPYoHBKtaJ97QIr6Co/_buildManifest.js +0 -1
  196. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  197. package/.next/static/chunks/4632.0ed92ae42905ce40.js +0 -1
  198. package/.next/static/chunks/4686-7d694def920ff093.js +0 -1
  199. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  200. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  201. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  202. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  203. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  204. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  205. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  206. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  207. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  208. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  209. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  210. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  211. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  212. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  213. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  214. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  215. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  216. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  217. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  218. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  219. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  220. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  221. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  222. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  223. package/.next/static/chunks/app/page-9a5440047c5a415c.js +0 -1
  224. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  225. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  226. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  227. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  228. package/.next/static/chunks/webpack-42c5aae79b0e5d8c.js +0 -1
  229. package/.next/static/css/b2d35abc828d4713.css +0 -1
  230. /package/.next/static/{DdBwPYoHBKtaJ97QIr6Co → 0t1jBUAuVaTFTXxRyIo3q}/_ssgManifest.js +0 -0
@@ -33,7 +33,8 @@ async function toComparablePath(target: string): Promise<string> {
33
33
  } catch {
34
34
  resolved = path.resolve(target); // 路径还不存在(如待创建的技能目录):退化成普通规范化
35
35
  }
36
- return resolved.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
36
+ const normalized = resolved.replace(/\\/g, "/").replace(/\/+$/, "");
37
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
37
38
  }
38
39
 
39
40
  interface ScopeContext {
@@ -57,10 +58,10 @@ const within = (target: string, root: string) => target.startsWith(`${root}/`);
57
58
 
58
59
  async function classifyScope(filePath: string, context: ScopeContext): Promise<SkillView["scope"]> {
59
60
  const norm = await toComparablePath(filePath);
61
+ if (norm.includes("/node_modules/")) return "package";
60
62
  if (within(norm, context.agentDir) || within(norm, `${context.home}/.agents`) || within(norm, `${context.home}/.pi`))
61
63
  return "global";
62
64
  if (context.cwd && within(norm, context.cwd)) return "project";
63
- if (norm.includes("/node_modules/")) return "package";
64
65
  return "package";
65
66
  }
66
67
 
@@ -121,7 +122,7 @@ export async function setSkillDisabled(filePath: string, disabled: boolean, cwd?
121
122
  await fs.writeFile(authorized, next, "utf8");
122
123
  // 就地重载加载器并让活跃会话重建系统提示(技能列表在系统提示里);全局技能影响所有目录
123
124
  await reloadAllLoaders();
124
- const context = await scopeContext(cwd);
125
+ const context = await scopeContext(cwd ?? process.cwd());
125
126
  const scope = await classifyScope(authorized, context);
126
127
  await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
127
128
  return { changed: true };
@@ -137,17 +138,23 @@ export async function readSkillFile(filePath: string, cwd?: string): Promise<str
137
138
  export async function deleteSkill(filePath: string, cwd?: string): Promise<{ removed: string }> {
138
139
  const skills = await listSkills(cwd);
139
140
  const authorized = await resolveDiscoveredPath(filePath, skills.map((skill) => skill.filePath));
140
- const context = await scopeContext(cwd);
141
+ const context = await scopeContext(cwd ?? process.cwd());
141
142
  if ((await classifyScope(authorized, context)) === "package") {
142
143
  throw new Error("package-managed skill: disable it instead (it is reinstalled with the package)");
143
144
  }
144
145
  const skillDir = path.dirname(authorized);
145
- // 只删技能目录本身,不能误删父目录(全局技能目录 ~/.pi/agent/skills、项目 .agents/skills 等)
146
146
  const normalizedSkillDir = await toComparablePath(skillDir);
147
- const knownRoots = [`${context.agentDir}/skills`, `${context.agentDir}/web-skills`];
148
- if (!knownRoots.includes(normalizedSkillDir)) {
149
- // 非已知根:项目内技能目录,至少确认 SKILL.md 直接位于其下且目录名非空
150
- if (path.basename(skillDir) === "") throw new Error("refusing to delete workspace root");
147
+ const workspace = cwd ?? process.cwd();
148
+ const roots = [path.parse(skillDir).root, getAgentDir(), os.homedir(), workspace];
149
+ for (const base of [getAgentDir(), os.homedir(), workspace]) {
150
+ for (const relative of ["skills", "web-skills", ".git", ".agents", ".agents/skills", ".pi", ".pi/skills", ".pi/agent", ".pi/agent/skills"]) {
151
+ roots.push(path.join(base, relative));
152
+ }
153
+ }
154
+ const protectedRoots = await Promise.all(roots.map(toComparablePath));
155
+ // A discovered root-level SKILL.md never authorizes deleting its collection or workspace.
156
+ if (protectedRoots.some((root) => root === normalizedSkillDir || within(root, normalizedSkillDir))) {
157
+ throw new Error("refusing to delete protected skill collection or workspace root");
151
158
  }
152
159
  await fs.rm(skillDir, { recursive: true, force: true });
153
160
  await reloadAllLoaders();
@@ -0,0 +1,371 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { randomUUID } from "node:crypto";
6
+ import { DefaultResourceLoader, SessionManager, createAgentSession, defineTool, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { BoundaryError, isPathInside, resolveWorkspacePath, samePath } from "./path-security";
9
+ import { getAgentDir, getModelRuntime, getSettingsManager, resolveProjectTrust, withResourceLock } from "./pi";
10
+ import { beginActivity } from "./runtime-activity";
11
+
12
+ const exec = promisify(execFile);
13
+ const STORE = path.join(getAgentDir(), "piweb-swarms");
14
+ const MAX_TASKS = 3;
15
+ const MAX_FILE_BYTES = 200_000;
16
+ const MAX_PATCH_BYTES = 2_000_000;
17
+ const TASK_TIMEOUT_MS = 10 * 60_000;
18
+ const jobs = new Map<string, SwarmJob>();
19
+ const controllers = new Map<string, AbortController>();
20
+ const saveQueues = new Map<string, Promise<void>>();
21
+ const accepting = new Set<string>();
22
+ const worktreeQueues = new Map<string, Promise<void>>();
23
+
24
+ export interface SwarmTaskInput { title: string; instruction: string }
25
+ export interface SwarmTask extends SwarmTaskInput {
26
+ status: "pending" | "running" | "completed" | "failed" | "cancelled";
27
+ report?: string;
28
+ patch?: string;
29
+ error?: string;
30
+ accepted?: boolean;
31
+ }
32
+ export interface SwarmJob {
33
+ id: string;
34
+ cwd: string;
35
+ root: string;
36
+ base: string;
37
+ status: "running" | "completed" | "failed" | "cancelled" | "interrupted";
38
+ createdAt: number;
39
+ finishedAt?: number;
40
+ tasks: SwarmTask[];
41
+ }
42
+
43
+ function publicJob(job: SwarmJob): SwarmJob { return structuredClone(job); }
44
+ function jobFile(id: string): string {
45
+ if (!/^[0-9a-f-]{36}$/.test(id)) throw new BoundaryError("invalid swarm id");
46
+ return path.join(STORE, "jobs", `${id}.json`);
47
+ }
48
+ function save(job: SwarmJob): Promise<void> {
49
+ const previous = saveQueues.get(job.id) ?? Promise.resolve();
50
+ const next = previous.catch(() => undefined).then(async () => {
51
+ const file = jobFile(job.id);
52
+ await fs.mkdir(path.dirname(file), { recursive: true });
53
+ const tmp = `${file}.${randomUUID()}.tmp`;
54
+ await fs.writeFile(tmp, JSON.stringify(job), "utf8");
55
+ await fs.rename(tmp, file);
56
+ });
57
+ saveQueues.set(job.id, next);
58
+ return next;
59
+ }
60
+ async function git(cwd: string, args: string[], maxBuffer = 4_000_000): Promise<string> {
61
+ const result = await exec("git", args, {
62
+ cwd, timeout: 30_000, maxBuffer, windowsHide: true,
63
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
64
+ });
65
+ return result.stdout;
66
+ }
67
+ function worktreeGit(root: string, args: string[]): Promise<string> {
68
+ const previous = worktreeQueues.get(root) ?? Promise.resolve();
69
+ const next = previous.catch(() => undefined).then(() => git(root, args));
70
+ worktreeQueues.set(root, next.then(() => undefined, () => undefined));
71
+ return next;
72
+ }
73
+ function errorText(error: unknown): string {
74
+ const value = error as { stderr?: unknown; message?: unknown };
75
+ return String(value?.stderr || value?.message || error).trim().slice(0, 1000);
76
+ }
77
+ async function workspace(cwdValue: unknown): Promise<{ cwd: string; root: string; base: string }> {
78
+ const cwd = await resolveWorkspacePath(cwdValue);
79
+ const root = path.resolve((await git(cwd, ["rev-parse", "--show-toplevel"]).catch(() => {
80
+ throw new BoundaryError("workspace is not a Git repository");
81
+ })).trim());
82
+ if (!isPathInside(root, cwd)) throw new BoundaryError("invalid repository root");
83
+ if (!resolveProjectTrust(root).trusted) throw new BoundaryError("trust this project before running swarm tasks");
84
+ const status = await git(root, ["status", "--porcelain", "-z"]);
85
+ if (status) throw new BoundaryError("commit or stash workspace changes before starting a swarm");
86
+ return { cwd, root, base: (await git(root, ["rev-parse", "HEAD"])).trim() };
87
+ }
88
+ function checkedInput(value: unknown): SwarmTaskInput[] {
89
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_TASKS) throw new BoundaryError("provide one to three tasks");
90
+ return value.map((item) => {
91
+ const title = item?.title;
92
+ const instruction = item?.instruction;
93
+ if (typeof title !== "string" || !title.trim() || title.length > 80 || typeof instruction !== "string" || !instruction.trim() || instruction.length > 4000) {
94
+ throw new BoundaryError("invalid swarm task");
95
+ }
96
+ return { title: title.trim(), instruction: instruction.trim() };
97
+ });
98
+ }
99
+ function protectedComponent(part: string): boolean {
100
+ const name = process.platform === "win32" ? part.toLowerCase().split(":")[0].replace(/[. ]+$/, "") : part;
101
+ return name === ".git" || name === "node_modules";
102
+ }
103
+ function assertToolTarget(root: string, target: string): void {
104
+ if (!isPathInside(root, target)) throw new BoundaryError("path escapes isolated worktree");
105
+ if (path.relative(root, target).split(path.sep).some(protectedComponent)) throw new BoundaryError("protected worktree path");
106
+ }
107
+ export async function resolveSwarmToolPath(root: string, value: string): Promise<string> {
108
+ if (typeof value !== "string" || !value.trim() || value.length > 4096) throw new BoundaryError("invalid file path");
109
+ const target = path.resolve(root, value);
110
+ assertToolTarget(root, target);
111
+ const canonicalRoot = await fs.realpath(root);
112
+ let ancestor = target;
113
+ for (;;) {
114
+ const real = await fs.realpath(ancestor).catch((error: NodeJS.ErrnoException) => {
115
+ if (error.code === "ENOENT") return null;
116
+ throw error;
117
+ });
118
+ if (real) {
119
+ assertToolTarget(canonicalRoot, real);
120
+ break;
121
+ }
122
+ const parent = path.dirname(ancestor);
123
+ if (parent === ancestor) throw new BoundaryError("invalid worktree path");
124
+ ancestor = parent;
125
+ }
126
+ return target;
127
+ }
128
+ function toolText(text: string) { return { content: [{ type: "text" as const, text }], details: undefined }; }
129
+ function fileTools(root: string): ToolDefinition[] {
130
+ return [
131
+ defineTool({
132
+ name: "swarm_read", label: "Read isolated file", description: "Read a UTF-8 file inside this isolated worktree.",
133
+ parameters: Type.Object({ path: Type.String() }),
134
+ async execute(_id, args) {
135
+ const file = await resolveSwarmToolPath(root, args.path);
136
+ const stat = await fs.stat(file);
137
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new BoundaryError("file is not readable or is too large");
138
+ return toolText(await fs.readFile(file, "utf8"));
139
+ },
140
+ }),
141
+ defineTool({
142
+ name: "swarm_list", label: "List isolated directory", description: "List files and directories inside this isolated worktree.",
143
+ parameters: Type.Object({ path: Type.String() }),
144
+ async execute(_id, args) {
145
+ const dir = await resolveSwarmToolPath(root, args.path);
146
+ const entries = (await fs.readdir(dir, { withFileTypes: true }))
147
+ .filter((entry) => !protectedComponent(entry.name))
148
+ .slice(0, 200).map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
149
+ return toolText(entries.join("\n") || "(empty)");
150
+ },
151
+ }),
152
+ defineTool({
153
+ name: "swarm_search", label: "Search isolated files", description: "Find a literal text string in tracked source files inside this isolated worktree.",
154
+ parameters: Type.Object({ query: Type.String(), path: Type.Optional(Type.String()) }),
155
+ async execute(_id, args) {
156
+ if (!args.query || args.query.length > 200) throw new BoundaryError("invalid search query");
157
+ const start = await resolveSwarmToolPath(root, args.path || ".");
158
+ const found: string[] = [];
159
+ let scanned = 0;
160
+ async function walk(dir: string): Promise<void> {
161
+ if (scanned >= 1000 || found.length >= 100) return;
162
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
163
+ if (protectedComponent(entry.name) || entry.isSymbolicLink()) continue;
164
+ const file = await resolveSwarmToolPath(root, path.join(dir, entry.name));
165
+ if (entry.isDirectory()) await walk(file);
166
+ else if (entry.isFile()) {
167
+ scanned += 1;
168
+ if ((await fs.stat(file)).size > MAX_FILE_BYTES) continue;
169
+ const content = await fs.readFile(file, "utf8");
170
+ content.split("\n").forEach((line, index) => {
171
+ if (found.length < 100 && line.includes(args.query)) found.push(`${path.relative(root, file)}:${index + 1}: ${line.slice(0, 300)}`);
172
+ });
173
+ }
174
+ if (scanned >= 1000 || found.length >= 100) break;
175
+ }
176
+ }
177
+ const stat = await fs.stat(start);
178
+ if (stat.isDirectory()) await walk(start);
179
+ else if (stat.isFile()) {
180
+ const content = await fs.readFile(start, "utf8");
181
+ content.split("\n").forEach((line, index) => {
182
+ if (found.length < 100 && line.includes(args.query)) found.push(`${path.relative(root, start)}:${index + 1}: ${line.slice(0, 300)}`);
183
+ });
184
+ }
185
+ return toolText(found.join("\n") || "No matches");
186
+ },
187
+ }),
188
+ defineTool({
189
+ name: "swarm_edit", label: "Edit isolated file", description: "Replace one exact, unique text occurrence in an isolated file.",
190
+ executionMode: "sequential",
191
+ parameters: Type.Object({ path: Type.String(), oldText: Type.String(), newText: Type.String() }),
192
+ async execute(_id, args) {
193
+ const file = await resolveSwarmToolPath(root, args.path);
194
+ if (!args.oldText || args.newText.length > MAX_FILE_BYTES) throw new BoundaryError("invalid edit");
195
+ const stat = await fs.stat(file);
196
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new BoundaryError("file is not editable or is too large");
197
+ const before = await fs.readFile(file, "utf8");
198
+ const offset = before.indexOf(args.oldText);
199
+ if (offset < 0 || before.indexOf(args.oldText, offset + 1) >= 0) throw new BoundaryError("oldText must match exactly once");
200
+ const after = before.slice(0, offset) + args.newText + before.slice(offset + args.oldText.length);
201
+ if (Buffer.byteLength(after) > MAX_FILE_BYTES) throw new BoundaryError("edited file is too large");
202
+ await fs.writeFile(file, after, "utf8");
203
+ return toolText(`Updated ${path.relative(root, file)}`);
204
+ },
205
+ }),
206
+ defineTool({
207
+ name: "swarm_write", label: "Write isolated file", description: "Create or replace a UTF-8 file inside this isolated worktree.",
208
+ executionMode: "sequential",
209
+ parameters: Type.Object({ path: Type.String(), content: Type.String() }),
210
+ async execute(_id, args) {
211
+ if (Buffer.byteLength(args.content) > MAX_FILE_BYTES) throw new BoundaryError("file is too large");
212
+ const file = await resolveSwarmToolPath(root, args.path);
213
+ await fs.mkdir(path.dirname(file), { recursive: true });
214
+ await fs.writeFile(file, args.content, "utf8");
215
+ return toolText(`Wrote ${path.relative(root, file)}`);
216
+ },
217
+ }),
218
+ ];
219
+ }
220
+ function reportOf(session: AgentSession): string {
221
+ const answer = [...session.messages].reverse().find((message) => message.role === "assistant");
222
+ if (!answer || answer.role !== "assistant") throw new Error("agent returned no assistant response");
223
+ if (answer.stopReason !== "stop") throw new Error(answer.errorMessage || `agent stopped: ${answer.stopReason}`);
224
+ return answer.content.filter((part) => part.type === "text").map((part) => part.text).join("\n").slice(0, 20_000);
225
+ }
226
+ async function runTask(job: SwarmJob, index: number, signal: AbortSignal): Promise<void> {
227
+ const task = job.tasks[index];
228
+ const worktree = path.join(STORE, "worktrees", job.id, String(index));
229
+ let created = false;
230
+ let child: AgentSession | undefined;
231
+ let timer: ReturnType<typeof setTimeout> | undefined;
232
+ let timedOut = false;
233
+ try {
234
+ if (signal.aborted) throw new Error("cancelled");
235
+ task.status = "running";
236
+ await save(job);
237
+ await fs.mkdir(path.dirname(worktree), { recursive: true });
238
+ await worktreeGit(job.root, ["worktree", "add", "--detach", worktree, job.base]);
239
+ created = true;
240
+ if (signal.aborted) throw new Error("cancelled");
241
+ const loader = new DefaultResourceLoader({
242
+ cwd: worktree, agentDir: getAgentDir(), settingsManager: getSettingsManager(worktree),
243
+ noExtensions: true, noPromptTemplates: true, noThemes: true,
244
+ appendSystemPrompt: ["You are a PiWeb swarm worker in an isolated Git worktree. Use only swarm_* tools. Never access other directories. Make only the assigned changes, then report what changed and what you could not verify. Do not claim tests ran; no command execution is available."],
245
+ });
246
+ try {
247
+ await withResourceLock(() => loader.reload());
248
+ if (signal.aborted) throw new Error("cancelled");
249
+ const result = await createAgentSession({
250
+ cwd: worktree, agentDir: getAgentDir(), sessionManager: SessionManager.inMemory(worktree),
251
+ modelRuntime: await getModelRuntime(), settingsManager: getSettingsManager(worktree),
252
+ resourceLoader: loader, tools: ["swarm_read", "swarm_list", "swarm_search", "swarm_edit", "swarm_write"],
253
+ customTools: fileTools(worktree),
254
+ });
255
+ child = result.session;
256
+ if (signal.aborted) throw new Error("cancelled");
257
+ const abort = () => { void child?.abort().catch(() => undefined); };
258
+ signal.addEventListener("abort", abort, { once: true });
259
+ timer = setTimeout(() => { timedOut = true; abort(); }, TASK_TIMEOUT_MS);
260
+ try { await child.prompt(`${task.title}\n\n${task.instruction}`, { source: "rpc", expandPromptTemplates: false }); }
261
+ finally { signal.removeEventListener("abort", abort); }
262
+ if (timedOut) throw new Error("task timed out after ten minutes");
263
+ if (signal.aborted) throw new Error("cancelled");
264
+ task.report = reportOf(child);
265
+ await git(worktree, ["add", "-N", "--", "."]);
266
+ const patch = await git(worktree, ["diff", "--binary", "HEAD", "--", "."], MAX_PATCH_BYTES + 1024);
267
+ if (Buffer.byteLength(patch) > MAX_PATCH_BYTES) throw new Error("patch exceeds 2 MB limit");
268
+ task.patch = patch;
269
+ task.status = "completed";
270
+ } finally {
271
+ child?.dispose();
272
+ loader.getExtensions().runtime.invalidate();
273
+ }
274
+ } catch (error) {
275
+ task.status = signal.aborted ? "cancelled" : "failed";
276
+ task.error = errorText(error);
277
+ } finally {
278
+ if (timer) clearTimeout(timer);
279
+ if (created) await worktreeGit(job.root, ["worktree", "remove", "--force", worktree]).catch(() => undefined);
280
+ if (isPathInside(path.join(STORE, "worktrees"), worktree)) await fs.rm(worktree, { recursive: true, force: true }).catch(() => undefined);
281
+ await save(job);
282
+ }
283
+ }
284
+ export async function startSwarm(cwdValue: unknown, taskValue: unknown): Promise<SwarmJob> {
285
+ const releaseActivity = beginActivity("swarm");
286
+ try {
287
+ return await startSwarmActive(cwdValue, taskValue, releaseActivity);
288
+ } catch (error) {
289
+ releaseActivity();
290
+ throw error;
291
+ }
292
+ }
293
+ async function startSwarmActive(cwdValue: unknown, taskValue: unknown, releaseActivity: () => void): Promise<SwarmJob> {
294
+ const tasks = checkedInput(taskValue);
295
+ const { cwd, root, base } = await workspace(cwdValue);
296
+ if ([...jobs.values()].some((job) => samePath(job.root, root) && job.status === "running")) throw new BoundaryError("a swarm is already running for this repository");
297
+ const job: SwarmJob = { id: randomUUID(), cwd, root, base, status: "running", createdAt: Date.now(), tasks: tasks.map((task) => ({ ...task, status: "pending" })) };
298
+ jobs.set(job.id, job);
299
+ const controller = new AbortController();
300
+ controllers.set(job.id, controller);
301
+ try {
302
+ await save(job);
303
+ } catch (error) {
304
+ controllers.delete(job.id);
305
+ jobs.delete(job.id);
306
+ throw error;
307
+ }
308
+ void Promise.all(job.tasks.map((_, index) => runTask(job, index, controller.signal))).then(async () => {
309
+ job.status = controller.signal.aborted ? "cancelled" : job.tasks.every((task) => task.status === "completed") ? "completed" : "failed";
310
+ job.finishedAt = Date.now();
311
+ controllers.delete(job.id);
312
+ await save(job);
313
+ }).catch(async () => {
314
+ job.status = "failed";
315
+ job.finishedAt = Date.now();
316
+ controllers.delete(job.id);
317
+ await save(job).catch(() => undefined);
318
+ }).finally(releaseActivity);
319
+ return publicJob(job);
320
+ }
321
+ export async function getSwarm(id: string): Promise<SwarmJob> {
322
+ const live = jobs.get(id);
323
+ if (live) return publicJob(live);
324
+ const raw = await fs.readFile(jobFile(id), "utf8").catch(() => { throw new BoundaryError("swarm not found"); });
325
+ const job = JSON.parse(raw) as SwarmJob;
326
+ if (job.status === "running") job.status = "interrupted";
327
+ return publicJob(job);
328
+ }
329
+ export async function listSwarms(cwdValue: unknown): Promise<SwarmJob[]> {
330
+ const cwd = await resolveWorkspacePath(cwdValue);
331
+ const files = await fs.readdir(path.join(STORE, "jobs")).catch(() => []);
332
+ const loaded = await Promise.all(files.filter((name) => /^[0-9a-f-]{36}\.json$/.test(name)).map((name) => getSwarm(name.slice(0, -5)).catch(() => null)));
333
+ return loaded.filter((job): job is SwarmJob => !!job && samePath(job.cwd, cwd)).sort((a, b) => b.createdAt - a.createdAt).slice(0, 20);
334
+ }
335
+ export async function cancelSwarm(id: string, cwdValue: unknown): Promise<SwarmJob> {
336
+ const job = await getSwarm(id);
337
+ const cwd = await resolveWorkspacePath(cwdValue);
338
+ if (!samePath(job.cwd, cwd)) throw new BoundaryError("swarm belongs to another workspace");
339
+ const controller = controllers.get(id);
340
+ if (!controller || job.status !== "running") throw new BoundaryError("swarm is not running");
341
+ controller.abort();
342
+ return getSwarm(id);
343
+ }
344
+ export async function acceptSwarmTask(id: string, index: number, cwdValue: unknown): Promise<SwarmJob> {
345
+ const job = jobs.get(id) ?? await getSwarm(id);
346
+ const cwd = await resolveWorkspacePath(cwdValue);
347
+ if (!samePath(job.cwd, cwd)) throw new BoundaryError("swarm belongs to another workspace");
348
+ if (job.status === "running") throw new BoundaryError("wait for all swarm tasks to finish before accepting changes");
349
+ if (!resolveProjectTrust(job.root).trusted) throw new BoundaryError("trust this project before accepting changes");
350
+ if (!Number.isInteger(index) || index < 0 || index >= job.tasks.length) throw new BoundaryError("invalid task index");
351
+ const task = job.tasks[index];
352
+ if (task.status !== "completed" || !task.patch || task.accepted) throw new BoundaryError("task has no unapplied patch");
353
+ if (accepting.has(job.root)) throw new BoundaryError("another patch is being accepted");
354
+ accepting.add(job.root);
355
+ const patchFile = path.join(STORE, `accept-${randomUUID()}.patch`);
356
+ try {
357
+ const head = (await git(job.root, ["rev-parse", "HEAD"])).trim();
358
+ if (head !== job.base) throw new BoundaryError("repository HEAD changed; review the patch again before applying");
359
+ await fs.mkdir(STORE, { recursive: true });
360
+ await fs.writeFile(patchFile, task.patch, "utf8");
361
+ await git(job.root, ["apply", "--check", "--binary", patchFile]);
362
+ await git(job.root, ["apply", "--binary", patchFile]);
363
+ task.accepted = true;
364
+ jobs.set(id, job);
365
+ await save(job);
366
+ return publicJob(job);
367
+ } finally {
368
+ accepting.delete(job.root);
369
+ if (isPathInside(STORE, patchFile)) await fs.rm(patchFile, { force: true }).catch(() => undefined);
370
+ }
371
+ }
@@ -0,0 +1,122 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { promisify } from "node:util";
7
+ import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
8
+ import { resolveProjectTrust } from "./pi";
9
+ import { APP_ROOT } from "./app-root";
10
+ import { beginActivity } from "./runtime-activity";
11
+
12
+ const exec = promisify(execFile);
13
+ const running = new Set<string>();
14
+ const TEST_NAME = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
15
+ const MAX_SOURCE_BYTES = 200_000;
16
+
17
+ export interface TestRunResult {
18
+ file: string;
19
+ passed: number;
20
+ failed: number;
21
+ skipped: number;
22
+ durationMs: number;
23
+ success: boolean;
24
+ tests: Array<{ name: string; status: string; durationMs: number }>;
25
+ output: string;
26
+ }
27
+
28
+ async function testRoot(cwdValue: unknown): Promise<{ cwd: string; root: string }> {
29
+ const cwd = await resolveWorkspacePath(cwdValue);
30
+ const root = path.join(cwd, "tests");
31
+ const real = await fs.realpath(root).catch(() => { throw new BoundaryError("tests directory not found"); });
32
+ if (!isPathInside(cwd, real)) throw new BoundaryError("tests directory escapes workspace");
33
+ return { cwd, root: real };
34
+ }
35
+ async function testFile(cwdValue: unknown, fileValue: unknown): Promise<{ cwd: string; file: string; relative: string }> {
36
+ const { cwd, root } = await testRoot(cwdValue);
37
+ if (typeof fileValue !== "string" || !TEST_NAME.test(fileValue) || fileValue.length > 4096) throw new BoundaryError("invalid test file");
38
+ const file = await fs.realpath(path.resolve(cwd, fileValue)).catch(() => { throw new BoundaryError("test file not found"); });
39
+ if (!isPathInside(root, file)) throw new BoundaryError("test file escapes tests directory");
40
+ const stat = await fs.stat(file);
41
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) throw new BoundaryError("test file is too large");
42
+ return { cwd, file, relative: path.relative(cwd, file).replace(/\\/g, "/") };
43
+ }
44
+ export async function listTestFiles(cwdValue: unknown): Promise<string[]> {
45
+ const { cwd, root } = await testRoot(cwdValue);
46
+ const files: string[] = [];
47
+ async function walk(dir: string): Promise<void> {
48
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
49
+ if (files.length >= 500) break;
50
+ if (entry.isSymbolicLink()) continue;
51
+ const candidate = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) await walk(candidate);
53
+ else if (entry.isFile() && TEST_NAME.test(entry.name)) files.push(path.relative(cwd, candidate).replace(/\\/g, "/"));
54
+ }
55
+ }
56
+ await walk(root);
57
+ return files.sort();
58
+ }
59
+ export async function readTestFile(cwdValue: unknown, fileValue: unknown): Promise<{ file: string; content: string }> {
60
+ const { file, relative } = await testFile(cwdValue, fileValue);
61
+ return { file: relative, content: await fs.readFile(file, "utf8") };
62
+ }
63
+ export function summarizeVitestReport(data: any, stdout: string, stderr: string): Pick<TestRunResult, "passed" | "failed" | "skipped" | "tests" | "output"> {
64
+ const suites = Array.isArray(data?.testResults) ? data.testResults : [];
65
+ const tests = suites.flatMap((suite: any) => Array.isArray(suite?.assertionResults)
66
+ ? suite.assertionResults.map((test: any) => ({ name: String(test.fullName ?? test.title ?? ""), status: String(test.status ?? "unknown"), durationMs: Number(test.duration ?? 0) }))
67
+ : []);
68
+ const failures = suites.flatMap((suite: any) => [
69
+ ...(suite?.message ? [String(suite.message)] : []),
70
+ ...(Array.isArray(suite?.assertionResults) ? suite.assertionResults.flatMap((test: any) => Array.isArray(test?.failureMessages) ? test.failureMessages.map(String) : []) : []),
71
+ ]);
72
+ return {
73
+ passed: Number(data?.numPassedTests ?? tests.filter((test: { status: string }) => test.status === "passed").length),
74
+ failed: Number(data?.numFailedTests ?? tests.filter((test: { status: string }) => test.status === "failed").length),
75
+ skipped: Number(data?.numPendingTests ?? 0),
76
+ tests,
77
+ output: `${data ? failures.join("\n\n") : stdout}\n${stderr}`.trim().slice(-20_000),
78
+ };
79
+ }
80
+ export async function runTestFile(cwdValue: unknown, fileValue: unknown): Promise<TestRunResult> {
81
+ const { cwd, file, relative } = await testFile(cwdValue, fileValue);
82
+ if (!resolveProjectTrust(cwd).trusted) throw new BoundaryError("trust this project before running tests");
83
+ if (running.has(cwd)) throw new BoundaryError("a test run is already active for this workspace");
84
+ const releaseActivity = beginActivity("tests");
85
+ running.add(cwd);
86
+ const started = Date.now();
87
+ const reportFile = path.join(os.tmpdir(), `piweb-vitest-${randomUUID()}.json`);
88
+ let stdout = "";
89
+ let stderr = "";
90
+ let exitCode = 0;
91
+ try {
92
+ const vitest = await fs.realpath(path.join(cwd, "node_modules", "vitest", "vitest.mjs")).catch(() => {
93
+ throw new BoundaryError("local Vitest is not installed in this workspace");
94
+ });
95
+ if (!isPathInside(path.join(cwd, "node_modules"), vitest)) throw new BoundaryError("Vitest entry escapes node_modules");
96
+ try {
97
+ const result = await exec(process.execPath, [path.join(APP_ROOT, "scripts/run-test-file.mjs"), cwd, file, reportFile], {
98
+ cwd, timeout: 180_000, maxBuffer: 2_000_000, windowsHide: true,
99
+ env: { ...process.env, CI: "1", FORCE_COLOR: "0", NO_COLOR: "1" },
100
+ });
101
+ stdout = result.stdout;
102
+ stderr = result.stderr;
103
+ } catch (error) {
104
+ const failure = error as { stdout?: string; stderr?: string; code?: number | string };
105
+ stdout = failure.stdout ?? "";
106
+ stderr = failure.stderr ?? String(error);
107
+ exitCode = typeof failure.code === "number" ? failure.code : 1;
108
+ }
109
+ let data: any = null;
110
+ try { data = JSON.parse(await fs.readFile(reportFile, "utf8")); } catch { /* preserve raw output below */ }
111
+ return {
112
+ file: relative,
113
+ durationMs: Date.now() - started,
114
+ success: exitCode === 0 && data?.success === true,
115
+ ...summarizeVitestReport(data, stdout, stderr),
116
+ };
117
+ } finally {
118
+ running.delete(cwd);
119
+ releaseActivity();
120
+ await fs.rm(reportFile, { force: true }).catch(() => undefined);
121
+ }
122
+ }