@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,9 +33,12 @@ import { TrajLedger, buildTrajectoryFromEntries, toTrajTokens } from "./trajecto
33
33
  import { startArchivedSessionRetention } from "./session-retention";
34
34
  import { sanitizeToolOutput } from "./text-sanitize";
35
35
  import { BoundaryError } from "./path-security";
36
+ import { beginActivity } from "./runtime-activity";
36
37
  import { estimateTokensOf } from "./process-format";
37
38
  import { getPiSettings } from "./pi-settings";
38
39
  import { createManagedNiubashTools } from "./managed-niubash";
40
+ import { createDelegationTool } from "./delegation";
41
+ import { awaitingGoalMessage, isWorkflowMode, planCanRun, readWorkflow, saveWorkflow, workflowInstruction, type WorkflowState } from "./workflow";
39
42
  import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
40
43
  import type {
41
44
  AgentCommand,
@@ -66,6 +69,7 @@ interface Managed {
66
69
  ledger: TrajLedger;
67
70
  lastActive: number;
68
71
  toolPreset: ToolPreset;
72
+ workflow: WorkflowState;
69
73
  /** 显式逐项选择优先于 preset;保留失效名称以便资源重新启用后恢复用户选择。 */
70
74
  customActiveTools?: string[];
71
75
  resourceVersion: number;
@@ -527,6 +531,20 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
527
531
  break;
528
532
  case "agent_settled":
529
533
  m.runActive = false;
534
+ if (m.workflow.mode === "plan") {
535
+ if (m.workflow.planStatus === "planning") {
536
+ const answer = [...(m.session?.messages ?? [])].reverse().find((message) => message.role === "assistant");
537
+ const ready = answer?.role === "assistant" && answer.stopReason === "stop" && answer.content.some((part) => part.type === "text" && part.text.trim());
538
+ const entry = ready ? m.sm.getBranch().slice().reverse().find((item) => item.type === "message" && item.message.role === "assistant" && item.message.stopReason === "stop") : undefined;
539
+ setWorkflow(m, { ...m.workflow, planStatus: entry ? "ready" : "idle", planId: entry?.id });
540
+ } else if (m.workflow.planStatus === "executing") {
541
+ // 执行报错或被中止时方案仍待执行:以当前叶节点为新的批准锚点,可以直接再次执行。
542
+ const answer = [...(m.session?.messages ?? [])].reverse().find((message) => message.role === "assistant");
543
+ const failed = answer?.role === "assistant" && (answer.stopReason === "error" || answer.stopReason === "aborted");
544
+ const anchor = failed ? m.sm.getLeafId() : null;
545
+ setWorkflow(m, anchor ? { ...m.workflow, planStatus: "ready", planId: anchor } : { ...m.workflow, planStatus: "idle", planId: undefined });
546
+ }
547
+ }
530
548
  publish(m, { type: "status", isStreaming: false, state: "idle", ts: now });
531
549
  publishUsage(m);
532
550
  if (m.resourceReloadPending) queueMicrotask(() => void reloadManagedResources(m));
@@ -636,6 +654,7 @@ function registerManaged(sessionPath: string, cwd: string, sm: SessionManager):
636
654
  ledger: new TrajLedger(initialTrajectory),
637
655
  lastActive: Date.now(),
638
656
  toolPreset: "readonly",
657
+ workflow: readWorkflow(sm),
639
658
  resourceVersion: 0,
640
659
  resourceReloadPending: false,
641
660
  resourceReloading: false,
@@ -784,12 +803,33 @@ function publishEnvironmentTrajectory(m: Managed, session: AgentSession): void {
784
803
  }
785
804
 
786
805
  export async function ensureSession(m: Managed): Promise<AgentSession> {
806
+ const release = beginActivity("session");
807
+ try { return await ensureSessionActive(m); }
808
+ finally { release(); }
809
+ }
810
+
811
+ async function ensureSessionActive(m: Managed): Promise<AgentSession> {
787
812
  if (m.disposed) throw new Error("session is disposed");
788
813
  if (m.creating) return m.creating;
789
814
  if (m.session) return m.session;
790
815
  m.creating = (async () => {
791
816
  await resourceLoaderReady(m.cwd);
792
- const loader = await createSessionResourceLoader(m.cwd);
817
+ const loader = await createSessionResourceLoader(m.cwd, [{
818
+ name: "piweb-workflow",
819
+ hidden: true,
820
+ factory: (pi) => {
821
+ pi.on("before_agent_start", (event) => {
822
+ const instruction = workflowInstruction(m.workflow);
823
+ if (instruction) {
824
+ if (event.systemPromptOptions.forceSystemPrompt !== undefined) event.systemPromptOptions.forceSystemPrompt += `\n\n${instruction}`;
825
+ else event.systemPromptOptions.sections.piweb_workflow = instruction;
826
+ } else delete event.systemPromptOptions.sections.piweb_workflow;
827
+ if (m.workflow.mode === "plan" && m.workflow.planStatus !== "executing") {
828
+ event.systemPromptOptions.selectedTools = event.systemPromptOptions.selectedTools.filter((name) => [...TOOL_PRESETS.readonly, "piweb_delegate"].includes(name));
829
+ }
830
+ });
831
+ },
832
+ }]);
793
833
  let created: AgentSession | undefined;
794
834
  try {
795
835
  if (m.disposed) throw new Error("session is disposed");
@@ -801,7 +841,7 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
801
841
  resourceLoader: loader,
802
842
  settingsManager: getSettingsManager(m.cwd),
803
843
  // Windows: 同名 bash 覆盖 SDK 内置工具,指向托管 niubash;其它平台返回 [],走 Pi 默认。
804
- customTools: createManagedNiubashTools(m.cwd),
844
+ customTools: [...createManagedNiubashTools(m.cwd), createDelegationTool(m.cwd, () => m.session)],
805
845
  });
806
846
  created = session;
807
847
  if (m.disposed) throw new Error("session is disposed");
@@ -848,7 +888,9 @@ function isManagedBusy(m: Managed): boolean {
848
888
 
849
889
  function allowedToolNames(m: Managed, session: AgentSession): string[] {
850
890
  const allowed = m.customActiveTools ?? (m.toolPreset === "full" ? undefined : TOOL_PRESETS[m.toolPreset]);
851
- return listAllToolNames(session).filter((name) => !allowed || allowed.includes(name));
891
+ const effective = allowed ? [...allowed, ...(m.customActiveTools || m.toolPreset === "readonly" ? [] : ["piweb_delegate"])] : undefined;
892
+ const planning = m.workflow.mode === "plan" && m.workflow.planStatus !== "executing";
893
+ return listAllToolNames(session).filter((name) => (!effective || effective.includes(name)) && (!planning || [...TOOL_PRESETS.readonly, "piweb_delegate"].includes(name)));
852
894
  }
853
895
 
854
896
  function applyToolPolicy(m: Managed, session: AgentSession): void {
@@ -859,6 +901,20 @@ function publishTools(m: Managed, session: AgentSession): void {
859
901
  publish(m, { type: "tools", active: listActiveTools(session), all: listAllToolNames(session), toolPreset: m.toolPreset, customActiveTools: m.customActiveTools ?? null, ts: Date.now() });
860
902
  }
861
903
 
904
+ function setWorkflow(m: Managed, next: WorkflowState): void {
905
+ saveWorkflow(m.sm, next);
906
+ m.workflow = next;
907
+ publishWorkflow(m);
908
+ }
909
+
910
+ function publishWorkflow(m: Managed): void {
911
+ if (m.session && !m.disposed) {
912
+ applyToolPolicy(m, m.session);
913
+ publishTools(m, m.session);
914
+ }
915
+ publish(m, { type: "workflow", workflow: m.workflow, ts: Date.now() });
916
+ }
917
+
862
918
  /** SDK reload 自动启用扩展工具,注册新工具也会刷新全集;统一在激活边界收敛到服务端选择。 */
863
919
  function installSessionPolicy(m: Managed, session: AgentSession): void {
864
920
  const setActive = session.setActiveToolsByName.bind(session);
@@ -1174,12 +1230,12 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1174
1230
  }
1175
1231
  for (let i = messages.length - 1; i >= 0 && !compactedSinceLastAssistant; i--) {
1176
1232
  const msg = messages[i];
1177
- if (msg.role === "assistant" && msg.usage) {
1178
- lastTurnTokens =
1233
+ if (msg.role === "assistant" && msg.usage && msg.stopReason !== "error" && msg.stopReason !== "aborted") {
1234
+ lastTurnTokens = Number(msg.usage.totalTokens ?? 0) || (
1179
1235
  Number(msg.usage.input ?? 0) +
1180
1236
  Number(msg.usage.cacheRead ?? 0) +
1181
1237
  Number(msg.usage.cacheWrite ?? 0) +
1182
- Number(msg.usage.output ?? 0);
1238
+ Number(msg.usage.output ?? 0));
1183
1239
  break;
1184
1240
  }
1185
1241
  }
@@ -1244,6 +1300,7 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1244
1300
  thinkingLevel,
1245
1301
  thinkingLevels,
1246
1302
  toolPreset: m.toolPreset,
1303
+ workflow: m.workflow,
1247
1304
  customActiveTools: m.customActiveTools ?? null,
1248
1305
  extensionUiRequests: m.ui?.getPendingRequests() ?? [],
1249
1306
  tools,
@@ -1297,12 +1354,52 @@ export interface CommandResult {
1297
1354
  }
1298
1355
 
1299
1356
  export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandResult> {
1357
+ let release: (() => void) | undefined;
1358
+ try {
1359
+ if (!["abort", "clearQueue", "extensionUiResponse"].includes(cmd.cmd)) release = beginActivity("session");
1360
+ return await executeCommand(m, cmd);
1361
+ } catch (error) {
1362
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1363
+ } finally {
1364
+ release?.();
1365
+ }
1366
+ }
1367
+
1368
+ function trackedPrompt(session: AgentSession, ...args: Parameters<AgentSession["prompt"]>): ReturnType<AgentSession["prompt"]> {
1369
+ const release = beginActivity("session");
1370
+ try { return session.prompt(...args).finally(release); }
1371
+ catch (error) { release(); throw error; }
1372
+ }
1373
+
1374
+ async function executeCommand(m: Managed, cmd: AgentCommand): Promise<CommandResult> {
1300
1375
  touch(m);
1301
1376
  try {
1302
1377
  switch (cmd.cmd) {
1303
1378
  case "prepare":
1304
1379
  await ensureSession(m);
1305
1380
  return { ok: true };
1381
+ case "setWorkflowMode": {
1382
+ if (!isWorkflowMode(cmd.mode)) return { ok: false, error: "invalid workflow mode" };
1383
+ if (isManagedBusy(m)) return { ok: false, error: "cannot change workflow while the agent is running" };
1384
+ // 重选当前模式不重置:否则会清掉 Goal 目标或待执行的方案,并多写一条记录。
1385
+ if (cmd.mode === m.workflow.mode) return { ok: true };
1386
+ setWorkflow(m, { mode: cmd.mode, planStatus: "idle", goal: "" });
1387
+ return { ok: true };
1388
+ }
1389
+ case "approvePlan": {
1390
+ if (!planCanRun(m.workflow)) return { ok: false, error: "there is no plan awaiting approval" };
1391
+ if (isManagedBusy(m)) return { ok: false, error: "session is busy" };
1392
+ const branch = m.sm.getBranch();
1393
+ const planIndex = branch.findIndex((entry) => entry.id === m.workflow.planId);
1394
+ if (planIndex < 0 || branch.slice(planIndex + 1).some((entry) => entry.type === "message")) {
1395
+ return { ok: false, error: "the plan is no longer the latest conversation; request a new plan" };
1396
+ }
1397
+ const ready = m.workflow;
1398
+ setWorkflow(m, { ...ready, planStatus: "executing" });
1399
+ const result = await execute(m, { cmd: "prompt", text: "Execute the plan you just proposed. Verify the changes and report any unfinished work." });
1400
+ if (!result.ok) setWorkflow(m, ready);
1401
+ return result;
1402
+ }
1306
1403
  case "prompt": {
1307
1404
  const text = (cmd.text ?? "").trim();
1308
1405
  if (!text && !cmd.images?.length) return { ok: false, error: "empty prompt" };
@@ -1314,6 +1411,7 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1314
1411
  if (m.disposed) throw new Error("session is disposed");
1315
1412
  // 只有用户显式选择 steer/followUp 才排队;旧的普通提交不能悄悄成为 steering。
1316
1413
  if ((session.isStreaming || m.runActive) && !cmd.behavior) return { ok: false, error: "session is busy; choose steer or followUp" };
1414
+ if (m.workflow.mode === "plan" && (session.isStreaming || m.runActive)) return { ok: false, error: "wait for the current plan or execution to finish" };
1317
1415
  if (!session.isStreaming) {
1318
1416
  // 基线快照不能无限阻塞首条 prompt(git 不可用时每次探测至多 30s 超时):
1319
1417
  // 5s 内没完成就放行 prompt,快照后台补拍(工具结束后照常记步,外部修改兜底)。
@@ -1323,11 +1421,18 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1323
1421
  ]);
1324
1422
  }
1325
1423
  if (m.disposed) throw new Error("session is disposed");
1326
- return await new Promise<CommandResult>((resolve) => {
1424
+ const previousWorkflow = m.workflow;
1425
+ if (m.workflow.mode === "plan" && m.workflow.planStatus !== "executing") {
1426
+ setWorkflow(m, { ...m.workflow, planStatus: "planning", planId: undefined });
1427
+ } else if (m.workflow.mode === "goal" && !m.workflow.goal && text) {
1428
+ setWorkflow(m, { ...m.workflow, goal: text.slice(0, 8_000) });
1429
+ }
1430
+ const result = await new Promise<CommandResult>((resolve) => {
1327
1431
  let accepted = false;
1328
- const run = session.prompt(text, {
1432
+ const run = trackedPrompt(session, text, {
1329
1433
  images: cmd.images,
1330
1434
  streamingBehavior: cmd.behavior,
1435
+ expandPromptTemplates: m.workflow.mode !== "plan" || m.workflow.planStatus === "executing",
1331
1436
  source: "rpc",
1332
1437
  preflightResult: (success) => {
1333
1438
  if (success) {
@@ -1344,7 +1449,9 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1344
1449
  if (accepted) publish(m, { type: "error", message, ts: Date.now() });
1345
1450
  else resolve({ ok: false, error: message });
1346
1451
  });
1347
- });
1452
+ });
1453
+ if (!result.ok && m.workflow !== previousWorkflow) setWorkflow(m, previousWorkflow);
1454
+ return result;
1348
1455
  } finally {
1349
1456
  m.promptSubmitting = false;
1350
1457
  if (!m.disposed) publish(m, { type: "status", isStreaming: isManagedBusy(m), state: isManagedBusy(m) ? "running" : "idle", ts: Date.now() });
@@ -1353,6 +1460,7 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1353
1460
  }
1354
1461
  case "steer":
1355
1462
  case "followUp": {
1463
+ if (m.workflow.mode === "plan") return { ok: false, error: "Plan mode does not queue prompts" };
1356
1464
  if (m.promptSubmitting || m.resourceReloading) return { ok: false, error: "session is busy accepting another command" };
1357
1465
  const session = await ensureSession(m);
1358
1466
  if (m.promptSubmitting || m.resourceReloading || !session.isStreaming) return { ok: false, error: "session is not accepting queued messages" };
@@ -1441,6 +1549,10 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1441
1549
  const entryId = String((cmd as any).entryId ?? "");
1442
1550
  if (!entryId) return { ok: false, error: "missing entryId" };
1443
1551
  const r = await session.navigateTree(entryId);
1552
+ if (!r?.cancelled) {
1553
+ m.workflow = readWorkflow(m.sm);
1554
+ publishWorkflow(m);
1555
+ }
1444
1556
  return { ok: !r?.cancelled, data: { cancelled: r?.cancelled === true, editorText: r?.editorText } };
1445
1557
  }
1446
1558
  case "editAndResend": {
@@ -1450,6 +1562,9 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1450
1562
  const text = (cmd.text ?? "").trim();
1451
1563
  if (!entryId) return { ok: false, error: "missing entryId" };
1452
1564
  if (!text) return { ok: false, error: "empty prompt" };
1565
+ const target = m.sm.getEntry(entryId);
1566
+ if (target?.type !== "message" || target.message.role !== "user") return { ok: false, error: "message is not editable" };
1567
+ const images = Array.isArray(target.message.content) ? target.message.content.filter((part) => part.type === "image") : [];
1453
1568
  if (m.promptSubmitting || m.resourceReloading) return { ok: false, error: "session is busy accepting another command" };
1454
1569
  m.promptSubmitting = true;
1455
1570
  try {
@@ -1463,11 +1578,64 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1463
1578
  }
1464
1579
  }
1465
1580
  if (m.disposed) throw new Error("session is disposed");
1466
- const moved = await session.navigateTree(entryId);
1581
+ // SDK 对当前叶节点直接返回 no-op;先移开叶节点,才能把最后一条用户消息也原位重发。
1582
+ const originalLeafId = m.sm.getLeafId();
1583
+ const leafWasTarget = originalLeafId === entryId;
1584
+ if (leafWasTarget) {
1585
+ if (target.parentId) m.sm.branch(target.parentId);
1586
+ else m.sm.resetLeaf();
1587
+ }
1588
+ let moved;
1589
+ try {
1590
+ moved = await session.navigateTree(entryId);
1591
+ } catch (error) {
1592
+ if (leafWasTarget) m.sm.branch(entryId);
1593
+ throw error;
1594
+ }
1595
+ if (moved?.cancelled && leafWasTarget) m.sm.branch(entryId);
1467
1596
  if (moved?.cancelled) return { ok: false, error: "edit cancelled" };
1597
+ m.workflow = readWorkflow(m.sm);
1598
+ publishWorkflow(m);
1468
1599
  await growthOf(m).prepare().catch(() => undefined);
1469
- await session.prompt(text);
1470
- return { ok: true };
1600
+ const previousWorkflow = m.workflow;
1601
+ if (m.workflow.mode === "plan") setWorkflow(m, { ...m.workflow, planStatus: "planning", planId: undefined });
1602
+ // 编辑的正是定下目标的那条消息时,目标跟着改;旧目标记录是它的祖先,回退分支不会带走它。
1603
+ else if (m.workflow.mode === "goal" && awaitingGoalMessage(m.sm) && m.workflow.goal !== text.slice(0, 8_000)) {
1604
+ setWorkflow(m, { ...m.workflow, goal: text.slice(0, 8_000) });
1605
+ }
1606
+ const options = { images, expandPromptTemplates: m.workflow.mode !== "plan" };
1607
+ return await new Promise<CommandResult>((resolve) => {
1608
+ let accepted = false;
1609
+ const run = trackedPrompt(session, text, {
1610
+ ...options,
1611
+ preflightResult: (success) => {
1612
+ if (success) {
1613
+ accepted = true;
1614
+ resolve({ ok: true, data: { accepted: true } });
1615
+ }
1616
+ },
1617
+ });
1618
+ void run.then(() => {
1619
+ if (!accepted) resolve({ ok: true });
1620
+ }, (error) => {
1621
+ const message = String(error?.message ?? error);
1622
+ if (accepted) {
1623
+ publish(m, { type: "error", message, ts: Date.now() });
1624
+ return;
1625
+ }
1626
+ // Rejected preflight never wrote the edited turn; restore the old branch before the client resyncs.
1627
+ try {
1628
+ if (originalLeafId) m.sm.branch(originalLeafId);
1629
+ else m.sm.resetLeaf();
1630
+ session.refreshContext();
1631
+ m.workflow = readWorkflow(m.sm);
1632
+ publishWorkflow(m);
1633
+ } catch {
1634
+ if (m.workflow !== previousWorkflow) setWorkflow(m, previousWorkflow);
1635
+ }
1636
+ resolve({ ok: false, error: message });
1637
+ });
1638
+ });
1471
1639
  } finally {
1472
1640
  m.promptSubmitting = false;
1473
1641
  }
@@ -1,4 +1,4 @@
1
- import type { AgentCommand, ImageAttachment, ToolPreset } from "./types";
1
+ import type { AgentCommand, ImageAttachment, ToolPreset, WorkflowMode } from "./types";
2
2
 
3
3
  const COMMANDS = new Set<AgentCommand["cmd"]>([
4
4
  "prepare",
@@ -10,6 +10,8 @@ const COMMANDS = new Set<AgentCommand["cmd"]>([
10
10
  "setModel",
11
11
  "setThinkingLevel",
12
12
  "setToolPreset",
13
+ "setWorkflowMode",
14
+ "approvePlan",
13
15
  "setActiveTools",
14
16
  "rename",
15
17
  "fork",
@@ -21,6 +23,7 @@ const COMMANDS = new Set<AgentCommand["cmd"]>([
21
23
  "reload",
22
24
  ]);
23
25
  const TOOL_PRESETS = new Set<ToolPreset>(["readonly", "standard", "full"]);
26
+ const WORKFLOW_MODES = new Set<WorkflowMode>(["agent", "plan", "goal"]);
24
27
  const STREAMING_BEHAVIORS = new Set(["steer", "followUp"]);
25
28
  const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
26
29
  const MAX_TEXT = 1_000_000;
@@ -78,6 +81,10 @@ export function parseAgentCommand(value: unknown): CommandParseResult {
78
81
  if (typeof raw.preset !== "string" || !TOOL_PRESETS.has(raw.preset as ToolPreset)) throw new Error("invalid tool preset");
79
82
  command.preset = raw.preset as ToolPreset;
80
83
  }
84
+ if (raw.mode !== undefined) {
85
+ if (typeof raw.mode !== "string" || !WORKFLOW_MODES.has(raw.mode as WorkflowMode)) throw new Error("invalid workflow mode");
86
+ command.mode = raw.mode as WorkflowMode;
87
+ }
81
88
  if (raw.behavior !== undefined) {
82
89
  if (typeof raw.behavior !== "string" || !STREAMING_BEHAVIORS.has(raw.behavior)) throw new Error("invalid streaming behavior");
83
90
  command.behavior = raw.behavior as "steer" | "followUp";
@@ -108,6 +115,7 @@ export function parseAgentCommand(value: unknown): CommandParseResult {
108
115
  if (command.cmd === "setModel" && (!command.provider || !command.modelId)) throw new Error("provider and modelId are required");
109
116
  if (command.cmd === "setThinkingLevel" && !command.level) throw new Error("level is required");
110
117
  if (command.cmd === "setToolPreset" && !command.preset) throw new Error("preset is required");
118
+ if (command.cmd === "setWorkflowMode" && !command.mode) throw new Error("mode is required");
111
119
  if (command.cmd === "setActiveTools" && !command.names) throw new Error("names are required");
112
120
  if (command.cmd === "navigate" && !command.entryId) throw new Error("entryId is required");
113
121
  return { ok: true, command };
@@ -0,0 +1,93 @@
1
+ import { DefaultResourceLoader, SessionManager, createAgentSession, defineTool, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { getAgentDir, getModelRuntime, getSettingsManager, TOOL_PRESETS, withResourceLock } from "./pi";
4
+
5
+ const MAX_TASKS = 3;
6
+ const TASK_TIMEOUT_MS = 120_000;
7
+ const MAX_REPORT_CHARS = 12_000;
8
+
9
+ const parameters = Type.Object({
10
+ tasks: Type.Array(Type.Object({
11
+ title: Type.String({ minLength: 1, maxLength: 80 }),
12
+ instruction: Type.String({ minLength: 1, maxLength: 4_000 }),
13
+ }), { minItems: 1, maxItems: MAX_TASKS }),
14
+ });
15
+
16
+ function reportOf(session: AgentSession): string {
17
+ const answer = [...session.messages].reverse().find((message) => message.role === "assistant");
18
+ if (!answer || answer.role !== "assistant") return "The subagent returned no assistant answer.";
19
+ return answer.content.filter((part) => part.type === "text").map((part) => part.text).join("\n").slice(0, MAX_REPORT_CHARS) || "The subagent returned no text answer.";
20
+ }
21
+
22
+ async function runSubagent(cwd: string, parent: AgentSession, instruction: string, signal?: AbortSignal): Promise<string> {
23
+ if (signal?.aborted) throw new Error("Delegation cancelled");
24
+ const loader = new DefaultResourceLoader({
25
+ cwd,
26
+ agentDir: getAgentDir(),
27
+ settingsManager: getSettingsManager(cwd),
28
+ noExtensions: true,
29
+ noPromptTemplates: true,
30
+ noThemes: true,
31
+ appendSystemPrompt: ["You are a read-only PiWeb subagent. Investigate the assigned task and return a concise, evidence-based report. Do not modify files or run commands. Do not delegate further."],
32
+ });
33
+ let child: AgentSession | undefined;
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ let timedOut = false;
36
+ const abort = () => { void child?.abort().catch(() => undefined); };
37
+ try {
38
+ await withResourceLock(() => loader.reload());
39
+ if (signal?.aborted) throw new Error("Delegation cancelled");
40
+ const created = await createAgentSession({
41
+ cwd,
42
+ agentDir: getAgentDir(),
43
+ sessionManager: SessionManager.inMemory(cwd),
44
+ modelRuntime: await getModelRuntime(),
45
+ model: parent.model ?? undefined,
46
+ thinkingLevel: parent.thinkingLevel,
47
+ settingsManager: getSettingsManager(cwd),
48
+ resourceLoader: loader,
49
+ tools: TOOL_PRESETS.readonly,
50
+ });
51
+ child = created.session;
52
+ if (signal?.aborted) throw new Error("Delegation cancelled");
53
+ signal?.addEventListener("abort", abort, { once: true });
54
+ timer = setTimeout(() => { timedOut = true; abort(); }, TASK_TIMEOUT_MS);
55
+ await child.prompt(instruction, { source: "rpc", expandPromptTemplates: false });
56
+ if (timedOut) throw new Error("Subagent timed out after two minutes");
57
+ if (signal?.aborted) throw new Error("Delegation cancelled");
58
+ return reportOf(child);
59
+ } finally {
60
+ if (timer) clearTimeout(timer);
61
+ signal?.removeEventListener("abort", abort);
62
+ child?.dispose();
63
+ loader.getExtensions().runtime.invalidate();
64
+ }
65
+ }
66
+
67
+ export function createDelegationTool(cwd: string, parent: () => AgentSession | null): ToolDefinition {
68
+ return defineTool({
69
+ name: "piweb_delegate",
70
+ label: "Delegate research",
71
+ description: "Delegate one to three independent, read-only research or review tasks to temporary Pi subagents. They cannot edit files, run shell commands, or delegate further. Use only when parallel investigation is useful; summarize their findings yourself.",
72
+ promptSnippet: "Delegate bounded, read-only research tasks to temporary subagents.",
73
+ parameters,
74
+ executionMode: "sequential",
75
+ async execute(_id, { tasks }, signal, onUpdate) {
76
+ const leader = parent();
77
+ if (!leader) throw new Error("Parent session is unavailable");
78
+ const reports: string[] = new Array(tasks.length);
79
+ await Promise.all(tasks.map(async (task, index) => {
80
+ try {
81
+ const report = await runSubagent(cwd, leader, task.instruction, signal);
82
+ reports[index] = `## ${task.title}\n${report}`;
83
+ } catch (error) {
84
+ reports[index] = `## ${task.title}\nFailed: ${String((error as Error)?.message ?? error)}`;
85
+ }
86
+ onUpdate?.({ content: [{ type: "text", text: reports.filter(Boolean).join("\n\n") }], details: undefined });
87
+ }));
88
+ if (signal?.aborted) throw new Error("Delegation cancelled");
89
+ const text = reports.join("\n\n");
90
+ return { content: [{ type: "text", text }], details: undefined };
91
+ },
92
+ });
93
+ }
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import { createRequire } from "node:module";
3
+ import * as nodeModule from "node:module";
4
4
  import path from "node:path";
5
5
  import { createBashToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  import { APP_ROOT } from "./app-root";
@@ -56,12 +56,16 @@ type LoadedPackage = { entryPath: string; value: unknown };
56
56
  * 这与平台包发布后的目录布局一一对应,不引入第二套解析规则。
57
57
  */
58
58
  export function resolveManagedNiubashPackageEntry(packageName: string, arch: SupportedArch, root = APP_ROOT): LoadedPackage {
59
- const require = createRequire(path.join(root, "package.json"));
59
+ // 必须通过命名空间调用。webpack 会改写从 node:module 具名导入的 createRequire:
60
+ // 参数不是字符串字面量时把调用擦掉,运行时变成对 undefined 调用 resolve。
61
+ const loadPackage = nodeModule.createRequire(path.join(root, "package.json"));
62
+ const resolveEntry = loadPackage.resolve.bind(loadPackage);
60
63
  try {
61
- return { entryPath: require.resolve(packageName), value: require(packageName) };
64
+ const entryPath = resolveEntry(packageName);
65
+ return { entryPath, value: loadPackage(packageName) };
62
66
  } catch (error) {
63
67
  const checkoutEntry = path.join(root, "platform-packages", PACKAGE_BY_ARCH[arch].directory, "index.cjs");
64
- if (fs.existsSync(checkoutEntry)) return { entryPath: checkoutEntry, value: require(checkoutEntry) };
68
+ if (fs.existsSync(checkoutEntry)) return { entryPath: checkoutEntry, value: loadPackage(checkoutEntry) };
65
69
  throw error;
66
70
  }
67
71
  }
package/src/lib/pi.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type SettingsManager,
10
10
  DefaultPackageManager,
11
11
  DefaultResourceLoader,
12
+ type InlineExtension,
12
13
  ModelRuntime,
13
14
  ProjectTrustStore,
14
15
  SessionManager,
@@ -157,10 +158,10 @@ function loaderKey(cwd: string): string {
157
158
  }
158
159
 
159
160
  /** 每个热会话必须拥有自己的扩展 runtime;这里只创建,不加入发现缓存。 */
160
- export async function createSessionResourceLoader(cwd: string): Promise<DefaultResourceLoader> {
161
+ export async function createSessionResourceLoader(cwd: string, extensionFactories: InlineExtension[] = []): Promise<DefaultResourceLoader> {
161
162
  const settingsManager = getSettingsManager(cwd);
162
163
  settingsManager.setProjectTrusted(resolveProjectTrust(cwd).trusted);
163
- const loader = new DefaultResourceLoader({ cwd: path.resolve(cwd), agentDir: getAgentDir(), settingsManager });
164
+ const loader = new DefaultResourceLoader({ cwd: path.resolve(cwd), agentDir: getAgentDir(), settingsManager, extensionFactories });
164
165
  const reload = loader.reload.bind(loader);
165
166
  loader.reload = async (options) => {
166
167
  settingsManager.setProjectTrusted(resolveProjectTrust(cwd).trusted);
@@ -0,0 +1,27 @@
1
+ type ActivityKind = "session" | "swarm" | "tests";
2
+ interface Registry { active: Map<symbol, ActivityKind>; maintenance: boolean }
3
+
4
+ // Route bundles must share a process-wide gate, not separate module-local registries.
5
+ const globals = globalThis as typeof globalThis & { __piwebRuntimeActivity?: Registry };
6
+ const registry = globals.__piwebRuntimeActivity ??= { active: new Map(), maintenance: false };
7
+
8
+ export class RuntimeBusyError extends Error {}
9
+
10
+ export function beginActivity(kind: ActivityKind): () => void {
11
+ if (registry.maintenance) throw new RuntimeBusyError("update maintenance is active; wait for the release to finish");
12
+ const token = Symbol(kind);
13
+ registry.active.set(token, kind);
14
+ return () => { registry.active.delete(token); };
15
+ }
16
+
17
+ export function beginMaintenance(): () => void {
18
+ if (registry.maintenance || registry.active.size) {
19
+ throw new RuntimeBusyError("work is active; wait for sessions, swarms and tests to finish before entering the maintenance window");
20
+ }
21
+ registry.maintenance = true;
22
+ let released = false;
23
+ return () => {
24
+ if (!released) registry.maintenance = false;
25
+ released = true;
26
+ };
27
+ }
@@ -18,6 +18,7 @@ import { dshSource } from "./dsh";
18
18
  import { zcodeSource } from "./zcode";
19
19
  import { opencodeSource } from "./opencode";
20
20
  import { importedKey, listImportedKeys, findImported, writeImportedSession } from "./writer";
21
+ import { withImportLock } from "./lock";
21
22
 
22
23
  export const IMPORT_SOURCE_MODULES: Record<ImportSource, ImportSourceModule> = {
23
24
  claude: claudeSource,
@@ -146,6 +147,10 @@ async function oversize(location: string): Promise<boolean> {
146
147
  }
147
148
 
148
149
  export async function importSessions(items: ImportSelection[], options: ImportOptions): Promise<ImportReport> {
150
+ return withImportLock(options.agentDir, () => importSessionsLocked(items, options));
151
+ }
152
+
153
+ async function importSessionsLocked(items: ImportSelection[], options: ImportOptions): Promise<ImportReport> {
149
154
  const results: ImportOutcome[] = [];
150
155
  const workspaces = new Set<string>();
151
156
  const fallback = (await isDirectory(options.fallbackCwd)) ? path.resolve(options.fallbackCwd as string) : undefined;
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+
5
+ const pending = new Map<string, Promise<void>>();
6
+
7
+ export async function withImportLock<T>(agentDir: string, operation: () => Promise<T>): Promise<T> {
8
+ await fs.mkdir(agentDir, { recursive: true });
9
+ const real = await fs.realpath(agentDir);
10
+ const key = process.platform === "win32" ? real.toLowerCase() : real;
11
+ const previous = pending.get(key) ?? Promise.resolve();
12
+ const result = previous.then(async () => {
13
+ // Serialize the global provenance lookup and write, including across server processes.
14
+ const release = await lockfile.lock(path.join(real, "piweb-session-import"), {
15
+ realpath: false, stale: 120_000,
16
+ retries: { retries: 60, minTimeout: 100, maxTimeout: 500 },
17
+ });
18
+ try { return await operation(); }
19
+ finally { await release(); }
20
+ });
21
+ const settled = result.then(() => undefined, () => undefined);
22
+ pending.set(key, settled);
23
+ void settled.then(() => { if (pending.get(key) === settled) pending.delete(key); });
24
+ return result;
25
+ }
@@ -235,9 +235,14 @@ export async function writeImportedSession(session: ImportedSession, options: Wr
235
235
  const dir = path.join(options.agentDir, "sessions", encodeSessionDir(options.cwd));
236
236
  await fs.mkdir(dir, { recursive: true });
237
237
  const target = path.join(dir, importedFileName(summary.source, summary.externalId, createdAt));
238
- const temporary = `${target}.${process.pid}.tmp`;
239
- await fs.writeFile(temporary, `${lines.join("\n")}\n`, "utf8");
240
- await fs.rename(temporary, target);
238
+ const temporary = `${target}.${randomUUID()}.tmp`;
239
+ try {
240
+ await fs.writeFile(temporary, `${lines.join("\n")}\n`, { encoding: "utf8", flag: "wx" });
241
+ // Publish a complete file atomically without replacing an existing imported session.
242
+ await fs.link(temporary, target);
243
+ } finally {
244
+ await fs.rm(temporary, { force: true });
245
+ }
241
246
  return { path: target, messageCount: messages.messages.length, skipped };
242
247
  }
243
248