@jmfederico/pi-web 1.202607.0 → 1.202607.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 (119) hide show
  1. package/README.md +12 -9
  2. package/dist/cli.js +305 -322
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-tXF3UTsY.js → CodeViewer-B4M13447.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-D_ihiWAt.js → TerminalPanel-Bmvj-Ecn.js} +1 -1
  6. package/dist/client/assets/{UnifiedDiffViewer-BziirXBJ.js → UnifiedDiffViewer-C0hygwRo.js} +1 -1
  7. package/dist/client/assets/index-TvPsRyS1.js +3545 -0
  8. package/dist/client/index.html +6 -6
  9. package/dist/client/manifest.webmanifest +4 -4
  10. package/dist/config.js +157 -2
  11. package/dist/config.js.map +1 -1
  12. package/dist/nativeServices/serviceDoctor.js +480 -0
  13. package/dist/nativeServices/serviceDoctor.js.map +1 -0
  14. package/dist/nativeServices/serviceInstall.js +35 -0
  15. package/dist/nativeServices/serviceInstall.js.map +1 -0
  16. package/dist/nativeServices/servicePlan.js +358 -0
  17. package/dist/nativeServices/servicePlan.js.map +1 -0
  18. package/dist/nativeServices/serviceProbe.js +447 -0
  19. package/dist/nativeServices/serviceProbe.js.map +1 -0
  20. package/dist/nativeServices/serviceRendering.js +122 -0
  21. package/dist/nativeServices/serviceRendering.js.map +1 -0
  22. package/dist/pi-web-plugins/updates/pi-web-plugin.js +12 -0
  23. package/dist/plugin-api.d.ts +2 -0
  24. package/dist/server/activeAgentProfileProvider.js +25 -0
  25. package/dist/server/activeAgentProfileProvider.js.map +1 -0
  26. package/dist/server/app.js +57 -16
  27. package/dist/server/app.js.map +1 -1
  28. package/dist/server/configRoutes.js +43 -9
  29. package/dist/server/configRoutes.js.map +1 -1
  30. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  31. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  32. package/dist/server/git/gitService.js +176 -5
  33. package/dist/server/git/gitService.js.map +1 -1
  34. package/dist/server/machines/machinePluginProxyRoutes.js +7 -4
  35. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  36. package/dist/server/machines/machineProxyRoutes.js +16 -4
  37. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  38. package/dist/server/machines/machineRoutes.js +1 -1
  39. package/dist/server/machines/machineRoutes.js.map +1 -1
  40. package/dist/server/machines/machineService.js +2 -2
  41. package/dist/server/machines/machineService.js.map +1 -1
  42. package/dist/server/piPackageRoutes.js +7 -3
  43. package/dist/server/piPackageRoutes.js.map +1 -1
  44. package/dist/server/piPackageService.js +34 -2
  45. package/dist/server/piPackageService.js.map +1 -1
  46. package/dist/server/piWebPluginService.js +48 -12
  47. package/dist/server/piWebPluginService.js.map +1 -1
  48. package/dist/server/piWebReleaseLookupCache.js +37 -0
  49. package/dist/server/piWebReleaseLookupCache.js.map +1 -0
  50. package/dist/server/piWebStatus.js +38 -39
  51. package/dist/server/piWebStatus.js.map +1 -1
  52. package/dist/server/piWebStatusCache.js +19 -6
  53. package/dist/server/piWebStatusCache.js.map +1 -1
  54. package/dist/server/realtime/sessionEventHub.js +37 -7
  55. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  56. package/dist/server/sessiond/sessionDaemonStartup.js +49 -0
  57. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -0
  58. package/dist/server/sessiond.js +128 -62
  59. package/dist/server/sessiond.js.map +1 -1
  60. package/dist/server/sessions/authProviderOptions.js +30 -32
  61. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  62. package/dist/server/sessions/authRoutes.js +14 -4
  63. package/dist/server/sessions/authRoutes.js.map +1 -1
  64. package/dist/server/sessions/authService.js +140 -29
  65. package/dist/server/sessions/authService.js.map +1 -1
  66. package/dist/server/sessions/globalProviderPolicy.js +98 -0
  67. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  68. package/dist/server/sessions/modelCatalogRefresher.js +184 -0
  69. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  70. package/dist/server/sessions/oauthLoginFlowService.js +176 -53
  71. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  72. package/dist/server/sessions/piSessionManagerGateway.js +22 -21
  73. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  74. package/dist/server/sessions/piSessionService.js +936 -111
  75. package/dist/server/sessions/piSessionService.js.map +1 -1
  76. package/dist/server/sessions/plainTextTheme.js +112 -0
  77. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  78. package/dist/server/sessions/sessionArchiveMigration.js +590 -0
  79. package/dist/server/sessions/sessionArchiveMigration.js.map +1 -0
  80. package/dist/server/sessions/sessionArchiveStore.js +7 -4
  81. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  82. package/dist/server/sessions/sessionCommandService.js +68 -9
  83. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  84. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  85. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  86. package/dist/server/sessions/sessionRoutes.js +202 -2
  87. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  88. package/dist/server/sessions/sessionService.js +2 -0
  89. package/dist/server/sessions/sessionService.js.map +1 -0
  90. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  91. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  92. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  93. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  94. package/dist/server/sessions/spawnSessionTool.js +2 -2
  95. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  96. package/dist/server/sessions/spawnSubsessionTool.js +69 -31
  97. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  98. package/dist/server/terminals/terminalService.js +10 -4
  99. package/dist/server/terminals/terminalService.js.map +1 -1
  100. package/dist/sessiond/activeAgentProfile.js +31 -0
  101. package/dist/sessiond/activeAgentProfile.js.map +1 -0
  102. package/dist/sessiond/sessionDaemonClient.js +38 -0
  103. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  104. package/dist/shared/activeAgentProfile.js +76 -0
  105. package/dist/shared/activeAgentProfile.js.map +1 -0
  106. package/dist/shared/apiTypes.d.ts +256 -4
  107. package/dist/shared/apiTypes.js +12 -0
  108. package/dist/shared/apiTypes.js.map +1 -1
  109. package/dist/shared/capabilities.js +11 -0
  110. package/dist/shared/capabilities.js.map +1 -1
  111. package/dist/shared/federatedRoutes.js +12 -0
  112. package/dist/shared/federatedRoutes.js.map +1 -1
  113. package/dist/shared/piWebStatusParsing.js +6 -0
  114. package/dist/shared/piWebStatusParsing.js.map +1 -1
  115. package/docs/config.md +85 -12
  116. package/docs/plugins.md +29 -11
  117. package/install.sh +1 -1
  118. package/package.json +9 -8
  119. package/dist/client/assets/index-Brfk1lwl.js +0 -2950
@@ -1,22 +1,31 @@
1
1
  import { statSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { open, readFile, writeFile } from "node:fs/promises";
3
- import { AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, getAgentDir, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
5
+ import { projectBrowserMessage } from "../browserMessageProjection.js";
4
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
5
7
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
6
8
  import { SessionCommandService } from "./sessionCommandService.js";
9
+ import { projectSessionTree } from "./sessionTreeProjection.js";
7
10
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
8
11
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
9
12
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
10
13
  import { computeEditPreview } from "./editPreview.js";
11
- import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
12
14
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
13
15
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
+ import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
14
17
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
15
18
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
16
19
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
17
20
  import { buildTranscriptView } from "./subsessionTranscript.js";
18
21
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
22
+ import { SessionNotificationStore, } from "./sessionNotificationStore.js";
23
+ import { plainTextTheme } from "./plainTextTheme.js";
24
+ import { SessionUnreadStore } from "./sessionUnreadStore.js";
19
25
  const noopLogger = { info() { } };
26
+ const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
27
+ const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
28
+ const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
20
29
  function noop() {
21
30
  // Intentionally empty default unsubscribe callback.
22
31
  }
@@ -37,6 +46,37 @@ function isPiSessionRef(ref) {
37
46
  function lookupMatchesActiveSession(ref, active) {
38
47
  return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
39
48
  }
49
+ function sessionTreeNavigationOptions(request) {
50
+ switch (request.summary.mode) {
51
+ case "none":
52
+ return { summarize: false };
53
+ case "default":
54
+ return { summarize: true };
55
+ case "custom": {
56
+ const customInstructions = request.summary.instructions.trim();
57
+ if (customInstructions === "")
58
+ throw new Error("Custom branch-summary instructions are required");
59
+ if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
60
+ throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
61
+ }
62
+ return { summarize: true, customInstructions };
63
+ }
64
+ }
65
+ }
66
+ function decrementWeakCount(counts, key) {
67
+ const remaining = (counts.get(key) ?? 1) - 1;
68
+ if (remaining <= 0)
69
+ counts.delete(key);
70
+ else
71
+ counts.set(key, remaining);
72
+ }
73
+ function decrementMapCount(counts, key) {
74
+ const remaining = (counts.get(key) ?? 1) - 1;
75
+ if (remaining <= 0)
76
+ counts.delete(key);
77
+ else
78
+ counts.set(key, remaining);
79
+ }
40
80
  function requirePromptText(value) {
41
81
  if (typeof value !== "string")
42
82
  throw new Error("Prompt text is required");
@@ -49,6 +89,109 @@ function parsePromptStreamingBehavior(value) {
49
89
  return value;
50
90
  throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
51
91
  }
92
+ const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
93
+ const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
94
+ function resourceDiagnosticToWarning(diagnostic, source) {
95
+ return {
96
+ severity: diagnostic.type === "error" ? "error" : "warning",
97
+ message: diagnostic.message,
98
+ source,
99
+ ...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
100
+ };
101
+ }
102
+ function runtimeDiagnosticToWarning(diagnostic) {
103
+ return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
104
+ }
105
+ /**
106
+ * Compute the live warnings for a runtime by re-reading its current resource
107
+ * loader diagnostics, extension load errors, and runtime setup diagnostics.
108
+ *
109
+ * This mimics the TUI recomputing warnings on every (re)bind: it reads the
110
+ * runtime's current state rather than a cached snapshot, so a rebuilt runtime
111
+ * yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
112
+ * contribute no warnings.
113
+ */
114
+ export function collectRuntimeWarnings(runtime) {
115
+ const warnings = [];
116
+ for (const diagnostic of runtime.diagnostics ?? [])
117
+ warnings.push(runtimeDiagnosticToWarning(diagnostic));
118
+ const resourceLoader = runtime.services?.resourceLoader;
119
+ if (resourceLoader !== undefined) {
120
+ for (const diagnostic of resourceLoader.getSkills().diagnostics)
121
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
122
+ for (const diagnostic of resourceLoader.getPrompts().diagnostics)
123
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
124
+ for (const diagnostic of resourceLoader.getThemes().diagnostics)
125
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
126
+ for (const error of resourceLoader.getExtensions().errors) {
127
+ warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
128
+ }
129
+ }
130
+ return warnings;
131
+ }
132
+ /**
133
+ * Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
134
+ * character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
135
+ * the SDK's interactive mode so the browser shows the same message the TUI does.
136
+ */
137
+ const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
138
+ /** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
139
+ function isAnthropicSubscriptionAuthKey(apiKey) {
140
+ return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
141
+ }
142
+ /**
143
+ * Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
144
+ * own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
145
+ * coupling `pi` already defines rather than inventing a parallel vocabulary, and
146
+ * {@link dismissSessionWarning} maps it back to `setWarnings`.
147
+ */
148
+ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
149
+ /**
150
+ * Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
151
+ * live from the session's current model, stored Anthropic credential, and warning
152
+ * settings. Returns the billing warning when the active provider is `anthropic`
153
+ * and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
154
+ * key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
155
+ *
156
+ * The stored credential is read synchronously (matching the TUI's `oauth` branch
157
+ * and the documented `sk-ant-oat` key trigger) so warnings stay part of the
158
+ * synchronous live status computation.
159
+ */
160
+ export function anthropicSubscriptionWarning(session, authPath) {
161
+ if (session.settingsManager.getWarnings().anthropicExtraUsage === false)
162
+ return undefined;
163
+ if (session.model?.provider !== "anthropic")
164
+ return undefined;
165
+ const credential = readStoredCredential("anthropic", authPath);
166
+ if (credential === undefined)
167
+ return undefined;
168
+ const isSubscriptionAuth = credential.type === "oauth"
169
+ ? true
170
+ : isAnthropicSubscriptionAuthKey(credential.key);
171
+ if (!isSubscriptionAuth)
172
+ return undefined;
173
+ return {
174
+ severity: "warning",
175
+ message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
176
+ source: "anthropic",
177
+ dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
178
+ };
179
+ }
180
+ /**
181
+ * Durably suppress a dismissable session warning by mapping its opaque dismiss
182
+ * id back to the concrete `pi` suppression it represents. Only known ids are
183
+ * honored; unknown ids throw so a stale/forged client cannot silently no-op.
184
+ *
185
+ * This is the single place provider-specific suppression lives: the wire type,
186
+ * parser, and UI stay agnostic. Adding a future dismissable warning is a
187
+ * server-only change here plus a `dismiss` id on its producer.
188
+ */
189
+ export function dismissSessionWarning(session, dismissId) {
190
+ if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
191
+ throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
192
+ }
193
+ session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
194
+ }
52
195
  function defaultCreateAgentRuntime(createRuntime, options) {
53
196
  if (!(options.sessionManager instanceof SessionManager))
54
197
  throw new Error("Default runtime creation requires an SDK SessionManager");
@@ -83,9 +226,9 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
83
226
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
84
227
  ];
85
228
  }
86
- function createDefaultRuntimeFactory(authStorage, modelRegistry, sessionManagers, spawn, subsessions) {
229
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
87
230
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
88
- const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
231
+ const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
89
232
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
90
233
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
91
234
  const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -121,11 +264,20 @@ function createPiWebEditToolDefinition(cwd) {
121
264
  });
122
265
  }
123
266
  export class PiSessionService {
124
- constructor(events, deps = {}) {
267
+ constructor(events, deps) {
125
268
  this.events = events;
126
269
  this.active = new Map();
127
270
  this.pendingSessionOpens = new Map();
128
271
  this.activities = new Map();
272
+ /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
273
+ this.treeNavigations = new WeakSet();
274
+ /** Counts async operations that may append an entry before they settle. */
275
+ this.sessionEntryMutationCounts = new WeakMap();
276
+ /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
277
+ this.treeExclusiveRuntimeOperationCounts = new WeakMap();
278
+ this.treeExclusiveSessionOperationCounts = new Map();
279
+ this.deferredSubsessionNotifications = new WeakMap();
280
+ this.deferredGeneratedSessionNames = new WeakMap();
129
281
  this.compactionPromptQueues = new Map();
130
282
  this.compactionDrainTimers = new Map();
131
283
  this.authLossWarnings = new Set();
@@ -143,17 +295,25 @@ export class PiSessionService {
143
295
  * child that works again (and stops again) notifies the parent each time.
144
296
  */
145
297
  this.subsessionNotifyArmed = new Map();
298
+ this.notificationGenerationBySession = new WeakMap();
299
+ this.pendingUnreadMutations = [];
300
+ this.unreadPublicationFlushRequested = false;
301
+ this.unreadPublicationStopped = false;
146
302
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
147
- this.agentDir = deps.agentDir ?? getAgentDir();
148
- this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
149
- this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
303
+ this.agentDir = deps.agentDir;
304
+ this.sessionManager = deps.sessionManager;
305
+ this.modelRuntime = deps.modelRuntime;
150
306
  this.spawnTargets = deps.spawnTargets;
151
307
  this.logger = deps.logger ?? noopLogger;
152
308
  this.now = deps.now ?? (() => new Date());
309
+ this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
310
+ this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
311
+ this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
312
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
153
313
  // Subsessions are a beta capability gated behind their own flag, and they
154
314
  // also require the spawn capability (they share its project-scope resolver).
155
315
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
156
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
316
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
157
317
  spawn: (input) => this.spawnSubsession(input),
158
318
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
159
319
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
@@ -164,19 +324,57 @@ export class PiSessionService {
164
324
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
165
325
  this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
166
326
  onCompactionStart: (session) => {
327
+ this.beginSessionEntryMutation(session, "compact the session");
167
328
  this.publishActivity(session, "compacting", "active");
168
329
  this.publishStatus(session);
169
330
  },
170
331
  onCompactionEnd: (session, result, detail) => {
332
+ this.endSessionEntryMutation(session);
171
333
  this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
172
334
  this.publishStatus(session);
173
335
  },
174
336
  reloadSession: (session) => this.reloadSessionRuntime(session),
337
+ getSessionTree: (session) => {
338
+ if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
339
+ return undefined;
340
+ return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
341
+ },
342
+ hasActiveWork: (session) => this.hasActiveWork(session),
343
+ isTreeNavigationActive: (session) => this.treeNavigations.has(session),
344
+ runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before replacing the session", operation),
175
345
  }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
176
346
  }
177
347
  activeCount() {
178
348
  return this.active.size;
179
349
  }
350
+ notificationCatalog() {
351
+ return this.notificationStore.catalogSnapshot();
352
+ }
353
+ async unreadCatalog() {
354
+ await this.publishUnreadMutations([]);
355
+ return this.unreadStore.durableCatalogSnapshot();
356
+ }
357
+ async acknowledgeUnread(sessionId, request) {
358
+ const result = this.unreadStore.acknowledge(sessionId, {
359
+ ...request,
360
+ cwd: canonicalizeStoredCwd(request.cwd),
361
+ });
362
+ await this.publishUnreadMutations(result.mutations);
363
+ return this.unreadStore.durableCatalogSnapshot();
364
+ }
365
+ notificationInbox(ref) {
366
+ return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
367
+ }
368
+ dismissNotification(ref, request) {
369
+ const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
370
+ this.publishNotificationMutations(result.mutations);
371
+ return result.snapshot;
372
+ }
373
+ dismissAllNotifications(ref, request) {
374
+ const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
375
+ this.publishNotificationMutations(result.mutations);
376
+ return result.snapshot;
377
+ }
180
378
  async cleanupPreview(request) {
181
379
  return previewResponseFromPlan(await this.cleanupPlan(request));
182
380
  }
@@ -194,22 +392,24 @@ export class PiSessionService {
194
392
  skippedBusySessionIds.add(input.sessionId);
195
393
  continue;
196
394
  }
197
- await this.closeActive(input.sessionId);
395
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
198
396
  readyArchiveInputs.push(input);
199
397
  }
200
398
  await this.archiveStoreArchiveMany(readyArchiveInputs);
201
399
  archiveInputs.push(...readyArchiveInputs);
400
+ await this.forgetUnreadSessions(readyArchiveInputs);
202
401
  for (const record of plan.deleteRecords) {
203
402
  if (this.activeSessionHasWork(record.sessionId)) {
204
403
  skippedBusySessionIds.add(record.sessionId);
205
404
  continue;
206
405
  }
207
- await this.closeActive(record.sessionId);
406
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
208
407
  readyDeleteRecords.push(record);
209
408
  }
210
409
  await this.ensureArchivedRecordsMoved(readyDeleteRecords);
211
410
  const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
212
411
  deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
412
+ await this.forgetUnreadSessions(deleteRecords);
213
413
  return summarizeSessionCleanupExecution({
214
414
  archiveInputs,
215
415
  deleteRecords,
@@ -219,12 +419,16 @@ export class PiSessionService {
219
419
  });
220
420
  }
221
421
  async dispose() {
422
+ this.unreadPublicationStopped = true;
423
+ this.clearUnreadPublicationRetry();
222
424
  clearInterval(this.heartbeat);
223
425
  this.clearCompactionDrainTimers();
224
426
  const pendingOpens = this.pendingSessionOpenPromises();
225
427
  if (pendingOpens.length > 0)
226
428
  await Promise.allSettled(pendingOpens);
227
429
  const activeSessions = Array.from(new Set(this.active.values()));
430
+ for (const active of activeSessions)
431
+ this.forgetUnreadActivity(active.runtime.session);
228
432
  this.active.clear();
229
433
  this.pendingSessionOpens.clear();
230
434
  this.activities.clear();
@@ -235,16 +439,19 @@ export class PiSessionService {
235
439
  this.subsessionLinks.clear();
236
440
  this.subsessionHydratedParents.clear();
237
441
  this.subsessionNotifyArmed.clear();
442
+ this.notificationStore.clearAll("service-dispose");
238
443
  await Promise.all(activeSessions.map(async (active) => {
239
444
  active.unsubscribe();
445
+ active.runtime.setRebindSession(undefined);
240
446
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
241
447
  try {
242
- await active.runtime.session.abort();
448
+ await this.abortSessionOperations(active.runtime.session);
243
449
  }
244
450
  finally {
245
451
  await active.runtime.dispose();
246
452
  }
247
453
  }));
454
+ await this.publishUnreadMutations([]);
248
455
  }
249
456
  async list(cwd) {
250
457
  const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
@@ -253,8 +460,13 @@ export class PiSessionService {
253
460
  .filter((record) => record.cwd === cwd)
254
461
  .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
255
462
  const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
463
+ for (const record of archivedForCwd) {
464
+ this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
465
+ }
256
466
  const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
257
- this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById));
467
+ const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
468
+ this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
469
+ await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
258
470
  const archivedSessions = archivedForCwd
259
471
  .sort(compareArchivedRecords)
260
472
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
@@ -329,7 +541,7 @@ export class PiSessionService {
329
541
  ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
330
542
  cwd: decision.cwd,
331
543
  };
332
- this.registerVerifiedSubsession(link);
544
+ await this.registerVerifiedSubsession(link);
333
545
  this.persistSubsessionLink(link);
334
546
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
335
547
  await this.prompt(created.id, input.prompt);
@@ -404,7 +616,7 @@ export class PiSessionService {
404
616
  return undefined;
405
617
  return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
406
618
  }
407
- registerVerifiedSubsession(link) {
619
+ async registerVerifiedSubsession(link) {
408
620
  const { childSessionId, parentSessionId } = link;
409
621
  const previousParentId = this.subsessionParents.get(childSessionId);
410
622
  if (previousParentId !== undefined && previousParentId !== parentSessionId) {
@@ -420,6 +632,24 @@ export class PiSessionService {
420
632
  this.subsessionLinks.set(childSessionId, link);
421
633
  if (!this.subsessionNotifyArmed.has(childSessionId))
422
634
  this.subsessionNotifyArmed.set(childSessionId, false);
635
+ const cwd = this.cwdForVerifiedSubsession(link);
636
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
637
+ }
638
+ cwdForVerifiedSubsession(link) {
639
+ const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
640
+ const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
641
+ if (linkedCwd !== undefined)
642
+ return canonicalizeStoredCwd(linkedCwd);
643
+ const childSessionFile = link.childSessionFile;
644
+ if (childSessionFile !== undefined) {
645
+ try {
646
+ return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
647
+ }
648
+ catch (error) {
649
+ throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
650
+ }
651
+ }
652
+ throw new Error("Could not resolve cwd for verified tracked sub-session");
423
653
  }
424
654
  unregisterSubsession(childSessionId) {
425
655
  const parentSessionId = this.subsessionParents.get(childSessionId);
@@ -466,40 +696,44 @@ export class PiSessionService {
466
696
  const activeParent = this.active.get(parentSessionId);
467
697
  if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
468
698
  const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
469
- await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
470
- this.subsessionHydratedParents.add(hydrationKey);
699
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
700
+ if (complete)
701
+ this.subsessionHydratedParents.add(hydrationKey);
471
702
  return;
472
703
  }
473
704
  if (parentSessionFile === undefined)
474
705
  return;
475
- if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
476
- this.subsessionHydratedParents.add(hydrationKey);
706
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
477
707
  return;
478
- }
479
708
  let parentManager;
480
709
  try {
481
710
  parentManager = this.sessionManager.open(parentSessionFile);
482
711
  }
483
712
  catch {
484
- this.subsessionHydratedParents.add(hydrationKey);
485
713
  return;
486
714
  }
487
- await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
488
- this.subsessionHydratedParents.add(hydrationKey);
715
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
716
+ if (complete)
717
+ this.subsessionHydratedParents.add(hydrationKey);
489
718
  }
490
719
  async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
491
720
  // Parent custom links are the authoritative recovery record: verify the
492
- // exact live child file/header before tracking.
721
+ // exact live child file/header before tracking. Do not negatively cache a
722
+ // scan while a candidate child is temporarily unavailable.
493
723
  const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
724
+ let complete = true;
494
725
  for (const entry of entries) {
495
726
  const link = parsePersistedParentSubsessionLink(entry);
496
- if (link === undefined)
727
+ if (link?.spawnedBySessionId !== parentSessionId)
497
728
  continue;
498
729
  const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
499
- if (verified === undefined)
730
+ if (verified === undefined) {
731
+ complete = false;
500
732
  continue;
501
- this.registerVerifiedSubsession(verified);
733
+ }
734
+ await this.registerVerifiedSubsession(verified);
502
735
  }
736
+ return complete;
503
737
  }
504
738
  async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
505
739
  if (parentSessionFile === undefined)
@@ -518,7 +752,7 @@ export class PiSessionService {
518
752
  const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
519
753
  if (link === undefined)
520
754
  return;
521
- this.registerVerifiedSubsession(link);
755
+ await this.registerVerifiedSubsession(link);
522
756
  }
523
757
  verifiedSubsessionLinkFromOpenedChild(session) {
524
758
  return verifiedTrackedSubsessionLink(this.sessionManager, {
@@ -563,6 +797,16 @@ export class PiSessionService {
563
797
  return "error";
564
798
  return "idle";
565
799
  }
800
+ workingSubsessionIds(parentSessionId) {
801
+ const childIds = this.subsessionChildren.get(parentSessionId);
802
+ if (childIds === undefined)
803
+ return [];
804
+ return [...childIds].filter((childId) => {
805
+ const link = this.subsessionLinks.get(childId);
806
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
807
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
808
+ });
809
+ }
566
810
  /**
567
811
  * Drive parent notifications from a tracked child's status. Arms a pending
568
812
  * notification while the child is working, and when it stops fires a single
@@ -583,8 +827,12 @@ export class PiSessionService {
583
827
  this.subsessionNotifyArmed.set(childId, false);
584
828
  const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
585
829
  const finalText = finalAssistantText(historyMessages(session));
586
- const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
587
- const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`;
830
+ const outputSection = formatSubsessionNotificationOutput(childId, finalText);
831
+ const workingIds = this.workingSubsessionIds(link.parentSessionId);
832
+ const next = workingIds.length === 0
833
+ ? "No other tracked subsessions are working."
834
+ : `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.`;
835
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
588
836
  void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
589
837
  }
590
838
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
@@ -613,13 +861,25 @@ export class PiSessionService {
613
861
  async notifyParentOfSubsession(parentId, childId, text) {
614
862
  try {
615
863
  const session = await this.getOrOpenParentForSubsession(parentId, childId);
616
- await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
617
- this.publishStatus(session);
864
+ if (this.treeNavigations.has(session)) {
865
+ const pending = this.deferredSubsessionNotifications.get(session) ?? [];
866
+ pending.push({ parentId, childId, text });
867
+ this.deferredSubsessionNotifications.set(session, pending);
868
+ return;
869
+ }
870
+ await this.deliverSubsessionNotification(session, { parentId, childId, text });
618
871
  }
619
872
  catch (error) {
620
- this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
873
+ this.logSubsessionNotificationFailure(parentId, childId, error);
621
874
  }
622
875
  }
876
+ async deliverSubsessionNotification(session, notification) {
877
+ 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" }));
878
+ this.publishStatus(session);
879
+ }
880
+ logSubsessionNotificationFailure(parentId, childId, error) {
881
+ this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
882
+ }
623
883
  async messages(ref, page) {
624
884
  const session = await this.getOrOpen(ref);
625
885
  return pageMessagesAtSafeBoundary(historyMessages(session), page);
@@ -627,26 +887,47 @@ export class PiSessionService {
627
887
  async status(ref) {
628
888
  return this.statusFromSession(await this.getOrOpen(ref));
629
889
  }
890
+ /**
891
+ * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
892
+ * the partial are read together in one synchronous tick (no await between the
893
+ * `currentSeq` read and the `state.streamingMessage` read) so a joining client
894
+ * can seed the partial and then apply only buffered live events with
895
+ * `seq > snapshot.seq`. The partial is browser-projected to strip thinking
896
+ * signatures; it is `null` when no assistant message is mid-stream.
897
+ */
898
+ async streamSnapshot(ref) {
899
+ const session = await this.getOrOpen(ref);
900
+ // Single consistent tick: capture the watermark and the partial together so
901
+ // the seq matches the partial the client seeds against.
902
+ const seq = this.events.currentSeq(session.sessionId);
903
+ const streamingMessage = session.state.streamingMessage;
904
+ const partial = streamingMessage === undefined || streamingMessage === null
905
+ ? null
906
+ : projectBrowserMessage(streamingMessage);
907
+ return { seq, partial };
908
+ }
630
909
  async availableModels(ref) {
631
910
  const session = await this.getOrOpen(ref);
632
- session.modelRegistry.refresh();
911
+ await session.modelRuntime.refresh();
633
912
  const models = session.scopedModels.length > 0
634
913
  ? session.scopedModels.map((scoped) => scoped.model)
635
- : session.modelRegistry.getAvailable();
914
+ : session.modelRuntime.getAvailableSnapshot();
636
915
  return models.map(modelToClientModel);
637
916
  }
638
917
  async setModel(ref, provider, modelId) {
639
918
  await this.assertWritable(ref);
640
919
  const session = await this.getOrOpen(ref);
641
- session.modelRegistry.refresh();
920
+ this.assertTreeNavigationInactive(session, "change models");
921
+ await session.modelRuntime.refresh();
922
+ this.assertTreeNavigationInactive(session, "change models");
642
923
  const candidates = session.scopedModels.length > 0
643
924
  ? session.scopedModels.map((scoped) => scoped.model)
644
- : session.modelRegistry.getAvailable();
925
+ : session.modelRuntime.getAvailableSnapshot();
645
926
  const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
646
- ?? session.modelRegistry.find(provider, modelId);
927
+ ?? session.modelRuntime.getModel(provider, modelId);
647
928
  if (model === undefined)
648
929
  throw new Error(`Model not found: ${provider}/${modelId}`);
649
- await session.setModel(model);
930
+ await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
650
931
  this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
651
932
  this.publishStatus(session);
652
933
  return this.statusFromSession(session);
@@ -654,7 +935,7 @@ export class PiSessionService {
654
935
  async cycleModel(ref, direction) {
655
936
  await this.assertWritable(ref);
656
937
  const session = await this.getOrOpen(ref);
657
- const result = await session.cycleModel(direction);
938
+ const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
658
939
  if (result === undefined)
659
940
  throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
660
941
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
@@ -668,6 +949,7 @@ export class PiSessionService {
668
949
  async setThinkingLevel(ref, level) {
669
950
  await this.assertWritable(ref);
670
951
  const session = await this.getOrOpen(ref);
952
+ this.assertTreeNavigationInactive(session, "change the thinking level");
671
953
  // pi owns the valid set; validate against the session's live levels rather
672
954
  // than a hardcoded union so this stays correct if pi changes the set.
673
955
  const available = session.getAvailableThinkingLevels();
@@ -682,6 +964,7 @@ export class PiSessionService {
682
964
  async cycleThinkingLevel(ref) {
683
965
  await this.assertWritable(ref);
684
966
  const session = await this.getOrOpen(ref);
967
+ this.assertTreeNavigationInactive(session, "change the thinking level");
685
968
  const level = session.cycleThinkingLevel();
686
969
  if (level === undefined)
687
970
  throw new Error("Current model does not support thinking");
@@ -715,6 +998,7 @@ export class PiSessionService {
715
998
  const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
716
999
  await this.assertWritable(ref);
717
1000
  const session = await this.getOrOpen(ref);
1001
+ this.assertTreeNavigationInactive(session, "send a prompt");
718
1002
  this.maybeGenerateSessionName(session, promptText);
719
1003
  const isQueued = session.isStreaming || session.isCompacting;
720
1004
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
@@ -734,7 +1018,7 @@ export class PiSessionService {
734
1018
  if (behavior === undefined && echoUserMessage)
735
1019
  this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
736
1020
  const promptOptions = buildPromptOptions(behavior, images);
737
- const promptPromise = session.prompt(text, promptOptions).catch((error) => {
1021
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
738
1022
  const message = error instanceof Error ? error.message : String(error);
739
1023
  this.publishActivity(session, "error", "error", message);
740
1024
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -761,6 +1045,7 @@ export class PiSessionService {
761
1045
  await this.assertWritable(ref);
762
1046
  const active = await this.getActive(ref);
763
1047
  const { session } = active.runtime;
1048
+ this.assertTreeNavigationInactive(session, "run a shell command");
764
1049
  const isExcluded = text.startsWith("!!");
765
1050
  const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
766
1051
  if (!command)
@@ -769,11 +1054,11 @@ export class PiSessionService {
769
1054
  throw new Error("A bash command is already running");
770
1055
  this.publishActivity(session, "running bash", "active", command);
771
1056
  this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
772
- void session.executeBash(command, (chunk) => {
1057
+ void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
773
1058
  this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
774
1059
  this.publishActivity(session, "running bash", "active", command);
775
1060
  this.publishStatus(session);
776
- }, { excludeFromContext: isExcluded }).then((result) => {
1061
+ }, { excludeFromContext: isExcluded })).then((result) => {
777
1062
  this.events.publish(session.sessionId, {
778
1063
  type: "shell.end",
779
1064
  output: result.output,
@@ -802,30 +1087,103 @@ export class PiSessionService {
802
1087
  const active = await this.getActive(ref);
803
1088
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
804
1089
  }
805
- async reloadSessionRuntime(session) {
1090
+ async navigateTree(ref, request) {
1091
+ if (request.targetId.trim() === "")
1092
+ throw new Error("Session tree target is required");
1093
+ if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
1094
+ throw new Error("Stop current session activity before navigating the session tree");
1095
+ }
1096
+ await this.assertWritable(ref);
1097
+ const options = sessionTreeNavigationOptions(request);
1098
+ const session = await this.getOrOpen(ref);
1099
+ if (typeof session.navigateTree !== "function")
1100
+ throw new Error("Session tree navigation is not supported by this Pi runtime");
806
1101
  if (this.hasActiveWork(session))
807
- throw new Error("Stop current session activity before reloading");
808
- this.publishActivity(session, "reloading resources", "active");
1102
+ throw new Error("Stop current session activity before navigating the session tree");
1103
+ // Acquire synchronously after the active-work check. No leaf-producing work
1104
+ // may enter this runtime until Pi's potentially asynchronous summary settles.
1105
+ this.treeNavigations.add(session);
809
1106
  try {
810
- await session.reload();
811
- this.publishActivity(session, "resources reloaded", "idle");
1107
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1108
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1109
+ }
1110
+ this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
812
1111
  this.publishStatus(session);
1112
+ const result = await session.navigateTree(request.targetId, options);
1113
+ if (result.cancelled) {
1114
+ if (this.isCurrentActiveSession(session)) {
1115
+ this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1116
+ }
1117
+ return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1118
+ }
1119
+ if (this.isCurrentActiveSession(session))
1120
+ this.publishActivity(session, "session tree navigated", "idle");
1121
+ return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
813
1122
  }
814
1123
  catch (error) {
815
1124
  const message = error instanceof Error ? error.message : String(error);
816
- this.publishActivity(session, "reload failed", "error", message);
817
- this.events.publish(session.sessionId, { type: "session.error", message });
818
- this.publishStatus(session);
1125
+ if (this.isCurrentActiveSession(session)) {
1126
+ this.publishActivity(session, "tree navigation failed", "error", message);
1127
+ this.events.publish(session.sessionId, { type: "session.error", message });
1128
+ }
819
1129
  throw error;
820
1130
  }
1131
+ finally {
1132
+ this.treeNavigations.delete(session);
1133
+ if (this.isCurrentActiveSession(session)) {
1134
+ this.flushDeferredTreeNavigationWork(session);
1135
+ this.publishStatus(session);
1136
+ }
1137
+ else {
1138
+ this.deferredGeneratedSessionNames.delete(session);
1139
+ this.deferredSubsessionNotifications.delete(session);
1140
+ }
1141
+ }
1142
+ }
1143
+ async reloadSessionRuntime(session) {
1144
+ if (this.hasActiveWork(session))
1145
+ throw new Error("Stop current session activity before reloading");
1146
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
1147
+ this.publishActivity(session, "reloading resources", "active");
1148
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1149
+ let candidateGeneration;
1150
+ try {
1151
+ await session.reload(priorGeneration === undefined ? undefined : {
1152
+ beforeSessionStart: () => {
1153
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1154
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1155
+ this.replaceSessionNotificationContext(session, candidateGeneration);
1156
+ },
1157
+ });
1158
+ if (candidateGeneration !== undefined) {
1159
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1160
+ }
1161
+ this.publishActivity(session, "resources reloaded", "idle");
1162
+ this.publishStatus(session);
1163
+ }
1164
+ catch (error) {
1165
+ if (candidateGeneration !== undefined) {
1166
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
1167
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1168
+ }
1169
+ const message = error instanceof Error ? error.message : String(error);
1170
+ this.publishActivity(session, "reload failed", "error", message);
1171
+ this.events.publish(session.sessionId, { type: "session.error", message });
1172
+ this.publishStatus(session);
1173
+ throw error;
1174
+ }
1175
+ });
821
1176
  }
822
1177
  async archive(ref) {
823
1178
  const session = await this.getOrOpen(ref);
824
1179
  if (this.hasActiveWork(session))
825
1180
  throw new Error("Stop current session activity before archiving");
826
- const archiveInput = await this.archiveInputForSession(session);
827
- await this.closeActive(session.sessionId);
828
- await this.archiveStore.archive(archiveInput);
1181
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1182
+ const archiveInput = await this.archiveInputForSession(session);
1183
+ await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
1184
+ await this.archiveStore.archive(archiveInput);
1185
+ await this.forgetUnreadSessions([archiveInput]);
1186
+ });
829
1187
  }
830
1188
  async archiveMany(refs) {
831
1189
  const uniqueRefs = uniqueBulkSessionRefs(refs);
@@ -835,11 +1193,14 @@ export class PiSessionService {
835
1193
  ]);
836
1194
  const failures = [];
837
1195
  const alreadyArchivedSessionIds = [];
1196
+ const unreadArchivedIdentities = [];
838
1197
  const planItems = [];
839
1198
  for (const ref of uniqueRefs) {
840
1199
  const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
841
1200
  if (archived !== undefined) {
1201
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
842
1202
  alreadyArchivedSessionIds.push(archived.sessionId);
1203
+ unreadArchivedIdentities.push(archived);
843
1204
  continue;
844
1205
  }
845
1206
  const active = this.activeForLookup(bulkRefToLookup(ref));
@@ -864,25 +1225,41 @@ export class PiSessionService {
864
1225
  failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
865
1226
  }
866
1227
  }
867
- const readyInputs = [];
1228
+ const readyPlanItems = [];
868
1229
  for (const item of planItems) {
1230
+ const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
1231
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1232
+ failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1233
+ continue;
1234
+ }
1235
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
1236
+ }
1237
+ const readyInputs = [];
1238
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1239
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1240
+ sessionId: input.sessionId,
1241
+ ...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
1242
+ })), "Stop current session activity before archiving", async () => {
1243
+ for (const item of readyPlanItems) {
1244
+ try {
1245
+ await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
1246
+ readyInputs.push(item.input);
1247
+ }
1248
+ catch (error) {
1249
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1250
+ }
1251
+ }
869
1252
  try {
870
- await this.closeActive(item.input.sessionId);
871
- readyInputs.push(item.input);
1253
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1254
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1255
+ unreadArchivedIdentities.push(...archived);
872
1256
  }
873
1257
  catch (error) {
874
- failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1258
+ for (const input of readyInputs)
1259
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
875
1260
  }
876
- }
877
- const archivedSessionIds = [...alreadyArchivedSessionIds];
878
- try {
879
- const archived = await this.archiveStoreArchiveMany(readyInputs);
880
- archivedSessionIds.push(...archived.map((record) => record.sessionId));
881
- }
882
- catch (error) {
883
- for (const input of readyInputs)
884
- failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
885
- }
1261
+ });
1262
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
886
1263
  return {
887
1264
  archived: true,
888
1265
  archivedSessionIds: uniqueStrings(archivedSessionIds),
@@ -899,9 +1276,19 @@ export class PiSessionService {
899
1276
  if (busy !== undefined)
900
1277
  throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
901
1278
  const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
902
- for (const input of archiveInputs)
903
- await this.closeActive(input.sessionId);
904
- await this.archiveStoreArchiveMany(archiveInputs);
1279
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1280
+ sessionId: target.id,
1281
+ ...(target.activeSession === undefined ? {} : { session: target.activeSession }),
1282
+ })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
1283
+ for (const target of plan.targets) {
1284
+ if (target.archived)
1285
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1286
+ }
1287
+ for (const input of archiveInputs)
1288
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
1289
+ await this.archiveStoreArchiveMany(archiveInputs);
1290
+ });
1291
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
905
1292
  return {
906
1293
  archived: true,
907
1294
  sessionIds: archiveInputs.map((input) => input.sessionId),
@@ -913,8 +1300,9 @@ export class PiSessionService {
913
1300
  const archived = await this.getArchived(ref);
914
1301
  if (archived === undefined)
915
1302
  throw new Error("Session not found");
916
- await this.closeActive(archived.sessionId);
1303
+ await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
917
1304
  await this.archiveStore.restore(archived.sessionId);
1305
+ await this.forgetUnreadSessions([archived]);
918
1306
  }
919
1307
  async deleteArchived(ref) {
920
1308
  const record = await this.getArchived(ref);
@@ -922,10 +1310,11 @@ export class PiSessionService {
922
1310
  throw new Error("Archived session not found");
923
1311
  if (this.archiveStore.deleteArchived === undefined)
924
1312
  throw new Error("Archive store does not support deletion");
925
- await this.closeActive(record.sessionId);
1313
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
926
1314
  if (record.archivePath === undefined)
927
1315
  await this.ensureArchivedRecordMoved(record);
928
1316
  await this.archiveStore.deleteArchived(record.sessionId);
1317
+ await this.forgetUnreadSessions([record]);
929
1318
  }
930
1319
  async deleteArchivedMany(refs) {
931
1320
  if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
@@ -950,7 +1339,7 @@ export class PiSessionService {
950
1339
  const readyRecords = [];
951
1340
  for (const item of planItems) {
952
1341
  try {
953
- await this.closeActive(item.record.sessionId);
1342
+ await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
954
1343
  readyRecords.push(item.record);
955
1344
  }
956
1345
  catch (error) {
@@ -971,6 +1360,8 @@ export class PiSessionService {
971
1360
  for (const sessionId of deleteIds)
972
1361
  failures.push({ sessionId, error: errorMessage(error) });
973
1362
  }
1363
+ const deletedIdSet = new Set(deletedSessionIds);
1364
+ await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
974
1365
  return {
975
1366
  deleted: true,
976
1367
  deletedSessionIds,
@@ -983,9 +1374,29 @@ export class PiSessionService {
983
1374
  const session = await this.getOrOpen(ref);
984
1375
  if (this.hasActiveWork(session))
985
1376
  throw new Error("Stop current session activity before reloading");
986
- await this.closeActive(session.sessionId);
987
- const reopened = await this.getActive(ref);
988
- this.publishStatus(reopened.runtime.session);
1377
+ const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
1378
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1379
+ const { sessionId, cwd } = notificationIdentityForSession(session);
1380
+ let candidateGeneration;
1381
+ try {
1382
+ await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
1383
+ candidateGeneration = priorGeneration === undefined
1384
+ ? undefined
1385
+ : this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
1386
+ const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
1387
+ if (candidateGeneration !== undefined) {
1388
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1389
+ }
1390
+ return reopened.runtime.session;
1391
+ }
1392
+ catch (error) {
1393
+ if (candidateGeneration !== undefined) {
1394
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
1395
+ }
1396
+ throw error;
1397
+ }
1398
+ });
1399
+ this.publishStatus(reopenedSession);
989
1400
  }
990
1401
  async detachParent(ref) {
991
1402
  const session = await this.getOrOpen(ref);
@@ -995,6 +1406,21 @@ export class PiSessionService {
995
1406
  await clearParentSession(sessionFile);
996
1407
  clearParentSessionHeader(session.sessionManager);
997
1408
  this.unregisterSubsession(session.sessionId);
1409
+ await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
1410
+ }
1411
+ async clearQueue(ref) {
1412
+ await this.assertWritable(ref);
1413
+ const session = await this.getOrOpen(ref);
1414
+ this.clearCompactionPromptQueue(session.sessionId);
1415
+ clearSessionQueue(session);
1416
+ this.publishStatus(session);
1417
+ return this.statusFromSession(session);
1418
+ }
1419
+ async dismissWarning(ref, dismissId) {
1420
+ const session = await this.getOrOpen(ref);
1421
+ dismissSessionWarning(session, dismissId);
1422
+ this.publishStatus(session);
1423
+ return this.statusFromSession(session);
998
1424
  }
999
1425
  async abort(ref) {
1000
1426
  const active = this.activeForLookup(ref);
@@ -1003,17 +1429,30 @@ export class PiSessionService {
1003
1429
  const sessionId = active.runtime.session.sessionId;
1004
1430
  this.clearCompactionPromptQueue(sessionId);
1005
1431
  clearSessionQueue(active.runtime.session);
1006
- await active.runtime.session.abort();
1007
- this.publishActivity(active.runtime.session, "stopped", "idle");
1008
- this.publishStatus(active.runtime.session);
1432
+ try {
1433
+ await this.abortSessionOperations(active.runtime.session);
1434
+ this.publishActivity(active.runtime.session, "stopped", "idle");
1435
+ }
1436
+ catch (error) {
1437
+ const message = error instanceof Error ? error.message : String(error);
1438
+ this.publishActivity(active.runtime.session, "stop failed", "error", message);
1439
+ throw error;
1440
+ }
1441
+ finally {
1442
+ this.publishStatus(active.runtime.session);
1443
+ }
1009
1444
  }
1010
- stop(ref) {
1445
+ async stop(ref) {
1011
1446
  const active = this.activeForLookup(ref);
1012
- if (active === undefined)
1447
+ if (active !== undefined) {
1448
+ await this.closeActive(active.runtime.session.sessionId);
1013
1449
  return;
1014
- void this.closeActive(active.runtime.session.sessionId).catch(() => {
1015
- // Best-effort shutdown; callers that need errors await closeActive directly.
1016
- });
1450
+ }
1451
+ if (isPiSessionRef(ref)) {
1452
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1453
+ return;
1454
+ }
1455
+ await this.closeActive(ref);
1017
1456
  }
1018
1457
  async bulkSessionLookupContext(refs) {
1019
1458
  const cwdSet = new Set();
@@ -1202,13 +1641,21 @@ export class PiSessionService {
1202
1641
  }
1203
1642
  return [...names];
1204
1643
  }
1205
- async closeActive(sessionId) {
1644
+ async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
1206
1645
  const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1207
1646
  if (pendingOpens.length > 0)
1208
1647
  await Promise.allSettled(pendingOpens);
1209
1648
  const active = this.active.get(sessionId);
1649
+ if (notificationPolicy.kind === "clear") {
1650
+ const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
1651
+ const mutations = generation === undefined
1652
+ ? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
1653
+ : this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
1654
+ this.publishNotificationMutations(mutations);
1655
+ }
1210
1656
  if (!active)
1211
1657
  return;
1658
+ this.forgetUnreadActivity(active.runtime.session);
1212
1659
  this.active.delete(sessionId);
1213
1660
  this.activities.delete(sessionId);
1214
1661
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -1221,13 +1668,36 @@ export class PiSessionService {
1221
1668
  this.subsessionNotifyArmed.delete(sessionId);
1222
1669
  clearSessionQueue(active.runtime.session);
1223
1670
  active.unsubscribe();
1671
+ active.runtime.setRebindSession(undefined);
1224
1672
  try {
1225
- await active.runtime.session.abort();
1673
+ await this.abortSessionOperations(active.runtime.session);
1226
1674
  }
1227
1675
  finally {
1228
1676
  await active.runtime.dispose();
1229
1677
  }
1230
1678
  }
1679
+ async abortSessionOperations(session) {
1680
+ let branchSummaryAbortFailed = false;
1681
+ let branchSummaryAbortError;
1682
+ try {
1683
+ session.abortBranchSummary?.();
1684
+ }
1685
+ catch (error) {
1686
+ branchSummaryAbortFailed = true;
1687
+ branchSummaryAbortError = error;
1688
+ }
1689
+ try {
1690
+ await session.abort();
1691
+ }
1692
+ catch (abortError) {
1693
+ if (branchSummaryAbortFailed) {
1694
+ throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
1695
+ }
1696
+ throw abortError;
1697
+ }
1698
+ if (branchSummaryAbortFailed)
1699
+ throw branchSummaryAbortError;
1700
+ }
1231
1701
  async assertWritable(ref) {
1232
1702
  if (await this.getArchived(ref) !== undefined)
1233
1703
  throw new Error("Archived sessions are read-only. Restore the session to continue.");
@@ -1235,23 +1705,23 @@ export class PiSessionService {
1235
1705
  async getOrOpen(ref) {
1236
1706
  return (await this.getActive(ref)).runtime.session;
1237
1707
  }
1238
- async getActive(ref) {
1708
+ async getActive(ref, options = {}) {
1239
1709
  const active = this.activeForLookup(ref);
1240
1710
  if (active !== undefined)
1241
1711
  return active;
1242
1712
  const archived = await this.getArchived(ref);
1243
1713
  if (archived?.archivePath !== undefined) {
1244
1714
  const { archivePath } = archived;
1245
- return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath));
1715
+ return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
1246
1716
  }
1247
1717
  const match = isPiSessionRef(ref)
1248
1718
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
1249
1719
  : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
1250
1720
  if (!match)
1251
1721
  throw new Error("Session not found");
1252
- return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
1722
+ return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
1253
1723
  }
1254
- openExistingSession(sessionId, cwd, openSessionManager) {
1724
+ openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
1255
1725
  const active = this.activeForLookup({ id: sessionId, cwd });
1256
1726
  if (active !== undefined)
1257
1727
  return Promise.resolve(active);
@@ -1261,7 +1731,7 @@ export class PiSessionService {
1261
1731
  return existing.promise;
1262
1732
  const pending = {
1263
1733
  sessionId,
1264
- promise: this.create(openSessionManager(), cwd),
1734
+ promise: this.create(openSessionManager(), cwd, options),
1265
1735
  };
1266
1736
  pending.promise = pending.promise.finally(() => {
1267
1737
  if (this.pendingSessionOpens.get(key) === pending)
@@ -1283,6 +1753,9 @@ export class PiSessionService {
1283
1753
  return undefined;
1284
1754
  return archived;
1285
1755
  }
1756
+ isCurrentActiveSession(session) {
1757
+ return this.active.get(session.sessionId)?.runtime.session === session;
1758
+ }
1286
1759
  activeForLookup(ref) {
1287
1760
  const sessionId = sessionIdFromLookup(ref);
1288
1761
  const exact = this.active.get(sessionId);
@@ -1305,21 +1778,83 @@ export class PiSessionService {
1305
1778
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1306
1779
  });
1307
1780
  const active = { runtime, unsubscribe: noop };
1781
+ let boundSession = runtime.session;
1782
+ let notificationGeneration = options.notificationGeneration;
1783
+ let notificationOwnership = options.notifications === "disabled"
1784
+ ? "disabled"
1785
+ : notificationGeneration === undefined
1786
+ ? "registered"
1787
+ : "external";
1788
+ if (notificationOwnership === "registered") {
1789
+ const notificationIdentity = notificationIdentityForSession(runtime.session);
1790
+ const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
1791
+ if (existingCandidate !== undefined) {
1792
+ notificationGeneration = existingCandidate;
1793
+ notificationOwnership = "replacement";
1794
+ }
1795
+ else {
1796
+ const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
1797
+ notificationGeneration = registration.generation;
1798
+ this.publishNotificationMutations(registration.mutations);
1799
+ }
1800
+ }
1801
+ if (notificationGeneration !== undefined)
1802
+ this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
1308
1803
  try {
1309
- await this.bindSessionExtensions(runtime.session);
1804
+ if (options.creationProvenance === "tracked-subsession") {
1805
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
1806
+ }
1807
+ else {
1808
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1809
+ }
1810
+ await this.bindSessionExtensions(runtime.session, notificationGeneration);
1310
1811
  this.bindRuntime(active);
1311
1812
  runtime.setRebindSession(async (session) => {
1312
- await this.bindSessionExtensions(session);
1313
- this.bindRuntime(active);
1314
- await this.recoverSubsessionTrackingForOpenedSession(session);
1813
+ const priorGeneration = notificationGeneration;
1814
+ let candidateGeneration;
1815
+ try {
1816
+ await this.prepareUnreadRuntimeRebind(boundSession, session);
1817
+ await this.recoverSubsessionTrackingForOpenedSession(session);
1818
+ if (priorGeneration !== undefined) {
1819
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1820
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1821
+ }
1822
+ this.bindRuntime(active, session);
1823
+ boundSession = session;
1824
+ await this.bindSessionExtensions(session, candidateGeneration);
1825
+ if (candidateGeneration !== undefined) {
1826
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1827
+ notificationGeneration = candidateGeneration;
1828
+ }
1829
+ }
1830
+ catch (error) {
1831
+ if (candidateGeneration !== undefined) {
1832
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
1833
+ notificationGeneration = candidateGeneration;
1834
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1835
+ }
1836
+ throw error;
1837
+ }
1315
1838
  });
1316
1839
  this.active.set(runtime.session.sessionId, active);
1317
- await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1840
+ if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
1841
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
1842
+ notificationOwnership = "external";
1843
+ }
1318
1844
  this.publishStatus(runtime.session);
1319
1845
  return active;
1320
1846
  }
1321
1847
  catch (error) {
1848
+ if (notificationGeneration !== undefined) {
1849
+ if (notificationOwnership === "registered") {
1850
+ this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
1851
+ }
1852
+ else if (notificationOwnership === "replacement") {
1853
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
1854
+ }
1855
+ }
1322
1856
  active.unsubscribe();
1857
+ this.forgetUnreadActivity(boundSession);
1323
1858
  let removedActive = false;
1324
1859
  for (const [sessionId, candidate] of this.active.entries()) {
1325
1860
  if (candidate !== active)
@@ -1342,8 +1877,11 @@ export class PiSessionService {
1342
1877
  throw error;
1343
1878
  }
1344
1879
  }
1345
- async bindSessionExtensions(session) {
1880
+ async bindSessionExtensions(session, generation) {
1881
+ const uiContext = this.sessionUiContext(session, generation);
1346
1882
  await session.bindExtensions({
1883
+ uiContext,
1884
+ mode: "rpc",
1347
1885
  onError: (error) => {
1348
1886
  const message = `${error.extensionPath}: ${error.error}`;
1349
1887
  this.publishActivity(session, "extension error", "error", message);
@@ -1351,9 +1889,157 @@ export class PiSessionService {
1351
1889
  },
1352
1890
  });
1353
1891
  }
1354
- bindRuntime(active) {
1892
+ replaceSessionNotificationContext(session, generation) {
1893
+ session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
1894
+ }
1895
+ sessionUiContext(session, generation) {
1896
+ const baseUiContext = session.extensionRunner.getUIContext();
1897
+ const notify = (message, type) => {
1898
+ if (generation === undefined) {
1899
+ this.events.publish(session.sessionId, {
1900
+ type: "command.output",
1901
+ level: type === "error" ? "error" : "info",
1902
+ message,
1903
+ });
1904
+ return;
1905
+ }
1906
+ const added = this.notificationStore.addNotification(generation, message, type);
1907
+ this.publishNotificationMutations(added.mutations);
1908
+ if (added.notification === undefined)
1909
+ return;
1910
+ this.events.publish(session.sessionId, {
1911
+ type: "command.output",
1912
+ level: type === "error" ? "error" : "info",
1913
+ message,
1914
+ notificationId: added.notification.id,
1915
+ });
1916
+ };
1917
+ // PI WEB owns the browser-facing notification and text-formatting
1918
+ // boundaries. Delegate every other UI method to Pi's headless defaults so
1919
+ // unsupported dialogs cancel safely instead of hanging.
1920
+ return new Proxy(baseUiContext, {
1921
+ get(target, property, receiver) {
1922
+ if (property === "notify")
1923
+ return notify;
1924
+ if (property === "theme")
1925
+ return plainTextTheme;
1926
+ const value = Reflect.get(target, property, receiver);
1927
+ return value;
1928
+ },
1929
+ });
1930
+ }
1931
+ publishNotificationMutations(mutations) {
1932
+ for (const mutation of mutations) {
1933
+ this.events.publish(mutation.sessionId, mutation.inboxEvent);
1934
+ this.events.publishNotificationSummary(mutation.summaryEvent);
1935
+ }
1936
+ }
1937
+ async prepareUnreadRuntimeRebind(previous, next) {
1938
+ const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
1939
+ this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
1940
+ const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
1941
+ if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
1942
+ return;
1943
+ await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
1944
+ }
1945
+ forgetUnreadActivity(session) {
1946
+ this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
1947
+ }
1948
+ async forgetUnreadSessions(identities) {
1949
+ const mutations = [];
1950
+ for (const identity of identities) {
1951
+ mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
1952
+ }
1953
+ await this.publishUnreadMutations(mutations);
1954
+ }
1955
+ observeUnreadActivityState(session) {
1956
+ const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
1957
+ if (mutations.length === 0)
1958
+ return;
1959
+ void this.publishUnreadMutations(mutations).catch(() => undefined);
1960
+ }
1961
+ publishUnreadMutations(mutations) {
1962
+ this.enqueueUnreadMutations(mutations);
1963
+ this.unreadPublicationFlushRequested = true;
1964
+ if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
1965
+ const failure = this.unreadPublicationFailure;
1966
+ return Promise.reject(failure instanceof Error
1967
+ ? failure
1968
+ : new Error("Session unread publication is awaiting retry", { cause: failure }));
1969
+ }
1970
+ return this.ensureUnreadPublication();
1971
+ }
1972
+ ensureUnreadPublication() {
1973
+ const existing = this.unreadPublication;
1974
+ if (existing !== undefined)
1975
+ return existing;
1976
+ const publication = this.drainUnreadPublication();
1977
+ this.unreadPublication = publication;
1978
+ void publication.then(() => {
1979
+ if (this.unreadPublication === publication)
1980
+ this.unreadPublication = undefined;
1981
+ }, (error) => {
1982
+ if (this.unreadPublication === publication)
1983
+ this.unreadPublication = undefined;
1984
+ this.unreadPublicationFailure = error;
1985
+ this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
1986
+ this.scheduleUnreadPublicationRetry();
1987
+ });
1988
+ return publication;
1989
+ }
1990
+ async drainUnreadPublication() {
1991
+ while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
1992
+ this.unreadPublicationFlushRequested = false;
1993
+ const batch = this.pendingUnreadMutations.splice(0);
1994
+ let publishedCount = 0;
1995
+ try {
1996
+ await this.unreadStore.flush();
1997
+ for (const mutation of batch) {
1998
+ this.events.publishGlobal(mutation.event);
1999
+ publishedCount += 1;
2000
+ }
2001
+ }
2002
+ catch (error) {
2003
+ this.prependUnreadMutations(batch.slice(publishedCount));
2004
+ this.unreadPublicationFlushRequested = true;
2005
+ throw error;
2006
+ }
2007
+ this.unreadPublicationFailure = undefined;
2008
+ this.clearUnreadPublicationRetry();
2009
+ }
2010
+ }
2011
+ enqueueUnreadMutations(mutations) {
2012
+ this.pendingUnreadMutations.push(...mutations);
2013
+ this.trimPendingUnreadMutations();
2014
+ }
2015
+ prependUnreadMutations(mutations) {
2016
+ this.pendingUnreadMutations.unshift(...mutations);
2017
+ this.trimPendingUnreadMutations();
2018
+ }
2019
+ trimPendingUnreadMutations() {
2020
+ const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
2021
+ if (excess > 0)
2022
+ this.pendingUnreadMutations.splice(0, excess);
2023
+ }
2024
+ scheduleUnreadPublicationRetry() {
2025
+ if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
2026
+ return;
2027
+ const delay = this.unreadPublicationRetryDelayMs;
2028
+ this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
2029
+ this.unreadPublicationRetryTimer = setTimeout(() => {
2030
+ this.unreadPublicationRetryTimer = undefined;
2031
+ void this.ensureUnreadPublication().catch(() => undefined);
2032
+ }, delay);
2033
+ this.unreadPublicationRetryTimer.unref();
2034
+ }
2035
+ clearUnreadPublicationRetry() {
2036
+ if (this.unreadPublicationRetryTimer !== undefined)
2037
+ clearTimeout(this.unreadPublicationRetryTimer);
2038
+ this.unreadPublicationRetryTimer = undefined;
2039
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2040
+ }
2041
+ bindRuntime(active, session = active.runtime.session) {
1355
2042
  active.unsubscribe();
1356
- const { session } = active.runtime;
1357
2043
  for (const [sessionId, candidate] of this.active.entries()) {
1358
2044
  if (candidate === active) {
1359
2045
  this.active.delete(sessionId);
@@ -1444,7 +2130,7 @@ export class PiSessionService {
1444
2130
  const model = session.model;
1445
2131
  if (model === undefined)
1446
2132
  return;
1447
- void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
2133
+ void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
1448
2134
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
1449
2135
  }).catch(() => {
1450
2136
  this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1453,14 +2139,38 @@ export class PiSessionService {
1453
2139
  applyGeneratedSessionName(session, name) {
1454
2140
  if (name === undefined || session.sessionName !== undefined)
1455
2141
  return;
2142
+ if (this.treeNavigations.has(session)) {
2143
+ this.deferredGeneratedSessionNames.set(session, name);
2144
+ return;
2145
+ }
1456
2146
  session.setSessionName(name);
1457
2147
  this.publishSessionName(session);
1458
2148
  }
2149
+ flushDeferredTreeNavigationWork(session) {
2150
+ const generatedName = this.deferredGeneratedSessionNames.get(session);
2151
+ this.deferredGeneratedSessionNames.delete(session);
2152
+ if (generatedName !== undefined) {
2153
+ try {
2154
+ this.applyGeneratedSessionName(session, generatedName);
2155
+ }
2156
+ catch (error) {
2157
+ this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
2158
+ }
2159
+ }
2160
+ const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
2161
+ this.deferredSubsessionNotifications.delete(session);
2162
+ for (const notification of notifications) {
2163
+ void this.deliverSubsessionNotification(session, notification).catch((error) => {
2164
+ this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
2165
+ });
2166
+ }
2167
+ }
1459
2168
  applyAuthChange(change = {}) {
1460
- this.modelRegistry.refresh();
2169
+ // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
2170
+ // emits the change, so no refresh is needed here. Keeping this synchronous
2171
+ // also lets every active session observe the same committed auth snapshot.
1461
2172
  for (const active of this.active.values()) {
1462
2173
  const { session } = active.runtime;
1463
- session.modelRegistry.refresh();
1464
2174
  this.syncCurrentModelAuthWarning(session, change.removedProviderId);
1465
2175
  this.publishStatus(session);
1466
2176
  }
@@ -1472,10 +2182,10 @@ export class PiSessionService {
1472
2182
  if (model.provider === "unknown" && model.id === "unknown")
1473
2183
  return;
1474
2184
  const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
1475
- const registered = session.modelRegistry.find(model.provider, model.id);
2185
+ const registered = session.modelRuntime.getModel(model.provider, model.id);
1476
2186
  if (registered === undefined)
1477
2187
  return;
1478
- if (session.modelRegistry.hasConfiguredAuth(registered)) {
2188
+ if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
1479
2189
  this.authLossWarnings.delete(warningKey);
1480
2190
  return;
1481
2191
  }
@@ -1523,6 +2233,10 @@ export class PiSessionService {
1523
2233
  }
1524
2234
  }
1525
2235
  activityLabelFromStatus(session) {
2236
+ if (this.treeNavigations.has(session))
2237
+ return "navigating session tree";
2238
+ if (this.isSessionEntryMutationActive(session))
2239
+ return "updating session";
1526
2240
  if (session.isCompacting)
1527
2241
  return "compacting";
1528
2242
  if (session.isBashRunning)
@@ -1534,7 +2248,92 @@ export class PiSessionService {
1534
2248
  return "active";
1535
2249
  }
1536
2250
  hasActiveWork(session) {
1537
- return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2251
+ return this.treeNavigations.has(session)
2252
+ || this.isSessionEntryMutationActive(session)
2253
+ || this.isTreeExclusiveOperationActive(session)
2254
+ || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2255
+ }
2256
+ async runTreeExclusiveOperation(targets, activeError, operation) {
2257
+ const sessionIds = new Set();
2258
+ const runtimes = new Set();
2259
+ const sessions = new Set();
2260
+ for (const target of targets) {
2261
+ const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
2262
+ const session = target.session ?? runtime?.session;
2263
+ if (session !== undefined && this.hasActiveWork(session))
2264
+ throw new Error(activeError);
2265
+ sessionIds.add(target.sessionId);
2266
+ if (runtime !== undefined)
2267
+ runtimes.add(runtime);
2268
+ if (session !== undefined)
2269
+ sessions.add(session);
2270
+ }
2271
+ for (const sessionId of sessionIds) {
2272
+ this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
2273
+ }
2274
+ for (const runtime of runtimes) {
2275
+ this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
2276
+ }
2277
+ for (const session of sessions)
2278
+ this.observeUnreadActivityState(session);
2279
+ try {
2280
+ return await operation();
2281
+ }
2282
+ finally {
2283
+ for (const runtime of runtimes)
2284
+ decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
2285
+ for (const sessionId of sessionIds)
2286
+ decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
2287
+ for (const session of sessions) {
2288
+ if (this.isCurrentActiveSession(session))
2289
+ this.observeUnreadActivityState(session);
2290
+ }
2291
+ }
2292
+ }
2293
+ isTreeExclusiveSessionIdentityActive(sessionId) {
2294
+ return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
2295
+ }
2296
+ isTreeExclusiveOperationActive(session) {
2297
+ if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
2298
+ return true;
2299
+ const runtime = this.activeRuntimeForSession(session);
2300
+ return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
2301
+ }
2302
+ activeRuntimeForSession(session) {
2303
+ for (const active of new Set(this.active.values())) {
2304
+ if (active.runtime.session === session)
2305
+ return active.runtime;
2306
+ }
2307
+ return undefined;
2308
+ }
2309
+ assertTreeNavigationInactive(session, action) {
2310
+ if (this.treeNavigations.has(session))
2311
+ throw new Error(`Cannot ${action} while session tree navigation is active`);
2312
+ }
2313
+ async runSessionEntryMutation(session, action, operation) {
2314
+ this.beginSessionEntryMutation(session, action);
2315
+ try {
2316
+ return await operation();
2317
+ }
2318
+ finally {
2319
+ this.endSessionEntryMutation(session);
2320
+ }
2321
+ }
2322
+ beginSessionEntryMutation(session, action) {
2323
+ this.assertTreeNavigationInactive(session, action);
2324
+ this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
2325
+ this.observeUnreadActivityState(session);
2326
+ }
2327
+ endSessionEntryMutation(session) {
2328
+ const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
2329
+ if (remaining <= 0)
2330
+ this.sessionEntryMutationCounts.delete(session);
2331
+ else
2332
+ this.sessionEntryMutationCounts.set(session, remaining);
2333
+ this.observeUnreadActivityState(session);
2334
+ }
2335
+ isSessionEntryMutationActive(session) {
2336
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
1538
2337
  }
1539
2338
  publishActivityForEvent(session, event) {
1540
2339
  const eventType = getString(event, "type");
@@ -1596,6 +2395,7 @@ export class PiSessionService {
1596
2395
  this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
1597
2396
  this.events.publish(session.sessionId, { type: "activity.update", activity });
1598
2397
  this.events.publishGlobal({ type: "activity.update", activity });
2398
+ this.observeUnreadActivityState(session);
1599
2399
  }
1600
2400
  publishStatus(session) {
1601
2401
  const status = this.statusFromSession(session);
@@ -1603,6 +2403,7 @@ export class PiSessionService {
1603
2403
  this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
1604
2404
  this.events.publish(session.sessionId, { type: "status.update", status });
1605
2405
  this.events.publishGlobal({ type: "status.update", status });
2406
+ this.observeUnreadActivityState(session);
1606
2407
  }
1607
2408
  clearStaleActiveActivity(session) {
1608
2409
  const current = this.activities.get(session.sessionId);
@@ -1619,6 +2420,7 @@ export class PiSessionService {
1619
2420
  const stats = session.getSessionStats();
1620
2421
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
1621
2422
  const contextUsage = session.getContextUsage();
2423
+ const warnings = this.warningsForSession(session);
1622
2424
  return {
1623
2425
  sessionId: session.sessionId,
1624
2426
  persisted: sessionFileExists(session.sessionFile),
@@ -1633,8 +2435,23 @@ export class PiSessionService {
1633
2435
  tokens: stats.tokens,
1634
2436
  cost: stats.cost,
1635
2437
  ...(contextUsage === undefined ? {} : { contextUsage }),
2438
+ ...(warnings.length === 0 ? {} : { warnings }),
1636
2439
  };
1637
2440
  }
2441
+ /**
2442
+ * Compute the live warning set for a session: runtime/resource diagnostics from
2443
+ * the active runtime (if any) plus the Anthropic subscription-auth notice. Read
2444
+ * fresh on each status publish so a rebuilt runtime or an auth/model change is
2445
+ * reflected without caching a stale snapshot.
2446
+ */
2447
+ warningsForSession(session) {
2448
+ const runtime = this.active.get(session.sessionId)?.runtime;
2449
+ const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
2450
+ const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
2451
+ if (anthropic !== undefined)
2452
+ warnings.push(anthropic);
2453
+ return warnings;
2454
+ }
1638
2455
  pendingMessageCount(session) {
1639
2456
  return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
1640
2457
  }
@@ -1699,6 +2516,12 @@ function modelToClientModel(model) {
1699
2516
  ...(reasoning === undefined ? {} : { reasoning }),
1700
2517
  };
1701
2518
  }
2519
+ function notificationIdentityForSession(session) {
2520
+ return {
2521
+ sessionId: session.sessionId,
2522
+ cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
2523
+ };
2524
+ }
1702
2525
  function clientSessionFromListEntry(session) {
1703
2526
  return {
1704
2527
  id: session.id,
@@ -2096,11 +2919,13 @@ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
2096
2919
  const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
2097
2920
  /** customType marking a parent-facing subsession-completion notice. */
2098
2921
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
2099
- const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
2100
- function truncateForNotification(text) {
2101
- if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS)
2102
- return text;
2103
- return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`;
2922
+ const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
2923
+ /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
2924
+ function formatSubsessionNotificationOutput(childSessionId, text) {
2925
+ if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
2926
+ 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.`;
2927
+ }
2928
+ return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
2104
2929
  }
2105
2930
  /** Most recent assistant text from a history message list, or "" if none. */
2106
2931
  function finalAssistantText(messages) {