@jmfederico/pi-web 1.202607.1 → 1.202607.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +17 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-uASb28Y1.js → CodeViewer-B4M13447.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-DfTmykFw.js → TerminalPanel-Bmvj-Ecn.js} +1 -1
  6. package/dist/client/assets/{UnifiedDiffViewer-laQN1x7P.js → UnifiedDiffViewer-C0hygwRo.js} +1 -1
  7. package/dist/client/assets/index-TvPsRyS1.js +3545 -0
  8. package/dist/client/index.html +1 -1
  9. package/dist/config.js +13 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  12. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  13. package/dist/server/git/gitService.js +176 -5
  14. package/dist/server/git/gitService.js.map +1 -1
  15. package/dist/server/piWebStatus.js +6 -3
  16. package/dist/server/piWebStatus.js.map +1 -1
  17. package/dist/server/realtime/sessionEventHub.js +4 -0
  18. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  19. package/dist/server/sessiond.js +47 -7
  20. package/dist/server/sessiond.js.map +1 -1
  21. package/dist/server/sessions/authService.js +58 -7
  22. package/dist/server/sessions/authService.js.map +1 -1
  23. package/dist/server/sessions/globalProviderPolicy.js +98 -0
  24. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  25. package/dist/server/sessions/modelCatalogRefresher.js +184 -0
  26. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  27. package/dist/server/sessions/piSessionService.js +736 -100
  28. package/dist/server/sessions/piSessionService.js.map +1 -1
  29. package/dist/server/sessions/plainTextTheme.js +112 -0
  30. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  31. package/dist/server/sessions/sessionCommandService.js +68 -9
  32. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  33. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  34. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  35. package/dist/server/sessions/sessionRoutes.js +175 -0
  36. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  37. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  38. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  39. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  40. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  41. package/dist/server/sessions/spawnSessionTool.js +2 -2
  42. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  43. package/dist/server/sessions/spawnSubsessionTool.js +10 -12
  44. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  45. package/dist/shared/apiTypes.d.ts +159 -2
  46. package/dist/shared/apiTypes.js +10 -0
  47. package/dist/shared/apiTypes.js.map +1 -1
  48. package/dist/shared/capabilities.js +6 -0
  49. package/dist/shared/capabilities.js.map +1 -1
  50. package/dist/shared/federatedRoutes.js +8 -0
  51. package/dist/shared/federatedRoutes.js.map +1 -1
  52. package/docs/config.md +32 -4
  53. package/docs/plugins.md +10 -6
  54. package/install.sh +1 -1
  55. package/package.json +8 -7
  56. package/dist/client/assets/index-Dhapi0oR.js +0 -3093
@@ -6,18 +6,26 @@ import { projectBrowserMessage } from "../browserMessageProjection.js";
6
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
7
7
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
8
8
  import { SessionCommandService } from "./sessionCommandService.js";
9
+ import { projectSessionTree } from "./sessionTreeProjection.js";
9
10
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
10
11
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
11
12
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
12
13
  import { computeEditPreview } from "./editPreview.js";
13
14
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
14
15
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
+ import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
15
17
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
16
18
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
17
19
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
18
20
  import { buildTranscriptView } from "./subsessionTranscript.js";
19
21
  import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
22
+ import { SessionNotificationStore, } from "./sessionNotificationStore.js";
23
+ import { plainTextTheme } from "./plainTextTheme.js";
24
+ import { SessionUnreadStore } from "./sessionUnreadStore.js";
20
25
  const noopLogger = { info() { } };
26
+ const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
27
+ const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
28
+ const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
21
29
  function noop() {
22
30
  // Intentionally empty default unsubscribe callback.
23
31
  }
@@ -38,6 +46,37 @@ function isPiSessionRef(ref) {
38
46
  function lookupMatchesActiveSession(ref, active) {
39
47
  return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
40
48
  }
49
+ function sessionTreeNavigationOptions(request) {
50
+ switch (request.summary.mode) {
51
+ case "none":
52
+ return { summarize: false };
53
+ case "default":
54
+ return { summarize: true };
55
+ case "custom": {
56
+ const customInstructions = request.summary.instructions.trim();
57
+ if (customInstructions === "")
58
+ throw new Error("Custom branch-summary instructions are required");
59
+ if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
60
+ throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
61
+ }
62
+ return { summarize: true, customInstructions };
63
+ }
64
+ }
65
+ }
66
+ function decrementWeakCount(counts, key) {
67
+ const remaining = (counts.get(key) ?? 1) - 1;
68
+ if (remaining <= 0)
69
+ counts.delete(key);
70
+ else
71
+ counts.set(key, remaining);
72
+ }
73
+ function decrementMapCount(counts, key) {
74
+ const remaining = (counts.get(key) ?? 1) - 1;
75
+ if (remaining <= 0)
76
+ counts.delete(key);
77
+ else
78
+ counts.set(key, remaining);
79
+ }
41
80
  function requirePromptText(value) {
42
81
  if (typeof value !== "string")
43
82
  throw new Error("Prompt text is required");
@@ -50,6 +89,8 @@ function parsePromptStreamingBehavior(value) {
50
89
  return value;
51
90
  throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
52
91
  }
92
+ const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
93
+ const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
53
94
  function resourceDiagnosticToWarning(diagnostic, source) {
54
95
  return {
55
96
  severity: diagnostic.type === "error" ? "error" : "warning",
@@ -228,6 +269,15 @@ export class PiSessionService {
228
269
  this.active = new Map();
229
270
  this.pendingSessionOpens = new Map();
230
271
  this.activities = new Map();
272
+ /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
273
+ this.treeNavigations = new WeakSet();
274
+ /** Counts async operations that may append an entry before they settle. */
275
+ this.sessionEntryMutationCounts = new WeakMap();
276
+ /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
277
+ this.treeExclusiveRuntimeOperationCounts = new WeakMap();
278
+ this.treeExclusiveSessionOperationCounts = new Map();
279
+ this.deferredSubsessionNotifications = new WeakMap();
280
+ this.deferredGeneratedSessionNames = new WeakMap();
231
281
  this.compactionPromptQueues = new Map();
232
282
  this.compactionDrainTimers = new Map();
233
283
  this.authLossWarnings = new Set();
@@ -245,6 +295,10 @@ export class PiSessionService {
245
295
  * child that works again (and stops again) notifies the parent each time.
246
296
  */
247
297
  this.subsessionNotifyArmed = new Map();
298
+ this.notificationGenerationBySession = new WeakMap();
299
+ this.pendingUnreadMutations = [];
300
+ this.unreadPublicationFlushRequested = false;
301
+ this.unreadPublicationStopped = false;
248
302
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
249
303
  this.agentDir = deps.agentDir;
250
304
  this.sessionManager = deps.sessionManager;
@@ -252,6 +306,10 @@ export class PiSessionService {
252
306
  this.spawnTargets = deps.spawnTargets;
253
307
  this.logger = deps.logger ?? noopLogger;
254
308
  this.now = deps.now ?? (() => new Date());
309
+ this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
310
+ this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
311
+ this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
312
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
255
313
  // Subsessions are a beta capability gated behind their own flag, and they
256
314
  // also require the spawn capability (they share its project-scope resolver).
257
315
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -266,19 +324,57 @@ export class PiSessionService {
266
324
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
267
325
  this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
268
326
  onCompactionStart: (session) => {
327
+ this.beginSessionEntryMutation(session, "compact the session");
269
328
  this.publishActivity(session, "compacting", "active");
270
329
  this.publishStatus(session);
271
330
  },
272
331
  onCompactionEnd: (session, result, detail) => {
332
+ this.endSessionEntryMutation(session);
273
333
  this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
274
334
  this.publishStatus(session);
275
335
  },
276
336
  reloadSession: (session) => this.reloadSessionRuntime(session),
337
+ getSessionTree: (session) => {
338
+ if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
339
+ return undefined;
340
+ return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
341
+ },
342
+ hasActiveWork: (session) => this.hasActiveWork(session),
343
+ isTreeNavigationActive: (session) => this.treeNavigations.has(session),
344
+ runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before replacing the session", operation),
277
345
  }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
278
346
  }
279
347
  activeCount() {
280
348
  return this.active.size;
281
349
  }
350
+ notificationCatalog() {
351
+ return this.notificationStore.catalogSnapshot();
352
+ }
353
+ async unreadCatalog() {
354
+ await this.publishUnreadMutations([]);
355
+ return this.unreadStore.durableCatalogSnapshot();
356
+ }
357
+ async acknowledgeUnread(sessionId, request) {
358
+ const result = this.unreadStore.acknowledge(sessionId, {
359
+ ...request,
360
+ cwd: canonicalizeStoredCwd(request.cwd),
361
+ });
362
+ await this.publishUnreadMutations(result.mutations);
363
+ return this.unreadStore.durableCatalogSnapshot();
364
+ }
365
+ notificationInbox(ref) {
366
+ return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
367
+ }
368
+ dismissNotification(ref, request) {
369
+ const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
370
+ this.publishNotificationMutations(result.mutations);
371
+ return result.snapshot;
372
+ }
373
+ dismissAllNotifications(ref, request) {
374
+ const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
375
+ this.publishNotificationMutations(result.mutations);
376
+ return result.snapshot;
377
+ }
282
378
  async cleanupPreview(request) {
283
379
  return previewResponseFromPlan(await this.cleanupPlan(request));
284
380
  }
@@ -296,22 +392,24 @@ export class PiSessionService {
296
392
  skippedBusySessionIds.add(input.sessionId);
297
393
  continue;
298
394
  }
299
- await this.closeActive(input.sessionId);
395
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
300
396
  readyArchiveInputs.push(input);
301
397
  }
302
398
  await this.archiveStoreArchiveMany(readyArchiveInputs);
303
399
  archiveInputs.push(...readyArchiveInputs);
400
+ await this.forgetUnreadSessions(readyArchiveInputs);
304
401
  for (const record of plan.deleteRecords) {
305
402
  if (this.activeSessionHasWork(record.sessionId)) {
306
403
  skippedBusySessionIds.add(record.sessionId);
307
404
  continue;
308
405
  }
309
- await this.closeActive(record.sessionId);
406
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
310
407
  readyDeleteRecords.push(record);
311
408
  }
312
409
  await this.ensureArchivedRecordsMoved(readyDeleteRecords);
313
410
  const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
314
411
  deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
412
+ await this.forgetUnreadSessions(deleteRecords);
315
413
  return summarizeSessionCleanupExecution({
316
414
  archiveInputs,
317
415
  deleteRecords,
@@ -321,12 +419,16 @@ export class PiSessionService {
321
419
  });
322
420
  }
323
421
  async dispose() {
422
+ this.unreadPublicationStopped = true;
423
+ this.clearUnreadPublicationRetry();
324
424
  clearInterval(this.heartbeat);
325
425
  this.clearCompactionDrainTimers();
326
426
  const pendingOpens = this.pendingSessionOpenPromises();
327
427
  if (pendingOpens.length > 0)
328
428
  await Promise.allSettled(pendingOpens);
329
429
  const activeSessions = Array.from(new Set(this.active.values()));
430
+ for (const active of activeSessions)
431
+ this.forgetUnreadActivity(active.runtime.session);
330
432
  this.active.clear();
331
433
  this.pendingSessionOpens.clear();
332
434
  this.activities.clear();
@@ -337,16 +439,19 @@ export class PiSessionService {
337
439
  this.subsessionLinks.clear();
338
440
  this.subsessionHydratedParents.clear();
339
441
  this.subsessionNotifyArmed.clear();
442
+ this.notificationStore.clearAll("service-dispose");
340
443
  await Promise.all(activeSessions.map(async (active) => {
341
444
  active.unsubscribe();
445
+ active.runtime.setRebindSession(undefined);
342
446
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
343
447
  try {
344
- await active.runtime.session.abort();
448
+ await this.abortSessionOperations(active.runtime.session);
345
449
  }
346
450
  finally {
347
451
  await active.runtime.dispose();
348
452
  }
349
453
  }));
454
+ await this.publishUnreadMutations([]);
350
455
  }
351
456
  async list(cwd) {
352
457
  const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
@@ -355,8 +460,13 @@ export class PiSessionService {
355
460
  .filter((record) => record.cwd === cwd)
356
461
  .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
357
462
  const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
463
+ for (const record of archivedForCwd) {
464
+ this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
465
+ }
358
466
  const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
359
- this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById));
467
+ const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
468
+ this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
469
+ await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
360
470
  const archivedSessions = archivedForCwd
361
471
  .sort(compareArchivedRecords)
362
472
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
@@ -431,7 +541,7 @@ export class PiSessionService {
431
541
  ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
432
542
  cwd: decision.cwd,
433
543
  };
434
- this.registerVerifiedSubsession(link);
544
+ await this.registerVerifiedSubsession(link);
435
545
  this.persistSubsessionLink(link);
436
546
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
437
547
  await this.prompt(created.id, input.prompt);
@@ -506,7 +616,7 @@ export class PiSessionService {
506
616
  return undefined;
507
617
  return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
508
618
  }
509
- registerVerifiedSubsession(link) {
619
+ async registerVerifiedSubsession(link) {
510
620
  const { childSessionId, parentSessionId } = link;
511
621
  const previousParentId = this.subsessionParents.get(childSessionId);
512
622
  if (previousParentId !== undefined && previousParentId !== parentSessionId) {
@@ -522,6 +632,24 @@ export class PiSessionService {
522
632
  this.subsessionLinks.set(childSessionId, link);
523
633
  if (!this.subsessionNotifyArmed.has(childSessionId))
524
634
  this.subsessionNotifyArmed.set(childSessionId, false);
635
+ const cwd = this.cwdForVerifiedSubsession(link);
636
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
637
+ }
638
+ cwdForVerifiedSubsession(link) {
639
+ const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
640
+ const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
641
+ if (linkedCwd !== undefined)
642
+ return canonicalizeStoredCwd(linkedCwd);
643
+ const childSessionFile = link.childSessionFile;
644
+ if (childSessionFile !== undefined) {
645
+ try {
646
+ return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
647
+ }
648
+ catch (error) {
649
+ throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
650
+ }
651
+ }
652
+ throw new Error("Could not resolve cwd for verified tracked sub-session");
525
653
  }
526
654
  unregisterSubsession(childSessionId) {
527
655
  const parentSessionId = this.subsessionParents.get(childSessionId);
@@ -568,40 +696,44 @@ export class PiSessionService {
568
696
  const activeParent = this.active.get(parentSessionId);
569
697
  if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
570
698
  const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
571
- await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
572
- this.subsessionHydratedParents.add(hydrationKey);
699
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
700
+ if (complete)
701
+ this.subsessionHydratedParents.add(hydrationKey);
573
702
  return;
574
703
  }
575
704
  if (parentSessionFile === undefined)
576
705
  return;
577
- if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
578
- this.subsessionHydratedParents.add(hydrationKey);
706
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
579
707
  return;
580
- }
581
708
  let parentManager;
582
709
  try {
583
710
  parentManager = this.sessionManager.open(parentSessionFile);
584
711
  }
585
712
  catch {
586
- this.subsessionHydratedParents.add(hydrationKey);
587
713
  return;
588
714
  }
589
- await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
590
- this.subsessionHydratedParents.add(hydrationKey);
715
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
716
+ if (complete)
717
+ this.subsessionHydratedParents.add(hydrationKey);
591
718
  }
592
719
  async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
593
720
  // Parent custom links are the authoritative recovery record: verify the
594
- // exact live child file/header before tracking.
721
+ // exact live child file/header before tracking. Do not negatively cache a
722
+ // scan while a candidate child is temporarily unavailable.
595
723
  const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
724
+ let complete = true;
596
725
  for (const entry of entries) {
597
726
  const link = parsePersistedParentSubsessionLink(entry);
598
- if (link === undefined)
727
+ if (link?.spawnedBySessionId !== parentSessionId)
599
728
  continue;
600
729
  const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
601
- if (verified === undefined)
730
+ if (verified === undefined) {
731
+ complete = false;
602
732
  continue;
603
- this.registerVerifiedSubsession(verified);
733
+ }
734
+ await this.registerVerifiedSubsession(verified);
604
735
  }
736
+ return complete;
605
737
  }
606
738
  async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
607
739
  if (parentSessionFile === undefined)
@@ -620,7 +752,7 @@ export class PiSessionService {
620
752
  const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
621
753
  if (link === undefined)
622
754
  return;
623
- this.registerVerifiedSubsession(link);
755
+ await this.registerVerifiedSubsession(link);
624
756
  }
625
757
  verifiedSubsessionLinkFromOpenedChild(session) {
626
758
  return verifiedTrackedSubsessionLink(this.sessionManager, {
@@ -729,13 +861,25 @@ export class PiSessionService {
729
861
  async notifyParentOfSubsession(parentId, childId, text) {
730
862
  try {
731
863
  const session = await this.getOrOpenParentForSubsession(parentId, childId);
732
- await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
733
- this.publishStatus(session);
864
+ if (this.treeNavigations.has(session)) {
865
+ const pending = this.deferredSubsessionNotifications.get(session) ?? [];
866
+ pending.push({ parentId, childId, text });
867
+ this.deferredSubsessionNotifications.set(session, pending);
868
+ return;
869
+ }
870
+ await this.deliverSubsessionNotification(session, { parentId, childId, text });
734
871
  }
735
872
  catch (error) {
736
- this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
873
+ this.logSubsessionNotificationFailure(parentId, childId, error);
737
874
  }
738
875
  }
876
+ async deliverSubsessionNotification(session, notification) {
877
+ await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } }, { triggerTurn: true, deliverAs: "followUp" }));
878
+ this.publishStatus(session);
879
+ }
880
+ logSubsessionNotificationFailure(parentId, childId, error) {
881
+ this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
882
+ }
739
883
  async messages(ref, page) {
740
884
  const session = await this.getOrOpen(ref);
741
885
  return pageMessagesAtSafeBoundary(historyMessages(session), page);
@@ -764,7 +908,7 @@ export class PiSessionService {
764
908
  }
765
909
  async availableModels(ref) {
766
910
  const session = await this.getOrOpen(ref);
767
- await session.modelRuntime.reloadConfig();
911
+ await session.modelRuntime.refresh();
768
912
  const models = session.scopedModels.length > 0
769
913
  ? session.scopedModels.map((scoped) => scoped.model)
770
914
  : session.modelRuntime.getAvailableSnapshot();
@@ -773,7 +917,9 @@ export class PiSessionService {
773
917
  async setModel(ref, provider, modelId) {
774
918
  await this.assertWritable(ref);
775
919
  const session = await this.getOrOpen(ref);
776
- await session.modelRuntime.reloadConfig();
920
+ this.assertTreeNavigationInactive(session, "change models");
921
+ await session.modelRuntime.refresh();
922
+ this.assertTreeNavigationInactive(session, "change models");
777
923
  const candidates = session.scopedModels.length > 0
778
924
  ? session.scopedModels.map((scoped) => scoped.model)
779
925
  : session.modelRuntime.getAvailableSnapshot();
@@ -781,7 +927,7 @@ export class PiSessionService {
781
927
  ?? session.modelRuntime.getModel(provider, modelId);
782
928
  if (model === undefined)
783
929
  throw new Error(`Model not found: ${provider}/${modelId}`);
784
- await session.setModel(model);
930
+ await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
785
931
  this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
786
932
  this.publishStatus(session);
787
933
  return this.statusFromSession(session);
@@ -789,7 +935,7 @@ export class PiSessionService {
789
935
  async cycleModel(ref, direction) {
790
936
  await this.assertWritable(ref);
791
937
  const session = await this.getOrOpen(ref);
792
- const result = await session.cycleModel(direction);
938
+ const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
793
939
  if (result === undefined)
794
940
  throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
795
941
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
@@ -803,6 +949,7 @@ export class PiSessionService {
803
949
  async setThinkingLevel(ref, level) {
804
950
  await this.assertWritable(ref);
805
951
  const session = await this.getOrOpen(ref);
952
+ this.assertTreeNavigationInactive(session, "change the thinking level");
806
953
  // pi owns the valid set; validate against the session's live levels rather
807
954
  // than a hardcoded union so this stays correct if pi changes the set.
808
955
  const available = session.getAvailableThinkingLevels();
@@ -817,6 +964,7 @@ export class PiSessionService {
817
964
  async cycleThinkingLevel(ref) {
818
965
  await this.assertWritable(ref);
819
966
  const session = await this.getOrOpen(ref);
967
+ this.assertTreeNavigationInactive(session, "change the thinking level");
820
968
  const level = session.cycleThinkingLevel();
821
969
  if (level === undefined)
822
970
  throw new Error("Current model does not support thinking");
@@ -850,6 +998,7 @@ export class PiSessionService {
850
998
  const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
851
999
  await this.assertWritable(ref);
852
1000
  const session = await this.getOrOpen(ref);
1001
+ this.assertTreeNavigationInactive(session, "send a prompt");
853
1002
  this.maybeGenerateSessionName(session, promptText);
854
1003
  const isQueued = session.isStreaming || session.isCompacting;
855
1004
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
@@ -869,7 +1018,7 @@ export class PiSessionService {
869
1018
  if (behavior === undefined && echoUserMessage)
870
1019
  this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
871
1020
  const promptOptions = buildPromptOptions(behavior, images);
872
- const promptPromise = session.prompt(text, promptOptions).catch((error) => {
1021
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
873
1022
  const message = error instanceof Error ? error.message : String(error);
874
1023
  this.publishActivity(session, "error", "error", message);
875
1024
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -896,6 +1045,7 @@ export class PiSessionService {
896
1045
  await this.assertWritable(ref);
897
1046
  const active = await this.getActive(ref);
898
1047
  const { session } = active.runtime;
1048
+ this.assertTreeNavigationInactive(session, "run a shell command");
899
1049
  const isExcluded = text.startsWith("!!");
900
1050
  const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
901
1051
  if (!command)
@@ -904,11 +1054,11 @@ export class PiSessionService {
904
1054
  throw new Error("A bash command is already running");
905
1055
  this.publishActivity(session, "running bash", "active", command);
906
1056
  this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
907
- void session.executeBash(command, (chunk) => {
1057
+ void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
908
1058
  this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
909
1059
  this.publishActivity(session, "running bash", "active", command);
910
1060
  this.publishStatus(session);
911
- }, { excludeFromContext: isExcluded }).then((result) => {
1061
+ }, { excludeFromContext: isExcluded })).then((result) => {
912
1062
  this.events.publish(session.sessionId, {
913
1063
  type: "shell.end",
914
1064
  output: result.output,
@@ -937,30 +1087,103 @@ export class PiSessionService {
937
1087
  const active = await this.getActive(ref);
938
1088
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
939
1089
  }
940
- async reloadSessionRuntime(session) {
1090
+ async navigateTree(ref, request) {
1091
+ if (request.targetId.trim() === "")
1092
+ throw new Error("Session tree target is required");
1093
+ if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
1094
+ throw new Error("Stop current session activity before navigating the session tree");
1095
+ }
1096
+ await this.assertWritable(ref);
1097
+ const options = sessionTreeNavigationOptions(request);
1098
+ const session = await this.getOrOpen(ref);
1099
+ if (typeof session.navigateTree !== "function")
1100
+ throw new Error("Session tree navigation is not supported by this Pi runtime");
941
1101
  if (this.hasActiveWork(session))
942
- throw new Error("Stop current session activity before reloading");
943
- this.publishActivity(session, "reloading resources", "active");
1102
+ throw new Error("Stop current session activity before navigating the session tree");
1103
+ // Acquire synchronously after the active-work check. No leaf-producing work
1104
+ // may enter this runtime until Pi's potentially asynchronous summary settles.
1105
+ this.treeNavigations.add(session);
944
1106
  try {
945
- await session.reload();
946
- this.publishActivity(session, "resources reloaded", "idle");
1107
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1108
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1109
+ }
1110
+ this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
947
1111
  this.publishStatus(session);
1112
+ const result = await session.navigateTree(request.targetId, options);
1113
+ if (result.cancelled) {
1114
+ if (this.isCurrentActiveSession(session)) {
1115
+ this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1116
+ }
1117
+ return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1118
+ }
1119
+ if (this.isCurrentActiveSession(session))
1120
+ this.publishActivity(session, "session tree navigated", "idle");
1121
+ return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
948
1122
  }
949
1123
  catch (error) {
950
1124
  const message = error instanceof Error ? error.message : String(error);
951
- this.publishActivity(session, "reload failed", "error", message);
952
- this.events.publish(session.sessionId, { type: "session.error", message });
953
- this.publishStatus(session);
1125
+ if (this.isCurrentActiveSession(session)) {
1126
+ this.publishActivity(session, "tree navigation failed", "error", message);
1127
+ this.events.publish(session.sessionId, { type: "session.error", message });
1128
+ }
954
1129
  throw error;
955
1130
  }
1131
+ finally {
1132
+ this.treeNavigations.delete(session);
1133
+ if (this.isCurrentActiveSession(session)) {
1134
+ this.flushDeferredTreeNavigationWork(session);
1135
+ this.publishStatus(session);
1136
+ }
1137
+ else {
1138
+ this.deferredGeneratedSessionNames.delete(session);
1139
+ this.deferredSubsessionNotifications.delete(session);
1140
+ }
1141
+ }
1142
+ }
1143
+ async reloadSessionRuntime(session) {
1144
+ if (this.hasActiveWork(session))
1145
+ throw new Error("Stop current session activity before reloading");
1146
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
1147
+ this.publishActivity(session, "reloading resources", "active");
1148
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1149
+ let candidateGeneration;
1150
+ try {
1151
+ await session.reload(priorGeneration === undefined ? undefined : {
1152
+ beforeSessionStart: () => {
1153
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1154
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1155
+ this.replaceSessionNotificationContext(session, candidateGeneration);
1156
+ },
1157
+ });
1158
+ if (candidateGeneration !== undefined) {
1159
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1160
+ }
1161
+ this.publishActivity(session, "resources reloaded", "idle");
1162
+ this.publishStatus(session);
1163
+ }
1164
+ catch (error) {
1165
+ if (candidateGeneration !== undefined) {
1166
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
1167
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1168
+ }
1169
+ const message = error instanceof Error ? error.message : String(error);
1170
+ this.publishActivity(session, "reload failed", "error", message);
1171
+ this.events.publish(session.sessionId, { type: "session.error", message });
1172
+ this.publishStatus(session);
1173
+ throw error;
1174
+ }
1175
+ });
956
1176
  }
957
1177
  async archive(ref) {
958
1178
  const session = await this.getOrOpen(ref);
959
1179
  if (this.hasActiveWork(session))
960
1180
  throw new Error("Stop current session activity before archiving");
961
- const archiveInput = await this.archiveInputForSession(session);
962
- await this.closeActive(session.sessionId);
963
- await this.archiveStore.archive(archiveInput);
1181
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1182
+ const archiveInput = await this.archiveInputForSession(session);
1183
+ await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
1184
+ await this.archiveStore.archive(archiveInput);
1185
+ await this.forgetUnreadSessions([archiveInput]);
1186
+ });
964
1187
  }
965
1188
  async archiveMany(refs) {
966
1189
  const uniqueRefs = uniqueBulkSessionRefs(refs);
@@ -970,11 +1193,14 @@ export class PiSessionService {
970
1193
  ]);
971
1194
  const failures = [];
972
1195
  const alreadyArchivedSessionIds = [];
1196
+ const unreadArchivedIdentities = [];
973
1197
  const planItems = [];
974
1198
  for (const ref of uniqueRefs) {
975
1199
  const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
976
1200
  if (archived !== undefined) {
1201
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
977
1202
  alreadyArchivedSessionIds.push(archived.sessionId);
1203
+ unreadArchivedIdentities.push(archived);
978
1204
  continue;
979
1205
  }
980
1206
  const active = this.activeForLookup(bulkRefToLookup(ref));
@@ -999,25 +1225,41 @@ export class PiSessionService {
999
1225
  failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
1000
1226
  }
1001
1227
  }
1002
- const readyInputs = [];
1228
+ const readyPlanItems = [];
1003
1229
  for (const item of planItems) {
1230
+ const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
1231
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1232
+ failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1233
+ continue;
1234
+ }
1235
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
1236
+ }
1237
+ const readyInputs = [];
1238
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1239
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1240
+ sessionId: input.sessionId,
1241
+ ...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
1242
+ })), "Stop current session activity before archiving", async () => {
1243
+ for (const item of readyPlanItems) {
1244
+ try {
1245
+ await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
1246
+ readyInputs.push(item.input);
1247
+ }
1248
+ catch (error) {
1249
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1250
+ }
1251
+ }
1004
1252
  try {
1005
- await this.closeActive(item.input.sessionId);
1006
- readyInputs.push(item.input);
1253
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1254
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1255
+ unreadArchivedIdentities.push(...archived);
1007
1256
  }
1008
1257
  catch (error) {
1009
- failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1258
+ for (const input of readyInputs)
1259
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1010
1260
  }
1011
- }
1012
- const archivedSessionIds = [...alreadyArchivedSessionIds];
1013
- try {
1014
- const archived = await this.archiveStoreArchiveMany(readyInputs);
1015
- archivedSessionIds.push(...archived.map((record) => record.sessionId));
1016
- }
1017
- catch (error) {
1018
- for (const input of readyInputs)
1019
- failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1020
- }
1261
+ });
1262
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
1021
1263
  return {
1022
1264
  archived: true,
1023
1265
  archivedSessionIds: uniqueStrings(archivedSessionIds),
@@ -1034,9 +1276,19 @@ export class PiSessionService {
1034
1276
  if (busy !== undefined)
1035
1277
  throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
1036
1278
  const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1037
- for (const input of archiveInputs)
1038
- await this.closeActive(input.sessionId);
1039
- await this.archiveStoreArchiveMany(archiveInputs);
1279
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1280
+ sessionId: target.id,
1281
+ ...(target.activeSession === undefined ? {} : { session: target.activeSession }),
1282
+ })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
1283
+ for (const target of plan.targets) {
1284
+ if (target.archived)
1285
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1286
+ }
1287
+ for (const input of archiveInputs)
1288
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
1289
+ await this.archiveStoreArchiveMany(archiveInputs);
1290
+ });
1291
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
1040
1292
  return {
1041
1293
  archived: true,
1042
1294
  sessionIds: archiveInputs.map((input) => input.sessionId),
@@ -1048,8 +1300,9 @@ export class PiSessionService {
1048
1300
  const archived = await this.getArchived(ref);
1049
1301
  if (archived === undefined)
1050
1302
  throw new Error("Session not found");
1051
- await this.closeActive(archived.sessionId);
1303
+ await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
1052
1304
  await this.archiveStore.restore(archived.sessionId);
1305
+ await this.forgetUnreadSessions([archived]);
1053
1306
  }
1054
1307
  async deleteArchived(ref) {
1055
1308
  const record = await this.getArchived(ref);
@@ -1057,10 +1310,11 @@ export class PiSessionService {
1057
1310
  throw new Error("Archived session not found");
1058
1311
  if (this.archiveStore.deleteArchived === undefined)
1059
1312
  throw new Error("Archive store does not support deletion");
1060
- await this.closeActive(record.sessionId);
1313
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
1061
1314
  if (record.archivePath === undefined)
1062
1315
  await this.ensureArchivedRecordMoved(record);
1063
1316
  await this.archiveStore.deleteArchived(record.sessionId);
1317
+ await this.forgetUnreadSessions([record]);
1064
1318
  }
1065
1319
  async deleteArchivedMany(refs) {
1066
1320
  if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
@@ -1085,7 +1339,7 @@ export class PiSessionService {
1085
1339
  const readyRecords = [];
1086
1340
  for (const item of planItems) {
1087
1341
  try {
1088
- await this.closeActive(item.record.sessionId);
1342
+ await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
1089
1343
  readyRecords.push(item.record);
1090
1344
  }
1091
1345
  catch (error) {
@@ -1106,6 +1360,8 @@ export class PiSessionService {
1106
1360
  for (const sessionId of deleteIds)
1107
1361
  failures.push({ sessionId, error: errorMessage(error) });
1108
1362
  }
1363
+ const deletedIdSet = new Set(deletedSessionIds);
1364
+ await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
1109
1365
  return {
1110
1366
  deleted: true,
1111
1367
  deletedSessionIds,
@@ -1118,9 +1374,29 @@ export class PiSessionService {
1118
1374
  const session = await this.getOrOpen(ref);
1119
1375
  if (this.hasActiveWork(session))
1120
1376
  throw new Error("Stop current session activity before reloading");
1121
- await this.closeActive(session.sessionId);
1122
- const reopened = await this.getActive(ref);
1123
- this.publishStatus(reopened.runtime.session);
1377
+ const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
1378
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1379
+ const { sessionId, cwd } = notificationIdentityForSession(session);
1380
+ let candidateGeneration;
1381
+ try {
1382
+ await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
1383
+ candidateGeneration = priorGeneration === undefined
1384
+ ? undefined
1385
+ : this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
1386
+ const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
1387
+ if (candidateGeneration !== undefined) {
1388
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1389
+ }
1390
+ return reopened.runtime.session;
1391
+ }
1392
+ catch (error) {
1393
+ if (candidateGeneration !== undefined) {
1394
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
1395
+ }
1396
+ throw error;
1397
+ }
1398
+ });
1399
+ this.publishStatus(reopenedSession);
1124
1400
  }
1125
1401
  async detachParent(ref) {
1126
1402
  const session = await this.getOrOpen(ref);
@@ -1130,6 +1406,7 @@ export class PiSessionService {
1130
1406
  await clearParentSession(sessionFile);
1131
1407
  clearParentSessionHeader(session.sessionManager);
1132
1408
  this.unregisterSubsession(session.sessionId);
1409
+ await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
1133
1410
  }
1134
1411
  async clearQueue(ref) {
1135
1412
  await this.assertWritable(ref);
@@ -1152,17 +1429,30 @@ export class PiSessionService {
1152
1429
  const sessionId = active.runtime.session.sessionId;
1153
1430
  this.clearCompactionPromptQueue(sessionId);
1154
1431
  clearSessionQueue(active.runtime.session);
1155
- await active.runtime.session.abort();
1156
- this.publishActivity(active.runtime.session, "stopped", "idle");
1157
- this.publishStatus(active.runtime.session);
1432
+ try {
1433
+ await this.abortSessionOperations(active.runtime.session);
1434
+ this.publishActivity(active.runtime.session, "stopped", "idle");
1435
+ }
1436
+ catch (error) {
1437
+ const message = error instanceof Error ? error.message : String(error);
1438
+ this.publishActivity(active.runtime.session, "stop failed", "error", message);
1439
+ throw error;
1440
+ }
1441
+ finally {
1442
+ this.publishStatus(active.runtime.session);
1443
+ }
1158
1444
  }
1159
- stop(ref) {
1445
+ async stop(ref) {
1160
1446
  const active = this.activeForLookup(ref);
1161
- if (active === undefined)
1447
+ if (active !== undefined) {
1448
+ await this.closeActive(active.runtime.session.sessionId);
1162
1449
  return;
1163
- void this.closeActive(active.runtime.session.sessionId).catch(() => {
1164
- // Best-effort shutdown; callers that need errors await closeActive directly.
1165
- });
1450
+ }
1451
+ if (isPiSessionRef(ref)) {
1452
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1453
+ return;
1454
+ }
1455
+ await this.closeActive(ref);
1166
1456
  }
1167
1457
  async bulkSessionLookupContext(refs) {
1168
1458
  const cwdSet = new Set();
@@ -1351,13 +1641,21 @@ export class PiSessionService {
1351
1641
  }
1352
1642
  return [...names];
1353
1643
  }
1354
- async closeActive(sessionId) {
1644
+ async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
1355
1645
  const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1356
1646
  if (pendingOpens.length > 0)
1357
1647
  await Promise.allSettled(pendingOpens);
1358
1648
  const active = this.active.get(sessionId);
1649
+ if (notificationPolicy.kind === "clear") {
1650
+ const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
1651
+ const mutations = generation === undefined
1652
+ ? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
1653
+ : this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
1654
+ this.publishNotificationMutations(mutations);
1655
+ }
1359
1656
  if (!active)
1360
1657
  return;
1658
+ this.forgetUnreadActivity(active.runtime.session);
1361
1659
  this.active.delete(sessionId);
1362
1660
  this.activities.delete(sessionId);
1363
1661
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -1370,13 +1668,36 @@ export class PiSessionService {
1370
1668
  this.subsessionNotifyArmed.delete(sessionId);
1371
1669
  clearSessionQueue(active.runtime.session);
1372
1670
  active.unsubscribe();
1671
+ active.runtime.setRebindSession(undefined);
1373
1672
  try {
1374
- await active.runtime.session.abort();
1673
+ await this.abortSessionOperations(active.runtime.session);
1375
1674
  }
1376
1675
  finally {
1377
1676
  await active.runtime.dispose();
1378
1677
  }
1379
1678
  }
1679
+ async abortSessionOperations(session) {
1680
+ let branchSummaryAbortFailed = false;
1681
+ let branchSummaryAbortError;
1682
+ try {
1683
+ session.abortBranchSummary?.();
1684
+ }
1685
+ catch (error) {
1686
+ branchSummaryAbortFailed = true;
1687
+ branchSummaryAbortError = error;
1688
+ }
1689
+ try {
1690
+ await session.abort();
1691
+ }
1692
+ catch (abortError) {
1693
+ if (branchSummaryAbortFailed) {
1694
+ throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
1695
+ }
1696
+ throw abortError;
1697
+ }
1698
+ if (branchSummaryAbortFailed)
1699
+ throw branchSummaryAbortError;
1700
+ }
1380
1701
  async assertWritable(ref) {
1381
1702
  if (await this.getArchived(ref) !== undefined)
1382
1703
  throw new Error("Archived sessions are read-only. Restore the session to continue.");
@@ -1384,23 +1705,23 @@ export class PiSessionService {
1384
1705
  async getOrOpen(ref) {
1385
1706
  return (await this.getActive(ref)).runtime.session;
1386
1707
  }
1387
- async getActive(ref) {
1708
+ async getActive(ref, options = {}) {
1388
1709
  const active = this.activeForLookup(ref);
1389
1710
  if (active !== undefined)
1390
1711
  return active;
1391
1712
  const archived = await this.getArchived(ref);
1392
1713
  if (archived?.archivePath !== undefined) {
1393
1714
  const { archivePath } = archived;
1394
- return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath));
1715
+ return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
1395
1716
  }
1396
1717
  const match = isPiSessionRef(ref)
1397
1718
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
1398
1719
  : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
1399
1720
  if (!match)
1400
1721
  throw new Error("Session not found");
1401
- return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
1722
+ return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
1402
1723
  }
1403
- openExistingSession(sessionId, cwd, openSessionManager) {
1724
+ openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
1404
1725
  const active = this.activeForLookup({ id: sessionId, cwd });
1405
1726
  if (active !== undefined)
1406
1727
  return Promise.resolve(active);
@@ -1410,7 +1731,7 @@ export class PiSessionService {
1410
1731
  return existing.promise;
1411
1732
  const pending = {
1412
1733
  sessionId,
1413
- promise: this.create(openSessionManager(), cwd),
1734
+ promise: this.create(openSessionManager(), cwd, options),
1414
1735
  };
1415
1736
  pending.promise = pending.promise.finally(() => {
1416
1737
  if (this.pendingSessionOpens.get(key) === pending)
@@ -1432,6 +1753,9 @@ export class PiSessionService {
1432
1753
  return undefined;
1433
1754
  return archived;
1434
1755
  }
1756
+ isCurrentActiveSession(session) {
1757
+ return this.active.get(session.sessionId)?.runtime.session === session;
1758
+ }
1435
1759
  activeForLookup(ref) {
1436
1760
  const sessionId = sessionIdFromLookup(ref);
1437
1761
  const exact = this.active.get(sessionId);
@@ -1454,21 +1778,83 @@ export class PiSessionService {
1454
1778
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1455
1779
  });
1456
1780
  const active = { runtime, unsubscribe: noop };
1781
+ let boundSession = runtime.session;
1782
+ let notificationGeneration = options.notificationGeneration;
1783
+ let notificationOwnership = options.notifications === "disabled"
1784
+ ? "disabled"
1785
+ : notificationGeneration === undefined
1786
+ ? "registered"
1787
+ : "external";
1788
+ if (notificationOwnership === "registered") {
1789
+ const notificationIdentity = notificationIdentityForSession(runtime.session);
1790
+ const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
1791
+ if (existingCandidate !== undefined) {
1792
+ notificationGeneration = existingCandidate;
1793
+ notificationOwnership = "replacement";
1794
+ }
1795
+ else {
1796
+ const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
1797
+ notificationGeneration = registration.generation;
1798
+ this.publishNotificationMutations(registration.mutations);
1799
+ }
1800
+ }
1801
+ if (notificationGeneration !== undefined)
1802
+ this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
1457
1803
  try {
1458
- await this.bindSessionExtensions(runtime.session);
1804
+ if (options.creationProvenance === "tracked-subsession") {
1805
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
1806
+ }
1807
+ else {
1808
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1809
+ }
1810
+ await this.bindSessionExtensions(runtime.session, notificationGeneration);
1459
1811
  this.bindRuntime(active);
1460
1812
  runtime.setRebindSession(async (session) => {
1461
- await this.bindSessionExtensions(session);
1462
- this.bindRuntime(active);
1463
- await this.recoverSubsessionTrackingForOpenedSession(session);
1813
+ const priorGeneration = notificationGeneration;
1814
+ let candidateGeneration;
1815
+ try {
1816
+ await this.prepareUnreadRuntimeRebind(boundSession, session);
1817
+ await this.recoverSubsessionTrackingForOpenedSession(session);
1818
+ if (priorGeneration !== undefined) {
1819
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1820
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1821
+ }
1822
+ this.bindRuntime(active, session);
1823
+ boundSession = session;
1824
+ await this.bindSessionExtensions(session, candidateGeneration);
1825
+ if (candidateGeneration !== undefined) {
1826
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1827
+ notificationGeneration = candidateGeneration;
1828
+ }
1829
+ }
1830
+ catch (error) {
1831
+ if (candidateGeneration !== undefined) {
1832
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
1833
+ notificationGeneration = candidateGeneration;
1834
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1835
+ }
1836
+ throw error;
1837
+ }
1464
1838
  });
1465
1839
  this.active.set(runtime.session.sessionId, active);
1466
- await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1840
+ if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
1841
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
1842
+ notificationOwnership = "external";
1843
+ }
1467
1844
  this.publishStatus(runtime.session);
1468
1845
  return active;
1469
1846
  }
1470
1847
  catch (error) {
1848
+ if (notificationGeneration !== undefined) {
1849
+ if (notificationOwnership === "registered") {
1850
+ this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
1851
+ }
1852
+ else if (notificationOwnership === "replacement") {
1853
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
1854
+ }
1855
+ }
1471
1856
  active.unsubscribe();
1857
+ this.forgetUnreadActivity(boundSession);
1472
1858
  let removedActive = false;
1473
1859
  for (const [sessionId, candidate] of this.active.entries()) {
1474
1860
  if (candidate !== active)
@@ -1491,39 +1877,169 @@ export class PiSessionService {
1491
1877
  throw error;
1492
1878
  }
1493
1879
  }
1494
- async bindSessionExtensions(session) {
1880
+ async bindSessionExtensions(session, generation) {
1881
+ const uiContext = this.sessionUiContext(session, generation);
1882
+ await session.bindExtensions({
1883
+ uiContext,
1884
+ mode: "rpc",
1885
+ onError: (error) => {
1886
+ const message = `${error.extensionPath}: ${error.error}`;
1887
+ this.publishActivity(session, "extension error", "error", message);
1888
+ this.events.publish(session.sessionId, { type: "session.error", message });
1889
+ },
1890
+ });
1891
+ }
1892
+ replaceSessionNotificationContext(session, generation) {
1893
+ session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
1894
+ }
1895
+ sessionUiContext(session, generation) {
1495
1896
  const baseUiContext = session.extensionRunner.getUIContext();
1496
1897
  const notify = (message, type) => {
1898
+ if (generation === undefined) {
1899
+ this.events.publish(session.sessionId, {
1900
+ type: "command.output",
1901
+ level: type === "error" ? "error" : "info",
1902
+ message,
1903
+ });
1904
+ return;
1905
+ }
1906
+ const added = this.notificationStore.addNotification(generation, message, type);
1907
+ this.publishNotificationMutations(added.mutations);
1908
+ if (added.notification === undefined)
1909
+ return;
1497
1910
  this.events.publish(session.sessionId, {
1498
1911
  type: "command.output",
1499
1912
  level: type === "error" ? "error" : "info",
1500
1913
  message,
1914
+ notificationId: added.notification.id,
1501
1915
  });
1502
1916
  };
1503
- // PI WEB is a remote UI host, but currently only extension notifications
1504
- // cross this boundary. Delegate every other UI method to Pi's headless
1505
- // defaults so unsupported dialogs cancel safely instead of hanging.
1506
- const uiContext = new Proxy(baseUiContext, {
1917
+ // PI WEB owns the browser-facing notification and text-formatting
1918
+ // boundaries. Delegate every other UI method to Pi's headless defaults so
1919
+ // unsupported dialogs cancel safely instead of hanging.
1920
+ return new Proxy(baseUiContext, {
1507
1921
  get(target, property, receiver) {
1508
1922
  if (property === "notify")
1509
1923
  return notify;
1924
+ if (property === "theme")
1925
+ return plainTextTheme;
1510
1926
  const value = Reflect.get(target, property, receiver);
1511
1927
  return value;
1512
1928
  },
1513
1929
  });
1514
- await session.bindExtensions({
1515
- uiContext,
1516
- mode: "rpc",
1517
- onError: (error) => {
1518
- const message = `${error.extensionPath}: ${error.error}`;
1519
- this.publishActivity(session, "extension error", "error", message);
1520
- this.events.publish(session.sessionId, { type: "session.error", message });
1521
- },
1930
+ }
1931
+ publishNotificationMutations(mutations) {
1932
+ for (const mutation of mutations) {
1933
+ this.events.publish(mutation.sessionId, mutation.inboxEvent);
1934
+ this.events.publishNotificationSummary(mutation.summaryEvent);
1935
+ }
1936
+ }
1937
+ async prepareUnreadRuntimeRebind(previous, next) {
1938
+ const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
1939
+ this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
1940
+ const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
1941
+ if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
1942
+ return;
1943
+ await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
1944
+ }
1945
+ forgetUnreadActivity(session) {
1946
+ this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
1947
+ }
1948
+ async forgetUnreadSessions(identities) {
1949
+ const mutations = [];
1950
+ for (const identity of identities) {
1951
+ mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
1952
+ }
1953
+ await this.publishUnreadMutations(mutations);
1954
+ }
1955
+ observeUnreadActivityState(session) {
1956
+ const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
1957
+ if (mutations.length === 0)
1958
+ return;
1959
+ void this.publishUnreadMutations(mutations).catch(() => undefined);
1960
+ }
1961
+ publishUnreadMutations(mutations) {
1962
+ this.enqueueUnreadMutations(mutations);
1963
+ this.unreadPublicationFlushRequested = true;
1964
+ if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
1965
+ const failure = this.unreadPublicationFailure;
1966
+ return Promise.reject(failure instanceof Error
1967
+ ? failure
1968
+ : new Error("Session unread publication is awaiting retry", { cause: failure }));
1969
+ }
1970
+ return this.ensureUnreadPublication();
1971
+ }
1972
+ ensureUnreadPublication() {
1973
+ const existing = this.unreadPublication;
1974
+ if (existing !== undefined)
1975
+ return existing;
1976
+ const publication = this.drainUnreadPublication();
1977
+ this.unreadPublication = publication;
1978
+ void publication.then(() => {
1979
+ if (this.unreadPublication === publication)
1980
+ this.unreadPublication = undefined;
1981
+ }, (error) => {
1982
+ if (this.unreadPublication === publication)
1983
+ this.unreadPublication = undefined;
1984
+ this.unreadPublicationFailure = error;
1985
+ this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
1986
+ this.scheduleUnreadPublicationRetry();
1522
1987
  });
1988
+ return publication;
1989
+ }
1990
+ async drainUnreadPublication() {
1991
+ while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
1992
+ this.unreadPublicationFlushRequested = false;
1993
+ const batch = this.pendingUnreadMutations.splice(0);
1994
+ let publishedCount = 0;
1995
+ try {
1996
+ await this.unreadStore.flush();
1997
+ for (const mutation of batch) {
1998
+ this.events.publishGlobal(mutation.event);
1999
+ publishedCount += 1;
2000
+ }
2001
+ }
2002
+ catch (error) {
2003
+ this.prependUnreadMutations(batch.slice(publishedCount));
2004
+ this.unreadPublicationFlushRequested = true;
2005
+ throw error;
2006
+ }
2007
+ this.unreadPublicationFailure = undefined;
2008
+ this.clearUnreadPublicationRetry();
2009
+ }
2010
+ }
2011
+ enqueueUnreadMutations(mutations) {
2012
+ this.pendingUnreadMutations.push(...mutations);
2013
+ this.trimPendingUnreadMutations();
2014
+ }
2015
+ prependUnreadMutations(mutations) {
2016
+ this.pendingUnreadMutations.unshift(...mutations);
2017
+ this.trimPendingUnreadMutations();
1523
2018
  }
1524
- bindRuntime(active) {
2019
+ trimPendingUnreadMutations() {
2020
+ const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
2021
+ if (excess > 0)
2022
+ this.pendingUnreadMutations.splice(0, excess);
2023
+ }
2024
+ scheduleUnreadPublicationRetry() {
2025
+ if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
2026
+ return;
2027
+ const delay = this.unreadPublicationRetryDelayMs;
2028
+ this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
2029
+ this.unreadPublicationRetryTimer = setTimeout(() => {
2030
+ this.unreadPublicationRetryTimer = undefined;
2031
+ void this.ensureUnreadPublication().catch(() => undefined);
2032
+ }, delay);
2033
+ this.unreadPublicationRetryTimer.unref();
2034
+ }
2035
+ clearUnreadPublicationRetry() {
2036
+ if (this.unreadPublicationRetryTimer !== undefined)
2037
+ clearTimeout(this.unreadPublicationRetryTimer);
2038
+ this.unreadPublicationRetryTimer = undefined;
2039
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2040
+ }
2041
+ bindRuntime(active, session = active.runtime.session) {
1525
2042
  active.unsubscribe();
1526
- const { session } = active.runtime;
1527
2043
  for (const [sessionId, candidate] of this.active.entries()) {
1528
2044
  if (candidate === active) {
1529
2045
  this.active.delete(sessionId);
@@ -1614,7 +2130,7 @@ export class PiSessionService {
1614
2130
  const model = session.model;
1615
2131
  if (model === undefined)
1616
2132
  return;
1617
- void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
2133
+ void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
1618
2134
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
1619
2135
  }).catch(() => {
1620
2136
  this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1623,9 +2139,32 @@ export class PiSessionService {
1623
2139
  applyGeneratedSessionName(session, name) {
1624
2140
  if (name === undefined || session.sessionName !== undefined)
1625
2141
  return;
2142
+ if (this.treeNavigations.has(session)) {
2143
+ this.deferredGeneratedSessionNames.set(session, name);
2144
+ return;
2145
+ }
1626
2146
  session.setSessionName(name);
1627
2147
  this.publishSessionName(session);
1628
2148
  }
2149
+ flushDeferredTreeNavigationWork(session) {
2150
+ const generatedName = this.deferredGeneratedSessionNames.get(session);
2151
+ this.deferredGeneratedSessionNames.delete(session);
2152
+ if (generatedName !== undefined) {
2153
+ try {
2154
+ this.applyGeneratedSessionName(session, generatedName);
2155
+ }
2156
+ catch (error) {
2157
+ this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
2158
+ }
2159
+ }
2160
+ const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
2161
+ this.deferredSubsessionNotifications.delete(session);
2162
+ for (const notification of notifications) {
2163
+ void this.deliverSubsessionNotification(session, notification).catch((error) => {
2164
+ this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
2165
+ });
2166
+ }
2167
+ }
1629
2168
  applyAuthChange(change = {}) {
1630
2169
  // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
1631
2170
  // emits the change, so no refresh is needed here. Keeping this synchronous
@@ -1694,6 +2233,10 @@ export class PiSessionService {
1694
2233
  }
1695
2234
  }
1696
2235
  activityLabelFromStatus(session) {
2236
+ if (this.treeNavigations.has(session))
2237
+ return "navigating session tree";
2238
+ if (this.isSessionEntryMutationActive(session))
2239
+ return "updating session";
1697
2240
  if (session.isCompacting)
1698
2241
  return "compacting";
1699
2242
  if (session.isBashRunning)
@@ -1705,7 +2248,92 @@ export class PiSessionService {
1705
2248
  return "active";
1706
2249
  }
1707
2250
  hasActiveWork(session) {
1708
- return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2251
+ return this.treeNavigations.has(session)
2252
+ || this.isSessionEntryMutationActive(session)
2253
+ || this.isTreeExclusiveOperationActive(session)
2254
+ || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
2255
+ }
2256
+ async runTreeExclusiveOperation(targets, activeError, operation) {
2257
+ const sessionIds = new Set();
2258
+ const runtimes = new Set();
2259
+ const sessions = new Set();
2260
+ for (const target of targets) {
2261
+ const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
2262
+ const session = target.session ?? runtime?.session;
2263
+ if (session !== undefined && this.hasActiveWork(session))
2264
+ throw new Error(activeError);
2265
+ sessionIds.add(target.sessionId);
2266
+ if (runtime !== undefined)
2267
+ runtimes.add(runtime);
2268
+ if (session !== undefined)
2269
+ sessions.add(session);
2270
+ }
2271
+ for (const sessionId of sessionIds) {
2272
+ this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
2273
+ }
2274
+ for (const runtime of runtimes) {
2275
+ this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
2276
+ }
2277
+ for (const session of sessions)
2278
+ this.observeUnreadActivityState(session);
2279
+ try {
2280
+ return await operation();
2281
+ }
2282
+ finally {
2283
+ for (const runtime of runtimes)
2284
+ decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
2285
+ for (const sessionId of sessionIds)
2286
+ decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
2287
+ for (const session of sessions) {
2288
+ if (this.isCurrentActiveSession(session))
2289
+ this.observeUnreadActivityState(session);
2290
+ }
2291
+ }
2292
+ }
2293
+ isTreeExclusiveSessionIdentityActive(sessionId) {
2294
+ return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
2295
+ }
2296
+ isTreeExclusiveOperationActive(session) {
2297
+ if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
2298
+ return true;
2299
+ const runtime = this.activeRuntimeForSession(session);
2300
+ return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
2301
+ }
2302
+ activeRuntimeForSession(session) {
2303
+ for (const active of new Set(this.active.values())) {
2304
+ if (active.runtime.session === session)
2305
+ return active.runtime;
2306
+ }
2307
+ return undefined;
2308
+ }
2309
+ assertTreeNavigationInactive(session, action) {
2310
+ if (this.treeNavigations.has(session))
2311
+ throw new Error(`Cannot ${action} while session tree navigation is active`);
2312
+ }
2313
+ async runSessionEntryMutation(session, action, operation) {
2314
+ this.beginSessionEntryMutation(session, action);
2315
+ try {
2316
+ return await operation();
2317
+ }
2318
+ finally {
2319
+ this.endSessionEntryMutation(session);
2320
+ }
2321
+ }
2322
+ beginSessionEntryMutation(session, action) {
2323
+ this.assertTreeNavigationInactive(session, action);
2324
+ this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
2325
+ this.observeUnreadActivityState(session);
2326
+ }
2327
+ endSessionEntryMutation(session) {
2328
+ const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
2329
+ if (remaining <= 0)
2330
+ this.sessionEntryMutationCounts.delete(session);
2331
+ else
2332
+ this.sessionEntryMutationCounts.set(session, remaining);
2333
+ this.observeUnreadActivityState(session);
2334
+ }
2335
+ isSessionEntryMutationActive(session) {
2336
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
1709
2337
  }
1710
2338
  publishActivityForEvent(session, event) {
1711
2339
  const eventType = getString(event, "type");
@@ -1767,6 +2395,7 @@ export class PiSessionService {
1767
2395
  this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
1768
2396
  this.events.publish(session.sessionId, { type: "activity.update", activity });
1769
2397
  this.events.publishGlobal({ type: "activity.update", activity });
2398
+ this.observeUnreadActivityState(session);
1770
2399
  }
1771
2400
  publishStatus(session) {
1772
2401
  const status = this.statusFromSession(session);
@@ -1774,6 +2403,7 @@ export class PiSessionService {
1774
2403
  this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
1775
2404
  this.events.publish(session.sessionId, { type: "status.update", status });
1776
2405
  this.events.publishGlobal({ type: "status.update", status });
2406
+ this.observeUnreadActivityState(session);
1777
2407
  }
1778
2408
  clearStaleActiveActivity(session) {
1779
2409
  const current = this.activities.get(session.sessionId);
@@ -1886,6 +2516,12 @@ function modelToClientModel(model) {
1886
2516
  ...(reasoning === undefined ? {} : { reasoning }),
1887
2517
  };
1888
2518
  }
2519
+ function notificationIdentityForSession(session) {
2520
+ return {
2521
+ sessionId: session.sessionId,
2522
+ cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
2523
+ };
2524
+ }
1889
2525
  function clientSessionFromListEntry(session) {
1890
2526
  return {
1891
2527
  id: session.id,