@jmfederico/pi-web 1.202608.0 → 1.202608.2

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 (258) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.js +287 -113
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-BMWwxG7q.js +4 -0
  5. package/dist/client/assets/{TerminalPanel-fYrKc3jh.js → TerminalPanel-CacQDIYn.js} +6 -6
  6. package/dist/client/assets/index-DUW2xnoV.js +4279 -0
  7. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
  8. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
  9. package/dist/client/index.html +3 -3
  10. package/dist/config.js +110 -73
  11. package/dist/config.js.map +1 -1
  12. package/dist/docker/piWebDockerCommandPlan.js +4 -3
  13. package/dist/docker/piWebDockerCommandPlan.js.map +1 -1
  14. package/dist/environment.js +4 -0
  15. package/dist/environment.js.map +1 -0
  16. package/dist/nativeServices/installedServiceDefinitions.js +602 -0
  17. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  18. package/dist/nativeServices/serviceAction.js +174 -0
  19. package/dist/nativeServices/serviceAction.js.map +1 -0
  20. package/dist/nativeServices/serviceDoctor.js +374 -71
  21. package/dist/nativeServices/serviceDoctor.js.map +1 -1
  22. package/dist/nativeServices/servicePlan.js +1 -1
  23. package/dist/nativeServices/servicePlan.js.map +1 -1
  24. package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
  25. package/dist/pi-packages/relays/package.json +11 -0
  26. package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +4 -4
  27. package/dist/pi-packages/relays/prompts/relay-worktree.md +157 -0
  28. package/dist/pi-packages/relays/prompts/relay.md +152 -0
  29. package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +1 -1
  30. package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +1 -1
  31. package/dist/pi-packages/relays/skills/relay/SKILL.md +123 -0
  32. package/dist/pi-web-plugins/git/browser/git-contract.js +108 -0
  33. package/dist/pi-web-plugins/git/browser/git-panel.js +609 -0
  34. package/dist/pi-web-plugins/git/browser/gitFileList.js +43 -0
  35. package/dist/pi-web-plugins/git/browser/gitFileShared.js +16 -0
  36. package/dist/pi-web-plugins/git/browser/gitFileTree.js +81 -0
  37. package/dist/pi-web-plugins/git/browser/gitFileViewPreference.js +35 -0
  38. package/dist/pi-web-plugins/git/browser/gitRoute.js +42 -0
  39. package/dist/pi-web-plugins/git/browser/pi-web-plugin.js +10 -0
  40. package/dist/pi-web-plugins/git/browser/unifiedDiff.js +301 -0
  41. package/dist/{server/git/gitService.js → pi-web-plugins/git/git-backend.js} +209 -60
  42. package/dist/pi-web-plugins/git/package.json +16 -0
  43. package/dist/pi-web-plugins/git/server-plugin.js +176 -0
  44. package/dist/pi-web-plugins/info/infoInternals.js +16 -2
  45. package/dist/pi-web-plugins/info/package.json +1 -1
  46. package/dist/pi-web-plugins/info/pi-web-plugin.js +2 -2
  47. package/dist/pi-web-plugins/updates/package.json +1 -1
  48. package/dist/pi-web-plugins/updates/pi-web-plugin.js +1 -1
  49. package/dist/pi-web-plugins/workspace-tasks/package.json +1 -1
  50. package/dist/pi-web-plugins/workspace-tasks/pi-web-plugin.js +3 -3
  51. package/dist/piWebVersionReport.js +63 -10
  52. package/dist/piWebVersionReport.js.map +1 -1
  53. package/dist/plugin-api.d.ts +33 -16
  54. package/dist/pluginRecoveryCli.js +172 -0
  55. package/dist/pluginRecoveryCli.js.map +1 -0
  56. package/dist/server/activity/workspaceActivityService.js +27 -19
  57. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  58. package/dist/server/app.js +41 -15
  59. package/dist/server/app.js.map +1 -1
  60. package/dist/server/configRoutes.js +3 -21
  61. package/dist/server/configRoutes.js.map +1 -1
  62. package/dist/server/knownAutoInstallPiPackages.js +30 -0
  63. package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
  64. package/dist/server/machines/machineClient.js +23 -4
  65. package/dist/server/machines/machineClient.js.map +1 -1
  66. package/dist/server/machines/machinePluginProxyRoutes.js +74 -17
  67. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  68. package/dist/server/machines/machineProxyRoutes.js +165 -8
  69. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  70. package/dist/server/machines/machineService.js +23 -0
  71. package/dist/server/machines/machineService.js.map +1 -1
  72. package/dist/server/piPackageIdentity.js +25 -0
  73. package/dist/server/piPackageIdentity.js.map +1 -0
  74. package/dist/server/piPackageService.js +69 -8
  75. package/dist/server/piPackageService.js.map +1 -1
  76. package/dist/server/piWebPluginCatalog.js +584 -0
  77. package/dist/server/piWebPluginCatalog.js.map +1 -0
  78. package/dist/server/piWebPluginLifecycle.js +162 -0
  79. package/dist/server/piWebPluginLifecycle.js.map +1 -0
  80. package/dist/server/piWebPluginService.js +159 -261
  81. package/dist/server/piWebPluginService.js.map +1 -1
  82. package/dist/server/piWebStatus.js +41 -9
  83. package/dist/server/piWebStatus.js.map +1 -1
  84. package/dist/server/plugins/pluginBackendProxyRoutes.js +69 -0
  85. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -0
  86. package/dist/server/plugins/serverPluginExec.js +184 -0
  87. package/dist/server/plugins/serverPluginExec.js.map +1 -0
  88. package/dist/server/plugins/serverPluginRuntime.js +480 -0
  89. package/dist/server/plugins/serverPluginRuntime.js.map +1 -0
  90. package/dist/server/projectTrustRoutes.js +80 -0
  91. package/dist/server/projectTrustRoutes.js.map +1 -0
  92. package/dist/server/projects/projectService.js +3 -1
  93. package/dist/server/projects/projectService.js.map +1 -1
  94. package/dist/server/realtime/sessionEventHub.js +26 -11
  95. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  96. package/dist/server/requestCancellation.js +32 -0
  97. package/dist/server/requestCancellation.js.map +1 -0
  98. package/dist/server/sessiond/agentProcessEnvironment.js +29 -30
  99. package/dist/server/sessiond/agentProcessEnvironment.js.map +1 -1
  100. package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
  101. package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
  102. package/dist/server/sessiond/pluginBackendRoutes.js +64 -0
  103. package/dist/server/sessiond/pluginBackendRoutes.js.map +1 -0
  104. package/dist/server/sessiond/sessionDaemonShutdown.js +24 -0
  105. package/dist/server/sessiond/sessionDaemonShutdown.js.map +1 -0
  106. package/dist/server/sessiond/sessionProxyRoutes.js +2 -2
  107. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  108. package/dist/server/sessiond/sessionServiceDependencies.js +2 -1
  109. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
  110. package/dist/server/sessiond/sessiondStateOwnership.js +235 -0
  111. package/dist/server/sessiond/sessiondStateOwnership.js.map +1 -0
  112. package/dist/server/sessiond/workspaceCatalogRoutes.js +31 -0
  113. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -0
  114. package/dist/server/sessiond/workspaceRemovalRoutes.js +41 -0
  115. package/dist/server/sessiond/workspaceRemovalRoutes.js.map +1 -0
  116. package/dist/server/sessiond.js +284 -98
  117. package/dist/server/sessiond.js.map +1 -1
  118. package/dist/server/sessions/authService.js +15 -54
  119. package/dist/server/sessions/authService.js.map +1 -1
  120. package/dist/server/sessions/dockerEnvironmentFacts.js +254 -0
  121. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -0
  122. package/dist/server/sessions/modelCatalogRefresher.js +4 -3
  123. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -1
  124. package/dist/server/sessions/piSessionManagerGateway.js +18 -67
  125. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  126. package/dist/server/sessions/piSessionService.js +495 -142
  127. package/dist/server/sessions/piSessionService.js.map +1 -1
  128. package/dist/server/sessions/sessionCommandService.js +24 -1
  129. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  130. package/dist/server/sessions/sessionEnvironmentFacts.js +48 -0
  131. package/dist/server/sessions/sessionEnvironmentFacts.js.map +1 -0
  132. package/dist/server/sessions/sessionFileFormat.js +27 -0
  133. package/dist/server/sessions/sessionFileFormat.js.map +1 -0
  134. package/dist/server/sessions/sessionFileHeader.js +1 -14
  135. package/dist/server/sessions/sessionFileHeader.js.map +1 -1
  136. package/dist/server/sessions/sessionModelScope.js +160 -0
  137. package/dist/server/sessions/sessionModelScope.js.map +1 -0
  138. package/dist/server/sessions/sessionNameGenerator.js +6 -6
  139. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  140. package/dist/server/sessions/sessionRoutes.js +55 -0
  141. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  142. package/dist/server/sessions/sessionSummaryScanner.js +96 -224
  143. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
  144. package/dist/server/sessions/spawnSubsessionTool.js +5 -9
  145. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  146. package/dist/server/sessions/spawnTargetResolver.js +1 -1
  147. package/dist/server/status/machineStatusRoutes.js +8 -0
  148. package/dist/server/status/machineStatusRoutes.js.map +1 -0
  149. package/dist/server/status/machineStatusService.js +181 -0
  150. package/dist/server/status/machineStatusService.js.map +1 -0
  151. package/dist/server/status/workspaceAttribution.js +87 -0
  152. package/dist/server/status/workspaceAttribution.js.map +1 -0
  153. package/dist/server/storage/piPackageDismissalStore.js +78 -0
  154. package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
  155. package/dist/server/terminalProxyRoutes.js +2 -1
  156. package/dist/server/terminalProxyRoutes.js.map +1 -1
  157. package/dist/server/workspaceExplorerRoutes.js +20 -13
  158. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  159. package/dist/server/workspaces/fileContentService.js +18 -9
  160. package/dist/server/workspaces/fileContentService.js.map +1 -1
  161. package/dist/server/workspaces/filePreviewResponseHeaders.js +18 -0
  162. package/dist/server/workspaces/filePreviewResponseHeaders.js.map +1 -0
  163. package/dist/server/workspaces/filePreviewResponsePolicy.js +55 -0
  164. package/dist/server/workspaces/filePreviewResponsePolicy.js.map +1 -0
  165. package/dist/server/workspaces/filePreviewService.js +81 -0
  166. package/dist/server/workspaces/filePreviewService.js.map +1 -0
  167. package/dist/server/workspaces/projectWorkspaceCwds.js +0 -5
  168. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -1
  169. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +402 -0
  170. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -0
  171. package/dist/server/workspaces/workspaceCatalog.js +47 -0
  172. package/dist/server/workspaces/workspaceCatalog.js.map +1 -0
  173. package/dist/server/workspaces/workspaceContext.js +1 -3
  174. package/dist/server/workspaces/workspaceContext.js.map +1 -1
  175. package/dist/server/workspaces/workspaceDeletionRoutes.js +35 -125
  176. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  177. package/dist/server/workspaces/workspaceProviderRegistry.js +651 -0
  178. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -0
  179. package/dist/server/workspaces/workspaceRemovalService.js +256 -0
  180. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -0
  181. package/dist/server/workspaces/workspaceRouteErrors.js +7 -0
  182. package/dist/server/workspaces/workspaceRouteErrors.js.map +1 -0
  183. package/dist/server/workspaces/worktreePreRemoveHook.js +39 -0
  184. package/dist/server/workspaces/worktreePreRemoveHook.js.map +1 -0
  185. package/dist/server-plugin-api.d.ts +140 -0
  186. package/dist/server-plugin-api.js +2 -0
  187. package/dist/server-plugin-api.js.map +1 -0
  188. package/dist/serverPluginRecovery.js +138 -0
  189. package/dist/serverPluginRecovery.js.map +1 -0
  190. package/dist/sessiond/activeAgentProfile.js +3 -22
  191. package/dist/sessiond/activeAgentProfile.js.map +1 -1
  192. package/dist/sessiond/config.js +11 -0
  193. package/dist/sessiond/config.js.map +1 -1
  194. package/dist/sessiond/sessionDaemonClient.js +11 -9
  195. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  196. package/dist/shared/activeAgentProfile.js +2 -41
  197. package/dist/shared/activeAgentProfile.js.map +1 -1
  198. package/dist/shared/activity.js +0 -3
  199. package/dist/shared/activity.js.map +1 -1
  200. package/dist/shared/apiTypes.js +7 -5
  201. package/dist/shared/apiTypes.js.map +1 -1
  202. package/dist/shared/capabilities.js +8 -6
  203. package/dist/shared/capabilities.js.map +1 -1
  204. package/dist/shared/federatedRoutes.js +36 -5
  205. package/dist/shared/federatedRoutes.js.map +1 -1
  206. package/dist/shared/machinePluginIds.js +4 -2
  207. package/dist/shared/machinePluginIds.js.map +1 -1
  208. package/dist/shared/machineStatus.js +94 -0
  209. package/dist/shared/machineStatus.js.map +1 -0
  210. package/dist/shared/piWebStatusParsing.js +33 -0
  211. package/dist/shared/piWebStatusParsing.js.map +1 -1
  212. package/dist/shared/pluginApiTypes.d.ts +150 -0
  213. package/dist/shared/pluginApiTypes.js +3 -0
  214. package/dist/shared/pluginApiTypes.js.map +1 -0
  215. package/dist/shared/pluginBackendProtocol.js +110 -0
  216. package/dist/shared/pluginBackendProtocol.js.map +1 -0
  217. package/dist/shared/pluginIds.js +5 -0
  218. package/dist/shared/pluginIds.js.map +1 -1
  219. package/dist/shared/pluginRecoveryCommands.js +14 -0
  220. package/dist/shared/pluginRecoveryCommands.js.map +1 -0
  221. package/dist/shared/workspaceFiles.js +41 -2
  222. package/dist/shared/workspaceFiles.js.map +1 -1
  223. package/dist/shared/workspaceRemovalProtocol.js +24 -0
  224. package/dist/shared/workspaceRemovalProtocol.js.map +1 -0
  225. package/docs/config.md +105 -52
  226. package/docs/plugins.md +403 -146
  227. package/examples/workspace-provider-plugin/README.md +52 -0
  228. package/examples/workspace-provider-plugin/package.json +25 -0
  229. package/examples/workspace-provider-plugin/src/browser/index.ts +69 -0
  230. package/examples/workspace-provider-plugin/src/server.ts +64 -0
  231. package/examples/workspace-provider-plugin/tsconfig.json +21 -0
  232. package/package.json +30 -11
  233. package/server-plugin-api.d.ts +1 -0
  234. package/dist/client/assets/CodeViewer-D91Sp61M.js +0 -4
  235. package/dist/client/assets/UnifiedDiffViewer-BafLWGtF.js +0 -39
  236. package/dist/client/assets/index-CA8q9_o7.js +0 -4025
  237. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
  238. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
  239. package/dist/pi-web-plugins/relays/package.json +0 -9
  240. package/dist/plugin-api/unstable.d.ts +0 -22
  241. package/dist/server/activity/workspaceActivityRoutes.js +0 -4
  242. package/dist/server/activity/workspaceActivityRoutes.js.map +0 -1
  243. package/dist/server/git/gitService.js.map +0 -1
  244. package/dist/server/gitRoutes.js +0 -23
  245. package/dist/server/gitRoutes.js.map +0 -1
  246. package/dist/server/sessions/parentSessionLocator.js +0 -75
  247. package/dist/server/sessions/parentSessionLocator.js.map +0 -1
  248. package/dist/server/workspaces/gitWorktreeDiscovery.js +0 -43
  249. package/dist/server/workspaces/gitWorktreeDiscovery.js.map +0 -1
  250. package/dist/server/workspaces/imagePreviewService.js +0 -40
  251. package/dist/server/workspaces/imagePreviewService.js.map +0 -1
  252. package/dist/server/workspaces/workspaceService.js +0 -52
  253. package/dist/server/workspaces/workspaceService.js.map +0 -1
  254. package/dist/shared/apiTypes.d.ts +0 -1217
  255. package/dist/shared/thinkingLevels.d.ts +0 -27
  256. package/plugin-api/unstable.d.ts +0 -1
  257. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
  258. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/marked.esm.js +0 -0
@@ -1,7 +1,7 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readFile, stat, writeFile } from "node:fs/promises";
4
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
7
7
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
@@ -16,8 +16,6 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
16
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
17
17
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
18
18
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
19
- import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
20
- import { siblingWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
21
19
  import { createAskUserToolDefinition } from "./askUserTool.js";
22
20
  import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
23
21
  import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
@@ -30,6 +28,7 @@ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionC
30
28
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
31
29
  import { plainTextTheme } from "./plainTextTheme.js";
32
30
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
31
+ import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
33
32
  const noopLogger = { info() { } };
34
33
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
35
34
  /**
@@ -56,6 +55,14 @@ function spawnTargetError(decision) {
56
55
  return new Error("Spawning session is not in a registered project");
57
56
  return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
58
57
  }
58
+ /**
59
+ * Tracked subsessions are worktree-scoped, so a requested target other than the
60
+ * parent's own cwd fails closed instead of being silently retargeted. The
61
+ * message names the rule and both supported ways to get work done elsewhere.
62
+ */
63
+ function subsessionCwdError(spawningCwd, requestedCwd) {
64
+ return new Error(`A tracked subsession runs in this session's working directory (${spawningCwd}); ${requestedCwd} was requested. Instruct the child to work elsewhere from this workspace, or use spawn_session for an independent session in another workspace.`);
65
+ }
59
66
  function modelSpecOf(model) {
60
67
  return `${model.provider}/${model.id}`;
61
68
  }
@@ -275,9 +282,182 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
275
282
  ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
276
283
  ];
277
284
  }
278
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
285
+ /**
286
+ * Run the `project_trust` event over a pre-trust extension set, mirroring
287
+ * `emitProjectTrustEvent` in the SDK's `dist/core/extensions/runner.js`. That
288
+ * helper is not part of the package's public exports (the main index exports
289
+ * only the `ProjectTrust*` types and `ProjectTrustStore`, and the package's
290
+ * `exports` map blocks subpath imports, so `resolveProjectTrusted`/
291
+ * `emitProjectTrustEvent` are not callable from here), so PI WEB reimplements
292
+ * its documented decision loop over the SDK-provided extension objects: per
293
+ * extension, the registered `project_trust` handlers run in order; the first
294
+ * handler returning `yes`/`no` decides and `undecided` falls through to the
295
+ * next handler/extension; a throwing handler is collected as an error and
296
+ * later handlers still get their chance.
297
+ */
298
+ export async function emitWebProjectTrustEvent(extensionsResult, event, ctx) {
299
+ const errors = [];
300
+ for (const extension of extensionsResult.extensions) {
301
+ // A single extension may register multiple handlers for the same event.
302
+ // The handlers map is keyed exactly as the extension registered it, so a
303
+ // `project_trust` key guarantees `ProjectTrustHandler` entries — the same
304
+ // assumption the SDK's emitProjectTrustEvent makes.
305
+ const handlers = extension.handlers.get("project_trust");
306
+ if (handlers === undefined || handlers.length === 0)
307
+ continue;
308
+ for (const handler of handlers) {
309
+ try {
310
+ const handlerResult = await handler(event, ctx);
311
+ // The SDK reads `trusted` straight off the handler result, so a
312
+ // non-object would throw there; PI WEB reports it as a handler error
313
+ // and lets the next handler/extension try.
314
+ if (typeof handlerResult !== "object" || handlerResult === null) {
315
+ errors.push({ extensionPath: extension.path, error: "project_trust handler returned a non-object result" });
316
+ continue;
317
+ }
318
+ const trusted = "trusted" in handlerResult ? handlerResult.trusted : undefined;
319
+ if (trusted === "undecided") {
320
+ continue;
321
+ }
322
+ // Rebuild the decision so only the documented `trusted`/`remember`
323
+ // fields carry over — the SDK's resolver reads exactly those two.
324
+ const remember = "remember" in handlerResult ? handlerResult.remember : undefined;
325
+ return {
326
+ result: {
327
+ trusted: trusted === "yes" ? "yes" : "no",
328
+ ...(remember === true ? { remember: true } : {}),
329
+ },
330
+ errors,
331
+ };
332
+ }
333
+ catch (error) {
334
+ errors.push({
335
+ extensionPath: extension.path,
336
+ error: error instanceof Error ? error.message : String(error),
337
+ });
338
+ }
339
+ }
340
+ }
341
+ return { errors };
342
+ }
343
+ /**
344
+ * PI WEB's headless project-trust context. `hasUI` is false because there is
345
+ * no browser trust prompt (chartered behavior: `ask` never loads untrusted
346
+ * resources), so the UI methods are inert — the same no-UI shape the SDK
347
+ * passes when pi runs without a trust UI. The host mode is `rpc`, mirroring
348
+ * how PI WEB binds its session extension contexts.
349
+ */
350
+ function webProjectTrustContext(cwd) {
351
+ return {
352
+ cwd,
353
+ mode: "rpc",
354
+ hasUI: false,
355
+ ui: {
356
+ select: () => Promise.resolve(undefined),
357
+ confirm: () => Promise.resolve(false),
358
+ input: () => Promise.resolve(undefined),
359
+ notify: () => undefined,
360
+ },
361
+ };
362
+ }
363
+ /**
364
+ * Resolve whether a workspace's project-local `.pi/` resources may load, the
365
+ * way `pi` resolves it — a faithful mirror of the SDK's `resolveProjectTrusted`
366
+ * (`dist/core/project-trust.js`, also not a public export). PI WEB has no
367
+ * browser trust prompt, so the precedence is, in order:
368
+ *
369
+ * 1. Nothing trust-requiring under `cwd` → trusted.
370
+ * 2. Pre-trust extensions (user/global — project-local ones are not loaded
371
+ * yet) may decide via the `project_trust` event; `remember: true` persists
372
+ * the decision to the agent dir's `trust.json`. Handler errors are reported
373
+ * through {@link WebProjectTrustResolution.onExtensionError} and never
374
+ * abort resolution.
375
+ * 3. Otherwise the saved `trust.json` decision wins.
376
+ * 4. Otherwise `defaultProjectTrust` decides (`always` trusts; `never`/`ask`
377
+ * do not — `ask` cannot prompt in the browser, matching `pi` run without a
378
+ * trust UI).
379
+ */
380
+ export async function resolveWebProjectTrusted(resolution) {
381
+ const { cwd, trustStore, settingsManager } = resolution;
382
+ if (!hasTrustRequiringProjectResources(cwd))
383
+ return true;
384
+ if (resolution.extensionsResult) {
385
+ const { result, errors } = await emitWebProjectTrustEvent(resolution.extensionsResult, { type: "project_trust", cwd }, webProjectTrustContext(cwd));
386
+ for (const error of errors) {
387
+ resolution.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
388
+ }
389
+ if (result) {
390
+ const trusted = result.trusted === "yes";
391
+ if (result.remember === true) {
392
+ trustStore.set(cwd, trusted);
393
+ }
394
+ return trusted;
395
+ }
396
+ }
397
+ const saved = trustStore.get(cwd);
398
+ if (saved !== null)
399
+ return saved;
400
+ return settingsManager.getDefaultProjectTrust() === "always";
401
+ }
402
+ /**
403
+ * Resource-loader options that append PI WEB's own system-prompt sections.
404
+ *
405
+ * `appendSystemPromptOverride` composes with what the loader already resolved,
406
+ * so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
407
+ * and PI WEB's sections land after them. Returns `undefined` when there is
408
+ * nothing to append, leaving the loader exactly as pi configures it.
409
+ */
410
+ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
411
+ if (appendSystemPromptSections.length === 0)
412
+ return undefined;
413
+ return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
414
+ }
415
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
416
+ const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
279
417
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
280
- const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
418
+ // PI WEB always honors pi's project-trust model. When the workspace ships
419
+ // trust-requiring resources, trust is resolved exactly once, mirroring the
420
+ // SDK's flow: the resource loader first loads the pre-trust extension set
421
+ // (user/global; project-local ones stay out) and calls back with it, so
422
+ // those extensions may decide via the `project_trust` event; the resolved
423
+ // value then lands in the SettingsManager before any project-local
424
+ // resource (extensions, packages, settings, prompts) loads. With no
425
+ // browser trust prompt, an untrusted project's resources are skipped
426
+ // (matching `pi` run without a UI). Projects without trust-requiring
427
+ // resources skip resolution entirely and are trusted, as before.
428
+ const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
429
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
430
+ // Pre-session-creation trust failures (`project_trust` handler errors)
431
+ // land in the runtime diagnostics next to the services diagnostics,
432
+ // exactly as the CLI appends its project-trust diagnostics.
433
+ const projectTrustDiagnostics = [];
434
+ const services = await createAgentSessionServices({
435
+ cwd,
436
+ agentDir,
437
+ modelRuntime,
438
+ settingsManager,
439
+ ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
440
+ ...(projectTrustRequiring
441
+ ? {
442
+ resourceLoaderReloadOptions: {
443
+ resolveProjectTrust: async ({ extensionsResult }) => resolveWebProjectTrusted({
444
+ cwd,
445
+ trustStore: new ProjectTrustStore(agentDir),
446
+ settingsManager,
447
+ extensionsResult,
448
+ onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
449
+ }),
450
+ },
451
+ }
452
+ : {}),
453
+ });
454
+ const modelOptions = await resolveSessionModelOptions({
455
+ services,
456
+ hasExistingSession: sessionManager.buildSessionContext().messages.length > 0,
457
+ ...(initialModel === undefined ? {} : { initialModel }),
458
+ ...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
459
+ });
460
+ services.diagnostics.push(...modelOptions.diagnostics);
281
461
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
282
462
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
283
463
  const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
@@ -286,10 +466,11 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
286
466
  sessionManager,
287
467
  customTools,
288
468
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
289
- ...(initialModel === undefined ? {} : { model: initialModel }),
290
- ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
469
+ ...(modelOptions.model === undefined ? {} : { model: modelOptions.model }),
470
+ ...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
471
+ ...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
291
472
  });
292
- return { ...result, services, diagnostics: services.diagnostics };
473
+ return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
293
474
  };
294
475
  }
295
476
  function createPiWebEditToolDefinition(cwd) {
@@ -331,6 +512,16 @@ export class PiSessionService {
331
512
  this.treeNavigations = new WeakSet();
332
513
  /** Counts async operations that may append an entry before they settle. */
333
514
  this.sessionEntryMutationCounts = new WeakMap();
515
+ /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
516
+ this.modelScopeMutationQueue = Promise.resolve();
517
+ /** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
518
+ this.modelScopeStates = new Map();
519
+ /** Coalesces initialization reads independently for the global and each workspace scope. */
520
+ this.modelScopeInitializations = new Map();
521
+ /** Monotonic revision published for global scope changes and consumed by clients. */
522
+ this.modelScopeRevision = 0;
523
+ /** Last scope/catalog revision projected into each runtime, synchronized lazily on use. */
524
+ this.modelScopeCache = new WeakMap();
334
525
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
335
526
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
336
527
  this.treeExclusiveSessionOperationCounts = new Map();
@@ -347,13 +538,6 @@ export class PiSessionService {
347
538
  this.subsessionLinks = new Map();
348
539
  /** Parent id/file identities whose persisted links have already been loaded. */
349
540
  this.subsessionHydratedParents = new Set();
350
- /**
351
- * Session file path -> its parsed header and the file identity at read
352
- * time. Headers are written once, so identity-verified reads are cached.
353
- */
354
- this.sessionHeaderCache = new Map();
355
- /** Session file path -> its in-flight header read, so concurrent scans share one read per file. */
356
- this.pendingSessionHeaderReads = new Map();
357
541
  /**
358
542
  * Tracked subsession id -> whether a completion notification is armed.
359
543
  * Armed when the child starts working; firing on completion disarms it so a
@@ -366,46 +550,23 @@ export class PiSessionService {
366
550
  this.pendingUnreadMutations = [];
367
551
  this.unreadPublicationFlushRequested = false;
368
552
  this.unreadPublicationStopped = false;
369
- /**
370
- * Read a session file header, memoized per path, with one in-flight read
371
- * per path: concurrent scans of a shared session directory share the read
372
- * instead of each opening the file. Pi writes the header once at session
373
- * creation, so a successful read stays valid as long as the path still
374
- * holds the same file: every hit re-checks the file's identity (dev/ino),
375
- * and a replacement landed at a cached path is re-read instead of serving
376
- * the previous file's header — the summary scanner detects such
377
- * replacements, so the listing and the header reads must agree. The one
378
- * thing identity cannot detect is an in-place rewrite that keeps the
379
- * inode; detach, the only such writer, deletes the entry itself. Failures
380
- * are not cached so a session file that appears later is picked up.
381
- */
382
- this.readCachedSessionHeader = (sessionFile) => {
383
- const pending = this.pendingSessionHeaderReads.get(sessionFile);
384
- if (pending !== undefined)
385
- return pending;
386
- const read = this.loadSessionHeader(sessionFile).finally(() => {
387
- this.pendingSessionHeaderReads.delete(sessionFile);
388
- });
389
- this.pendingSessionHeaderReads.set(sessionFile, read);
390
- return read;
391
- };
392
553
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
393
554
  this.agentDir = deps.agentDir;
394
555
  this.sessionManager = deps.sessionManager;
395
556
  this.modelRuntime = deps.modelRuntime;
396
557
  this.spawnTargets = deps.spawnTargets;
397
- this.projectWorkspaces = deps.projectWorkspaces;
398
558
  this.logger = deps.logger ?? noopLogger;
399
559
  this.now = deps.now ?? (() => new Date());
400
560
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
401
561
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
562
+ this.onUnreadChanged = deps.onUnreadChanged;
402
563
  this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
403
564
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
404
565
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
405
566
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
406
567
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
407
568
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
408
- // Subsessions are a beta capability gated behind their own flag, and they
569
+ // Subsessions are gated behind their own flag, and they
409
570
  // also require the spawn capability (they share its project-scope resolver).
410
571
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
411
572
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
@@ -413,7 +574,7 @@ export class PiSessionService {
413
574
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
414
575
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
415
576
  read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
416
- }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
577
+ }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, deps.appendSystemPromptSections ?? []);
417
578
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
418
579
  this.workspaceActivity = deps.workspaceActivity;
419
580
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -541,8 +702,6 @@ export class PiSessionService {
541
702
  this.subsessionLinks.clear();
542
703
  this.subsessionHydratedParents.clear();
543
704
  this.subsessionNotifyArmed.clear();
544
- this.sessionHeaderCache.clear();
545
- this.pendingSessionHeaderReads.clear();
546
705
  this.notificationStore.clearAll("service-dispose");
547
706
  await Promise.all(activeSessions.map(async (active) => {
548
707
  active.unsubscribe();
@@ -573,83 +732,7 @@ export class PiSessionService {
573
732
  .sort(compareArchivedRecords)
574
733
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
575
734
  .filter(isDefined);
576
- return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
577
- }
578
- /**
579
- * Annotate a listing with the session relationships that cross workspace
580
- * boundaries: where an out-of-listing parent lives, and how many children a
581
- * listed session has in sibling workspaces.
582
- *
583
- * Both directions are best-effort. An unreadable parent header or a sibling
584
- * workspace that cannot be listed leaves the session unannotated rather than
585
- * failing the listing, and the browser falls back to its generic states.
586
- */
587
- async withRelatedSessionsElsewhere(sessions, cwd) {
588
- const [parentLocations, childCounts] = await Promise.all([
589
- locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
590
- this.countChildrenInSiblingWorkspaces(sessions, cwd),
591
- ]);
592
- return sessions.map((session) => {
593
- const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
594
- const childrenElsewhere = childCounts.get(session.path);
595
- if (parent === undefined && childrenElsewhere === undefined)
596
- return session;
597
- const annotated = { ...session };
598
- if (parent !== undefined) {
599
- annotated.parentSessionId = parent.parentSessionId;
600
- annotated.parentSessionCwd = parent.parentSessionCwd;
601
- }
602
- if (childrenElsewhere !== undefined)
603
- annotated.childSessionsElsewhere = childrenElsewhere;
604
- return annotated;
605
- });
606
- }
607
- /**
608
- * Count children of the listed sessions that live in other workspaces of the
609
- * same project.
610
- *
611
- * Only sibling workspaces are scanned: agents may only spawn into workspaces
612
- * of the spawning session's own project, so that bounds where a child can be.
613
- * Each sibling scan reads only the header line of every session file (the
614
- * only place `parentSessionPath` is recorded), never the transcripts. Listing
615
- * is skipped entirely when no project-workspace locator is configured or the
616
- * cwd belongs to no registered project.
617
- */
618
- async countChildrenInSiblingWorkspaces(sessions, cwd) {
619
- if (this.projectWorkspaces === undefined || sessions.length === 0)
620
- return new Map();
621
- try {
622
- const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
623
- if (siblingCwds.length === 0)
624
- return new Map();
625
- const listings = await Promise.all(siblingCwds.map((siblingCwd) => this.sessionManager.listParentSessionPaths(siblingCwd, this.readCachedSessionHeader)));
626
- return countOutOfListingChildren(sessions, listings.flat());
627
- }
628
- catch (error) {
629
- this.logger.info({ cwd, error: error instanceof Error ? error.message : String(error) }, "failed to count child sessions in sibling workspaces");
630
- return new Map();
631
- }
632
- }
633
- async loadSessionHeader(sessionFile) {
634
- let stats;
635
- try {
636
- stats = await stat(sessionFile);
637
- }
638
- catch {
639
- // Gone or unreadable: report no header and forget any cached entry.
640
- this.sessionHeaderCache.delete(sessionFile);
641
- return undefined;
642
- }
643
- const cached = this.sessionHeaderCache.get(sessionFile);
644
- if (cached?.dev === stats.dev && cached.ino === stats.ino)
645
- return cached.header;
646
- const header = await readSessionHeaderSummary(sessionFile);
647
- if (header === undefined) {
648
- this.sessionHeaderCache.delete(sessionFile);
649
- return undefined;
650
- }
651
- this.sessionHeaderCache.set(sessionFile, { header, dev: stats.dev, ino: stats.ino });
652
- return header;
735
+ return [...unarchivedSessions, ...archivedSessions];
653
736
  }
654
737
  async start(cwd, options = {}) {
655
738
  return this.startSession(cwd, options);
@@ -711,15 +794,22 @@ export class PiSessionService {
711
794
  };
712
795
  }
713
796
  /**
714
- * Start a *tracked* child session on behalf of a LLM. Identical to
715
- * {@link spawnSession} in how the target cwd is resolved, but the child
716
- * records its parent (so it shows in the session tree) and is registered so
717
- * the parent is notified when it stops working and can inspect it later.
797
+ * Start a *tracked* child session on behalf of a LLM. Unlike
798
+ * {@link spawnSession}, a tracked child always runs in the parent's own
799
+ * workspace: parent/child trees are worktree-scoped, so a child elsewhere
800
+ * would be invisible to the parent's listing. The child records its parent
801
+ * (so it shows in the session tree) and is registered so the parent is
802
+ * notified when it stops working and can inspect it later.
718
803
  */
719
804
  async spawnSubsession(input) {
720
805
  if (this.spawnTargets === undefined)
721
806
  throw new Error("Spawning sessions is disabled");
722
- const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
807
+ if (input.cwd !== undefined && input.cwd !== "" && !cwdPathsEqual(input.cwd, input.spawningCwd)) {
808
+ throw subsessionCwdError(input.spawningCwd, input.cwd);
809
+ }
810
+ // Resolved against the parent's own cwd only: this still refuses spawning
811
+ // from an unregistered directory, which keeps the child visible in the UI.
812
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, undefined);
723
813
  if (!decision.allowed)
724
814
  throw spawnTargetError(decision);
725
815
  // A model spec overrides the inherited model and is resolved against the
@@ -754,15 +844,117 @@ export class PiSessionService {
754
844
  };
755
845
  }
756
846
  /**
757
- * The models a session may pick from: its scoped set when model-scoped,
758
- * otherwise the runtime's available snapshot. Refreshes the runtime catalog
759
- * first so callers see newly configured providers and models.
847
+ * Resolve the settings domain for a session. Pi's settings manager deep-merges
848
+ * objects, but an `enabledModels` array in `.pi/settings.json` replaces the
849
+ * global array, so that workspace gets its own effective scope and is not
850
+ * affected by global picker edits.
851
+ */
852
+ modelScopeContext(session) {
853
+ const projectSettings = session.settingsManager.getProjectSettings?.() ?? {};
854
+ if (!Object.hasOwn(projectSettings, "enabledModels")) {
855
+ return { key: "global", source: "global", editable: true };
856
+ }
857
+ const workspace = canonicalizeStoredCwd(session.sessionManager.getCwd());
858
+ return { key: `workspace:${workspace}`, source: "workspace", editable: false };
859
+ }
860
+ /**
861
+ * Read the effective scope for one settings domain. Global sessions share one
862
+ * snapshot; project-overridden sessions share a snapshot keyed by workspace.
863
+ * Neither path enumerates or mutates other active sessions.
864
+ */
865
+ async modelScopeSnapshot(session, context = this.modelScopeContext(session)) {
866
+ const existing = this.modelScopeStates.get(context.key);
867
+ if (existing !== undefined)
868
+ return existing;
869
+ const pending = this.modelScopeInitializations.get(context.key);
870
+ if (pending !== undefined)
871
+ return pending;
872
+ const initialization = (async () => {
873
+ const enabledIds = await resolveEnabledModelIds(sessionScopeSource(session));
874
+ const snapshot = {
875
+ enabledIds: enabledIds === null ? null : [...enabledIds],
876
+ revision: 0,
877
+ };
878
+ this.modelScopeStates.set(context.key, snapshot);
879
+ return snapshot;
880
+ })();
881
+ this.modelScopeInitializations.set(context.key, initialization);
882
+ try {
883
+ return await initialization;
884
+ }
885
+ finally {
886
+ if (this.modelScopeInitializations.get(context.key) === initialization)
887
+ this.modelScopeInitializations.delete(context.key);
888
+ }
889
+ }
890
+ /**
891
+ * Project one settings-domain scope into one SDK session only when that
892
+ * session is used. `PiAgentSession` needs an in-memory array for cycling, but
893
+ * that array is a cache; the keyed snapshot above is the source of truth.
894
+ */
895
+ async synchronizeSessionModelScope(session, available) {
896
+ const context = this.modelScopeContext(session);
897
+ const snapshot = await this.modelScopeSnapshot(session, context);
898
+ const catalogKey = available.map(modelScopeId).join("\0");
899
+ const cached = this.modelScopeCache.get(session);
900
+ if (cached?.scopeKey === context.key && cached.revision === snapshot.revision && cached.catalogKey === catalogKey)
901
+ return;
902
+ session.setScopedModels(scopedModelsFromEnabledIds(available, snapshot.enabledIds, session.scopedModels));
903
+ this.modelScopeCache.set(session, { scopeKey: context.key, revision: snapshot.revision, catalogKey });
904
+ }
905
+ /**
906
+ * The models a session may pick from: its synchronized scoped set when
907
+ * model-scoped, otherwise the runtime's available snapshot. Refreshes the
908
+ * shared runtime catalog first so callers see newly configured providers and
909
+ * models. The refresh stays local (`allowNetwork: false`); network refreshes
910
+ * belong to the bounded background catalog refresher, not this request path.
760
911
  */
761
912
  async sessionModelCandidates(session) {
762
- await session.modelRuntime.refresh();
913
+ await session.modelRuntime.refresh({ allowNetwork: false });
914
+ const available = session.modelRuntime.getAvailableSnapshot();
915
+ await this.synchronizeSessionModelScope(session, available);
763
916
  return session.scopedModels.length > 0
764
917
  ? session.scopedModels.map((scoped) => scoped.model)
765
- : session.modelRuntime.getAvailableSnapshot();
918
+ : available;
919
+ }
920
+ /**
921
+ * The session machine's full available catalog with per-model enabled state,
922
+ * ordered enabled-first for scope semantics while retaining each model's
923
+ * natural catalog index for stable picker placement. The enabled state comes
924
+ * from the keyed scope snapshot rather than a session-local SettingsManager.
925
+ */
926
+ async enabledModelCatalog(session) {
927
+ await session.modelRuntime.refresh({ allowNetwork: false });
928
+ const available = session.modelRuntime.getAvailableSnapshot();
929
+ await this.synchronizeSessionModelScope(session, available);
930
+ const context = this.modelScopeContext(session);
931
+ const enabledIds = (await this.modelScopeSnapshot(session, context)).enabledIds;
932
+ return catalogWithEnabledFirst(available, enabledIds);
933
+ }
934
+ runModelScopeMutation(operation) {
935
+ const queued = this.modelScopeMutationQueue.then(operation);
936
+ this.modelScopeMutationQueue = queued.then(() => undefined, () => undefined);
937
+ return queued;
938
+ }
939
+ /** Persist one global enabled-model selection and publish its new revision. */
940
+ applyEnabledModelScope(session, available, enabledIds) {
941
+ const availableIds = available.map(modelScopeId);
942
+ const persisted = persistedEnabledModelPatterns(enabledIds, availableIds);
943
+ const nextState = {
944
+ // Pi normalizes a scope covering the complete current catalog to an
945
+ // omitted setting, which is the global canonical all-enabled state.
946
+ enabledIds: persisted === undefined ? null : enabledIds === null ? [] : [...enabledIds],
947
+ revision: ++this.modelScopeRevision,
948
+ };
949
+ session.settingsManager.setEnabledModels(persisted);
950
+ this.modelScopeStates.set("global", nextState);
951
+ session.setScopedModels(scopedModelsFromEnabledIds(available, nextState.enabledIds, session.scopedModels));
952
+ this.modelScopeCache.set(session, {
953
+ scopeKey: "global",
954
+ revision: nextState.revision,
955
+ catalogKey: available.map(modelScopeId).join("\0"),
956
+ });
957
+ this.events.publishGlobal({ type: "models.changed", revision: nextState.revision });
766
958
  }
767
959
  /**
768
960
  * Resolve a strict `provider/model-id` spec from a spawn tool against the
@@ -1357,6 +1549,68 @@ export class PiSessionService {
1357
1549
  const models = await this.sessionModelCandidates(session);
1358
1550
  return models.map(modelToClientModel);
1359
1551
  }
1552
+ async modelCatalog(ref) {
1553
+ const session = await this.getOrOpen(ref);
1554
+ const scope = this.modelScopeContext(session);
1555
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry, scope.editable));
1556
+ }
1557
+ /**
1558
+ * Add/remove one model to/from the global pi `enabledModels` scope. A
1559
+ * workspace `.pi/settings.json` override remains authoritative for that
1560
+ * workspace and is intentionally read-only in this picker.
1561
+ */
1562
+ async setModelEnabled(ref, provider, modelId, enabled) {
1563
+ await this.assertWritable(ref);
1564
+ const session = await this.getOrOpen(ref);
1565
+ return this.runModelScopeMutation(async () => {
1566
+ const scope = this.modelScopeContext(session);
1567
+ if (scope.source !== "global")
1568
+ throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
1569
+ this.assertTreeNavigationInactive(session, "change enabled models");
1570
+ await session.modelRuntime.refresh({ allowNetwork: false });
1571
+ const currentIds = (await this.modelScopeSnapshot(session, scope)).enabledIds;
1572
+ const available = session.modelRuntime.getAvailableSnapshot();
1573
+ const availableIds = available.map(modelScopeId);
1574
+ const targetId = `${provider}/${modelId}`;
1575
+ if (!availableIds.includes(targetId))
1576
+ throw new Error(`Model not found: ${targetId}`);
1577
+ if (!enabled && session.model !== undefined && modelScopeId(session.model) === targetId) {
1578
+ throw new Error("Current model cannot be disabled");
1579
+ }
1580
+ const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
1581
+ this.assertTreeNavigationInactive(session, "change enabled models");
1582
+ if (nextIds !== currentIds)
1583
+ this.applyEnabledModelScope(session, available, nextIds);
1584
+ // Respond from a fresh post-edit read so the response is exactly what
1585
+ // GET models/catalog returns after the edit, including pi's normalizations.
1586
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1587
+ });
1588
+ }
1589
+ /** Atomically expose every model or narrow the global enabled scope to the current model. */
1590
+ async setModelScope(ref, mode) {
1591
+ await this.assertWritable(ref);
1592
+ const session = await this.getOrOpen(ref);
1593
+ return this.runModelScopeMutation(async () => {
1594
+ const scope = this.modelScopeContext(session);
1595
+ if (scope.source !== "global")
1596
+ throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
1597
+ this.assertTreeNavigationInactive(session, "change enabled models");
1598
+ await session.modelRuntime.refresh({ allowNetwork: false });
1599
+ const available = session.modelRuntime.getAvailableSnapshot();
1600
+ const availableIds = available.map(modelScopeId);
1601
+ let nextIds = null;
1602
+ if (mode === "current") {
1603
+ const current = session.model;
1604
+ if (current === undefined || !availableIds.includes(modelScopeId(current))) {
1605
+ throw new Error("Current model is unavailable");
1606
+ }
1607
+ nextIds = [modelScopeId(current)];
1608
+ }
1609
+ this.assertTreeNavigationInactive(session, "change enabled models");
1610
+ this.applyEnabledModelScope(session, available, nextIds);
1611
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1612
+ });
1613
+ }
1360
1614
  async setModel(ref, provider, modelId) {
1361
1615
  await this.assertWritable(ref);
1362
1616
  const session = await this.getOrOpen(ref);
@@ -1375,7 +1629,11 @@ export class PiSessionService {
1375
1629
  async cycleModel(ref, direction) {
1376
1630
  await this.assertWritable(ref);
1377
1631
  const session = await this.getOrOpen(ref);
1378
- const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1632
+ const result = await this.runModelScopeMutation(async () => {
1633
+ await session.modelRuntime.refresh({ allowNetwork: false });
1634
+ await this.synchronizeSessionModelScope(session, session.modelRuntime.getAvailableSnapshot());
1635
+ return this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1636
+ });
1379
1637
  if (result === undefined)
1380
1638
  throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
1381
1639
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
@@ -1585,6 +1843,83 @@ export class PiSessionService {
1585
1843
  }
1586
1844
  }
1587
1845
  }
1846
+ /**
1847
+ * Fork the session from one entry of its tree into a new session file,
1848
+ * leaving the original session untouched. The forked runtime replaces the
1849
+ * current one, so the outcome is reported for the session the client is
1850
+ * about to join rather than the forked-from record.
1851
+ */
1852
+ async forkFromTree(ref, request) {
1853
+ if (request.entryId.trim() === "")
1854
+ throw new Error("Session tree entry is required");
1855
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1856
+ throw new Error("Stop current session activity before forking the session tree");
1857
+ }
1858
+ await this.assertWritable(ref);
1859
+ const session = await this.getOrOpen(ref);
1860
+ if (this.hasActiveWork(session))
1861
+ throw new Error("Stop current session activity before forking the session tree");
1862
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1863
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1864
+ }
1865
+ this.publishActivity(session, "forking session from entry", "active");
1866
+ this.publishStatus(session);
1867
+ try {
1868
+ const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
1869
+ expectedLeafId: request.expectedLeafId,
1870
+ });
1871
+ if (result.type === "unsupported")
1872
+ throw new Error(result.message);
1873
+ if (result.type !== "done")
1874
+ throw new Error("Session fork is unavailable");
1875
+ if (result.session === undefined) {
1876
+ if (this.isCurrentActiveSession(session)) {
1877
+ this.publishActivity(session, "fork cancelled", "idle");
1878
+ this.publishStatus(session);
1879
+ }
1880
+ return { cancelled: true };
1881
+ }
1882
+ const forkedSession = this.active.get(result.session.id)?.runtime.session;
1883
+ if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
1884
+ this.publishActivity(forkedSession, "session forked", "idle");
1885
+ this.publishStatus(forkedSession);
1886
+ }
1887
+ if (result.session.id !== session.sessionId)
1888
+ this.clearSupersededSessionActivity(session);
1889
+ return {
1890
+ cancelled: false,
1891
+ session: result.session,
1892
+ ...(result.promptDraft === undefined ? {} : { promptDraft: result.promptDraft }),
1893
+ };
1894
+ }
1895
+ catch (error) {
1896
+ const message = error instanceof Error ? error.message : String(error);
1897
+ if (this.isCurrentActiveSession(session)) {
1898
+ this.publishActivity(session, "fork failed", "error", message);
1899
+ this.events.publish(session.sessionId, { type: "session.error", message });
1900
+ this.publishStatus(session);
1901
+ }
1902
+ throw error;
1903
+ }
1904
+ }
1905
+ /**
1906
+ * A changed-id fork can rebind its runtime after a heartbeat published the
1907
+ * prior identity as active. Clear every observable owner before forgetting
1908
+ * that identity's local activity record.
1909
+ */
1910
+ clearSupersededSessionActivity(session) {
1911
+ const sessionId = session.sessionId;
1912
+ if (this.activities.get(sessionId)?.phase === "active") {
1913
+ const at = new Date().toISOString();
1914
+ const stored = { phase: "idle", label: "idle", at };
1915
+ this.activities.set(sessionId, stored);
1916
+ const activity = { sessionId, ...stored };
1917
+ this.events.publish(sessionId, { type: "activity.update", activity });
1918
+ this.events.publishGlobal({ type: "activity.update", activity });
1919
+ }
1920
+ this.workspaceActivity?.removeSession(sessionId, session.sessionManager.getCwd());
1921
+ this.activities.delete(sessionId);
1922
+ }
1588
1923
  async reloadSessionRuntime(session) {
1589
1924
  if (this.hasActiveWork(session))
1590
1925
  throw new Error("Stop current session activity before reloading");
@@ -1832,13 +2167,10 @@ export class PiSessionService {
1832
2167
  if (sessionFile === undefined || sessionFile === "")
1833
2168
  throw new Error("Session is not persisted");
1834
2169
  await clearParentSession(sessionFile);
1835
- // The header cache memoizes the first successful read, and detach is the
1836
- // only flow that rewrites a header; drop the stale entry so sibling child
1837
- // counts and parent locations observe the cleared parent immediately.
1838
- this.sessionHeaderCache.delete(sessionFile);
1839
- // The same in-place rewrite is invisible to the gateway's summary memo
1840
- // (same inode, possibly grown file), which would otherwise keep listing
1841
- // the old parent link and a stale message count until restart.
2170
+ // The header rewrite keeps the inode, and whenever it leaves the file's
2171
+ // size unchanged it is invisible to the gateway's summary memo, which
2172
+ // cannot detect such rewrites from identity + size alone and would keep
2173
+ // listing the old parent link until restart.
1842
2174
  this.sessionManager.invalidateSessionFile(sessionFile);
1843
2175
  clearParentSessionHeader(session.sessionManager);
1844
2176
  this.unregisterSubsession(session.sessionId);
@@ -2106,9 +2438,11 @@ export class PiSessionService {
2106
2438
  const { archivePath } = archived;
2107
2439
  return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
2108
2440
  }
2109
- // Resolve the session file directly by id: opening one inactive session
2110
- // must not pay for a full transcript listing of its whole workspace.
2111
- const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id, this.readCachedSessionHeader);
2441
+ // Resolve the session file directly by id: opening one known session must
2442
+ // not depend on — or wait behind — a full transcript listing of its whole
2443
+ // workspace. `getActive` routes prompt/shell/runCommand, so coupling it to
2444
+ // the listing would let an in-flight listing serialize unrelated sends.
2445
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
2112
2446
  if (!match)
2113
2447
  throw new Error("Session not found");
2114
2448
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
@@ -2424,6 +2758,11 @@ export class PiSessionService {
2424
2758
  void this.publishUnreadMutations(mutations).catch(() => undefined);
2425
2759
  }
2426
2760
  publishUnreadMutations(mutations) {
2761
+ // The store applied the mutations already, so the status projection is told
2762
+ // now rather than after the durable flush: it reads in-memory unread state
2763
+ // and must not lag behind the rows the browser is about to see.
2764
+ if (mutations.length > 0)
2765
+ this.onUnreadChanged?.();
2427
2766
  this.enqueueUnreadMutations(mutations);
2428
2767
  this.unreadPublicationFlushRequested = true;
2429
2768
  if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
@@ -3033,6 +3372,20 @@ function modelToClientModel(model) {
3033
3372
  ...(reasoning === undefined ? {} : { reasoning }),
3034
3373
  };
3035
3374
  }
3375
+ /** The scope source pi's enabled-id precedence reads from: live scope, settings patterns, runtime catalog. */
3376
+ function sessionScopeSource(session) {
3377
+ return { settingsManager: session.settingsManager, modelRuntime: session.modelRuntime, scopedModels: session.scopedModels };
3378
+ }
3379
+ function catalogEntryToClientModel(entry, editable = true) {
3380
+ return {
3381
+ ...modelToClientModel(entry.model),
3382
+ provider: entry.model.provider,
3383
+ id: entry.model.id,
3384
+ enabled: entry.enabled,
3385
+ catalogIndex: entry.catalogIndex,
3386
+ ...(editable ? {} : { editable: false }),
3387
+ };
3388
+ }
3036
3389
  function notificationIdentityForSession(session) {
3037
3390
  return {
3038
3391
  sessionId: session.sessionId,