@jmfederico/pi-web 1.202609.0 → 1.202610.0

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 (218) 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/assets/index-_1GwJfSs.js +4176 -0
  6. package/dist/client/assets/vendor-editor-core-YWog6mAR.js +10 -0
  7. package/dist/client/assets/vendor-editor-languages-DOCw3vc9.js +28 -0
  8. package/dist/client/index.html +4 -4
  9. package/dist/docker/piWebDockerCommandPlan.js +9 -1
  10. package/dist/docker/piWebDockerCommandPlan.js.map +1 -1
  11. package/dist/nativeServices/installedServiceDefinitions.js +24 -17
  12. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -1
  13. package/dist/nativeServices/serviceAction.js +38 -26
  14. package/dist/nativeServices/serviceAction.js.map +1 -1
  15. package/dist/nativeServices/servicePlan.js +3 -3
  16. package/dist/nativeServices/servicePlan.js.map +1 -1
  17. package/dist/pi-packages/captains-log/README.md +11 -0
  18. package/dist/pi-packages/captains-log/dist/browser/channelProtocol.js +29 -0
  19. package/dist/pi-packages/captains-log/dist/browser/index.js +145 -0
  20. package/dist/pi-packages/captains-log/dist/browser/markdown.js +67 -0
  21. package/dist/pi-packages/captains-log/dist/browser/panel.js +107 -0
  22. package/dist/pi-packages/captains-log/dist/browser/protocol.js +26 -0
  23. package/dist/pi-packages/captains-log/dist/companion.js +126 -0
  24. package/dist/pi-packages/captains-log/dist/roundtrip.js +62 -0
  25. package/dist/pi-packages/captains-log/dist/server.js +210 -0
  26. package/dist/pi-packages/captains-log/dist/store.js +75 -0
  27. package/dist/pi-packages/captains-log/docs/usage.md +32 -0
  28. package/dist/pi-packages/captains-log/package.json +35 -0
  29. package/dist/pi-packages/captains-log/tsconfig.json +21 -0
  30. package/dist/pi-packages/captains-log/vite.config.mjs +28 -0
  31. package/dist/pi-packages/relays/pi-web-plugin.js +1 -1
  32. package/dist/pi-packages/relays/prompts/relay.md +1 -1
  33. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +21 -13
  34. package/dist/pi-web-plugins/files/browser/assets/files-icon-DZObYhxb.svg +3 -0
  35. package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +439 -0
  36. package/dist/pi-web-plugins/files/package.json +15 -0
  37. package/dist/pi-web-plugins/git/browser/git-panel.js +54 -24
  38. package/dist/pi-web-plugins/git/browser/pi-web-plugin.js +1 -1
  39. package/dist/pi-web-plugins/git/git-backend.js +5 -5
  40. package/dist/pi-web-plugins/git/server-plugin.js +5 -3
  41. package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
  42. package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +3590 -0
  43. package/dist/pi-web-plugins/mermaid/browser/pi-web-plugin.js +6 -0
  44. package/dist/pi-web-plugins/mermaid/package.json +13 -0
  45. package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +210 -0
  46. package/dist/pi-web-plugins/terminal/package.json +16 -0
  47. package/dist/pi-web-plugins/terminal/server-plugin.js +365 -0
  48. package/dist/{server/terminals → pi-web-plugins/terminal}/terminalService.js +159 -79
  49. package/dist/pi-web-plugins/updates/pi-web-plugin.js +1 -1
  50. package/dist/pi-web-plugins/workspace-tasks/pi-web-plugin.js +1 -1
  51. package/dist/piWebVersionReport.js +12 -4
  52. package/dist/piWebVersionReport.js.map +1 -1
  53. package/dist/plugin-api.d.ts +235 -23
  54. package/dist/plugin-api.js +1 -0
  55. package/dist/pluginRecoveryCli.js +3 -2
  56. package/dist/pluginRecoveryCli.js.map +1 -1
  57. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  58. package/dist/server/app.js +32 -28
  59. package/dist/server/app.js.map +1 -1
  60. package/dist/server/browserMessageProjection.js +39 -20
  61. package/dist/server/browserMessageProjection.js.map +1 -1
  62. package/dist/server/knownPiPackages.js +12 -0
  63. package/dist/server/knownPiPackages.js.map +1 -0
  64. package/dist/server/machines/machineClient.js +2 -2
  65. package/dist/server/machines/machineClient.js.map +1 -1
  66. package/dist/server/machines/machinePluginProxyRoutes.js +53 -11
  67. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  68. package/dist/server/machines/machineProxyRoutes.js +101 -7
  69. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  70. package/dist/server/notices/serverNoticeStore.js +45 -4
  71. package/dist/server/notices/serverNoticeStore.js.map +1 -1
  72. package/dist/server/piPackageService.js +5 -5
  73. package/dist/server/piPackageService.js.map +1 -1
  74. package/dist/server/piWebPluginCatalog.js +46 -12
  75. package/dist/server/piWebPluginCatalog.js.map +1 -1
  76. package/dist/server/piWebPluginLifecycle.js +26 -3
  77. package/dist/server/piWebPluginLifecycle.js.map +1 -1
  78. package/dist/server/piWebPluginService.js +37 -2
  79. package/dist/server/piWebPluginService.js.map +1 -1
  80. package/dist/server/pluginCallbackDrain.js +35 -0
  81. package/dist/server/pluginCallbackDrain.js.map +1 -0
  82. package/dist/server/plugins/pluginBackendChannelProxyAdmission.js +84 -0
  83. package/dist/server/plugins/pluginBackendChannelProxyAdmission.js.map +1 -0
  84. package/dist/server/plugins/pluginBackendChannelProxyCoordinator.js +234 -0
  85. package/dist/server/plugins/pluginBackendChannelProxyCoordinator.js.map +1 -0
  86. package/dist/server/plugins/pluginBackendChannelProxyRoutes.js +52 -0
  87. package/dist/server/plugins/pluginBackendChannelProxyRoutes.js.map +1 -0
  88. package/dist/server/plugins/pluginBackendProxyRoutes.js +38 -31
  89. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -1
  90. package/dist/server/plugins/pluginBackendRegistry.js +740 -0
  91. package/dist/server/plugins/pluginBackendRegistry.js.map +1 -0
  92. package/dist/server/plugins/serverPluginPiSessionEventsCapability.js +34 -0
  93. package/dist/server/plugins/serverPluginPiSessionEventsCapability.js.map +1 -0
  94. package/dist/server/plugins/serverPluginPiSessionsCapability.js +185 -0
  95. package/dist/server/plugins/serverPluginPiSessionsCapability.js.map +1 -0
  96. package/dist/server/plugins/serverPluginRuntime.js +1028 -107
  97. package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
  98. package/dist/server/plugins/serverPluginWorkspacesCapability.js +114 -0
  99. package/dist/server/plugins/serverPluginWorkspacesCapability.js.map +1 -0
  100. package/dist/server/projects/projectLifecycleService.js +91 -0
  101. package/dist/server/projects/projectLifecycleService.js.map +1 -0
  102. package/dist/server/realtime/sessionEventHub.js +22 -9
  103. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  104. package/dist/server/sessiond/pluginBackendChannelRoutes.js +393 -0
  105. package/dist/server/sessiond/pluginBackendChannelRoutes.js.map +1 -0
  106. package/dist/server/sessiond/pluginBackendRoutes.js +10 -7
  107. package/dist/server/sessiond/pluginBackendRoutes.js.map +1 -1
  108. package/dist/server/sessiond/projectMutationRoutes.js +38 -0
  109. package/dist/server/sessiond/projectMutationRoutes.js.map +1 -0
  110. package/dist/server/sessiond/sessionDaemonShutdown.js +9 -2
  111. package/dist/server/sessiond/sessionDaemonShutdown.js.map +1 -1
  112. package/dist/server/sessiond/sessionProxyRoutes.js +24 -0
  113. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  114. package/dist/server/sessiond.js +99 -24
  115. package/dist/server/sessiond.js.map +1 -1
  116. package/dist/server/sessions/builtinCommands.js +2 -2
  117. package/dist/server/sessions/builtinCommands.js.map +1 -1
  118. package/dist/server/sessions/clientSessionPreview.js +14 -0
  119. package/dist/server/sessions/clientSessionPreview.js.map +1 -0
  120. package/dist/server/sessions/piSessionEventConnections.js +65 -0
  121. package/dist/server/sessions/piSessionEventConnections.js.map +1 -0
  122. package/dist/server/sessions/piSessionManagerGateway.js +8 -2
  123. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  124. package/dist/server/sessions/piSessionService.js +411 -102
  125. package/dist/server/sessions/piSessionService.js.map +1 -1
  126. package/dist/server/sessions/sessionActivityMarker.js +105 -0
  127. package/dist/server/sessions/sessionActivityMarker.js.map +1 -0
  128. package/dist/server/sessions/sessionEnvironmentFacts.js +5 -4
  129. package/dist/server/sessions/sessionEnvironmentFacts.js.map +1 -1
  130. package/dist/server/sessions/sessionMediaIndex.js +184 -0
  131. package/dist/server/sessions/sessionMediaIndex.js.map +1 -0
  132. package/dist/server/sessions/sessionNameGenerator.js +3 -2
  133. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  134. package/dist/server/sessions/sessionRoutes.js +64 -4
  135. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  136. package/dist/server/sessions/sessionUnreadStore.js +26 -1
  137. package/dist/server/sessions/sessionUnreadStore.js.map +1 -1
  138. package/dist/server/sessions/spawnSessionTool.js +7 -2
  139. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  140. package/dist/server/sessions/spawnSubsessionTool.js +7 -2
  141. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  142. package/dist/server/sessions/transcriptMessages.js +54 -0
  143. package/dist/server/sessions/transcriptMessages.js.map +1 -0
  144. package/dist/server/terminals/requiredTerminalService.js +103 -0
  145. package/dist/server/terminals/requiredTerminalService.js.map +1 -0
  146. package/dist/server/webSocketBridge.js +339 -0
  147. package/dist/server/webSocketBridge.js.map +1 -1
  148. package/dist/server/workspaceExplorerRoutes.js +1 -1
  149. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  150. package/dist/server/workspaces/filePreviewService.js +27 -3
  151. package/dist/server/workspaces/filePreviewService.js.map +1 -1
  152. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +23 -3
  153. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -1
  154. package/dist/server/workspaces/workspaceCatalog.js +3 -1
  155. package/dist/server/workspaces/workspaceCatalog.js.map +1 -1
  156. package/dist/server/workspaces/workspaceProviderRegistry.js +64 -149
  157. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
  158. package/dist/server/workspaces/workspaceRemovalService.js +23 -1
  159. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -1
  160. package/dist/server-plugin-api.d.ts +216 -22
  161. package/dist/server-plugin-api.js +319 -2
  162. package/dist/server-plugin-api.js.map +1 -1
  163. package/dist/serverPluginRecovery.js +4 -0
  164. package/dist/serverPluginRecovery.js.map +1 -1
  165. package/dist/sessiond/sessionDaemonClient.js +27 -3
  166. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  167. package/dist/shared/apiTypes.js +1 -1
  168. package/dist/shared/apiTypes.js.map +1 -1
  169. package/dist/shared/federatedRoutes.js +47 -17
  170. package/dist/shared/federatedRoutes.js.map +1 -1
  171. package/dist/shared/machinePluginIds.js +28 -7
  172. package/dist/shared/machinePluginIds.js.map +1 -1
  173. package/dist/shared/pluginApiTypes.d.ts +21 -1
  174. package/dist/shared/pluginApiTypes.js +0 -1
  175. package/dist/shared/pluginBackendProtocol.js +163 -2
  176. package/dist/shared/pluginBackendProtocol.js.map +1 -1
  177. package/dist/shared/pluginIds.js +8 -1
  178. package/dist/shared/pluginIds.js.map +1 -1
  179. package/dist/shared/requiredTerminalPlugin.js +6 -0
  180. package/dist/shared/requiredTerminalPlugin.js.map +1 -0
  181. package/dist/shared/serverNoticeContract.js +88 -0
  182. package/dist/shared/serverNoticeContract.js.map +1 -0
  183. package/dist/shared/sessionDefaults.js +41 -0
  184. package/dist/shared/sessionDefaults.js.map +1 -0
  185. package/dist/shared/sessionMedia.js +6 -0
  186. package/dist/shared/sessionMedia.js.map +1 -0
  187. package/docs/config.md +50 -36
  188. package/docs/plugins.md +157 -1212
  189. package/examples/session-bridge-plugin/README.md +20 -0
  190. package/examples/session-bridge-plugin/docs/usage.md +54 -0
  191. package/examples/session-bridge-plugin/package.json +26 -0
  192. package/examples/session-bridge-plugin/src/browser/index.ts +84 -0
  193. package/examples/session-bridge-plugin/src/browser/protocol.ts +24 -0
  194. package/examples/session-bridge-plugin/src/companion.ts +65 -0
  195. package/examples/session-bridge-plugin/src/reviewRun.ts +39 -0
  196. package/examples/session-bridge-plugin/src/server.ts +85 -0
  197. package/examples/session-bridge-plugin/src/store.ts +57 -0
  198. package/examples/session-bridge-plugin/tsconfig.json +21 -0
  199. package/examples/workspace-provider-plugin/README.md +4 -4
  200. package/examples/workspace-provider-plugin/package.json +1 -1
  201. package/examples/workspace-provider-plugin/src/browser/index.ts +9 -9
  202. package/examples/workspace-provider-plugin/src/server.ts +15 -11
  203. package/package.json +19 -12
  204. package/dist/client/assets/CodeViewer-CGAlg9S8.js +0 -4
  205. package/dist/client/assets/TerminalPanel-xhJRhOas.js +0 -187
  206. package/dist/client/assets/index-DXQKhn1P.js +0 -4326
  207. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +0 -12
  208. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +0 -46
  209. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +0 -1
  210. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +0 -1
  211. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +0 -35
  212. package/dist/server/terminalProxyRoutes.js +0 -141
  213. package/dist/server/terminalProxyRoutes.js.map +0 -1
  214. package/dist/server/terminals/terminalRoutes.js +0 -155
  215. package/dist/server/terminals/terminalRoutes.js.map +0 -1
  216. package/dist/server/terminals/terminalService.js.map +0 -1
  217. package/dist/server/terminals/terminalSize.js +0 -17
  218. package/dist/server/terminals/terminalSize.js.map +0 -1
@@ -1,20 +1,25 @@
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
+ import { isSessionMediaId } from "../../shared/sessionMedia.js";
6
7
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
8
+ import { clientSessionFirstMessagePreview } from "./clientSessionPreview.js";
7
9
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
8
10
  import { SessionCommandService } from "./sessionCommandService.js";
11
+ import { SessionActivityMarker } from "./sessionActivityMarker.js";
9
12
  import { projectSessionTree } from "./sessionTreeProjection.js";
10
13
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
11
14
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
15
+ import { PiSessionEventConnections } from "./piSessionEventConnections.js";
12
16
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
13
17
  import { computeEditPreview } from "./editPreview.js";
14
18
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
15
19
  import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
16
20
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
17
21
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
22
+ import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
18
23
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
19
24
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
20
25
  import { createAskUserToolDefinition } from "./askUserTool.js";
@@ -25,6 +30,7 @@ import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
25
30
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
26
31
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
27
32
  import { buildTranscriptView } from "./subsessionTranscript.js";
33
+ import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
28
34
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
29
35
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
30
36
  import { plainTextTheme } from "./plainTextTheme.js";
@@ -422,7 +428,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
422
428
  return undefined;
423
429
  return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
424
430
  }
425
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
431
+ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
426
432
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
427
433
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
428
434
  // PI WEB always honors pi's project-trust model. When the workspace ships
@@ -435,6 +441,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
435
441
  // browser trust prompt, an untrusted project's resources are skipped
436
442
  // (matching `pi` run without a UI). Projects without trust-requiring
437
443
  // resources skip resolution entirely and are trusted, as before.
444
+ const eventBus = createEventBus();
438
445
  const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
439
446
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
440
447
  // Pre-session-creation trust failures (`project_trust` handler errors)
@@ -446,7 +453,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
446
453
  agentDir,
447
454
  modelRuntime,
448
455
  settingsManager,
449
- ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
456
+ resourceLoaderOptions: { ...resourceLoaderOptions, eventBus },
450
457
  ...(projectTrustRequiring
451
458
  ? {
452
459
  resourceLoaderReloadOptions: {
@@ -480,6 +487,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
480
487
  ...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
481
488
  ...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
482
489
  });
490
+ sessionEvents.register(result.session, eventBus);
483
491
  return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
484
492
  };
485
493
  }
@@ -528,6 +536,8 @@ export class PiSessionService {
528
536
  this.unpersistedTreeBranchLeaves = new WeakMap();
529
537
  /** Counts async operations that may append an entry before they settle. */
530
538
  this.sessionEntryMutationCounts = new WeakMap();
539
+ this.pendingPromptEchoes = new WeakMap();
540
+ this.publishedAssistantPartials = new WeakMap();
531
541
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
532
542
  this.modelScopeMutationQueue = Promise.resolve();
533
543
  /** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
@@ -556,8 +566,8 @@ export class PiSessionService {
556
566
  this.subsessionHydratedParents = new Set();
557
567
  /**
558
568
  * Tracked subsession id -> whether a completion notification is armed.
559
- * Armed when the child starts working; firing on completion disarms it so a
560
- * child that works again (and stops again) notifies the parent each time.
569
+ * Armed when the child's agent starts a run or a prompt fails before starting
570
+ * one (not for standalone commands or compaction); completion disarms it.
561
571
  */
562
572
  this.subsessionNotifyArmed = new Map();
563
573
  this.notificationGenerationBySession = new WeakMap();
@@ -575,6 +585,10 @@ export class PiSessionService {
575
585
  this.spawnTargets = deps.spawnTargets;
576
586
  this.logger = deps.logger ?? noopLogger;
577
587
  this.now = deps.now ?? (() => new Date());
588
+ this.activityMarker = new SessionActivityMarker({
589
+ now: () => this.now().getTime(),
590
+ onError: (error) => { this.logger.info({ err: error }, "Could not update advisory session activity marker"); },
591
+ });
578
592
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
579
593
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
580
594
  this.onUnreadChanged = deps.onUnreadChanged;
@@ -588,7 +602,8 @@ export class PiSessionService {
588
602
  // Subsessions are gated behind their own flag, and they
589
603
  // also require the spawn capability (they share its project-scope resolver).
590
604
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
591
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
605
+ this.sessionEvents = deps.sessionEvents ?? new PiSessionEventConnections();
606
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
592
607
  spawn: (input) => this.spawnSubsession(input),
593
608
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
594
609
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
@@ -625,6 +640,9 @@ export class PiSessionService {
625
640
  notificationCatalog() {
626
641
  return this.notificationStore.catalogSnapshot();
627
642
  }
643
+ async reconcileUnreadWorkspaces(cwds) {
644
+ await this.publishUnreadMutations(this.unreadStore.reconcileWorkspaces(cwds));
645
+ }
628
646
  async unreadCatalog() {
629
647
  await this.publishUnreadMutations([]);
630
648
  return this.unreadStore.durableCatalogSnapshot();
@@ -732,7 +750,10 @@ export class PiSessionService {
732
750
  finally {
733
751
  await active.runtime.dispose();
734
752
  }
735
- }));
753
+ })).finally(async () => {
754
+ this.events.mediaIndex.clear();
755
+ await this.activityMarker.dispose();
756
+ });
736
757
  await this.publishUnreadMutations([]);
737
758
  }
738
759
  async list(cwd) {
@@ -748,14 +769,48 @@ export class PiSessionService {
748
769
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
749
770
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
750
771
  const archivedSessions = archivedForCwd
751
- .sort(compareArchivedRecords)
752
772
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
753
- .filter(isDefined);
773
+ .filter(isDefined)
774
+ .sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
754
775
  return [...unarchivedSessions, ...archivedSessions];
755
776
  }
756
777
  async start(cwd, options = {}) {
757
778
  return this.startSession(cwd, options);
758
779
  }
780
+ /** Publish a host-owned conversation without submitting an initial prompt. */
781
+ async createHostedSession(cwd, signal) {
782
+ const created = await this.startManagedSession(cwd, signal);
783
+ return Object.freeze({ id: created.id });
784
+ }
785
+ /** Atomically start and prompt one visible host-owned, non-delegating session. */
786
+ async startOneShotRun(cwd, text, signal) {
787
+ const promptText = requirePromptText(text);
788
+ return this.startManagedSession(cwd, signal, promptText);
789
+ }
790
+ async startManagedSession(cwd, signal, promptText) {
791
+ signal.throwIfAborted();
792
+ const active = await this.create(this.sessionManager.create(cwd), cwd, { startupIntent: "create", creationProvenance: "host-one-shot", startupSignal: signal });
793
+ const { session } = active.runtime;
794
+ try {
795
+ signal.throwIfAborted();
796
+ if (promptText !== undefined)
797
+ this.maybeGenerateSessionName(session, promptText);
798
+ const created = this.announceCreatedSession(active, cwd);
799
+ // Publication transfers lifetime ownership to normal hosting. Plugin
800
+ // cancellation after this point must not abort this or later user work.
801
+ const completion = promptText === undefined ? Promise.resolve() : this.submitInitialRunPrompt(session, promptText);
802
+ return Object.freeze({ id: created.id, completion });
803
+ }
804
+ catch (error) {
805
+ try {
806
+ await this.closeActive(session.sessionId);
807
+ }
808
+ catch (cleanupError) {
809
+ throw new AggregateError([error, cleanupError], `Failed to clean up managed session ${session.sessionId} after startup failed`, { cause: cleanupError });
810
+ }
811
+ throw error;
812
+ }
813
+ }
759
814
  async startSession(cwd, options) {
760
815
  const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
761
816
  startupIntent: "create",
@@ -764,6 +819,9 @@ export class PiSessionService {
764
819
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
765
820
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
766
821
  });
822
+ return this.announceCreatedSession(active, cwd, options.parentSession);
823
+ }
824
+ announceCreatedSession(active, cwd, parentSession) {
767
825
  const { session } = active.runtime;
768
826
  const created = {
769
827
  id: session.sessionId,
@@ -776,7 +834,7 @@ export class PiSessionService {
776
834
  firstMessage: "",
777
835
  // Include the parent so listeners can nest the new session in the tree
778
836
  // immediately, instead of showing it flat until the next reload.
779
- ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
837
+ ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
780
838
  };
781
839
  // Broadcast so other clients (and the spawning agent's UI) can add the new
782
840
  // session to their list without a manual reload.
@@ -1114,8 +1172,12 @@ export class PiSessionService {
1114
1172
  * `async` so a rejected dialog becomes a rejection rather than a synchronous
1115
1173
  * throw from a promise-returning method.
1116
1174
  */
1117
- async openExtensionDialog(session, request, opts) {
1118
- const signal = opts?.signal;
1175
+ async openExtensionDialog(session, request, opts, startupSignal) {
1176
+ const signal = opts?.signal === undefined
1177
+ ? startupSignal
1178
+ : startupSignal === undefined
1179
+ ? opts.signal
1180
+ : AbortSignal.any([opts.signal, startupSignal]);
1119
1181
  // A pre-aborted signal dismisses the dialog before it ever opens.
1120
1182
  if (signal?.aborted === true)
1121
1183
  return extensionDialogCancelValue(request.kind);
@@ -1465,31 +1527,34 @@ export class PiSessionService {
1465
1527
  });
1466
1528
  }
1467
1529
  /**
1468
- * Drive parent notifications from a tracked child's status. Arms a pending
1469
- * notification while the child is working, and when it stops fires a single
1470
- * follow-up message to the parent via {@link prompt} (which queues if the
1471
- * parent is busy and delivers immediately when it is idle).
1530
+ * Arm on a tracked child's agent_start, then notify the parent once it stops
1531
+ * working. Prompt failures before agent_start explicitly arm the same latch.
1532
+ * Standalone commands and compactions must not arm a completion notice.
1472
1533
  */
1473
- updateSubsessionTracking(session) {
1534
+ updateSubsessionTracking(session, agentStarted = false) {
1474
1535
  const link = this.subsessionLinkForActiveChild(session);
1475
1536
  if (link === undefined)
1476
1537
  return;
1477
1538
  const childId = link.childSessionId;
1478
- if (this.hasActiveWork(session)) {
1539
+ if (agentStarted) {
1479
1540
  this.subsessionNotifyArmed.set(childId, true);
1480
1541
  return;
1481
1542
  }
1543
+ if (this.hasActiveWork(session))
1544
+ return;
1482
1545
  if (this.subsessionNotifyArmed.get(childId) !== true)
1483
1546
  return;
1484
1547
  this.subsessionNotifyArmed.set(childId, false);
1485
- const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
1548
+ const activity = this.activities.get(childId);
1549
+ const status = activity?.phase === "error" ? "error" : "idle";
1550
+ const errorSection = status === "error" && activity?.detail !== undefined ? `Error: ${activity.detail}\n\n` : "";
1486
1551
  const finalText = finalAssistantText(historyMessages(session));
1487
1552
  const outputSection = formatSubsessionNotificationOutput(childId, finalText);
1488
1553
  const workingIds = this.workingSubsessionIds(link.parentSessionId);
1489
1554
  const next = workingIds.length === 0
1490
1555
  ? "No other tracked subsessions are working."
1491
1556
  : `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
1492
- const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
1557
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${errorSection}${outputSection}`;
1493
1558
  void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
1494
1559
  }
1495
1560
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
@@ -1537,12 +1602,72 @@ export class PiSessionService {
1537
1602
  logSubsessionNotificationFailure(parentId, childId, error) {
1538
1603
  this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1539
1604
  }
1605
+ async transcriptSnapshot(ref, page) {
1606
+ const session = await this.getOrOpen(ref);
1607
+ const seqBeforeRead = this.events.currentSeq(session.sessionId);
1608
+ const activeAtCapture = this.hasActiveWork(session);
1609
+ const diskBranch = activeAtCapture ? undefined : await this.readableSessionBranch(ref, session);
1610
+ // Disk reads yield, even if a whole turn starts AND ends while they run.
1611
+ // From the final runtime read through the watermark capture nothing may yield.
1612
+ const branch = diskBranch === undefined || this.hasActiveWork(session) || this.events.currentSeq(session.sessionId) !== seqBeforeRead
1613
+ ? session.sessionManager.getBranch()
1614
+ : diskBranch;
1615
+ const messages = historyMessagesFromEntries(branch);
1616
+ const userCount = messages.filter((message) => isRecord(message) && message["role"] === "user").length;
1617
+ const echoes = (this.pendingPromptEchoes.get(session) ?? []).filter((echo) => echo.userIndex >= userCount);
1618
+ messages.push(...echoes.map((echo) => echo.message));
1619
+ return {
1620
+ page: pageMessagesAtSafeBoundary(messages, page),
1621
+ status: this.statusFromSession(session, transcriptMessageCount(branch) + echoes.length),
1622
+ seq: this.events.currentSeq(session.sessionId),
1623
+ partial: this.publishedAssistantPartials.get(session) ?? null,
1624
+ };
1625
+ }
1540
1626
  async messages(ref, page) {
1541
1627
  const session = await this.getOrOpen(ref);
1542
1628
  return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1543
1629
  }
1630
+ async media(ref, mediaId) {
1631
+ if (!isSessionMediaId(mediaId))
1632
+ throw new Error("Invalid media id");
1633
+ const index = this.events.mediaIndex;
1634
+ const session = this.activeForRef(ref)?.runtime.session ?? this.startupSessionForRef(ref);
1635
+ // Verify session/cwd ownership before consulting the session-scoped cache.
1636
+ // Cold binary reads do not construct an SDK runtime (which can migrate files).
1637
+ const archived = session === undefined ? await this.getArchived(ref) : undefined;
1638
+ const resolved = session === undefined && archived?.archivePath === undefined
1639
+ ? await this.sessionManager.resolveSessionFile(ref.cwd, ref.id)
1640
+ : undefined;
1641
+ const sessionId = session?.sessionId ?? archived?.sessionId ?? resolved?.id;
1642
+ if (sessionId === undefined || (resolved !== undefined && !cwdPathsEqual(resolved.cwd, ref.cwd)))
1643
+ throw new Error("Session not found");
1644
+ const scope = { id: sessionId, cwd: canonicalizeStoredCwd(session?.sessionManager.getCwd() ?? archived?.cwd ?? resolved?.cwd ?? ref.cwd) };
1645
+ const cached = index.get(scope, mediaId);
1646
+ if (cached !== undefined)
1647
+ return cached;
1648
+ if (session !== undefined) {
1649
+ const live = index.find(scope, mediaId, [
1650
+ session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(),
1651
+ session.messages,
1652
+ session.state.streamingMessage,
1653
+ this.publishedAssistantPartials.get(session),
1654
+ ...(this.pendingPromptEchoes.get(session) ?? []).map((echo) => echo.message),
1655
+ ]);
1656
+ if (live !== undefined)
1657
+ return live;
1658
+ }
1659
+ // Read disk on misses even for a cached idle runtime: other processes can
1660
+ // append images on any branch without emitting events through this daemon.
1661
+ const path = session?.sessionFile ?? session?.sessionManager.getSessionFile() ?? archived?.archivePath ?? resolved?.path
1662
+ ?? (session === undefined ? undefined : (await this.sessionManager.resolveSessionFile(ref.cwd, sessionId))?.path);
1663
+ if (path === undefined)
1664
+ return undefined;
1665
+ const entries = await this.sessionManager.readEntries?.(path);
1666
+ return entries === undefined ? undefined : index.find(scope, mediaId, entries);
1667
+ }
1544
1668
  async status(ref) {
1545
1669
  const session = await this.sessionForStatusOrDialogClose(ref);
1670
+ await this.activityMarker.refresh(session.sessionFile, this.hasActiveWork(session));
1546
1671
  if (this.hasActiveWork(session))
1547
1672
  return this.statusFromSession(session);
1548
1673
  const branch = await this.readableSessionBranch(ref, session);
@@ -1567,6 +1692,47 @@ export class PiSessionService {
1567
1692
  : annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
1568
1693
  return { seq, partial };
1569
1694
  }
1695
+ async getSessionDefaults(ref) {
1696
+ await this.getOrOpen(ref);
1697
+ const settings = SettingsManager.create(ref.cwd, this.agentDir);
1698
+ await settings.reload();
1699
+ this.assertDefaultsSettingsHealthy(settings);
1700
+ // Do not use merged getters: workspace overrides are not global pins.
1701
+ return parseSessionDefaults(settings.getGlobalSettings());
1702
+ }
1703
+ async setSessionDefaults(ref, defaults) {
1704
+ const update = parseSessionDefaultsUpdate({ ...defaults });
1705
+ await this.assertWritable(ref);
1706
+ const session = await this.getOrOpen(ref);
1707
+ return this.runModelScopeMutation(async () => {
1708
+ // A separate manager avoids mutating the active session's settings cache.
1709
+ const settings = SettingsManager.create(ref.cwd, this.agentDir);
1710
+ await settings.reload();
1711
+ this.assertDefaultsSettingsHealthy(settings);
1712
+ if (update.provider !== undefined && update.modelId !== undefined) {
1713
+ await session.modelRuntime.refresh({ allowNetwork: false });
1714
+ const target = `${update.provider}/${update.modelId}`;
1715
+ const enabledIds = await resolveEnabledModelIds({ settingsManager: settings, modelRuntime: session.modelRuntime, scopedModels: [] });
1716
+ if (!session.modelRuntime.getAvailableSnapshot().some((model) => modelScopeId(model) === target)) {
1717
+ throw new Error(`Model not found: ${target}`);
1718
+ }
1719
+ if (enabledIds !== null && !enabledIds.includes(target))
1720
+ throw new Error(`Model is not enabled: ${target}`);
1721
+ settings.setDefaultModelAndProvider(update.provider, update.modelId);
1722
+ }
1723
+ else if (update.thinkingLevel !== undefined) {
1724
+ settings.setDefaultThinkingLevel(update.thinkingLevel);
1725
+ }
1726
+ await settings.flush();
1727
+ this.assertDefaultsSettingsHealthy(settings);
1728
+ return parseSessionDefaults(settings.getGlobalSettings());
1729
+ });
1730
+ }
1731
+ assertDefaultsSettingsHealthy(settings) {
1732
+ const errors = settings.drainErrors();
1733
+ if (errors.length > 0)
1734
+ throw new Error(`Session defaults settings failed: ${errors.map(({ error }) => error.message).join("; ")}`);
1735
+ }
1570
1736
  async availableModels(ref) {
1571
1737
  const session = await this.getOrOpen(ref);
1572
1738
  const models = await this.sessionModelCandidates(session);
@@ -1700,7 +1866,12 @@ export class PiSessionService {
1700
1866
  commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
1701
1867
  }
1702
1868
  for (const template of session.promptTemplates) {
1703
- commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
1869
+ commands.push({
1870
+ name: template.name,
1871
+ ...(template.description === undefined ? {} : { description: template.description }),
1872
+ ...(template.argumentHint === undefined ? {} : { argumentHint: template.argumentHint }),
1873
+ source: "prompt",
1874
+ });
1704
1875
  }
1705
1876
  for (const skill of session.resourceLoader.getSkills().skills) {
1706
1877
  commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
@@ -1740,18 +1911,104 @@ export class PiSessionService {
1740
1911
  void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
1741
1912
  }
1742
1913
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
1914
+ return this.beginPromptSubmission(session, text, behavior, images, echoUserMessage).catch(() => undefined);
1915
+ }
1916
+ beginPromptSubmission(session, text, behavior, images = [], echoUserMessage = true) {
1743
1917
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1744
- if (behavior === undefined && echoUserMessage)
1745
- this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
1918
+ const echoes = this.pendingPromptEchoes.get(session) ?? [];
1919
+ const userCount = historyMessagesFromEntries(session.sessionManager.getBranch()).filter((message) => isRecord(message) && message["role"] === "user").length;
1920
+ const echo = behavior === undefined && echoUserMessage
1921
+ ? { userIndex: Math.max(userCount, ...echoes.map((pending) => pending.userIndex + 1)), message: userMessage(text, images) }
1922
+ : undefined;
1923
+ if (echo !== undefined) {
1924
+ // SDK input hooks may await before appending the user message. Keep the
1925
+ // already-published echo visible in snapshots until that append occurs.
1926
+ this.pendingPromptEchoes.set(session, [...echoes, echo]);
1927
+ this.events.publish(session.sessionId, { type: "message.append", message: echo.message }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
1928
+ }
1746
1929
  const promptOptions = buildPromptOptions(behavior, images);
1747
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
1930
+ const commandName = text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
1931
+ const trackEarlyFailure = behavior === undefined
1932
+ && this.subsessionLinkForActiveChild(session) !== undefined
1933
+ && !session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
1934
+ let agentStarted = false;
1935
+ const unsubscribe = !trackEarlyFailure ? undefined : session.subscribe((event) => {
1936
+ if (getString(event, "type") === "agent_start")
1937
+ agentStarted = true;
1938
+ });
1939
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).finally(() => {
1940
+ unsubscribe?.();
1941
+ if (echo !== undefined) {
1942
+ const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
1943
+ if (remaining.length === 0)
1944
+ this.pendingPromptEchoes.delete(session);
1945
+ else
1946
+ this.pendingPromptEchoes.set(session, remaining);
1947
+ }
1948
+ });
1949
+ void promptPromise.catch((error) => {
1748
1950
  const message = error instanceof Error ? error.message : String(error);
1749
1951
  this.publishActivity(session, "error", "error", message);
1750
1952
  this.events.publish(session.sessionId, { type: "session.error", message });
1953
+ if (this.isCurrentActiveSession(session)) {
1954
+ const link = this.subsessionLinkForActiveChild(session);
1955
+ // Model/auth validation can reject without any agent event. Wake the
1956
+ // parent, but do not re-arm a run that already emitted agent_start.
1957
+ if (trackEarlyFailure && !agentStarted && link !== undefined)
1958
+ this.subsessionNotifyArmed.set(link.childSessionId, true);
1959
+ this.updateSubsessionTracking(session);
1960
+ }
1751
1961
  });
1752
- void promptPromise;
1753
1962
  return promptPromise;
1754
1963
  }
1964
+ async submitInitialRunPrompt(session, text) {
1965
+ let failure;
1966
+ let settled = false;
1967
+ let sawInitialUserMessage = false;
1968
+ const unsubscribe = session.subscribe((event) => {
1969
+ if (settled || !isRecord(event))
1970
+ return;
1971
+ if (event["type"] === "agent_settled") {
1972
+ // Freeze the initial outcome before a later user submission can change it.
1973
+ settled = true;
1974
+ return;
1975
+ }
1976
+ if (event["type"] === "auto_retry_end" && event["success"] === false && event["finalError"] === "Retry cancelled") {
1977
+ // Native Pi cancels retry sleep without an aborted assistant message.
1978
+ failure = new DOMException("Initial PI session run was aborted", "AbortError");
1979
+ return;
1980
+ }
1981
+ const message = event["message"];
1982
+ if (!isRecord(message))
1983
+ return;
1984
+ if (event["type"] === "message_start" && message["role"] === "user") {
1985
+ // Pi can drain a queued user follow-up inside the same prompt promise.
1986
+ // Its outcome belongs to that later submission, not this launcher.
1987
+ if (sawInitialUserMessage)
1988
+ settled = true;
1989
+ sawInitialUserMessage = true;
1990
+ return;
1991
+ }
1992
+ if (event["type"] !== "message_end" || message["role"] !== "assistant")
1993
+ return;
1994
+ // A successful retry supersedes a transient provider failure. Pi's
1995
+ // prompt promise can fulfill even when the final assistant is an error.
1996
+ const reason = message["stopReason"];
1997
+ failure = reason === "aborted"
1998
+ ? new DOMException("Initial PI session run was aborted", "AbortError")
1999
+ : reason === "error"
2000
+ ? new Error(typeof message["errorMessage"] === "string" ? message["errorMessage"] : "Initial PI session run failed")
2001
+ : undefined;
2002
+ });
2003
+ try {
2004
+ await this.beginPromptSubmission(session, text, undefined);
2005
+ if (failure !== undefined)
2006
+ throw failure;
2007
+ }
2008
+ finally {
2009
+ unsubscribe();
2010
+ }
2011
+ }
1755
2012
  enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true) {
1756
2013
  const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1757
2014
  queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
@@ -1882,6 +2139,13 @@ export class PiSessionService {
1882
2139
  }
1883
2140
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1884
2141
  }
2142
+ if (session.sessionManager.getLeafId() !== oldLeafId) {
2143
+ // Questions belong to the position we left. Close them for every browser
2144
+ // without appending a cancellation message or waking the rewound session.
2145
+ const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
2146
+ if (outcome !== undefined)
2147
+ this.publishAskClosed(session.sessionId, outcome);
2148
+ }
1885
2149
  if (result.summaryEntry !== undefined) {
1886
2150
  // A summary entry durably identifies the selected branch as the file's
1887
2151
  // newest leaf, superseding any earlier bare selection.
@@ -2322,10 +2586,12 @@ export class PiSessionService {
2322
2586
  async archiveStoreArchiveMany(inputs) {
2323
2587
  if (inputs.length === 0)
2324
2588
  return [];
2589
+ // Work from the bottom of the activity-ordered list, using metadata already loaded.
2590
+ const oldestFirst = [...inputs].sort((a, b) => Date.parse(a.modified) - Date.parse(b.modified));
2325
2591
  if (this.archiveStore.archiveMany !== undefined)
2326
- return this.archiveStore.archiveMany(inputs);
2592
+ return this.archiveStore.archiveMany(oldestFirst);
2327
2593
  const records = [];
2328
- for (const input of inputs)
2594
+ for (const input of oldestFirst)
2329
2595
  records.push(await this.archiveStore.archive(input));
2330
2596
  return records;
2331
2597
  }
@@ -2457,6 +2723,7 @@ export class PiSessionService {
2457
2723
  // Promises inside the dying runtime: settle them rather than dropping them.
2458
2724
  this.endSessionExtensionDialogs(sessionId);
2459
2725
  this.active.delete(sessionId);
2726
+ this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
2460
2727
  this.activities.delete(sessionId);
2461
2728
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
2462
2729
  this.clearAuthLossWarningsForSession(sessionId);
@@ -2473,6 +2740,7 @@ export class PiSessionService {
2473
2740
  await this.abortSessionOperations(active.runtime.session);
2474
2741
  }
2475
2742
  finally {
2743
+ await this.activityMarker.release(active.runtime.session.sessionFile);
2476
2744
  await active.runtime.dispose();
2477
2745
  }
2478
2746
  }
@@ -2564,6 +2832,9 @@ export class PiSessionService {
2564
2832
  }
2565
2833
  async getActive(ref, options = {}) {
2566
2834
  const active = this.activeForRef(ref);
2835
+ const pending = this.pendingSessionOpens.get(JSON.stringify([canonicalizeStoredCwd(ref.cwd), active?.runtime.session.sessionId ?? ref.id]));
2836
+ if (pending !== undefined)
2837
+ return pending.promise;
2567
2838
  if (active !== undefined)
2568
2839
  return active;
2569
2840
  const archived = await this.getArchived(ref);
@@ -2581,13 +2852,13 @@ export class PiSessionService {
2581
2852
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
2582
2853
  }
2583
2854
  openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
2584
- const active = this.activeForRef({ id: sessionId, cwd });
2585
- if (active !== undefined)
2586
- return Promise.resolve(active);
2587
2855
  const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
2588
2856
  const existing = this.pendingSessionOpens.get(key);
2589
2857
  if (existing !== undefined)
2590
2858
  return existing.promise;
2859
+ const active = this.activeForRef({ id: sessionId, cwd });
2860
+ if (active !== undefined)
2861
+ return Promise.resolve(active);
2591
2862
  const pending = {
2592
2863
  sessionId,
2593
2864
  promise: this.create(openSessionManager(), cwd, options),
@@ -2667,18 +2938,27 @@ export class PiSessionService {
2667
2938
  * first is both the slowest phase and one that can fail on its own; a clear
2668
2939
  * that only ran for the later phases would leave a stale label behind.
2669
2940
  */
2670
- async create(sessionManager, cwd, options = {}) {
2941
+ create(sessionManager, cwd, options = {}) {
2671
2942
  const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
2672
- try {
2673
- return await this.createSessionRuntime(sessionManager, cwd, options, startup);
2674
- }
2675
- finally {
2943
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionManager.getSessionId()]);
2944
+ // Early native observation must not admit ordinary operations before
2945
+ // session_start finishes. Track new sessions as well as on-demand opens so
2946
+ // close/dispose wait for startup before tearing down its runtime.
2947
+ const pending = {
2948
+ sessionId: sessionManager.getSessionId(),
2949
+ promise: this.createSessionRuntime(sessionManager, cwd, options, startup),
2950
+ };
2951
+ pending.promise = pending.promise.finally(() => {
2676
2952
  startup.end();
2677
- }
2953
+ if (this.pendingSessionOpens.get(key) === pending)
2954
+ this.pendingSessionOpens.delete(key);
2955
+ });
2956
+ this.pendingSessionOpens.set(key, pending);
2957
+ return pending.promise;
2678
2958
  }
2679
2959
  async createSessionRuntime(sessionManager, cwd, options, startup) {
2680
2960
  startup.report(STARTUP_PHASE_RUNTIME);
2681
- const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
2961
+ const delegationToolsEnabled = options.creationProvenance === undefined
2682
2962
  && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
2683
2963
  const runtime = await this.createAgentRuntime(this.createRuntime, {
2684
2964
  cwd,
@@ -2690,6 +2970,11 @@ export class PiSessionService {
2690
2970
  });
2691
2971
  const active = { runtime, unsubscribe: noop };
2692
2972
  let boundSession = runtime.session;
2973
+ const startupSignal = options.startupSignal;
2974
+ const cancelStartup = () => { this.endSessionExtensionDialogs(boundSession.sessionId); };
2975
+ if (startupSignal !== undefined && !startupSignal.aborted) {
2976
+ startupSignal.addEventListener("abort", cancelStartup, { once: true });
2977
+ }
2693
2978
  let notificationGeneration = options.notificationGeneration;
2694
2979
  let notificationOwnership = options.notifications === "disabled"
2695
2980
  ? "disabled"
@@ -2712,15 +2997,20 @@ export class PiSessionService {
2712
2997
  if (notificationGeneration !== undefined)
2713
2998
  this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
2714
2999
  try {
3000
+ startupSignal?.throwIfAborted();
2715
3001
  if (options.creationProvenance === "tracked-subsession") {
2716
3002
  await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
2717
3003
  }
2718
3004
  else {
2719
3005
  await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
2720
3006
  }
3007
+ startupSignal?.throwIfAborted();
2721
3008
  startup.report(STARTUP_PHASE_EXTENSIONS);
2722
- await this.bindSessionExtensions(runtime.session, notificationGeneration);
3009
+ // session_start hooks use the same native agent as ordinary prompts.
3010
+ // Observe them before binding extensions, including startup messages.
2723
3011
  this.bindRuntime(active);
3012
+ await this.bindSessionExtensions(runtime.session, notificationGeneration, startupSignal);
3013
+ startupSignal?.throwIfAborted();
2724
3014
  runtime.setRebindSession(async (session) => {
2725
3015
  const priorGeneration = notificationGeneration;
2726
3016
  let candidateGeneration;
@@ -2752,6 +3042,7 @@ export class PiSessionService {
2752
3042
  throw error;
2753
3043
  }
2754
3044
  });
3045
+ startupSignal?.throwIfAborted();
2755
3046
  this.active.set(runtime.session.sessionId, active);
2756
3047
  if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2757
3048
  this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
@@ -2795,13 +3086,16 @@ export class PiSessionService {
2795
3086
  }
2796
3087
  throw error;
2797
3088
  }
3089
+ finally {
3090
+ startupSignal?.removeEventListener("abort", cancelStartup);
3091
+ }
2798
3092
  }
2799
- async bindSessionExtensions(session, generation) {
2800
- const uiContext = this.sessionUiContext(session, generation);
2801
- // A `session_start` hook can park this bind on a dialog the browser has
2802
- // not answered yet. On the initial create/open path the session becomes
2803
- // active only after this returns, so register it for the duration: the
2804
- // answer that unblocks startup has to be reachable while it waits.
3093
+ async bindSessionExtensions(session, generation, startupSignal) {
3094
+ const startupDialogs = { signal: startupSignal };
3095
+ const uiContext = this.sessionUiContext(session, generation, startupDialogs);
3096
+ // A session_start hook can park this bind on a browser dialog. Keep
3097
+ // startup lookup and cancellation available for the duration of the bind,
3098
+ // including paths which resolve a dialog before session creation returns.
2805
3099
  this.startupSessions.set(session.sessionId, session);
2806
3100
  try {
2807
3101
  await session.bindExtensions({
@@ -2815,13 +3109,16 @@ export class PiSessionService {
2815
3109
  });
2816
3110
  }
2817
3111
  finally {
3112
+ // Even a captured startup UI context must not carry plugin cancellation
3113
+ // into later user turns after startup has finished.
3114
+ startupDialogs.signal = undefined;
2818
3115
  this.startupSessions.delete(session.sessionId);
2819
3116
  }
2820
3117
  }
2821
3118
  replaceSessionNotificationContext(session, generation) {
2822
3119
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
2823
3120
  }
2824
- sessionUiContext(session, generation) {
3121
+ sessionUiContext(session, generation, startupDialogs) {
2825
3122
  const baseUiContext = session.extensionRunner.getUIContext();
2826
3123
  const notify = (message, type) => {
2827
3124
  if (generation === undefined) {
@@ -2847,13 +3144,13 @@ export class PiSessionService {
2847
3144
  if (property === "theme")
2848
3145
  return plainTextTheme;
2849
3146
  if (property === "confirm") {
2850
- return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
3147
+ return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts, startupDialogs?.signal);
2851
3148
  }
2852
3149
  if (property === "select") {
2853
- return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
3150
+ return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts, startupDialogs?.signal);
2854
3151
  }
2855
3152
  if (property === "input") {
2856
- return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
3153
+ return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts, startupDialogs?.signal);
2857
3154
  }
2858
3155
  const value = Reflect.get(target, property, receiver);
2859
3156
  return value;
@@ -2895,7 +3192,7 @@ export class PiSessionService {
2895
3192
  // now rather than after the durable flush: it reads in-memory unread state
2896
3193
  // and must not lag behind the rows the browser is about to see.
2897
3194
  if (mutations.length > 0)
2898
- this.onUnreadChanged?.();
3195
+ this.onUnreadChanged?.(mutations.some(({ event }) => event.unread !== null));
2899
3196
  this.enqueueUnreadMutations(mutations);
2900
3197
  this.unreadPublicationFlushRequested = true;
2901
3198
  if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
@@ -2975,6 +3272,16 @@ export class PiSessionService {
2975
3272
  this.unreadPublicationRetryTimer = undefined;
2976
3273
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2977
3274
  }
3275
+ connectSessionEvents(ref, lifetime) {
3276
+ const active = this.active.get(ref.id);
3277
+ if (active?.runtime.session.sessionId !== ref.id
3278
+ || !cwdPathsEqual(active.runtime.session.sessionManager.getCwd(), ref.cwd)) {
3279
+ throw new Error("Selected session is not hosted in this workspace on this machine");
3280
+ }
3281
+ if (this.startupSessions.has(ref.id))
3282
+ throw new Error("Selected session is still initializing its extensions");
3283
+ return this.sessionEvents.connect(active.runtime.session, lifetime);
3284
+ }
2978
3285
  bindRuntime(active, session = active.runtime.session) {
2979
3286
  active.unsubscribe();
2980
3287
  for (const [sessionId, candidate] of this.active.entries()) {
@@ -2984,10 +3291,28 @@ export class PiSessionService {
2984
3291
  this.clearCompactionPromptQueue(sessionId);
2985
3292
  }
2986
3293
  }
2987
- active.unsubscribe = session.subscribe((event) => {
2988
- this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
2989
- this.publishActivityForEvent(session, event);
3294
+ const unsubscribe = session.subscribe((event) => {
2990
3295
  const eventType = getString(event, "type");
3296
+ const message = getProperty(event, "message");
3297
+ if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
3298
+ // SDK state advances before async extension hooks finish. Capture only
3299
+ // published messages, detaching even blocks left intact by projection.
3300
+ this.publishedAssistantPartials.set(session, structuredClone(annotateAssistantThinkingLevel(projectBrowserMessage(message), session.thinkingLevel)));
3301
+ }
3302
+ else if (eventType === "message_start" || eventType === "message_end" || eventType === "agent_end") {
3303
+ this.publishedAssistantPartials.delete(session);
3304
+ }
3305
+ this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel), { id: session.sessionId, cwd: session.sessionManager.getCwd() });
3306
+ this.publishActivityForEvent(session, event);
3307
+ // Queued messages can reach the model after an ask opened, even though
3308
+ // there was no ask to dismiss when the user originally submitted them.
3309
+ if (eventType === "message_start" && isRecord(event) && getString(event["message"], "role") === "user") {
3310
+ void this.voidOpenAskForUserMessage(session).catch((error) => {
3311
+ const message = error instanceof Error ? error.message : String(error);
3312
+ this.publishActivity(session, "error", "error", message);
3313
+ this.events.publish(session.sessionId, { type: "session.error", message });
3314
+ });
3315
+ }
2991
3316
  if (eventType === "agent_end")
2992
3317
  this.abortRunScopedExtensionDialogs(session.sessionId);
2993
3318
  if (eventType === "compaction_end")
@@ -2995,8 +3320,13 @@ export class PiSessionService {
2995
3320
  if (eventType === "agent_start" || eventType === "agent_end")
2996
3321
  this.scheduleCompactionQueueDrain(session.sessionId);
2997
3322
  this.publishStatus(session);
2998
- this.updateSubsessionTracking(session);
3323
+ this.updateSubsessionTracking(session, eventType === "agent_start");
2999
3324
  });
3325
+ active.unsubscribe = () => {
3326
+ this.sessionEvents.close(session);
3327
+ unsubscribe();
3328
+ this.publishedAssistantPartials.delete(session);
3329
+ };
3000
3330
  this.active.set(session.sessionId, active);
3001
3331
  }
3002
3332
  scheduleCompactionQueueDrain(sessionId, delayMs = 0) {
@@ -3158,6 +3488,7 @@ export class PiSessionService {
3158
3488
  // the session still reports active work transiently, so the event-driven
3159
3489
  // latch may not fire. The heartbeat re-checks once the session settles.
3160
3490
  this.updateSubsessionTracking(session);
3491
+ void this.refreshActivityMarker(session);
3161
3492
  const activity = this.activities.get(session.sessionId);
3162
3493
  if (!this.hasActiveWork(session)) {
3163
3494
  if (activity?.phase === "active")
@@ -3384,7 +3715,18 @@ export class PiSessionService {
3384
3715
  this.events.publishGlobal({ type: "activity.update", activity });
3385
3716
  this.observeUnreadActivityState(session);
3386
3717
  }
3718
+ async refreshActivityMarker(session) {
3719
+ if (this.active.get(session.sessionId)?.runtime.session !== session)
3720
+ return;
3721
+ const previous = this.activityMarker.isActiveElsewhere(session.sessionFile);
3722
+ await this.activityMarker.refresh(session.sessionFile, this.hasActiveWork(session));
3723
+ if (this.active.get(session.sessionId)?.runtime.session === session
3724
+ && previous !== this.activityMarker.isActiveElsewhere(session.sessionFile)) {
3725
+ this.publishStatus(session);
3726
+ }
3727
+ }
3387
3728
  publishStatus(session) {
3729
+ void this.refreshActivityMarker(session);
3388
3730
  const status = this.statusFromSession(session);
3389
3731
  this.clearStaleActiveActivity(session);
3390
3732
  this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
@@ -3438,6 +3780,13 @@ export class PiSessionService {
3438
3780
  warningsForSession(session) {
3439
3781
  const runtime = this.active.get(session.sessionId)?.runtime;
3440
3782
  const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
3783
+ if (this.activityMarker.isActiveElsewhere(session.sessionFile)) {
3784
+ warnings.push({
3785
+ severity: "info",
3786
+ message: "Recently active in another PI-WEB instance. Avoid working on this session in both instances at once.",
3787
+ source: "PI-WEB",
3788
+ });
3789
+ }
3441
3790
  const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3442
3791
  if (anthropic !== undefined)
3443
3792
  warnings.push(anthropic);
@@ -3535,7 +3884,7 @@ function clientSessionFromListEntry(session) {
3535
3884
  created: session.created.toISOString(),
3536
3885
  modified: session.modified.toISOString(),
3537
3886
  messageCount: session.messageCount,
3538
- firstMessage: session.firstMessage,
3887
+ firstMessage: clientSessionFirstMessagePreview(session.firstMessage),
3539
3888
  ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3540
3889
  };
3541
3890
  }
@@ -3638,7 +3987,7 @@ function clientSessionFromArchivedRecord(record, fallback) {
3638
3987
  created,
3639
3988
  modified,
3640
3989
  messageCount,
3641
- firstMessage,
3990
+ firstMessage: clientSessionFirstMessagePreview(firstMessage),
3642
3991
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3643
3992
  archived: true,
3644
3993
  archivedAt: record.archivedAt,
@@ -3649,13 +3998,6 @@ function addSessionName(names, name) {
3649
3998
  if (trimmed !== undefined && trimmed !== "")
3650
3999
  names.add(trimmed);
3651
4000
  }
3652
- function compareArchivedRecords(a, b) {
3653
- return archivedTimestamp(b) - archivedTimestamp(a);
3654
- }
3655
- function archivedTimestamp(record) {
3656
- const time = Date.parse(record.archivedAt);
3657
- return Number.isNaN(time) ? 0 : time;
3658
- }
3659
4001
  function isDefined(value) {
3660
4002
  return value !== undefined;
3661
4003
  }
@@ -3875,49 +4217,9 @@ function buildPromptOptions(behavior, images) {
3875
4217
  options.images = images;
3876
4218
  return Object.keys(options).length > 0 ? options : undefined;
3877
4219
  }
3878
- function stringValue(value) {
3879
- return typeof value === "string" ? value : "";
3880
- }
3881
- /**
3882
- * Attach the thinking level in effect when an assistant message was generated,
3883
- * so chat bubbles can show it next to the model. Non-assistant messages pass
3884
- * through by reference; assistant messages are copied only when a level is set.
3885
- * "off" is the absence of thinking, not a level worth labeling on every bubble.
3886
- */
3887
- function annotateAssistantThinkingLevel(message, thinkingLevel) {
3888
- if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off")
3889
- return message;
3890
- if (!isRecord(message) || message["role"] !== "assistant")
3891
- return message;
3892
- return { ...message, thinkingLevel };
3893
- }
3894
4220
  function historyMessages(session) {
3895
4221
  return historyMessagesFromEntries(session.sessionManager.getBranch());
3896
4222
  }
3897
- function historyMessagesFromEntries(entries) {
3898
- const messages = [];
3899
- // Pi records the initial level at session creation and every later change, so
3900
- // walking the branch yields the level in effect for each assistant message.
3901
- let thinkingLevel;
3902
- for (const entry of entries) {
3903
- if (!isRecord(entry))
3904
- continue;
3905
- if (entry["type"] === "message")
3906
- messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
3907
- else if (entry["type"] === "thinking_level_change") {
3908
- const level = getString(entry, "thinkingLevel");
3909
- if (level !== undefined)
3910
- thinkingLevel = level;
3911
- }
3912
- else if (entry["type"] === "custom_message" && entry["display"] === true)
3913
- messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
3914
- else if (entry["type"] === "compaction")
3915
- messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
3916
- else if (entry["type"] === "branch_summary")
3917
- messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
3918
- }
3919
- return messages;
3920
- }
3921
4223
  function transcriptMessageCount(entries) {
3922
4224
  let count = 0;
3923
4225
  for (const entry of entries) {
@@ -4007,6 +4309,13 @@ function toClientEvent(event, thinkingLevel) {
4007
4309
  const result = getProperty(event, "result");
4008
4310
  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 };
4009
4311
  }
4312
+ if (eventType === "entry_appended") {
4313
+ // Boundary drafts do not emit message_end. Use the same raw-history
4314
+ // projection as reconnects: context edits must not rewrite the transcript.
4315
+ const [message] = historyMessagesFromEntries([getProperty(event, "entry")]);
4316
+ if (message !== undefined)
4317
+ return { type: "message.append", message };
4318
+ }
4010
4319
  if (eventType === "agent_start")
4011
4320
  return { type: "agent.start" };
4012
4321
  if (eventType === "agent_end")