@jmfederico/pi-web 1.202610.1 → 1.202610.3

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 (148) hide show
  1. package/README.md +23 -134
  2. package/dist/cli.js +4 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/index-BAl5IbYx.js +4429 -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 +30 -32
  11. package/dist/pi-packages/captains-log/dist/companion.js +1 -1
  12. package/dist/pi-packages/captains-log/package.json +4 -4
  13. package/dist/pi-packages/relays/extensions/index.js +60 -0
  14. package/dist/pi-packages/relays/package.json +10 -1
  15. package/dist/pi-packages/relays/pi-web-plugin.js +31 -1
  16. package/dist/pi-packages/relays/prompts/relay-worktree.md +6 -16
  17. package/dist/pi-packages/relays/prompts/relay.md +6 -33
  18. package/dist/pi-packages/relays/relayDispatch.js +67 -0
  19. package/dist/pi-packages/relays/relayIdentity.js +37 -0
  20. package/dist/pi-packages/relays/relaysPanelElement.js +37 -12
  21. package/dist/pi-packages/relays/skills/relay/SKILL.md +24 -68
  22. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +40 -219
  23. package/dist/pi-packages/workflows/browser/pi-web-plugin.js +33 -0
  24. package/dist/pi-packages/workflows/package.json +22 -0
  25. package/dist/pi-packages/workflows/prompts/worktree.md +12 -0
  26. package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +49 -51
  27. package/dist/pi-web-plugins/git/browser/git-panel.js +0 -1
  28. package/dist/pi-web-plugins/git/server-plugin.js +13 -2
  29. package/dist/pi-web-plugins/git/workspace-discovery-notify.cjs +14 -0
  30. package/dist/pi-web-plugins/git/workspaceDiscoveryHooks.js +186 -0
  31. package/dist/pi-web-plugins/info/infoInternals.js +3 -3
  32. package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
  33. package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +1087 -422
  34. package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +30 -30
  35. package/dist/pi-web-plugins/updates/pi-web-plugin.js +1 -1
  36. package/dist/piWebUpdate.js +2 -2
  37. package/dist/piWebUpdate.js.map +1 -1
  38. package/dist/plugin-api.d.ts +229 -8
  39. package/dist/server/activity/workspaceActivityService.js +4 -1
  40. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  41. package/dist/server/app.js +5 -0
  42. package/dist/server/app.js.map +1 -1
  43. package/dist/server/configRoutes.js +6 -0
  44. package/dist/server/configRoutes.js.map +1 -1
  45. package/dist/server/knownAutoInstallPiPackages.js +6 -0
  46. package/dist/server/knownAutoInstallPiPackages.js.map +1 -1
  47. package/dist/server/navigationPreferenceRoutes.js +39 -0
  48. package/dist/server/navigationPreferenceRoutes.js.map +1 -0
  49. package/dist/server/plugins/projectPluginSettings.js +95 -0
  50. package/dist/server/plugins/projectPluginSettings.js.map +1 -0
  51. package/dist/server/plugins/serverPluginRuntime.js +26 -3
  52. package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
  53. package/dist/server/sessiond/autoInstallPiPackages.js +3 -3
  54. package/dist/server/sessiond/workspaceCatalogRoutes.js +3 -1
  55. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -1
  56. package/dist/server/sessiond/workspaceNotificationRoutes.js +41 -0
  57. package/dist/server/sessiond/workspaceNotificationRoutes.js.map +1 -0
  58. package/dist/server/sessiond.js +40 -7
  59. package/dist/server/sessiond.js.map +1 -1
  60. package/dist/server/sessions/askUserDomain.js +232 -0
  61. package/dist/server/sessions/askUserDomain.js.map +1 -0
  62. package/dist/server/sessions/askUserTool.js +15 -17
  63. package/dist/server/sessions/askUserTool.js.map +1 -1
  64. package/dist/server/sessions/dockerEnvironmentFacts.js +8 -0
  65. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -1
  66. package/dist/server/sessions/extensionSessionDispatch.js +76 -0
  67. package/dist/server/sessions/extensionSessionDispatch.js.map +1 -0
  68. package/dist/server/sessions/globalProviderPolicy.js +71 -16
  69. package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
  70. package/dist/server/sessions/messageHistoryTarget.js +61 -0
  71. package/dist/server/sessions/messageHistoryTarget.js.map +1 -0
  72. package/dist/server/sessions/piSessionManagerGateway.js +23 -24
  73. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  74. package/dist/server/sessions/piSessionService.js +759 -276
  75. package/dist/server/sessions/piSessionService.js.map +1 -1
  76. package/dist/server/sessions/queuedMessageMutation.js +168 -0
  77. package/dist/server/sessions/queuedMessageMutation.js.map +1 -0
  78. package/dist/server/sessions/sessionArchiveStore.js +5 -1
  79. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  80. package/dist/server/sessions/sessionArchiveTree.js +2 -2
  81. package/dist/server/sessions/sessionArchiveTree.js.map +1 -1
  82. package/dist/server/sessions/sessionCommandService.js +9 -3
  83. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  84. package/dist/server/sessions/sessionImageDimensions.js +31 -0
  85. package/dist/server/sessions/sessionImageDimensions.js.map +1 -0
  86. package/dist/server/sessions/sessionMediaIndex.js +7 -1
  87. package/dist/server/sessions/sessionMediaIndex.js.map +1 -1
  88. package/dist/server/sessions/sessionMetadata.js +51 -0
  89. package/dist/server/sessions/sessionMetadata.js.map +1 -0
  90. package/dist/server/sessions/sessionNameGenerator.js +8 -28
  91. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  92. package/dist/server/sessions/sessionRoutes.js +20 -2
  93. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  94. package/dist/server/sessions/sessionSummaryScanner.js +62 -5
  95. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
  96. package/dist/server/sessions/spawnSessionTool.js +3 -1
  97. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  98. package/dist/server/sessions/spawnSubsessionTool.js +25 -17
  99. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  100. package/dist/server/sessions/subsessionCompletionDelivery.js +15 -0
  101. package/dist/server/sessions/subsessionCompletionDelivery.js.map +1 -0
  102. package/dist/server/sessions/subsessionCompletionInbox.js +134 -0
  103. package/dist/server/sessions/subsessionCompletionInbox.js.map +1 -0
  104. package/dist/server/sessions/subsessionProvenance.js +27 -0
  105. package/dist/server/sessions/subsessionProvenance.js.map +1 -0
  106. package/dist/server/status/machineStatusService.js +17 -5
  107. package/dist/server/status/machineStatusService.js.map +1 -1
  108. package/dist/server/storage/navigationPreferenceStore.js +137 -0
  109. package/dist/server/storage/navigationPreferenceStore.js.map +1 -0
  110. package/dist/server/workspaces/projectPiWebConfig.js +88 -6
  111. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  112. package/dist/server/workspaces/workspaceChangeNotifier.js +12 -0
  113. package/dist/server/workspaces/workspaceChangeNotifier.js.map +1 -0
  114. package/dist/server/workspaces/workspaceNotificationCommand.js +64 -0
  115. package/dist/server/workspaces/workspaceNotificationCommand.js.map +1 -0
  116. package/dist/server/workspaces/workspaceProviderRegistry.js +55 -0
  117. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
  118. package/dist/server-plugin-api.d.ts +18 -0
  119. package/dist/server-plugin-api.js.map +1 -1
  120. package/dist/sessiond/sessionDaemonClient.js +3 -3
  121. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  122. package/dist/shared/apiTypes.js +2 -2
  123. package/dist/shared/apiTypes.js.map +1 -1
  124. package/dist/shared/federatedRoutes.js +6 -0
  125. package/dist/shared/federatedRoutes.js.map +1 -1
  126. package/dist/shared/machineStatus.js +11 -2
  127. package/dist/shared/machineStatus.js.map +1 -1
  128. package/dist/shared/navigationPreferences.js +6 -0
  129. package/dist/shared/navigationPreferences.js.map +1 -0
  130. package/dist/shared/pluginApiTypes.d.ts +2 -0
  131. package/dist/shared/sessionMedia.js +6 -0
  132. package/dist/shared/sessionMedia.js.map +1 -1
  133. package/dist/shared/streamingBehavior.js +7 -0
  134. package/dist/shared/streamingBehavior.js.map +1 -0
  135. package/dist/shared/themePreference.js +23 -0
  136. package/dist/shared/themePreference.js.map +1 -0
  137. package/dist/workspaceNotificationCli.js +95 -0
  138. package/dist/workspaceNotificationCli.js.map +1 -0
  139. package/docs/config.md +111 -86
  140. package/docs/plugins.md +105 -30
  141. package/examples/session-bridge-plugin/package.json +2 -2
  142. package/examples/session-bridge-plugin/src/companion.ts +1 -1
  143. package/examples/workspace-provider-plugin/package.json +1 -1
  144. package/package.json +33 -33
  145. package/dist/client/assets/index-CBjDnBIN.js +0 -4178
  146. package/dist/client/assets/vendor-editor-core-YWog6mAR.js +0 -10
  147. package/dist/server/sessions/pendingAskStore.js +0 -275
  148. package/dist/server/sessions/pendingAskStore.js.map +0 -1
@@ -1,6 +1,9 @@
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";
6
+ import { QueueCompatibilityGuard, mirroredQueueEntries, mutableQueueAgent, mutableTextMirror, parseQueueMessageMutation, queuedMessageAttachmentCounts, queuedMessageAttachmentCount, snapshotAgentQueues } from "./queuedMessageMutation.js";
4
7
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEventBus, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
8
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
9
  import { isSessionMediaId } from "../../shared/sessionMedia.js";
@@ -11,10 +14,12 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
11
14
  import { SessionCommandService } from "./sessionCommandService.js";
12
15
  import { SessionActivityMarker } from "./sessionActivityMarker.js";
13
16
  import { projectSessionTree } from "./sessionTreeProjection.js";
17
+ import { messageHistoryTargetId } from "./messageHistoryTarget.js";
14
18
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
15
- import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
19
+ import { findArchiveCandidateByExactId, planSessionArchiveTree } from "./sessionArchiveTree.js";
16
20
  import { PiSessionEventConnections } from "./piSessionEventConnections.js";
17
- import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
21
+ import { fallbackSessionName, generateShortSessionName, requireInitialSessionName } from "./sessionNameGenerator.js";
22
+ import { createExtensionSessionDispatch } from "./extensionSessionDispatch.js";
18
23
  import { computeEditPreview } from "./editPreview.js";
19
24
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
20
25
  import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
@@ -22,15 +27,19 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
22
27
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
23
28
  import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
24
29
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
30
+ import { parsePersistedChildSubsessionLink, parsePersistedParentSubsessionLink } from "./subsessionProvenance.js";
25
31
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
26
32
  import { createAskUserToolDefinition } from "./askUserTool.js";
27
- import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
33
+ import { createAskUserOutcome, renderAskUserAnswersText, resolveAskUserRequest } from "./askUserDomain.js";
28
34
  import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
29
35
  import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
30
36
  import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
31
37
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
38
+ import { readSessionUiMetadata, requireSessionUiMetadata, SESSION_UI_METADATA_CUSTOM_TYPE } from "./sessionMetadata.js";
32
39
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
33
40
  import { buildTranscriptView } from "./subsessionTranscript.js";
41
+ import { createSubsessionCompletionDelivery } from "./subsessionCompletionDelivery.js";
42
+ import { completionReceiptIds, persistedSubsessionCompletions, renderSubsessionCompletions, SUBSESSION_COMPLETION_ENTRY_TYPE, SUBSESSION_NOTIFICATION_CUSTOM_TYPE, SUBSESSION_RECEIPT_ENTRY_TYPE, SubsessionCompletionInbox } from "./subsessionCompletionInbox.js";
34
43
  import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
35
44
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
36
45
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
@@ -290,14 +299,14 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
290
299
  });
291
300
  };
292
301
  }
293
- export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
302
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUserEnabled = false) {
294
303
  return [
295
304
  createPiWebEditToolDefinition(cwd),
296
305
  ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
297
306
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
298
307
  // Asking the user is not delegation: the questions land in the session the
299
308
  // user is already watching, so tracked children may ask too.
300
- ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
309
+ ...(askUserEnabled ? [createAskUserToolDefinition()] : []),
301
310
  ];
302
311
  }
303
312
  /**
@@ -430,7 +439,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
430
439
  return undefined;
431
440
  return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
432
441
  }
433
- function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
442
+ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUserEnabled = false, appendSystemPromptSections = []) {
434
443
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
435
444
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
436
445
  // PI WEB always honors pi's project-trust model. When the workspace ships
@@ -445,6 +454,13 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
445
454
  // resources skip resolution entirely and are trusted, as before.
446
455
  const eventBus = createEventBus();
447
456
  const builtinFactories = await getBuiltinExtensionFactories();
457
+ // Resolved before session_start. The bridge shares the same delegation
458
+ // policy as core tools, including tracked-child and global disablement.
459
+ let dispatchEnabled = false;
460
+ const sessionDispatch = createExtensionSessionDispatch(cwd, {
461
+ ...(spawn === undefined ? {} : { spawn }),
462
+ isEnabled: () => dispatchEnabled,
463
+ });
448
464
  const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
449
465
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
450
466
  // Pre-session-creation trust failures (`project_trust` handler errors)
@@ -456,7 +472,10 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
456
472
  agentDir,
457
473
  modelRuntime,
458
474
  settingsManager,
459
- resourceLoaderOptions: { ...resourceLoaderOptions, eventBus, extensionFactories: builtinFactories },
475
+ resourceLoaderOptions: {
476
+ ...resourceLoaderOptions, eventBus,
477
+ extensionFactories: [...builtinFactories, sessionDispatch, ...(subsessions === undefined ? [] : [createSubsessionCompletionDelivery(subsessions)])],
478
+ },
460
479
  ...(projectTrustRequiring
461
480
  ? {
462
481
  resourceLoaderReloadOptions: {
@@ -480,7 +499,8 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
480
499
  services.diagnostics.push(...modelOptions.diagnostics);
481
500
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
482
501
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
483
- const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
502
+ dispatchEnabled = resolvedDelegationToolsEnabled;
503
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUserEnabled);
484
504
  const result = await createAgentSessionFromServices({
485
505
  services,
486
506
  sessionManager,
@@ -537,8 +557,12 @@ export class PiSessionService {
537
557
  * can anchor the selected branch on disk.
538
558
  */
539
559
  this.unpersistedTreeBranchLeaves = new WeakMap();
560
+ /** Latest prepared text belongs to the session, never to a browser's unsent draft. */
561
+ this.suggestedInputs = new Map();
540
562
  /** Counts async operations that may append an entry before they settle. */
541
563
  this.sessionEntryMutationCounts = new WeakMap();
564
+ /** Exempt only a command's own prompt receipt while it invokes guarded session control. */
565
+ this.extensionCommandExecution = new AsyncLocalStorage();
542
566
  this.pendingPromptEchoes = new WeakMap();
543
567
  this.publishedAssistantPartials = new WeakMap();
544
568
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
@@ -554,7 +578,11 @@ export class PiSessionService {
554
578
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
555
579
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
556
580
  this.treeExclusiveSessionOperationCounts = new Map();
557
- this.deferredSubsessionNotifications = new WeakMap();
581
+ /** Parent id/file identity -> one inbox shared by all completion delivery paths. */
582
+ this.subsessionInboxes = new Map();
583
+ this.subsessionStoppedParents = new WeakSet();
584
+ this.subsessionDeliveringParents = new WeakSet();
585
+ this.subsessionDeliveryScheduled = new WeakSet();
558
586
  this.deferredGeneratedSessionNames = new WeakMap();
559
587
  this.compactionPromptQueues = new Map();
560
588
  this.compactionDrainTimers = new Map();
@@ -573,6 +601,9 @@ export class PiSessionService {
573
601
  * one (not for standalone commands or compaction); completion disarms it.
574
602
  */
575
603
  this.subsessionNotifyArmed = new Map();
604
+ this.queueCompatibility = new QueueCompatibilityGuard((session, err) => {
605
+ this.logger.info({ sessionId: session.sessionId, err }, "Disabled SDK-dependent queue controls after an incompatible queue interface");
606
+ });
576
607
  this.notificationGenerationBySession = new WeakMap();
577
608
  /** Last idle-poll transcript file resolution per runtime, throttled; entries die with their runtime. */
578
609
  this.idleSessionFileResolutions = new WeakMap();
@@ -595,7 +626,6 @@ export class PiSessionService {
595
626
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
596
627
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
597
628
  this.onUnreadChanged = deps.onUnreadChanged;
598
- this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
599
629
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
600
630
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
601
631
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
@@ -609,9 +639,11 @@ export class PiSessionService {
609
639
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
610
640
  spawn: (input) => this.spawnSubsession(input),
611
641
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
612
- check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
613
- read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
614
- }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, deps.appendSystemPromptSections ?? []);
642
+ join: (parentSessionId, parentSessionFile, toolCallId) => this.joinSubsessions(parentSessionId, parentSessionFile, toolCallId),
643
+ prepare: (parentSessionId, parentSessionFile) => this.prepareSubsessionDelivery(parentSessionId, parentSessionFile),
644
+ check: (parentSessionId, sessionId, parentSessionFile, toolCallId) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId),
645
+ read: (parentSessionId, sessionId, query, parentSessionFile, toolCallId) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId),
646
+ }, deps.askUserEnabled === true, deps.appendSystemPromptSections ?? []);
615
647
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
616
648
  this.workspaceActivity = deps.workspaceActivity;
617
649
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -728,12 +760,12 @@ export class PiSessionService {
728
760
  const activeSessions = Array.from(new Set(this.active.values()));
729
761
  for (const active of activeSessions) {
730
762
  this.forgetUnreadActivity(active.runtime.session);
731
- this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
732
763
  this.endSessionExtensionDialogs(active.runtime.session.sessionId);
733
764
  }
734
765
  this.active.clear();
735
766
  this.pendingSessionOpens.clear();
736
767
  this.startupSessions.clear();
768
+ this.suggestedInputs.clear();
737
769
  this.activities.clear();
738
770
  this.compactionPromptQueues.clear();
739
771
  this.authLossWarnings.clear();
@@ -742,6 +774,7 @@ export class PiSessionService {
742
774
  this.subsessionLinks.clear();
743
775
  this.subsessionHydratedParents.clear();
744
776
  this.subsessionNotifyArmed.clear();
777
+ this.subsessionInboxes.clear();
745
778
  this.notificationStore.clearAll("service-dispose");
746
779
  await Promise.all(activeSessions.map(async (active) => {
747
780
  active.unsubscribe();
@@ -754,6 +787,7 @@ export class PiSessionService {
754
787
  await active.runtime.dispose();
755
788
  }
756
789
  })).finally(async () => {
790
+ this.extensionCommandExecution.disable();
757
791
  this.events.mediaIndex.clear();
758
792
  await this.activityMarker.dispose();
759
793
  });
@@ -767,7 +801,29 @@ export class PiSessionService {
767
801
  for (const record of archivedForCwd) {
768
802
  this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
769
803
  }
770
- const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
804
+ const unarchivedById = new Map(sessions.filter((session) => !archivedById.has(session.id))
805
+ .map((session) => [session.id, clientSessionFromListEntry(session)]));
806
+ // Pi can fork before the first conversational entry without creating a file.
807
+ // Enumerate already-hosted runtimes only; listing must not open cold sessions.
808
+ for (const active of new Set(this.active.values())) {
809
+ const session = active.runtime.session;
810
+ if (!cwdPathsEqual(session.sessionManager.getCwd(), cwd) || archivedById.has(session.sessionId))
811
+ continue;
812
+ const listed = unarchivedById.get(session.sessionId);
813
+ const tracked = this.subsessionLinkForActiveChild(session) !== undefined;
814
+ if (listed !== undefined) {
815
+ // Match the file too: a clone with the same id must not inherit a
816
+ // live runtime's classification. Disk rows otherwise use the scanner.
817
+ if (tracked && session.sessionFile !== undefined && sessionPathsEqual(listed.path, session.sessionFile))
818
+ listed.agentSubsession = true;
819
+ continue;
820
+ }
821
+ const info = clientSessionFromActiveSession(session);
822
+ if (tracked)
823
+ info.agentSubsession = true;
824
+ unarchivedById.set(session.sessionId, info);
825
+ }
826
+ const unarchivedSessions = [...unarchivedById.values()].sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
771
827
  const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
772
828
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
773
829
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
@@ -815,29 +871,37 @@ export class PiSessionService {
815
871
  }
816
872
  }
817
873
  async startSession(cwd, options) {
818
- const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
874
+ const metadata = options.metadata === undefined ? undefined : requireSessionUiMetadata(options.metadata);
875
+ const name = options.name === undefined ? undefined : requireInitialSessionName(options.name);
876
+ const manager = this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession });
877
+ if (metadata !== undefined) {
878
+ if (manager.appendCustomEntry === undefined)
879
+ throw new Error("Session manager does not support UI metadata initialization");
880
+ manager.appendCustomEntry(SESSION_UI_METADATA_CUSTOM_TYPE, { version: 1, metadata });
881
+ }
882
+ if (name !== undefined) {
883
+ if (manager.appendSessionInfo === undefined)
884
+ throw new Error("Session manager does not support initial naming");
885
+ manager.appendSessionInfo(name);
886
+ }
887
+ const active = await this.create(manager, cwd, {
819
888
  startupIntent: "create",
820
889
  ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
821
890
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
822
891
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
823
892
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
824
893
  });
825
- return this.announceCreatedSession(active, cwd, options.parentSession);
894
+ return options.deferAnnouncement === true
895
+ ? clientSessionFromActiveSession(active.runtime.session)
896
+ : this.announceCreatedSession(active, cwd, options.parentSession);
826
897
  }
827
- announceCreatedSession(active, cwd, parentSession) {
898
+ announceCreatedSession(active, cwd, parentSession, agentSubsession) {
828
899
  const { session } = active.runtime;
829
900
  const created = {
830
- id: session.sessionId,
831
- path: session.sessionFile ?? "",
901
+ ...clientSessionFromActiveSession(session),
832
902
  cwd,
833
- persisted: sessionFileExists(session.sessionFile),
834
- created: new Date().toISOString(),
835
- modified: new Date().toISOString(),
836
- messageCount: session.messages.length,
837
- firstMessage: "",
838
- // Include the parent so listeners can nest the new session in the tree
839
- // immediately, instead of showing it flat until the next reload.
840
903
  ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
904
+ ...(agentSubsession === true ? { agentSubsession: true } : {}),
841
905
  };
842
906
  // Broadcast so other clients (and the spawning agent's UI) can add the new
843
907
  // session to their list without a manual reload.
@@ -861,6 +925,8 @@ export class PiSessionService {
861
925
  ? input.model
862
926
  : await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
863
927
  const created = await this.start(decision.cwd, {
928
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
929
+ ...(input.name === undefined ? {} : { name: input.name }),
864
930
  ...(model === undefined ? {} : { initialModel: model }),
865
931
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
866
932
  });
@@ -902,6 +968,7 @@ export class PiSessionService {
902
968
  ...(model === undefined ? {} : { initialModel: model }),
903
969
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
904
970
  creationProvenance: "tracked-subsession",
971
+ deferAnnouncement: true,
905
972
  });
906
973
  const modelUsed = this.active.get(created.id)?.runtime.session.model;
907
974
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
@@ -915,6 +982,13 @@ export class PiSessionService {
915
982
  await this.registerVerifiedSubsession(link);
916
983
  this.persistSubsessionLink(link);
917
984
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
985
+ const active = this.active.get(created.id);
986
+ if (active === undefined)
987
+ throw new Error("Tracked subsession disappeared during creation");
988
+ // Freshly created children are known by this exact runtime/file link even
989
+ // before Pi flushes its buffered custom entries. Reopened sessions only
990
+ // enter this registry after reciprocal persisted provenance validation.
991
+ this.announceCreatedSession(active, decision.cwd, input.parentSessionFile, this.subsessionLinkForActiveChild(active.runtime.session) !== undefined);
918
992
  await this.prompt(created, input.prompt);
919
993
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
920
994
  return {
@@ -1055,83 +1129,30 @@ export class PiSessionService {
1055
1129
  throw unknownSpawnModelError(modelSpec);
1056
1130
  return model;
1057
1131
  }
1058
- /**
1059
- * Register the question set an agent wants the user to answer as the session's
1060
- * open ask. Deliberately does not wait for the user: `ask_user` terminates the
1061
- * run and the submitted answers come back later as a follow-up message.
1062
- *
1063
- * Rejected question sets throw {@link PendingAskValidationError}, which the
1064
- * agent loop reports to the model as an error tool result.
1065
- */
1066
- // 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.
1067
- async openAsk(input) {
1068
- const result = this.pendingAskStore.open(input);
1069
- // A supersede closes the earlier ask, so the browsers watching it must hear
1070
- // that before they hear about its replacement.
1071
- if (result.superseded !== undefined)
1072
- this.publishAskClosed(input.sessionId, result.superseded);
1073
- this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
1074
- this.publishStatusForSessionId(input.sessionId);
1075
- return result;
1076
- }
1077
- /**
1078
- * Record the user's answers to the session's open ask and hand them to the
1079
- * model. The answers travel as a system-authored custom message rather than a
1080
- * user message, so they are not attributed to the human in the transcript;
1081
- * they still wake an idle session (`triggerTurn`) and queue behind in-flight
1082
- * work (`deliverAs: "followUp"`), which is how the run that `ask_user`
1083
- * terminated continues.
1084
- */
1132
+ /** Reply to a durable question entry, even after later conversation or replies. */
1085
1133
  async submitAsk(ref, askId, submission) {
1086
1134
  await this.assertWritable(ref);
1087
1135
  const session = await this.getOrOpen(ref);
1088
- // Checked before the store closes the ask so a refused delivery cannot
1089
- // discard answers the user already submitted.
1090
1136
  this.assertTreeNavigationInactive(session, "answer questions");
1091
- return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
1137
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1138
+ const outcome = createAskUserOutcome(ask, submission, this.now().toISOString());
1139
+ return this.deliverAskOutcome(session, outcome);
1092
1140
  }
1093
- /**
1094
- * Close the open ask without answers. The model is still told, naming every
1095
- * question as unanswered: it was promised a follow-up message and would
1096
- * otherwise wait for one that never comes.
1097
- */
1141
+ /** Skip is also a transcript reply; it does not invalidate future answers. */
1098
1142
  async cancelAsk(ref, askId) {
1099
1143
  await this.assertWritable(ref);
1100
1144
  const session = await this.getOrOpen(ref);
1101
- this.assertTreeNavigationInactive(session, "dismiss questions");
1102
- return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
1103
- }
1104
- /**
1105
- * Publish and deliver a closed ask. A stale close is reported rather than
1106
- * thrown: losing the race against a supersede, another browser, or a session
1107
- * that went away is ordinary, and the returned status tells the browser what
1108
- * the session's open ask is now.
1109
- */
1110
- async closeAsk(session, result) {
1111
- if (result.status === "stale")
1112
- return { result: "stale", sessionStatus: this.statusFromSession(session) };
1113
- const { outcome } = result;
1114
- this.publishAskClosed(session.sessionId, outcome);
1145
+ this.assertTreeNavigationInactive(session, "skip questions");
1146
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1147
+ return this.deliverAskOutcome(session, createAskUserOutcome(ask, undefined, this.now().toISOString()));
1148
+ }
1149
+ async deliverAskOutcome(session, outcome) {
1150
+ // Pi persists/displays the structured message and wakes an idle agent, or
1151
+ // queues it behind in-flight work. Ordinary entry-mutation protection still
1152
+ // applies, but there is no answer-specific stale/duplicate gate.
1115
1153
  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" }));
1116
1154
  this.publishStatus(session);
1117
- return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
1118
- }
1119
- publishAskClosed(sessionId, outcome) {
1120
- this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
1121
- }
1122
- /**
1123
- * Void the session's open ask because the user sent a chat message instead of
1124
- * answering it. Every browser closes the card as cancelled, and the model is
1125
- * told — without being woken — so the notice rides into the turn the message
1126
- * itself triggers rather than becoming a turn of its own.
1127
- */
1128
- async voidOpenAskForUserMessage(session) {
1129
- const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
1130
- if (outcome === undefined)
1131
- return;
1132
- this.publishAskClosed(session.sessionId, outcome);
1133
- 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" }));
1134
- this.publishStatus(session);
1155
+ return this.statusFromSession(session);
1135
1156
  }
1136
1157
  /**
1137
1158
  * Record the user's answer to an open extension dialog and resolve the
@@ -1280,27 +1301,120 @@ export class PiSessionService {
1280
1301
  const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
1281
1302
  return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
1282
1303
  }
1304
+ /** Collect ready answers before deciding whether the parent needs to wait. */
1305
+ async joinSubsessions(parentSessionId, parentSessionFile, toolCallId) {
1306
+ const subsessions = await this.listSubsessions(parentSessionId, parentSessionFile);
1307
+ this.reconcileSubsessionCompletions(parentSessionId, parentSessionFile);
1308
+ const completions = this.subsessionInbox(parentSessionId, parentSessionFile).pending();
1309
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, completions, toolCallId);
1310
+ return { subsessions, completions };
1311
+ }
1283
1312
  /** Status and final result of a subsession, scoped to the caller's children. */
1284
- async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
1313
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId) {
1285
1314
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1315
+ // Finalize the same completion even if its event/heartbeat notification has
1316
+ // not run yet. Inspection must not leave a future notice armed for this run.
1317
+ this.reconcileSubsessionCompletion(session);
1318
+ const status = this.subsessionStatus(session);
1286
1319
  const messages = historyMessages(session);
1320
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1321
+ if (status !== "working" && completion !== undefined)
1322
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1287
1323
  return {
1288
1324
  sessionId,
1289
1325
  cwd: session.sessionManager.getCwd(),
1290
- status: this.subsessionStatus(session),
1326
+ status,
1291
1327
  finalText: finalAssistantText(messages),
1292
1328
  messageCount: messages.length,
1329
+ ...(status === "working" || completion?.errorDetail === undefined ? {} : { errorDetail: completion.errorDetail }),
1293
1330
  };
1294
1331
  }
1295
- /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
1296
- async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
1332
+ /** Filtered transcript reads acknowledge only the final answer actually returned. */
1333
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId) {
1297
1334
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1298
- const view = buildTranscriptView(historyMessages(session), query);
1335
+ this.reconcileSubsessionCompletion(session);
1336
+ const messages = historyMessages(session);
1337
+ const view = buildTranscriptView(messages, query);
1338
+ const status = this.subsessionStatus(session);
1339
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1340
+ const errorDetail = status === "working" ? undefined : completion?.errorDetail;
1341
+ if (status !== "working" && completion !== undefined
1342
+ && (transcriptReturnsFinalOutput(messages, view, completion.finalText) || (completion.finalText === "" && errorDetail !== undefined))) {
1343
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1344
+ }
1345
+ return { sessionId, cwd: session.sessionManager.getCwd(), status, ...view, ...(errorDetail === undefined ? {} : { errorDetail }) };
1346
+ }
1347
+ subsessionInbox(parentSessionId, parentSessionFile) {
1348
+ const file = nonEmptyString(parentSessionFile) ?? nonEmptyString(this.active.get(parentSessionId)?.runtime.session.sessionFile);
1349
+ const key = subsessionHydratedParentKey(parentSessionId, file);
1350
+ let inbox = this.subsessionInboxes.get(key);
1351
+ if (inbox === undefined) {
1352
+ inbox = new SubsessionCompletionInbox();
1353
+ this.subsessionInboxes.set(key, inbox);
1354
+ }
1355
+ return inbox;
1356
+ }
1357
+ restoreSubsessionReceipts(session, releaseClaims = true) {
1358
+ this.subsessionInbox(session.sessionId, session.sessionFile).restoreReceipts(session.sessionManager.getBranch(), releaseClaims);
1359
+ }
1360
+ subsessionCompletionForOutput(parentId, parentFile, child, messages) {
1361
+ const branch = persistedSubsessionCompletions(child.sessionManager.getBranch()).filter(({ sessionId }) => sessionId === child.sessionId);
1362
+ // A child can be rewound independently of its parent. Never acknowledge an
1363
+ // abandoned branch's newer result while returning the current branch's text.
1364
+ const completion = branch.at(-1) ?? (child.sessionManager.appendCustomEntry === undefined
1365
+ ? this.subsessionInbox(parentId, parentFile).latest(child.sessionId) : undefined);
1366
+ return completion?.finalText === finalAssistantText(messages) ? completion : undefined;
1367
+ }
1368
+ receiveSubsessionCompletions(parentId, parentFile, completions, toolCallId) {
1369
+ if (completions.length === 0)
1370
+ return;
1371
+ const parent = this.active.get(parentId)?.runtime.session;
1372
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile))) {
1373
+ throw new Error(`Parent session ${parentId} is not available to receive subsession results`);
1374
+ }
1375
+ const inbox = this.subsessionInbox(parentId, parentFile);
1376
+ const completionIds = completions.map(({ completionId }) => completionId).filter((id) => !inbox.hasReceived(id));
1377
+ if (completionIds.length === 0)
1378
+ return;
1379
+ // Persist before removing unread results. A storage failure leaves the
1380
+ // answer unread and fails the tool instead of losing the notification.
1381
+ this.assertTreeNavigationInactive(parent, "receive subsession results");
1382
+ parent.sessionManager.appendCustomEntry?.(SUBSESSION_RECEIPT_ENTRY_TYPE, { completionIds, ...(toolCallId === undefined ? {} : { toolCallId }) });
1383
+ inbox.receive(completionIds);
1384
+ }
1385
+ reconcileSubsessionCompletions(parentId, parentFile) {
1386
+ for (const childId of this.subsessionChildren.get(parentId) ?? []) {
1387
+ if (!this.subsessionLinkBelongsToParent(parentId, nonEmptyString(parentFile), childId))
1388
+ continue;
1389
+ const link = this.subsessionLinks.get(childId);
1390
+ const child = link === undefined ? undefined : this.activeChildForSubsessionLink(link)?.runtime.session;
1391
+ if (child !== undefined)
1392
+ this.reconcileSubsessionCompletion(child);
1393
+ }
1394
+ }
1395
+ reconcileSubsessionCompletion(session) {
1396
+ this.updateSubsessionTracking(session);
1397
+ if (!this.hasActiveWork(session) && this.subsessionNotifyArmed.get(session.sessionId) === true) {
1398
+ throw new Error(`Could not record completion of subsession ${session.sessionId}; its result remains unread. Retry after fixing session storage.`);
1399
+ }
1400
+ }
1401
+ /** Claims commit on entry_appended (boundary drafts) or message_end (idle wakes). */
1402
+ async prepareSubsessionDelivery(parentId, parentFile) {
1403
+ await this.hydrateSubsessionsForParent(parentId, nonEmptyString(parentFile));
1404
+ const parent = this.active.get(parentId)?.runtime.session;
1405
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile)))
1406
+ return undefined;
1407
+ // Tool receipts are intents until the successful (possibly nested) result
1408
+ // is persisted. Reconcile those intents without releasing in-flight drafts.
1409
+ this.restoreSubsessionReceipts(parent, false);
1410
+ this.reconcileSubsessionCompletions(parentId, parentFile);
1411
+ const completions = this.subsessionInbox(parentId, parentFile).claim();
1412
+ if (completions.length === 0)
1413
+ return undefined;
1299
1414
  return {
1300
- sessionId,
1301
- cwd: session.sessionManager.getCwd(),
1302
- status: this.subsessionStatus(session),
1303
- ...view,
1415
+ type: "custom_message", customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1416
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(parentId)),
1417
+ details: { sessionId: completions[0]?.sessionId, completionIds: completions.map(({ completionId }) => completionId) },
1304
1418
  };
1305
1419
  }
1306
1420
  /** Open a session after verifying it is one of the caller's tracked children. */
@@ -1354,6 +1468,14 @@ export class PiSessionService {
1354
1468
  this.subsessionLinks.set(childSessionId, link);
1355
1469
  if (!this.subsessionNotifyArmed.has(childSessionId))
1356
1470
  this.subsessionNotifyArmed.set(childSessionId, false);
1471
+ const childManager = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager
1472
+ ?? (link.childSessionFile === undefined ? undefined : this.sessionManager.open(link.childSessionFile));
1473
+ if (childManager !== undefined) {
1474
+ for (const completion of persistedSubsessionCompletions(childManager.getEntries?.() ?? childManager.getBranch())) {
1475
+ if (completion.sessionId === childSessionId)
1476
+ this.subsessionInbox(parentSessionId, link.parentSessionFile).record(completion);
1477
+ }
1478
+ }
1357
1479
  const cwd = this.cwdForVerifiedSubsession(link);
1358
1480
  await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
1359
1481
  }
@@ -1378,6 +1500,8 @@ export class PiSessionService {
1378
1500
  this.subsessionParents.delete(childSessionId);
1379
1501
  this.subsessionLinks.delete(childSessionId);
1380
1502
  this.subsessionNotifyArmed.delete(childSessionId);
1503
+ for (const inbox of this.subsessionInboxes.values())
1504
+ inbox.forgetChild(childSessionId);
1381
1505
  if (parentSessionId === undefined)
1382
1506
  return;
1383
1507
  const children = this.subsessionChildren.get(parentSessionId);
@@ -1534,7 +1658,7 @@ export class PiSessionService {
1534
1658
  * working. Prompt failures before agent_start explicitly arm the same latch.
1535
1659
  * Standalone commands and compactions must not arm a completion notice.
1536
1660
  */
1537
- updateSubsessionTracking(session, agentStarted = false) {
1661
+ updateSubsessionTracking(session, agentStarted = false, agentSettled = false) {
1538
1662
  const link = this.subsessionLinkForActiveChild(session);
1539
1663
  if (link === undefined)
1540
1664
  return;
@@ -1543,22 +1667,31 @@ export class PiSessionService {
1543
1667
  this.subsessionNotifyArmed.set(childId, true);
1544
1668
  return;
1545
1669
  }
1546
- if (this.hasActiveWork(session))
1670
+ // Native settlement is authoritative even while the host prompt Promise's
1671
+ // mutation receipt is unwinding. Capture now, before another run can start.
1672
+ if (!agentSettled && this.hasActiveWork(session))
1547
1673
  return;
1548
1674
  if (this.subsessionNotifyArmed.get(childId) !== true)
1549
1675
  return;
1550
- this.subsessionNotifyArmed.set(childId, false);
1551
1676
  const activity = this.activities.get(childId);
1552
- const status = activity?.phase === "error" ? "error" : "idle";
1553
- const errorSection = status === "error" && activity?.detail !== undefined ? `Error: ${activity.detail}\n\n` : "";
1554
- const finalText = finalAssistantText(historyMessages(session));
1555
- const outputSection = formatSubsessionNotificationOutput(childId, finalText);
1556
- const workingIds = this.workingSubsessionIds(link.parentSessionId);
1557
- const next = workingIds.length === 0
1558
- ? "No other tracked subsessions are working."
1559
- : `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.`;
1560
- const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${errorSection}${outputSection}`;
1561
- void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
1677
+ const messages = historyMessages(session);
1678
+ const completion = {
1679
+ completionId: randomUUID(), sessionId: childId, cwd: session.sessionManager.getCwd(),
1680
+ status: activity?.phase === "error" ? "error" : "idle",
1681
+ finalText: finalAssistantText(messages), messageCount: messages.length,
1682
+ ...(activity?.phase !== "error" || activity.detail === undefined ? {} : { errorDetail: activity.detail }),
1683
+ };
1684
+ try {
1685
+ session.sessionManager.appendCustomEntry?.(SUBSESSION_COMPLETION_ENTRY_TYPE, completion);
1686
+ }
1687
+ catch (error) {
1688
+ // Keep the latch armed so a later reconciliation can retry persistence.
1689
+ this.logSubsessionNotificationFailure(link.parentSessionId, childId, error);
1690
+ return;
1691
+ }
1692
+ this.subsessionNotifyArmed.set(childId, false);
1693
+ this.subsessionInbox(link.parentSessionId, link.parentSessionFile).record(completion);
1694
+ void this.wakeParentForSubsession(link.parentSessionId, childId);
1562
1695
  }
1563
1696
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
1564
1697
  const link = this.subsessionLinks.get(childSessionId);
@@ -1576,31 +1709,60 @@ export class PiSessionService {
1576
1709
  const sessionManager = this.sessionManager.open(parentSessionFile);
1577
1710
  return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
1578
1711
  }
1579
- /**
1580
- * Deliver a subsession-completion notice to the parent as a system-authored
1581
- * custom message rather than a user message, so it is not attributed to the
1582
- * human in the transcript. It still wakes an idle parent (`triggerTurn`) and
1583
- * queues behind in-flight work (`deliverAs: "followUp"`), preserving the
1584
- * established "queue if busy, send and act if idle" behavior.
1585
- */
1586
- async notifyParentOfSubsession(parentId, childId, text) {
1712
+ async wakeParentForSubsession(parentId, childId) {
1587
1713
  try {
1588
1714
  const session = await this.getOrOpenParentForSubsession(parentId, childId);
1589
- if (this.treeNavigations.has(session)) {
1590
- const pending = this.deferredSubsessionNotifications.get(session) ?? [];
1591
- pending.push({ parentId, childId, text });
1592
- this.deferredSubsessionNotifications.set(session, pending);
1593
- return;
1594
- }
1595
- await this.deliverSubsessionNotification(session, { parentId, childId, text });
1715
+ this.scheduleSubsessionDelivery(session);
1596
1716
  }
1597
1717
  catch (error) {
1598
1718
  this.logSubsessionNotificationFailure(parentId, childId, error);
1599
1719
  }
1600
1720
  }
1601
- async deliverSubsessionNotification(session, notification) {
1602
- 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" }));
1603
- this.publishStatus(session);
1721
+ /** Busy parents keep results in the inbox; never enqueue a second copy in Pi. */
1722
+ async deliverPendingSubsessionCompletions(session) {
1723
+ if (this.hasActiveWork(session) || !this.isCurrentActiveSession(session) || this.subsessionStoppedParents.has(session))
1724
+ return;
1725
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
1726
+ const completions = inbox.claim();
1727
+ if (completions.length === 0)
1728
+ return;
1729
+ const completionIds = completions.map(({ completionId }) => completionId);
1730
+ this.subsessionDeliveringParents.add(session);
1731
+ let delivered = false;
1732
+ try {
1733
+ await this.runSessionEntryMutation(session, "deliver subsession results", () => session.sendCustomMessage({
1734
+ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1735
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(session.sessionId)),
1736
+ details: { sessionId: completions[0]?.sessionId, completionIds },
1737
+ }, { triggerTurn: true }));
1738
+ inbox.receive(completionIds);
1739
+ delivered = true;
1740
+ }
1741
+ catch (error) {
1742
+ this.subsessionStoppedParents.add(session);
1743
+ throw error;
1744
+ }
1745
+ finally {
1746
+ inbox.release(completionIds);
1747
+ this.subsessionDeliveringParents.delete(session);
1748
+ if (delivered)
1749
+ this.scheduleSubsessionDelivery(session);
1750
+ this.publishStatus(session);
1751
+ }
1752
+ }
1753
+ scheduleSubsessionDelivery(session) {
1754
+ if (this.subsessionDeliveryScheduled.has(session) || this.subsessionInbox(session.sessionId, session.sessionFile).pending().length === 0)
1755
+ return;
1756
+ this.subsessionDeliveryScheduled.add(session);
1757
+ // Settlement can precede the host prompt-mutation receipt's finally block.
1758
+ // Recheck on the next tick, closing the yield/child-completion race without
1759
+ // restarting a stopped run or putting rendered answers in a follow-up queue.
1760
+ setTimeout(() => {
1761
+ this.subsessionDeliveryScheduled.delete(session);
1762
+ void this.deliverPendingSubsessionCompletions(session).catch((error) => {
1763
+ this.logSubsessionNotificationFailure(session.sessionId, "inbox", error);
1764
+ });
1765
+ }, 0);
1604
1766
  }
1605
1767
  logSubsessionNotificationFailure(parentId, childId, error) {
1606
1768
  this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
@@ -1909,11 +2071,6 @@ export class PiSessionService {
1909
2071
  this.publishStatus(session);
1910
2072
  return;
1911
2073
  }
1912
- // A chat message answers the session's open ask in the user's own words, so
1913
- // the form is void: keeping it open would invite answers to questions the
1914
- // conversation has already moved past. Ignored duplicates skip this on
1915
- // purpose: they must not void an ask posted after the queued original.
1916
- await this.voidOpenAskForUserMessage(session);
1917
2074
  if (session.isCompacting) {
1918
2075
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1919
2076
  return;
@@ -1923,7 +2080,7 @@ export class PiSessionService {
1923
2080
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
1924
2081
  return this.beginPromptSubmission(session, text, behavior, images, echoUserMessage).catch(() => undefined);
1925
2082
  }
1926
- beginPromptSubmission(session, text, behavior, images = [], echoUserMessage = true) {
2083
+ beginPromptSubmission(session, text, behavior, images = [], echoUserMessage = true, expandPromptTemplates = true) {
1927
2084
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1928
2085
  const echoes = this.pendingPromptEchoes.get(session) ?? [];
1929
2086
  const userCount = historyMessagesFromEntries(session.sessionManager.getBranch()).filter((message) => isRecord(message) && message["role"] === "user").length;
@@ -1936,8 +2093,8 @@ export class PiSessionService {
1936
2093
  this.pendingPromptEchoes.set(session, [...echoes, echo]);
1937
2094
  this.events.publish(session.sessionId, { type: "message.append", message: this.browserTranscriptMessage(session, echo.message) }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
1938
2095
  }
1939
- const promptOptions = buildPromptOptions(behavior, images);
1940
- const commandName = text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
2096
+ const promptOptions = expandPromptTemplates ? buildPromptOptions(behavior, images) : { ...buildPromptOptions(behavior, images), expandPromptTemplates: false };
2097
+ const commandName = expandPromptTemplates && text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
1941
2098
  const trackEarlyFailure = behavior === undefined
1942
2099
  && this.subsessionLinkForActiveChild(session) !== undefined
1943
2100
  && !session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
@@ -1946,7 +2103,12 @@ export class PiSessionService {
1946
2103
  if (getString(event, "type") === "agent_start")
1947
2104
  agentStarted = true;
1948
2105
  });
1949
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).finally(() => {
2106
+ const submission = { session, active: true };
2107
+ const isExtensionCommand = session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
2108
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => isExtensionCommand
2109
+ ? this.extensionCommandExecution.run({ submission }, () => session.prompt(text, promptOptions))
2110
+ : session.prompt(text, promptOptions)).finally(() => {
2111
+ submission.active = false;
1950
2112
  unsubscribe?.();
1951
2113
  if (echo !== undefined) {
1952
2114
  const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
@@ -1957,6 +2119,7 @@ export class PiSessionService {
1957
2119
  }
1958
2120
  });
1959
2121
  void promptPromise.catch((error) => {
2122
+ this.subsessionStoppedParents.add(session);
1960
2123
  const message = error instanceof Error ? error.message : String(error);
1961
2124
  this.publishActivity(session, "error", "error", message);
1962
2125
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -2099,15 +2262,20 @@ export class PiSessionService {
2099
2262
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
2100
2263
  }
2101
2264
  async navigateTree(ref, request) {
2102
- if (request.targetId.trim() === "")
2265
+ return this.navigateSessionTree(ref, request.targetId, request.expectedLeafId, sessionTreeNavigationOptions(request), undefined, request.retainCheckpoint);
2266
+ }
2267
+ async navigateSessionTree(ref, targetId, expectedLeafId, options, expectedSession, retainCheckpoint = false) {
2268
+ if (targetId.trim() === "")
2103
2269
  throw new Error("Session tree target is required");
2104
2270
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
2105
2271
  throw new Error("Stop current session activity before navigating the session tree");
2106
2272
  }
2107
2273
  await this.assertWritable(ref);
2108
- const options = sessionTreeNavigationOptions(request);
2109
2274
  const session = await this.getOrOpen(ref);
2110
- if (typeof session.navigateTree !== "function")
2275
+ if (expectedSession !== undefined && session !== expectedSession)
2276
+ throw new Error("This extension command belongs to an unavailable session runtime");
2277
+ const navigateTree = session.navigateTree?.bind(session);
2278
+ if (navigateTree === undefined)
2111
2279
  throw new Error("Session tree navigation is not supported by this Pi runtime");
2112
2280
  if (this.hasActiveWork(session))
2113
2281
  throw new Error("Stop current session activity before navigating the session tree");
@@ -2116,13 +2284,22 @@ export class PiSessionService {
2116
2284
  this.treeNavigations.add(session);
2117
2285
  try {
2118
2286
  const oldLeafId = session.sessionManager.getLeafId();
2119
- if (oldLeafId !== request.expectedLeafId) {
2287
+ if (oldLeafId !== expectedLeafId) {
2120
2288
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
2121
2289
  }
2122
- const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
2123
- this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
2290
+ if (retainCheckpoint)
2291
+ targetId = messageHistoryTargetId(session.sessionManager.getBranch(), targetId);
2292
+ const checkpointTail = retainCheckpoint ? session.sessionManager.getBranch().find((entry) => isRecord(entry) && entry["id"] === targetId) : undefined;
2293
+ const retainCustomMessage = isRecord(checkpointTail) && checkpointTail["type"] === "custom_message";
2294
+ if (retainCustomMessage && options.summarize === true) {
2295
+ throw new Error("Retaining a custom-message checkpoint requires navigation without a branch summary");
2296
+ }
2297
+ const activeEditableTargetParentId = retainCustomMessage ? undefined : activeEditableTreeTargetParentId(session.sessionManager, targetId, oldLeafId);
2298
+ this.publishActivity(session, options.summarize === true ? "summarizing branch" : "navigating session tree", "active");
2124
2299
  this.publishStatus(session);
2125
- let result = await session.navigateTree(request.targetId, options);
2300
+ let result = retainCustomMessage
2301
+ ? await navigateTreeRetainingCustomMessage(session.sessionManager, targetId, checkpointTail["parentId"], () => navigateTree(targetId, options))
2302
+ : await navigateTree(targetId, options);
2126
2303
  if (activeEditableTargetParentId !== undefined
2127
2304
  && !result.cancelled
2128
2305
  && result.editorText === undefined
@@ -2134,14 +2311,14 @@ export class PiSessionService {
2134
2311
  // extraction, agent-context rebuilding, and tree extension events.
2135
2312
  setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
2136
2313
  try {
2137
- result = await session.navigateTree(request.targetId, options);
2314
+ result = await navigateTree(targetId, options);
2138
2315
  }
2139
2316
  catch (error) {
2140
- session.sessionManager.branch(request.targetId);
2317
+ session.sessionManager.branch(targetId);
2141
2318
  throw error;
2142
2319
  }
2143
2320
  if (result.cancelled)
2144
- session.sessionManager.branch(request.targetId);
2321
+ session.sessionManager.branch(targetId);
2145
2322
  }
2146
2323
  if (result.cancelled) {
2147
2324
  if (this.isCurrentActiveSession(session)) {
@@ -2149,13 +2326,6 @@ export class PiSessionService {
2149
2326
  }
2150
2327
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
2151
2328
  }
2152
- if (session.sessionManager.getLeafId() !== oldLeafId) {
2153
- // Questions belong to the position we left. Close them for every browser
2154
- // without appending a cancellation message or waking the rewound session.
2155
- const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
2156
- if (outcome !== undefined)
2157
- this.publishAskClosed(session.sessionId, outcome);
2158
- }
2159
2329
  if (result.summaryEntry !== undefined) {
2160
2330
  // A summary entry durably identifies the selected branch as the file's
2161
2331
  // newest leaf, superseding any earlier bare selection.
@@ -2169,6 +2339,8 @@ export class PiSessionService {
2169
2339
  this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
2170
2340
  }
2171
2341
  }
2342
+ this.setSuggestedInput(session.sessionId, result.editorText);
2343
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2172
2344
  if (this.isCurrentActiveSession(session))
2173
2345
  this.publishActivity(session, "session tree navigated", "idle");
2174
2346
  return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
@@ -2189,7 +2361,6 @@ export class PiSessionService {
2189
2361
  }
2190
2362
  else {
2191
2363
  this.deferredGeneratedSessionNames.delete(session);
2192
- this.deferredSubsessionNotifications.delete(session);
2193
2364
  }
2194
2365
  }
2195
2366
  }
@@ -2200,6 +2371,9 @@ export class PiSessionService {
2200
2371
  * about to join rather than the forked-from record.
2201
2372
  */
2202
2373
  async forkFromTree(ref, request) {
2374
+ return this.forkSessionTree(ref, request);
2375
+ }
2376
+ async forkSessionTree(ref, request, options, expectedSession) {
2203
2377
  if (request.entryId.trim() === "")
2204
2378
  throw new Error("Session tree entry is required");
2205
2379
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
@@ -2207,6 +2381,8 @@ export class PiSessionService {
2207
2381
  }
2208
2382
  await this.assertWritable(ref);
2209
2383
  const session = await this.getOrOpen(ref);
2384
+ if (expectedSession !== undefined && session !== expectedSession)
2385
+ throw new Error("This extension command belongs to an unavailable session runtime");
2210
2386
  if (this.hasActiveWork(session))
2211
2387
  throw new Error("Stop current session activity before forking the session tree");
2212
2388
  if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
@@ -2215,8 +2391,15 @@ export class PiSessionService {
2215
2391
  this.publishActivity(session, "forking session from entry", "active");
2216
2392
  this.publishStatus(session);
2217
2393
  try {
2218
- const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
2394
+ // forkEntry rechecks this same leaf after asynchronous naming and inside
2395
+ // the replacement gate, so the resolved branch cutoff cannot go stale.
2396
+ const entryId = request.retainCheckpoint === true
2397
+ ? messageHistoryTargetId(session.sessionManager.getBranch(), request.entryId)
2398
+ : request.entryId;
2399
+ const result = await this.commandService.forkEntry(session.sessionId, entryId, {
2400
+ ...options,
2219
2401
  expectedLeafId: request.expectedLeafId,
2402
+ expectedSession: session,
2220
2403
  });
2221
2404
  if (result.type === "unsupported")
2222
2405
  throw new Error(result.message);
@@ -2229,6 +2412,8 @@ export class PiSessionService {
2229
2412
  }
2230
2413
  return { cancelled: true };
2231
2414
  }
2415
+ this.setSuggestedInput(result.session.id, result.promptDraft);
2416
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2232
2417
  const forkedSession = this.active.get(result.session.id)?.runtime.session;
2233
2418
  if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
2234
2419
  this.publishActivity(forkedSession, "session forked", "idle");
@@ -2277,9 +2462,16 @@ export class PiSessionService {
2277
2462
  this.publishActivity(session, "reloading resources", "active");
2278
2463
  const priorGeneration = this.notificationGenerationBySession.get(session);
2279
2464
  let candidateGeneration;
2465
+ const reloadBoundary = { beforeSessionStart: false };
2280
2466
  try {
2281
- await session.reload(priorGeneration === undefined ? undefined : {
2467
+ await session.reload({
2282
2468
  beforeSessionStart: () => {
2469
+ // The SDK invalidated the old runner. Settle its dialogs before
2470
+ // replacement session_start handlers can open new ones.
2471
+ this.endSessionExtensionDialogs(session.sessionId);
2472
+ reloadBoundary.beforeSessionStart = true;
2473
+ if (priorGeneration === undefined)
2474
+ return;
2283
2475
  candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2284
2476
  this.notificationGenerationBySession.set(session, candidateGeneration);
2285
2477
  this.replaceSessionNotificationContext(session, candidateGeneration);
@@ -2292,6 +2484,10 @@ export class PiSessionService {
2292
2484
  this.publishStatus(session);
2293
2485
  }
2294
2486
  catch (error) {
2487
+ // Settings/resource loading can fail after SDK invalidation but
2488
+ // before the hook. Never sweep replacement session_start dialogs.
2489
+ if (!reloadBoundary.beforeSessionStart)
2490
+ this.endSessionExtensionDialogs(session.sessionId);
2295
2491
  if (candidateGeneration !== undefined) {
2296
2492
  this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2297
2493
  this.notificationGenerationBySession.set(session, candidateGeneration);
@@ -2400,7 +2596,7 @@ export class PiSessionService {
2400
2596
  async archiveTree(ref) {
2401
2597
  const session = await this.getOrOpen(ref);
2402
2598
  const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
2403
- const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2599
+ const root = findArchiveCandidateByExactId(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2404
2600
  const plan = planSessionArchiveTree(root, catalog);
2405
2601
  const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
2406
2602
  if (busy !== undefined)
@@ -2526,6 +2722,124 @@ export class PiSessionService {
2526
2722
  this.unregisterSubsession(session.sessionId);
2527
2723
  await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
2528
2724
  }
2725
+ async mutateQueueMessage(ref, input) {
2726
+ const mutation = parseQueueMessageMutation(input);
2727
+ await this.assertWritable(ref);
2728
+ const session = await this.getOrOpen(ref);
2729
+ // Compare the entire execution-order queue after all awaits, then mutate
2730
+ // synchronously so neither another request nor the agent loop can interleave.
2731
+ const current = this.queuedMessages(session);
2732
+ if (this.queueCompatibility.reason(session) !== undefined) {
2733
+ this.publishStatus(session);
2734
+ this.queueCompatibility.assertAvailable(session);
2735
+ }
2736
+ if (current.length !== mutation.expectedQueue.length || current.some((entry, index) => {
2737
+ const expected = mutation.expectedQueue[index];
2738
+ if (expected === undefined)
2739
+ return true;
2740
+ return entry.kind !== expected.kind || entry.text !== expected.text
2741
+ || (mutation.action === "edit" && index === mutation.index && (entry.hasAttachments === true) !== (expected.hasAttachments === true));
2742
+ }))
2743
+ throw new Error("Queued messages changed; refresh the queue and try again");
2744
+ if (mutation.index >= current.length)
2745
+ throw new Error("Invalid queue index");
2746
+ try {
2747
+ this.queueCompatibility.run(session, () => { this.applyQueueMessageMutation(session, mutation); });
2748
+ }
2749
+ catch (error) {
2750
+ this.publishStatus(session);
2751
+ throw error;
2752
+ }
2753
+ this.publishStatus(session);
2754
+ return this.statusFromSession(session);
2755
+ }
2756
+ applyQueueMessageMutation(session, mutation) {
2757
+ const agent = mutableQueueAgent(session.agent);
2758
+ const mirrors = { steer: mutableTextMirror(session.getSteeringMessages()), followUp: mutableTextMirror(session.getFollowUpMessages()) };
2759
+ if (session.getSteeringMessages() !== mirrors.steer || session.getFollowUpMessages() !== mirrors.followUp) {
2760
+ throw new Error("SDK queue getters no longer expose stable backing arrays");
2761
+ }
2762
+ const queues = snapshotAgentQueues(agent);
2763
+ const originalQueues = { steer: [...queues.steer], followUp: [...queues.followUp] };
2764
+ const originalMirrors = { steer: [...mirrors.steer], followUp: [...mirrors.followUp] };
2765
+ const held = this.compactionQueuedMessages(session.sessionId).map((prompt) => ({ ...prompt }));
2766
+ const entries = ["steer", "followUp"].flatMap((kind) => [
2767
+ ...mirroredQueueEntries(queues[kind], mirrors[kind]).map((entry) => ({ ...entry, kind })),
2768
+ ...held.filter((prompt) => prompt.kind === kind).map((prompt) => ({ kind, text: prompt.text, prompt })),
2769
+ ]);
2770
+ const selected = entries[mutation.index];
2771
+ if (selected === undefined)
2772
+ throw new Error("Invalid queue index");
2773
+ const removed = mutation.action === "edit" ? entries.slice(mutation.index) : [selected];
2774
+ const remaining = entries.filter((entry) => !removed.includes(entry));
2775
+ for (const entry of removed) {
2776
+ if (entry.message !== undefined) {
2777
+ const queue = queues[entry.kind];
2778
+ queue.splice(queue.indexOf(entry.message), 1);
2779
+ }
2780
+ if (entry.prompt !== undefined)
2781
+ held.splice(held.indexOf(entry.prompt), 1);
2782
+ }
2783
+ if (mutation.action === "toggle") {
2784
+ const kind = selected.kind === "steer" ? "followUp" : "steer";
2785
+ // Destination includes held prompts: append there when necessary to keep
2786
+ // status order and eventual execution order identical.
2787
+ if (selected.prompt !== undefined) {
2788
+ selected.prompt.kind = kind;
2789
+ held.push(selected.prompt);
2790
+ remaining.push({ ...selected, kind });
2791
+ }
2792
+ else if (selected.message !== undefined) {
2793
+ if (held.some((prompt) => prompt.kind === kind)) {
2794
+ const prompt = { kind, text: selected.text, message: selected.message };
2795
+ held.push(prompt);
2796
+ remaining.push({ kind, text: selected.text, prompt });
2797
+ }
2798
+ else {
2799
+ queues[kind].push(selected.message);
2800
+ remaining.push({ ...selected, kind });
2801
+ }
2802
+ }
2803
+ }
2804
+ try {
2805
+ agent.clearAllQueues();
2806
+ for (const message of queues.steer)
2807
+ agent.steer(message);
2808
+ for (const message of queues.followUp)
2809
+ agent.followUp(message);
2810
+ for (const kind of ["steer", "followUp"]) {
2811
+ mirrors[kind].splice(0, mirrors[kind].length, ...remaining.filter((entry) => entry.kind === kind && entry.message !== undefined).map((entry) => entry.text));
2812
+ }
2813
+ }
2814
+ catch (error) {
2815
+ // Best-effort rollback when a callable SDK method fails during replacement.
2816
+ // Restoration itself can fail after an incompatible behavioral change.
2817
+ const failures = [error];
2818
+ try {
2819
+ agent.clearAllQueues();
2820
+ for (const message of originalQueues.steer)
2821
+ agent.steer(message);
2822
+ for (const message of originalQueues.followUp)
2823
+ agent.followUp(message);
2824
+ }
2825
+ catch (restoreError) {
2826
+ failures.push(restoreError);
2827
+ }
2828
+ for (const kind of ["steer", "followUp"]) {
2829
+ try {
2830
+ mirrors[kind].splice(0, mirrors[kind].length, ...originalMirrors[kind]);
2831
+ }
2832
+ catch (restoreError) {
2833
+ failures.push(restoreError);
2834
+ }
2835
+ }
2836
+ throw new AggregateError(failures, "Could not replace SDK queues; attempted to restore their original contents", { cause: error });
2837
+ }
2838
+ if (held.length === 0)
2839
+ this.clearCompactionPromptQueue(session.sessionId);
2840
+ else
2841
+ this.compactionPromptQueues.set(session.sessionId, held);
2842
+ }
2529
2843
  async clearQueue(ref) {
2530
2844
  await this.assertWritable(ref);
2531
2845
  const session = await this.getOrOpen(ref);
@@ -2546,6 +2860,7 @@ export class PiSessionService {
2546
2860
  return;
2547
2861
  const sessionId = active.runtime.session.sessionId;
2548
2862
  this.clearCompactionPromptQueue(sessionId);
2863
+ this.subsessionStoppedParents.add(active.runtime.session);
2549
2864
  clearSessionQueue(active.runtime.session);
2550
2865
  // Settle run-scoped dialogs now, at abort-request time: pi's agent loop
2551
2866
  // waits for a parked `tool_call` dialog handler before it can emit
@@ -2716,6 +3031,7 @@ export class PiSessionService {
2716
3031
  if (pendingOpens.length > 0)
2717
3032
  await Promise.allSettled(pendingOpens);
2718
3033
  const active = this.active.get(sessionId);
3034
+ this.suggestedInputs.delete(sessionId);
2719
3035
  if (notificationPolicy.kind === "clear") {
2720
3036
  const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
2721
3037
  const mutations = generation === undefined
@@ -2726,11 +3042,8 @@ export class PiSessionService {
2726
3042
  if (!active)
2727
3043
  return;
2728
3044
  this.forgetUnreadActivity(active.runtime.session);
2729
- // An open ask is meaningful only while the runtime that posted it exists: no
2730
- // one is left to receive the answers, so it is dropped without an outcome.
2731
- this.pendingAskStore.forgetSession(sessionId);
2732
- // Open dialogs share that stance, but their extension waiters are parked
2733
- // Promises inside the dying runtime: settle them rather than dropping them.
3045
+ // Extension dialog waiters are parked Promises inside the dying runtime:
3046
+ // settle them rather than dropping them.
2734
3047
  this.endSessionExtensionDialogs(sessionId);
2735
3048
  this.active.delete(sessionId);
2736
3049
  this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
@@ -2908,26 +3221,12 @@ export class PiSessionService {
2908
3221
  return { id: sessionId, cwd: active.runtime.cwd };
2909
3222
  }
2910
3223
  activeForRef(ref) {
2911
- const sessionId = ref.id;
2912
- const exact = this.active.get(sessionId);
2913
- if (exact !== undefined && refMatchesActiveSession(ref, exact))
2914
- return exact;
2915
- for (const [candidateId, active] of this.active.entries()) {
2916
- if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
2917
- return active;
2918
- }
2919
- return undefined;
3224
+ const active = this.active.get(ref.id);
3225
+ return active !== undefined && refMatchesActiveSession(ref, active) ? active : undefined;
2920
3226
  }
2921
3227
  startupSessionForRef(ref) {
2922
- const sessionId = ref.id;
2923
- const exact = this.startupSessions.get(sessionId);
2924
- if (exact !== undefined && refMatchesStartupSession(ref, exact))
2925
- return exact;
2926
- for (const [candidateId, session] of this.startupSessions.entries()) {
2927
- if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
2928
- return session;
2929
- }
2930
- return undefined;
3228
+ const session = this.startupSessions.get(ref.id);
3229
+ return session !== undefined && refMatchesStartupSession(ref, session) ? session : undefined;
2931
3230
  }
2932
3231
  /**
2933
3232
  * The session to serve a read-only status or a dialog close for, while it
@@ -3036,7 +3335,13 @@ export class PiSessionService {
3036
3335
  // holds for this session; settle those waits before the new
3037
3336
  // runtime's extensions can open fresh dialogs under the same id.
3038
3337
  this.endSessionExtensionDialogs(boundSession.sessionId);
3338
+ const changedIdentity = boundSession.sessionId !== session.sessionId;
3039
3339
  boundSession = session;
3340
+ // Publish existence as soon as status/dialog APIs can address it,
3341
+ // before session_start or arbitrary withSession code can park on a question.
3342
+ this.startupSessions.set(session.sessionId, session);
3343
+ if (changedIdentity)
3344
+ this.announceCreatedSession(active, session.sessionManager.getCwd());
3040
3345
  await this.bindSessionExtensions(session, candidateGeneration);
3041
3346
  if (candidateGeneration !== undefined) {
3042
3347
  this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
@@ -3111,6 +3416,7 @@ export class PiSessionService {
3111
3416
  await session.bindExtensions({
3112
3417
  uiContext,
3113
3418
  mode: "rpc",
3419
+ commandContextActions: this.extensionCommandActions(session),
3114
3420
  onError: (error) => {
3115
3421
  const message = `${error.extensionPath}: ${error.error}`;
3116
3422
  this.publishActivity(session, "extension error", "error", message);
@@ -3125,6 +3431,83 @@ export class PiSessionService {
3125
3431
  this.startupSessions.delete(session.sessionId);
3126
3432
  }
3127
3433
  }
3434
+ extensionCommandActions(session) {
3435
+ const ref = { id: session.sessionId, cwd: session.sessionManager.getCwd() };
3436
+ return {
3437
+ waitForIdle: () => {
3438
+ this.assertCurrentExtensionSession(session);
3439
+ return session.waitForIdle();
3440
+ },
3441
+ navigateTree: (targetId, options) => {
3442
+ const expectedLeafId = session.sessionManager.getLeafId();
3443
+ return this.runExtensionSessionControl(session, async () => {
3444
+ const result = await this.navigateSessionTree(ref, targetId, expectedLeafId, options ?? { summarize: false }, session);
3445
+ return { cancelled: result.cancelled };
3446
+ });
3447
+ },
3448
+ fork: (entryId, options) => {
3449
+ const expectedLeafId = session.sessionManager.getLeafId();
3450
+ const withSession = options?.withSession;
3451
+ let completeReplacement;
3452
+ const forkOptions = {
3453
+ ...options,
3454
+ position: options?.position ?? "before",
3455
+ // Capture Pi's fresh context, but run user code after the host's
3456
+ // replacement reservation is released so fresh controls can work.
3457
+ ...(withSession === undefined ? {} : { withSession: (ctx) => {
3458
+ completeReplacement = () => withSession(ctx);
3459
+ return Promise.resolve();
3460
+ } }),
3461
+ };
3462
+ return this.runExtensionSessionControl(session, async () => {
3463
+ const runtime = this.active.get(ref.id)?.runtime;
3464
+ const result = await this.forkSessionTree(ref, { entryId, expectedLeafId }, forkOptions, session);
3465
+ if (!result.cancelled) {
3466
+ try {
3467
+ await completeReplacement?.();
3468
+ }
3469
+ catch (error) {
3470
+ // The fork is committed. Report callback failure on the current
3471
+ // runtime (which may have forked again), including its recoverable inbox.
3472
+ const current = runtime?.session;
3473
+ if (current !== undefined && this.isCurrentActiveSession(current)) {
3474
+ const message = errorMessage(error);
3475
+ current.extensionRunner.getUIContext().notify(message, "error");
3476
+ this.publishActivity(current, "extension callback failed", "error", message);
3477
+ this.events.publish(current.sessionId, { type: "session.error", message });
3478
+ }
3479
+ throw error;
3480
+ }
3481
+ }
3482
+ return { cancelled: result.cancelled };
3483
+ });
3484
+ },
3485
+ reload: () => this.runExtensionSessionControl(session, () => this.reloadSessionRuntime(session)),
3486
+ // These need host-owned creation/selection policies, not SDK runtime-only
3487
+ // replacement. Fail visibly until PI WEB supplies that integration.
3488
+ newSession: () => Promise.reject(new Error("ctx.newSession() is not supported in PI WEB. Start a new session from the web UI.")),
3489
+ switchSession: () => Promise.reject(new Error("ctx.switchSession() is not supported in PI WEB. Select a session from the web UI.")),
3490
+ };
3491
+ }
3492
+ setSuggestedInput(sessionId, text) {
3493
+ if (text === undefined || text === "")
3494
+ this.suggestedInputs.delete(sessionId);
3495
+ else
3496
+ this.suggestedInputs.set(sessionId, text);
3497
+ }
3498
+ assertCurrentExtensionSession(session) {
3499
+ if (!this.isCurrentActiveSession(session) || this.startupSessions.has(session.sessionId)) {
3500
+ throw new Error("This extension command belongs to an unavailable session runtime");
3501
+ }
3502
+ }
3503
+ runExtensionSessionControl(session, operation) {
3504
+ return this.extensionCommandExecution.run({ ...this.extensionCommandExecution.getStore(), sessionControl: true }, async () => {
3505
+ this.assertCurrentExtensionSession(session);
3506
+ await this.assertWritable({ id: session.sessionId, cwd: session.sessionManager.getCwd() });
3507
+ this.assertCurrentExtensionSession(session);
3508
+ return operation();
3509
+ });
3510
+ }
3128
3511
  replaceSessionNotificationContext(session, generation) {
3129
3512
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
3130
3513
  }
@@ -3301,9 +3684,30 @@ export class PiSessionService {
3301
3684
  this.clearCompactionPromptQueue(sessionId);
3302
3685
  }
3303
3686
  }
3687
+ this.restoreSubsessionReceipts(session);
3304
3688
  const unsubscribe = session.subscribe((event) => {
3305
3689
  const eventType = getString(event, "type");
3306
3690
  const message = getProperty(event, "message");
3691
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
3692
+ if (eventType === "agent_start") {
3693
+ this.restoreSubsessionReceipts(session, false);
3694
+ this.subsessionStoppedParents.delete(session);
3695
+ }
3696
+ if (eventType === "message_end" && getString(message, "role") === "assistant"
3697
+ && (getString(message, "stopReason") === "aborted" || getString(message, "stopReason") === "error")) {
3698
+ this.subsessionStoppedParents.add(session);
3699
+ }
3700
+ const entry = getProperty(event, "entry");
3701
+ if (eventType === "entry_appended" && getString(entry, "type") === "custom_message"
3702
+ && getString(entry, "customType") === SUBSESSION_NOTIFICATION_CUSTOM_TYPE) {
3703
+ inbox.receive(completionReceiptIds(getProperty(entry, "details")));
3704
+ }
3705
+ // message_end listeners run before SDK persistence. These later
3706
+ // boundaries can safely reconcile committed messages and failed intents.
3707
+ if (eventType === "turn_end" || eventType === "agent_settled")
3708
+ this.restoreSubsessionReceipts(session);
3709
+ if (eventType === "agent_settled")
3710
+ this.scheduleSubsessionDelivery(session);
3307
3711
  if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
3308
3712
  // SDK state advances before async extension hooks finish. Capture only
3309
3713
  // published messages, detaching even blocks left intact by projection.
@@ -3318,23 +3722,18 @@ export class PiSessionService {
3318
3722
  }
3319
3723
  this.events.publish(session.sessionId, clientEvent, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
3320
3724
  this.publishActivityForEvent(session, event);
3321
- // Queued messages can reach the model after an ask opened, even though
3322
- // there was no ask to dismiss when the user originally submitted them.
3323
- if (eventType === "message_start" && isRecord(event) && getString(event["message"], "role") === "user") {
3324
- void this.voidOpenAskForUserMessage(session).catch((error) => {
3325
- const message = error instanceof Error ? error.message : String(error);
3326
- this.publishActivity(session, "error", "error", message);
3327
- this.events.publish(session.sessionId, { type: "session.error", message });
3328
- });
3329
- }
3725
+ if (eventType === "message_start" && getString(message, "role") === "user")
3726
+ this.suggestedInputs.delete(session.sessionId);
3330
3727
  if (eventType === "agent_end")
3331
3728
  this.abortRunScopedExtensionDialogs(session.sessionId);
3332
3729
  if (eventType === "compaction_end")
3333
3730
  this.scheduleCompactionQueueDrain(session.sessionId);
3731
+ if (eventType === "compaction_end" || eventType === "bash_execution_end")
3732
+ this.scheduleSubsessionDelivery(session);
3334
3733
  if (eventType === "agent_start" || eventType === "agent_end")
3335
3734
  this.scheduleCompactionQueueDrain(session.sessionId);
3336
3735
  this.publishStatus(session);
3337
- this.updateSubsessionTracking(session, eventType === "agent_start");
3736
+ this.updateSubsessionTracking(session, eventType === "agent_start", eventType === "agent_settled");
3338
3737
  });
3339
3738
  active.unsubscribe = () => {
3340
3739
  this.sessionEvents.close(session);
@@ -3367,16 +3766,25 @@ export class PiSessionService {
3367
3766
  return;
3368
3767
  this.publishStatus(session);
3369
3768
  for (const prompt of queued)
3370
- void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
3769
+ void this.submitHeldPrompt(session, prompt, prompt.kind);
3371
3770
  return;
3372
3771
  }
3373
3772
  const prompt = this.shiftCompactionPrompt(sessionId);
3374
3773
  if (prompt === undefined)
3375
3774
  return;
3376
3775
  this.publishStatus(session);
3377
- const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
3776
+ const submitted = this.submitHeldPrompt(session, prompt);
3378
3777
  void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
3379
3778
  }
3779
+ submitHeldPrompt(session, prompt, kind) {
3780
+ if (prompt.message === undefined)
3781
+ return this.submitPrompt(session, prompt.text, kind, prompt.images, prompt.echoUserMessage ?? true);
3782
+ // Use the public session API even after the queue adapter has been disabled.
3783
+ // Keep auth, run ownership and settlement SDK-owned; text is already expanded.
3784
+ const images = prompt.message.role === "user" && Array.isArray(prompt.message.content)
3785
+ ? prompt.message.content.filter((part) => part.type === "image") : [];
3786
+ return this.beginPromptSubmission(session, prompt.text, kind, images, true, false).catch(() => undefined);
3787
+ }
3380
3788
  takeCompactionPromptQueue(sessionId) {
3381
3789
  const queued = this.compactionPromptQueues.get(sessionId) ?? [];
3382
3790
  this.compactionPromptQueues.delete(sessionId);
@@ -3384,7 +3792,8 @@ export class PiSessionService {
3384
3792
  }
3385
3793
  shiftCompactionPrompt(sessionId) {
3386
3794
  const queue = this.compactionPromptQueues.get(sessionId);
3387
- const prompt = queue?.shift();
3795
+ const steeringIndex = queue?.findIndex((prompt) => prompt.kind === "steer") ?? -1;
3796
+ const prompt = queue?.splice(steeringIndex < 0 ? 0 : steeringIndex, 1)[0];
3388
3797
  if (queue === undefined || queue.length === 0)
3389
3798
  this.compactionPromptQueues.delete(sessionId);
3390
3799
  return prompt;
@@ -3405,11 +3814,6 @@ export class PiSessionService {
3405
3814
  maybeGenerateSessionName(session, firstMessage) {
3406
3815
  if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
3407
3816
  return;
3408
- const deterministicName = deterministicSessionName(firstMessage);
3409
- if (deterministicName !== undefined) {
3410
- this.applyGeneratedSessionName(session, deterministicName);
3411
- return;
3412
- }
3413
3817
  const model = session.model;
3414
3818
  if (model === undefined)
3415
3819
  return;
@@ -3440,13 +3844,8 @@ export class PiSessionService {
3440
3844
  this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
3441
3845
  }
3442
3846
  }
3443
- const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
3444
- this.deferredSubsessionNotifications.delete(session);
3445
- for (const notification of notifications) {
3446
- void this.deliverSubsessionNotification(session, notification).catch((error) => {
3447
- this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
3448
- });
3449
- }
3847
+ this.restoreSubsessionReceipts(session);
3848
+ this.scheduleSubsessionDelivery(session);
3450
3849
  }
3451
3850
  applyAuthChange(change = {}) {
3452
3851
  // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
@@ -3532,8 +3931,11 @@ export class PiSessionService {
3532
3931
  return "active";
3533
3932
  }
3534
3933
  hasActiveWork(session) {
3934
+ const execution = this.extensionCommandExecution.getStore();
3935
+ const ownCommandReceipt = execution?.sessionControl === true
3936
+ && execution.submission?.active === true && execution.submission.session === session ? 1 : 0;
3535
3937
  return this.treeNavigations.has(session)
3536
- || this.isSessionEntryMutationActive(session)
3938
+ || this.isSessionEntryMutationActive(session, ownCommandReceipt)
3537
3939
  || this.isTreeExclusiveOperationActive(session)
3538
3940
  || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
3539
3941
  }
@@ -3615,9 +4017,11 @@ export class PiSessionService {
3615
4017
  else
3616
4018
  this.sessionEntryMutationCounts.set(session, remaining);
3617
4019
  this.observeUnreadActivityState(session);
4020
+ if (remaining <= 0 && !this.subsessionDeliveringParents.has(session))
4021
+ this.scheduleSubsessionDelivery(session);
3618
4022
  }
3619
- isSessionEntryMutationActive(session) {
3620
- return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
4023
+ isSessionEntryMutationActive(session, ignoredReceipts = 0) {
4024
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > ignoredReceipts;
3621
4025
  }
3622
4026
  publishActivityForEvent(session, event) {
3623
4027
  const eventType = getString(event, "type");
@@ -3764,25 +4168,29 @@ export class PiSessionService {
3764
4168
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3765
4169
  const contextUsage = session.getContextUsage();
3766
4170
  const warnings = this.warningsForSession(session);
3767
- const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
3768
4171
  const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
4172
+ const suggestedInput = this.suggestedInputs.get(session.sessionId);
4173
+ const queuedMessages = this.queuedMessages(session);
4174
+ const queueMessageActionsUnavailable = this.queueCompatibility.reason(session);
3769
4175
  return {
3770
4176
  sessionId: session.sessionId,
3771
4177
  persisted: sessionFileExists(session.sessionFile),
4178
+ recentlyActiveElsewhere: this.activityMarker.isActiveElsewhere(session.sessionFile),
3772
4179
  ...(model === undefined ? {} : { model }),
3773
4180
  thinkingLevel: session.thinkingLevel,
3774
4181
  isStreaming: session.isStreaming,
3775
4182
  isCompacting: session.isCompacting,
3776
4183
  isBashRunning: session.isBashRunning,
3777
4184
  pendingMessageCount: this.pendingMessageCount(session),
3778
- queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
4185
+ queuedMessages,
4186
+ ...(queueMessageActionsUnavailable === undefined ? {} : { queueMessageActionsUnavailable }),
3779
4187
  messageCount,
3780
4188
  tokens: stats.tokens,
3781
4189
  cost: stats.cost,
3782
4190
  ...(contextUsage === undefined ? {} : { contextUsage }),
3783
4191
  ...(warnings.length === 0 ? {} : { warnings }),
3784
- ...(pendingAsk === undefined ? {} : { pendingAsk }),
3785
4192
  ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
4193
+ ...(suggestedInput === undefined ? {} : { suggestedInput }),
3786
4194
  };
3787
4195
  }
3788
4196
  /**
@@ -3794,13 +4202,6 @@ export class PiSessionService {
3794
4202
  warningsForSession(session) {
3795
4203
  const runtime = this.active.get(session.sessionId)?.runtime;
3796
4204
  const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
3797
- if (this.activityMarker.isActiveElsewhere(session.sessionFile)) {
3798
- warnings.push({
3799
- severity: "info",
3800
- message: "Recently active in another PI-WEB instance. Avoid working on this session in both instances at once.",
3801
- source: "PI-WEB",
3802
- });
3803
- }
3804
4205
  const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3805
4206
  if (anthropic !== undefined)
3806
4207
  warnings.push(anthropic);
@@ -3812,8 +4213,12 @@ export class PiSessionService {
3812
4213
  compactionQueuedMessages(sessionId) {
3813
4214
  return this.compactionPromptQueues.get(sessionId) ?? [];
3814
4215
  }
4216
+ queuedMessages(session) {
4217
+ const held = this.compactionQueuedMessages(session.sessionId);
4218
+ return this.queueCompatibility.inspect(session, () => queuedMessagesFromSession(session, held), () => queuedMessagesFromSession(session, held, false));
4219
+ }
3815
4220
  hasQueuedMessageText(session, text) {
3816
- return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
4221
+ return this.queuedMessages(session).some((message) => message.text === text);
3817
4222
  }
3818
4223
  }
3819
4224
  function previewResponseFromPlan(plan) {
@@ -3841,13 +4246,13 @@ function bulkRefToSessionRef(ref) {
3841
4246
  return { id: ref.id, cwd: ref.cwd };
3842
4247
  }
3843
4248
  function findArchivedRecordForBulkRef(records, ref) {
3844
- return records.find((record) => record.cwd === ref.cwd && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
4249
+ return records.find((record) => record.cwd === ref.cwd && record.sessionId === ref.id);
3845
4250
  }
3846
4251
  function findListedSessionForBulkRef(context, ref) {
3847
- return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
4252
+ return findSessionByExactId(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
3848
4253
  }
3849
- function findSessionByIdOrPrefix(sessions, sessionId) {
3850
- return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
4254
+ function findSessionByExactId(sessions, sessionId) {
4255
+ return sessions.find((session) => session.id === sessionId);
3851
4256
  }
3852
4257
  function uniqueStrings(values) {
3853
4258
  return [...new Set(values)];
@@ -3888,12 +4293,35 @@ function notificationIdentityForSession(session) {
3888
4293
  cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3889
4294
  };
3890
4295
  }
4296
+ function metadataFromActiveSession(session) {
4297
+ return readSessionUiMetadata(session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch());
4298
+ }
4299
+ function clientSessionFromActiveSession(session) {
4300
+ const header = session.sessionManager.getHeader?.();
4301
+ const created = header?.timestamp ?? new Date().toISOString();
4302
+ const metadata = metadataFromActiveSession(session);
4303
+ return {
4304
+ id: session.sessionId,
4305
+ path: session.sessionFile ?? "",
4306
+ cwd: session.sessionManager.getCwd(),
4307
+ persisted: sessionFileExists(session.sessionFile),
4308
+ created,
4309
+ modified: created,
4310
+ messageCount: session.messages.length,
4311
+ firstMessage: "",
4312
+ ...(metadata === undefined ? {} : { metadata }),
4313
+ ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
4314
+ ...(header?.parentSession === undefined ? {} : { parentSessionPath: header.parentSession }),
4315
+ };
4316
+ }
3891
4317
  function clientSessionFromListEntry(session) {
3892
4318
  return {
3893
4319
  id: session.id,
3894
4320
  path: session.path,
3895
4321
  cwd: session.cwd,
3896
4322
  persisted: true,
4323
+ ...(session.agentSubsession === true ? { agentSubsession: true } : {}),
4324
+ ...(session.metadata === undefined ? {} : { metadata: requireSessionUiMetadata(session.metadata) }),
3897
4325
  ...(session.name === undefined ? {} : { name: session.name }),
3898
4326
  created: session.created.toISOString(),
3899
4327
  modified: session.modified.toISOString(),
@@ -3911,6 +4339,7 @@ function archiveInputFromListEntry(session) {
3911
4339
  modified: session.modified.toISOString(),
3912
4340
  messageCount: session.messageCount,
3913
4341
  firstMessage: session.firstMessage,
4342
+ ...(session.metadata === undefined ? {} : { metadata: session.metadata }),
3914
4343
  ...(session.name === undefined ? {} : { name: session.name }),
3915
4344
  ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3916
4345
  };
@@ -3920,6 +4349,7 @@ function archiveInputFromActiveSession(session) {
3920
4349
  if (sessionFile === undefined || sessionFile === "")
3921
4350
  throw new Error("Session is not persisted");
3922
4351
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
4352
+ const metadata = metadataFromActiveSession(session);
3923
4353
  return {
3924
4354
  sessionId: session.sessionId,
3925
4355
  cwd: session.sessionManager.getCwd(),
@@ -3928,6 +4358,7 @@ function archiveInputFromActiveSession(session) {
3928
4358
  modified: new Date().toISOString(),
3929
4359
  messageCount: session.messages.length,
3930
4360
  firstMessage: "",
4361
+ ...(metadata === undefined ? {} : { metadata }),
3931
4362
  ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
3932
4363
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3933
4364
  };
@@ -3992,11 +4423,13 @@ function clientSessionFromArchivedRecord(record, fallback) {
3992
4423
  if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
3993
4424
  return undefined;
3994
4425
  const name = record.name ?? fallback?.name;
4426
+ const metadata = record.metadata ?? fallback?.metadata;
3995
4427
  const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
3996
4428
  return {
3997
4429
  id: record.sessionId,
3998
4430
  path,
3999
4431
  cwd: record.cwd,
4432
+ ...(metadata === undefined ? {} : { metadata: requireSessionUiMetadata(metadata) }),
4000
4433
  ...(name === undefined ? {} : { name }),
4001
4434
  created,
4002
4435
  modified,
@@ -4110,37 +4543,6 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
4110
4543
  spawnedSessionId: childSessionId,
4111
4544
  };
4112
4545
  }
4113
- function parsePersistedParentSubsessionLink(entry) {
4114
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
4115
- return undefined;
4116
- const data = entry["data"];
4117
- if (!isRecord(data))
4118
- return undefined;
4119
- const spawnedBySessionId = getString(data, "spawnedBySessionId");
4120
- const spawnedSessionId = getString(data, "spawnedSessionId");
4121
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4122
- return undefined;
4123
- const spawnedSessionFile = getString(data, "spawnedSessionFile");
4124
- const cwd = getString(data, "cwd");
4125
- return {
4126
- spawnedBySessionId,
4127
- spawnedSessionId,
4128
- ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
4129
- ...(cwd === undefined || cwd === "" ? {} : { cwd }),
4130
- };
4131
- }
4132
- function parsePersistedChildSubsessionLink(entry) {
4133
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
4134
- return undefined;
4135
- const data = entry["data"];
4136
- if (!isRecord(data))
4137
- return undefined;
4138
- const spawnedBySessionId = getString(data, "spawnedBySessionId");
4139
- const spawnedSessionId = getString(data, "spawnedSessionId");
4140
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4141
- return undefined;
4142
- return { spawnedBySessionId, spawnedSessionId };
4143
- }
4144
4546
  function nonEmptyString(value) {
4145
4547
  return value === undefined || value === "" ? undefined : value;
4146
4548
  }
@@ -4199,12 +4601,37 @@ function clearParentSessionHeader(sessionManager) {
4199
4601
  function clearSessionQueue(session) {
4200
4602
  session.clearQueue();
4201
4603
  }
4202
- function queuedMessagesFromSession(session, extraQueuedMessages = []) {
4203
- return [
4204
- ...session.getSteeringMessages().map((text) => ({ kind: "steer", text })),
4205
- ...session.getFollowUpMessages().map((text) => ({ kind: "followUp", text })),
4206
- ...extraQueuedMessages,
4207
- ];
4604
+ function queuedMessagesFromSession(session, extraQueuedMessages = [], inspectSdk = true) {
4605
+ const texts = inspectSdk
4606
+ ? { steer: mutableTextMirror(session.getSteeringMessages()), followUp: mutableTextMirror(session.getFollowUpMessages()) }
4607
+ : { steer: publicQueueTexts(() => session.getSteeringMessages()), followUp: publicQueueTexts(() => session.getFollowUpMessages()) };
4608
+ const attachmentCounts = { steer: [], followUp: [] };
4609
+ if (inspectSdk && (texts.steer.length > 0 || texts.followUp.length > 0 || extraQueuedMessages.length > 0)) {
4610
+ const queues = snapshotAgentQueues(mutableQueueAgent(session.agent));
4611
+ for (const kind of ["steer", "followUp"]) {
4612
+ attachmentCounts[kind] = queuedMessageAttachmentCounts(queues[kind], texts[kind]);
4613
+ }
4614
+ }
4615
+ return ["steer", "followUp"].flatMap((kind) => [
4616
+ ...texts[kind].map((text, index) => ({ kind, text, ...queuedAttachmentMetadata(attachmentCounts[kind][index] ?? 0) })),
4617
+ ...extraQueuedMessages.filter((message) => message.kind === kind).map((prompt) => ({
4618
+ kind, text: prompt.text,
4619
+ ...queuedAttachmentMetadata(prompt.message === undefined ? prompt.images?.length ?? 0 : queuedMessageAttachmentCount(prompt.message)),
4620
+ })),
4621
+ ]);
4622
+ }
4623
+ /** Degraded status still uses public text getters where their values are usable. */
4624
+ function publicQueueTexts(read) {
4625
+ try {
4626
+ const value = read();
4627
+ return Array.isArray(value) ? value.filter((text) => typeof text === "string") : [];
4628
+ }
4629
+ catch {
4630
+ return [];
4631
+ } // The compatibility guard already records the original failure.
4632
+ }
4633
+ function queuedAttachmentMetadata(attachmentCount) {
4634
+ return attachmentCount > 0 ? { hasAttachments: true, attachmentCount } : {};
4208
4635
  }
4209
4636
  function userTextMessage(text) {
4210
4637
  return { role: "user", content: text };
@@ -4258,6 +4685,55 @@ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
4258
4685
  return undefined;
4259
4686
  return parentId === null || typeof parentId === "string" ? parentId : undefined;
4260
4687
  }
4688
+ /**
4689
+ * Pi edits custom messages from their parent. For a retained checkpoint, remap
4690
+ * that single leaf movement instead, before Pi rebuilds context/restores tools
4691
+ * and emits session_tree. Restore the manager methods before extension handlers
4692
+ * run: their appended history must not be overwritten by a post-event repair.
4693
+ * The caller holds the tree-navigation gate and excludes branch summarization.
4694
+ */
4695
+ async function navigateTreeRetainingCustomMessage(manager, targetId, parentId, navigate) {
4696
+ if (parentId !== null && typeof parentId !== "string")
4697
+ throw new Error("This checkpoint has no valid history parent");
4698
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- Restore exact identities; calls below explicitly bind the manager.
4699
+ const { branch, resetLeaf } = manager;
4700
+ let pending = true;
4701
+ const restore = () => {
4702
+ if (!pending)
4703
+ return;
4704
+ pending = false;
4705
+ manager.branch = branch;
4706
+ manager.resetLeaf = resetLeaf;
4707
+ };
4708
+ try {
4709
+ manager.branch = (entryId) => {
4710
+ if (entryId === parentId) {
4711
+ restore();
4712
+ branch.call(manager, targetId);
4713
+ }
4714
+ else
4715
+ branch.call(manager, entryId);
4716
+ };
4717
+ manager.resetLeaf = () => {
4718
+ if (parentId === null) {
4719
+ restore();
4720
+ branch.call(manager, targetId);
4721
+ }
4722
+ else
4723
+ resetLeaf.call(manager);
4724
+ };
4725
+ const result = await navigate();
4726
+ if (result.cancelled)
4727
+ return result;
4728
+ // The retained notice is context, not text to restore into the editor.
4729
+ const { editorText, ...retainedResult } = result;
4730
+ void editorText;
4731
+ return retainedResult;
4732
+ }
4733
+ finally {
4734
+ restore();
4735
+ }
4736
+ }
4261
4737
  function setSessionTreeLeaf(manager, leafId) {
4262
4738
  if (leafId === null)
4263
4739
  manager.resetLeaf();
@@ -4271,15 +4747,22 @@ function transcriptBranchIncludesEntry(entries, entryId) {
4271
4747
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
4272
4748
  /** custom entry type used to mark a child as created by spawn_subsession. */
4273
4749
  const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
4274
- /** customType marking a parent-facing subsession-completion notice. */
4275
- const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
4276
- const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
4277
- /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
4278
- function formatSubsessionNotificationOutput(childSessionId, text) {
4279
- if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
4280
- 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.`;
4281
- }
4282
- return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
4750
+ /** A filtered read is a receipt only when it contains the complete latest final text. */
4751
+ function transcriptReturnsFinalOutput(messages, view, finalText) {
4752
+ if (finalText === "")
4753
+ return false;
4754
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
4755
+ const text = finalAssistantText([messages[index]]);
4756
+ if (text === "")
4757
+ continue;
4758
+ const entry = view.entries.find((candidate) => candidate.index === index && candidate.role === "assistant");
4759
+ if (entry === undefined || text !== finalText)
4760
+ return false;
4761
+ const texts = entry.parts.filter((part) => part.kind === "text");
4762
+ return texts.every((part) => part.truncated === undefined)
4763
+ && texts.map((part) => part.text).join("\n").trim() === finalText.trim();
4764
+ }
4765
+ return false;
4283
4766
  }
4284
4767
  /** Most recent assistant text from a history message list, or "" if none. */
4285
4768
  function finalAssistantText(messages) {