@jmfederico/pi-web 1.202608.2 → 1.202609.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 (233) hide show
  1. package/README.md +3 -3
  2. package/dist/.plugins-ready +1 -0
  3. package/dist/cli.js +5 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/client/apple-touch-icon-dev.png +0 -0
  6. package/dist/client/assets/index-AroYaCLx.js +4130 -0
  7. package/dist/client/assets/vendor-editor-core-YWog6mAR.js +10 -0
  8. package/dist/client/assets/vendor-editor-languages-DOCw3vc9.js +28 -0
  9. package/dist/client/favicon-dev.svg +8 -0
  10. package/dist/client/index.html +5 -5
  11. package/dist/client/pwa-icon-dev-192.png +0 -0
  12. package/dist/client/pwa-icon-dev-512.png +0 -0
  13. package/dist/config.js +20 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/docker/piWebDockerCommandPlan.js +9 -1
  16. package/dist/docker/piWebDockerCommandPlan.js.map +1 -1
  17. package/dist/nativeServices/installedServiceDefinitions.js +25 -19
  18. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -1
  19. package/dist/nativeServices/serviceAction.js +38 -26
  20. package/dist/nativeServices/serviceAction.js.map +1 -1
  21. package/dist/nativeServices/servicePlan.js +3 -3
  22. package/dist/nativeServices/servicePlan.js.map +1 -1
  23. package/dist/pi-packages/captains-log/README.md +11 -0
  24. package/dist/pi-packages/captains-log/dist/browser/channelProtocol.js +29 -0
  25. package/dist/pi-packages/captains-log/dist/browser/index.js +144 -0
  26. package/dist/pi-packages/captains-log/dist/browser/markdown.js +67 -0
  27. package/dist/pi-packages/captains-log/dist/browser/panel.js +105 -0
  28. package/dist/pi-packages/captains-log/dist/browser/protocol.js +26 -0
  29. package/dist/pi-packages/captains-log/dist/companion.js +126 -0
  30. package/dist/pi-packages/captains-log/dist/roundtrip.js +62 -0
  31. package/dist/pi-packages/captains-log/dist/server.js +210 -0
  32. package/dist/pi-packages/captains-log/dist/store.js +75 -0
  33. package/dist/pi-packages/captains-log/docs/usage.md +30 -0
  34. package/dist/pi-packages/captains-log/package.json +35 -0
  35. package/dist/pi-packages/captains-log/tsconfig.json +21 -0
  36. package/dist/pi-packages/captains-log/vite.config.mjs +28 -0
  37. package/dist/pi-packages/relays/package.json +1 -1
  38. package/dist/pi-packages/relays/pi-web-plugin.js +1 -1
  39. package/dist/pi-packages/relays/prompts/relay-worktree.md +13 -142
  40. package/dist/pi-packages/relays/prompts/relay.md +25 -132
  41. package/dist/pi-packages/relays/relayDiscovery.js +2 -2
  42. package/dist/pi-packages/relays/relaysPanelElement.js +1 -1
  43. package/dist/pi-packages/relays/skills/relay/SKILL.md +59 -88
  44. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +253 -0
  45. package/dist/pi-web-plugins/files/browser/assets/files-icon-DZObYhxb.svg +3 -0
  46. package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +439 -0
  47. package/dist/pi-web-plugins/files/package.json +15 -0
  48. package/dist/pi-web-plugins/git/browser/git-panel.js +54 -24
  49. package/dist/pi-web-plugins/git/browser/pi-web-plugin.js +1 -1
  50. package/dist/pi-web-plugins/git/git-backend.js +5 -5
  51. package/dist/pi-web-plugins/git/server-plugin.js +5 -3
  52. package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
  53. package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +3590 -0
  54. package/dist/pi-web-plugins/mermaid/browser/pi-web-plugin.js +6 -0
  55. package/dist/pi-web-plugins/mermaid/package.json +13 -0
  56. package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +210 -0
  57. package/dist/pi-web-plugins/terminal/package.json +16 -0
  58. package/dist/pi-web-plugins/terminal/server-plugin.js +365 -0
  59. package/dist/{server/terminals → pi-web-plugins/terminal}/terminalService.js +162 -65
  60. package/dist/pi-web-plugins/updates/pi-web-plugin.js +45 -35
  61. package/dist/pi-web-plugins/workspace-tasks/pi-web-plugin.js +1 -1
  62. package/dist/piWebVersionReport.js +12 -4
  63. package/dist/piWebVersionReport.js.map +1 -1
  64. package/dist/plugin-api.d.ts +222 -23
  65. package/dist/plugin-api.js +1 -0
  66. package/dist/pluginRecoveryCli.js +3 -2
  67. package/dist/pluginRecoveryCli.js.map +1 -1
  68. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  69. package/dist/server/app.js +33 -12
  70. package/dist/server/app.js.map +1 -1
  71. package/dist/server/configRoutes.js +6 -1
  72. package/dist/server/configRoutes.js.map +1 -1
  73. package/dist/server/deploymentIdentity.js +67 -0
  74. package/dist/server/deploymentIdentity.js.map +1 -0
  75. package/dist/server/deploymentIdentityRoutes.js +22 -0
  76. package/dist/server/deploymentIdentityRoutes.js.map +1 -0
  77. package/dist/server/knownAutoInstallPiPackages.js +1 -1
  78. package/dist/server/knownAutoInstallPiPackages.js.map +1 -1
  79. package/dist/server/knownPiPackages.js +12 -0
  80. package/dist/server/knownPiPackages.js.map +1 -0
  81. package/dist/server/machines/machineClient.js +2 -2
  82. package/dist/server/machines/machineClient.js.map +1 -1
  83. package/dist/server/machines/machinePluginProxyRoutes.js +53 -11
  84. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  85. package/dist/server/machines/machineProxyRoutes.js +70 -7
  86. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  87. package/dist/server/notices/serverNoticeRoutes.js +34 -0
  88. package/dist/server/notices/serverNoticeRoutes.js.map +1 -0
  89. package/dist/server/notices/serverNoticeService.js +26 -0
  90. package/dist/server/notices/serverNoticeService.js.map +1 -0
  91. package/dist/server/notices/serverNoticeStore.js +119 -0
  92. package/dist/server/notices/serverNoticeStore.js.map +1 -0
  93. package/dist/server/piPackageService.js +5 -5
  94. package/dist/server/piPackageService.js.map +1 -1
  95. package/dist/server/piWebPluginCatalog.js +46 -12
  96. package/dist/server/piWebPluginCatalog.js.map +1 -1
  97. package/dist/server/piWebPluginLifecycle.js +26 -3
  98. package/dist/server/piWebPluginLifecycle.js.map +1 -1
  99. package/dist/server/piWebPluginService.js +37 -2
  100. package/dist/server/piWebPluginService.js.map +1 -1
  101. package/dist/server/piWebStatus.js +1 -1
  102. package/dist/server/piWebStatus.js.map +1 -1
  103. package/dist/server/pluginCallbackDrain.js +35 -0
  104. package/dist/server/pluginCallbackDrain.js.map +1 -0
  105. package/dist/server/plugins/pluginBackendChannelProxyAdmission.js +84 -0
  106. package/dist/server/plugins/pluginBackendChannelProxyAdmission.js.map +1 -0
  107. package/dist/server/plugins/pluginBackendChannelProxyCoordinator.js +234 -0
  108. package/dist/server/plugins/pluginBackendChannelProxyCoordinator.js.map +1 -0
  109. package/dist/server/plugins/pluginBackendChannelProxyRoutes.js +52 -0
  110. package/dist/server/plugins/pluginBackendChannelProxyRoutes.js.map +1 -0
  111. package/dist/server/plugins/pluginBackendProxyRoutes.js +38 -31
  112. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -1
  113. package/dist/server/plugins/pluginBackendRegistry.js +740 -0
  114. package/dist/server/plugins/pluginBackendRegistry.js.map +1 -0
  115. package/dist/server/plugins/serverPluginPiSessionEventsCapability.js +34 -0
  116. package/dist/server/plugins/serverPluginPiSessionEventsCapability.js.map +1 -0
  117. package/dist/server/plugins/serverPluginPiSessionsCapability.js +185 -0
  118. package/dist/server/plugins/serverPluginPiSessionsCapability.js.map +1 -0
  119. package/dist/server/plugins/serverPluginRuntime.js +1028 -107
  120. package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
  121. package/dist/server/plugins/serverPluginWorkspacesCapability.js +114 -0
  122. package/dist/server/plugins/serverPluginWorkspacesCapability.js.map +1 -0
  123. package/dist/server/sessiond/pluginBackendChannelRoutes.js +393 -0
  124. package/dist/server/sessiond/pluginBackendChannelRoutes.js.map +1 -0
  125. package/dist/server/sessiond/pluginBackendRoutes.js +10 -7
  126. package/dist/server/sessiond/pluginBackendRoutes.js.map +1 -1
  127. package/dist/server/sessiond/sessionDaemonShutdown.js +9 -2
  128. package/dist/server/sessiond/sessionDaemonShutdown.js.map +1 -1
  129. package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
  130. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  131. package/dist/server/sessiond/sessionServiceDependencies.js +1 -0
  132. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
  133. package/dist/server/sessiond.js +82 -21
  134. package/dist/server/sessiond.js.map +1 -1
  135. package/dist/server/sessions/attachmentService.js +1 -5
  136. package/dist/server/sessions/attachmentService.js.map +1 -1
  137. package/dist/server/sessions/builtinCommands.js +2 -2
  138. package/dist/server/sessions/builtinCommands.js.map +1 -1
  139. package/dist/server/sessions/clientSessionPreview.js +14 -0
  140. package/dist/server/sessions/clientSessionPreview.js.map +1 -0
  141. package/dist/server/sessions/piSessionEventConnections.js +65 -0
  142. package/dist/server/sessions/piSessionEventConnections.js.map +1 -0
  143. package/dist/server/sessions/piSessionManagerGateway.js +171 -1
  144. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  145. package/dist/server/sessions/piSessionService.js +442 -90
  146. package/dist/server/sessions/piSessionService.js.map +1 -1
  147. package/dist/server/sessions/sessionActivityMarker.js +105 -0
  148. package/dist/server/sessions/sessionActivityMarker.js.map +1 -0
  149. package/dist/server/sessions/sessionEnvironmentFacts.js +5 -4
  150. package/dist/server/sessions/sessionEnvironmentFacts.js.map +1 -1
  151. package/dist/server/sessions/sessionNameGenerator.js +3 -2
  152. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  153. package/dist/server/sessions/sessionRoutes.js +18 -0
  154. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  155. package/dist/server/sessions/spawnSessionTool.js +1 -1
  156. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  157. package/dist/server/sessions/spawnSubsessionTool.js +1 -1
  158. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  159. package/dist/server/sessions/transcriptBranchCache.js +81 -0
  160. package/dist/server/sessions/transcriptBranchCache.js.map +1 -0
  161. package/dist/server/sessions/transcriptMessages.js +54 -0
  162. package/dist/server/sessions/transcriptMessages.js.map +1 -0
  163. package/dist/server/terminals/requiredTerminalService.js +103 -0
  164. package/dist/server/terminals/requiredTerminalService.js.map +1 -0
  165. package/dist/server/webSocketBridge.js +339 -0
  166. package/dist/server/webSocketBridge.js.map +1 -1
  167. package/dist/server/workspaces/projectPiWebConfig.js +6 -1
  168. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  169. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +23 -3
  170. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -1
  171. package/dist/server/workspaces/workspaceCatalog.js +3 -1
  172. package/dist/server/workspaces/workspaceCatalog.js.map +1 -1
  173. package/dist/server/workspaces/workspaceProviderRegistry.js +64 -149
  174. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
  175. package/dist/server/workspaces/workspaceRemovalService.js +38 -5
  176. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -1
  177. package/dist/server-plugin-api.d.ts +216 -22
  178. package/dist/server-plugin-api.js +319 -2
  179. package/dist/server-plugin-api.js.map +1 -1
  180. package/dist/serverPluginRecovery.js +4 -0
  181. package/dist/serverPluginRecovery.js.map +1 -1
  182. package/dist/sessiond/sessionDaemonClient.js +3 -3
  183. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  184. package/dist/shared/apiTypes.js +1 -1
  185. package/dist/shared/apiTypes.js.map +1 -1
  186. package/dist/shared/federatedRoutes.js +47 -17
  187. package/dist/shared/federatedRoutes.js.map +1 -1
  188. package/dist/shared/machinePluginIds.js +28 -7
  189. package/dist/shared/machinePluginIds.js.map +1 -1
  190. package/dist/shared/pluginApiTypes.d.ts +21 -1
  191. package/dist/shared/pluginApiTypes.js +0 -1
  192. package/dist/shared/pluginBackendProtocol.js +163 -2
  193. package/dist/shared/pluginBackendProtocol.js.map +1 -1
  194. package/dist/shared/pluginIds.js +8 -1
  195. package/dist/shared/pluginIds.js.map +1 -1
  196. package/dist/shared/requiredTerminalPlugin.js +6 -0
  197. package/dist/shared/requiredTerminalPlugin.js.map +1 -0
  198. package/dist/shared/serverNoticeContract.js +88 -0
  199. package/dist/shared/serverNoticeContract.js.map +1 -0
  200. package/dist/shared/sessionDefaults.js +41 -0
  201. package/dist/shared/sessionDefaults.js.map +1 -0
  202. package/docs/config.md +77 -37
  203. package/docs/plugins.md +146 -1213
  204. package/examples/session-bridge-plugin/README.md +20 -0
  205. package/examples/session-bridge-plugin/docs/usage.md +54 -0
  206. package/examples/session-bridge-plugin/package.json +26 -0
  207. package/examples/session-bridge-plugin/src/browser/index.ts +84 -0
  208. package/examples/session-bridge-plugin/src/browser/protocol.ts +24 -0
  209. package/examples/session-bridge-plugin/src/companion.ts +65 -0
  210. package/examples/session-bridge-plugin/src/reviewRun.ts +39 -0
  211. package/examples/session-bridge-plugin/src/server.ts +85 -0
  212. package/examples/session-bridge-plugin/src/store.ts +57 -0
  213. package/examples/session-bridge-plugin/tsconfig.json +21 -0
  214. package/examples/workspace-provider-plugin/README.md +4 -4
  215. package/examples/workspace-provider-plugin/package.json +1 -1
  216. package/examples/workspace-provider-plugin/src/browser/index.ts +9 -9
  217. package/examples/workspace-provider-plugin/src/server.ts +15 -11
  218. package/package.json +17 -11
  219. package/dist/client/assets/CodeViewer-BMWwxG7q.js +0 -4
  220. package/dist/client/assets/TerminalPanel-CacQDIYn.js +0 -187
  221. package/dist/client/assets/index-DUW2xnoV.js +0 -4279
  222. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +0 -12
  223. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +0 -46
  224. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +0 -1
  225. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +0 -1
  226. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +0 -35
  227. package/dist/server/terminalProxyRoutes.js +0 -141
  228. package/dist/server/terminalProxyRoutes.js.map +0 -1
  229. package/dist/server/terminals/terminalRoutes.js +0 -155
  230. package/dist/server/terminals/terminalRoutes.js.map +0 -1
  231. package/dist/server/terminals/terminalService.js.map +0 -1
  232. package/dist/server/terminals/terminalSize.js +0 -17
  233. package/dist/server/terminals/terminalSize.js.map +0 -1
@@ -1,19 +1,24 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
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";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEventBus, 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
+ import { clientSessionFirstMessagePreview } from "./clientSessionPreview.js";
7
8
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
8
9
  import { SessionCommandService } from "./sessionCommandService.js";
10
+ import { SessionActivityMarker } from "./sessionActivityMarker.js";
9
11
  import { projectSessionTree } from "./sessionTreeProjection.js";
10
12
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
11
13
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
14
+ import { PiSessionEventConnections } from "./piSessionEventConnections.js";
12
15
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
13
16
  import { computeEditPreview } from "./editPreview.js";
14
17
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
18
+ import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
15
19
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
20
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
21
+ import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
17
22
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
18
23
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
19
24
  import { createAskUserToolDefinition } from "./askUserTool.js";
@@ -24,6 +29,7 @@ import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
24
29
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
25
30
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
26
31
  import { buildTranscriptView } from "./subsessionTranscript.js";
32
+ import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
27
33
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
28
34
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
29
35
  import { plainTextTheme } from "./plainTextTheme.js";
@@ -47,6 +53,15 @@ const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
47
53
  const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
48
54
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
49
55
  const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
56
+ /**
57
+ * Upper bound on how often one idle runtime re-resolves its transcript file.
58
+ * A runtime created in memory and never persisted has no session file, so
59
+ * every poll would otherwise rescan the session directory; throttling keeps
60
+ * steady-state polling of such a session O(1). The window can only delay
61
+ * noticing a file the runtime did not write itself: once the runtime
62
+ * persists, `getSessionFile()` answers and this throttle is bypassed.
63
+ */
64
+ const IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS = 30_000;
50
65
  function noop() {
51
66
  // Intentionally empty default unsubscribe callback.
52
67
  }
@@ -412,7 +427,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
412
427
  return undefined;
413
428
  return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
414
429
  }
415
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
430
+ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
416
431
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
417
432
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
418
433
  // PI WEB always honors pi's project-trust model. When the workspace ships
@@ -425,6 +440,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
425
440
  // browser trust prompt, an untrusted project's resources are skipped
426
441
  // (matching `pi` run without a UI). Projects without trust-requiring
427
442
  // resources skip resolution entirely and are trusted, as before.
443
+ const eventBus = createEventBus();
428
444
  const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
429
445
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
430
446
  // Pre-session-creation trust failures (`project_trust` handler errors)
@@ -436,7 +452,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
436
452
  agentDir,
437
453
  modelRuntime,
438
454
  settingsManager,
439
- ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
455
+ resourceLoaderOptions: { ...resourceLoaderOptions, eventBus },
440
456
  ...(projectTrustRequiring
441
457
  ? {
442
458
  resourceLoaderReloadOptions: {
@@ -470,6 +486,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
470
486
  ...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
471
487
  ...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
472
488
  });
489
+ sessionEvents.register(result.session, eventBus);
473
490
  return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
474
491
  };
475
492
  }
@@ -510,6 +527,12 @@ export class PiSessionService {
510
527
  this.activities = new Map();
511
528
  /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
512
529
  this.treeNavigations = new WeakSet();
530
+ /**
531
+ * Bare live leaf selected without an appended summary entry. Recording that
532
+ * leaf distinguishes the unpersisted move from a later runtime append that
533
+ * can anchor the selected branch on disk.
534
+ */
535
+ this.unpersistedTreeBranchLeaves = new WeakMap();
513
536
  /** Counts async operations that may append an entry before they settle. */
514
537
  this.sessionEntryMutationCounts = new WeakMap();
515
538
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
@@ -545,6 +568,8 @@ export class PiSessionService {
545
568
  */
546
569
  this.subsessionNotifyArmed = new Map();
547
570
  this.notificationGenerationBySession = new WeakMap();
571
+ /** Last idle-poll transcript file resolution per runtime, throttled; entries die with their runtime. */
572
+ this.idleSessionFileResolutions = new WeakMap();
548
573
  /** The parked extension Promise resolvers behind the store's open dialogs. */
549
574
  this.dialogWaiters = new ExtensionDialogWaiters();
550
575
  this.pendingUnreadMutations = [];
@@ -557,6 +582,10 @@ export class PiSessionService {
557
582
  this.spawnTargets = deps.spawnTargets;
558
583
  this.logger = deps.logger ?? noopLogger;
559
584
  this.now = deps.now ?? (() => new Date());
585
+ this.activityMarker = new SessionActivityMarker({
586
+ now: () => this.now().getTime(),
587
+ onError: (error) => { this.logger.info({ err: error }, "Could not update advisory session activity marker"); },
588
+ });
560
589
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
561
590
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
562
591
  this.onUnreadChanged = deps.onUnreadChanged;
@@ -564,12 +593,14 @@ export class PiSessionService {
564
593
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
565
594
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
566
595
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
596
+ this.config = deps.config;
567
597
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
568
598
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
569
599
  // Subsessions are gated behind their own flag, and they
570
600
  // also require the spawn capability (they share its project-scope resolver).
571
601
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
572
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
602
+ this.sessionEvents = deps.sessionEvents ?? new PiSessionEventConnections();
603
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
573
604
  spawn: (input) => this.spawnSubsession(input),
574
605
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
575
606
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
@@ -713,7 +744,7 @@ export class PiSessionService {
713
744
  finally {
714
745
  await active.runtime.dispose();
715
746
  }
716
- }));
747
+ })).finally(() => this.activityMarker.dispose());
717
748
  await this.publishUnreadMutations([]);
718
749
  }
719
750
  async list(cwd) {
@@ -729,14 +760,48 @@ export class PiSessionService {
729
760
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
730
761
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
731
762
  const archivedSessions = archivedForCwd
732
- .sort(compareArchivedRecords)
733
763
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
734
- .filter(isDefined);
764
+ .filter(isDefined)
765
+ .sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
735
766
  return [...unarchivedSessions, ...archivedSessions];
736
767
  }
737
768
  async start(cwd, options = {}) {
738
769
  return this.startSession(cwd, options);
739
770
  }
771
+ /** Publish a host-owned conversation without submitting an initial prompt. */
772
+ async createHostedSession(cwd, signal) {
773
+ const created = await this.startManagedSession(cwd, signal);
774
+ return Object.freeze({ id: created.id });
775
+ }
776
+ /** Atomically start and prompt one visible host-owned, non-delegating session. */
777
+ async startOneShotRun(cwd, text, signal) {
778
+ const promptText = requirePromptText(text);
779
+ return this.startManagedSession(cwd, signal, promptText);
780
+ }
781
+ async startManagedSession(cwd, signal, promptText) {
782
+ signal.throwIfAborted();
783
+ const active = await this.create(this.sessionManager.create(cwd), cwd, { startupIntent: "create", creationProvenance: "host-one-shot", startupSignal: signal });
784
+ const { session } = active.runtime;
785
+ try {
786
+ signal.throwIfAborted();
787
+ if (promptText !== undefined)
788
+ this.maybeGenerateSessionName(session, promptText);
789
+ const created = this.announceCreatedSession(active, cwd);
790
+ // Publication transfers lifetime ownership to normal hosting. Plugin
791
+ // cancellation after this point must not abort this or later user work.
792
+ const completion = promptText === undefined ? Promise.resolve() : this.submitInitialRunPrompt(session, promptText);
793
+ return Object.freeze({ id: created.id, completion });
794
+ }
795
+ catch (error) {
796
+ try {
797
+ await this.closeActive(session.sessionId);
798
+ }
799
+ catch (cleanupError) {
800
+ throw new AggregateError([error, cleanupError], `Failed to clean up managed session ${session.sessionId} after startup failed`, { cause: cleanupError });
801
+ }
802
+ throw error;
803
+ }
804
+ }
740
805
  async startSession(cwd, options) {
741
806
  const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
742
807
  startupIntent: "create",
@@ -745,6 +810,9 @@ export class PiSessionService {
745
810
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
746
811
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
747
812
  });
813
+ return this.announceCreatedSession(active, cwd, options.parentSession);
814
+ }
815
+ announceCreatedSession(active, cwd, parentSession) {
748
816
  const { session } = active.runtime;
749
817
  const created = {
750
818
  id: session.sessionId,
@@ -757,7 +825,7 @@ export class PiSessionService {
757
825
  firstMessage: "",
758
826
  // Include the parent so listeners can nest the new session in the tree
759
827
  // immediately, instead of showing it flat until the next reload.
760
- ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
828
+ ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
761
829
  };
762
830
  // Broadcast so other clients (and the spawning agent's UI) can add the new
763
831
  // session to their list without a manual reload.
@@ -1095,8 +1163,12 @@ export class PiSessionService {
1095
1163
  * `async` so a rejected dialog becomes a rejection rather than a synchronous
1096
1164
  * throw from a promise-returning method.
1097
1165
  */
1098
- async openExtensionDialog(session, request, opts) {
1099
- const signal = opts?.signal;
1166
+ async openExtensionDialog(session, request, opts, startupSignal) {
1167
+ const signal = opts?.signal === undefined
1168
+ ? startupSignal
1169
+ : startupSignal === undefined
1170
+ ? opts.signal
1171
+ : AbortSignal.any([opts.signal, startupSignal]);
1100
1172
  // A pre-aborted signal dismisses the dialog before it ever opens.
1101
1173
  if (signal?.aborted === true)
1102
1174
  return extensionDialogCancelValue(request.kind);
@@ -1520,10 +1592,15 @@ export class PiSessionService {
1520
1592
  }
1521
1593
  async messages(ref, page) {
1522
1594
  const session = await this.getOrOpen(ref);
1523
- return pageMessagesAtSafeBoundary(historyMessages(session), page);
1595
+ return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1524
1596
  }
1525
1597
  async status(ref) {
1526
- return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
1598
+ const session = await this.sessionForStatusOrDialogClose(ref);
1599
+ await this.activityMarker.refresh(session.sessionFile, this.hasActiveWork(session));
1600
+ if (this.hasActiveWork(session))
1601
+ return this.statusFromSession(session);
1602
+ const branch = await this.readableSessionBranch(ref, session);
1603
+ return this.statusFromSession(session, transcriptMessageCount(branch));
1527
1604
  }
1528
1605
  /**
1529
1606
  * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
@@ -1544,6 +1621,47 @@ export class PiSessionService {
1544
1621
  : annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
1545
1622
  return { seq, partial };
1546
1623
  }
1624
+ async getSessionDefaults(ref) {
1625
+ await this.getOrOpen(ref);
1626
+ const settings = SettingsManager.create(ref.cwd, this.agentDir);
1627
+ await settings.reload();
1628
+ this.assertDefaultsSettingsHealthy(settings);
1629
+ // Do not use merged getters: workspace overrides are not global pins.
1630
+ return parseSessionDefaults(settings.getGlobalSettings());
1631
+ }
1632
+ async setSessionDefaults(ref, defaults) {
1633
+ const update = parseSessionDefaultsUpdate({ ...defaults });
1634
+ await this.assertWritable(ref);
1635
+ const session = await this.getOrOpen(ref);
1636
+ return this.runModelScopeMutation(async () => {
1637
+ // A separate manager avoids mutating the active session's settings cache.
1638
+ const settings = SettingsManager.create(ref.cwd, this.agentDir);
1639
+ await settings.reload();
1640
+ this.assertDefaultsSettingsHealthy(settings);
1641
+ if (update.provider !== undefined && update.modelId !== undefined) {
1642
+ await session.modelRuntime.refresh({ allowNetwork: false });
1643
+ const target = `${update.provider}/${update.modelId}`;
1644
+ const enabledIds = await resolveEnabledModelIds({ settingsManager: settings, modelRuntime: session.modelRuntime, scopedModels: [] });
1645
+ if (!session.modelRuntime.getAvailableSnapshot().some((model) => modelScopeId(model) === target)) {
1646
+ throw new Error(`Model not found: ${target}`);
1647
+ }
1648
+ if (enabledIds !== null && !enabledIds.includes(target))
1649
+ throw new Error(`Model is not enabled: ${target}`);
1650
+ settings.setDefaultModelAndProvider(update.provider, update.modelId);
1651
+ }
1652
+ else if (update.thinkingLevel !== undefined) {
1653
+ settings.setDefaultThinkingLevel(update.thinkingLevel);
1654
+ }
1655
+ await settings.flush();
1656
+ this.assertDefaultsSettingsHealthy(settings);
1657
+ return parseSessionDefaults(settings.getGlobalSettings());
1658
+ });
1659
+ }
1660
+ assertDefaultsSettingsHealthy(settings) {
1661
+ const errors = settings.drainErrors();
1662
+ if (errors.length > 0)
1663
+ throw new Error(`Session defaults settings failed: ${errors.map(({ error }) => error.message).join("; ")}`);
1664
+ }
1547
1665
  async availableModels(ref) {
1548
1666
  const session = await this.getOrOpen(ref);
1549
1667
  const models = await this.sessionModelCandidates(session);
@@ -1677,7 +1795,12 @@ export class PiSessionService {
1677
1795
  commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
1678
1796
  }
1679
1797
  for (const template of session.promptTemplates) {
1680
- commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
1798
+ commands.push({
1799
+ name: template.name,
1800
+ ...(template.description === undefined ? {} : { description: template.description }),
1801
+ ...(template.argumentHint === undefined ? {} : { argumentHint: template.argumentHint }),
1802
+ source: "prompt",
1803
+ });
1681
1804
  }
1682
1805
  for (const skill of session.resourceLoader.getSkills().skills) {
1683
1806
  commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
@@ -1717,18 +1840,69 @@ export class PiSessionService {
1717
1840
  void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
1718
1841
  }
1719
1842
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
1843
+ return this.beginPromptSubmission(session, text, behavior, images, echoUserMessage).catch(() => undefined);
1844
+ }
1845
+ beginPromptSubmission(session, text, behavior, images = [], echoUserMessage = true) {
1720
1846
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1721
1847
  if (behavior === undefined && echoUserMessage)
1722
1848
  this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
1723
1849
  const promptOptions = buildPromptOptions(behavior, images);
1724
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
1850
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions));
1851
+ void promptPromise.catch((error) => {
1725
1852
  const message = error instanceof Error ? error.message : String(error);
1726
1853
  this.publishActivity(session, "error", "error", message);
1727
1854
  this.events.publish(session.sessionId, { type: "session.error", message });
1728
1855
  });
1729
- void promptPromise;
1730
1856
  return promptPromise;
1731
1857
  }
1858
+ async submitInitialRunPrompt(session, text) {
1859
+ let failure;
1860
+ let settled = false;
1861
+ let sawInitialUserMessage = false;
1862
+ const unsubscribe = session.subscribe((event) => {
1863
+ if (settled || !isRecord(event))
1864
+ return;
1865
+ if (event["type"] === "agent_settled") {
1866
+ // Freeze the initial outcome before a later user submission can change it.
1867
+ settled = true;
1868
+ return;
1869
+ }
1870
+ if (event["type"] === "auto_retry_end" && event["success"] === false && event["finalError"] === "Retry cancelled") {
1871
+ // Native Pi cancels retry sleep without an aborted assistant message.
1872
+ failure = new DOMException("Initial PI session run was aborted", "AbortError");
1873
+ return;
1874
+ }
1875
+ const message = event["message"];
1876
+ if (!isRecord(message))
1877
+ return;
1878
+ if (event["type"] === "message_start" && message["role"] === "user") {
1879
+ // Pi can drain a queued user follow-up inside the same prompt promise.
1880
+ // Its outcome belongs to that later submission, not this launcher.
1881
+ if (sawInitialUserMessage)
1882
+ settled = true;
1883
+ sawInitialUserMessage = true;
1884
+ return;
1885
+ }
1886
+ if (event["type"] !== "message_end" || message["role"] !== "assistant")
1887
+ return;
1888
+ // A successful retry supersedes a transient provider failure. Pi's
1889
+ // prompt promise can fulfill even when the final assistant is an error.
1890
+ const reason = message["stopReason"];
1891
+ failure = reason === "aborted"
1892
+ ? new DOMException("Initial PI session run was aborted", "AbortError")
1893
+ : reason === "error"
1894
+ ? new Error(typeof message["errorMessage"] === "string" ? message["errorMessage"] : "Initial PI session run failed")
1895
+ : undefined;
1896
+ });
1897
+ try {
1898
+ await this.beginPromptSubmission(session, text, undefined);
1899
+ if (failure !== undefined)
1900
+ throw failure;
1901
+ }
1902
+ finally {
1903
+ unsubscribe();
1904
+ }
1905
+ }
1732
1906
  enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true) {
1733
1907
  const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1734
1908
  queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
@@ -1742,7 +1916,25 @@ export class PiSessionService {
1742
1916
  return [];
1743
1917
  await this.assertWritable(ref);
1744
1918
  const active = await this.getActive(ref);
1745
- return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
1919
+ const cwd = active.runtime.cwd;
1920
+ // An explicit request folder wins; the config lookup below is only the
1921
+ // fallback for folder-less calls (see workspaceAttachmentsConfig).
1922
+ const effectiveFolder = folder ?? (await this.workspaceAttachmentsConfig(cwd)).defaultFolder;
1923
+ return saveAttachmentsToWorkspace(cwd, parsed, effectiveFolder === undefined ? {} : { folder: effectiveFolder });
1924
+ }
1925
+ /**
1926
+ * Fallback attachments config for save requests that omit an explicit
1927
+ * folder: the live global config merged with the session cwd's own
1928
+ * project-local override. Unlike `workspaceEffectiveConfig` in app.ts (which
1929
+ * resolves from the owning project's path), this lookup keys off the cwd
1930
+ * itself, so for secondary (worktree) workspaces it cannot see the owning
1931
+ * project's override. The composer therefore always sends the
1932
+ * workspace-effective folder it displayed explicitly; this cwd-based
1933
+ * resolution only governs folder-less API calls.
1934
+ */
1935
+ async workspaceAttachmentsConfig(cwd) {
1936
+ const globalConfig = this.config === undefined ? {} : (await this.config.read()).effectiveConfig;
1937
+ return loadEffectiveProjectAttachmentsConfig(cwd, globalConfig);
1746
1938
  }
1747
1939
  async shell(ref, text) {
1748
1940
  await this.assertWritable(ref);
@@ -1807,18 +1999,60 @@ export class PiSessionService {
1807
1999
  // may enter this runtime until Pi's potentially asynchronous summary settles.
1808
2000
  this.treeNavigations.add(session);
1809
2001
  try {
1810
- if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
2002
+ const oldLeafId = session.sessionManager.getLeafId();
2003
+ if (oldLeafId !== request.expectedLeafId) {
1811
2004
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1812
2005
  }
2006
+ const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
1813
2007
  this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
1814
2008
  this.publishStatus(session);
1815
- const result = await session.navigateTree(request.targetId, options);
2009
+ let result = await session.navigateTree(request.targetId, options);
2010
+ if (activeEditableTargetParentId !== undefined
2011
+ && !result.cancelled
2012
+ && result.editorText === undefined
2013
+ && result.summaryEntry === undefined
2014
+ && session.sessionManager.getLeafId() === oldLeafId) {
2015
+ // Supported Pi versions can return early when the target is already the
2016
+ // leaf, before applying their user/custom-message edit semantics. Move
2017
+ // to the target's parent and delegate again so Pi still owns editor-text
2018
+ // extraction, agent-context rebuilding, and tree extension events.
2019
+ setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
2020
+ try {
2021
+ result = await session.navigateTree(request.targetId, options);
2022
+ }
2023
+ catch (error) {
2024
+ session.sessionManager.branch(request.targetId);
2025
+ throw error;
2026
+ }
2027
+ if (result.cancelled)
2028
+ session.sessionManager.branch(request.targetId);
2029
+ }
1816
2030
  if (result.cancelled) {
1817
2031
  if (this.isCurrentActiveSession(session)) {
1818
2032
  this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1819
2033
  }
1820
2034
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1821
2035
  }
2036
+ if (session.sessionManager.getLeafId() !== oldLeafId) {
2037
+ // Questions belong to the position we left. Close them for every browser
2038
+ // without appending a cancellation message or waking the rewound session.
2039
+ const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
2040
+ if (outcome !== undefined)
2041
+ this.publishAskClosed(session.sessionId, outcome);
2042
+ }
2043
+ if (result.summaryEntry !== undefined) {
2044
+ // A summary entry durably identifies the selected branch as the file's
2045
+ // newest leaf, superseding any earlier bare selection.
2046
+ this.unpersistedTreeBranchLeaves.delete(session);
2047
+ }
2048
+ else {
2049
+ const selectedLeafId = session.sessionManager.getLeafId();
2050
+ if (selectedLeafId !== oldLeafId) {
2051
+ // SessionManager.branch()/resetLeaf() only move Pi's in-memory leaf.
2052
+ // Keep that branch authoritative until a later append makes disk agree.
2053
+ this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
2054
+ }
2055
+ }
1822
2056
  if (this.isCurrentActiveSession(session))
1823
2057
  this.publishActivity(session, "session tree navigated", "idle");
1824
2058
  return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
@@ -2246,10 +2480,12 @@ export class PiSessionService {
2246
2480
  async archiveStoreArchiveMany(inputs) {
2247
2481
  if (inputs.length === 0)
2248
2482
  return [];
2483
+ // Work from the bottom of the activity-ordered list, using metadata already loaded.
2484
+ const oldestFirst = [...inputs].sort((a, b) => Date.parse(a.modified) - Date.parse(b.modified));
2249
2485
  if (this.archiveStore.archiveMany !== undefined)
2250
- return this.archiveStore.archiveMany(inputs);
2486
+ return this.archiveStore.archiveMany(oldestFirst);
2251
2487
  const records = [];
2252
- for (const input of inputs)
2488
+ for (const input of oldestFirst)
2253
2489
  records.push(await this.archiveStore.archive(input));
2254
2490
  return records;
2255
2491
  }
@@ -2397,6 +2633,7 @@ export class PiSessionService {
2397
2633
  await this.abortSessionOperations(active.runtime.session);
2398
2634
  }
2399
2635
  finally {
2636
+ await this.activityMarker.release(active.runtime.session.sessionFile);
2400
2637
  await active.runtime.dispose();
2401
2638
  }
2402
2639
  }
@@ -2429,8 +2666,68 @@ export class PiSessionService {
2429
2666
  async getOrOpen(ref) {
2430
2667
  return (await this.getActive(ref)).runtime.session;
2431
2668
  }
2669
+ /**
2670
+ * An idle runtime is only Pi Web's cached control view. Another Pi process may
2671
+ * keep appending to the same JSONL file, so transcript reads must open a fresh
2672
+ * read-only snapshot rather than serving that cached branch forever. Active
2673
+ * runtimes remain authoritative and are never replaced, reloaded, or aborted.
2674
+ */
2675
+ async readableSessionBranch(ref, session) {
2676
+ if (this.hasActiveWork(session) || this.sessionManager.readBranch === undefined)
2677
+ return session.sessionManager.getBranch();
2678
+ const sessionFile = session.sessionFile ?? session.sessionManager.getSessionFile();
2679
+ const resolvedPath = sessionFile === undefined ? await this.idleSessionFilePath(ref, session) : undefined;
2680
+ if (this.hasActiveWork(session))
2681
+ return session.sessionManager.getBranch();
2682
+ const path = sessionFile ?? resolvedPath;
2683
+ if (path === undefined)
2684
+ return session.sessionManager.getBranch();
2685
+ const snapshot = await this.sessionManager.readBranch(path);
2686
+ // No snapshot exists when the transcript file is absent (a session known
2687
+ // by path but never persisted, or externally removed): the runtime branch
2688
+ // is the only readable branch then.
2689
+ if (snapshot === undefined)
2690
+ return session.sessionManager.getBranch();
2691
+ // Reading also yields. A prompt that started meanwhile must still win over
2692
+ // the completed disk snapshot and its potentially older event watermark.
2693
+ if (this.hasActiveWork(session))
2694
+ return session.sessionManager.getBranch();
2695
+ const selectedLeafId = this.unpersistedTreeBranchLeaves.get(session);
2696
+ if (selectedLeafId !== undefined) {
2697
+ const runtimeLeafId = session.sessionManager.getLeafId();
2698
+ const selectedBranchIsAnchored = runtimeLeafId !== null
2699
+ && runtimeLeafId !== selectedLeafId
2700
+ && transcriptBranchIncludesEntry(snapshot, runtimeLeafId);
2701
+ if (!selectedBranchIsAnchored)
2702
+ return session.sessionManager.getBranch();
2703
+ // A later runtime append now anchors the live selection in this persisted
2704
+ // branch. Resume ordinary idle snapshots, including external descendants.
2705
+ this.unpersistedTreeBranchLeaves.delete(session);
2706
+ }
2707
+ return snapshot;
2708
+ }
2709
+ /**
2710
+ * Resolve the transcript file for an idle runtime that does not know one,
2711
+ * at most once per {@link IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS}. The
2712
+ * negative result (no file yet) is the hot case: a never-persisted session
2713
+ * is polled every few seconds, and re-scanning its directory each tick is
2714
+ * pure waste. `getActive` never uses this path, so prompt routing always
2715
+ * sees a fresh resolution.
2716
+ */
2717
+ async idleSessionFilePath(ref, session) {
2718
+ const cached = this.idleSessionFileResolutions.get(session);
2719
+ const at = this.now().getTime();
2720
+ if (cached !== undefined && at - cached.at < IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS)
2721
+ return cached.path;
2722
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
2723
+ this.idleSessionFileResolutions.set(session, { at, path: match?.path });
2724
+ return match?.path;
2725
+ }
2432
2726
  async getActive(ref, options = {}) {
2433
2727
  const active = this.activeForRef(ref);
2728
+ const pending = this.pendingSessionOpens.get(JSON.stringify([canonicalizeStoredCwd(ref.cwd), active?.runtime.session.sessionId ?? ref.id]));
2729
+ if (pending !== undefined)
2730
+ return pending.promise;
2434
2731
  if (active !== undefined)
2435
2732
  return active;
2436
2733
  const archived = await this.getArchived(ref);
@@ -2448,13 +2745,13 @@ export class PiSessionService {
2448
2745
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
2449
2746
  }
2450
2747
  openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
2451
- const active = this.activeForRef({ id: sessionId, cwd });
2452
- if (active !== undefined)
2453
- return Promise.resolve(active);
2454
2748
  const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
2455
2749
  const existing = this.pendingSessionOpens.get(key);
2456
2750
  if (existing !== undefined)
2457
2751
  return existing.promise;
2752
+ const active = this.activeForRef({ id: sessionId, cwd });
2753
+ if (active !== undefined)
2754
+ return Promise.resolve(active);
2458
2755
  const pending = {
2459
2756
  sessionId,
2460
2757
  promise: this.create(openSessionManager(), cwd, options),
@@ -2534,18 +2831,27 @@ export class PiSessionService {
2534
2831
  * first is both the slowest phase and one that can fail on its own; a clear
2535
2832
  * that only ran for the later phases would leave a stale label behind.
2536
2833
  */
2537
- async create(sessionManager, cwd, options = {}) {
2834
+ create(sessionManager, cwd, options = {}) {
2538
2835
  const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
2539
- try {
2540
- return await this.createSessionRuntime(sessionManager, cwd, options, startup);
2541
- }
2542
- finally {
2836
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionManager.getSessionId()]);
2837
+ // Early native observation must not admit ordinary operations before
2838
+ // session_start finishes. Track new sessions as well as on-demand opens so
2839
+ // close/dispose wait for startup before tearing down its runtime.
2840
+ const pending = {
2841
+ sessionId: sessionManager.getSessionId(),
2842
+ promise: this.createSessionRuntime(sessionManager, cwd, options, startup),
2843
+ };
2844
+ pending.promise = pending.promise.finally(() => {
2543
2845
  startup.end();
2544
- }
2846
+ if (this.pendingSessionOpens.get(key) === pending)
2847
+ this.pendingSessionOpens.delete(key);
2848
+ });
2849
+ this.pendingSessionOpens.set(key, pending);
2850
+ return pending.promise;
2545
2851
  }
2546
2852
  async createSessionRuntime(sessionManager, cwd, options, startup) {
2547
2853
  startup.report(STARTUP_PHASE_RUNTIME);
2548
- const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
2854
+ const delegationToolsEnabled = options.creationProvenance === undefined
2549
2855
  && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
2550
2856
  const runtime = await this.createAgentRuntime(this.createRuntime, {
2551
2857
  cwd,
@@ -2557,6 +2863,11 @@ export class PiSessionService {
2557
2863
  });
2558
2864
  const active = { runtime, unsubscribe: noop };
2559
2865
  let boundSession = runtime.session;
2866
+ const startupSignal = options.startupSignal;
2867
+ const cancelStartup = () => { this.endSessionExtensionDialogs(boundSession.sessionId); };
2868
+ if (startupSignal !== undefined && !startupSignal.aborted) {
2869
+ startupSignal.addEventListener("abort", cancelStartup, { once: true });
2870
+ }
2560
2871
  let notificationGeneration = options.notificationGeneration;
2561
2872
  let notificationOwnership = options.notifications === "disabled"
2562
2873
  ? "disabled"
@@ -2579,15 +2890,20 @@ export class PiSessionService {
2579
2890
  if (notificationGeneration !== undefined)
2580
2891
  this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
2581
2892
  try {
2893
+ startupSignal?.throwIfAborted();
2582
2894
  if (options.creationProvenance === "tracked-subsession") {
2583
2895
  await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
2584
2896
  }
2585
2897
  else {
2586
2898
  await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
2587
2899
  }
2900
+ startupSignal?.throwIfAborted();
2588
2901
  startup.report(STARTUP_PHASE_EXTENSIONS);
2589
- await this.bindSessionExtensions(runtime.session, notificationGeneration);
2902
+ // session_start hooks use the same native agent as ordinary prompts.
2903
+ // Observe them before binding extensions, including startup messages.
2590
2904
  this.bindRuntime(active);
2905
+ await this.bindSessionExtensions(runtime.session, notificationGeneration, startupSignal);
2906
+ startupSignal?.throwIfAborted();
2591
2907
  runtime.setRebindSession(async (session) => {
2592
2908
  const priorGeneration = notificationGeneration;
2593
2909
  let candidateGeneration;
@@ -2619,6 +2935,7 @@ export class PiSessionService {
2619
2935
  throw error;
2620
2936
  }
2621
2937
  });
2938
+ startupSignal?.throwIfAborted();
2622
2939
  this.active.set(runtime.session.sessionId, active);
2623
2940
  if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2624
2941
  this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
@@ -2662,13 +2979,16 @@ export class PiSessionService {
2662
2979
  }
2663
2980
  throw error;
2664
2981
  }
2982
+ finally {
2983
+ startupSignal?.removeEventListener("abort", cancelStartup);
2984
+ }
2665
2985
  }
2666
- async bindSessionExtensions(session, generation) {
2667
- const uiContext = this.sessionUiContext(session, generation);
2668
- // A `session_start` hook can park this bind on a dialog the browser has
2669
- // not answered yet. On the initial create/open path the session becomes
2670
- // active only after this returns, so register it for the duration: the
2671
- // answer that unblocks startup has to be reachable while it waits.
2986
+ async bindSessionExtensions(session, generation, startupSignal) {
2987
+ const startupDialogs = { signal: startupSignal };
2988
+ const uiContext = this.sessionUiContext(session, generation, startupDialogs);
2989
+ // A session_start hook can park this bind on a browser dialog. Keep
2990
+ // startup lookup and cancellation available for the duration of the bind,
2991
+ // including paths which resolve a dialog before session creation returns.
2672
2992
  this.startupSessions.set(session.sessionId, session);
2673
2993
  try {
2674
2994
  await session.bindExtensions({
@@ -2682,13 +3002,16 @@ export class PiSessionService {
2682
3002
  });
2683
3003
  }
2684
3004
  finally {
3005
+ // Even a captured startup UI context must not carry plugin cancellation
3006
+ // into later user turns after startup has finished.
3007
+ startupDialogs.signal = undefined;
2685
3008
  this.startupSessions.delete(session.sessionId);
2686
3009
  }
2687
3010
  }
2688
3011
  replaceSessionNotificationContext(session, generation) {
2689
3012
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
2690
3013
  }
2691
- sessionUiContext(session, generation) {
3014
+ sessionUiContext(session, generation, startupDialogs) {
2692
3015
  const baseUiContext = session.extensionRunner.getUIContext();
2693
3016
  const notify = (message, type) => {
2694
3017
  if (generation === undefined) {
@@ -2714,13 +3037,13 @@ export class PiSessionService {
2714
3037
  if (property === "theme")
2715
3038
  return plainTextTheme;
2716
3039
  if (property === "confirm") {
2717
- return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
3040
+ return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts, startupDialogs?.signal);
2718
3041
  }
2719
3042
  if (property === "select") {
2720
- return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
3043
+ return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts, startupDialogs?.signal);
2721
3044
  }
2722
3045
  if (property === "input") {
2723
- return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
3046
+ return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts, startupDialogs?.signal);
2724
3047
  }
2725
3048
  const value = Reflect.get(target, property, receiver);
2726
3049
  return value;
@@ -2842,6 +3165,16 @@ export class PiSessionService {
2842
3165
  this.unreadPublicationRetryTimer = undefined;
2843
3166
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2844
3167
  }
3168
+ connectSessionEvents(ref, lifetime) {
3169
+ const active = this.active.get(ref.id);
3170
+ if (active?.runtime.session.sessionId !== ref.id
3171
+ || !cwdPathsEqual(active.runtime.session.sessionManager.getCwd(), ref.cwd)) {
3172
+ throw new Error("Selected session is not hosted in this workspace on this machine");
3173
+ }
3174
+ if (this.startupSessions.has(ref.id))
3175
+ throw new Error("Selected session is still initializing its extensions");
3176
+ return this.sessionEvents.connect(active.runtime.session, lifetime);
3177
+ }
2845
3178
  bindRuntime(active, session = active.runtime.session) {
2846
3179
  active.unsubscribe();
2847
3180
  for (const [sessionId, candidate] of this.active.entries()) {
@@ -2851,7 +3184,7 @@ export class PiSessionService {
2851
3184
  this.clearCompactionPromptQueue(sessionId);
2852
3185
  }
2853
3186
  }
2854
- active.unsubscribe = session.subscribe((event) => {
3187
+ const unsubscribe = session.subscribe((event) => {
2855
3188
  this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
2856
3189
  this.publishActivityForEvent(session, event);
2857
3190
  const eventType = getString(event, "type");
@@ -2864,6 +3197,10 @@ export class PiSessionService {
2864
3197
  this.publishStatus(session);
2865
3198
  this.updateSubsessionTracking(session);
2866
3199
  });
3200
+ active.unsubscribe = () => {
3201
+ this.sessionEvents.close(session);
3202
+ unsubscribe();
3203
+ };
2867
3204
  this.active.set(session.sessionId, active);
2868
3205
  }
2869
3206
  scheduleCompactionQueueDrain(sessionId, delayMs = 0) {
@@ -3025,6 +3362,7 @@ export class PiSessionService {
3025
3362
  // the session still reports active work transiently, so the event-driven
3026
3363
  // latch may not fire. The heartbeat re-checks once the session settles.
3027
3364
  this.updateSubsessionTracking(session);
3365
+ void this.refreshActivityMarker(session);
3028
3366
  const activity = this.activities.get(session.sessionId);
3029
3367
  if (!this.hasActiveWork(session)) {
3030
3368
  if (activity?.phase === "active")
@@ -3251,7 +3589,18 @@ export class PiSessionService {
3251
3589
  this.events.publishGlobal({ type: "activity.update", activity });
3252
3590
  this.observeUnreadActivityState(session);
3253
3591
  }
3592
+ async refreshActivityMarker(session) {
3593
+ if (this.active.get(session.sessionId)?.runtime.session !== session)
3594
+ return;
3595
+ const previous = this.activityMarker.isActiveElsewhere(session.sessionFile);
3596
+ await this.activityMarker.refresh(session.sessionFile, this.hasActiveWork(session));
3597
+ if (this.active.get(session.sessionId)?.runtime.session === session
3598
+ && previous !== this.activityMarker.isActiveElsewhere(session.sessionFile)) {
3599
+ this.publishStatus(session);
3600
+ }
3601
+ }
3254
3602
  publishStatus(session) {
3603
+ void this.refreshActivityMarker(session);
3255
3604
  const status = this.statusFromSession(session);
3256
3605
  this.clearStaleActiveActivity(session);
3257
3606
  this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
@@ -3270,7 +3619,7 @@ export class PiSessionService {
3270
3619
  this.events.publish(session.sessionId, { type: "activity.update", activity });
3271
3620
  this.events.publishGlobal({ type: "activity.update", activity });
3272
3621
  }
3273
- statusFromSession(session) {
3622
+ statusFromSession(session, messageCount = session.messages.length) {
3274
3623
  const stats = session.getSessionStats();
3275
3624
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3276
3625
  const contextUsage = session.getContextUsage();
@@ -3287,7 +3636,7 @@ export class PiSessionService {
3287
3636
  isBashRunning: session.isBashRunning,
3288
3637
  pendingMessageCount: this.pendingMessageCount(session),
3289
3638
  queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
3290
- messageCount: session.messages.length,
3639
+ messageCount,
3291
3640
  tokens: stats.tokens,
3292
3641
  cost: stats.cost,
3293
3642
  ...(contextUsage === undefined ? {} : { contextUsage }),
@@ -3305,6 +3654,13 @@ export class PiSessionService {
3305
3654
  warningsForSession(session) {
3306
3655
  const runtime = this.active.get(session.sessionId)?.runtime;
3307
3656
  const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
3657
+ if (this.activityMarker.isActiveElsewhere(session.sessionFile)) {
3658
+ warnings.push({
3659
+ severity: "info",
3660
+ message: "Recently active in another PI-WEB instance. Avoid working on this session in both instances at once.",
3661
+ source: "PI-WEB",
3662
+ });
3663
+ }
3308
3664
  const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3309
3665
  if (anthropic !== undefined)
3310
3666
  warnings.push(anthropic);
@@ -3402,7 +3758,7 @@ function clientSessionFromListEntry(session) {
3402
3758
  created: session.created.toISOString(),
3403
3759
  modified: session.modified.toISOString(),
3404
3760
  messageCount: session.messageCount,
3405
- firstMessage: session.firstMessage,
3761
+ firstMessage: clientSessionFirstMessagePreview(session.firstMessage),
3406
3762
  ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3407
3763
  };
3408
3764
  }
@@ -3505,7 +3861,7 @@ function clientSessionFromArchivedRecord(record, fallback) {
3505
3861
  created,
3506
3862
  modified,
3507
3863
  messageCount,
3508
- firstMessage,
3864
+ firstMessage: clientSessionFirstMessagePreview(firstMessage),
3509
3865
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3510
3866
  archived: true,
3511
3867
  archivedAt: record.archivedAt,
@@ -3516,13 +3872,6 @@ function addSessionName(names, name) {
3516
3872
  if (trimmed !== undefined && trimmed !== "")
3517
3873
  names.add(trimmed);
3518
3874
  }
3519
- function compareArchivedRecords(a, b) {
3520
- return archivedTimestamp(b) - archivedTimestamp(a);
3521
- }
3522
- function archivedTimestamp(record) {
3523
- const time = Date.parse(record.archivedAt);
3524
- return Number.isNaN(time) ? 0 : time;
3525
- }
3526
3875
  function isDefined(value) {
3527
3876
  return value !== undefined;
3528
3877
  }
@@ -3742,45 +4091,41 @@ function buildPromptOptions(behavior, images) {
3742
4091
  options.images = images;
3743
4092
  return Object.keys(options).length > 0 ? options : undefined;
3744
4093
  }
3745
- function stringValue(value) {
3746
- return typeof value === "string" ? value : "";
4094
+ function historyMessages(session) {
4095
+ return historyMessagesFromEntries(session.sessionManager.getBranch());
3747
4096
  }
3748
- /**
3749
- * Attach the thinking level in effect when an assistant message was generated,
3750
- * so chat bubbles can show it next to the model. Non-assistant messages pass
3751
- * through by reference; assistant messages are copied only when a level is set.
3752
- * "off" is the absence of thinking, not a level worth labeling on every bubble.
3753
- */
3754
- function annotateAssistantThinkingLevel(message, thinkingLevel) {
3755
- if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off")
3756
- return message;
3757
- if (!isRecord(message) || message["role"] !== "assistant")
3758
- return message;
3759
- return { ...message, thinkingLevel };
4097
+ function transcriptMessageCount(entries) {
4098
+ let count = 0;
4099
+ for (const entry of entries) {
4100
+ if (isRecord(entry) && entry["type"] === "message")
4101
+ count += 1;
4102
+ }
4103
+ return count;
3760
4104
  }
3761
- function historyMessages(session) {
3762
- const messages = [];
3763
- // Pi records the initial level at session creation and every later change, so
3764
- // walking the branch yields the level in effect for each assistant message.
3765
- let thinkingLevel;
3766
- for (const entry of session.sessionManager.getBranch()) {
3767
- if (!isRecord(entry))
3768
- continue;
3769
- if (entry["type"] === "message")
3770
- messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
3771
- else if (entry["type"] === "thinking_level_change") {
3772
- const level = getString(entry, "thinkingLevel");
3773
- if (level !== undefined)
3774
- thinkingLevel = level;
3775
- }
3776
- else if (entry["type"] === "custom_message" && entry["display"] === true)
3777
- messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
3778
- else if (entry["type"] === "compaction")
3779
- messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
3780
- else if (entry["type"] === "branch_summary")
3781
- messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
3782
- }
3783
- return messages;
4105
+ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
4106
+ if (activeLeafId !== targetId)
4107
+ return undefined;
4108
+ const entry = manager.getBranch().at(-1);
4109
+ if (!isRecord(entry) || entry["id"] !== targetId)
4110
+ return undefined;
4111
+ const isUserMessage = entry["type"] === "message"
4112
+ && isRecord(entry["message"])
4113
+ && entry["message"]["role"] === "user";
4114
+ if (!isUserMessage && entry["type"] !== "custom_message")
4115
+ return undefined;
4116
+ const parentId = entry["parentId"];
4117
+ if (parentId === targetId)
4118
+ return undefined;
4119
+ return parentId === null || typeof parentId === "string" ? parentId : undefined;
4120
+ }
4121
+ function setSessionTreeLeaf(manager, leafId) {
4122
+ if (leafId === null)
4123
+ manager.resetLeaf();
4124
+ else
4125
+ manager.branch(leafId);
4126
+ }
4127
+ function transcriptBranchIncludesEntry(entries, entryId) {
4128
+ return entries.some((entry) => isRecord(entry) && entry["id"] === entryId);
3784
4129
  }
3785
4130
  /** custom entry type used to persist parent -> child subsession links outside LLM context. */
3786
4131
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
@@ -3838,6 +4183,13 @@ function toClientEvent(event, thinkingLevel) {
3838
4183
  const result = getProperty(event, "result");
3839
4184
  return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
3840
4185
  }
4186
+ if (eventType === "entry_appended") {
4187
+ // Boundary drafts do not emit message_end. Use the same raw-history
4188
+ // projection as reconnects: context edits must not rewrite the transcript.
4189
+ const [message] = historyMessagesFromEntries([getProperty(event, "entry")]);
4190
+ if (message !== undefined)
4191
+ return { type: "message.append", message };
4192
+ }
3841
4193
  if (eventType === "agent_start")
3842
4194
  return { type: "agent.start" };
3843
4195
  if (eventType === "agent_end")