@jmfederico/pi-web 1.202608.0 → 1.202608.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.js +69 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-D91Sp61M.js → CodeViewer-mEiBZFA5.js} +2 -2
  5. package/dist/client/assets/{TerminalPanel-fYrKc3jh.js → TerminalPanel-O9381Eme.js} +6 -6
  6. package/dist/client/assets/index-uI2AGFUy.js +4091 -0
  7. package/dist/client/index.html +1 -1
  8. package/dist/config.js +109 -73
  9. package/dist/config.js.map +1 -1
  10. package/dist/docker/piWebDockerCommandPlan.js +4 -3
  11. package/dist/docker/piWebDockerCommandPlan.js.map +1 -1
  12. package/dist/nativeServices/servicePlan.js +1 -1
  13. package/dist/nativeServices/servicePlan.js.map +1 -1
  14. package/dist/pi-web-plugins/git/browser/git-contract.js +108 -0
  15. package/dist/pi-web-plugins/git/browser/git-panel.js +609 -0
  16. package/dist/pi-web-plugins/git/browser/gitFileList.js +43 -0
  17. package/dist/pi-web-plugins/git/browser/gitFileShared.js +16 -0
  18. package/dist/pi-web-plugins/git/browser/gitFileTree.js +81 -0
  19. package/dist/pi-web-plugins/git/browser/gitFileViewPreference.js +35 -0
  20. package/dist/pi-web-plugins/git/browser/gitRoute.js +42 -0
  21. package/dist/pi-web-plugins/git/browser/pi-web-plugin.js +10 -0
  22. package/dist/pi-web-plugins/git/browser/unifiedDiff.js +301 -0
  23. package/dist/{server/git/gitService.js → pi-web-plugins/git/git-backend.js} +209 -60
  24. package/dist/pi-web-plugins/git/package.json +16 -0
  25. package/dist/pi-web-plugins/git/server-plugin.js +176 -0
  26. package/dist/pi-web-plugins/info/infoInternals.js +1 -2
  27. package/dist/pi-web-plugins/info/package.json +1 -1
  28. package/dist/pi-web-plugins/info/pi-web-plugin.js +2 -2
  29. package/dist/pi-web-plugins/relays/package.json +1 -1
  30. package/dist/pi-web-plugins/relays/pi-web-plugin.js +3 -3
  31. package/dist/pi-web-plugins/updates/package.json +1 -1
  32. package/dist/pi-web-plugins/updates/pi-web-plugin.js +1 -1
  33. package/dist/pi-web-plugins/workspace-tasks/package.json +1 -1
  34. package/dist/pi-web-plugins/workspace-tasks/pi-web-plugin.js +3 -3
  35. package/dist/plugin-api.d.ts +33 -16
  36. package/dist/pluginRecoveryCli.js +172 -0
  37. package/dist/pluginRecoveryCli.js.map +1 -0
  38. package/dist/server/activity/workspaceActivityService.js +27 -19
  39. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  40. package/dist/server/app.js +35 -15
  41. package/dist/server/app.js.map +1 -1
  42. package/dist/server/configRoutes.js +3 -21
  43. package/dist/server/configRoutes.js.map +1 -1
  44. package/dist/server/machines/machineClient.js +23 -4
  45. package/dist/server/machines/machineClient.js.map +1 -1
  46. package/dist/server/machines/machinePluginProxyRoutes.js +74 -17
  47. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  48. package/dist/server/machines/machineProxyRoutes.js +165 -8
  49. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  50. package/dist/server/machines/machineService.js +22 -0
  51. package/dist/server/machines/machineService.js.map +1 -1
  52. package/dist/server/piWebPluginCatalog.js +584 -0
  53. package/dist/server/piWebPluginCatalog.js.map +1 -0
  54. package/dist/server/piWebPluginLifecycle.js +162 -0
  55. package/dist/server/piWebPluginLifecycle.js.map +1 -0
  56. package/dist/server/piWebPluginService.js +159 -261
  57. package/dist/server/piWebPluginService.js.map +1 -1
  58. package/dist/server/piWebStatus.js +28 -7
  59. package/dist/server/piWebStatus.js.map +1 -1
  60. package/dist/server/plugins/pluginBackendProxyRoutes.js +69 -0
  61. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -0
  62. package/dist/server/plugins/serverPluginExec.js +184 -0
  63. package/dist/server/plugins/serverPluginExec.js.map +1 -0
  64. package/dist/server/plugins/serverPluginRuntime.js +480 -0
  65. package/dist/server/plugins/serverPluginRuntime.js.map +1 -0
  66. package/dist/server/realtime/sessionEventHub.js +26 -11
  67. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  68. package/dist/server/requestCancellation.js +32 -0
  69. package/dist/server/requestCancellation.js.map +1 -0
  70. package/dist/server/sessiond/agentProcessEnvironment.js +29 -30
  71. package/dist/server/sessiond/agentProcessEnvironment.js.map +1 -1
  72. package/dist/server/sessiond/pluginBackendRoutes.js +64 -0
  73. package/dist/server/sessiond/pluginBackendRoutes.js.map +1 -0
  74. package/dist/server/sessiond/sessionDaemonShutdown.js +24 -0
  75. package/dist/server/sessiond/sessionDaemonShutdown.js.map +1 -0
  76. package/dist/server/sessiond/sessionProxyRoutes.js +2 -2
  77. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  78. package/dist/server/sessiond/sessionServiceDependencies.js +2 -1
  79. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
  80. package/dist/server/sessiond/sessiondStateOwnership.js +235 -0
  81. package/dist/server/sessiond/sessiondStateOwnership.js.map +1 -0
  82. package/dist/server/sessiond/workspaceCatalogRoutes.js +31 -0
  83. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -0
  84. package/dist/server/sessiond/workspaceRemovalRoutes.js +41 -0
  85. package/dist/server/sessiond/workspaceRemovalRoutes.js.map +1 -0
  86. package/dist/server/sessiond.js +265 -99
  87. package/dist/server/sessiond.js.map +1 -1
  88. package/dist/server/sessions/authService.js +15 -54
  89. package/dist/server/sessions/authService.js.map +1 -1
  90. package/dist/server/sessions/dockerEnvironmentFacts.js +254 -0
  91. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -0
  92. package/dist/server/sessions/modelCatalogRefresher.js +4 -3
  93. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -1
  94. package/dist/server/sessions/piSessionManagerGateway.js +18 -67
  95. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  96. package/dist/server/sessions/piSessionService.js +141 -134
  97. package/dist/server/sessions/piSessionService.js.map +1 -1
  98. package/dist/server/sessions/sessionCommandService.js +24 -1
  99. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  100. package/dist/server/sessions/sessionEnvironmentFacts.js +48 -0
  101. package/dist/server/sessions/sessionEnvironmentFacts.js.map +1 -0
  102. package/dist/server/sessions/sessionFileFormat.js +27 -0
  103. package/dist/server/sessions/sessionFileFormat.js.map +1 -0
  104. package/dist/server/sessions/sessionFileHeader.js +1 -14
  105. package/dist/server/sessions/sessionFileHeader.js.map +1 -1
  106. package/dist/server/sessions/sessionRoutes.js +14 -0
  107. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  108. package/dist/server/sessions/sessionSummaryScanner.js +96 -224
  109. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
  110. package/dist/server/sessions/spawnSubsessionTool.js +5 -9
  111. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  112. package/dist/server/sessions/spawnTargetResolver.js +1 -1
  113. package/dist/server/status/machineStatusRoutes.js +8 -0
  114. package/dist/server/status/machineStatusRoutes.js.map +1 -0
  115. package/dist/server/status/machineStatusService.js +181 -0
  116. package/dist/server/status/machineStatusService.js.map +1 -0
  117. package/dist/server/status/workspaceAttribution.js +87 -0
  118. package/dist/server/status/workspaceAttribution.js.map +1 -0
  119. package/dist/server/terminalProxyRoutes.js +2 -1
  120. package/dist/server/terminalProxyRoutes.js.map +1 -1
  121. package/dist/server/workspaceExplorerRoutes.js +20 -13
  122. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  123. package/dist/server/workspaces/fileContentService.js +18 -9
  124. package/dist/server/workspaces/fileContentService.js.map +1 -1
  125. package/dist/server/workspaces/filePreviewResponseHeaders.js +18 -0
  126. package/dist/server/workspaces/filePreviewResponseHeaders.js.map +1 -0
  127. package/dist/server/workspaces/filePreviewResponsePolicy.js +55 -0
  128. package/dist/server/workspaces/filePreviewResponsePolicy.js.map +1 -0
  129. package/dist/server/workspaces/filePreviewService.js +81 -0
  130. package/dist/server/workspaces/filePreviewService.js.map +1 -0
  131. package/dist/server/workspaces/projectWorkspaceCwds.js +0 -5
  132. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -1
  133. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +402 -0
  134. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -0
  135. package/dist/server/workspaces/workspaceCatalog.js +47 -0
  136. package/dist/server/workspaces/workspaceCatalog.js.map +1 -0
  137. package/dist/server/workspaces/workspaceContext.js +1 -3
  138. package/dist/server/workspaces/workspaceContext.js.map +1 -1
  139. package/dist/server/workspaces/workspaceDeletionRoutes.js +35 -125
  140. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  141. package/dist/server/workspaces/workspaceProviderRegistry.js +651 -0
  142. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -0
  143. package/dist/server/workspaces/workspaceRemovalService.js +256 -0
  144. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -0
  145. package/dist/server/workspaces/workspaceRouteErrors.js +7 -0
  146. package/dist/server/workspaces/workspaceRouteErrors.js.map +1 -0
  147. package/dist/server/workspaces/worktreePreRemoveHook.js +39 -0
  148. package/dist/server/workspaces/worktreePreRemoveHook.js.map +1 -0
  149. package/dist/server-plugin-api.d.ts +140 -0
  150. package/dist/server-plugin-api.js +2 -0
  151. package/dist/server-plugin-api.js.map +1 -0
  152. package/dist/serverPluginRecovery.js +138 -0
  153. package/dist/serverPluginRecovery.js.map +1 -0
  154. package/dist/sessiond/activeAgentProfile.js +3 -22
  155. package/dist/sessiond/activeAgentProfile.js.map +1 -1
  156. package/dist/sessiond/config.js +11 -0
  157. package/dist/sessiond/config.js.map +1 -1
  158. package/dist/sessiond/sessionDaemonClient.js +11 -9
  159. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  160. package/dist/shared/activeAgentProfile.js +2 -41
  161. package/dist/shared/activeAgentProfile.js.map +1 -1
  162. package/dist/shared/activity.js +0 -3
  163. package/dist/shared/activity.js.map +1 -1
  164. package/dist/shared/apiTypes.js +7 -5
  165. package/dist/shared/apiTypes.js.map +1 -1
  166. package/dist/shared/capabilities.js +8 -6
  167. package/dist/shared/capabilities.js.map +1 -1
  168. package/dist/shared/federatedRoutes.js +28 -5
  169. package/dist/shared/federatedRoutes.js.map +1 -1
  170. package/dist/shared/machinePluginIds.js +4 -2
  171. package/dist/shared/machinePluginIds.js.map +1 -1
  172. package/dist/shared/machineStatus.js +94 -0
  173. package/dist/shared/machineStatus.js.map +1 -0
  174. package/dist/shared/piWebStatusParsing.js +29 -0
  175. package/dist/shared/piWebStatusParsing.js.map +1 -1
  176. package/dist/shared/pluginApiTypes.d.ts +148 -0
  177. package/dist/shared/pluginApiTypes.js +3 -0
  178. package/dist/shared/pluginApiTypes.js.map +1 -0
  179. package/dist/shared/pluginBackendProtocol.js +110 -0
  180. package/dist/shared/pluginBackendProtocol.js.map +1 -0
  181. package/dist/shared/pluginIds.js +5 -0
  182. package/dist/shared/pluginIds.js.map +1 -1
  183. package/dist/shared/pluginRecoveryCommands.js +14 -0
  184. package/dist/shared/pluginRecoveryCommands.js.map +1 -0
  185. package/dist/shared/workspaceFiles.js +41 -2
  186. package/dist/shared/workspaceFiles.js.map +1 -1
  187. package/dist/shared/workspaceRemovalProtocol.js +24 -0
  188. package/dist/shared/workspaceRemovalProtocol.js.map +1 -0
  189. package/docs/config.md +89 -51
  190. package/docs/plugins.md +383 -136
  191. package/examples/workspace-provider-plugin/README.md +52 -0
  192. package/examples/workspace-provider-plugin/package.json +25 -0
  193. package/examples/workspace-provider-plugin/src/browser/index.ts +69 -0
  194. package/examples/workspace-provider-plugin/src/server.ts +64 -0
  195. package/examples/workspace-provider-plugin/tsconfig.json +21 -0
  196. package/package.json +29 -10
  197. package/server-plugin-api.d.ts +1 -0
  198. package/dist/client/assets/UnifiedDiffViewer-BafLWGtF.js +0 -39
  199. package/dist/client/assets/index-CA8q9_o7.js +0 -4025
  200. package/dist/plugin-api/unstable.d.ts +0 -22
  201. package/dist/server/activity/workspaceActivityRoutes.js +0 -4
  202. package/dist/server/activity/workspaceActivityRoutes.js.map +0 -1
  203. package/dist/server/git/gitService.js.map +0 -1
  204. package/dist/server/gitRoutes.js +0 -23
  205. package/dist/server/gitRoutes.js.map +0 -1
  206. package/dist/server/sessions/parentSessionLocator.js +0 -75
  207. package/dist/server/sessions/parentSessionLocator.js.map +0 -1
  208. package/dist/server/workspaces/gitWorktreeDiscovery.js +0 -43
  209. package/dist/server/workspaces/gitWorktreeDiscovery.js.map +0 -1
  210. package/dist/server/workspaces/imagePreviewService.js +0 -40
  211. package/dist/server/workspaces/imagePreviewService.js.map +0 -1
  212. package/dist/server/workspaces/workspaceService.js +0 -52
  213. package/dist/server/workspaces/workspaceService.js.map +0 -1
  214. package/dist/shared/apiTypes.d.ts +0 -1217
  215. package/dist/shared/thinkingLevels.d.ts +0 -27
  216. package/plugin-api/unstable.d.ts +0 -1
@@ -1,6 +1,6 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
4
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.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";
@@ -56,6 +54,14 @@ function spawnTargetError(decision) {
56
54
  return new Error("Spawning session is not in a registered project");
57
55
  return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
58
56
  }
57
+ /**
58
+ * Tracked subsessions are worktree-scoped, so a requested target other than the
59
+ * parent's own cwd fails closed instead of being silently retargeted. The
60
+ * message names the rule and both supported ways to get work done elsewhere.
61
+ */
62
+ function subsessionCwdError(spawningCwd, requestedCwd) {
63
+ 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.`);
64
+ }
59
65
  function modelSpecOf(model) {
60
66
  return `${model.provider}/${model.id}`;
61
67
  }
@@ -275,9 +281,28 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
275
281
  ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
276
282
  ];
277
283
  }
278
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
284
+ /**
285
+ * Resource-loader options that append PI WEB's own system-prompt sections.
286
+ *
287
+ * `appendSystemPromptOverride` composes with what the loader already resolved,
288
+ * so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
289
+ * and PI WEB's sections land after them. Returns `undefined` when there is
290
+ * nothing to add, leaving the loader exactly as pi configures it.
291
+ */
292
+ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
293
+ if (appendSystemPromptSections.length === 0)
294
+ return undefined;
295
+ return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
296
+ }
297
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
298
+ const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
279
299
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
280
- const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
300
+ const services = await createAgentSessionServices({
301
+ cwd,
302
+ agentDir,
303
+ modelRuntime,
304
+ ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
305
+ });
281
306
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
282
307
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
283
308
  const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
@@ -347,13 +372,6 @@ export class PiSessionService {
347
372
  this.subsessionLinks = new Map();
348
373
  /** Parent id/file identities whose persisted links have already been loaded. */
349
374
  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
375
  /**
358
376
  * Tracked subsession id -> whether a completion notification is armed.
359
377
  * Armed when the child starts working; firing on completion disarms it so a
@@ -366,46 +384,23 @@ export class PiSessionService {
366
384
  this.pendingUnreadMutations = [];
367
385
  this.unreadPublicationFlushRequested = false;
368
386
  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
387
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
393
388
  this.agentDir = deps.agentDir;
394
389
  this.sessionManager = deps.sessionManager;
395
390
  this.modelRuntime = deps.modelRuntime;
396
391
  this.spawnTargets = deps.spawnTargets;
397
- this.projectWorkspaces = deps.projectWorkspaces;
398
392
  this.logger = deps.logger ?? noopLogger;
399
393
  this.now = deps.now ?? (() => new Date());
400
394
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
401
395
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
396
+ this.onUnreadChanged = deps.onUnreadChanged;
402
397
  this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
403
398
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
404
399
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
405
400
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
406
401
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
407
402
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
408
- // Subsessions are a beta capability gated behind their own flag, and they
403
+ // Subsessions are gated behind their own flag, and they
409
404
  // also require the spawn capability (they share its project-scope resolver).
410
405
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
411
406
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
@@ -413,7 +408,7 @@ export class PiSessionService {
413
408
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
414
409
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
415
410
  read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
416
- }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
411
+ }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, deps.appendSystemPromptSections ?? []);
417
412
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
418
413
  this.workspaceActivity = deps.workspaceActivity;
419
414
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -541,8 +536,6 @@ export class PiSessionService {
541
536
  this.subsessionLinks.clear();
542
537
  this.subsessionHydratedParents.clear();
543
538
  this.subsessionNotifyArmed.clear();
544
- this.sessionHeaderCache.clear();
545
- this.pendingSessionHeaderReads.clear();
546
539
  this.notificationStore.clearAll("service-dispose");
547
540
  await Promise.all(activeSessions.map(async (active) => {
548
541
  active.unsubscribe();
@@ -573,83 +566,7 @@ export class PiSessionService {
573
566
  .sort(compareArchivedRecords)
574
567
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
575
568
  .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;
569
+ return [...unarchivedSessions, ...archivedSessions];
653
570
  }
654
571
  async start(cwd, options = {}) {
655
572
  return this.startSession(cwd, options);
@@ -711,15 +628,22 @@ export class PiSessionService {
711
628
  };
712
629
  }
713
630
  /**
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.
631
+ * Start a *tracked* child session on behalf of a LLM. Unlike
632
+ * {@link spawnSession}, a tracked child always runs in the parent's own
633
+ * workspace: parent/child trees are worktree-scoped, so a child elsewhere
634
+ * would be invisible to the parent's listing. The child records its parent
635
+ * (so it shows in the session tree) and is registered so the parent is
636
+ * notified when it stops working and can inspect it later.
718
637
  */
719
638
  async spawnSubsession(input) {
720
639
  if (this.spawnTargets === undefined)
721
640
  throw new Error("Spawning sessions is disabled");
722
- const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
641
+ if (input.cwd !== undefined && input.cwd !== "" && !cwdPathsEqual(input.cwd, input.spawningCwd)) {
642
+ throw subsessionCwdError(input.spawningCwd, input.cwd);
643
+ }
644
+ // Resolved against the parent's own cwd only: this still refuses spawning
645
+ // from an unregistered directory, which keeps the child visible in the UI.
646
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, undefined);
723
647
  if (!decision.allowed)
724
648
  throw spawnTargetError(decision);
725
649
  // A model spec overrides the inherited model and is resolved against the
@@ -756,10 +680,12 @@ export class PiSessionService {
756
680
  /**
757
681
  * The models a session may pick from: its scoped set when model-scoped,
758
682
  * otherwise the runtime's available snapshot. Refreshes the runtime catalog
759
- * first so callers see newly configured providers and models.
683
+ * first so callers see newly configured providers and models. The refresh
684
+ * stays local (`allowNetwork: false`); network refreshes belong to the
685
+ * bounded background catalog refresher, not this request path.
760
686
  */
761
687
  async sessionModelCandidates(session) {
762
- await session.modelRuntime.refresh();
688
+ await session.modelRuntime.refresh({ allowNetwork: false });
763
689
  return session.scopedModels.length > 0
764
690
  ? session.scopedModels.map((scoped) => scoped.model)
765
691
  : session.modelRuntime.getAvailableSnapshot();
@@ -1585,6 +1511,83 @@ export class PiSessionService {
1585
1511
  }
1586
1512
  }
1587
1513
  }
1514
+ /**
1515
+ * Fork the session from one entry of its tree into a new session file,
1516
+ * leaving the original session untouched. The forked runtime replaces the
1517
+ * current one, so the outcome is reported for the session the client is
1518
+ * about to join rather than the forked-from record.
1519
+ */
1520
+ async forkFromTree(ref, request) {
1521
+ if (request.entryId.trim() === "")
1522
+ throw new Error("Session tree entry is required");
1523
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1524
+ throw new Error("Stop current session activity before forking the session tree");
1525
+ }
1526
+ await this.assertWritable(ref);
1527
+ const session = await this.getOrOpen(ref);
1528
+ if (this.hasActiveWork(session))
1529
+ throw new Error("Stop current session activity before forking the session tree");
1530
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1531
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1532
+ }
1533
+ this.publishActivity(session, "forking session from entry", "active");
1534
+ this.publishStatus(session);
1535
+ try {
1536
+ const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
1537
+ expectedLeafId: request.expectedLeafId,
1538
+ });
1539
+ if (result.type === "unsupported")
1540
+ throw new Error(result.message);
1541
+ if (result.type !== "done")
1542
+ throw new Error("Session fork is unavailable");
1543
+ if (result.session === undefined) {
1544
+ if (this.isCurrentActiveSession(session)) {
1545
+ this.publishActivity(session, "fork cancelled", "idle");
1546
+ this.publishStatus(session);
1547
+ }
1548
+ return { cancelled: true };
1549
+ }
1550
+ const forkedSession = this.active.get(result.session.id)?.runtime.session;
1551
+ if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
1552
+ this.publishActivity(forkedSession, "session forked", "idle");
1553
+ this.publishStatus(forkedSession);
1554
+ }
1555
+ if (result.session.id !== session.sessionId)
1556
+ this.clearSupersededSessionActivity(session);
1557
+ return {
1558
+ cancelled: false,
1559
+ session: result.session,
1560
+ ...(result.promptDraft === undefined ? {} : { promptDraft: result.promptDraft }),
1561
+ };
1562
+ }
1563
+ catch (error) {
1564
+ const message = error instanceof Error ? error.message : String(error);
1565
+ if (this.isCurrentActiveSession(session)) {
1566
+ this.publishActivity(session, "fork failed", "error", message);
1567
+ this.events.publish(session.sessionId, { type: "session.error", message });
1568
+ this.publishStatus(session);
1569
+ }
1570
+ throw error;
1571
+ }
1572
+ }
1573
+ /**
1574
+ * A changed-id fork can rebind its runtime after a heartbeat published the
1575
+ * prior identity as active. Clear every observable owner before forgetting
1576
+ * that identity's local activity record.
1577
+ */
1578
+ clearSupersededSessionActivity(session) {
1579
+ const sessionId = session.sessionId;
1580
+ if (this.activities.get(sessionId)?.phase === "active") {
1581
+ const at = new Date().toISOString();
1582
+ const stored = { phase: "idle", label: "idle", at };
1583
+ this.activities.set(sessionId, stored);
1584
+ const activity = { sessionId, ...stored };
1585
+ this.events.publish(sessionId, { type: "activity.update", activity });
1586
+ this.events.publishGlobal({ type: "activity.update", activity });
1587
+ }
1588
+ this.workspaceActivity?.removeSession(sessionId, session.sessionManager.getCwd());
1589
+ this.activities.delete(sessionId);
1590
+ }
1588
1591
  async reloadSessionRuntime(session) {
1589
1592
  if (this.hasActiveWork(session))
1590
1593
  throw new Error("Stop current session activity before reloading");
@@ -1832,13 +1835,10 @@ export class PiSessionService {
1832
1835
  if (sessionFile === undefined || sessionFile === "")
1833
1836
  throw new Error("Session is not persisted");
1834
1837
  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.
1838
+ // The header rewrite keeps the inode, and whenever it leaves the file's
1839
+ // size unchanged it is invisible to the gateway's summary memo, which
1840
+ // cannot detect such rewrites from identity + size alone and would keep
1841
+ // listing the old parent link until restart.
1842
1842
  this.sessionManager.invalidateSessionFile(sessionFile);
1843
1843
  clearParentSessionHeader(session.sessionManager);
1844
1844
  this.unregisterSubsession(session.sessionId);
@@ -2106,9 +2106,11 @@ export class PiSessionService {
2106
2106
  const { archivePath } = archived;
2107
2107
  return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
2108
2108
  }
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);
2109
+ // Resolve the session file directly by id: opening one known session must
2110
+ // not depend on — or wait behind — a full transcript listing of its whole
2111
+ // workspace. `getActive` routes prompt/shell/runCommand, so coupling it to
2112
+ // the listing would let an in-flight listing serialize unrelated sends.
2113
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
2112
2114
  if (!match)
2113
2115
  throw new Error("Session not found");
2114
2116
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
@@ -2424,6 +2426,11 @@ export class PiSessionService {
2424
2426
  void this.publishUnreadMutations(mutations).catch(() => undefined);
2425
2427
  }
2426
2428
  publishUnreadMutations(mutations) {
2429
+ // The store applied the mutations already, so the status projection is told
2430
+ // now rather than after the durable flush: it reads in-memory unread state
2431
+ // and must not lag behind the rows the browser is about to see.
2432
+ if (mutations.length > 0)
2433
+ this.onUnreadChanged?.();
2427
2434
  this.enqueueUnreadMutations(mutations);
2428
2435
  this.unreadPublicationFlushRequested = true;
2429
2436
  if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {