@jmfederico/pi-web 1.202610.0 → 1.202610.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +23 -134
  2. package/dist/cli.js +23 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/index-DUsqiytv.js +4408 -0
  5. package/dist/client/assets/vendor-editor-core-C2PJX3uW.js +10 -0
  6. package/dist/client/assets/{vendor-editor-languages-DOCw3vc9.js → vendor-editor-languages-CS6n1J8n.js} +1 -1
  7. package/dist/client/index.html +4 -48
  8. package/dist/config.js +8 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/pi-packages/captains-log/dist/browser/index.js +34 -34
  11. package/dist/pi-packages/captains-log/package.json +5 -5
  12. package/dist/pi-packages/relays/extensions/index.js +60 -0
  13. package/dist/pi-packages/relays/package.json +10 -1
  14. package/dist/pi-packages/relays/pi-web-plugin.js +31 -1
  15. package/dist/pi-packages/relays/prompts/relay-worktree.md +6 -16
  16. package/dist/pi-packages/relays/prompts/relay.md +6 -33
  17. package/dist/pi-packages/relays/relayDispatch.js +67 -0
  18. package/dist/pi-packages/relays/relayIdentity.js +37 -0
  19. package/dist/pi-packages/relays/relaysPanelElement.js +37 -12
  20. package/dist/pi-packages/relays/skills/relay/SKILL.md +24 -68
  21. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +40 -219
  22. package/dist/pi-packages/workflows/browser/pi-web-plugin.js +33 -0
  23. package/dist/pi-packages/workflows/package.json +22 -0
  24. package/dist/pi-packages/workflows/prompts/worktree.md +12 -0
  25. package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +53 -53
  26. package/dist/pi-web-plugins/git/browser/git-panel.js +0 -1
  27. package/dist/pi-web-plugins/git/server-plugin.js +11 -0
  28. package/dist/pi-web-plugins/git/workspace-discovery-notify.cjs +14 -0
  29. package/dist/pi-web-plugins/git/workspaceDiscoveryHooks.js +180 -0
  30. package/dist/pi-web-plugins/info/infoInternals.js +3 -3
  31. package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
  32. package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +1077 -414
  33. package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +30 -30
  34. package/dist/pi-web-plugins/updates/pi-web-plugin.js +2 -2
  35. package/dist/pi-web-plugins/updates/updatesLogic.js +6 -5
  36. package/dist/piWebUpdate.js +405 -0
  37. package/dist/piWebUpdate.js.map +1 -0
  38. package/dist/piWebVersionReport.js +43 -1
  39. package/dist/piWebVersionReport.js.map +1 -1
  40. package/dist/plugin-api.d.ts +229 -8
  41. package/dist/server/activity/workspaceActivityService.js +4 -1
  42. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  43. package/dist/server/app.js +5 -0
  44. package/dist/server/app.js.map +1 -1
  45. package/dist/server/configRoutes.js +6 -0
  46. package/dist/server/configRoutes.js.map +1 -1
  47. package/dist/server/knownAutoInstallPiPackages.js +6 -0
  48. package/dist/server/knownAutoInstallPiPackages.js.map +1 -1
  49. package/dist/server/navigationPreferenceRoutes.js +39 -0
  50. package/dist/server/navigationPreferenceRoutes.js.map +1 -0
  51. package/dist/server/piWebStatus.js +19 -25
  52. package/dist/server/piWebStatus.js.map +1 -1
  53. package/dist/server/plugins/projectPluginSettings.js +95 -0
  54. package/dist/server/plugins/projectPluginSettings.js.map +1 -0
  55. package/dist/server/plugins/serverPluginRuntime.js +26 -3
  56. package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
  57. package/dist/server/sessiond/autoInstallPiPackages.js +3 -3
  58. package/dist/server/sessiond/workspaceCatalogRoutes.js +3 -1
  59. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -1
  60. package/dist/server/sessiond/workspaceNotificationRoutes.js +41 -0
  61. package/dist/server/sessiond/workspaceNotificationRoutes.js.map +1 -0
  62. package/dist/server/sessiond.js +40 -7
  63. package/dist/server/sessiond.js.map +1 -1
  64. package/dist/server/sessions/askUserDomain.js +232 -0
  65. package/dist/server/sessions/askUserDomain.js.map +1 -0
  66. package/dist/server/sessions/askUserTool.js +17 -17
  67. package/dist/server/sessions/askUserTool.js.map +1 -1
  68. package/dist/server/sessions/builtinExtensionFactories.js +13 -0
  69. package/dist/server/sessions/builtinExtensionFactories.js.map +1 -0
  70. package/dist/server/sessions/dockerEnvironmentFacts.js +8 -0
  71. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -1
  72. package/dist/server/sessions/extensionSessionDispatch.js +76 -0
  73. package/dist/server/sessions/extensionSessionDispatch.js.map +1 -0
  74. package/dist/server/sessions/globalProviderPolicy.js +94 -18
  75. package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
  76. package/dist/server/sessions/messageHistoryTarget.js +61 -0
  77. package/dist/server/sessions/messageHistoryTarget.js.map +1 -0
  78. package/dist/server/sessions/piSessionManagerGateway.js +23 -24
  79. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  80. package/dist/server/sessions/piSessionService.js +604 -266
  81. package/dist/server/sessions/piSessionService.js.map +1 -1
  82. package/dist/server/sessions/sessionArchiveStore.js +5 -1
  83. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  84. package/dist/server/sessions/sessionArchiveTree.js +2 -2
  85. package/dist/server/sessions/sessionArchiveTree.js.map +1 -1
  86. package/dist/server/sessions/sessionCommandService.js +9 -3
  87. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  88. package/dist/server/sessions/sessionImageDimensions.js +31 -0
  89. package/dist/server/sessions/sessionImageDimensions.js.map +1 -0
  90. package/dist/server/sessions/sessionMediaIndex.js +7 -1
  91. package/dist/server/sessions/sessionMediaIndex.js.map +1 -1
  92. package/dist/server/sessions/sessionMetadata.js +51 -0
  93. package/dist/server/sessions/sessionMetadata.js.map +1 -0
  94. package/dist/server/sessions/sessionNameGenerator.js +8 -28
  95. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  96. package/dist/server/sessions/sessionRoutes.js +10 -2
  97. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  98. package/dist/server/sessions/sessionSummaryScanner.js +62 -5
  99. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
  100. package/dist/server/sessions/spawnSessionTool.js +3 -1
  101. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  102. package/dist/server/sessions/spawnSubsessionTool.js +27 -17
  103. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  104. package/dist/server/sessions/subsessionCompletionDelivery.js +15 -0
  105. package/dist/server/sessions/subsessionCompletionDelivery.js.map +1 -0
  106. package/dist/server/sessions/subsessionCompletionInbox.js +134 -0
  107. package/dist/server/sessions/subsessionCompletionInbox.js.map +1 -0
  108. package/dist/server/sessions/subsessionProvenance.js +27 -0
  109. package/dist/server/sessions/subsessionProvenance.js.map +1 -0
  110. package/dist/server/sessions/transcriptMarkdown.js +48 -0
  111. package/dist/server/sessions/transcriptMarkdown.js.map +1 -0
  112. package/dist/server/sessions/transcriptMessages.js +3 -0
  113. package/dist/server/sessions/transcriptMessages.js.map +1 -1
  114. package/dist/server/status/machineStatusService.js +17 -5
  115. package/dist/server/status/machineStatusService.js.map +1 -1
  116. package/dist/server/storage/navigationPreferenceStore.js +137 -0
  117. package/dist/server/storage/navigationPreferenceStore.js.map +1 -0
  118. package/dist/server/workspaces/projectPiWebConfig.js +88 -6
  119. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  120. package/dist/server/workspaces/workspaceChangeNotifier.js +12 -0
  121. package/dist/server/workspaces/workspaceChangeNotifier.js.map +1 -0
  122. package/dist/server/workspaces/workspaceNotificationCommand.js +64 -0
  123. package/dist/server/workspaces/workspaceNotificationCommand.js.map +1 -0
  124. package/dist/server/workspaces/workspaceProviderRegistry.js +55 -0
  125. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
  126. package/dist/server-plugin-api.d.ts +18 -0
  127. package/dist/server-plugin-api.js.map +1 -1
  128. package/dist/sessiond/sessionDaemonClient.js +3 -3
  129. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  130. package/dist/shared/apiTypes.js +2 -2
  131. package/dist/shared/apiTypes.js.map +1 -1
  132. package/dist/shared/federatedRoutes.js +5 -0
  133. package/dist/shared/federatedRoutes.js.map +1 -1
  134. package/dist/shared/machineStatus.js +11 -2
  135. package/dist/shared/machineStatus.js.map +1 -1
  136. package/dist/shared/navigationPreferences.js +6 -0
  137. package/dist/shared/navigationPreferences.js.map +1 -0
  138. package/dist/shared/pluginApiTypes.d.ts +2 -0
  139. package/dist/shared/sessionMedia.js +6 -0
  140. package/dist/shared/sessionMedia.js.map +1 -1
  141. package/dist/shared/streamingBehavior.js +7 -0
  142. package/dist/shared/streamingBehavior.js.map +1 -0
  143. package/dist/shared/themePreference.js +23 -0
  144. package/dist/shared/themePreference.js.map +1 -0
  145. package/dist/workspaceNotificationCli.js +95 -0
  146. package/dist/workspaceNotificationCli.js.map +1 -0
  147. package/docs/config.md +100 -85
  148. package/docs/plugins.md +109 -30
  149. package/examples/session-bridge-plugin/README.md +1 -1
  150. package/examples/session-bridge-plugin/package.json +3 -3
  151. package/examples/workspace-provider-plugin/package.json +1 -1
  152. package/package.json +36 -36
  153. package/dist/client/assets/index-_1GwJfSs.js +0 -4176
  154. package/dist/client/assets/vendor-editor-core-YWog6mAR.js +0 -10
  155. package/dist/server/sessions/pendingAskStore.js +0 -275
  156. package/dist/server/sessions/pendingAskStore.js.map +0 -1
@@ -1,19 +1,24 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { statSync } from "node:fs";
2
4
  import { join } from "node:path";
3
5
  import { readFile, writeFile } from "node:fs/promises";
4
6
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEventBus, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
7
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
8
  import { isSessionMediaId } from "../../shared/sessionMedia.js";
9
+ import { getBuiltinExtensionFactories } from "./builtinExtensionFactories.js";
7
10
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
8
11
  import { clientSessionFirstMessagePreview } from "./clientSessionPreview.js";
9
12
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
10
13
  import { SessionCommandService } from "./sessionCommandService.js";
11
14
  import { SessionActivityMarker } from "./sessionActivityMarker.js";
12
15
  import { projectSessionTree } from "./sessionTreeProjection.js";
16
+ import { messageHistoryTargetId } from "./messageHistoryTarget.js";
13
17
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
14
- import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
18
+ import { findArchiveCandidateByExactId, planSessionArchiveTree } from "./sessionArchiveTree.js";
15
19
  import { PiSessionEventConnections } from "./piSessionEventConnections.js";
16
- import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
20
+ import { fallbackSessionName, generateShortSessionName, requireInitialSessionName } from "./sessionNameGenerator.js";
21
+ import { createExtensionSessionDispatch } from "./extensionSessionDispatch.js";
17
22
  import { computeEditPreview } from "./editPreview.js";
18
23
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
19
24
  import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
@@ -21,19 +26,24 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
21
26
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
22
27
  import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
23
28
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
29
+ import { parsePersistedChildSubsessionLink, parsePersistedParentSubsessionLink } from "./subsessionProvenance.js";
24
30
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
25
31
  import { createAskUserToolDefinition } from "./askUserTool.js";
26
- import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
32
+ import { createAskUserOutcome, renderAskUserAnswersText, resolveAskUserRequest } from "./askUserDomain.js";
27
33
  import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
28
34
  import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
29
35
  import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
30
36
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
37
+ import { readSessionUiMetadata, requireSessionUiMetadata, SESSION_UI_METADATA_CUSTOM_TYPE } from "./sessionMetadata.js";
31
38
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
32
39
  import { buildTranscriptView } from "./subsessionTranscript.js";
40
+ import { createSubsessionCompletionDelivery } from "./subsessionCompletionDelivery.js";
41
+ import { completionReceiptIds, persistedSubsessionCompletions, renderSubsessionCompletions, SUBSESSION_COMPLETION_ENTRY_TYPE, SUBSESSION_NOTIFICATION_CUSTOM_TYPE, SUBSESSION_RECEIPT_ENTRY_TYPE, SubsessionCompletionInbox } from "./subsessionCompletionInbox.js";
33
42
  import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
34
43
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
35
44
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
36
45
  import { plainTextTheme } from "./plainTextTheme.js";
46
+ import { projectTranscriptMarkdown } from "./transcriptMarkdown.js";
37
47
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
38
48
  import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
39
49
  const noopLogger = { info() { } };
@@ -288,14 +298,14 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
288
298
  });
289
299
  };
290
300
  }
291
- export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
301
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUserEnabled = false) {
292
302
  return [
293
303
  createPiWebEditToolDefinition(cwd),
294
304
  ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
295
305
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
296
306
  // Asking the user is not delegation: the questions land in the session the
297
307
  // user is already watching, so tracked children may ask too.
298
- ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
308
+ ...(askUserEnabled ? [createAskUserToolDefinition()] : []),
299
309
  ];
300
310
  }
301
311
  /**
@@ -428,7 +438,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
428
438
  return undefined;
429
439
  return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
430
440
  }
431
- function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
441
+ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUserEnabled = false, appendSystemPromptSections = []) {
432
442
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
433
443
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
434
444
  // PI WEB always honors pi's project-trust model. When the workspace ships
@@ -442,6 +452,14 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
442
452
  // (matching `pi` run without a UI). Projects without trust-requiring
443
453
  // resources skip resolution entirely and are trusted, as before.
444
454
  const eventBus = createEventBus();
455
+ const builtinFactories = await getBuiltinExtensionFactories();
456
+ // Resolved before session_start. The bridge shares the same delegation
457
+ // policy as core tools, including tracked-child and global disablement.
458
+ let dispatchEnabled = false;
459
+ const sessionDispatch = createExtensionSessionDispatch(cwd, {
460
+ ...(spawn === undefined ? {} : { spawn }),
461
+ isEnabled: () => dispatchEnabled,
462
+ });
445
463
  const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
446
464
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
447
465
  // Pre-session-creation trust failures (`project_trust` handler errors)
@@ -453,7 +471,10 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
453
471
  agentDir,
454
472
  modelRuntime,
455
473
  settingsManager,
456
- resourceLoaderOptions: { ...resourceLoaderOptions, eventBus },
474
+ resourceLoaderOptions: {
475
+ ...resourceLoaderOptions, eventBus,
476
+ extensionFactories: [...builtinFactories, sessionDispatch, ...(subsessions === undefined ? [] : [createSubsessionCompletionDelivery(subsessions)])],
477
+ },
457
478
  ...(projectTrustRequiring
458
479
  ? {
459
480
  resourceLoaderReloadOptions: {
@@ -477,7 +498,8 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
477
498
  services.diagnostics.push(...modelOptions.diagnostics);
478
499
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
479
500
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
480
- const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
501
+ dispatchEnabled = resolvedDelegationToolsEnabled;
502
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUserEnabled);
481
503
  const result = await createAgentSessionFromServices({
482
504
  services,
483
505
  sessionManager,
@@ -534,8 +556,12 @@ export class PiSessionService {
534
556
  * can anchor the selected branch on disk.
535
557
  */
536
558
  this.unpersistedTreeBranchLeaves = new WeakMap();
559
+ /** Latest prepared text belongs to the session, never to a browser's unsent draft. */
560
+ this.suggestedInputs = new Map();
537
561
  /** Counts async operations that may append an entry before they settle. */
538
562
  this.sessionEntryMutationCounts = new WeakMap();
563
+ /** Exempt only a command's own prompt receipt while it invokes guarded session control. */
564
+ this.extensionCommandExecution = new AsyncLocalStorage();
539
565
  this.pendingPromptEchoes = new WeakMap();
540
566
  this.publishedAssistantPartials = new WeakMap();
541
567
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
@@ -551,7 +577,11 @@ export class PiSessionService {
551
577
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
552
578
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
553
579
  this.treeExclusiveSessionOperationCounts = new Map();
554
- this.deferredSubsessionNotifications = new WeakMap();
580
+ /** Parent id/file identity -> one inbox shared by all completion delivery paths. */
581
+ this.subsessionInboxes = new Map();
582
+ this.subsessionStoppedParents = new WeakSet();
583
+ this.subsessionDeliveringParents = new WeakSet();
584
+ this.subsessionDeliveryScheduled = new WeakSet();
555
585
  this.deferredGeneratedSessionNames = new WeakMap();
556
586
  this.compactionPromptQueues = new Map();
557
587
  this.compactionDrainTimers = new Map();
@@ -592,7 +622,6 @@ export class PiSessionService {
592
622
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
593
623
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
594
624
  this.onUnreadChanged = deps.onUnreadChanged;
595
- this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
596
625
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
597
626
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
598
627
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
@@ -606,9 +635,11 @@ export class PiSessionService {
606
635
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
607
636
  spawn: (input) => this.spawnSubsession(input),
608
637
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
609
- check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
610
- read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
611
- }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, deps.appendSystemPromptSections ?? []);
638
+ join: (parentSessionId, parentSessionFile, toolCallId) => this.joinSubsessions(parentSessionId, parentSessionFile, toolCallId),
639
+ prepare: (parentSessionId, parentSessionFile) => this.prepareSubsessionDelivery(parentSessionId, parentSessionFile),
640
+ check: (parentSessionId, sessionId, parentSessionFile, toolCallId) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId),
641
+ read: (parentSessionId, sessionId, query, parentSessionFile, toolCallId) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId),
642
+ }, deps.askUserEnabled === true, deps.appendSystemPromptSections ?? []);
612
643
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
613
644
  this.workspaceActivity = deps.workspaceActivity;
614
645
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -725,12 +756,12 @@ export class PiSessionService {
725
756
  const activeSessions = Array.from(new Set(this.active.values()));
726
757
  for (const active of activeSessions) {
727
758
  this.forgetUnreadActivity(active.runtime.session);
728
- this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
729
759
  this.endSessionExtensionDialogs(active.runtime.session.sessionId);
730
760
  }
731
761
  this.active.clear();
732
762
  this.pendingSessionOpens.clear();
733
763
  this.startupSessions.clear();
764
+ this.suggestedInputs.clear();
734
765
  this.activities.clear();
735
766
  this.compactionPromptQueues.clear();
736
767
  this.authLossWarnings.clear();
@@ -739,6 +770,7 @@ export class PiSessionService {
739
770
  this.subsessionLinks.clear();
740
771
  this.subsessionHydratedParents.clear();
741
772
  this.subsessionNotifyArmed.clear();
773
+ this.subsessionInboxes.clear();
742
774
  this.notificationStore.clearAll("service-dispose");
743
775
  await Promise.all(activeSessions.map(async (active) => {
744
776
  active.unsubscribe();
@@ -751,6 +783,7 @@ export class PiSessionService {
751
783
  await active.runtime.dispose();
752
784
  }
753
785
  })).finally(async () => {
786
+ this.extensionCommandExecution.disable();
754
787
  this.events.mediaIndex.clear();
755
788
  await this.activityMarker.dispose();
756
789
  });
@@ -764,7 +797,29 @@ export class PiSessionService {
764
797
  for (const record of archivedForCwd) {
765
798
  this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
766
799
  }
767
- const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
800
+ const unarchivedById = new Map(sessions.filter((session) => !archivedById.has(session.id))
801
+ .map((session) => [session.id, clientSessionFromListEntry(session)]));
802
+ // Pi can fork before the first conversational entry without creating a file.
803
+ // Enumerate already-hosted runtimes only; listing must not open cold sessions.
804
+ for (const active of new Set(this.active.values())) {
805
+ const session = active.runtime.session;
806
+ if (!cwdPathsEqual(session.sessionManager.getCwd(), cwd) || archivedById.has(session.sessionId))
807
+ continue;
808
+ const listed = unarchivedById.get(session.sessionId);
809
+ const tracked = this.subsessionLinkForActiveChild(session) !== undefined;
810
+ if (listed !== undefined) {
811
+ // Match the file too: a clone with the same id must not inherit a
812
+ // live runtime's classification. Disk rows otherwise use the scanner.
813
+ if (tracked && session.sessionFile !== undefined && sessionPathsEqual(listed.path, session.sessionFile))
814
+ listed.agentSubsession = true;
815
+ continue;
816
+ }
817
+ const info = clientSessionFromActiveSession(session);
818
+ if (tracked)
819
+ info.agentSubsession = true;
820
+ unarchivedById.set(session.sessionId, info);
821
+ }
822
+ const unarchivedSessions = [...unarchivedById.values()].sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
768
823
  const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
769
824
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
770
825
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
@@ -812,29 +867,37 @@ export class PiSessionService {
812
867
  }
813
868
  }
814
869
  async startSession(cwd, options) {
815
- const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
870
+ const metadata = options.metadata === undefined ? undefined : requireSessionUiMetadata(options.metadata);
871
+ const name = options.name === undefined ? undefined : requireInitialSessionName(options.name);
872
+ const manager = this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession });
873
+ if (metadata !== undefined) {
874
+ if (manager.appendCustomEntry === undefined)
875
+ throw new Error("Session manager does not support UI metadata initialization");
876
+ manager.appendCustomEntry(SESSION_UI_METADATA_CUSTOM_TYPE, { version: 1, metadata });
877
+ }
878
+ if (name !== undefined) {
879
+ if (manager.appendSessionInfo === undefined)
880
+ throw new Error("Session manager does not support initial naming");
881
+ manager.appendSessionInfo(name);
882
+ }
883
+ const active = await this.create(manager, cwd, {
816
884
  startupIntent: "create",
817
885
  ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
818
886
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
819
887
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
820
888
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
821
889
  });
822
- return this.announceCreatedSession(active, cwd, options.parentSession);
890
+ return options.deferAnnouncement === true
891
+ ? clientSessionFromActiveSession(active.runtime.session)
892
+ : this.announceCreatedSession(active, cwd, options.parentSession);
823
893
  }
824
- announceCreatedSession(active, cwd, parentSession) {
894
+ announceCreatedSession(active, cwd, parentSession, agentSubsession) {
825
895
  const { session } = active.runtime;
826
896
  const created = {
827
- id: session.sessionId,
828
- path: session.sessionFile ?? "",
897
+ ...clientSessionFromActiveSession(session),
829
898
  cwd,
830
- persisted: sessionFileExists(session.sessionFile),
831
- created: new Date().toISOString(),
832
- modified: new Date().toISOString(),
833
- messageCount: session.messages.length,
834
- firstMessage: "",
835
- // Include the parent so listeners can nest the new session in the tree
836
- // immediately, instead of showing it flat until the next reload.
837
899
  ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
900
+ ...(agentSubsession === true ? { agentSubsession: true } : {}),
838
901
  };
839
902
  // Broadcast so other clients (and the spawning agent's UI) can add the new
840
903
  // session to their list without a manual reload.
@@ -858,6 +921,8 @@ export class PiSessionService {
858
921
  ? input.model
859
922
  : await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
860
923
  const created = await this.start(decision.cwd, {
924
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
925
+ ...(input.name === undefined ? {} : { name: input.name }),
861
926
  ...(model === undefined ? {} : { initialModel: model }),
862
927
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
863
928
  });
@@ -899,6 +964,7 @@ export class PiSessionService {
899
964
  ...(model === undefined ? {} : { initialModel: model }),
900
965
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
901
966
  creationProvenance: "tracked-subsession",
967
+ deferAnnouncement: true,
902
968
  });
903
969
  const modelUsed = this.active.get(created.id)?.runtime.session.model;
904
970
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
@@ -912,6 +978,13 @@ export class PiSessionService {
912
978
  await this.registerVerifiedSubsession(link);
913
979
  this.persistSubsessionLink(link);
914
980
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
981
+ const active = this.active.get(created.id);
982
+ if (active === undefined)
983
+ throw new Error("Tracked subsession disappeared during creation");
984
+ // Freshly created children are known by this exact runtime/file link even
985
+ // before Pi flushes its buffered custom entries. Reopened sessions only
986
+ // enter this registry after reciprocal persisted provenance validation.
987
+ this.announceCreatedSession(active, decision.cwd, input.parentSessionFile, this.subsessionLinkForActiveChild(active.runtime.session) !== undefined);
915
988
  await this.prompt(created, input.prompt);
916
989
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
917
990
  return {
@@ -1052,83 +1125,30 @@ export class PiSessionService {
1052
1125
  throw unknownSpawnModelError(modelSpec);
1053
1126
  return model;
1054
1127
  }
1055
- /**
1056
- * Register the question set an agent wants the user to answer as the session's
1057
- * open ask. Deliberately does not wait for the user: `ask_user` terminates the
1058
- * run and the submitted answers come back later as a follow-up message.
1059
- *
1060
- * Rejected question sets throw {@link PendingAskValidationError}, which the
1061
- * agent loop reports to the model as an error tool result.
1062
- */
1063
- // eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
1064
- async openAsk(input) {
1065
- const result = this.pendingAskStore.open(input);
1066
- // A supersede closes the earlier ask, so the browsers watching it must hear
1067
- // that before they hear about its replacement.
1068
- if (result.superseded !== undefined)
1069
- this.publishAskClosed(input.sessionId, result.superseded);
1070
- this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
1071
- this.publishStatusForSessionId(input.sessionId);
1072
- return result;
1073
- }
1074
- /**
1075
- * Record the user's answers to the session's open ask and hand them to the
1076
- * model. The answers travel as a system-authored custom message rather than a
1077
- * user message, so they are not attributed to the human in the transcript;
1078
- * they still wake an idle session (`triggerTurn`) and queue behind in-flight
1079
- * work (`deliverAs: "followUp"`), which is how the run that `ask_user`
1080
- * terminated continues.
1081
- */
1128
+ /** Reply to a durable question entry, even after later conversation or replies. */
1082
1129
  async submitAsk(ref, askId, submission) {
1083
1130
  await this.assertWritable(ref);
1084
1131
  const session = await this.getOrOpen(ref);
1085
- // Checked before the store closes the ask so a refused delivery cannot
1086
- // discard answers the user already submitted.
1087
1132
  this.assertTreeNavigationInactive(session, "answer questions");
1088
- return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
1133
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1134
+ const outcome = createAskUserOutcome(ask, submission, this.now().toISOString());
1135
+ return this.deliverAskOutcome(session, outcome);
1089
1136
  }
1090
- /**
1091
- * Close the open ask without answers. The model is still told, naming every
1092
- * question as unanswered: it was promised a follow-up message and would
1093
- * otherwise wait for one that never comes.
1094
- */
1137
+ /** Skip is also a transcript reply; it does not invalidate future answers. */
1095
1138
  async cancelAsk(ref, askId) {
1096
1139
  await this.assertWritable(ref);
1097
1140
  const session = await this.getOrOpen(ref);
1098
- this.assertTreeNavigationInactive(session, "dismiss questions");
1099
- return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
1100
- }
1101
- /**
1102
- * Publish and deliver a closed ask. A stale close is reported rather than
1103
- * thrown: losing the race against a supersede, another browser, or a session
1104
- * that went away is ordinary, and the returned status tells the browser what
1105
- * the session's open ask is now.
1106
- */
1107
- async closeAsk(session, result) {
1108
- if (result.status === "stale")
1109
- return { result: "stale", sessionStatus: this.statusFromSession(session) };
1110
- const { outcome } = result;
1111
- this.publishAskClosed(session.sessionId, outcome);
1141
+ this.assertTreeNavigationInactive(session, "skip questions");
1142
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1143
+ return this.deliverAskOutcome(session, createAskUserOutcome(ask, undefined, this.now().toISOString()));
1144
+ }
1145
+ async deliverAskOutcome(session, outcome) {
1146
+ // Pi persists/displays the structured message and wakes an idle agent, or
1147
+ // queues it behind in-flight work. Ordinary entry-mutation protection still
1148
+ // applies, but there is no answer-specific stale/duplicate gate.
1112
1149
  await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
1113
1150
  this.publishStatus(session);
1114
- return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
1115
- }
1116
- publishAskClosed(sessionId, outcome) {
1117
- this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
1118
- }
1119
- /**
1120
- * Void the session's open ask because the user sent a chat message instead of
1121
- * answering it. Every browser closes the card as cancelled, and the model is
1122
- * told — without being woken — so the notice rides into the turn the message
1123
- * itself triggers rather than becoming a turn of its own.
1124
- */
1125
- async voidOpenAskForUserMessage(session) {
1126
- const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
1127
- if (outcome === undefined)
1128
- return;
1129
- this.publishAskClosed(session.sessionId, outcome);
1130
- await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
1131
- this.publishStatus(session);
1151
+ return this.statusFromSession(session);
1132
1152
  }
1133
1153
  /**
1134
1154
  * Record the user's answer to an open extension dialog and resolve the
@@ -1277,27 +1297,120 @@ export class PiSessionService {
1277
1297
  const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
1278
1298
  return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
1279
1299
  }
1300
+ /** Collect ready answers before deciding whether the parent needs to wait. */
1301
+ async joinSubsessions(parentSessionId, parentSessionFile, toolCallId) {
1302
+ const subsessions = await this.listSubsessions(parentSessionId, parentSessionFile);
1303
+ this.reconcileSubsessionCompletions(parentSessionId, parentSessionFile);
1304
+ const completions = this.subsessionInbox(parentSessionId, parentSessionFile).pending();
1305
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, completions, toolCallId);
1306
+ return { subsessions, completions };
1307
+ }
1280
1308
  /** Status and final result of a subsession, scoped to the caller's children. */
1281
- async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
1309
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId) {
1282
1310
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1311
+ // Finalize the same completion even if its event/heartbeat notification has
1312
+ // not run yet. Inspection must not leave a future notice armed for this run.
1313
+ this.reconcileSubsessionCompletion(session);
1314
+ const status = this.subsessionStatus(session);
1283
1315
  const messages = historyMessages(session);
1316
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1317
+ if (status !== "working" && completion !== undefined)
1318
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1284
1319
  return {
1285
1320
  sessionId,
1286
1321
  cwd: session.sessionManager.getCwd(),
1287
- status: this.subsessionStatus(session),
1322
+ status,
1288
1323
  finalText: finalAssistantText(messages),
1289
1324
  messageCount: messages.length,
1325
+ ...(status === "working" || completion?.errorDetail === undefined ? {} : { errorDetail: completion.errorDetail }),
1290
1326
  };
1291
1327
  }
1292
- /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
1293
- async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
1328
+ /** Filtered transcript reads acknowledge only the final answer actually returned. */
1329
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId) {
1294
1330
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1295
- const view = buildTranscriptView(historyMessages(session), query);
1331
+ this.reconcileSubsessionCompletion(session);
1332
+ const messages = historyMessages(session);
1333
+ const view = buildTranscriptView(messages, query);
1334
+ const status = this.subsessionStatus(session);
1335
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1336
+ const errorDetail = status === "working" ? undefined : completion?.errorDetail;
1337
+ if (status !== "working" && completion !== undefined
1338
+ && (transcriptReturnsFinalOutput(messages, view, completion.finalText) || (completion.finalText === "" && errorDetail !== undefined))) {
1339
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1340
+ }
1341
+ return { sessionId, cwd: session.sessionManager.getCwd(), status, ...view, ...(errorDetail === undefined ? {} : { errorDetail }) };
1342
+ }
1343
+ subsessionInbox(parentSessionId, parentSessionFile) {
1344
+ const file = nonEmptyString(parentSessionFile) ?? nonEmptyString(this.active.get(parentSessionId)?.runtime.session.sessionFile);
1345
+ const key = subsessionHydratedParentKey(parentSessionId, file);
1346
+ let inbox = this.subsessionInboxes.get(key);
1347
+ if (inbox === undefined) {
1348
+ inbox = new SubsessionCompletionInbox();
1349
+ this.subsessionInboxes.set(key, inbox);
1350
+ }
1351
+ return inbox;
1352
+ }
1353
+ restoreSubsessionReceipts(session, releaseClaims = true) {
1354
+ this.subsessionInbox(session.sessionId, session.sessionFile).restoreReceipts(session.sessionManager.getBranch(), releaseClaims);
1355
+ }
1356
+ subsessionCompletionForOutput(parentId, parentFile, child, messages) {
1357
+ const branch = persistedSubsessionCompletions(child.sessionManager.getBranch()).filter(({ sessionId }) => sessionId === child.sessionId);
1358
+ // A child can be rewound independently of its parent. Never acknowledge an
1359
+ // abandoned branch's newer result while returning the current branch's text.
1360
+ const completion = branch.at(-1) ?? (child.sessionManager.appendCustomEntry === undefined
1361
+ ? this.subsessionInbox(parentId, parentFile).latest(child.sessionId) : undefined);
1362
+ return completion?.finalText === finalAssistantText(messages) ? completion : undefined;
1363
+ }
1364
+ receiveSubsessionCompletions(parentId, parentFile, completions, toolCallId) {
1365
+ if (completions.length === 0)
1366
+ return;
1367
+ const parent = this.active.get(parentId)?.runtime.session;
1368
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile))) {
1369
+ throw new Error(`Parent session ${parentId} is not available to receive subsession results`);
1370
+ }
1371
+ const inbox = this.subsessionInbox(parentId, parentFile);
1372
+ const completionIds = completions.map(({ completionId }) => completionId).filter((id) => !inbox.hasReceived(id));
1373
+ if (completionIds.length === 0)
1374
+ return;
1375
+ // Persist before removing unread results. A storage failure leaves the
1376
+ // answer unread and fails the tool instead of losing the notification.
1377
+ this.assertTreeNavigationInactive(parent, "receive subsession results");
1378
+ parent.sessionManager.appendCustomEntry?.(SUBSESSION_RECEIPT_ENTRY_TYPE, { completionIds, ...(toolCallId === undefined ? {} : { toolCallId }) });
1379
+ inbox.receive(completionIds);
1380
+ }
1381
+ reconcileSubsessionCompletions(parentId, parentFile) {
1382
+ for (const childId of this.subsessionChildren.get(parentId) ?? []) {
1383
+ if (!this.subsessionLinkBelongsToParent(parentId, nonEmptyString(parentFile), childId))
1384
+ continue;
1385
+ const link = this.subsessionLinks.get(childId);
1386
+ const child = link === undefined ? undefined : this.activeChildForSubsessionLink(link)?.runtime.session;
1387
+ if (child !== undefined)
1388
+ this.reconcileSubsessionCompletion(child);
1389
+ }
1390
+ }
1391
+ reconcileSubsessionCompletion(session) {
1392
+ this.updateSubsessionTracking(session);
1393
+ if (!this.hasActiveWork(session) && this.subsessionNotifyArmed.get(session.sessionId) === true) {
1394
+ throw new Error(`Could not record completion of subsession ${session.sessionId}; its result remains unread. Retry after fixing session storage.`);
1395
+ }
1396
+ }
1397
+ /** Claims commit on entry_appended (boundary drafts) or message_end (idle wakes). */
1398
+ async prepareSubsessionDelivery(parentId, parentFile) {
1399
+ await this.hydrateSubsessionsForParent(parentId, nonEmptyString(parentFile));
1400
+ const parent = this.active.get(parentId)?.runtime.session;
1401
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile)))
1402
+ return undefined;
1403
+ // Tool receipts are intents until the successful (possibly nested) result
1404
+ // is persisted. Reconcile those intents without releasing in-flight drafts.
1405
+ this.restoreSubsessionReceipts(parent, false);
1406
+ this.reconcileSubsessionCompletions(parentId, parentFile);
1407
+ const completions = this.subsessionInbox(parentId, parentFile).claim();
1408
+ if (completions.length === 0)
1409
+ return undefined;
1296
1410
  return {
1297
- sessionId,
1298
- cwd: session.sessionManager.getCwd(),
1299
- status: this.subsessionStatus(session),
1300
- ...view,
1411
+ type: "custom_message", customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1412
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(parentId)),
1413
+ details: { sessionId: completions[0]?.sessionId, completionIds: completions.map(({ completionId }) => completionId) },
1301
1414
  };
1302
1415
  }
1303
1416
  /** Open a session after verifying it is one of the caller's tracked children. */
@@ -1351,6 +1464,14 @@ export class PiSessionService {
1351
1464
  this.subsessionLinks.set(childSessionId, link);
1352
1465
  if (!this.subsessionNotifyArmed.has(childSessionId))
1353
1466
  this.subsessionNotifyArmed.set(childSessionId, false);
1467
+ const childManager = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager
1468
+ ?? (link.childSessionFile === undefined ? undefined : this.sessionManager.open(link.childSessionFile));
1469
+ if (childManager !== undefined) {
1470
+ for (const completion of persistedSubsessionCompletions(childManager.getEntries?.() ?? childManager.getBranch())) {
1471
+ if (completion.sessionId === childSessionId)
1472
+ this.subsessionInbox(parentSessionId, link.parentSessionFile).record(completion);
1473
+ }
1474
+ }
1354
1475
  const cwd = this.cwdForVerifiedSubsession(link);
1355
1476
  await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
1356
1477
  }
@@ -1375,6 +1496,8 @@ export class PiSessionService {
1375
1496
  this.subsessionParents.delete(childSessionId);
1376
1497
  this.subsessionLinks.delete(childSessionId);
1377
1498
  this.subsessionNotifyArmed.delete(childSessionId);
1499
+ for (const inbox of this.subsessionInboxes.values())
1500
+ inbox.forgetChild(childSessionId);
1378
1501
  if (parentSessionId === undefined)
1379
1502
  return;
1380
1503
  const children = this.subsessionChildren.get(parentSessionId);
@@ -1531,7 +1654,7 @@ export class PiSessionService {
1531
1654
  * working. Prompt failures before agent_start explicitly arm the same latch.
1532
1655
  * Standalone commands and compactions must not arm a completion notice.
1533
1656
  */
1534
- updateSubsessionTracking(session, agentStarted = false) {
1657
+ updateSubsessionTracking(session, agentStarted = false, agentSettled = false) {
1535
1658
  const link = this.subsessionLinkForActiveChild(session);
1536
1659
  if (link === undefined)
1537
1660
  return;
@@ -1540,22 +1663,31 @@ export class PiSessionService {
1540
1663
  this.subsessionNotifyArmed.set(childId, true);
1541
1664
  return;
1542
1665
  }
1543
- if (this.hasActiveWork(session))
1666
+ // Native settlement is authoritative even while the host prompt Promise's
1667
+ // mutation receipt is unwinding. Capture now, before another run can start.
1668
+ if (!agentSettled && this.hasActiveWork(session))
1544
1669
  return;
1545
1670
  if (this.subsessionNotifyArmed.get(childId) !== true)
1546
1671
  return;
1547
- this.subsessionNotifyArmed.set(childId, false);
1548
1672
  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` : "";
1551
- const finalText = finalAssistantText(historyMessages(session));
1552
- const outputSection = formatSubsessionNotificationOutput(childId, finalText);
1553
- const workingIds = this.workingSubsessionIds(link.parentSessionId);
1554
- const next = workingIds.length === 0
1555
- ? "No other tracked subsessions are working."
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.`;
1557
- const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${errorSection}${outputSection}`;
1558
- void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
1673
+ const messages = historyMessages(session);
1674
+ const completion = {
1675
+ completionId: randomUUID(), sessionId: childId, cwd: session.sessionManager.getCwd(),
1676
+ status: activity?.phase === "error" ? "error" : "idle",
1677
+ finalText: finalAssistantText(messages), messageCount: messages.length,
1678
+ ...(activity?.phase !== "error" || activity.detail === undefined ? {} : { errorDetail: activity.detail }),
1679
+ };
1680
+ try {
1681
+ session.sessionManager.appendCustomEntry?.(SUBSESSION_COMPLETION_ENTRY_TYPE, completion);
1682
+ }
1683
+ catch (error) {
1684
+ // Keep the latch armed so a later reconciliation can retry persistence.
1685
+ this.logSubsessionNotificationFailure(link.parentSessionId, childId, error);
1686
+ return;
1687
+ }
1688
+ this.subsessionNotifyArmed.set(childId, false);
1689
+ this.subsessionInbox(link.parentSessionId, link.parentSessionFile).record(completion);
1690
+ void this.wakeParentForSubsession(link.parentSessionId, childId);
1559
1691
  }
1560
1692
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
1561
1693
  const link = this.subsessionLinks.get(childSessionId);
@@ -1573,35 +1705,69 @@ export class PiSessionService {
1573
1705
  const sessionManager = this.sessionManager.open(parentSessionFile);
1574
1706
  return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
1575
1707
  }
1576
- /**
1577
- * Deliver a subsession-completion notice to the parent as a system-authored
1578
- * custom message rather than a user message, so it is not attributed to the
1579
- * human in the transcript. It still wakes an idle parent (`triggerTurn`) and
1580
- * queues behind in-flight work (`deliverAs: "followUp"`), preserving the
1581
- * established "queue if busy, send and act if idle" behavior.
1582
- */
1583
- async notifyParentOfSubsession(parentId, childId, text) {
1708
+ async wakeParentForSubsession(parentId, childId) {
1584
1709
  try {
1585
1710
  const session = await this.getOrOpenParentForSubsession(parentId, childId);
1586
- if (this.treeNavigations.has(session)) {
1587
- const pending = this.deferredSubsessionNotifications.get(session) ?? [];
1588
- pending.push({ parentId, childId, text });
1589
- this.deferredSubsessionNotifications.set(session, pending);
1590
- return;
1591
- }
1592
- await this.deliverSubsessionNotification(session, { parentId, childId, text });
1711
+ this.scheduleSubsessionDelivery(session);
1593
1712
  }
1594
1713
  catch (error) {
1595
1714
  this.logSubsessionNotificationFailure(parentId, childId, error);
1596
1715
  }
1597
1716
  }
1598
- async deliverSubsessionNotification(session, notification) {
1599
- await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } }, { triggerTurn: true, deliverAs: "followUp" }));
1600
- this.publishStatus(session);
1717
+ /** Busy parents keep results in the inbox; never enqueue a second copy in Pi. */
1718
+ async deliverPendingSubsessionCompletions(session) {
1719
+ if (this.hasActiveWork(session) || !this.isCurrentActiveSession(session) || this.subsessionStoppedParents.has(session))
1720
+ return;
1721
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
1722
+ const completions = inbox.claim();
1723
+ if (completions.length === 0)
1724
+ return;
1725
+ const completionIds = completions.map(({ completionId }) => completionId);
1726
+ this.subsessionDeliveringParents.add(session);
1727
+ let delivered = false;
1728
+ try {
1729
+ await this.runSessionEntryMutation(session, "deliver subsession results", () => session.sendCustomMessage({
1730
+ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1731
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(session.sessionId)),
1732
+ details: { sessionId: completions[0]?.sessionId, completionIds },
1733
+ }, { triggerTurn: true }));
1734
+ inbox.receive(completionIds);
1735
+ delivered = true;
1736
+ }
1737
+ catch (error) {
1738
+ this.subsessionStoppedParents.add(session);
1739
+ throw error;
1740
+ }
1741
+ finally {
1742
+ inbox.release(completionIds);
1743
+ this.subsessionDeliveringParents.delete(session);
1744
+ if (delivered)
1745
+ this.scheduleSubsessionDelivery(session);
1746
+ this.publishStatus(session);
1747
+ }
1748
+ }
1749
+ scheduleSubsessionDelivery(session) {
1750
+ if (this.subsessionDeliveryScheduled.has(session) || this.subsessionInbox(session.sessionId, session.sessionFile).pending().length === 0)
1751
+ return;
1752
+ this.subsessionDeliveryScheduled.add(session);
1753
+ // Settlement can precede the host prompt-mutation receipt's finally block.
1754
+ // Recheck on the next tick, closing the yield/child-completion race without
1755
+ // restarting a stopped run or putting rendered answers in a follow-up queue.
1756
+ setTimeout(() => {
1757
+ this.subsessionDeliveryScheduled.delete(session);
1758
+ void this.deliverPendingSubsessionCompletions(session).catch((error) => {
1759
+ this.logSubsessionNotificationFailure(session.sessionId, "inbox", error);
1760
+ });
1761
+ }, 0);
1601
1762
  }
1602
1763
  logSubsessionNotificationFailure(parentId, childId, error) {
1603
1764
  this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1604
1765
  }
1766
+ browserTranscriptMessage(session, message) {
1767
+ return projectTranscriptMarkdown(message, session.extensionRunner.getMarkdownTransformers(), (error, transformerIndex) => {
1768
+ this.logger.info({ err: error, sessionId: session.sessionId, transformerIndex }, "Transcript Markdown transformer failed");
1769
+ });
1770
+ }
1605
1771
  async transcriptSnapshot(ref, page) {
1606
1772
  const session = await this.getOrOpen(ref);
1607
1773
  const seqBeforeRead = this.events.currentSeq(session.sessionId);
@@ -1616,8 +1782,9 @@ export class PiSessionService {
1616
1782
  const userCount = messages.filter((message) => isRecord(message) && message["role"] === "user").length;
1617
1783
  const echoes = (this.pendingPromptEchoes.get(session) ?? []).filter((echo) => echo.userIndex >= userCount);
1618
1784
  messages.push(...echoes.map((echo) => echo.message));
1785
+ const messagePage = pageMessagesAtSafeBoundary(messages, page);
1619
1786
  return {
1620
- page: pageMessagesAtSafeBoundary(messages, page),
1787
+ page: { ...messagePage, messages: messagePage.messages.map((message) => this.browserTranscriptMessage(session, message)) },
1621
1788
  status: this.statusFromSession(session, transcriptMessageCount(branch) + echoes.length),
1622
1789
  seq: this.events.currentSeq(session.sessionId),
1623
1790
  partial: this.publishedAssistantPartials.get(session) ?? null,
@@ -1625,7 +1792,8 @@ export class PiSessionService {
1625
1792
  }
1626
1793
  async messages(ref, page) {
1627
1794
  const session = await this.getOrOpen(ref);
1628
- return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1795
+ const result = pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1796
+ return { ...result, messages: result.messages.map((message) => this.browserTranscriptMessage(session, message)) };
1629
1797
  }
1630
1798
  async media(ref, mediaId) {
1631
1799
  if (!isSessionMediaId(mediaId))
@@ -1899,11 +2067,6 @@ export class PiSessionService {
1899
2067
  this.publishStatus(session);
1900
2068
  return;
1901
2069
  }
1902
- // A chat message answers the session's open ask in the user's own words, so
1903
- // the form is void: keeping it open would invite answers to questions the
1904
- // conversation has already moved past. Ignored duplicates skip this on
1905
- // purpose: they must not void an ask posted after the queued original.
1906
- await this.voidOpenAskForUserMessage(session);
1907
2070
  if (session.isCompacting) {
1908
2071
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1909
2072
  return;
@@ -1924,7 +2087,7 @@ export class PiSessionService {
1924
2087
  // SDK input hooks may await before appending the user message. Keep the
1925
2088
  // already-published echo visible in snapshots until that append occurs.
1926
2089
  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() });
2090
+ this.events.publish(session.sessionId, { type: "message.append", message: this.browserTranscriptMessage(session, echo.message) }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
1928
2091
  }
1929
2092
  const promptOptions = buildPromptOptions(behavior, images);
1930
2093
  const commandName = text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
@@ -1936,7 +2099,12 @@ export class PiSessionService {
1936
2099
  if (getString(event, "type") === "agent_start")
1937
2100
  agentStarted = true;
1938
2101
  });
1939
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).finally(() => {
2102
+ const submission = { session, active: true };
2103
+ const isExtensionCommand = session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
2104
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => isExtensionCommand
2105
+ ? this.extensionCommandExecution.run({ submission }, () => session.prompt(text, promptOptions))
2106
+ : session.prompt(text, promptOptions)).finally(() => {
2107
+ submission.active = false;
1940
2108
  unsubscribe?.();
1941
2109
  if (echo !== undefined) {
1942
2110
  const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
@@ -1947,6 +2115,7 @@ export class PiSessionService {
1947
2115
  }
1948
2116
  });
1949
2117
  void promptPromise.catch((error) => {
2118
+ this.subsessionStoppedParents.add(session);
1950
2119
  const message = error instanceof Error ? error.message : String(error);
1951
2120
  this.publishActivity(session, "error", "error", message);
1952
2121
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -2089,15 +2258,20 @@ export class PiSessionService {
2089
2258
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
2090
2259
  }
2091
2260
  async navigateTree(ref, request) {
2092
- if (request.targetId.trim() === "")
2261
+ return this.navigateSessionTree(ref, request.targetId, request.expectedLeafId, sessionTreeNavigationOptions(request), undefined, request.retainCheckpoint);
2262
+ }
2263
+ async navigateSessionTree(ref, targetId, expectedLeafId, options, expectedSession, retainCheckpoint = false) {
2264
+ if (targetId.trim() === "")
2093
2265
  throw new Error("Session tree target is required");
2094
2266
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
2095
2267
  throw new Error("Stop current session activity before navigating the session tree");
2096
2268
  }
2097
2269
  await this.assertWritable(ref);
2098
- const options = sessionTreeNavigationOptions(request);
2099
2270
  const session = await this.getOrOpen(ref);
2100
- if (typeof session.navigateTree !== "function")
2271
+ if (expectedSession !== undefined && session !== expectedSession)
2272
+ throw new Error("This extension command belongs to an unavailable session runtime");
2273
+ const navigateTree = session.navigateTree?.bind(session);
2274
+ if (navigateTree === undefined)
2101
2275
  throw new Error("Session tree navigation is not supported by this Pi runtime");
2102
2276
  if (this.hasActiveWork(session))
2103
2277
  throw new Error("Stop current session activity before navigating the session tree");
@@ -2106,13 +2280,22 @@ export class PiSessionService {
2106
2280
  this.treeNavigations.add(session);
2107
2281
  try {
2108
2282
  const oldLeafId = session.sessionManager.getLeafId();
2109
- if (oldLeafId !== request.expectedLeafId) {
2283
+ if (oldLeafId !== expectedLeafId) {
2110
2284
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
2111
2285
  }
2112
- const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
2113
- this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
2286
+ if (retainCheckpoint)
2287
+ targetId = messageHistoryTargetId(session.sessionManager.getBranch(), targetId);
2288
+ const checkpointTail = retainCheckpoint ? session.sessionManager.getBranch().find((entry) => isRecord(entry) && entry["id"] === targetId) : undefined;
2289
+ const retainCustomMessage = isRecord(checkpointTail) && checkpointTail["type"] === "custom_message";
2290
+ if (retainCustomMessage && options.summarize === true) {
2291
+ throw new Error("Retaining a custom-message checkpoint requires navigation without a branch summary");
2292
+ }
2293
+ const activeEditableTargetParentId = retainCustomMessage ? undefined : activeEditableTreeTargetParentId(session.sessionManager, targetId, oldLeafId);
2294
+ this.publishActivity(session, options.summarize === true ? "summarizing branch" : "navigating session tree", "active");
2114
2295
  this.publishStatus(session);
2115
- let result = await session.navigateTree(request.targetId, options);
2296
+ let result = retainCustomMessage
2297
+ ? await navigateTreeRetainingCustomMessage(session.sessionManager, targetId, checkpointTail["parentId"], () => navigateTree(targetId, options))
2298
+ : await navigateTree(targetId, options);
2116
2299
  if (activeEditableTargetParentId !== undefined
2117
2300
  && !result.cancelled
2118
2301
  && result.editorText === undefined
@@ -2124,14 +2307,14 @@ export class PiSessionService {
2124
2307
  // extraction, agent-context rebuilding, and tree extension events.
2125
2308
  setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
2126
2309
  try {
2127
- result = await session.navigateTree(request.targetId, options);
2310
+ result = await navigateTree(targetId, options);
2128
2311
  }
2129
2312
  catch (error) {
2130
- session.sessionManager.branch(request.targetId);
2313
+ session.sessionManager.branch(targetId);
2131
2314
  throw error;
2132
2315
  }
2133
2316
  if (result.cancelled)
2134
- session.sessionManager.branch(request.targetId);
2317
+ session.sessionManager.branch(targetId);
2135
2318
  }
2136
2319
  if (result.cancelled) {
2137
2320
  if (this.isCurrentActiveSession(session)) {
@@ -2139,13 +2322,6 @@ export class PiSessionService {
2139
2322
  }
2140
2323
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
2141
2324
  }
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
- }
2149
2325
  if (result.summaryEntry !== undefined) {
2150
2326
  // A summary entry durably identifies the selected branch as the file's
2151
2327
  // newest leaf, superseding any earlier bare selection.
@@ -2159,6 +2335,8 @@ export class PiSessionService {
2159
2335
  this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
2160
2336
  }
2161
2337
  }
2338
+ this.setSuggestedInput(session.sessionId, result.editorText);
2339
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2162
2340
  if (this.isCurrentActiveSession(session))
2163
2341
  this.publishActivity(session, "session tree navigated", "idle");
2164
2342
  return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
@@ -2179,7 +2357,6 @@ export class PiSessionService {
2179
2357
  }
2180
2358
  else {
2181
2359
  this.deferredGeneratedSessionNames.delete(session);
2182
- this.deferredSubsessionNotifications.delete(session);
2183
2360
  }
2184
2361
  }
2185
2362
  }
@@ -2190,6 +2367,9 @@ export class PiSessionService {
2190
2367
  * about to join rather than the forked-from record.
2191
2368
  */
2192
2369
  async forkFromTree(ref, request) {
2370
+ return this.forkSessionTree(ref, request);
2371
+ }
2372
+ async forkSessionTree(ref, request, options, expectedSession) {
2193
2373
  if (request.entryId.trim() === "")
2194
2374
  throw new Error("Session tree entry is required");
2195
2375
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
@@ -2197,6 +2377,8 @@ export class PiSessionService {
2197
2377
  }
2198
2378
  await this.assertWritable(ref);
2199
2379
  const session = await this.getOrOpen(ref);
2380
+ if (expectedSession !== undefined && session !== expectedSession)
2381
+ throw new Error("This extension command belongs to an unavailable session runtime");
2200
2382
  if (this.hasActiveWork(session))
2201
2383
  throw new Error("Stop current session activity before forking the session tree");
2202
2384
  if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
@@ -2205,8 +2387,15 @@ export class PiSessionService {
2205
2387
  this.publishActivity(session, "forking session from entry", "active");
2206
2388
  this.publishStatus(session);
2207
2389
  try {
2208
- const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
2390
+ // forkEntry rechecks this same leaf after asynchronous naming and inside
2391
+ // the replacement gate, so the resolved branch cutoff cannot go stale.
2392
+ const entryId = request.retainCheckpoint === true
2393
+ ? messageHistoryTargetId(session.sessionManager.getBranch(), request.entryId)
2394
+ : request.entryId;
2395
+ const result = await this.commandService.forkEntry(session.sessionId, entryId, {
2396
+ ...options,
2209
2397
  expectedLeafId: request.expectedLeafId,
2398
+ expectedSession: session,
2210
2399
  });
2211
2400
  if (result.type === "unsupported")
2212
2401
  throw new Error(result.message);
@@ -2219,6 +2408,8 @@ export class PiSessionService {
2219
2408
  }
2220
2409
  return { cancelled: true };
2221
2410
  }
2411
+ this.setSuggestedInput(result.session.id, result.promptDraft);
2412
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2222
2413
  const forkedSession = this.active.get(result.session.id)?.runtime.session;
2223
2414
  if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
2224
2415
  this.publishActivity(forkedSession, "session forked", "idle");
@@ -2267,9 +2458,16 @@ export class PiSessionService {
2267
2458
  this.publishActivity(session, "reloading resources", "active");
2268
2459
  const priorGeneration = this.notificationGenerationBySession.get(session);
2269
2460
  let candidateGeneration;
2461
+ const reloadBoundary = { beforeSessionStart: false };
2270
2462
  try {
2271
- await session.reload(priorGeneration === undefined ? undefined : {
2463
+ await session.reload({
2272
2464
  beforeSessionStart: () => {
2465
+ // The SDK invalidated the old runner. Settle its dialogs before
2466
+ // replacement session_start handlers can open new ones.
2467
+ this.endSessionExtensionDialogs(session.sessionId);
2468
+ reloadBoundary.beforeSessionStart = true;
2469
+ if (priorGeneration === undefined)
2470
+ return;
2273
2471
  candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2274
2472
  this.notificationGenerationBySession.set(session, candidateGeneration);
2275
2473
  this.replaceSessionNotificationContext(session, candidateGeneration);
@@ -2282,6 +2480,10 @@ export class PiSessionService {
2282
2480
  this.publishStatus(session);
2283
2481
  }
2284
2482
  catch (error) {
2483
+ // Settings/resource loading can fail after SDK invalidation but
2484
+ // before the hook. Never sweep replacement session_start dialogs.
2485
+ if (!reloadBoundary.beforeSessionStart)
2486
+ this.endSessionExtensionDialogs(session.sessionId);
2285
2487
  if (candidateGeneration !== undefined) {
2286
2488
  this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2287
2489
  this.notificationGenerationBySession.set(session, candidateGeneration);
@@ -2390,7 +2592,7 @@ export class PiSessionService {
2390
2592
  async archiveTree(ref) {
2391
2593
  const session = await this.getOrOpen(ref);
2392
2594
  const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
2393
- const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2595
+ const root = findArchiveCandidateByExactId(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2394
2596
  const plan = planSessionArchiveTree(root, catalog);
2395
2597
  const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
2396
2598
  if (busy !== undefined)
@@ -2536,6 +2738,7 @@ export class PiSessionService {
2536
2738
  return;
2537
2739
  const sessionId = active.runtime.session.sessionId;
2538
2740
  this.clearCompactionPromptQueue(sessionId);
2741
+ this.subsessionStoppedParents.add(active.runtime.session);
2539
2742
  clearSessionQueue(active.runtime.session);
2540
2743
  // Settle run-scoped dialogs now, at abort-request time: pi's agent loop
2541
2744
  // waits for a parked `tool_call` dialog handler before it can emit
@@ -2706,6 +2909,7 @@ export class PiSessionService {
2706
2909
  if (pendingOpens.length > 0)
2707
2910
  await Promise.allSettled(pendingOpens);
2708
2911
  const active = this.active.get(sessionId);
2912
+ this.suggestedInputs.delete(sessionId);
2709
2913
  if (notificationPolicy.kind === "clear") {
2710
2914
  const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
2711
2915
  const mutations = generation === undefined
@@ -2716,11 +2920,8 @@ export class PiSessionService {
2716
2920
  if (!active)
2717
2921
  return;
2718
2922
  this.forgetUnreadActivity(active.runtime.session);
2719
- // An open ask is meaningful only while the runtime that posted it exists: no
2720
- // one is left to receive the answers, so it is dropped without an outcome.
2721
- this.pendingAskStore.forgetSession(sessionId);
2722
- // Open dialogs share that stance, but their extension waiters are parked
2723
- // Promises inside the dying runtime: settle them rather than dropping them.
2923
+ // Extension dialog waiters are parked Promises inside the dying runtime:
2924
+ // settle them rather than dropping them.
2724
2925
  this.endSessionExtensionDialogs(sessionId);
2725
2926
  this.active.delete(sessionId);
2726
2927
  this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
@@ -2898,26 +3099,12 @@ export class PiSessionService {
2898
3099
  return { id: sessionId, cwd: active.runtime.cwd };
2899
3100
  }
2900
3101
  activeForRef(ref) {
2901
- const sessionId = ref.id;
2902
- const exact = this.active.get(sessionId);
2903
- if (exact !== undefined && refMatchesActiveSession(ref, exact))
2904
- return exact;
2905
- for (const [candidateId, active] of this.active.entries()) {
2906
- if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
2907
- return active;
2908
- }
2909
- return undefined;
3102
+ const active = this.active.get(ref.id);
3103
+ return active !== undefined && refMatchesActiveSession(ref, active) ? active : undefined;
2910
3104
  }
2911
3105
  startupSessionForRef(ref) {
2912
- const sessionId = ref.id;
2913
- const exact = this.startupSessions.get(sessionId);
2914
- if (exact !== undefined && refMatchesStartupSession(ref, exact))
2915
- return exact;
2916
- for (const [candidateId, session] of this.startupSessions.entries()) {
2917
- if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
2918
- return session;
2919
- }
2920
- return undefined;
3106
+ const session = this.startupSessions.get(ref.id);
3107
+ return session !== undefined && refMatchesStartupSession(ref, session) ? session : undefined;
2921
3108
  }
2922
3109
  /**
2923
3110
  * The session to serve a read-only status or a dialog close for, while it
@@ -3026,7 +3213,13 @@ export class PiSessionService {
3026
3213
  // holds for this session; settle those waits before the new
3027
3214
  // runtime's extensions can open fresh dialogs under the same id.
3028
3215
  this.endSessionExtensionDialogs(boundSession.sessionId);
3216
+ const changedIdentity = boundSession.sessionId !== session.sessionId;
3029
3217
  boundSession = session;
3218
+ // Publish existence as soon as status/dialog APIs can address it,
3219
+ // before session_start or arbitrary withSession code can park on a question.
3220
+ this.startupSessions.set(session.sessionId, session);
3221
+ if (changedIdentity)
3222
+ this.announceCreatedSession(active, session.sessionManager.getCwd());
3030
3223
  await this.bindSessionExtensions(session, candidateGeneration);
3031
3224
  if (candidateGeneration !== undefined) {
3032
3225
  this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
@@ -3101,6 +3294,7 @@ export class PiSessionService {
3101
3294
  await session.bindExtensions({
3102
3295
  uiContext,
3103
3296
  mode: "rpc",
3297
+ commandContextActions: this.extensionCommandActions(session),
3104
3298
  onError: (error) => {
3105
3299
  const message = `${error.extensionPath}: ${error.error}`;
3106
3300
  this.publishActivity(session, "extension error", "error", message);
@@ -3115,6 +3309,83 @@ export class PiSessionService {
3115
3309
  this.startupSessions.delete(session.sessionId);
3116
3310
  }
3117
3311
  }
3312
+ extensionCommandActions(session) {
3313
+ const ref = { id: session.sessionId, cwd: session.sessionManager.getCwd() };
3314
+ return {
3315
+ waitForIdle: () => {
3316
+ this.assertCurrentExtensionSession(session);
3317
+ return session.waitForIdle();
3318
+ },
3319
+ navigateTree: (targetId, options) => {
3320
+ const expectedLeafId = session.sessionManager.getLeafId();
3321
+ return this.runExtensionSessionControl(session, async () => {
3322
+ const result = await this.navigateSessionTree(ref, targetId, expectedLeafId, options ?? { summarize: false }, session);
3323
+ return { cancelled: result.cancelled };
3324
+ });
3325
+ },
3326
+ fork: (entryId, options) => {
3327
+ const expectedLeafId = session.sessionManager.getLeafId();
3328
+ const withSession = options?.withSession;
3329
+ let completeReplacement;
3330
+ const forkOptions = {
3331
+ ...options,
3332
+ position: options?.position ?? "before",
3333
+ // Capture Pi's fresh context, but run user code after the host's
3334
+ // replacement reservation is released so fresh controls can work.
3335
+ ...(withSession === undefined ? {} : { withSession: (ctx) => {
3336
+ completeReplacement = () => withSession(ctx);
3337
+ return Promise.resolve();
3338
+ } }),
3339
+ };
3340
+ return this.runExtensionSessionControl(session, async () => {
3341
+ const runtime = this.active.get(ref.id)?.runtime;
3342
+ const result = await this.forkSessionTree(ref, { entryId, expectedLeafId }, forkOptions, session);
3343
+ if (!result.cancelled) {
3344
+ try {
3345
+ await completeReplacement?.();
3346
+ }
3347
+ catch (error) {
3348
+ // The fork is committed. Report callback failure on the current
3349
+ // runtime (which may have forked again), including its recoverable inbox.
3350
+ const current = runtime?.session;
3351
+ if (current !== undefined && this.isCurrentActiveSession(current)) {
3352
+ const message = errorMessage(error);
3353
+ current.extensionRunner.getUIContext().notify(message, "error");
3354
+ this.publishActivity(current, "extension callback failed", "error", message);
3355
+ this.events.publish(current.sessionId, { type: "session.error", message });
3356
+ }
3357
+ throw error;
3358
+ }
3359
+ }
3360
+ return { cancelled: result.cancelled };
3361
+ });
3362
+ },
3363
+ reload: () => this.runExtensionSessionControl(session, () => this.reloadSessionRuntime(session)),
3364
+ // These need host-owned creation/selection policies, not SDK runtime-only
3365
+ // replacement. Fail visibly until PI WEB supplies that integration.
3366
+ newSession: () => Promise.reject(new Error("ctx.newSession() is not supported in PI WEB. Start a new session from the web UI.")),
3367
+ switchSession: () => Promise.reject(new Error("ctx.switchSession() is not supported in PI WEB. Select a session from the web UI.")),
3368
+ };
3369
+ }
3370
+ setSuggestedInput(sessionId, text) {
3371
+ if (text === undefined || text === "")
3372
+ this.suggestedInputs.delete(sessionId);
3373
+ else
3374
+ this.suggestedInputs.set(sessionId, text);
3375
+ }
3376
+ assertCurrentExtensionSession(session) {
3377
+ if (!this.isCurrentActiveSession(session) || this.startupSessions.has(session.sessionId)) {
3378
+ throw new Error("This extension command belongs to an unavailable session runtime");
3379
+ }
3380
+ }
3381
+ runExtensionSessionControl(session, operation) {
3382
+ return this.extensionCommandExecution.run({ ...this.extensionCommandExecution.getStore(), sessionControl: true }, async () => {
3383
+ this.assertCurrentExtensionSession(session);
3384
+ await this.assertWritable({ id: session.sessionId, cwd: session.sessionManager.getCwd() });
3385
+ this.assertCurrentExtensionSession(session);
3386
+ return operation();
3387
+ });
3388
+ }
3118
3389
  replaceSessionNotificationContext(session, generation) {
3119
3390
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
3120
3391
  }
@@ -3291,9 +3562,30 @@ export class PiSessionService {
3291
3562
  this.clearCompactionPromptQueue(sessionId);
3292
3563
  }
3293
3564
  }
3565
+ this.restoreSubsessionReceipts(session);
3294
3566
  const unsubscribe = session.subscribe((event) => {
3295
3567
  const eventType = getString(event, "type");
3296
3568
  const message = getProperty(event, "message");
3569
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
3570
+ if (eventType === "agent_start") {
3571
+ this.restoreSubsessionReceipts(session, false);
3572
+ this.subsessionStoppedParents.delete(session);
3573
+ }
3574
+ if (eventType === "message_end" && getString(message, "role") === "assistant"
3575
+ && (getString(message, "stopReason") === "aborted" || getString(message, "stopReason") === "error")) {
3576
+ this.subsessionStoppedParents.add(session);
3577
+ }
3578
+ const entry = getProperty(event, "entry");
3579
+ if (eventType === "entry_appended" && getString(entry, "type") === "custom_message"
3580
+ && getString(entry, "customType") === SUBSESSION_NOTIFICATION_CUSTOM_TYPE) {
3581
+ inbox.receive(completionReceiptIds(getProperty(entry, "details")));
3582
+ }
3583
+ // message_end listeners run before SDK persistence. These later
3584
+ // boundaries can safely reconcile committed messages and failed intents.
3585
+ if (eventType === "turn_end" || eventType === "agent_settled")
3586
+ this.restoreSubsessionReceipts(session);
3587
+ if (eventType === "agent_settled")
3588
+ this.scheduleSubsessionDelivery(session);
3297
3589
  if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
3298
3590
  // SDK state advances before async extension hooks finish. Capture only
3299
3591
  // published messages, detaching even blocks left intact by projection.
@@ -3302,25 +3594,24 @@ export class PiSessionService {
3302
3594
  else if (eventType === "message_start" || eventType === "message_end" || eventType === "agent_end") {
3303
3595
  this.publishedAssistantPartials.delete(session);
3304
3596
  }
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
- });
3597
+ const clientEvent = toClientEvent(event, session.thinkingLevel);
3598
+ if ((clientEvent.type === "message.end" || clientEvent.type === "message.append") && clientEvent.message !== undefined) {
3599
+ clientEvent.message = this.browserTranscriptMessage(session, clientEvent.message);
3315
3600
  }
3601
+ this.events.publish(session.sessionId, clientEvent, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
3602
+ this.publishActivityForEvent(session, event);
3603
+ if (eventType === "message_start" && getString(message, "role") === "user")
3604
+ this.suggestedInputs.delete(session.sessionId);
3316
3605
  if (eventType === "agent_end")
3317
3606
  this.abortRunScopedExtensionDialogs(session.sessionId);
3318
3607
  if (eventType === "compaction_end")
3319
3608
  this.scheduleCompactionQueueDrain(session.sessionId);
3609
+ if (eventType === "compaction_end" || eventType === "bash_execution_end")
3610
+ this.scheduleSubsessionDelivery(session);
3320
3611
  if (eventType === "agent_start" || eventType === "agent_end")
3321
3612
  this.scheduleCompactionQueueDrain(session.sessionId);
3322
3613
  this.publishStatus(session);
3323
- this.updateSubsessionTracking(session, eventType === "agent_start");
3614
+ this.updateSubsessionTracking(session, eventType === "agent_start", eventType === "agent_settled");
3324
3615
  });
3325
3616
  active.unsubscribe = () => {
3326
3617
  this.sessionEvents.close(session);
@@ -3391,11 +3682,6 @@ export class PiSessionService {
3391
3682
  maybeGenerateSessionName(session, firstMessage) {
3392
3683
  if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
3393
3684
  return;
3394
- const deterministicName = deterministicSessionName(firstMessage);
3395
- if (deterministicName !== undefined) {
3396
- this.applyGeneratedSessionName(session, deterministicName);
3397
- return;
3398
- }
3399
3685
  const model = session.model;
3400
3686
  if (model === undefined)
3401
3687
  return;
@@ -3426,13 +3712,8 @@ export class PiSessionService {
3426
3712
  this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
3427
3713
  }
3428
3714
  }
3429
- const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
3430
- this.deferredSubsessionNotifications.delete(session);
3431
- for (const notification of notifications) {
3432
- void this.deliverSubsessionNotification(session, notification).catch((error) => {
3433
- this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
3434
- });
3435
- }
3715
+ this.restoreSubsessionReceipts(session);
3716
+ this.scheduleSubsessionDelivery(session);
3436
3717
  }
3437
3718
  applyAuthChange(change = {}) {
3438
3719
  // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
@@ -3518,8 +3799,11 @@ export class PiSessionService {
3518
3799
  return "active";
3519
3800
  }
3520
3801
  hasActiveWork(session) {
3802
+ const execution = this.extensionCommandExecution.getStore();
3803
+ const ownCommandReceipt = execution?.sessionControl === true
3804
+ && execution.submission?.active === true && execution.submission.session === session ? 1 : 0;
3521
3805
  return this.treeNavigations.has(session)
3522
- || this.isSessionEntryMutationActive(session)
3806
+ || this.isSessionEntryMutationActive(session, ownCommandReceipt)
3523
3807
  || this.isTreeExclusiveOperationActive(session)
3524
3808
  || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
3525
3809
  }
@@ -3601,9 +3885,11 @@ export class PiSessionService {
3601
3885
  else
3602
3886
  this.sessionEntryMutationCounts.set(session, remaining);
3603
3887
  this.observeUnreadActivityState(session);
3888
+ if (remaining <= 0 && !this.subsessionDeliveringParents.has(session))
3889
+ this.scheduleSubsessionDelivery(session);
3604
3890
  }
3605
- isSessionEntryMutationActive(session) {
3606
- return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
3891
+ isSessionEntryMutationActive(session, ignoredReceipts = 0) {
3892
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > ignoredReceipts;
3607
3893
  }
3608
3894
  publishActivityForEvent(session, event) {
3609
3895
  const eventType = getString(event, "type");
@@ -3750,11 +4036,12 @@ export class PiSessionService {
3750
4036
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3751
4037
  const contextUsage = session.getContextUsage();
3752
4038
  const warnings = this.warningsForSession(session);
3753
- const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
3754
4039
  const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
4040
+ const suggestedInput = this.suggestedInputs.get(session.sessionId);
3755
4041
  return {
3756
4042
  sessionId: session.sessionId,
3757
4043
  persisted: sessionFileExists(session.sessionFile),
4044
+ recentlyActiveElsewhere: this.activityMarker.isActiveElsewhere(session.sessionFile),
3758
4045
  ...(model === undefined ? {} : { model }),
3759
4046
  thinkingLevel: session.thinkingLevel,
3760
4047
  isStreaming: session.isStreaming,
@@ -3767,8 +4054,8 @@ export class PiSessionService {
3767
4054
  cost: stats.cost,
3768
4055
  ...(contextUsage === undefined ? {} : { contextUsage }),
3769
4056
  ...(warnings.length === 0 ? {} : { warnings }),
3770
- ...(pendingAsk === undefined ? {} : { pendingAsk }),
3771
4057
  ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
4058
+ ...(suggestedInput === undefined ? {} : { suggestedInput }),
3772
4059
  };
3773
4060
  }
3774
4061
  /**
@@ -3780,13 +4067,6 @@ export class PiSessionService {
3780
4067
  warningsForSession(session) {
3781
4068
  const runtime = this.active.get(session.sessionId)?.runtime;
3782
4069
  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
- }
3790
4070
  const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3791
4071
  if (anthropic !== undefined)
3792
4072
  warnings.push(anthropic);
@@ -3827,13 +4107,13 @@ function bulkRefToSessionRef(ref) {
3827
4107
  return { id: ref.id, cwd: ref.cwd };
3828
4108
  }
3829
4109
  function findArchivedRecordForBulkRef(records, ref) {
3830
- return records.find((record) => record.cwd === ref.cwd && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
4110
+ return records.find((record) => record.cwd === ref.cwd && record.sessionId === ref.id);
3831
4111
  }
3832
4112
  function findListedSessionForBulkRef(context, ref) {
3833
- return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
4113
+ return findSessionByExactId(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
3834
4114
  }
3835
- function findSessionByIdOrPrefix(sessions, sessionId) {
3836
- return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
4115
+ function findSessionByExactId(sessions, sessionId) {
4116
+ return sessions.find((session) => session.id === sessionId);
3837
4117
  }
3838
4118
  function uniqueStrings(values) {
3839
4119
  return [...new Set(values)];
@@ -3874,12 +4154,35 @@ function notificationIdentityForSession(session) {
3874
4154
  cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3875
4155
  };
3876
4156
  }
4157
+ function metadataFromActiveSession(session) {
4158
+ return readSessionUiMetadata(session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch());
4159
+ }
4160
+ function clientSessionFromActiveSession(session) {
4161
+ const header = session.sessionManager.getHeader?.();
4162
+ const created = header?.timestamp ?? new Date().toISOString();
4163
+ const metadata = metadataFromActiveSession(session);
4164
+ return {
4165
+ id: session.sessionId,
4166
+ path: session.sessionFile ?? "",
4167
+ cwd: session.sessionManager.getCwd(),
4168
+ persisted: sessionFileExists(session.sessionFile),
4169
+ created,
4170
+ modified: created,
4171
+ messageCount: session.messages.length,
4172
+ firstMessage: "",
4173
+ ...(metadata === undefined ? {} : { metadata }),
4174
+ ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
4175
+ ...(header?.parentSession === undefined ? {} : { parentSessionPath: header.parentSession }),
4176
+ };
4177
+ }
3877
4178
  function clientSessionFromListEntry(session) {
3878
4179
  return {
3879
4180
  id: session.id,
3880
4181
  path: session.path,
3881
4182
  cwd: session.cwd,
3882
4183
  persisted: true,
4184
+ ...(session.agentSubsession === true ? { agentSubsession: true } : {}),
4185
+ ...(session.metadata === undefined ? {} : { metadata: requireSessionUiMetadata(session.metadata) }),
3883
4186
  ...(session.name === undefined ? {} : { name: session.name }),
3884
4187
  created: session.created.toISOString(),
3885
4188
  modified: session.modified.toISOString(),
@@ -3897,6 +4200,7 @@ function archiveInputFromListEntry(session) {
3897
4200
  modified: session.modified.toISOString(),
3898
4201
  messageCount: session.messageCount,
3899
4202
  firstMessage: session.firstMessage,
4203
+ ...(session.metadata === undefined ? {} : { metadata: session.metadata }),
3900
4204
  ...(session.name === undefined ? {} : { name: session.name }),
3901
4205
  ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3902
4206
  };
@@ -3906,6 +4210,7 @@ function archiveInputFromActiveSession(session) {
3906
4210
  if (sessionFile === undefined || sessionFile === "")
3907
4211
  throw new Error("Session is not persisted");
3908
4212
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
4213
+ const metadata = metadataFromActiveSession(session);
3909
4214
  return {
3910
4215
  sessionId: session.sessionId,
3911
4216
  cwd: session.sessionManager.getCwd(),
@@ -3914,6 +4219,7 @@ function archiveInputFromActiveSession(session) {
3914
4219
  modified: new Date().toISOString(),
3915
4220
  messageCount: session.messages.length,
3916
4221
  firstMessage: "",
4222
+ ...(metadata === undefined ? {} : { metadata }),
3917
4223
  ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
3918
4224
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3919
4225
  };
@@ -3978,11 +4284,13 @@ function clientSessionFromArchivedRecord(record, fallback) {
3978
4284
  if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
3979
4285
  return undefined;
3980
4286
  const name = record.name ?? fallback?.name;
4287
+ const metadata = record.metadata ?? fallback?.metadata;
3981
4288
  const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
3982
4289
  return {
3983
4290
  id: record.sessionId,
3984
4291
  path,
3985
4292
  cwd: record.cwd,
4293
+ ...(metadata === undefined ? {} : { metadata: requireSessionUiMetadata(metadata) }),
3986
4294
  ...(name === undefined ? {} : { name }),
3987
4295
  created,
3988
4296
  modified,
@@ -4096,37 +4404,6 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
4096
4404
  spawnedSessionId: childSessionId,
4097
4405
  };
4098
4406
  }
4099
- function parsePersistedParentSubsessionLink(entry) {
4100
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
4101
- return undefined;
4102
- const data = entry["data"];
4103
- if (!isRecord(data))
4104
- return undefined;
4105
- const spawnedBySessionId = getString(data, "spawnedBySessionId");
4106
- const spawnedSessionId = getString(data, "spawnedSessionId");
4107
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4108
- return undefined;
4109
- const spawnedSessionFile = getString(data, "spawnedSessionFile");
4110
- const cwd = getString(data, "cwd");
4111
- return {
4112
- spawnedBySessionId,
4113
- spawnedSessionId,
4114
- ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
4115
- ...(cwd === undefined || cwd === "" ? {} : { cwd }),
4116
- };
4117
- }
4118
- function parsePersistedChildSubsessionLink(entry) {
4119
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
4120
- return undefined;
4121
- const data = entry["data"];
4122
- if (!isRecord(data))
4123
- return undefined;
4124
- const spawnedBySessionId = getString(data, "spawnedBySessionId");
4125
- const spawnedSessionId = getString(data, "spawnedSessionId");
4126
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4127
- return undefined;
4128
- return { spawnedBySessionId, spawnedSessionId };
4129
- }
4130
4407
  function nonEmptyString(value) {
4131
4408
  return value === undefined || value === "" ? undefined : value;
4132
4409
  }
@@ -4244,6 +4521,55 @@ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
4244
4521
  return undefined;
4245
4522
  return parentId === null || typeof parentId === "string" ? parentId : undefined;
4246
4523
  }
4524
+ /**
4525
+ * Pi edits custom messages from their parent. For a retained checkpoint, remap
4526
+ * that single leaf movement instead, before Pi rebuilds context/restores tools
4527
+ * and emits session_tree. Restore the manager methods before extension handlers
4528
+ * run: their appended history must not be overwritten by a post-event repair.
4529
+ * The caller holds the tree-navigation gate and excludes branch summarization.
4530
+ */
4531
+ async function navigateTreeRetainingCustomMessage(manager, targetId, parentId, navigate) {
4532
+ if (parentId !== null && typeof parentId !== "string")
4533
+ throw new Error("This checkpoint has no valid history parent");
4534
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- Restore exact identities; calls below explicitly bind the manager.
4535
+ const { branch, resetLeaf } = manager;
4536
+ let pending = true;
4537
+ const restore = () => {
4538
+ if (!pending)
4539
+ return;
4540
+ pending = false;
4541
+ manager.branch = branch;
4542
+ manager.resetLeaf = resetLeaf;
4543
+ };
4544
+ try {
4545
+ manager.branch = (entryId) => {
4546
+ if (entryId === parentId) {
4547
+ restore();
4548
+ branch.call(manager, targetId);
4549
+ }
4550
+ else
4551
+ branch.call(manager, entryId);
4552
+ };
4553
+ manager.resetLeaf = () => {
4554
+ if (parentId === null) {
4555
+ restore();
4556
+ branch.call(manager, targetId);
4557
+ }
4558
+ else
4559
+ resetLeaf.call(manager);
4560
+ };
4561
+ const result = await navigate();
4562
+ if (result.cancelled)
4563
+ return result;
4564
+ // The retained notice is context, not text to restore into the editor.
4565
+ const { editorText, ...retainedResult } = result;
4566
+ void editorText;
4567
+ return retainedResult;
4568
+ }
4569
+ finally {
4570
+ restore();
4571
+ }
4572
+ }
4247
4573
  function setSessionTreeLeaf(manager, leafId) {
4248
4574
  if (leafId === null)
4249
4575
  manager.resetLeaf();
@@ -4257,15 +4583,22 @@ function transcriptBranchIncludesEntry(entries, entryId) {
4257
4583
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
4258
4584
  /** custom entry type used to mark a child as created by spawn_subsession. */
4259
4585
  const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
4260
- /** customType marking a parent-facing subsession-completion notice. */
4261
- const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
4262
- const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
4263
- /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
4264
- function formatSubsessionNotificationOutput(childSessionId, text) {
4265
- if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
4266
- return `Output from subsession ${childSessionId} was too long for this completion notice and was omitted. Call check_subsession with sessionId "${childSessionId}" to retrieve the final output.`;
4267
- }
4268
- return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
4586
+ /** A filtered read is a receipt only when it contains the complete latest final text. */
4587
+ function transcriptReturnsFinalOutput(messages, view, finalText) {
4588
+ if (finalText === "")
4589
+ return false;
4590
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
4591
+ const text = finalAssistantText([messages[index]]);
4592
+ if (text === "")
4593
+ continue;
4594
+ const entry = view.entries.find((candidate) => candidate.index === index && candidate.role === "assistant");
4595
+ if (entry === undefined || text !== finalText)
4596
+ return false;
4597
+ const texts = entry.parts.filter((part) => part.kind === "text");
4598
+ return texts.every((part) => part.truncated === undefined)
4599
+ && texts.map((part) => part.text).join("\n").trim() === finalText.trim();
4600
+ }
4601
+ return false;
4269
4602
  }
4270
4603
  /** Most recent assistant text from a history message list, or "" if none. */
4271
4604
  function finalAssistantText(messages) {
@@ -4290,6 +4623,11 @@ function finalAssistantText(messages) {
4290
4623
  }
4291
4624
  function toClientEvent(event, thinkingLevel) {
4292
4625
  const eventType = getString(event, "type");
4626
+ // Nested execution belongs to the parent tool result, not a durable message.
4627
+ // Do not create standalone transcript rows that disappear on reconnect.
4628
+ if (getString(event, "parentToolCallId") !== undefined && eventType?.startsWith("tool_execution_") === true) {
4629
+ return { type: "pi.event", eventType };
4630
+ }
4293
4631
  const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
4294
4632
  if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
4295
4633
  return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };