@chainingintention/pi-web-cn 1.202607.11 → 1.202608.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 (104) hide show
  1. package/README.md +9 -9
  2. package/dist/cli.js +27 -12
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-WeDkBH1U.js → CodeViewer-j37b7YHz.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-DgiIksV-.js → TerminalPanel-DqDiXDrk.js} +5 -5
  6. package/dist/client/assets/{UnifiedDiffViewer-FKeWfK-h.js → UnifiedDiffViewer-AQTSNI1y.js} +1 -1
  7. package/dist/client/assets/index-DDuQX9_Y.js +4101 -0
  8. package/dist/client/index.html +1 -1
  9. package/dist/config.js +36 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/nativeServices/servicePlan.js +3 -2
  12. package/dist/nativeServices/servicePlan.js.map +1 -1
  13. package/dist/nativeServices/serviceProbe.js +7 -5
  14. package/dist/nativeServices/serviceProbe.js.map +1 -1
  15. package/dist/pi-web-plugins/info/infoInternals.js +223 -0
  16. package/dist/pi-web-plugins/info/pi-web-plugin.js +19 -22
  17. package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
  18. package/dist/pi-web-plugins/relays/package.json +9 -0
  19. package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
  20. package/dist/pi-web-plugins/relays/relayDiscovery.js +96 -0
  21. package/dist/pi-web-plugins/relays/relaysPanelElement.js +327 -0
  22. package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
  23. package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
  24. package/dist/pi-web-plugins/updates/pi-web-plugin.js +100 -100
  25. package/dist/pi-web-plugins/updates/updatesLogic.js +8 -5
  26. package/dist/plugin-api.d.ts +5 -1
  27. package/dist/server/configRoutes.js +11 -0
  28. package/dist/server/configRoutes.js.map +1 -1
  29. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  30. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  31. package/dist/server/git/gitService.js +176 -5
  32. package/dist/server/git/gitService.js.map +1 -1
  33. package/dist/server/piWebStatus.js +7 -4
  34. package/dist/server/piWebStatus.js.map +1 -1
  35. package/dist/server/realtime/sessionEventHub.js +16 -2
  36. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  37. package/dist/server/sessiond/sessionDaemonStartup.js +0 -42
  38. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -1
  39. package/dist/server/sessiond/sessionServiceDependencies.js +31 -0
  40. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
  41. package/dist/server/sessions/askUserTool.js +105 -0
  42. package/dist/server/sessions/askUserTool.js.map +1 -0
  43. package/dist/server/sessions/authProviderOptions.js +26 -29
  44. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  45. package/dist/server/sessions/authRoutes.js +9 -1
  46. package/dist/server/sessions/authRoutes.js.map +1 -1
  47. package/dist/server/sessions/authService.js +143 -33
  48. package/dist/server/sessions/authService.js.map +1 -1
  49. package/dist/server/sessions/globalProviderPolicy.js +167 -0
  50. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  51. package/dist/server/sessions/modelCatalogRefresher.js +192 -0
  52. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  53. package/dist/server/sessions/oauthLoginFlowService.js +181 -56
  54. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  55. package/dist/server/sessions/parentSessionLocator.js +75 -0
  56. package/dist/server/sessions/parentSessionLocator.js.map +1 -0
  57. package/dist/server/sessions/pendingAskStore.js +278 -0
  58. package/dist/server/sessions/pendingAskStore.js.map +1 -0
  59. package/dist/server/sessions/piSessionService.js +1264 -199
  60. package/dist/server/sessions/piSessionService.js.map +1 -1
  61. package/dist/server/sessions/plainTextTheme.js +112 -0
  62. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  63. package/dist/server/sessions/projectAuthService.js +3 -3
  64. package/dist/server/sessions/projectAuthService.js.map +1 -1
  65. package/dist/server/sessions/sessionCommandService.js +60 -5
  66. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  67. package/dist/server/sessions/sessionFileHeader.js +45 -0
  68. package/dist/server/sessions/sessionFileHeader.js.map +1 -0
  69. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  70. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  71. package/dist/server/sessions/sessionRoutes.js +245 -1
  72. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  73. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  74. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  75. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  76. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  77. package/dist/server/sessions/spawnSubsessionTool.js +10 -12
  78. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  79. package/dist/server/sessions/spawnTargetResolver.js +3 -16
  80. package/dist/server/sessions/spawnTargetResolver.js.map +1 -1
  81. package/dist/server/terminals/terminalService.js +8 -2
  82. package/dist/server/terminals/terminalService.js.map +1 -1
  83. package/dist/server/workspaces/gitWorktreeDiscovery.js +9 -0
  84. package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -1
  85. package/dist/server/workspaces/projectWorkspaceCwds.js +22 -0
  86. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
  87. package/dist/server/workspaces/workspaceService.js +15 -2
  88. package/dist/server/workspaces/workspaceService.js.map +1 -1
  89. package/dist/shared/activity.js +9 -1
  90. package/dist/shared/activity.js.map +1 -1
  91. package/dist/shared/apiTypes.d.ts +403 -8
  92. package/dist/shared/apiTypes.js +26 -0
  93. package/dist/shared/apiTypes.js.map +1 -1
  94. package/dist/shared/capabilities.js +9 -0
  95. package/dist/shared/capabilities.js.map +1 -1
  96. package/dist/shared/federatedRoutes.js +12 -0
  97. package/dist/shared/federatedRoutes.js.map +1 -1
  98. package/docs/config.md +76 -9
  99. package/docs/plugins.md +72 -16
  100. package/install.sh +1 -1
  101. package/package.json +4 -2
  102. package/dist/client/assets/index-kgpXTDyy.js +0 -3247
  103. package/dist/server/sessions/sessionArchiveMigration.js +0 -590
  104. package/dist/server/sessions/sessionArchiveMigration.js.map +0 -1
@@ -1,34 +1,61 @@
1
1
  import { statSync } from "node:fs";
2
- import { open, readFile, writeFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, getAgentDir, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, getAgentDir, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, ModelRuntime, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
7
7
  import { eventScopeFromManagementContext, managementContextKey, NORMAL_SESSION_EVENT_SCOPE } from "../realtime/sessionEventScope.js";
8
8
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
9
9
  import { SessionCommandService } from "./sessionCommandService.js";
10
+ import { projectSessionTree } from "./sessionTreeProjection.js";
10
11
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
11
12
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
12
13
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
13
14
  import { computeEditPreview } from "./editPreview.js";
14
15
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
15
16
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
17
+ import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
16
18
  import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
17
19
  import { SessionPinStore } from "./sessionPinStore.js";
18
20
  import { SessionActivityCoordinator } from "./sessionActivityCoordinator.js";
19
21
  import { searchSessionContent } from "./sessionContentSearch.js";
20
22
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
23
+ import { readSessionHeaderSummary } from "./sessionFileHeader.js";
24
+ import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
25
+ import { siblingWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
21
26
  import { piWebDataDir } from "../../config.js";
22
27
  import { createScopedSettingsManager, resolveSettingsScopeDirectory } from "./projectSettingsScope.js";
28
+ import { createAskUserToolDefinition } from "./askUserTool.js";
29
+ import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
23
30
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
24
31
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
25
32
  import { buildTranscriptView } from "./subsessionTranscript.js";
26
33
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
27
34
  import { createManagedAgentToolOptions, createManagedPythonToolDefinition } from "./managementAgentTools.js";
28
35
  import { PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR, managementAgentToolNames, withRuntimeCreationEnvironment, writeManagementPermissionSystemPolicy } from "./managementPermissionSystem.js";
36
+ import { SessionNotificationStore } from "./sessionNotificationStore.js";
37
+ import { plainTextTheme } from "./plainTextTheme.js";
38
+ import { SessionUnreadStore } from "./sessionUnreadStore.js";
29
39
  import { archiveCandidateFromActiveSession, archiveCandidateFromArchivedRecord, archiveCandidateFromListEntry, archiveInputFromActiveSession, archiveInputFromCandidate, archiveInputFromListEntry, clientSessionFromArchivedRecord, compareArchivedRecords, } from "./sessionArchiveMapping.js";
30
40
  import { getBoolean, getProperty, getString, toClientEvent } from "./sessionUiEvents.js";
31
41
  const noopLogger = { info() { } };
42
+ const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
43
+ /**
44
+ * User-facing names for the two phases of session startup PI WEB can prove it
45
+ * is inside: it awaits exactly one call for each, so the phase is a fact rather
46
+ * than a guess. Deliberately free of internal symbol names and file paths.
47
+ */
48
+ const STARTUP_PHASE_RUNTIME = "正在启动 Pi 会话";
49
+ const STARTUP_PHASE_EXTENSIONS = "正在加载会话扩展";
50
+ /**
51
+ * Appended to whichever phase is running when a background provider catalog
52
+ * refresh happens to be in flight. It is stated as a concurrent fact, never as
53
+ * the cause: PI WEB can verify that a refresh is running, but not that this
54
+ * particular startup is waiting on it.
55
+ */
56
+ const STARTUP_CONCURRENT_CATALOG_REFRESH = "服务商模型列表正在刷新";
57
+ const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
58
+ const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
32
59
  function noop() {
33
60
  // Intentionally empty default unsubscribe callback.
34
61
  }
@@ -52,6 +79,37 @@ function lookupMatchesActiveSession(ref, active) {
52
79
  function activeSessionKey(sessionId, eventScope) {
53
80
  return `${eventScope}\0${sessionId}`;
54
81
  }
82
+ function sessionTreeNavigationOptions(request) {
83
+ switch (request.summary.mode) {
84
+ case "none":
85
+ return { summarize: false };
86
+ case "default":
87
+ return { summarize: true };
88
+ case "custom": {
89
+ const customInstructions = request.summary.instructions.trim();
90
+ if (customInstructions === "")
91
+ throw new Error("Custom branch-summary instructions are required");
92
+ if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
93
+ throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
94
+ }
95
+ return { summarize: true, customInstructions };
96
+ }
97
+ }
98
+ }
99
+ function decrementWeakCount(counts, key) {
100
+ const remaining = (counts.get(key) ?? 1) - 1;
101
+ if (remaining <= 0)
102
+ counts.delete(key);
103
+ else
104
+ counts.set(key, remaining);
105
+ }
106
+ function decrementMapCount(counts, key) {
107
+ const remaining = (counts.get(key) ?? 1) - 1;
108
+ if (remaining <= 0)
109
+ counts.delete(key);
110
+ else
111
+ counts.set(key, remaining);
112
+ }
55
113
  function requirePromptText(value) {
56
114
  if (typeof value !== "string")
57
115
  throw new Error("提示文本为必填项");
@@ -84,6 +142,109 @@ function pathInsideOrEqual(parent, child) {
84
142
  const childRelativePath = relative(normalizedParent, normalizedChild);
85
143
  return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
86
144
  }
145
+ const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
146
+ const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
147
+ function resourceDiagnosticToWarning(diagnostic, source) {
148
+ return {
149
+ severity: diagnostic.type === "error" ? "error" : "warning",
150
+ message: diagnostic.message,
151
+ source,
152
+ ...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
153
+ };
154
+ }
155
+ function runtimeDiagnosticToWarning(diagnostic) {
156
+ return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
157
+ }
158
+ /**
159
+ * Compute the live warnings for a runtime by re-reading its current resource
160
+ * loader diagnostics, extension load errors, and runtime setup diagnostics.
161
+ *
162
+ * This mimics the TUI recomputing warnings on every (re)bind: it reads the
163
+ * runtime's current state rather than a cached snapshot, so a rebuilt runtime
164
+ * yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
165
+ * contribute no warnings.
166
+ */
167
+ export function collectRuntimeWarnings(runtime) {
168
+ const warnings = [];
169
+ for (const diagnostic of runtime.diagnostics ?? [])
170
+ warnings.push(runtimeDiagnosticToWarning(diagnostic));
171
+ const resourceLoader = runtime.services?.resourceLoader;
172
+ if (resourceLoader !== undefined) {
173
+ for (const diagnostic of resourceLoader.getSkills().diagnostics)
174
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
175
+ for (const diagnostic of resourceLoader.getPrompts().diagnostics)
176
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
177
+ for (const diagnostic of resourceLoader.getThemes().diagnostics)
178
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
179
+ for (const error of resourceLoader.getExtensions().errors) {
180
+ warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
181
+ }
182
+ }
183
+ return warnings;
184
+ }
185
+ /**
186
+ * Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
187
+ * character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
188
+ * the SDK's interactive mode so the browser shows the same message the TUI does.
189
+ */
190
+ 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.";
191
+ /** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
192
+ function isAnthropicSubscriptionAuthKey(apiKey) {
193
+ return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
194
+ }
195
+ /**
196
+ * Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
197
+ * own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
198
+ * coupling `pi` already defines rather than inventing a parallel vocabulary, and
199
+ * {@link dismissSessionWarning} maps it back to `setWarnings`.
200
+ */
201
+ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
202
+ /**
203
+ * Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
204
+ * live from the session's current model, stored Anthropic credential, and warning
205
+ * settings. Returns the billing warning when the active provider is `anthropic`
206
+ * and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
207
+ * key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
208
+ *
209
+ * The stored credential is read synchronously (matching the TUI's `oauth` branch
210
+ * and the documented `sk-ant-oat` key trigger) so warnings stay part of the
211
+ * synchronous live status computation.
212
+ */
213
+ export function anthropicSubscriptionWarning(session, authPath) {
214
+ if (session.settingsManager.getWarnings().anthropicExtraUsage === false)
215
+ return undefined;
216
+ if (session.model?.provider !== "anthropic")
217
+ return undefined;
218
+ const credential = readStoredCredential("anthropic", authPath);
219
+ if (credential === undefined)
220
+ return undefined;
221
+ const isSubscriptionAuth = credential.type === "oauth"
222
+ ? true
223
+ : isAnthropicSubscriptionAuthKey(credential.key);
224
+ if (!isSubscriptionAuth)
225
+ return undefined;
226
+ return {
227
+ severity: "warning",
228
+ message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
229
+ source: "anthropic",
230
+ dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
231
+ };
232
+ }
233
+ /**
234
+ * Durably suppress a dismissable session warning by mapping its opaque dismiss
235
+ * id back to the concrete `pi` suppression it represents. Only known ids are
236
+ * honored; unknown ids throw so a stale/forged client cannot silently no-op.
237
+ *
238
+ * This is the single place provider-specific suppression lives: the wire type,
239
+ * parser, and UI stay agnostic. Adding a future dismissable warning is a
240
+ * server-only change here plus a `dismiss` id on its producer.
241
+ */
242
+ export function dismissSessionWarning(session, dismissId) {
243
+ if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
244
+ throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
245
+ }
246
+ session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
247
+ }
87
248
  function defaultCreateAgentRuntime(createRuntime, options) {
88
249
  if (!(options.sessionManager instanceof SessionManager))
89
250
  throw new Error("Default runtime creation requires an SDK SessionManager");
@@ -112,11 +273,11 @@ function createRuntimeWithContextAndOneShotInitialModel(createRuntime, managemen
112
273
  });
113
274
  };
114
275
  }
115
- function createDefaultRuntimeFactory(normalModelRuntimeForCwd, managementModelRuntime, resolveSettingsScope, spawn, subsessions) {
276
+ function createDefaultRuntimeFactory(normalModelRuntimeForCwd, managementModelRuntime, resolveSettingsScope, spawn, subsessions, askUser) {
116
277
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, managementContext, initialModel, delegationToolsEnabled = true }) => {
117
278
  const scopedModelRuntime = managementContext === undefined ? await normalModelRuntimeForCwd(cwd) : await managementModelRuntime();
118
279
  if (managementContext !== undefined) {
119
- return createManagementRuntimeFactory(scopedModelRuntime, resolveSettingsScope, spawn, subsessions, managementContext)({
280
+ return createManagementRuntimeFactory(scopedModelRuntime, resolveSettingsScope, spawn, subsessions, askUser, managementContext)({
120
281
  cwd,
121
282
  agentDir,
122
283
  sessionManager,
@@ -131,7 +292,7 @@ function createDefaultRuntimeFactory(normalModelRuntimeForCwd, managementModelRu
131
292
  globalAgentDir: agentDir,
132
293
  });
133
294
  const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime: scopedModelRuntime, settingsManager });
134
- const customTools = createPiWebCustomToolDefinitions(cwd, delegationToolsEnabled, spawn, subsessions);
295
+ const customTools = createPiWebCustomToolDefinitions(cwd, delegationToolsEnabled, spawn, subsessions, askUser);
135
296
  const result = await createAgentSessionFromServices({
136
297
  services,
137
298
  sessionManager,
@@ -142,7 +303,7 @@ function createDefaultRuntimeFactory(normalModelRuntimeForCwd, managementModelRu
142
303
  return { ...result, services, diagnostics: services.diagnostics };
143
304
  };
144
305
  }
145
- function createManagementRuntimeFactory(modelRuntime, resolveSettingsScope, spawn, subsessions, managementContext) {
306
+ function createManagementRuntimeFactory(modelRuntime, resolveSettingsScope, spawn, subsessions, askUser, managementContext) {
146
307
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled = true }) => {
147
308
  const policyAgentDir = await writeManagementPermissionSystemPolicy(agentDir, cwd, managementContext);
148
309
  return withRuntimeCreationEnvironment({ [PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR]: policyAgentDir }, async () => {
@@ -166,6 +327,7 @@ function createManagementRuntimeFactory(modelRuntime, resolveSettingsScope, spaw
166
327
  createManagedPythonToolDefinition(cwd, managementContext),
167
328
  ...(delegationToolsEnabled && spawn !== undefined ? [defineTool(createSpawnSessionToolDefinition(cwd, { spawn }))] : []),
168
329
  ...(delegationToolsEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions).map((tool) => defineTool(tool)) : []),
330
+ ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
169
331
  ];
170
332
  const options = {
171
333
  services,
@@ -180,11 +342,12 @@ function createManagementRuntimeFactory(modelRuntime, resolveSettingsScope, spaw
180
342
  });
181
343
  };
182
344
  }
183
- export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
345
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
184
346
  return [
185
347
  ...managedAgentToolDefinitions(cwd),
186
348
  ...(delegationEnabled && spawn !== undefined ? [defineTool(createSpawnSessionToolDefinition(cwd, { spawn }))] : []),
187
349
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions).map((tool) => defineTool(tool)) : []),
350
+ ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
188
351
  ];
189
352
  }
190
353
  function managedAgentToolDefinitions(cwd, options) {
@@ -224,8 +387,18 @@ export class PiSessionService {
224
387
  constructor(events, deps) {
225
388
  this.events = events;
226
389
  this.active = new Map();
390
+ this.runtimeBySession = new WeakMap();
227
391
  this.pendingSessionOpens = new Map();
228
392
  this.activities = new SessionActivityCoordinator();
393
+ /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
394
+ this.treeNavigations = new WeakSet();
395
+ /** Counts async operations that may append an entry before they settle. */
396
+ this.sessionEntryMutationCounts = new WeakMap();
397
+ /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
398
+ this.treeExclusiveRuntimeOperationCounts = new WeakMap();
399
+ this.treeExclusiveSessionOperationCounts = new Map();
400
+ this.deferredSubsessionNotifications = new WeakMap();
401
+ this.deferredGeneratedSessionNames = new WeakMap();
229
402
  this.compactionPromptQueues = new Map();
230
403
  this.compactionDrainTimers = new Map();
231
404
  this.authLossWarnings = new Set();
@@ -237,12 +410,32 @@ export class PiSessionService {
237
410
  this.subsessionLinks = new Map();
238
411
  /** Parent id/file identities whose persisted links have already been loaded. */
239
412
  this.subsessionHydratedParents = new Set();
413
+ /** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
414
+ this.sessionHeaderCache = new Map();
240
415
  /**
241
416
  * Tracked subsession id -> whether a completion notification is armed.
242
417
  * Armed when the child starts working; firing on completion disarms it so a
243
418
  * child that works again (and stops again) notifies the parent each time.
244
419
  */
245
420
  this.subsessionNotifyArmed = new Map();
421
+ this.notificationGenerationBySession = new WeakMap();
422
+ this.pendingUnreadMutations = [];
423
+ this.unreadPublicationFlushRequested = false;
424
+ this.unreadPublicationStopped = false;
425
+ /**
426
+ * Read a session file header, memoized per path. Pi writes the header once at
427
+ * session creation, so a successful read stays valid for the process lifetime;
428
+ * failures are not cached so a session file that appears later is picked up.
429
+ */
430
+ this.readCachedSessionHeader = async (sessionFile) => {
431
+ const cached = this.sessionHeaderCache.get(sessionFile);
432
+ if (cached !== undefined)
433
+ return cached;
434
+ const header = await readSessionHeaderSummary(sessionFile);
435
+ if (header !== undefined)
436
+ this.sessionHeaderCache.set(sessionFile, header);
437
+ return header;
438
+ };
246
439
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
247
440
  this.pinStore = deps.pinStore ?? new SessionPinStore(join(deps.dataDir ?? piWebDataDir(), "session-pins.json"));
248
441
  this.agentDir = deps.agentDir ?? getAgentDir();
@@ -264,8 +457,15 @@ export class PiSessionService {
264
457
  ? resolveDefaultModelRuntime
265
458
  : () => Promise.resolve(managementModelRuntime);
266
459
  this.spawnTargets = deps.spawnTargets;
460
+ this.projectWorkspaces = deps.projectWorkspaces;
267
461
  this.logger = deps.logger ?? noopLogger;
268
462
  this.now = deps.now ?? (() => new Date());
463
+ this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
464
+ this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
465
+ this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
466
+ this.catalogRefreshStatus = deps.catalogRefreshStatus;
467
+ this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
468
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
269
469
  // Subsessions are a beta capability gated behind their own flag, and they
270
470
  // also require the spawn capability (they share its project-scope resolver).
271
471
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -274,20 +474,33 @@ export class PiSessionService {
274
474
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
275
475
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
276
476
  read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
277
- });
477
+ }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
278
478
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
279
479
  this.workspaceActivity = deps.workspaceActivity;
280
480
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
281
481
  this.commandService = new SessionCommandService((sessionId, eventScope) => this.getActiveForCommand(sessionId, eventScope), (sessionId, text, eventScope) => this.submitCommandPrompt(sessionId, text, eventScope), events, {
282
482
  onCompactionStart: (session) => {
283
- this.publishActivity(session, "compacting", "active");
483
+ this.beginSessionEntryMutation(session, "compact the session");
484
+ this.publishActivity(session, "正在压缩上下文", "active");
284
485
  this.publishStatus(session);
285
486
  },
286
487
  onCompactionEnd: (session, result, detail) => {
287
- this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
488
+ this.endSessionEntryMutation(session);
489
+ this.publishActivity(session, result === "success" ? "上下文压缩完成" : "上下文压缩失败", result === "success" ? "idle" : "error", detail);
288
490
  this.publishStatus(session);
289
491
  },
290
492
  reloadSession: (session) => this.reloadSessionRuntime(session),
493
+ getSessionTree: (session) => {
494
+ if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
495
+ return undefined;
496
+ return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
497
+ },
498
+ hasActiveWork: (session) => this.hasActiveWork(session),
499
+ isTreeNavigationActive: (session) => this.treeNavigations.has(session),
500
+ runSessionReplacement: (session, operation) => {
501
+ const runtime = this.activeRuntimeForSession(session);
502
+ return this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session, ...(runtime === undefined ? {} : { runtime }) }], "Stop current session activity before replacing the session", operation);
503
+ },
291
504
  }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
292
505
  }
293
506
  activeCount() {
@@ -319,22 +532,24 @@ export class PiSessionService {
319
532
  skippedBusySessionIds.add(input.sessionId);
320
533
  continue;
321
534
  }
322
- await this.closeActive(input.sessionId);
535
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
323
536
  readyArchiveInputs.push(input);
324
537
  }
325
538
  await this.archiveStoreArchiveMany(readyArchiveInputs);
326
539
  archiveInputs.push(...readyArchiveInputs);
540
+ await this.forgetUnreadSessions(readyArchiveInputs);
327
541
  for (const record of plan.deleteRecords) {
328
542
  if (this.activeSessionHasWork(record.sessionId)) {
329
543
  skippedBusySessionIds.add(record.sessionId);
330
544
  continue;
331
545
  }
332
- await this.closeActive(record.sessionId);
546
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
333
547
  readyDeleteRecords.push(record);
334
548
  }
335
549
  await this.ensureArchivedRecordsMoved(readyDeleteRecords);
336
550
  const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
337
551
  deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
552
+ await this.forgetUnreadSessions(deleteRecords);
338
553
  return summarizeSessionCleanupExecution({
339
554
  archiveInputs,
340
555
  deleteRecords,
@@ -344,12 +559,18 @@ export class PiSessionService {
344
559
  });
345
560
  }
346
561
  async dispose() {
562
+ this.unreadPublicationStopped = true;
563
+ this.clearUnreadPublicationRetry();
347
564
  clearInterval(this.heartbeat);
348
565
  this.clearCompactionDrainTimers();
349
566
  const pendingOpens = this.pendingSessionOpenPromises();
350
567
  if (pendingOpens.length > 0)
351
568
  await Promise.allSettled(pendingOpens);
352
569
  const activeSessions = Array.from(new Set(this.active.values()));
570
+ for (const active of activeSessions) {
571
+ this.forgetUnreadActivity(active.runtime.session);
572
+ this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
573
+ }
353
574
  this.active.clear();
354
575
  this.pendingSessionOpens.clear();
355
576
  this.activities.clear();
@@ -360,12 +581,15 @@ export class PiSessionService {
360
581
  this.subsessionLinks.clear();
361
582
  this.subsessionHydratedParents.clear();
362
583
  this.subsessionNotifyArmed.clear();
584
+ this.sessionHeaderCache.clear();
585
+ this.notificationStore.clearAll("service-dispose");
363
586
  await Promise.all(activeSessions.map(async (active) => {
364
587
  active.unsubscribe();
365
588
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd(), active.eventScope);
366
589
  await active.runtime.session.abort();
367
590
  await active.runtime.dispose();
368
591
  }));
592
+ await this.publishUnreadMutations([]);
369
593
  }
370
594
  async list(cwd, managementContext) {
371
595
  return (await this.listWorkspaceSessions(cwd, managementContext)).sessions;
@@ -422,18 +646,81 @@ export class PiSessionService {
422
646
  .filter((record) => record.cwd === cwd)
423
647
  .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
424
648
  const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
649
+ for (const record of archivedForCwd) {
650
+ this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
651
+ }
425
652
  const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
426
- this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById), eventScopeFromManagementContext(managementContext));
653
+ const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
654
+ this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds, eventScopeFromManagementContext(managementContext));
655
+ if (managementContext === undefined) {
656
+ await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
657
+ }
427
658
  const archivedSessions = archivedForCwd
428
659
  .sort(compareArchivedRecords)
429
660
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
430
661
  .filter(isDefined);
431
662
  return {
432
- sessions: [...unarchivedSessions, ...archivedSessions],
663
+ sessions: await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd),
433
664
  entriesById: new Map(sessions.map((session) => [session.id, session])),
434
665
  archivedById,
435
666
  };
436
667
  }
668
+ /**
669
+ * Annotate a listing with the session relationships that cross workspace
670
+ * boundaries: where an out-of-listing parent lives, and how many children a
671
+ * listed session has in sibling workspaces.
672
+ *
673
+ * Both directions are best-effort. An unreadable parent header or a sibling
674
+ * workspace that cannot be listed leaves the session unannotated rather than
675
+ * failing the listing, and the browser falls back to its generic states.
676
+ */
677
+ async withRelatedSessionsElsewhere(sessions, cwd) {
678
+ const [parentLocations, childCounts] = await Promise.all([
679
+ locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
680
+ this.countChildrenInSiblingWorkspaces(sessions, cwd),
681
+ ]);
682
+ return sessions.map((session) => {
683
+ const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
684
+ const childrenElsewhere = childCounts.get(session.path);
685
+ if (parent === undefined && childrenElsewhere === undefined)
686
+ return session;
687
+ const annotated = { ...session };
688
+ if (parent !== undefined) {
689
+ annotated.parentSessionId = parent.parentSessionId;
690
+ annotated.parentSessionCwd = parent.parentSessionCwd;
691
+ }
692
+ if (childrenElsewhere !== undefined)
693
+ annotated.childSessionsElsewhere = childrenElsewhere;
694
+ return annotated;
695
+ });
696
+ }
697
+ /**
698
+ * Count children of the listed sessions that live in other workspaces of the
699
+ * same project.
700
+ *
701
+ * Only sibling workspaces are scanned: agents may only spawn into workspaces
702
+ * of the spawning session's own project, so that bounds where a child can be.
703
+ * Listing is skipped entirely when no project-workspace locator is configured
704
+ * or the cwd belongs to no registered project.
705
+ */
706
+ async countChildrenInSiblingWorkspaces(sessions, cwd) {
707
+ if (this.projectWorkspaces === undefined || sessions.length === 0)
708
+ return new Map();
709
+ try {
710
+ const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
711
+ if (siblingCwds.length === 0)
712
+ return new Map();
713
+ const listings = await Promise.all(siblingCwds.map(async (siblingCwd) => {
714
+ const entries = await this.sessionManager.list(siblingCwd);
715
+ return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
716
+ }));
717
+ return countOutOfListingChildren(sessions, listings.flat());
718
+ }
719
+ catch (error) {
720
+ this.logger.info({ cwd, error: error instanceof Error ? error.message : String(error) }, "failed to count child sessions in sibling workspaces");
721
+ return new Map();
722
+ }
723
+ }
437
724
  async start(cwd, options = {}) {
438
725
  if (options.managementContext === undefined)
439
726
  await this.normalModelRuntimeForCwd(cwd);
@@ -444,6 +731,8 @@ export class PiSessionService {
444
731
  ...(options.managementContext === undefined ? {} : { managementContext: options.managementContext }),
445
732
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
446
733
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
734
+ ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
735
+ startupIntent: "create",
447
736
  });
448
737
  const { session } = active.runtime;
449
738
  const created = {
@@ -505,13 +794,100 @@ export class PiSessionService {
505
794
  ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
506
795
  cwd: decision.cwd,
507
796
  };
508
- this.registerVerifiedSubsession(link);
797
+ await this.registerVerifiedSubsession(link);
509
798
  this.persistSubsessionLink(link);
510
799
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
511
800
  await this.prompt(created.id, input.prompt);
512
801
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
513
802
  return { sessionId: created.id, cwd: decision.cwd };
514
803
  }
804
+ /**
805
+ * Register the question set an agent wants the user to answer as the session's
806
+ * open ask. Deliberately does not wait for the user: `ask_user` terminates the
807
+ * run and the submitted answers come back later as a follow-up message.
808
+ *
809
+ * Rejected question sets throw {@link PendingAskValidationError}, which the
810
+ * agent loop reports to the model as an error tool result.
811
+ */
812
+ // eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
813
+ async openAsk(input) {
814
+ const result = this.pendingAskStore.open(input);
815
+ // A supersede closes the earlier ask, so the browsers watching it must hear
816
+ // that before they hear about its replacement.
817
+ if (result.superseded !== undefined)
818
+ this.publishAskClosed(input.sessionId, result.superseded);
819
+ this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
820
+ this.publishStatusForSessionId(input.sessionId);
821
+ return result;
822
+ }
823
+ /**
824
+ * Record the user's answers to the session's open ask and hand them to the
825
+ * model. The answers travel as a system-authored custom message rather than a
826
+ * user message, so they are not attributed to the human in the transcript;
827
+ * they still wake an idle session (`triggerTurn`) and queue behind in-flight
828
+ * work (`deliverAs: "followUp"`), which is how the run that `ask_user`
829
+ * terminated continues.
830
+ */
831
+ async submitAsk(ref, askId, submission) {
832
+ await this.assertWritable(ref);
833
+ const session = await this.getOrOpen(ref);
834
+ // Checked before the store closes the ask so a refused delivery cannot
835
+ // discard answers the user already submitted.
836
+ this.assertTreeNavigationInactive(session, "answer questions");
837
+ return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
838
+ }
839
+ /**
840
+ * Close the open ask without answers. The model is still told, naming every
841
+ * question as unanswered: it was promised a follow-up message and would
842
+ * otherwise wait for one that never comes.
843
+ */
844
+ async cancelAsk(ref, askId) {
845
+ await this.assertWritable(ref);
846
+ const session = await this.getOrOpen(ref);
847
+ this.assertTreeNavigationInactive(session, "dismiss questions");
848
+ return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
849
+ }
850
+ /**
851
+ * Publish and deliver a closed ask. A stale close is reported rather than
852
+ * thrown: losing the race against a supersede, another browser, or a session
853
+ * that went away is ordinary, and the returned status tells the browser what
854
+ * the session's open ask is now.
855
+ */
856
+ async closeAsk(session, result) {
857
+ if (result.status === "stale")
858
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
859
+ const { outcome } = result;
860
+ this.publishAskClosed(session.sessionId, outcome);
861
+ await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
862
+ this.publishStatus(session);
863
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
864
+ }
865
+ publishAskClosed(sessionId, outcome) {
866
+ this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
867
+ }
868
+ /**
869
+ * Void the session's open ask because the user sent a chat message instead of
870
+ * answering it. Every browser closes the card as cancelled, and the model is
871
+ * told — without being woken — so the notice rides into the turn the message
872
+ * itself triggers rather than becoming a turn of its own.
873
+ */
874
+ async voidOpenAskForUserMessage(session) {
875
+ const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
876
+ if (outcome === undefined)
877
+ return;
878
+ this.publishAskClosed(session.sessionId, outcome);
879
+ await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
880
+ this.publishStatus(session);
881
+ }
882
+ /**
883
+ * Publish status for a session known only by id, as the ask tools are: they
884
+ * run inside the session's own runtime, so the active entry is the session.
885
+ */
886
+ publishStatusForSessionId(sessionId) {
887
+ const session = this.active.get(sessionId)?.runtime.session;
888
+ if (session !== undefined)
889
+ this.publishStatus(session);
890
+ }
515
891
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
516
892
  async listSubsessions(parentSessionId, parentSessionFile) {
517
893
  const parentFile = nonEmptyString(parentSessionFile);
@@ -574,7 +950,7 @@ export class PiSessionService {
574
950
  return undefined;
575
951
  return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
576
952
  }
577
- registerVerifiedSubsession(link) {
953
+ async registerVerifiedSubsession(link) {
578
954
  const { childSessionId, parentSessionId } = link;
579
955
  const previousParentId = this.subsessionParents.get(childSessionId);
580
956
  if (previousParentId !== undefined && previousParentId !== parentSessionId) {
@@ -590,6 +966,24 @@ export class PiSessionService {
590
966
  this.subsessionLinks.set(childSessionId, link);
591
967
  if (!this.subsessionNotifyArmed.has(childSessionId))
592
968
  this.subsessionNotifyArmed.set(childSessionId, false);
969
+ const cwd = this.cwdForVerifiedSubsession(link);
970
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
971
+ }
972
+ cwdForVerifiedSubsession(link) {
973
+ const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
974
+ const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
975
+ if (linkedCwd !== undefined)
976
+ return canonicalizeStoredCwd(linkedCwd);
977
+ const childSessionFile = link.childSessionFile;
978
+ if (childSessionFile !== undefined) {
979
+ try {
980
+ return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
981
+ }
982
+ catch (error) {
983
+ throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
984
+ }
985
+ }
986
+ throw new Error("Could not resolve cwd for verified tracked sub-session");
593
987
  }
594
988
  unregisterSubsession(childSessionId) {
595
989
  const parentSessionId = this.subsessionParents.get(childSessionId);
@@ -636,40 +1030,44 @@ export class PiSessionService {
636
1030
  const activeParent = this.activeForSessionId(parentSessionId);
637
1031
  if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
638
1032
  const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
639
- await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
640
- this.subsessionHydratedParents.add(hydrationKey);
1033
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
1034
+ if (complete)
1035
+ this.subsessionHydratedParents.add(hydrationKey);
641
1036
  return;
642
1037
  }
643
1038
  if (parentSessionFile === undefined)
644
1039
  return;
645
- if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
646
- this.subsessionHydratedParents.add(hydrationKey);
1040
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
647
1041
  return;
648
- }
649
1042
  let parentManager;
650
1043
  try {
651
1044
  parentManager = this.sessionManager.open(parentSessionFile);
652
1045
  }
653
1046
  catch {
654
- this.subsessionHydratedParents.add(hydrationKey);
655
1047
  return;
656
1048
  }
657
- await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
658
- this.subsessionHydratedParents.add(hydrationKey);
1049
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
1050
+ if (complete)
1051
+ this.subsessionHydratedParents.add(hydrationKey);
659
1052
  }
660
1053
  async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
661
1054
  // Parent custom links are the authoritative recovery record: verify the
662
- // exact live child file/header before tracking.
1055
+ // exact live child file/header before tracking. Do not negatively cache a
1056
+ // scan while a candidate child is temporarily unavailable.
663
1057
  const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
1058
+ let complete = true;
664
1059
  for (const entry of entries) {
665
1060
  const link = parsePersistedParentSubsessionLink(entry);
666
- if (link === undefined)
1061
+ if (link?.spawnedBySessionId !== parentSessionId)
667
1062
  continue;
668
1063
  const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
669
- if (verified === undefined)
1064
+ if (verified === undefined) {
1065
+ complete = false;
670
1066
  continue;
671
- this.registerVerifiedSubsession(verified);
1067
+ }
1068
+ await this.registerVerifiedSubsession(verified);
672
1069
  }
1070
+ return complete;
673
1071
  }
674
1072
  async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
675
1073
  if (parentSessionFile === undefined)
@@ -688,7 +1086,7 @@ export class PiSessionService {
688
1086
  const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
689
1087
  if (link === undefined)
690
1088
  return;
691
- this.registerVerifiedSubsession(link);
1089
+ await this.registerVerifiedSubsession(link);
692
1090
  }
693
1091
  verifiedSubsessionLinkFromOpenedChild(session) {
694
1092
  return verifiedTrackedSubsessionLink(this.sessionManager, {
@@ -763,12 +1161,12 @@ export class PiSessionService {
763
1161
  this.subsessionNotifyArmed.set(childId, false);
764
1162
  const status = this.activityForSession(session)?.phase === "error" ? "error" : "idle";
765
1163
  const finalText = finalAssistantText(historyMessages(session));
766
- const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
1164
+ const outputSection = formatSubsessionNotificationOutput(childId, finalText);
767
1165
  const workingIds = this.workingSubsessionIds(link.parentSessionId);
768
1166
  const next = workingIds.length === 0
769
1167
  ? "No other tracked subsessions are working."
770
1168
  : `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.`;
771
- const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
1169
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
772
1170
  void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
773
1171
  }
774
1172
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
@@ -797,11 +1195,16 @@ export class PiSessionService {
797
1195
  async notifyParentOfSubsession(parentId, childId, text) {
798
1196
  try {
799
1197
  const session = await this.getOrOpenParentForSubsession(parentId, childId);
800
- await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
801
- this.publishStatus(session);
1198
+ if (this.treeNavigations.has(session)) {
1199
+ const pending = this.deferredSubsessionNotifications.get(session) ?? [];
1200
+ pending.push({ parentId, childId, text });
1201
+ this.deferredSubsessionNotifications.set(session, pending);
1202
+ return;
1203
+ }
1204
+ await this.deliverSubsessionNotification(session, { parentId, childId, text });
802
1205
  }
803
1206
  catch (error) {
804
- this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1207
+ this.logSubsessionNotificationFailure(parentId, childId, error);
805
1208
  }
806
1209
  }
807
1210
  async messages(ref, page, managementContext) {
@@ -859,18 +1262,18 @@ export class PiSessionService {
859
1262
  ?? session.modelRuntime.getModel(provider, modelId);
860
1263
  if (model === undefined)
861
1264
  throw new Error(`未找到模型:${provider}/${modelId}`);
862
- await session.setModel(model);
863
- this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
1265
+ await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
1266
+ this.publishActivity(session, `模型:${model.id}`, "idle", model.provider);
864
1267
  this.publishStatus(session);
865
1268
  return this.statusFromSession(session);
866
1269
  }
867
1270
  async cycleModel(ref, direction, managementContext) {
868
1271
  await this.assertWritable(ref);
869
1272
  const session = await this.getOrOpen(ref, managementContext);
870
- const result = await session.cycleModel(direction);
1273
+ const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
871
1274
  if (result === undefined)
872
1275
  throw new Error(session.scopedModels.length > 0 ? "作用域内只有一个模型" : "只有一个可用模型");
873
- this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
1276
+ this.publishActivity(session, `模型:${result.model.id}`, "idle", result.model.provider);
874
1277
  this.publishStatus(session);
875
1278
  return this.statusFromSession(session);
876
1279
  }
@@ -881,24 +1284,28 @@ export class PiSessionService {
881
1284
  async setThinkingLevel(ref, level, managementContext) {
882
1285
  await this.assertWritable(ref);
883
1286
  const session = await this.getOrOpen(ref, managementContext);
1287
+ this.assertTreeNavigationInactive(session, "change the thinking level");
884
1288
  // pi owns the valid set; validate against the session's live levels rather
885
1289
  // than a hardcoded union so this stays correct if pi changes the set.
886
1290
  const available = session.getAvailableThinkingLevels();
887
1291
  const match = available.find((candidate) => candidate === level);
888
1292
  if (match === undefined)
889
1293
  throw new Error(`无效的思考级别:${level}`);
890
- session.setThinkingLevel(match);
891
- this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
1294
+ await this.runSessionEntryMutation(session, "change the thinking level", () => {
1295
+ session.setThinkingLevel(match);
1296
+ return Promise.resolve();
1297
+ });
1298
+ this.publishActivity(session, `思考级别:${session.thinkingLevel}`, "idle");
892
1299
  this.publishStatus(session);
893
1300
  return this.statusFromSession(session);
894
1301
  }
895
1302
  async cycleThinkingLevel(ref, managementContext) {
896
1303
  await this.assertWritable(ref);
897
1304
  const session = await this.getOrOpen(ref, managementContext);
898
- const level = session.cycleThinkingLevel();
1305
+ const level = await this.runSessionEntryMutation(session, "change the thinking level", () => Promise.resolve(session.cycleThinkingLevel()));
899
1306
  if (level === undefined)
900
1307
  throw new Error("当前模型不支持思考");
901
- this.publishActivity(session, `thinking: ${level}`, "idle");
1308
+ this.publishActivity(session, `思考级别:${level}`, "idle");
902
1309
  this.publishStatus(session);
903
1310
  return this.statusFromSession(session);
904
1311
  }
@@ -928,14 +1335,20 @@ export class PiSessionService {
928
1335
  const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
929
1336
  await this.assertWritable(ref);
930
1337
  const session = await this.getOrOpen(ref, options?.managementContext);
1338
+ this.assertTreeNavigationInactive(session, "send a prompt");
931
1339
  this.maybeGenerateSessionName(session, promptText);
932
1340
  const isQueued = session.isStreaming || session.isCompacting;
933
1341
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
934
1342
  if (isQueued && images.length === 0 && this.hasQueuedMessageText(session, promptText)) {
935
- this.publishActivity(session, "duplicate queued message ignored", "active");
1343
+ this.publishActivity(session, "已忽略重复的排队消息", "active");
936
1344
  this.publishStatus(session);
937
1345
  return;
938
1346
  }
1347
+ // A chat message answers the session's open ask in the user's own words, so
1348
+ // the form is void: keeping it open would invite answers to questions the
1349
+ // conversation has already moved past. Ignored duplicates skip this on
1350
+ // purpose: they must not void an ask posted after the queued original.
1351
+ await this.voidOpenAskForUserMessage(session);
939
1352
  if (session.isCompacting) {
940
1353
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
941
1354
  return;
@@ -943,14 +1356,14 @@ export class PiSessionService {
943
1356
  void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
944
1357
  }
945
1358
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
946
- this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1359
+ this.publishActivity(session, behavior === "steer" ? "插队消息已排队" : behavior === "followUp" ? "消息已排队" : "消息已接收", "active");
947
1360
  const eventScope = this.eventScopeForSession(session);
948
1361
  if (behavior === undefined && echoUserMessage)
949
1362
  this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) }, eventScope);
950
1363
  const promptOptions = buildPromptOptions(behavior, images);
951
- const promptPromise = session.prompt(text, promptOptions).catch((error) => {
1364
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
952
1365
  const message = error instanceof Error ? error.message : String(error);
953
- this.publishActivity(session, "error", "error", message);
1366
+ this.publishActivity(session, "发生错误", "error", message);
954
1367
  this.events.publish(session.sessionId, { type: "session.error", message }, eventScope);
955
1368
  });
956
1369
  void promptPromise;
@@ -960,7 +1373,7 @@ export class PiSessionService {
960
1373
  const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
961
1374
  queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
962
1375
  this.compactionPromptQueues.set(session.sessionId, queue);
963
- this.publishActivity(session, "message queued during compaction", "active");
1376
+ this.publishActivity(session, "压缩期间消息已排队", "active");
964
1377
  this.publishStatus(session);
965
1378
  }
966
1379
  async saveAttachments(ref, attachments, folder, managementContext) {
@@ -975,6 +1388,7 @@ export class PiSessionService {
975
1388
  await this.assertWritable(ref);
976
1389
  const active = await this.getActive(ref, managementContext);
977
1390
  const { session } = active.runtime;
1391
+ this.assertTreeNavigationInactive(session, "run a shell command");
978
1392
  const isExcluded = text.startsWith("!!");
979
1393
  const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
980
1394
  if (!command)
@@ -982,13 +1396,13 @@ export class PiSessionService {
982
1396
  if (session.isBashRunning)
983
1397
  throw new Error("已有 bash 命令正在运行");
984
1398
  const eventScope = active.eventScope;
985
- this.publishActivity(session, "running bash", "active", command);
1399
+ this.publishActivity(session, "正在运行命令", "active", command);
986
1400
  this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded }, eventScope);
987
- void session.executeBash(command, (chunk) => {
1401
+ void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
988
1402
  this.events.publish(session.sessionId, { type: "shell.chunk", chunk }, eventScope);
989
- this.publishActivity(session, "running bash", "active", command);
1403
+ this.publishActivity(session, "正在运行命令", "active", command);
990
1404
  this.publishStatus(session);
991
- }, { excludeFromContext: isExcluded }).then((result) => {
1405
+ }, { excludeFromContext: isExcluded })).then((result) => {
992
1406
  this.events.publish(session.sessionId, {
993
1407
  type: "shell.end",
994
1408
  output: result.output,
@@ -997,13 +1411,13 @@ export class PiSessionService {
997
1411
  truncated: result.truncated,
998
1412
  ...(result.fullOutputPath === undefined ? {} : { fullOutputPath: result.fullOutputPath }),
999
1413
  }, eventScope);
1000
- this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
1414
+ this.publishActivity(session, "命令执行完成", result.exitCode === 0 ? "idle" : "error", command);
1001
1415
  this.publishStatus(session);
1002
1416
  }).catch((error) => {
1003
1417
  const message = error instanceof Error ? error.message : String(error);
1004
1418
  this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true }, eventScope);
1005
1419
  this.events.publish(session.sessionId, { type: "session.error", message }, eventScope);
1006
- this.publishActivity(session, "bash failed", "error", message);
1420
+ this.publishActivity(session, "命令执行失败", "error", message);
1007
1421
  this.publishStatus(session);
1008
1422
  });
1009
1423
  }
@@ -1017,31 +1431,67 @@ export class PiSessionService {
1017
1431
  const active = await this.getActive(ref, managementContext);
1018
1432
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value, active.eventScope);
1019
1433
  }
1020
- async reloadSessionRuntime(session) {
1434
+ async navigateTree(ref, request, managementContext) {
1435
+ if (request.targetId.trim() === "")
1436
+ throw new Error("Session tree target is required");
1437
+ if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
1438
+ throw new Error("Stop current session activity before navigating the session tree");
1439
+ }
1440
+ await this.assertWritable(ref);
1441
+ const options = sessionTreeNavigationOptions(request);
1442
+ const session = await this.getOrOpen(ref, managementContext);
1443
+ if (typeof session.navigateTree !== "function")
1444
+ throw new Error("Session tree navigation is not supported by this Pi runtime");
1021
1445
  if (this.hasActiveWork(session))
1022
- throw new Error("Stop current session activity before reloading");
1023
- this.publishActivity(session, "reloading resources", "active");
1446
+ throw new Error("Stop current session activity before navigating the session tree");
1447
+ // Acquire synchronously after the active-work check. No leaf-producing work
1448
+ // may enter this runtime until Pi's potentially asynchronous summary settles.
1449
+ this.treeNavigations.add(session);
1024
1450
  try {
1025
- await session.reload();
1026
- this.publishActivity(session, "resources reloaded", "idle");
1451
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1452
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1453
+ }
1454
+ this.publishActivity(session, options.summarize ? "正在汇总分支" : "正在切换会话树", "active");
1027
1455
  this.publishStatus(session);
1456
+ const result = await session.navigateTree(request.targetId, options);
1457
+ if (result.cancelled) {
1458
+ if (this.isCurrentActiveSession(session)) {
1459
+ this.publishActivity(session, result.aborted === true ? "分支汇总已终止" : "会话树切换已取消", "idle");
1460
+ }
1461
+ return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1462
+ }
1463
+ if (this.isCurrentActiveSession(session))
1464
+ this.publishActivity(session, "已切换会话树", "idle");
1465
+ return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
1028
1466
  }
1029
1467
  catch (error) {
1030
1468
  const message = error instanceof Error ? error.message : String(error);
1031
- this.publishActivity(session, "reload failed", "error", message);
1469
+ this.publishActivity(session, "重新加载失败", "error", message);
1032
1470
  this.events.publish(session.sessionId, { type: "session.error", message }, this.eventScopeForSession(session));
1033
1471
  this.publishStatus(session);
1034
1472
  throw error;
1035
1473
  }
1474
+ finally {
1475
+ this.treeNavigations.delete(session);
1476
+ if (this.isCurrentActiveSession(session)) {
1477
+ this.flushDeferredTreeNavigationWork(session);
1478
+ this.publishStatus(session);
1479
+ }
1480
+ else {
1481
+ this.deferredGeneratedSessionNames.delete(session);
1482
+ this.deferredSubsessionNotifications.delete(session);
1483
+ }
1484
+ }
1036
1485
  }
1037
1486
  async archive(ref, managementContext) {
1038
1487
  const active = await this.getActive(ref, managementContext);
1039
1488
  const session = active.runtime.session;
1040
- if (this.hasActiveWork(session))
1041
- throw new Error("归档前请先停止当前会话活动");
1042
- const archiveInput = await this.archiveInputForSession(session);
1043
- await this.closeActiveSession(active);
1044
- await this.archiveStore.archive(archiveInput);
1489
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session, runtime: active.runtime }], "归档前请先停止当前会话活动", async () => {
1490
+ const archiveInput = await this.archiveInputForSession(session);
1491
+ await this.closeActiveSession(active);
1492
+ await this.archiveStore.archive(archiveInput);
1493
+ await this.forgetUnreadSessions([archiveInput]);
1494
+ });
1045
1495
  }
1046
1496
  async archiveMany(refs, managementContext) {
1047
1497
  const uniqueRefs = uniqueBulkSessionRefs(refs);
@@ -1051,11 +1501,14 @@ export class PiSessionService {
1051
1501
  ]);
1052
1502
  const failures = [];
1053
1503
  const alreadyArchivedSessionIds = [];
1504
+ const unreadArchivedIdentities = [];
1054
1505
  const planItems = [];
1055
1506
  for (const ref of uniqueRefs) {
1056
1507
  const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1057
1508
  if (archived !== undefined) {
1509
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1058
1510
  alreadyArchivedSessionIds.push(archived.sessionId);
1511
+ unreadArchivedIdentities.push(archived);
1059
1512
  continue;
1060
1513
  }
1061
1514
  const active = this.activeForLookup(bulkRefToLookup(ref), managementContext);
@@ -1080,28 +1533,44 @@ export class PiSessionService {
1080
1533
  failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
1081
1534
  }
1082
1535
  }
1083
- const readyInputs = [];
1536
+ const readyPlanItems = [];
1084
1537
  for (const item of planItems) {
1538
+ const active = item.active ?? this.activeForLookup(bulkRefToLookup({ id: item.input.sessionId, cwd: item.input.cwd }), managementContext);
1539
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1540
+ failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1541
+ continue;
1542
+ }
1543
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
1544
+ }
1545
+ const readyInputs = [];
1546
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1547
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1548
+ sessionId: input.sessionId,
1549
+ ...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
1550
+ })), "Stop current session activity before archiving", async () => {
1551
+ for (const item of readyPlanItems) {
1552
+ try {
1553
+ if (item.active !== undefined)
1554
+ await this.closeActiveSession(item.active);
1555
+ else
1556
+ await this.closeActiveInScope(item.input.sessionId, eventScopeFromManagementContext(managementContext));
1557
+ readyInputs.push(item.input);
1558
+ }
1559
+ catch (error) {
1560
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1561
+ }
1562
+ }
1085
1563
  try {
1086
- if (item.active !== undefined)
1087
- await this.closeActiveSession(item.active);
1088
- else
1089
- await this.closeActiveInScope(item.input.sessionId, eventScopeFromManagementContext(managementContext));
1090
- readyInputs.push(item.input);
1564
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1565
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1566
+ unreadArchivedIdentities.push(...archived);
1091
1567
  }
1092
1568
  catch (error) {
1093
- failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1569
+ for (const input of readyInputs)
1570
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1094
1571
  }
1095
- }
1096
- const archivedSessionIds = [...alreadyArchivedSessionIds];
1097
- try {
1098
- const archived = await this.archiveStoreArchiveMany(readyInputs);
1099
- archivedSessionIds.push(...archived.map((record) => record.sessionId));
1100
- }
1101
- catch (error) {
1102
- for (const input of readyInputs)
1103
- failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1104
- }
1572
+ });
1573
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
1105
1574
  return {
1106
1575
  archived: true,
1107
1576
  archivedSessionIds: uniqueStrings(archivedSessionIds),
@@ -1118,9 +1587,19 @@ export class PiSessionService {
1118
1587
  if (busy !== undefined)
1119
1588
  throw new Error(`归档 ${sessionDisplayName(busy)} 前请先停止当前会话活动`);
1120
1589
  const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1121
- for (const input of archiveInputs)
1122
- await this.closeActiveInScope(input.sessionId, eventScopeFromManagementContext(managementContext));
1123
- await this.archiveStoreArchiveMany(archiveInputs);
1590
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1591
+ sessionId: target.id,
1592
+ ...(target.activeSession === undefined ? {} : { session: target.activeSession }),
1593
+ })), `归档 ${sessionDisplayName(session)} 前请先停止当前会话活动`, async () => {
1594
+ for (const target of plan.targets) {
1595
+ if (target.archived)
1596
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1597
+ }
1598
+ for (const input of archiveInputs)
1599
+ await this.closeActiveInScope(input.sessionId, eventScopeFromManagementContext(managementContext));
1600
+ await this.archiveStoreArchiveMany(archiveInputs);
1601
+ });
1602
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
1124
1603
  return {
1125
1604
  archived: true,
1126
1605
  sessionIds: archiveInputs.map((input) => input.sessionId),
@@ -1134,6 +1613,7 @@ export class PiSessionService {
1134
1613
  throw new Error("未找到会话");
1135
1614
  await this.closeActiveInScope(archived.sessionId, eventScopeFromManagementContext(managementContext));
1136
1615
  await this.archiveStore.restore(archived.sessionId);
1616
+ await this.forgetUnreadSessions([archived]);
1137
1617
  }
1138
1618
  async deleteArchived(ref, managementContext) {
1139
1619
  const record = await this.getArchived(ref);
@@ -1145,6 +1625,7 @@ export class PiSessionService {
1145
1625
  if (record.archivePath === undefined)
1146
1626
  await this.ensureArchivedRecordMoved(record);
1147
1627
  await this.archiveStore.deleteArchived(record.sessionId);
1628
+ await this.forgetUnreadSessions([record]);
1148
1629
  }
1149
1630
  async deleteArchivedMany(refs, managementContext) {
1150
1631
  if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
@@ -1190,6 +1671,8 @@ export class PiSessionService {
1190
1671
  for (const sessionId of deleteIds)
1191
1672
  failures.push({ sessionId, error: errorMessage(error) });
1192
1673
  }
1674
+ const deletedIdSet = new Set(deletedSessionIds);
1675
+ await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
1193
1676
  return {
1194
1677
  deleted: true,
1195
1678
  deletedSessionIds,
@@ -1203,9 +1686,35 @@ export class PiSessionService {
1203
1686
  const session = active.runtime.session;
1204
1687
  if (this.hasActiveWork(session))
1205
1688
  throw new Error("重新加载前请先停止当前会话活动");
1206
- await this.closeActiveSession(active);
1207
- const reopened = await this.getActive(ref, managementContext);
1208
- this.publishStatus(reopened.runtime.session);
1689
+ const sessionFile = session.sessionFile;
1690
+ if (sessionFile === undefined || sessionFile === "")
1691
+ throw new Error("会话尚未持久化");
1692
+ const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session, runtime: active.runtime }], "重新加载前请先停止当前会话活动", async () => {
1693
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1694
+ const { sessionId, cwd } = notificationIdentityForSession(session);
1695
+ let candidateGeneration;
1696
+ try {
1697
+ await this.closeActiveSession(active, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
1698
+ candidateGeneration = priorGeneration === undefined
1699
+ ? undefined
1700
+ : this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
1701
+ const reopened = await this.openExistingSession(sessionId, cwd, () => this.sessionManager.open(sessionFile), {
1702
+ ...(managementContext === undefined ? {} : { managementContext }),
1703
+ ...(candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration }),
1704
+ });
1705
+ if (candidateGeneration !== undefined) {
1706
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1707
+ }
1708
+ return reopened.runtime.session;
1709
+ }
1710
+ catch (error) {
1711
+ if (candidateGeneration !== undefined) {
1712
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
1713
+ }
1714
+ throw error;
1715
+ }
1716
+ });
1717
+ this.publishStatus(reopenedSession);
1209
1718
  }
1210
1719
  async detachParent(ref, managementContext) {
1211
1720
  const session = await this.getOrOpen(ref, managementContext);
@@ -1215,6 +1724,7 @@ export class PiSessionService {
1215
1724
  await clearParentSession(sessionFile);
1216
1725
  clearParentSessionHeader(session.sessionManager);
1217
1726
  this.unregisterSubsession(session.sessionId);
1727
+ await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
1218
1728
  }
1219
1729
  async abort(ref, managementContext) {
1220
1730
  const active = this.activeForLookup(ref, managementContext);
@@ -1223,9 +1733,18 @@ export class PiSessionService {
1223
1733
  const sessionId = active.runtime.session.sessionId;
1224
1734
  this.clearCompactionPromptQueue(sessionId);
1225
1735
  clearSessionQueue(active.runtime.session);
1226
- await active.runtime.session.abort();
1227
- this.publishActivity(active.runtime.session, "stopped", "idle");
1228
- this.publishStatus(active.runtime.session);
1736
+ try {
1737
+ await this.abortSessionOperations(active.runtime.session);
1738
+ this.publishActivity(active.runtime.session, "已停止", "idle");
1739
+ }
1740
+ catch (error) {
1741
+ const message = error instanceof Error ? error.message : String(error);
1742
+ this.publishActivity(active.runtime.session, "停止失败", "error", message);
1743
+ throw error;
1744
+ }
1745
+ finally {
1746
+ this.publishStatus(active.runtime.session);
1747
+ }
1229
1748
  }
1230
1749
  async clearQueue(ref, managementContext) {
1231
1750
  await this.assertWritable(ref);
@@ -1235,13 +1754,15 @@ export class PiSessionService {
1235
1754
  this.publishStatus(session);
1236
1755
  return this.statusFromSession(session);
1237
1756
  }
1238
- stop(ref, managementContext) {
1757
+ async stop(ref, managementContext) {
1239
1758
  const active = this.activeForLookup(ref, managementContext);
1240
- if (active === undefined)
1759
+ if (active !== undefined) {
1760
+ await this.closeActiveSession(active);
1241
1761
  return;
1242
- void this.closeActiveSession(active).catch(() => {
1243
- // Best-effort shutdown; callers that need errors await closeActive directly.
1244
- });
1762
+ }
1763
+ if (isPiSessionRef(ref)) {
1764
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1765
+ }
1245
1766
  }
1246
1767
  async bulkSessionLookupContext(refs) {
1247
1768
  const cwdSet = new Set();
@@ -1429,20 +1950,37 @@ export class PiSessionService {
1429
1950
  }
1430
1951
  return [...names];
1431
1952
  }
1432
- async closeActive(sessionId) {
1953
+ async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
1433
1954
  const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1434
1955
  if (pendingOpens.length > 0)
1435
1956
  await Promise.allSettled(pendingOpens);
1436
1957
  const activeSessions = this.activeSessionsForId(sessionId);
1437
- await Promise.all(activeSessions.map((active) => this.closeActiveSession(active)));
1958
+ if (activeSessions.length === 0 && notificationPolicy.kind === "clear") {
1959
+ this.publishNotificationMutations(this.notificationStore.clearSession(sessionId, notificationPolicy.reason));
1960
+ return;
1961
+ }
1962
+ await Promise.all(activeSessions.map((active) => this.closeActiveSession(active, notificationPolicy)));
1438
1963
  }
1439
- async closeActiveInScope(sessionId, eventScope) {
1964
+ async closeActiveInScope(sessionId, eventScope, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
1440
1965
  const active = this.activeForSessionIdAndScope(sessionId, eventScope);
1441
- if (active !== undefined)
1442
- await this.closeActiveSession(active);
1966
+ if (active !== undefined) {
1967
+ await this.closeActiveSession(active, notificationPolicy);
1968
+ }
1969
+ else if (notificationPolicy.kind === "clear") {
1970
+ this.publishNotificationMutations(this.notificationStore.clearSession(sessionId, notificationPolicy.reason));
1971
+ }
1443
1972
  }
1444
- async closeActiveSession(active) {
1973
+ async closeActiveSession(active, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
1445
1974
  const sessionId = active.runtime.session.sessionId;
1975
+ if (notificationPolicy.kind === "clear") {
1976
+ const generation = this.notificationGenerationBySession.get(active.runtime.session);
1977
+ const mutations = generation === undefined
1978
+ ? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
1979
+ : this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
1980
+ this.publishNotificationMutations(mutations);
1981
+ }
1982
+ this.forgetUnreadActivity(active.runtime.session);
1983
+ this.pendingAskStore.forgetSession(sessionId);
1446
1984
  this.active.delete(activeSessionKey(sessionId, active.eventScope));
1447
1985
  this.activities.delete(activeSessionKey(sessionId, active.eventScope));
1448
1986
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd(), active.eventScope);
@@ -1455,13 +1993,36 @@ export class PiSessionService {
1455
1993
  this.subsessionNotifyArmed.delete(sessionId);
1456
1994
  clearSessionQueue(active.runtime.session);
1457
1995
  active.unsubscribe();
1996
+ active.runtime.setRebindSession(undefined);
1458
1997
  try {
1459
- await active.runtime.session.abort();
1998
+ await this.abortSessionOperations(active.runtime.session);
1460
1999
  }
1461
2000
  finally {
1462
2001
  await active.runtime.dispose();
1463
2002
  }
1464
2003
  }
2004
+ async abortSessionOperations(session) {
2005
+ let branchSummaryAbortFailed = false;
2006
+ let branchSummaryAbortError;
2007
+ try {
2008
+ session.abortBranchSummary?.();
2009
+ }
2010
+ catch (error) {
2011
+ branchSummaryAbortFailed = true;
2012
+ branchSummaryAbortError = error;
2013
+ }
2014
+ try {
2015
+ await session.abort();
2016
+ }
2017
+ catch (abortError) {
2018
+ if (branchSummaryAbortFailed) {
2019
+ throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
2020
+ }
2021
+ throw abortError;
2022
+ }
2023
+ if (branchSummaryAbortFailed)
2024
+ throw branchSummaryAbortError;
2025
+ }
1465
2026
  async assertWritable(ref) {
1466
2027
  if (await this.getArchived(ref) !== undefined)
1467
2028
  throw new Error("已归档会话为只读。请恢复会话后继续。");
@@ -1476,7 +2037,7 @@ export class PiSessionService {
1476
2037
  const archived = await this.getArchived(ref);
1477
2038
  if (archived?.archivePath !== undefined) {
1478
2039
  const archivePath = archived.archivePath;
1479
- return this.openPending(archived.sessionId, archived.cwd, managementContext, () => this.sessionManager.open(archivePath));
2040
+ return this.openPending(archived.sessionId, archived.cwd, managementContext, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
1480
2041
  }
1481
2042
  const match = isPiSessionRef(ref)
1482
2043
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
@@ -1485,14 +2046,17 @@ export class PiSessionService {
1485
2046
  throw new Error("未找到会话");
1486
2047
  return this.openPending(match.id, match.cwd, managementContext, () => this.sessionManager.open(match.path));
1487
2048
  }
1488
- openPending(sessionId, cwd, managementContext, openSessionManager) {
2049
+ openPending(sessionId, cwd, managementContext, openSessionManager, options = {}) {
1489
2050
  const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId, managementContextKey(managementContext)]);
1490
2051
  const existing = this.pendingSessionOpens.get(key);
1491
2052
  if (existing !== undefined)
1492
2053
  return existing.promise;
1493
2054
  const pending = {
1494
2055
  sessionId,
1495
- promise: this.create(openSessionManager(), cwd, managementContext === undefined ? {} : { managementContext }),
2056
+ promise: this.create(openSessionManager(), cwd, {
2057
+ ...options,
2058
+ ...(managementContext === undefined ? {} : { managementContext }),
2059
+ }),
1496
2060
  };
1497
2061
  pending.promise = pending.promise.finally(() => {
1498
2062
  if (this.pendingSessionOpens.get(key) === pending)
@@ -1564,66 +2128,29 @@ export class PiSessionService {
1564
2128
  return [...new Set(this.active.values())].filter((active) => active.runtime.session.sessionId === sessionId);
1565
2129
  }
1566
2130
  async create(sessionManager, cwd, options = {}) {
1567
- const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
1568
- && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
1569
- const runtime = await this.createAgentRuntime(this.createRuntime, {
1570
- cwd,
1571
- agentDir: this.agentDir,
1572
- sessionManager,
1573
- delegationToolsEnabled,
1574
- ...(options.managementContext === undefined ? {} : { managementContext: options.managementContext }),
1575
- ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1576
- });
1577
- const active = { runtime, unsubscribe: noop, managementContextKey: managementContextKey(options.managementContext), eventScope: eventScopeFromManagementContext(options.managementContext) };
2131
+ const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken, eventScopeFromManagementContext(options.managementContext));
1578
2132
  try {
1579
- await this.bindSessionExtensions(runtime.session);
1580
- this.bindRuntime(active);
1581
- runtime.setRebindSession(async (session) => {
1582
- await this.bindSessionExtensions(session);
1583
- this.bindRuntime(active);
1584
- await this.recoverSubsessionTrackingForOpenedSession(session);
1585
- });
1586
- this.active.set(activeSessionKey(runtime.session.sessionId, active.eventScope), active);
1587
- await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1588
- this.publishStatus(runtime.session);
1589
- return active;
2133
+ return await this.createSessionRuntime(sessionManager, cwd, options, startup);
1590
2134
  }
1591
- catch (error) {
1592
- active.unsubscribe();
1593
- let removedActive = false;
1594
- for (const [sessionKey, candidate] of this.active.entries()) {
1595
- if (candidate !== active)
1596
- continue;
1597
- this.active.delete(sessionKey);
1598
- this.activities.delete(sessionKey);
1599
- removedActive = true;
1600
- }
1601
- this.clearAuthLossWarningsForSession(runtime.session.sessionId);
1602
- this.clearCompactionPromptQueue(runtime.session.sessionId);
1603
- if (removedActive) {
1604
- this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd(), active.eventScope);
1605
- }
1606
- try {
1607
- await runtime.session.abort();
1608
- }
1609
- finally {
1610
- await runtime.dispose();
1611
- }
1612
- throw error;
2135
+ finally {
2136
+ startup.end();
1613
2137
  }
1614
2138
  }
1615
- async bindSessionExtensions(session) {
2139
+ async bindSessionExtensions(session, generation) {
2140
+ const uiContext = this.sessionUiContext(session, generation);
1616
2141
  await session.bindExtensions({
2142
+ uiContext,
2143
+ mode: "rpc",
1617
2144
  onError: (error) => {
1618
2145
  const message = `${error.extensionPath}: ${error.error}`;
1619
- this.publishActivity(session, "extension error", "error", message);
2146
+ this.publishActivity(session, "扩展错误", "error", message);
1620
2147
  this.events.publish(session.sessionId, { type: "session.error", message }, this.eventScopeForSession(session));
1621
2148
  },
1622
2149
  });
1623
2150
  }
1624
- bindRuntime(active) {
2151
+ bindRuntime(active, session = active.runtime.session) {
2152
+ this.runtimeBySession.set(session, active.runtime);
1625
2153
  active.unsubscribe();
1626
- const { session } = active.runtime;
1627
2154
  for (const [sessionId, candidate] of this.active.entries()) {
1628
2155
  if (candidate === active) {
1629
2156
  this.active.delete(sessionId);
@@ -1723,6 +2250,10 @@ export class PiSessionService {
1723
2250
  applyGeneratedSessionName(session, name) {
1724
2251
  if (name === undefined || session.sessionName !== undefined)
1725
2252
  return;
2253
+ if (this.treeNavigations.has(session)) {
2254
+ this.deferredGeneratedSessionNames.set(session, name);
2255
+ return;
2256
+ }
1726
2257
  session.setSessionName(name);
1727
2258
  this.publishSessionName(session);
1728
2259
  }
@@ -1794,74 +2325,214 @@ export class PiSessionService {
1794
2325
  }
1795
2326
  }
1796
2327
  activityLabelFromStatus(session) {
2328
+ if (this.treeNavigations.has(session))
2329
+ return "正在切换会话树";
2330
+ if (this.isSessionEntryMutationActive(session))
2331
+ return "正在更新会话";
1797
2332
  if (session.isCompacting)
1798
- return "compacting";
2333
+ return "正在压缩上下文";
1799
2334
  if (session.isBashRunning)
1800
- return "running bash";
2335
+ return "正在运行命令";
1801
2336
  if (session.isStreaming)
1802
- return "agent running";
2337
+ return "代理正在运行";
1803
2338
  if (this.pendingMessageCount(session) > 0)
1804
- return "queued";
1805
- return "active";
2339
+ return "正在排队";
2340
+ return "正在处理";
1806
2341
  }
1807
2342
  hasActiveWork(session) {
1808
- return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2343
+ return this.treeNavigations.has(session)
2344
+ || this.isSessionEntryMutationActive(session)
2345
+ || this.isTreeExclusiveOperationActive(session)
2346
+ || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2347
+ }
2348
+ async runTreeExclusiveOperation(targets, activeError, operation) {
2349
+ const sessionIds = new Set();
2350
+ const runtimes = new Set();
2351
+ const sessions = new Set();
2352
+ for (const target of targets) {
2353
+ const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
2354
+ const session = target.session ?? runtime?.session;
2355
+ if (session !== undefined && this.hasActiveWork(session))
2356
+ throw new Error(activeError);
2357
+ sessionIds.add(target.sessionId);
2358
+ if (runtime !== undefined)
2359
+ runtimes.add(runtime);
2360
+ if (session !== undefined)
2361
+ sessions.add(session);
2362
+ }
2363
+ for (const sessionId of sessionIds) {
2364
+ this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
2365
+ }
2366
+ for (const runtime of runtimes) {
2367
+ this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
2368
+ }
2369
+ for (const session of sessions)
2370
+ this.observeUnreadActivityState(session);
2371
+ try {
2372
+ return await operation();
2373
+ }
2374
+ finally {
2375
+ for (const runtime of runtimes)
2376
+ decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
2377
+ for (const sessionId of sessionIds)
2378
+ decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
2379
+ for (const session of sessions) {
2380
+ if (this.isCurrentActiveSession(session))
2381
+ this.observeUnreadActivityState(session);
2382
+ }
2383
+ }
2384
+ }
2385
+ isTreeExclusiveSessionIdentityActive(sessionId) {
2386
+ return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
2387
+ }
2388
+ isTreeExclusiveOperationActive(session) {
2389
+ if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
2390
+ return true;
2391
+ const runtime = this.activeRuntimeForSession(session);
2392
+ return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
2393
+ }
2394
+ activeRuntimeForSession(session) {
2395
+ const tracked = this.runtimeBySession.get(session);
2396
+ if (tracked !== undefined)
2397
+ return tracked;
2398
+ for (const active of new Set(this.active.values())) {
2399
+ if (active.runtime.session === session)
2400
+ return active.runtime;
2401
+ }
2402
+ return undefined;
2403
+ }
2404
+ assertTreeNavigationInactive(session, action) {
2405
+ if (this.treeNavigations.has(session))
2406
+ throw new Error(`Cannot ${action} while session tree navigation is active`);
2407
+ }
2408
+ async runSessionEntryMutation(session, action, operation) {
2409
+ this.beginSessionEntryMutation(session, action);
2410
+ try {
2411
+ return await operation();
2412
+ }
2413
+ finally {
2414
+ this.endSessionEntryMutation(session);
2415
+ }
2416
+ }
2417
+ beginSessionEntryMutation(session, action) {
2418
+ this.assertTreeNavigationInactive(session, action);
2419
+ this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
2420
+ this.observeUnreadActivityState(session);
2421
+ }
2422
+ endSessionEntryMutation(session) {
2423
+ const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
2424
+ if (remaining <= 0)
2425
+ this.sessionEntryMutationCounts.delete(session);
2426
+ else
2427
+ this.sessionEntryMutationCounts.set(session, remaining);
2428
+ this.observeUnreadActivityState(session);
2429
+ }
2430
+ isSessionEntryMutationActive(session) {
2431
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
1809
2432
  }
1810
2433
  publishActivityForEvent(session, event) {
1811
2434
  const eventType = getString(event, "type");
1812
2435
  if (eventType === undefined)
1813
2436
  return;
1814
2437
  if (eventType === "agent_start") {
1815
- this.publishActivity(session, "agent running", "active");
2438
+ this.publishActivity(session, "代理正在运行", "active");
1816
2439
  return;
1817
2440
  }
1818
2441
  if (eventType === "agent_end") {
1819
- this.publishActivity(session, "idle", "idle");
2442
+ this.publishActivity(session, "空闲", "idle");
1820
2443
  const eventScope = this.eventScopeForSession(session);
1821
2444
  const sessionKey = activeSessionKey(session.sessionId, eventScope);
1822
2445
  this.activities.scheduleSettledRefresh(sessionKey, () => {
1823
2446
  if (this.active.get(sessionKey)?.runtime.session !== session)
1824
2447
  return;
1825
- this.publishActivity(session, "idle", "idle");
2448
+ this.publishActivity(session, "空闲", "idle");
1826
2449
  this.publishStatus(session);
1827
2450
  }, 250);
1828
2451
  return;
1829
2452
  }
1830
2453
  if (eventType === "turn_end") {
1831
- this.publishActivity(session, "turn complete", "idle");
2454
+ this.publishActivity(session, "本轮已完成", "idle");
1832
2455
  return;
1833
2456
  }
1834
2457
  if (eventType === "message_start") {
1835
- this.publishActivity(session, "message started", "active");
2458
+ this.publishActivity(session, "消息开始处理", "active");
1836
2459
  return;
1837
2460
  }
1838
2461
  if (eventType === "message_end") {
1839
- this.publishActivity(session, "message complete", "idle");
2462
+ this.publishActivity(session, "消息处理完成", "idle");
1840
2463
  return;
1841
2464
  }
1842
2465
  if (eventType === "message_update") {
1843
- this.publishActivity(session, "receiving response", "active");
2466
+ this.publishActivity(session, "正在接收回复", "active");
1844
2467
  return;
1845
2468
  }
1846
2469
  if (eventType === "tool_execution_start") {
1847
- this.publishActivity(session, "running tool", "active", getString(event, "toolName"));
2470
+ this.publishActivity(session, "正在运行工具", "active", getString(event, "toolName"));
1848
2471
  return;
1849
2472
  }
1850
2473
  if (eventType === "tool_execution_end") {
1851
2474
  const isError = getBoolean(event, "isError") === true;
1852
- this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "idle", getString(event, "toolName"));
2475
+ this.publishActivity(session, isError ? "工具执行失败" : "工具执行完成", isError ? "error" : "idle", getString(event, "toolName"));
1853
2476
  return;
1854
2477
  }
1855
2478
  if (eventType === "bash_execution_start") {
1856
- this.publishActivity(session, "running bash", "active");
2479
+ this.publishActivity(session, "正在运行命令", "active");
1857
2480
  return;
1858
2481
  }
1859
2482
  if (eventType === "bash_execution_end") {
1860
- this.publishActivity(session, "bash complete", "idle");
2483
+ this.publishActivity(session, "命令执行完成", "idle");
1861
2484
  return;
1862
2485
  }
1863
2486
  if (this.hasActiveWork(session))
1864
- this.publishActivity(session, eventType.replaceAll("_", " "), "active");
2487
+ this.publishActivity(session, "正在处理", "active");
2488
+ }
2489
+ /**
2490
+ * Build the reporter for one session construction.
2491
+ *
2492
+ * The session id is known before any await — a `SessionManager` has its id
2493
+ * from construction — so the daemon can name what it is starting even though
2494
+ * the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
2495
+ * Without an id there is nothing to report against, so the reporter stays
2496
+ * silent and the browser keeps its own generic wording.
2497
+ */
2498
+ startupProgress(sessionManager, intent, startupToken, eventScope) {
2499
+ const sessionId = sessionManager.getSessionId();
2500
+ if (sessionId === "")
2501
+ return { report: noop, end: noop };
2502
+ const label = intent === "create" ? "正在创建会话" : "正在打开会话";
2503
+ return {
2504
+ report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase), eventScope); },
2505
+ end: () => {
2506
+ // A real activity published during the window (an extension error, say)
2507
+ // is the truth about this session and must survive the clear.
2508
+ if (this.activities.get(activeSessionKey(sessionId, eventScope)) !== undefined)
2509
+ return;
2510
+ this.publishStartupProgress(sessionId, startupToken, "空闲", "idle", undefined, eventScope);
2511
+ },
2512
+ };
2513
+ }
2514
+ startupDetail(phase) {
2515
+ return this.catalogRefreshStatus?.isRefreshInFlight() === true
2516
+ ? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
2517
+ : phase;
2518
+ }
2519
+ /**
2520
+ * Report startup progress on the global channel only, echoing the caller's
2521
+ * correlation token so a waiting browser row recognises its own construction.
2522
+ *
2523
+ * Unlike {@link publishActivity} this deliberately records nothing: no
2524
+ * `activities` entry, no workspace activity, no unread observation. There is
2525
+ * no session to own that state, and a failed creation would leave it stranded.
2526
+ *
2527
+ * Every report is marked `startup`, which is what keeps a session that is
2528
+ * merely opening from counting as one doing work. This is the only publisher
2529
+ * that sets the marker, and because it writes no `activities` entry no later
2530
+ * heartbeat re-publication can carry it.
2531
+ */
2532
+ publishStartupProgress(sessionId, startupToken, label, phase, detail, eventScope) {
2533
+ const at = new Date().toISOString();
2534
+ const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
2535
+ this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity }, eventScope);
1865
2536
  }
1866
2537
  publishActivity(session, label, phase, detail) {
1867
2538
  const at = new Date().toISOString();
@@ -1872,6 +2543,7 @@ export class PiSessionService {
1872
2543
  this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity, eventScope);
1873
2544
  this.events.publish(session.sessionId, { type: "activity.update", activity }, eventScope);
1874
2545
  this.events.publishGlobal({ type: "activity.update", activity }, eventScope);
2546
+ this.observeUnreadActivityState(session);
1875
2547
  }
1876
2548
  publishStatus(session) {
1877
2549
  const status = this.statusFromSession(session);
@@ -1880,6 +2552,7 @@ export class PiSessionService {
1880
2552
  this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status, eventScope);
1881
2553
  this.events.publish(session.sessionId, { type: "status.update", status }, eventScope);
1882
2554
  this.events.publishGlobal({ type: "status.update", status }, eventScope);
2555
+ this.observeUnreadActivityState(session);
1883
2556
  }
1884
2557
  clearStaleActiveActivity(session) {
1885
2558
  const eventScope = this.eventScopeForSession(session);
@@ -1887,7 +2560,7 @@ export class PiSessionService {
1887
2560
  if (current?.phase !== "active" || this.hasActiveWork(session))
1888
2561
  return;
1889
2562
  const at = new Date().toISOString();
1890
- const stored = { phase: "idle", label: "idle", at };
2563
+ const stored = { phase: "idle", label: "空闲", at };
1891
2564
  this.activities.set(activeSessionKey(session.sessionId, eventScope), stored);
1892
2565
  const activity = { sessionId: session.sessionId, ...stored };
1893
2566
  this.events.publish(session.sessionId, { type: "activity.update", activity }, eventScope);
@@ -1907,6 +2580,8 @@ export class PiSessionService {
1907
2580
  const stats = session.getSessionStats();
1908
2581
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
1909
2582
  const contextUsage = session.getContextUsage();
2583
+ const warnings = this.warningsForSession(session);
2584
+ const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
1910
2585
  return {
1911
2586
  sessionId: session.sessionId,
1912
2587
  persisted: sessionFileExists(session.sessionFile),
@@ -1921,8 +2596,24 @@ export class PiSessionService {
1921
2596
  tokens: stats.tokens,
1922
2597
  cost: stats.cost,
1923
2598
  ...(contextUsage === undefined ? {} : { contextUsage }),
2599
+ ...(warnings.length === 0 ? {} : { warnings }),
2600
+ ...(pendingAsk === undefined ? {} : { pendingAsk }),
1924
2601
  };
1925
2602
  }
2603
+ /**
2604
+ * Compute the live warning set for a session: runtime/resource diagnostics from
2605
+ * the active runtime (if any) plus the Anthropic subscription-auth notice. Read
2606
+ * fresh on each status publish so a rebuilt runtime or an auth/model change is
2607
+ * reflected without caching a stale snapshot.
2608
+ */
2609
+ warningsForSession(session) {
2610
+ const runtime = [...this.active.values()].find((active) => active.runtime.session === session)?.runtime;
2611
+ const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
2612
+ const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
2613
+ if (anthropic !== undefined)
2614
+ warnings.push(anthropic);
2615
+ return warnings;
2616
+ }
1926
2617
  pendingMessageCount(session) {
1927
2618
  return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
1928
2619
  }
@@ -1932,6 +2623,394 @@ export class PiSessionService {
1932
2623
  hasQueuedMessageText(session, text) {
1933
2624
  return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
1934
2625
  }
2626
+ notificationCatalog() {
2627
+ return this.notificationStore.catalogSnapshot();
2628
+ }
2629
+ async unreadCatalog() {
2630
+ await this.publishUnreadMutations([]);
2631
+ return this.unreadStore.durableCatalogSnapshot();
2632
+ }
2633
+ async acknowledgeUnread(sessionId, request) {
2634
+ const result = this.unreadStore.acknowledge(sessionId, {
2635
+ ...request,
2636
+ cwd: canonicalizeStoredCwd(request.cwd),
2637
+ });
2638
+ await this.publishUnreadMutations(result.mutations);
2639
+ return this.unreadStore.durableCatalogSnapshot();
2640
+ }
2641
+ notificationInbox(ref) {
2642
+ return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
2643
+ }
2644
+ dismissNotification(ref, request) {
2645
+ const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
2646
+ this.publishNotificationMutations(result.mutations);
2647
+ return result.snapshot;
2648
+ }
2649
+ dismissAllNotifications(ref, request) {
2650
+ const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
2651
+ this.publishNotificationMutations(result.mutations);
2652
+ return result.snapshot;
2653
+ }
2654
+ async deliverSubsessionNotification(session, notification) {
2655
+ 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" }));
2656
+ this.publishStatus(session);
2657
+ }
2658
+ logSubsessionNotificationFailure(parentId, childId, error) {
2659
+ this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
2660
+ }
2661
+ async reloadSessionRuntime(session) {
2662
+ if (this.hasActiveWork(session))
2663
+ throw new Error("Stop current session activity before reloading");
2664
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
2665
+ this.publishActivity(session, "正在重新加载资源", "active");
2666
+ const priorGeneration = this.notificationGenerationBySession.get(session);
2667
+ let candidateGeneration;
2668
+ try {
2669
+ await session.reload(priorGeneration === undefined ? undefined : {
2670
+ beforeSessionStart: () => {
2671
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2672
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2673
+ this.replaceSessionNotificationContext(session, candidateGeneration);
2674
+ },
2675
+ });
2676
+ if (candidateGeneration !== undefined) {
2677
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
2678
+ }
2679
+ this.publishActivity(session, "资源已重新加载", "idle");
2680
+ this.publishStatus(session);
2681
+ }
2682
+ catch (error) {
2683
+ if (candidateGeneration !== undefined) {
2684
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2685
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2686
+ }
2687
+ const message = error instanceof Error ? error.message : String(error);
2688
+ this.publishActivity(session, "重新加载失败", "error", message);
2689
+ this.events.publish(session.sessionId, { type: "session.error", message });
2690
+ this.publishStatus(session);
2691
+ throw error;
2692
+ }
2693
+ });
2694
+ }
2695
+ async dismissWarning(ref, dismissId) {
2696
+ const session = await this.getOrOpen(ref);
2697
+ dismissSessionWarning(session, dismissId);
2698
+ this.publishStatus(session);
2699
+ return this.statusFromSession(session);
2700
+ }
2701
+ openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
2702
+ const active = this.activeForLookup({ id: sessionId, cwd }, options.managementContext);
2703
+ if (active !== undefined)
2704
+ return Promise.resolve(active);
2705
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
2706
+ const existing = this.pendingSessionOpens.get(key);
2707
+ if (existing !== undefined)
2708
+ return existing.promise;
2709
+ const pending = {
2710
+ sessionId,
2711
+ promise: this.create(openSessionManager(), cwd, options),
2712
+ };
2713
+ pending.promise = pending.promise.finally(() => {
2714
+ if (this.pendingSessionOpens.get(key) === pending)
2715
+ this.pendingSessionOpens.delete(key);
2716
+ });
2717
+ this.pendingSessionOpens.set(key, pending);
2718
+ return pending.promise;
2719
+ }
2720
+ isCurrentActiveSession(session) {
2721
+ return [...this.active.values()].some((active) => active.runtime.session === session);
2722
+ }
2723
+ async createSessionRuntime(sessionManager, cwd, options, startup) {
2724
+ startup.report(STARTUP_PHASE_RUNTIME);
2725
+ const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
2726
+ && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
2727
+ const runtime = await this.createAgentRuntime(this.createRuntime, {
2728
+ cwd,
2729
+ agentDir: this.agentDir,
2730
+ sessionManager,
2731
+ delegationToolsEnabled,
2732
+ ...(options.managementContext === undefined ? {} : { managementContext: options.managementContext }),
2733
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
2734
+ });
2735
+ const active = {
2736
+ runtime,
2737
+ unsubscribe: noop,
2738
+ managementContextKey: managementContextKey(options.managementContext),
2739
+ eventScope: eventScopeFromManagementContext(options.managementContext),
2740
+ };
2741
+ let boundSession = runtime.session;
2742
+ let notificationGeneration = options.notificationGeneration;
2743
+ let notificationOwnership = options.notifications === "disabled"
2744
+ ? "disabled"
2745
+ : notificationGeneration === undefined
2746
+ ? "registered"
2747
+ : "external";
2748
+ if (notificationOwnership === "registered") {
2749
+ const notificationIdentity = notificationIdentityForSession(runtime.session);
2750
+ const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
2751
+ if (existingCandidate !== undefined) {
2752
+ notificationGeneration = existingCandidate;
2753
+ notificationOwnership = "replacement";
2754
+ }
2755
+ else {
2756
+ const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
2757
+ notificationGeneration = registration.generation;
2758
+ this.publishNotificationMutations(registration.mutations);
2759
+ }
2760
+ }
2761
+ if (notificationGeneration !== undefined)
2762
+ this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
2763
+ try {
2764
+ if (options.creationProvenance === "tracked-subsession") {
2765
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
2766
+ }
2767
+ else {
2768
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
2769
+ }
2770
+ startup.report(STARTUP_PHASE_EXTENSIONS);
2771
+ await this.bindSessionExtensions(runtime.session, notificationGeneration);
2772
+ this.bindRuntime(active);
2773
+ runtime.setRebindSession(async (session) => {
2774
+ const priorGeneration = notificationGeneration;
2775
+ let candidateGeneration;
2776
+ try {
2777
+ await this.prepareUnreadRuntimeRebind(boundSession, session);
2778
+ await this.recoverSubsessionTrackingForOpenedSession(session);
2779
+ if (priorGeneration !== undefined) {
2780
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2781
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2782
+ }
2783
+ this.bindRuntime(active, session);
2784
+ boundSession = session;
2785
+ await this.bindSessionExtensions(session, candidateGeneration);
2786
+ if (candidateGeneration !== undefined) {
2787
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
2788
+ notificationGeneration = candidateGeneration;
2789
+ }
2790
+ }
2791
+ catch (error) {
2792
+ if (candidateGeneration !== undefined) {
2793
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2794
+ notificationGeneration = candidateGeneration;
2795
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2796
+ }
2797
+ throw error;
2798
+ }
2799
+ });
2800
+ this.active.set(activeSessionKey(runtime.session.sessionId, active.eventScope), active);
2801
+ if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2802
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
2803
+ notificationOwnership = "external";
2804
+ }
2805
+ this.publishStatus(runtime.session);
2806
+ return active;
2807
+ }
2808
+ catch (error) {
2809
+ if (notificationGeneration !== undefined) {
2810
+ if (notificationOwnership === "registered") {
2811
+ this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
2812
+ }
2813
+ else if (notificationOwnership === "replacement") {
2814
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
2815
+ }
2816
+ }
2817
+ active.unsubscribe();
2818
+ this.forgetUnreadActivity(boundSession);
2819
+ let removedActive = false;
2820
+ for (const [sessionKey, candidate] of this.active.entries()) {
2821
+ if (candidate !== active)
2822
+ continue;
2823
+ this.active.delete(sessionKey);
2824
+ this.activities.delete(sessionKey);
2825
+ this.clearAuthLossWarningsForSession(runtime.session.sessionId);
2826
+ this.clearCompactionPromptQueue(runtime.session.sessionId);
2827
+ removedActive = true;
2828
+ }
2829
+ if (removedActive) {
2830
+ this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd(), active.eventScope);
2831
+ }
2832
+ try {
2833
+ await runtime.session.abort();
2834
+ }
2835
+ finally {
2836
+ await runtime.dispose();
2837
+ }
2838
+ throw error;
2839
+ }
2840
+ }
2841
+ replaceSessionNotificationContext(session, generation) {
2842
+ session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
2843
+ }
2844
+ sessionUiContext(session, generation) {
2845
+ const baseUiContext = session.extensionRunner.getUIContext();
2846
+ const notify = (message, type) => {
2847
+ if (generation === undefined) {
2848
+ this.events.publish(session.sessionId, {
2849
+ type: "command.output",
2850
+ level: type === "error" ? "error" : "info",
2851
+ message,
2852
+ });
2853
+ return;
2854
+ }
2855
+ const added = this.notificationStore.addNotification(generation, message, type);
2856
+ this.publishNotificationMutations(added.mutations);
2857
+ if (added.notification === undefined)
2858
+ return;
2859
+ this.events.publish(session.sessionId, {
2860
+ type: "command.output",
2861
+ level: type === "error" ? "error" : "info",
2862
+ message,
2863
+ notificationId: added.notification.id,
2864
+ });
2865
+ };
2866
+ // PI WEB owns the browser-facing notification and text-formatting
2867
+ // boundaries. Delegate every other UI method to Pi's headless defaults so
2868
+ // unsupported dialogs cancel safely instead of hanging.
2869
+ return new Proxy(baseUiContext, {
2870
+ get(target, property, receiver) {
2871
+ if (property === "notify")
2872
+ return notify;
2873
+ if (property === "theme")
2874
+ return plainTextTheme;
2875
+ const value = Reflect.get(target, property, receiver);
2876
+ return value;
2877
+ },
2878
+ });
2879
+ }
2880
+ publishNotificationMutations(mutations) {
2881
+ for (const mutation of mutations) {
2882
+ const scopes = new Set(this.activeSessionsForId(mutation.sessionId).map((active) => active.eventScope));
2883
+ if (scopes.size === 0)
2884
+ scopes.add(NORMAL_SESSION_EVENT_SCOPE);
2885
+ for (const scope of scopes) {
2886
+ this.events.publish(mutation.sessionId, mutation.inboxEvent, scope);
2887
+ this.events.publishGlobal(mutation.summaryEvent, scope);
2888
+ }
2889
+ }
2890
+ }
2891
+ async prepareUnreadRuntimeRebind(previous, next) {
2892
+ const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
2893
+ this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
2894
+ const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
2895
+ if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
2896
+ return;
2897
+ await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
2898
+ }
2899
+ forgetUnreadActivity(session) {
2900
+ this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
2901
+ }
2902
+ async forgetUnreadSessions(identities) {
2903
+ const mutations = [];
2904
+ for (const identity of identities) {
2905
+ mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
2906
+ }
2907
+ await this.publishUnreadMutations(mutations);
2908
+ }
2909
+ observeUnreadActivityState(session) {
2910
+ const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
2911
+ if (mutations.length === 0)
2912
+ return;
2913
+ void this.publishUnreadMutations(mutations).catch(() => undefined);
2914
+ }
2915
+ publishUnreadMutations(mutations) {
2916
+ this.enqueueUnreadMutations(mutations);
2917
+ this.unreadPublicationFlushRequested = true;
2918
+ if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
2919
+ const failure = this.unreadPublicationFailure;
2920
+ return Promise.reject(failure instanceof Error
2921
+ ? failure
2922
+ : new Error("Session unread publication is awaiting retry", { cause: failure }));
2923
+ }
2924
+ return this.ensureUnreadPublication();
2925
+ }
2926
+ ensureUnreadPublication() {
2927
+ const existing = this.unreadPublication;
2928
+ if (existing !== undefined)
2929
+ return existing;
2930
+ const publication = this.drainUnreadPublication();
2931
+ this.unreadPublication = publication;
2932
+ void publication.then(() => {
2933
+ if (this.unreadPublication === publication)
2934
+ this.unreadPublication = undefined;
2935
+ }, (error) => {
2936
+ if (this.unreadPublication === publication)
2937
+ this.unreadPublication = undefined;
2938
+ this.unreadPublicationFailure = error;
2939
+ this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
2940
+ this.scheduleUnreadPublicationRetry();
2941
+ });
2942
+ return publication;
2943
+ }
2944
+ async drainUnreadPublication() {
2945
+ while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
2946
+ this.unreadPublicationFlushRequested = false;
2947
+ const batch = this.pendingUnreadMutations.splice(0);
2948
+ let publishedCount = 0;
2949
+ try {
2950
+ await this.unreadStore.flush();
2951
+ for (const mutation of batch) {
2952
+ this.events.publishGlobal(mutation.event);
2953
+ publishedCount += 1;
2954
+ }
2955
+ }
2956
+ catch (error) {
2957
+ this.prependUnreadMutations(batch.slice(publishedCount));
2958
+ this.unreadPublicationFlushRequested = true;
2959
+ throw error;
2960
+ }
2961
+ this.unreadPublicationFailure = undefined;
2962
+ this.clearUnreadPublicationRetry();
2963
+ }
2964
+ }
2965
+ enqueueUnreadMutations(mutations) {
2966
+ this.pendingUnreadMutations.push(...mutations);
2967
+ this.trimPendingUnreadMutations();
2968
+ }
2969
+ prependUnreadMutations(mutations) {
2970
+ this.pendingUnreadMutations.unshift(...mutations);
2971
+ this.trimPendingUnreadMutations();
2972
+ }
2973
+ trimPendingUnreadMutations() {
2974
+ const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
2975
+ if (excess > 0)
2976
+ this.pendingUnreadMutations.splice(0, excess);
2977
+ }
2978
+ scheduleUnreadPublicationRetry() {
2979
+ if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
2980
+ return;
2981
+ const delay = this.unreadPublicationRetryDelayMs;
2982
+ this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
2983
+ this.unreadPublicationRetryTimer = setTimeout(() => {
2984
+ this.unreadPublicationRetryTimer = undefined;
2985
+ void this.ensureUnreadPublication().catch(() => undefined);
2986
+ }, delay);
2987
+ this.unreadPublicationRetryTimer.unref();
2988
+ }
2989
+ clearUnreadPublicationRetry() {
2990
+ if (this.unreadPublicationRetryTimer !== undefined)
2991
+ clearTimeout(this.unreadPublicationRetryTimer);
2992
+ this.unreadPublicationRetryTimer = undefined;
2993
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2994
+ }
2995
+ flushDeferredTreeNavigationWork(session) {
2996
+ const generatedName = this.deferredGeneratedSessionNames.get(session);
2997
+ this.deferredGeneratedSessionNames.delete(session);
2998
+ if (generatedName !== undefined) {
2999
+ try {
3000
+ this.applyGeneratedSessionName(session, generatedName);
3001
+ }
3002
+ catch (error) {
3003
+ this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
3004
+ }
3005
+ }
3006
+ const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
3007
+ this.deferredSubsessionNotifications.delete(session);
3008
+ for (const notification of notifications) {
3009
+ void this.deliverSubsessionNotification(session, notification).catch((error) => {
3010
+ this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
3011
+ });
3012
+ }
3013
+ }
1935
3014
  }
1936
3015
  function previewResponseFromPlan(plan) {
1937
3016
  return {
@@ -1987,6 +3066,12 @@ function modelToClientModel(model) {
1987
3066
  ...(reasoning === undefined ? {} : { reasoning }),
1988
3067
  };
1989
3068
  }
3069
+ function notificationIdentityForSession(session) {
3070
+ return {
3071
+ sessionId: session.sessionId,
3072
+ cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3073
+ };
3074
+ }
1990
3075
  function clientSessionFromListEntry(session) {
1991
3076
  return {
1992
3077
  id: session.id,
@@ -2183,28 +3268,6 @@ function activeSessionFileMatches(active, expectedSessionFile) {
2183
3268
  function trackedLinkParentFileMatches(link, parentSessionFile) {
2184
3269
  return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
2185
3270
  }
2186
- async function readSessionHeaderSummary(sessionFile) {
2187
- let file;
2188
- try {
2189
- file = await open(sessionFile, "r");
2190
- const buffer = Buffer.alloc(4096);
2191
- const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
2192
- const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
2193
- if (firstLine === undefined || firstLine === "")
2194
- return undefined;
2195
- const header = JSON.parse(firstLine);
2196
- if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
2197
- return undefined;
2198
- const parentSession = getString(header, "parentSession");
2199
- return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
2200
- }
2201
- catch {
2202
- return undefined;
2203
- }
2204
- finally {
2205
- await file?.close().catch(() => undefined);
2206
- }
2207
- }
2208
3271
  async function sessionFileHeaderMatches(sessionFile, expected) {
2209
3272
  const header = await readSessionHeaderSummary(sessionFile);
2210
3273
  if (header?.id !== expected.sessionId)
@@ -2294,11 +3357,13 @@ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
2294
3357
  const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
2295
3358
  /** customType marking a parent-facing subsession-completion notice. */
2296
3359
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
2297
- const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
2298
- function truncateForNotification(text) {
2299
- if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS)
2300
- return text;
2301
- return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`;
3360
+ const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
3361
+ /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
3362
+ function formatSubsessionNotificationOutput(childSessionId, text) {
3363
+ if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
3364
+ 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.`;
3365
+ }
3366
+ return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
2302
3367
  }
2303
3368
  /** Most recent assistant text from a history message list, or "" if none. */
2304
3369
  function finalAssistantText(messages) {