@jmfederico/pi-web 1.202610.1 → 1.202610.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) 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-DUsqiytv.js +4408 -0
  5. package/dist/client/assets/vendor-editor-core-C2PJX3uW.js +10 -0
  6. package/dist/client/assets/{vendor-editor-languages-DOCw3vc9.js → vendor-editor-languages-CS6n1J8n.js} +1 -1
  7. package/dist/client/index.html +4 -48
  8. package/dist/config.js +8 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/pi-packages/captains-log/dist/browser/index.js +30 -32
  11. package/dist/pi-packages/captains-log/package.json +4 -4
  12. package/dist/pi-packages/relays/extensions/index.js +60 -0
  13. package/dist/pi-packages/relays/package.json +10 -1
  14. package/dist/pi-packages/relays/pi-web-plugin.js +31 -1
  15. package/dist/pi-packages/relays/prompts/relay-worktree.md +6 -16
  16. package/dist/pi-packages/relays/prompts/relay.md +6 -33
  17. package/dist/pi-packages/relays/relayDispatch.js +67 -0
  18. package/dist/pi-packages/relays/relayIdentity.js +37 -0
  19. package/dist/pi-packages/relays/relaysPanelElement.js +37 -12
  20. package/dist/pi-packages/relays/skills/relay/SKILL.md +24 -68
  21. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +40 -219
  22. package/dist/pi-packages/workflows/browser/pi-web-plugin.js +33 -0
  23. package/dist/pi-packages/workflows/package.json +22 -0
  24. package/dist/pi-packages/workflows/prompts/worktree.md +12 -0
  25. package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +49 -51
  26. package/dist/pi-web-plugins/git/browser/git-panel.js +0 -1
  27. package/dist/pi-web-plugins/git/server-plugin.js +11 -0
  28. package/dist/pi-web-plugins/git/workspace-discovery-notify.cjs +14 -0
  29. package/dist/pi-web-plugins/git/workspaceDiscoveryHooks.js +180 -0
  30. package/dist/pi-web-plugins/info/infoInternals.js +3 -3
  31. package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
  32. package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +1077 -414
  33. package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +30 -30
  34. package/dist/pi-web-plugins/updates/pi-web-plugin.js +1 -1
  35. package/dist/piWebUpdate.js +2 -2
  36. package/dist/piWebUpdate.js.map +1 -1
  37. package/dist/plugin-api.d.ts +229 -8
  38. package/dist/server/activity/workspaceActivityService.js +4 -1
  39. package/dist/server/activity/workspaceActivityService.js.map +1 -1
  40. package/dist/server/app.js +5 -0
  41. package/dist/server/app.js.map +1 -1
  42. package/dist/server/configRoutes.js +6 -0
  43. package/dist/server/configRoutes.js.map +1 -1
  44. package/dist/server/knownAutoInstallPiPackages.js +6 -0
  45. package/dist/server/knownAutoInstallPiPackages.js.map +1 -1
  46. package/dist/server/navigationPreferenceRoutes.js +39 -0
  47. package/dist/server/navigationPreferenceRoutes.js.map +1 -0
  48. package/dist/server/plugins/projectPluginSettings.js +95 -0
  49. package/dist/server/plugins/projectPluginSettings.js.map +1 -0
  50. package/dist/server/plugins/serverPluginRuntime.js +26 -3
  51. package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
  52. package/dist/server/sessiond/autoInstallPiPackages.js +3 -3
  53. package/dist/server/sessiond/workspaceCatalogRoutes.js +3 -1
  54. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -1
  55. package/dist/server/sessiond/workspaceNotificationRoutes.js +41 -0
  56. package/dist/server/sessiond/workspaceNotificationRoutes.js.map +1 -0
  57. package/dist/server/sessiond.js +40 -7
  58. package/dist/server/sessiond.js.map +1 -1
  59. package/dist/server/sessions/askUserDomain.js +232 -0
  60. package/dist/server/sessions/askUserDomain.js.map +1 -0
  61. package/dist/server/sessions/askUserTool.js +15 -17
  62. package/dist/server/sessions/askUserTool.js.map +1 -1
  63. package/dist/server/sessions/dockerEnvironmentFacts.js +8 -0
  64. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -1
  65. package/dist/server/sessions/extensionSessionDispatch.js +76 -0
  66. package/dist/server/sessions/extensionSessionDispatch.js.map +1 -0
  67. package/dist/server/sessions/globalProviderPolicy.js +71 -16
  68. package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
  69. package/dist/server/sessions/messageHistoryTarget.js +61 -0
  70. package/dist/server/sessions/messageHistoryTarget.js.map +1 -0
  71. package/dist/server/sessions/piSessionManagerGateway.js +23 -24
  72. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  73. package/dist/server/sessions/piSessionService.js +581 -262
  74. package/dist/server/sessions/piSessionService.js.map +1 -1
  75. package/dist/server/sessions/sessionArchiveStore.js +5 -1
  76. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  77. package/dist/server/sessions/sessionArchiveTree.js +2 -2
  78. package/dist/server/sessions/sessionArchiveTree.js.map +1 -1
  79. package/dist/server/sessions/sessionCommandService.js +9 -3
  80. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  81. package/dist/server/sessions/sessionImageDimensions.js +31 -0
  82. package/dist/server/sessions/sessionImageDimensions.js.map +1 -0
  83. package/dist/server/sessions/sessionMediaIndex.js +7 -1
  84. package/dist/server/sessions/sessionMediaIndex.js.map +1 -1
  85. package/dist/server/sessions/sessionMetadata.js +51 -0
  86. package/dist/server/sessions/sessionMetadata.js.map +1 -0
  87. package/dist/server/sessions/sessionNameGenerator.js +8 -28
  88. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  89. package/dist/server/sessions/sessionRoutes.js +10 -2
  90. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  91. package/dist/server/sessions/sessionSummaryScanner.js +62 -5
  92. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
  93. package/dist/server/sessions/spawnSessionTool.js +3 -1
  94. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  95. package/dist/server/sessions/spawnSubsessionTool.js +25 -17
  96. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  97. package/dist/server/sessions/subsessionCompletionDelivery.js +15 -0
  98. package/dist/server/sessions/subsessionCompletionDelivery.js.map +1 -0
  99. package/dist/server/sessions/subsessionCompletionInbox.js +134 -0
  100. package/dist/server/sessions/subsessionCompletionInbox.js.map +1 -0
  101. package/dist/server/sessions/subsessionProvenance.js +27 -0
  102. package/dist/server/sessions/subsessionProvenance.js.map +1 -0
  103. package/dist/server/status/machineStatusService.js +17 -5
  104. package/dist/server/status/machineStatusService.js.map +1 -1
  105. package/dist/server/storage/navigationPreferenceStore.js +137 -0
  106. package/dist/server/storage/navigationPreferenceStore.js.map +1 -0
  107. package/dist/server/workspaces/projectPiWebConfig.js +88 -6
  108. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  109. package/dist/server/workspaces/workspaceChangeNotifier.js +12 -0
  110. package/dist/server/workspaces/workspaceChangeNotifier.js.map +1 -0
  111. package/dist/server/workspaces/workspaceNotificationCommand.js +64 -0
  112. package/dist/server/workspaces/workspaceNotificationCommand.js.map +1 -0
  113. package/dist/server/workspaces/workspaceProviderRegistry.js +55 -0
  114. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
  115. package/dist/server-plugin-api.d.ts +18 -0
  116. package/dist/server-plugin-api.js.map +1 -1
  117. package/dist/sessiond/sessionDaemonClient.js +3 -3
  118. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  119. package/dist/shared/apiTypes.js +2 -2
  120. package/dist/shared/apiTypes.js.map +1 -1
  121. package/dist/shared/federatedRoutes.js +5 -0
  122. package/dist/shared/federatedRoutes.js.map +1 -1
  123. package/dist/shared/machineStatus.js +11 -2
  124. package/dist/shared/machineStatus.js.map +1 -1
  125. package/dist/shared/navigationPreferences.js +6 -0
  126. package/dist/shared/navigationPreferences.js.map +1 -0
  127. package/dist/shared/pluginApiTypes.d.ts +2 -0
  128. package/dist/shared/sessionMedia.js +6 -0
  129. package/dist/shared/sessionMedia.js.map +1 -1
  130. package/dist/shared/streamingBehavior.js +7 -0
  131. package/dist/shared/streamingBehavior.js.map +1 -0
  132. package/dist/shared/themePreference.js +23 -0
  133. package/dist/shared/themePreference.js.map +1 -0
  134. package/dist/workspaceNotificationCli.js +95 -0
  135. package/dist/workspaceNotificationCli.js.map +1 -0
  136. package/docs/config.md +99 -86
  137. package/docs/plugins.md +103 -30
  138. package/examples/session-bridge-plugin/package.json +2 -2
  139. package/examples/workspace-provider-plugin/package.json +1 -1
  140. package/package.json +33 -33
  141. package/dist/client/assets/index-CBjDnBIN.js +0 -4178
  142. package/dist/client/assets/vendor-editor-core-YWog6mAR.js +0 -10
  143. package/dist/server/sessions/pendingAskStore.js +0 -275
  144. package/dist/server/sessions/pendingAskStore.js.map +0 -1
@@ -1,3 +1,5 @@
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";
@@ -11,10 +13,12 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
11
13
  import { SessionCommandService } from "./sessionCommandService.js";
12
14
  import { SessionActivityMarker } from "./sessionActivityMarker.js";
13
15
  import { projectSessionTree } from "./sessionTreeProjection.js";
16
+ import { messageHistoryTargetId } from "./messageHistoryTarget.js";
14
17
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
15
- import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
18
+ import { findArchiveCandidateByExactId, planSessionArchiveTree } from "./sessionArchiveTree.js";
16
19
  import { PiSessionEventConnections } from "./piSessionEventConnections.js";
17
- import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
20
+ import { fallbackSessionName, generateShortSessionName, requireInitialSessionName } from "./sessionNameGenerator.js";
21
+ import { createExtensionSessionDispatch } from "./extensionSessionDispatch.js";
18
22
  import { computeEditPreview } from "./editPreview.js";
19
23
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
20
24
  import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
@@ -22,15 +26,19 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
22
26
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
23
27
  import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
24
28
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
29
+ import { parsePersistedChildSubsessionLink, parsePersistedParentSubsessionLink } from "./subsessionProvenance.js";
25
30
  import { readSessionHeaderSummary } from "./sessionFileHeader.js";
26
31
  import { createAskUserToolDefinition } from "./askUserTool.js";
27
- import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
32
+ import { createAskUserOutcome, renderAskUserAnswersText, resolveAskUserRequest } from "./askUserDomain.js";
28
33
  import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
29
34
  import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
30
35
  import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
31
36
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
37
+ import { readSessionUiMetadata, requireSessionUiMetadata, SESSION_UI_METADATA_CUSTOM_TYPE } from "./sessionMetadata.js";
32
38
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
33
39
  import { buildTranscriptView } from "./subsessionTranscript.js";
40
+ import { createSubsessionCompletionDelivery } from "./subsessionCompletionDelivery.js";
41
+ import { completionReceiptIds, persistedSubsessionCompletions, renderSubsessionCompletions, SUBSESSION_COMPLETION_ENTRY_TYPE, SUBSESSION_NOTIFICATION_CUSTOM_TYPE, SUBSESSION_RECEIPT_ENTRY_TYPE, SubsessionCompletionInbox } from "./subsessionCompletionInbox.js";
34
42
  import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
35
43
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
36
44
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
@@ -290,14 +298,14 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
290
298
  });
291
299
  };
292
300
  }
293
- export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
301
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUserEnabled = false) {
294
302
  return [
295
303
  createPiWebEditToolDefinition(cwd),
296
304
  ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
297
305
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
298
306
  // Asking the user is not delegation: the questions land in the session the
299
307
  // user is already watching, so tracked children may ask too.
300
- ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
308
+ ...(askUserEnabled ? [createAskUserToolDefinition()] : []),
301
309
  ];
302
310
  }
303
311
  /**
@@ -430,7 +438,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
430
438
  return undefined;
431
439
  return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
432
440
  }
433
- function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
441
+ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUserEnabled = false, appendSystemPromptSections = []) {
434
442
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
435
443
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
436
444
  // PI WEB always honors pi's project-trust model. When the workspace ships
@@ -445,6 +453,13 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
445
453
  // resources skip resolution entirely and are trusted, as before.
446
454
  const eventBus = createEventBus();
447
455
  const builtinFactories = await getBuiltinExtensionFactories();
456
+ // Resolved before session_start. The bridge shares the same delegation
457
+ // policy as core tools, including tracked-child and global disablement.
458
+ let dispatchEnabled = false;
459
+ const sessionDispatch = createExtensionSessionDispatch(cwd, {
460
+ ...(spawn === undefined ? {} : { spawn }),
461
+ isEnabled: () => dispatchEnabled,
462
+ });
448
463
  const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
449
464
  const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
450
465
  // Pre-session-creation trust failures (`project_trust` handler errors)
@@ -456,7 +471,10 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
456
471
  agentDir,
457
472
  modelRuntime,
458
473
  settingsManager,
459
- resourceLoaderOptions: { ...resourceLoaderOptions, eventBus, extensionFactories: builtinFactories },
474
+ resourceLoaderOptions: {
475
+ ...resourceLoaderOptions, eventBus,
476
+ extensionFactories: [...builtinFactories, sessionDispatch, ...(subsessions === undefined ? [] : [createSubsessionCompletionDelivery(subsessions)])],
477
+ },
460
478
  ...(projectTrustRequiring
461
479
  ? {
462
480
  resourceLoaderReloadOptions: {
@@ -480,7 +498,8 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
480
498
  services.diagnostics.push(...modelOptions.diagnostics);
481
499
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
482
500
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
483
- const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
501
+ dispatchEnabled = resolvedDelegationToolsEnabled;
502
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUserEnabled);
484
503
  const result = await createAgentSessionFromServices({
485
504
  services,
486
505
  sessionManager,
@@ -537,8 +556,12 @@ export class PiSessionService {
537
556
  * can anchor the selected branch on disk.
538
557
  */
539
558
  this.unpersistedTreeBranchLeaves = new WeakMap();
559
+ /** Latest prepared text belongs to the session, never to a browser's unsent draft. */
560
+ this.suggestedInputs = new Map();
540
561
  /** Counts async operations that may append an entry before they settle. */
541
562
  this.sessionEntryMutationCounts = new WeakMap();
563
+ /** Exempt only a command's own prompt receipt while it invokes guarded session control. */
564
+ this.extensionCommandExecution = new AsyncLocalStorage();
542
565
  this.pendingPromptEchoes = new WeakMap();
543
566
  this.publishedAssistantPartials = new WeakMap();
544
567
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
@@ -554,7 +577,11 @@ export class PiSessionService {
554
577
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
555
578
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
556
579
  this.treeExclusiveSessionOperationCounts = new Map();
557
- this.deferredSubsessionNotifications = new WeakMap();
580
+ /** Parent id/file identity -> one inbox shared by all completion delivery paths. */
581
+ this.subsessionInboxes = new Map();
582
+ this.subsessionStoppedParents = new WeakSet();
583
+ this.subsessionDeliveringParents = new WeakSet();
584
+ this.subsessionDeliveryScheduled = new WeakSet();
558
585
  this.deferredGeneratedSessionNames = new WeakMap();
559
586
  this.compactionPromptQueues = new Map();
560
587
  this.compactionDrainTimers = new Map();
@@ -595,7 +622,6 @@ export class PiSessionService {
595
622
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
596
623
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
597
624
  this.onUnreadChanged = deps.onUnreadChanged;
598
- this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
599
625
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
600
626
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
601
627
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
@@ -609,9 +635,11 @@ export class PiSessionService {
609
635
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
610
636
  spawn: (input) => this.spawnSubsession(input),
611
637
  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 ?? []);
638
+ join: (parentSessionId, parentSessionFile, toolCallId) => this.joinSubsessions(parentSessionId, parentSessionFile, toolCallId),
639
+ prepare: (parentSessionId, parentSessionFile) => this.prepareSubsessionDelivery(parentSessionId, parentSessionFile),
640
+ check: (parentSessionId, sessionId, parentSessionFile, toolCallId) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId),
641
+ read: (parentSessionId, sessionId, query, parentSessionFile, toolCallId) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId),
642
+ }, deps.askUserEnabled === true, deps.appendSystemPromptSections ?? []);
615
643
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
616
644
  this.workspaceActivity = deps.workspaceActivity;
617
645
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -728,12 +756,12 @@ export class PiSessionService {
728
756
  const activeSessions = Array.from(new Set(this.active.values()));
729
757
  for (const active of activeSessions) {
730
758
  this.forgetUnreadActivity(active.runtime.session);
731
- this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
732
759
  this.endSessionExtensionDialogs(active.runtime.session.sessionId);
733
760
  }
734
761
  this.active.clear();
735
762
  this.pendingSessionOpens.clear();
736
763
  this.startupSessions.clear();
764
+ this.suggestedInputs.clear();
737
765
  this.activities.clear();
738
766
  this.compactionPromptQueues.clear();
739
767
  this.authLossWarnings.clear();
@@ -742,6 +770,7 @@ export class PiSessionService {
742
770
  this.subsessionLinks.clear();
743
771
  this.subsessionHydratedParents.clear();
744
772
  this.subsessionNotifyArmed.clear();
773
+ this.subsessionInboxes.clear();
745
774
  this.notificationStore.clearAll("service-dispose");
746
775
  await Promise.all(activeSessions.map(async (active) => {
747
776
  active.unsubscribe();
@@ -754,6 +783,7 @@ export class PiSessionService {
754
783
  await active.runtime.dispose();
755
784
  }
756
785
  })).finally(async () => {
786
+ this.extensionCommandExecution.disable();
757
787
  this.events.mediaIndex.clear();
758
788
  await this.activityMarker.dispose();
759
789
  });
@@ -767,7 +797,29 @@ export class PiSessionService {
767
797
  for (const record of archivedForCwd) {
768
798
  this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
769
799
  }
770
- const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
800
+ const unarchivedById = new Map(sessions.filter((session) => !archivedById.has(session.id))
801
+ .map((session) => [session.id, clientSessionFromListEntry(session)]));
802
+ // Pi can fork before the first conversational entry without creating a file.
803
+ // Enumerate already-hosted runtimes only; listing must not open cold sessions.
804
+ for (const active of new Set(this.active.values())) {
805
+ const session = active.runtime.session;
806
+ if (!cwdPathsEqual(session.sessionManager.getCwd(), cwd) || archivedById.has(session.sessionId))
807
+ continue;
808
+ const listed = unarchivedById.get(session.sessionId);
809
+ const tracked = this.subsessionLinkForActiveChild(session) !== undefined;
810
+ if (listed !== undefined) {
811
+ // Match the file too: a clone with the same id must not inherit a
812
+ // live runtime's classification. Disk rows otherwise use the scanner.
813
+ if (tracked && session.sessionFile !== undefined && sessionPathsEqual(listed.path, session.sessionFile))
814
+ listed.agentSubsession = true;
815
+ continue;
816
+ }
817
+ const info = clientSessionFromActiveSession(session);
818
+ if (tracked)
819
+ info.agentSubsession = true;
820
+ unarchivedById.set(session.sessionId, info);
821
+ }
822
+ const unarchivedSessions = [...unarchivedById.values()].sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
771
823
  const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
772
824
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
773
825
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
@@ -815,29 +867,37 @@ export class PiSessionService {
815
867
  }
816
868
  }
817
869
  async startSession(cwd, options) {
818
- const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
870
+ const metadata = options.metadata === undefined ? undefined : requireSessionUiMetadata(options.metadata);
871
+ const name = options.name === undefined ? undefined : requireInitialSessionName(options.name);
872
+ const manager = this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession });
873
+ if (metadata !== undefined) {
874
+ if (manager.appendCustomEntry === undefined)
875
+ throw new Error("Session manager does not support UI metadata initialization");
876
+ manager.appendCustomEntry(SESSION_UI_METADATA_CUSTOM_TYPE, { version: 1, metadata });
877
+ }
878
+ if (name !== undefined) {
879
+ if (manager.appendSessionInfo === undefined)
880
+ throw new Error("Session manager does not support initial naming");
881
+ manager.appendSessionInfo(name);
882
+ }
883
+ const active = await this.create(manager, cwd, {
819
884
  startupIntent: "create",
820
885
  ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
821
886
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
822
887
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
823
888
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
824
889
  });
825
- return this.announceCreatedSession(active, cwd, options.parentSession);
890
+ return options.deferAnnouncement === true
891
+ ? clientSessionFromActiveSession(active.runtime.session)
892
+ : this.announceCreatedSession(active, cwd, options.parentSession);
826
893
  }
827
- announceCreatedSession(active, cwd, parentSession) {
894
+ announceCreatedSession(active, cwd, parentSession, agentSubsession) {
828
895
  const { session } = active.runtime;
829
896
  const created = {
830
- id: session.sessionId,
831
- path: session.sessionFile ?? "",
897
+ ...clientSessionFromActiveSession(session),
832
898
  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
899
  ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
900
+ ...(agentSubsession === true ? { agentSubsession: true } : {}),
841
901
  };
842
902
  // Broadcast so other clients (and the spawning agent's UI) can add the new
843
903
  // session to their list without a manual reload.
@@ -861,6 +921,8 @@ export class PiSessionService {
861
921
  ? input.model
862
922
  : await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
863
923
  const created = await this.start(decision.cwd, {
924
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
925
+ ...(input.name === undefined ? {} : { name: input.name }),
864
926
  ...(model === undefined ? {} : { initialModel: model }),
865
927
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
866
928
  });
@@ -902,6 +964,7 @@ export class PiSessionService {
902
964
  ...(model === undefined ? {} : { initialModel: model }),
903
965
  ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
904
966
  creationProvenance: "tracked-subsession",
967
+ deferAnnouncement: true,
905
968
  });
906
969
  const modelUsed = this.active.get(created.id)?.runtime.session.model;
907
970
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
@@ -915,6 +978,13 @@ export class PiSessionService {
915
978
  await this.registerVerifiedSubsession(link);
916
979
  this.persistSubsessionLink(link);
917
980
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
981
+ const active = this.active.get(created.id);
982
+ if (active === undefined)
983
+ throw new Error("Tracked subsession disappeared during creation");
984
+ // Freshly created children are known by this exact runtime/file link even
985
+ // before Pi flushes its buffered custom entries. Reopened sessions only
986
+ // enter this registry after reciprocal persisted provenance validation.
987
+ this.announceCreatedSession(active, decision.cwd, input.parentSessionFile, this.subsessionLinkForActiveChild(active.runtime.session) !== undefined);
918
988
  await this.prompt(created, input.prompt);
919
989
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
920
990
  return {
@@ -1055,83 +1125,30 @@ export class PiSessionService {
1055
1125
  throw unknownSpawnModelError(modelSpec);
1056
1126
  return model;
1057
1127
  }
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
- */
1128
+ /** Reply to a durable question entry, even after later conversation or replies. */
1085
1129
  async submitAsk(ref, askId, submission) {
1086
1130
  await this.assertWritable(ref);
1087
1131
  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
1132
  this.assertTreeNavigationInactive(session, "answer questions");
1091
- return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
1133
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1134
+ const outcome = createAskUserOutcome(ask, submission, this.now().toISOString());
1135
+ return this.deliverAskOutcome(session, outcome);
1092
1136
  }
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
- */
1137
+ /** Skip is also a transcript reply; it does not invalidate future answers. */
1098
1138
  async cancelAsk(ref, askId) {
1099
1139
  await this.assertWritable(ref);
1100
1140
  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);
1141
+ this.assertTreeNavigationInactive(session, "skip questions");
1142
+ const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
1143
+ return this.deliverAskOutcome(session, createAskUserOutcome(ask, undefined, this.now().toISOString()));
1144
+ }
1145
+ async deliverAskOutcome(session, outcome) {
1146
+ // Pi persists/displays the structured message and wakes an idle agent, or
1147
+ // queues it behind in-flight work. Ordinary entry-mutation protection still
1148
+ // applies, but there is no answer-specific stale/duplicate gate.
1115
1149
  await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
1116
1150
  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);
1151
+ return this.statusFromSession(session);
1135
1152
  }
1136
1153
  /**
1137
1154
  * Record the user's answer to an open extension dialog and resolve the
@@ -1280,27 +1297,120 @@ export class PiSessionService {
1280
1297
  const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
1281
1298
  return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
1282
1299
  }
1300
+ /** Collect ready answers before deciding whether the parent needs to wait. */
1301
+ async joinSubsessions(parentSessionId, parentSessionFile, toolCallId) {
1302
+ const subsessions = await this.listSubsessions(parentSessionId, parentSessionFile);
1303
+ this.reconcileSubsessionCompletions(parentSessionId, parentSessionFile);
1304
+ const completions = this.subsessionInbox(parentSessionId, parentSessionFile).pending();
1305
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, completions, toolCallId);
1306
+ return { subsessions, completions };
1307
+ }
1283
1308
  /** Status and final result of a subsession, scoped to the caller's children. */
1284
- async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
1309
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId) {
1285
1310
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1311
+ // Finalize the same completion even if its event/heartbeat notification has
1312
+ // not run yet. Inspection must not leave a future notice armed for this run.
1313
+ this.reconcileSubsessionCompletion(session);
1314
+ const status = this.subsessionStatus(session);
1286
1315
  const messages = historyMessages(session);
1316
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1317
+ if (status !== "working" && completion !== undefined)
1318
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1287
1319
  return {
1288
1320
  sessionId,
1289
1321
  cwd: session.sessionManager.getCwd(),
1290
- status: this.subsessionStatus(session),
1322
+ status,
1291
1323
  finalText: finalAssistantText(messages),
1292
1324
  messageCount: messages.length,
1325
+ ...(status === "working" || completion?.errorDetail === undefined ? {} : { errorDetail: completion.errorDetail }),
1293
1326
  };
1294
1327
  }
1295
- /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
1296
- async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
1328
+ /** Filtered transcript reads acknowledge only the final answer actually returned. */
1329
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId) {
1297
1330
  const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1298
- const view = buildTranscriptView(historyMessages(session), query);
1331
+ this.reconcileSubsessionCompletion(session);
1332
+ const messages = historyMessages(session);
1333
+ const view = buildTranscriptView(messages, query);
1334
+ const status = this.subsessionStatus(session);
1335
+ const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
1336
+ const errorDetail = status === "working" ? undefined : completion?.errorDetail;
1337
+ if (status !== "working" && completion !== undefined
1338
+ && (transcriptReturnsFinalOutput(messages, view, completion.finalText) || (completion.finalText === "" && errorDetail !== undefined))) {
1339
+ this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
1340
+ }
1341
+ return { sessionId, cwd: session.sessionManager.getCwd(), status, ...view, ...(errorDetail === undefined ? {} : { errorDetail }) };
1342
+ }
1343
+ subsessionInbox(parentSessionId, parentSessionFile) {
1344
+ const file = nonEmptyString(parentSessionFile) ?? nonEmptyString(this.active.get(parentSessionId)?.runtime.session.sessionFile);
1345
+ const key = subsessionHydratedParentKey(parentSessionId, file);
1346
+ let inbox = this.subsessionInboxes.get(key);
1347
+ if (inbox === undefined) {
1348
+ inbox = new SubsessionCompletionInbox();
1349
+ this.subsessionInboxes.set(key, inbox);
1350
+ }
1351
+ return inbox;
1352
+ }
1353
+ restoreSubsessionReceipts(session, releaseClaims = true) {
1354
+ this.subsessionInbox(session.sessionId, session.sessionFile).restoreReceipts(session.sessionManager.getBranch(), releaseClaims);
1355
+ }
1356
+ subsessionCompletionForOutput(parentId, parentFile, child, messages) {
1357
+ const branch = persistedSubsessionCompletions(child.sessionManager.getBranch()).filter(({ sessionId }) => sessionId === child.sessionId);
1358
+ // A child can be rewound independently of its parent. Never acknowledge an
1359
+ // abandoned branch's newer result while returning the current branch's text.
1360
+ const completion = branch.at(-1) ?? (child.sessionManager.appendCustomEntry === undefined
1361
+ ? this.subsessionInbox(parentId, parentFile).latest(child.sessionId) : undefined);
1362
+ return completion?.finalText === finalAssistantText(messages) ? completion : undefined;
1363
+ }
1364
+ receiveSubsessionCompletions(parentId, parentFile, completions, toolCallId) {
1365
+ if (completions.length === 0)
1366
+ return;
1367
+ const parent = this.active.get(parentId)?.runtime.session;
1368
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile))) {
1369
+ throw new Error(`Parent session ${parentId} is not available to receive subsession results`);
1370
+ }
1371
+ const inbox = this.subsessionInbox(parentId, parentFile);
1372
+ const completionIds = completions.map(({ completionId }) => completionId).filter((id) => !inbox.hasReceived(id));
1373
+ if (completionIds.length === 0)
1374
+ return;
1375
+ // Persist before removing unread results. A storage failure leaves the
1376
+ // answer unread and fails the tool instead of losing the notification.
1377
+ this.assertTreeNavigationInactive(parent, "receive subsession results");
1378
+ parent.sessionManager.appendCustomEntry?.(SUBSESSION_RECEIPT_ENTRY_TYPE, { completionIds, ...(toolCallId === undefined ? {} : { toolCallId }) });
1379
+ inbox.receive(completionIds);
1380
+ }
1381
+ reconcileSubsessionCompletions(parentId, parentFile) {
1382
+ for (const childId of this.subsessionChildren.get(parentId) ?? []) {
1383
+ if (!this.subsessionLinkBelongsToParent(parentId, nonEmptyString(parentFile), childId))
1384
+ continue;
1385
+ const link = this.subsessionLinks.get(childId);
1386
+ const child = link === undefined ? undefined : this.activeChildForSubsessionLink(link)?.runtime.session;
1387
+ if (child !== undefined)
1388
+ this.reconcileSubsessionCompletion(child);
1389
+ }
1390
+ }
1391
+ reconcileSubsessionCompletion(session) {
1392
+ this.updateSubsessionTracking(session);
1393
+ if (!this.hasActiveWork(session) && this.subsessionNotifyArmed.get(session.sessionId) === true) {
1394
+ throw new Error(`Could not record completion of subsession ${session.sessionId}; its result remains unread. Retry after fixing session storage.`);
1395
+ }
1396
+ }
1397
+ /** Claims commit on entry_appended (boundary drafts) or message_end (idle wakes). */
1398
+ async prepareSubsessionDelivery(parentId, parentFile) {
1399
+ await this.hydrateSubsessionsForParent(parentId, nonEmptyString(parentFile));
1400
+ const parent = this.active.get(parentId)?.runtime.session;
1401
+ if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile)))
1402
+ return undefined;
1403
+ // Tool receipts are intents until the successful (possibly nested) result
1404
+ // is persisted. Reconcile those intents without releasing in-flight drafts.
1405
+ this.restoreSubsessionReceipts(parent, false);
1406
+ this.reconcileSubsessionCompletions(parentId, parentFile);
1407
+ const completions = this.subsessionInbox(parentId, parentFile).claim();
1408
+ if (completions.length === 0)
1409
+ return undefined;
1299
1410
  return {
1300
- sessionId,
1301
- cwd: session.sessionManager.getCwd(),
1302
- status: this.subsessionStatus(session),
1303
- ...view,
1411
+ type: "custom_message", customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1412
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(parentId)),
1413
+ details: { sessionId: completions[0]?.sessionId, completionIds: completions.map(({ completionId }) => completionId) },
1304
1414
  };
1305
1415
  }
1306
1416
  /** Open a session after verifying it is one of the caller's tracked children. */
@@ -1354,6 +1464,14 @@ export class PiSessionService {
1354
1464
  this.subsessionLinks.set(childSessionId, link);
1355
1465
  if (!this.subsessionNotifyArmed.has(childSessionId))
1356
1466
  this.subsessionNotifyArmed.set(childSessionId, false);
1467
+ const childManager = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager
1468
+ ?? (link.childSessionFile === undefined ? undefined : this.sessionManager.open(link.childSessionFile));
1469
+ if (childManager !== undefined) {
1470
+ for (const completion of persistedSubsessionCompletions(childManager.getEntries?.() ?? childManager.getBranch())) {
1471
+ if (completion.sessionId === childSessionId)
1472
+ this.subsessionInbox(parentSessionId, link.parentSessionFile).record(completion);
1473
+ }
1474
+ }
1357
1475
  const cwd = this.cwdForVerifiedSubsession(link);
1358
1476
  await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
1359
1477
  }
@@ -1378,6 +1496,8 @@ export class PiSessionService {
1378
1496
  this.subsessionParents.delete(childSessionId);
1379
1497
  this.subsessionLinks.delete(childSessionId);
1380
1498
  this.subsessionNotifyArmed.delete(childSessionId);
1499
+ for (const inbox of this.subsessionInboxes.values())
1500
+ inbox.forgetChild(childSessionId);
1381
1501
  if (parentSessionId === undefined)
1382
1502
  return;
1383
1503
  const children = this.subsessionChildren.get(parentSessionId);
@@ -1534,7 +1654,7 @@ export class PiSessionService {
1534
1654
  * working. Prompt failures before agent_start explicitly arm the same latch.
1535
1655
  * Standalone commands and compactions must not arm a completion notice.
1536
1656
  */
1537
- updateSubsessionTracking(session, agentStarted = false) {
1657
+ updateSubsessionTracking(session, agentStarted = false, agentSettled = false) {
1538
1658
  const link = this.subsessionLinkForActiveChild(session);
1539
1659
  if (link === undefined)
1540
1660
  return;
@@ -1543,22 +1663,31 @@ export class PiSessionService {
1543
1663
  this.subsessionNotifyArmed.set(childId, true);
1544
1664
  return;
1545
1665
  }
1546
- if (this.hasActiveWork(session))
1666
+ // Native settlement is authoritative even while the host prompt Promise's
1667
+ // mutation receipt is unwinding. Capture now, before another run can start.
1668
+ if (!agentSettled && this.hasActiveWork(session))
1547
1669
  return;
1548
1670
  if (this.subsessionNotifyArmed.get(childId) !== true)
1549
1671
  return;
1550
- this.subsessionNotifyArmed.set(childId, false);
1551
1672
  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);
1673
+ const messages = historyMessages(session);
1674
+ const completion = {
1675
+ completionId: randomUUID(), sessionId: childId, cwd: session.sessionManager.getCwd(),
1676
+ status: activity?.phase === "error" ? "error" : "idle",
1677
+ finalText: finalAssistantText(messages), messageCount: messages.length,
1678
+ ...(activity?.phase !== "error" || activity.detail === undefined ? {} : { errorDetail: activity.detail }),
1679
+ };
1680
+ try {
1681
+ session.sessionManager.appendCustomEntry?.(SUBSESSION_COMPLETION_ENTRY_TYPE, completion);
1682
+ }
1683
+ catch (error) {
1684
+ // Keep the latch armed so a later reconciliation can retry persistence.
1685
+ this.logSubsessionNotificationFailure(link.parentSessionId, childId, error);
1686
+ return;
1687
+ }
1688
+ this.subsessionNotifyArmed.set(childId, false);
1689
+ this.subsessionInbox(link.parentSessionId, link.parentSessionFile).record(completion);
1690
+ void this.wakeParentForSubsession(link.parentSessionId, childId);
1562
1691
  }
1563
1692
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
1564
1693
  const link = this.subsessionLinks.get(childSessionId);
@@ -1576,31 +1705,60 @@ export class PiSessionService {
1576
1705
  const sessionManager = this.sessionManager.open(parentSessionFile);
1577
1706
  return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
1578
1707
  }
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) {
1708
+ async wakeParentForSubsession(parentId, childId) {
1587
1709
  try {
1588
1710
  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 });
1711
+ this.scheduleSubsessionDelivery(session);
1596
1712
  }
1597
1713
  catch (error) {
1598
1714
  this.logSubsessionNotificationFailure(parentId, childId, error);
1599
1715
  }
1600
1716
  }
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);
1717
+ /** Busy parents keep results in the inbox; never enqueue a second copy in Pi. */
1718
+ async deliverPendingSubsessionCompletions(session) {
1719
+ if (this.hasActiveWork(session) || !this.isCurrentActiveSession(session) || this.subsessionStoppedParents.has(session))
1720
+ return;
1721
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
1722
+ const completions = inbox.claim();
1723
+ if (completions.length === 0)
1724
+ return;
1725
+ const completionIds = completions.map(({ completionId }) => completionId);
1726
+ this.subsessionDeliveringParents.add(session);
1727
+ let delivered = false;
1728
+ try {
1729
+ await this.runSessionEntryMutation(session, "deliver subsession results", () => session.sendCustomMessage({
1730
+ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
1731
+ content: renderSubsessionCompletions(completions, this.workingSubsessionIds(session.sessionId)),
1732
+ details: { sessionId: completions[0]?.sessionId, completionIds },
1733
+ }, { triggerTurn: true }));
1734
+ inbox.receive(completionIds);
1735
+ delivered = true;
1736
+ }
1737
+ catch (error) {
1738
+ this.subsessionStoppedParents.add(session);
1739
+ throw error;
1740
+ }
1741
+ finally {
1742
+ inbox.release(completionIds);
1743
+ this.subsessionDeliveringParents.delete(session);
1744
+ if (delivered)
1745
+ this.scheduleSubsessionDelivery(session);
1746
+ this.publishStatus(session);
1747
+ }
1748
+ }
1749
+ scheduleSubsessionDelivery(session) {
1750
+ if (this.subsessionDeliveryScheduled.has(session) || this.subsessionInbox(session.sessionId, session.sessionFile).pending().length === 0)
1751
+ return;
1752
+ this.subsessionDeliveryScheduled.add(session);
1753
+ // Settlement can precede the host prompt-mutation receipt's finally block.
1754
+ // Recheck on the next tick, closing the yield/child-completion race without
1755
+ // restarting a stopped run or putting rendered answers in a follow-up queue.
1756
+ setTimeout(() => {
1757
+ this.subsessionDeliveryScheduled.delete(session);
1758
+ void this.deliverPendingSubsessionCompletions(session).catch((error) => {
1759
+ this.logSubsessionNotificationFailure(session.sessionId, "inbox", error);
1760
+ });
1761
+ }, 0);
1604
1762
  }
1605
1763
  logSubsessionNotificationFailure(parentId, childId, error) {
1606
1764
  this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
@@ -1909,11 +2067,6 @@ export class PiSessionService {
1909
2067
  this.publishStatus(session);
1910
2068
  return;
1911
2069
  }
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
2070
  if (session.isCompacting) {
1918
2071
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1919
2072
  return;
@@ -1946,7 +2099,12 @@ export class PiSessionService {
1946
2099
  if (getString(event, "type") === "agent_start")
1947
2100
  agentStarted = true;
1948
2101
  });
1949
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).finally(() => {
2102
+ const submission = { session, active: true };
2103
+ const isExtensionCommand = session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
2104
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => isExtensionCommand
2105
+ ? this.extensionCommandExecution.run({ submission }, () => session.prompt(text, promptOptions))
2106
+ : session.prompt(text, promptOptions)).finally(() => {
2107
+ submission.active = false;
1950
2108
  unsubscribe?.();
1951
2109
  if (echo !== undefined) {
1952
2110
  const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
@@ -1957,6 +2115,7 @@ export class PiSessionService {
1957
2115
  }
1958
2116
  });
1959
2117
  void promptPromise.catch((error) => {
2118
+ this.subsessionStoppedParents.add(session);
1960
2119
  const message = error instanceof Error ? error.message : String(error);
1961
2120
  this.publishActivity(session, "error", "error", message);
1962
2121
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -2099,15 +2258,20 @@ export class PiSessionService {
2099
2258
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
2100
2259
  }
2101
2260
  async navigateTree(ref, request) {
2102
- if (request.targetId.trim() === "")
2261
+ return this.navigateSessionTree(ref, request.targetId, request.expectedLeafId, sessionTreeNavigationOptions(request), undefined, request.retainCheckpoint);
2262
+ }
2263
+ async navigateSessionTree(ref, targetId, expectedLeafId, options, expectedSession, retainCheckpoint = false) {
2264
+ if (targetId.trim() === "")
2103
2265
  throw new Error("Session tree target is required");
2104
2266
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
2105
2267
  throw new Error("Stop current session activity before navigating the session tree");
2106
2268
  }
2107
2269
  await this.assertWritable(ref);
2108
- const options = sessionTreeNavigationOptions(request);
2109
2270
  const session = await this.getOrOpen(ref);
2110
- if (typeof session.navigateTree !== "function")
2271
+ if (expectedSession !== undefined && session !== expectedSession)
2272
+ throw new Error("This extension command belongs to an unavailable session runtime");
2273
+ const navigateTree = session.navigateTree?.bind(session);
2274
+ if (navigateTree === undefined)
2111
2275
  throw new Error("Session tree navigation is not supported by this Pi runtime");
2112
2276
  if (this.hasActiveWork(session))
2113
2277
  throw new Error("Stop current session activity before navigating the session tree");
@@ -2116,13 +2280,22 @@ export class PiSessionService {
2116
2280
  this.treeNavigations.add(session);
2117
2281
  try {
2118
2282
  const oldLeafId = session.sessionManager.getLeafId();
2119
- if (oldLeafId !== request.expectedLeafId) {
2283
+ if (oldLeafId !== expectedLeafId) {
2120
2284
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
2121
2285
  }
2122
- const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
2123
- this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
2286
+ if (retainCheckpoint)
2287
+ targetId = messageHistoryTargetId(session.sessionManager.getBranch(), targetId);
2288
+ const checkpointTail = retainCheckpoint ? session.sessionManager.getBranch().find((entry) => isRecord(entry) && entry["id"] === targetId) : undefined;
2289
+ const retainCustomMessage = isRecord(checkpointTail) && checkpointTail["type"] === "custom_message";
2290
+ if (retainCustomMessage && options.summarize === true) {
2291
+ throw new Error("Retaining a custom-message checkpoint requires navigation without a branch summary");
2292
+ }
2293
+ const activeEditableTargetParentId = retainCustomMessage ? undefined : activeEditableTreeTargetParentId(session.sessionManager, targetId, oldLeafId);
2294
+ this.publishActivity(session, options.summarize === true ? "summarizing branch" : "navigating session tree", "active");
2124
2295
  this.publishStatus(session);
2125
- let result = await session.navigateTree(request.targetId, options);
2296
+ let result = retainCustomMessage
2297
+ ? await navigateTreeRetainingCustomMessage(session.sessionManager, targetId, checkpointTail["parentId"], () => navigateTree(targetId, options))
2298
+ : await navigateTree(targetId, options);
2126
2299
  if (activeEditableTargetParentId !== undefined
2127
2300
  && !result.cancelled
2128
2301
  && result.editorText === undefined
@@ -2134,14 +2307,14 @@ export class PiSessionService {
2134
2307
  // extraction, agent-context rebuilding, and tree extension events.
2135
2308
  setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
2136
2309
  try {
2137
- result = await session.navigateTree(request.targetId, options);
2310
+ result = await navigateTree(targetId, options);
2138
2311
  }
2139
2312
  catch (error) {
2140
- session.sessionManager.branch(request.targetId);
2313
+ session.sessionManager.branch(targetId);
2141
2314
  throw error;
2142
2315
  }
2143
2316
  if (result.cancelled)
2144
- session.sessionManager.branch(request.targetId);
2317
+ session.sessionManager.branch(targetId);
2145
2318
  }
2146
2319
  if (result.cancelled) {
2147
2320
  if (this.isCurrentActiveSession(session)) {
@@ -2149,13 +2322,6 @@ export class PiSessionService {
2149
2322
  }
2150
2323
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
2151
2324
  }
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
2325
  if (result.summaryEntry !== undefined) {
2160
2326
  // A summary entry durably identifies the selected branch as the file's
2161
2327
  // newest leaf, superseding any earlier bare selection.
@@ -2169,6 +2335,8 @@ export class PiSessionService {
2169
2335
  this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
2170
2336
  }
2171
2337
  }
2338
+ this.setSuggestedInput(session.sessionId, result.editorText);
2339
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2172
2340
  if (this.isCurrentActiveSession(session))
2173
2341
  this.publishActivity(session, "session tree navigated", "idle");
2174
2342
  return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
@@ -2189,7 +2357,6 @@ export class PiSessionService {
2189
2357
  }
2190
2358
  else {
2191
2359
  this.deferredGeneratedSessionNames.delete(session);
2192
- this.deferredSubsessionNotifications.delete(session);
2193
2360
  }
2194
2361
  }
2195
2362
  }
@@ -2200,6 +2367,9 @@ export class PiSessionService {
2200
2367
  * about to join rather than the forked-from record.
2201
2368
  */
2202
2369
  async forkFromTree(ref, request) {
2370
+ return this.forkSessionTree(ref, request);
2371
+ }
2372
+ async forkSessionTree(ref, request, options, expectedSession) {
2203
2373
  if (request.entryId.trim() === "")
2204
2374
  throw new Error("Session tree entry is required");
2205
2375
  if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
@@ -2207,6 +2377,8 @@ export class PiSessionService {
2207
2377
  }
2208
2378
  await this.assertWritable(ref);
2209
2379
  const session = await this.getOrOpen(ref);
2380
+ if (expectedSession !== undefined && session !== expectedSession)
2381
+ throw new Error("This extension command belongs to an unavailable session runtime");
2210
2382
  if (this.hasActiveWork(session))
2211
2383
  throw new Error("Stop current session activity before forking the session tree");
2212
2384
  if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
@@ -2215,8 +2387,15 @@ export class PiSessionService {
2215
2387
  this.publishActivity(session, "forking session from entry", "active");
2216
2388
  this.publishStatus(session);
2217
2389
  try {
2218
- const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
2390
+ // forkEntry rechecks this same leaf after asynchronous naming and inside
2391
+ // the replacement gate, so the resolved branch cutoff cannot go stale.
2392
+ const entryId = request.retainCheckpoint === true
2393
+ ? messageHistoryTargetId(session.sessionManager.getBranch(), request.entryId)
2394
+ : request.entryId;
2395
+ const result = await this.commandService.forkEntry(session.sessionId, entryId, {
2396
+ ...options,
2219
2397
  expectedLeafId: request.expectedLeafId,
2398
+ expectedSession: session,
2220
2399
  });
2221
2400
  if (result.type === "unsupported")
2222
2401
  throw new Error(result.message);
@@ -2229,6 +2408,8 @@ export class PiSessionService {
2229
2408
  }
2230
2409
  return { cancelled: true };
2231
2410
  }
2411
+ this.setSuggestedInput(result.session.id, result.promptDraft);
2412
+ this.events.publish(session.sessionId, { type: "session.tree.changed" });
2232
2413
  const forkedSession = this.active.get(result.session.id)?.runtime.session;
2233
2414
  if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
2234
2415
  this.publishActivity(forkedSession, "session forked", "idle");
@@ -2277,9 +2458,16 @@ export class PiSessionService {
2277
2458
  this.publishActivity(session, "reloading resources", "active");
2278
2459
  const priorGeneration = this.notificationGenerationBySession.get(session);
2279
2460
  let candidateGeneration;
2461
+ const reloadBoundary = { beforeSessionStart: false };
2280
2462
  try {
2281
- await session.reload(priorGeneration === undefined ? undefined : {
2463
+ await session.reload({
2282
2464
  beforeSessionStart: () => {
2465
+ // The SDK invalidated the old runner. Settle its dialogs before
2466
+ // replacement session_start handlers can open new ones.
2467
+ this.endSessionExtensionDialogs(session.sessionId);
2468
+ reloadBoundary.beforeSessionStart = true;
2469
+ if (priorGeneration === undefined)
2470
+ return;
2283
2471
  candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2284
2472
  this.notificationGenerationBySession.set(session, candidateGeneration);
2285
2473
  this.replaceSessionNotificationContext(session, candidateGeneration);
@@ -2292,6 +2480,10 @@ export class PiSessionService {
2292
2480
  this.publishStatus(session);
2293
2481
  }
2294
2482
  catch (error) {
2483
+ // Settings/resource loading can fail after SDK invalidation but
2484
+ // before the hook. Never sweep replacement session_start dialogs.
2485
+ if (!reloadBoundary.beforeSessionStart)
2486
+ this.endSessionExtensionDialogs(session.sessionId);
2295
2487
  if (candidateGeneration !== undefined) {
2296
2488
  this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2297
2489
  this.notificationGenerationBySession.set(session, candidateGeneration);
@@ -2400,7 +2592,7 @@ export class PiSessionService {
2400
2592
  async archiveTree(ref) {
2401
2593
  const session = await this.getOrOpen(ref);
2402
2594
  const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
2403
- const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2595
+ const root = findArchiveCandidateByExactId(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2404
2596
  const plan = planSessionArchiveTree(root, catalog);
2405
2597
  const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
2406
2598
  if (busy !== undefined)
@@ -2546,6 +2738,7 @@ export class PiSessionService {
2546
2738
  return;
2547
2739
  const sessionId = active.runtime.session.sessionId;
2548
2740
  this.clearCompactionPromptQueue(sessionId);
2741
+ this.subsessionStoppedParents.add(active.runtime.session);
2549
2742
  clearSessionQueue(active.runtime.session);
2550
2743
  // Settle run-scoped dialogs now, at abort-request time: pi's agent loop
2551
2744
  // waits for a parked `tool_call` dialog handler before it can emit
@@ -2716,6 +2909,7 @@ export class PiSessionService {
2716
2909
  if (pendingOpens.length > 0)
2717
2910
  await Promise.allSettled(pendingOpens);
2718
2911
  const active = this.active.get(sessionId);
2912
+ this.suggestedInputs.delete(sessionId);
2719
2913
  if (notificationPolicy.kind === "clear") {
2720
2914
  const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
2721
2915
  const mutations = generation === undefined
@@ -2726,11 +2920,8 @@ export class PiSessionService {
2726
2920
  if (!active)
2727
2921
  return;
2728
2922
  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.
2923
+ // Extension dialog waiters are parked Promises inside the dying runtime:
2924
+ // settle them rather than dropping them.
2734
2925
  this.endSessionExtensionDialogs(sessionId);
2735
2926
  this.active.delete(sessionId);
2736
2927
  this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
@@ -2908,26 +3099,12 @@ export class PiSessionService {
2908
3099
  return { id: sessionId, cwd: active.runtime.cwd };
2909
3100
  }
2910
3101
  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;
3102
+ const active = this.active.get(ref.id);
3103
+ return active !== undefined && refMatchesActiveSession(ref, active) ? active : undefined;
2920
3104
  }
2921
3105
  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;
3106
+ const session = this.startupSessions.get(ref.id);
3107
+ return session !== undefined && refMatchesStartupSession(ref, session) ? session : undefined;
2931
3108
  }
2932
3109
  /**
2933
3110
  * The session to serve a read-only status or a dialog close for, while it
@@ -3036,7 +3213,13 @@ export class PiSessionService {
3036
3213
  // holds for this session; settle those waits before the new
3037
3214
  // runtime's extensions can open fresh dialogs under the same id.
3038
3215
  this.endSessionExtensionDialogs(boundSession.sessionId);
3216
+ const changedIdentity = boundSession.sessionId !== session.sessionId;
3039
3217
  boundSession = session;
3218
+ // Publish existence as soon as status/dialog APIs can address it,
3219
+ // before session_start or arbitrary withSession code can park on a question.
3220
+ this.startupSessions.set(session.sessionId, session);
3221
+ if (changedIdentity)
3222
+ this.announceCreatedSession(active, session.sessionManager.getCwd());
3040
3223
  await this.bindSessionExtensions(session, candidateGeneration);
3041
3224
  if (candidateGeneration !== undefined) {
3042
3225
  this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
@@ -3111,6 +3294,7 @@ export class PiSessionService {
3111
3294
  await session.bindExtensions({
3112
3295
  uiContext,
3113
3296
  mode: "rpc",
3297
+ commandContextActions: this.extensionCommandActions(session),
3114
3298
  onError: (error) => {
3115
3299
  const message = `${error.extensionPath}: ${error.error}`;
3116
3300
  this.publishActivity(session, "extension error", "error", message);
@@ -3125,6 +3309,83 @@ export class PiSessionService {
3125
3309
  this.startupSessions.delete(session.sessionId);
3126
3310
  }
3127
3311
  }
3312
+ extensionCommandActions(session) {
3313
+ const ref = { id: session.sessionId, cwd: session.sessionManager.getCwd() };
3314
+ return {
3315
+ waitForIdle: () => {
3316
+ this.assertCurrentExtensionSession(session);
3317
+ return session.waitForIdle();
3318
+ },
3319
+ navigateTree: (targetId, options) => {
3320
+ const expectedLeafId = session.sessionManager.getLeafId();
3321
+ return this.runExtensionSessionControl(session, async () => {
3322
+ const result = await this.navigateSessionTree(ref, targetId, expectedLeafId, options ?? { summarize: false }, session);
3323
+ return { cancelled: result.cancelled };
3324
+ });
3325
+ },
3326
+ fork: (entryId, options) => {
3327
+ const expectedLeafId = session.sessionManager.getLeafId();
3328
+ const withSession = options?.withSession;
3329
+ let completeReplacement;
3330
+ const forkOptions = {
3331
+ ...options,
3332
+ position: options?.position ?? "before",
3333
+ // Capture Pi's fresh context, but run user code after the host's
3334
+ // replacement reservation is released so fresh controls can work.
3335
+ ...(withSession === undefined ? {} : { withSession: (ctx) => {
3336
+ completeReplacement = () => withSession(ctx);
3337
+ return Promise.resolve();
3338
+ } }),
3339
+ };
3340
+ return this.runExtensionSessionControl(session, async () => {
3341
+ const runtime = this.active.get(ref.id)?.runtime;
3342
+ const result = await this.forkSessionTree(ref, { entryId, expectedLeafId }, forkOptions, session);
3343
+ if (!result.cancelled) {
3344
+ try {
3345
+ await completeReplacement?.();
3346
+ }
3347
+ catch (error) {
3348
+ // The fork is committed. Report callback failure on the current
3349
+ // runtime (which may have forked again), including its recoverable inbox.
3350
+ const current = runtime?.session;
3351
+ if (current !== undefined && this.isCurrentActiveSession(current)) {
3352
+ const message = errorMessage(error);
3353
+ current.extensionRunner.getUIContext().notify(message, "error");
3354
+ this.publishActivity(current, "extension callback failed", "error", message);
3355
+ this.events.publish(current.sessionId, { type: "session.error", message });
3356
+ }
3357
+ throw error;
3358
+ }
3359
+ }
3360
+ return { cancelled: result.cancelled };
3361
+ });
3362
+ },
3363
+ reload: () => this.runExtensionSessionControl(session, () => this.reloadSessionRuntime(session)),
3364
+ // These need host-owned creation/selection policies, not SDK runtime-only
3365
+ // replacement. Fail visibly until PI WEB supplies that integration.
3366
+ newSession: () => Promise.reject(new Error("ctx.newSession() is not supported in PI WEB. Start a new session from the web UI.")),
3367
+ switchSession: () => Promise.reject(new Error("ctx.switchSession() is not supported in PI WEB. Select a session from the web UI.")),
3368
+ };
3369
+ }
3370
+ setSuggestedInput(sessionId, text) {
3371
+ if (text === undefined || text === "")
3372
+ this.suggestedInputs.delete(sessionId);
3373
+ else
3374
+ this.suggestedInputs.set(sessionId, text);
3375
+ }
3376
+ assertCurrentExtensionSession(session) {
3377
+ if (!this.isCurrentActiveSession(session) || this.startupSessions.has(session.sessionId)) {
3378
+ throw new Error("This extension command belongs to an unavailable session runtime");
3379
+ }
3380
+ }
3381
+ runExtensionSessionControl(session, operation) {
3382
+ return this.extensionCommandExecution.run({ ...this.extensionCommandExecution.getStore(), sessionControl: true }, async () => {
3383
+ this.assertCurrentExtensionSession(session);
3384
+ await this.assertWritable({ id: session.sessionId, cwd: session.sessionManager.getCwd() });
3385
+ this.assertCurrentExtensionSession(session);
3386
+ return operation();
3387
+ });
3388
+ }
3128
3389
  replaceSessionNotificationContext(session, generation) {
3129
3390
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
3130
3391
  }
@@ -3301,9 +3562,30 @@ export class PiSessionService {
3301
3562
  this.clearCompactionPromptQueue(sessionId);
3302
3563
  }
3303
3564
  }
3565
+ this.restoreSubsessionReceipts(session);
3304
3566
  const unsubscribe = session.subscribe((event) => {
3305
3567
  const eventType = getString(event, "type");
3306
3568
  const message = getProperty(event, "message");
3569
+ const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
3570
+ if (eventType === "agent_start") {
3571
+ this.restoreSubsessionReceipts(session, false);
3572
+ this.subsessionStoppedParents.delete(session);
3573
+ }
3574
+ if (eventType === "message_end" && getString(message, "role") === "assistant"
3575
+ && (getString(message, "stopReason") === "aborted" || getString(message, "stopReason") === "error")) {
3576
+ this.subsessionStoppedParents.add(session);
3577
+ }
3578
+ const entry = getProperty(event, "entry");
3579
+ if (eventType === "entry_appended" && getString(entry, "type") === "custom_message"
3580
+ && getString(entry, "customType") === SUBSESSION_NOTIFICATION_CUSTOM_TYPE) {
3581
+ inbox.receive(completionReceiptIds(getProperty(entry, "details")));
3582
+ }
3583
+ // message_end listeners run before SDK persistence. These later
3584
+ // boundaries can safely reconcile committed messages and failed intents.
3585
+ if (eventType === "turn_end" || eventType === "agent_settled")
3586
+ this.restoreSubsessionReceipts(session);
3587
+ if (eventType === "agent_settled")
3588
+ this.scheduleSubsessionDelivery(session);
3307
3589
  if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
3308
3590
  // SDK state advances before async extension hooks finish. Capture only
3309
3591
  // published messages, detaching even blocks left intact by projection.
@@ -3318,23 +3600,18 @@ export class PiSessionService {
3318
3600
  }
3319
3601
  this.events.publish(session.sessionId, clientEvent, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
3320
3602
  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
- }
3603
+ if (eventType === "message_start" && getString(message, "role") === "user")
3604
+ this.suggestedInputs.delete(session.sessionId);
3330
3605
  if (eventType === "agent_end")
3331
3606
  this.abortRunScopedExtensionDialogs(session.sessionId);
3332
3607
  if (eventType === "compaction_end")
3333
3608
  this.scheduleCompactionQueueDrain(session.sessionId);
3609
+ if (eventType === "compaction_end" || eventType === "bash_execution_end")
3610
+ this.scheduleSubsessionDelivery(session);
3334
3611
  if (eventType === "agent_start" || eventType === "agent_end")
3335
3612
  this.scheduleCompactionQueueDrain(session.sessionId);
3336
3613
  this.publishStatus(session);
3337
- this.updateSubsessionTracking(session, eventType === "agent_start");
3614
+ this.updateSubsessionTracking(session, eventType === "agent_start", eventType === "agent_settled");
3338
3615
  });
3339
3616
  active.unsubscribe = () => {
3340
3617
  this.sessionEvents.close(session);
@@ -3405,11 +3682,6 @@ export class PiSessionService {
3405
3682
  maybeGenerateSessionName(session, firstMessage) {
3406
3683
  if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
3407
3684
  return;
3408
- const deterministicName = deterministicSessionName(firstMessage);
3409
- if (deterministicName !== undefined) {
3410
- this.applyGeneratedSessionName(session, deterministicName);
3411
- return;
3412
- }
3413
3685
  const model = session.model;
3414
3686
  if (model === undefined)
3415
3687
  return;
@@ -3440,13 +3712,8 @@ export class PiSessionService {
3440
3712
  this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
3441
3713
  }
3442
3714
  }
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
- }
3715
+ this.restoreSubsessionReceipts(session);
3716
+ this.scheduleSubsessionDelivery(session);
3450
3717
  }
3451
3718
  applyAuthChange(change = {}) {
3452
3719
  // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
@@ -3532,8 +3799,11 @@ export class PiSessionService {
3532
3799
  return "active";
3533
3800
  }
3534
3801
  hasActiveWork(session) {
3802
+ const execution = this.extensionCommandExecution.getStore();
3803
+ const ownCommandReceipt = execution?.sessionControl === true
3804
+ && execution.submission?.active === true && execution.submission.session === session ? 1 : 0;
3535
3805
  return this.treeNavigations.has(session)
3536
- || this.isSessionEntryMutationActive(session)
3806
+ || this.isSessionEntryMutationActive(session, ownCommandReceipt)
3537
3807
  || this.isTreeExclusiveOperationActive(session)
3538
3808
  || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
3539
3809
  }
@@ -3615,9 +3885,11 @@ export class PiSessionService {
3615
3885
  else
3616
3886
  this.sessionEntryMutationCounts.set(session, remaining);
3617
3887
  this.observeUnreadActivityState(session);
3888
+ if (remaining <= 0 && !this.subsessionDeliveringParents.has(session))
3889
+ this.scheduleSubsessionDelivery(session);
3618
3890
  }
3619
- isSessionEntryMutationActive(session) {
3620
- return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
3891
+ isSessionEntryMutationActive(session, ignoredReceipts = 0) {
3892
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > ignoredReceipts;
3621
3893
  }
3622
3894
  publishActivityForEvent(session, event) {
3623
3895
  const eventType = getString(event, "type");
@@ -3764,11 +4036,12 @@ export class PiSessionService {
3764
4036
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3765
4037
  const contextUsage = session.getContextUsage();
3766
4038
  const warnings = this.warningsForSession(session);
3767
- const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
3768
4039
  const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
4040
+ const suggestedInput = this.suggestedInputs.get(session.sessionId);
3769
4041
  return {
3770
4042
  sessionId: session.sessionId,
3771
4043
  persisted: sessionFileExists(session.sessionFile),
4044
+ recentlyActiveElsewhere: this.activityMarker.isActiveElsewhere(session.sessionFile),
3772
4045
  ...(model === undefined ? {} : { model }),
3773
4046
  thinkingLevel: session.thinkingLevel,
3774
4047
  isStreaming: session.isStreaming,
@@ -3781,8 +4054,8 @@ export class PiSessionService {
3781
4054
  cost: stats.cost,
3782
4055
  ...(contextUsage === undefined ? {} : { contextUsage }),
3783
4056
  ...(warnings.length === 0 ? {} : { warnings }),
3784
- ...(pendingAsk === undefined ? {} : { pendingAsk }),
3785
4057
  ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
4058
+ ...(suggestedInput === undefined ? {} : { suggestedInput }),
3786
4059
  };
3787
4060
  }
3788
4061
  /**
@@ -3794,13 +4067,6 @@ export class PiSessionService {
3794
4067
  warningsForSession(session) {
3795
4068
  const runtime = this.active.get(session.sessionId)?.runtime;
3796
4069
  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
4070
  const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3805
4071
  if (anthropic !== undefined)
3806
4072
  warnings.push(anthropic);
@@ -3841,13 +4107,13 @@ function bulkRefToSessionRef(ref) {
3841
4107
  return { id: ref.id, cwd: ref.cwd };
3842
4108
  }
3843
4109
  function findArchivedRecordForBulkRef(records, ref) {
3844
- return records.find((record) => record.cwd === ref.cwd && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
4110
+ return records.find((record) => record.cwd === ref.cwd && record.sessionId === ref.id);
3845
4111
  }
3846
4112
  function findListedSessionForBulkRef(context, ref) {
3847
- return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
4113
+ return findSessionByExactId(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
3848
4114
  }
3849
- function findSessionByIdOrPrefix(sessions, sessionId) {
3850
- return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
4115
+ function findSessionByExactId(sessions, sessionId) {
4116
+ return sessions.find((session) => session.id === sessionId);
3851
4117
  }
3852
4118
  function uniqueStrings(values) {
3853
4119
  return [...new Set(values)];
@@ -3888,12 +4154,35 @@ function notificationIdentityForSession(session) {
3888
4154
  cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3889
4155
  };
3890
4156
  }
4157
+ function metadataFromActiveSession(session) {
4158
+ return readSessionUiMetadata(session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch());
4159
+ }
4160
+ function clientSessionFromActiveSession(session) {
4161
+ const header = session.sessionManager.getHeader?.();
4162
+ const created = header?.timestamp ?? new Date().toISOString();
4163
+ const metadata = metadataFromActiveSession(session);
4164
+ return {
4165
+ id: session.sessionId,
4166
+ path: session.sessionFile ?? "",
4167
+ cwd: session.sessionManager.getCwd(),
4168
+ persisted: sessionFileExists(session.sessionFile),
4169
+ created,
4170
+ modified: created,
4171
+ messageCount: session.messages.length,
4172
+ firstMessage: "",
4173
+ ...(metadata === undefined ? {} : { metadata }),
4174
+ ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
4175
+ ...(header?.parentSession === undefined ? {} : { parentSessionPath: header.parentSession }),
4176
+ };
4177
+ }
3891
4178
  function clientSessionFromListEntry(session) {
3892
4179
  return {
3893
4180
  id: session.id,
3894
4181
  path: session.path,
3895
4182
  cwd: session.cwd,
3896
4183
  persisted: true,
4184
+ ...(session.agentSubsession === true ? { agentSubsession: true } : {}),
4185
+ ...(session.metadata === undefined ? {} : { metadata: requireSessionUiMetadata(session.metadata) }),
3897
4186
  ...(session.name === undefined ? {} : { name: session.name }),
3898
4187
  created: session.created.toISOString(),
3899
4188
  modified: session.modified.toISOString(),
@@ -3911,6 +4200,7 @@ function archiveInputFromListEntry(session) {
3911
4200
  modified: session.modified.toISOString(),
3912
4201
  messageCount: session.messageCount,
3913
4202
  firstMessage: session.firstMessage,
4203
+ ...(session.metadata === undefined ? {} : { metadata: session.metadata }),
3914
4204
  ...(session.name === undefined ? {} : { name: session.name }),
3915
4205
  ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3916
4206
  };
@@ -3920,6 +4210,7 @@ function archiveInputFromActiveSession(session) {
3920
4210
  if (sessionFile === undefined || sessionFile === "")
3921
4211
  throw new Error("Session is not persisted");
3922
4212
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
4213
+ const metadata = metadataFromActiveSession(session);
3923
4214
  return {
3924
4215
  sessionId: session.sessionId,
3925
4216
  cwd: session.sessionManager.getCwd(),
@@ -3928,6 +4219,7 @@ function archiveInputFromActiveSession(session) {
3928
4219
  modified: new Date().toISOString(),
3929
4220
  messageCount: session.messages.length,
3930
4221
  firstMessage: "",
4222
+ ...(metadata === undefined ? {} : { metadata }),
3931
4223
  ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
3932
4224
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3933
4225
  };
@@ -3992,11 +4284,13 @@ function clientSessionFromArchivedRecord(record, fallback) {
3992
4284
  if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
3993
4285
  return undefined;
3994
4286
  const name = record.name ?? fallback?.name;
4287
+ const metadata = record.metadata ?? fallback?.metadata;
3995
4288
  const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
3996
4289
  return {
3997
4290
  id: record.sessionId,
3998
4291
  path,
3999
4292
  cwd: record.cwd,
4293
+ ...(metadata === undefined ? {} : { metadata: requireSessionUiMetadata(metadata) }),
4000
4294
  ...(name === undefined ? {} : { name }),
4001
4295
  created,
4002
4296
  modified,
@@ -4110,37 +4404,6 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
4110
4404
  spawnedSessionId: childSessionId,
4111
4405
  };
4112
4406
  }
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
4407
  function nonEmptyString(value) {
4145
4408
  return value === undefined || value === "" ? undefined : value;
4146
4409
  }
@@ -4258,6 +4521,55 @@ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
4258
4521
  return undefined;
4259
4522
  return parentId === null || typeof parentId === "string" ? parentId : undefined;
4260
4523
  }
4524
+ /**
4525
+ * Pi edits custom messages from their parent. For a retained checkpoint, remap
4526
+ * that single leaf movement instead, before Pi rebuilds context/restores tools
4527
+ * and emits session_tree. Restore the manager methods before extension handlers
4528
+ * run: their appended history must not be overwritten by a post-event repair.
4529
+ * The caller holds the tree-navigation gate and excludes branch summarization.
4530
+ */
4531
+ async function navigateTreeRetainingCustomMessage(manager, targetId, parentId, navigate) {
4532
+ if (parentId !== null && typeof parentId !== "string")
4533
+ throw new Error("This checkpoint has no valid history parent");
4534
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- Restore exact identities; calls below explicitly bind the manager.
4535
+ const { branch, resetLeaf } = manager;
4536
+ let pending = true;
4537
+ const restore = () => {
4538
+ if (!pending)
4539
+ return;
4540
+ pending = false;
4541
+ manager.branch = branch;
4542
+ manager.resetLeaf = resetLeaf;
4543
+ };
4544
+ try {
4545
+ manager.branch = (entryId) => {
4546
+ if (entryId === parentId) {
4547
+ restore();
4548
+ branch.call(manager, targetId);
4549
+ }
4550
+ else
4551
+ branch.call(manager, entryId);
4552
+ };
4553
+ manager.resetLeaf = () => {
4554
+ if (parentId === null) {
4555
+ restore();
4556
+ branch.call(manager, targetId);
4557
+ }
4558
+ else
4559
+ resetLeaf.call(manager);
4560
+ };
4561
+ const result = await navigate();
4562
+ if (result.cancelled)
4563
+ return result;
4564
+ // The retained notice is context, not text to restore into the editor.
4565
+ const { editorText, ...retainedResult } = result;
4566
+ void editorText;
4567
+ return retainedResult;
4568
+ }
4569
+ finally {
4570
+ restore();
4571
+ }
4572
+ }
4261
4573
  function setSessionTreeLeaf(manager, leafId) {
4262
4574
  if (leafId === null)
4263
4575
  manager.resetLeaf();
@@ -4271,15 +4583,22 @@ function transcriptBranchIncludesEntry(entries, entryId) {
4271
4583
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
4272
4584
  /** custom entry type used to mark a child as created by spawn_subsession. */
4273
4585
  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}`;
4586
+ /** A filtered read is a receipt only when it contains the complete latest final text. */
4587
+ function transcriptReturnsFinalOutput(messages, view, finalText) {
4588
+ if (finalText === "")
4589
+ return false;
4590
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
4591
+ const text = finalAssistantText([messages[index]]);
4592
+ if (text === "")
4593
+ continue;
4594
+ const entry = view.entries.find((candidate) => candidate.index === index && candidate.role === "assistant");
4595
+ if (entry === undefined || text !== finalText)
4596
+ return false;
4597
+ const texts = entry.parts.filter((part) => part.kind === "text");
4598
+ return texts.every((part) => part.truncated === undefined)
4599
+ && texts.map((part) => part.text).join("\n").trim() === finalText.trim();
4600
+ }
4601
+ return false;
4283
4602
  }
4284
4603
  /** Most recent assistant text from a history message list, or "" if none. */
4285
4604
  function finalAssistantText(messages) {