pi-ui-extend 0.1.70 → 0.1.72

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 (58) hide show
  1. package/dist/app/app.d.ts +2 -0
  2. package/dist/app/app.js +39 -1
  3. package/dist/app/commands/command-host.d.ts +7 -0
  4. package/dist/app/commands/command-host.js +10 -1
  5. package/dist/app/commands/command-model-actions.d.ts +1 -1
  6. package/dist/app/commands/command-model-actions.js +36 -2
  7. package/dist/app/commands/command-navigation-actions.d.ts +1 -1
  8. package/dist/app/commands/command-navigation-actions.js +40 -6
  9. package/dist/app/commands/command-session-actions.d.ts +1 -1
  10. package/dist/app/commands/command-session-actions.js +56 -0
  11. package/dist/app/commands/shell-controller.d.ts +9 -2
  12. package/dist/app/commands/shell-controller.js +32 -21
  13. package/dist/app/extensions/extension-actions-controller.d.ts +3 -0
  14. package/dist/app/extensions/extension-actions-controller.js +25 -7
  15. package/dist/app/extensions/extension-ui-controller.d.ts +5 -1
  16. package/dist/app/extensions/extension-ui-controller.js +104 -64
  17. package/dist/app/icons.d.ts +1 -0
  18. package/dist/app/icons.js +2 -0
  19. package/dist/app/input/input-action-controller.d.ts +4 -1
  20. package/dist/app/input/input-action-controller.js +68 -27
  21. package/dist/app/input/input-controller.d.ts +3 -0
  22. package/dist/app/input/input-controller.js +45 -1
  23. package/dist/app/input/input-paste-handler.d.ts +3 -0
  24. package/dist/app/input/input-paste-handler.js +21 -12
  25. package/dist/app/input/prompt-enhancer-controller.d.ts +1 -0
  26. package/dist/app/input/prompt-enhancer-controller.js +11 -5
  27. package/dist/app/input/voice-controller.d.ts +4 -0
  28. package/dist/app/input/voice-controller.js +76 -35
  29. package/dist/app/popup/popup-action-controller.d.ts +3 -0
  30. package/dist/app/popup/popup-action-controller.js +43 -8
  31. package/dist/app/rendering/render-controller.js +1 -0
  32. package/dist/app/rendering/status-line-renderer.d.ts +3 -1
  33. package/dist/app/rendering/status-line-renderer.js +11 -0
  34. package/dist/app/screen/mouse-controller.d.ts +5 -1
  35. package/dist/app/screen/mouse-controller.js +14 -2
  36. package/dist/app/session/queued-message-controller.d.ts +5 -4
  37. package/dist/app/session/queued-message-controller.js +32 -27
  38. package/dist/app/session/request-history.d.ts +2 -0
  39. package/dist/app/session/request-history.js +22 -15
  40. package/dist/app/session/session-event-controller.js +28 -8
  41. package/dist/app/session/session-history.js +8 -2
  42. package/dist/app/session/session-lifecycle-controller.d.ts +7 -0
  43. package/dist/app/session/session-lifecycle-controller.js +91 -19
  44. package/dist/app/session/tabs-controller.d.ts +26 -0
  45. package/dist/app/session/tabs-controller.js +469 -96
  46. package/dist/app/terminal/terminal-controller.d.ts +4 -0
  47. package/dist/app/terminal/terminal-controller.js +38 -11
  48. package/dist/app/types.d.ts +10 -0
  49. package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
  50. package/dist/app/workspace/workspace-actions-controller.js +37 -9
  51. package/dist/input-editor.d.ts +2 -0
  52. package/dist/input-editor.js +17 -0
  53. package/docs/concurrency.md +76 -0
  54. package/external/pi-tools-suite/package.json +3 -3
  55. package/external/pi-tools-suite/src/dcp/state-persistence.ts +29 -14
  56. package/external/pi-tools-suite/src/todo/index.ts +2 -2
  57. package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
  58. package/package.json +4 -4
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir, open as openFile, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, join, resolve } from "node:path";
@@ -13,6 +13,7 @@ const BACKGROUND_PREWARM_TAB_LIMIT = 2;
13
13
  const TAB_ATTENTION_BLINK_KEY = "tab-attention";
14
14
  const LOADING_TAB_TITLE_PATTERN = /^loading(?:…|\.\.\.)?$/iu;
15
15
  const DEFAULT_SESSION_TITLE_PATTERN = /^(?:session )?[0-9a-f]{8}$/iu;
16
+ const SESSION_HEADER_SCAN_MAX_BYTES = 64 * 1024;
16
17
  const SESSION_TITLE_HEAD_SCAN_MAX_BYTES = 256 * 1024;
17
18
  const SESSION_TITLE_SCAN_MAX_BYTES = 2 * 1024 * 1024;
18
19
  export class AppTabsController {
@@ -29,9 +30,18 @@ export class AppTabsController {
29
30
  sessionViewsByTabId = new Map();
30
31
  scrollStatesByTabId = new Map();
31
32
  tabIdsNeedingHistoryReload = new Set();
33
+ historyInvalidationGenerationByTabId = new Map();
34
+ runtimeOwnershipGenerationByTabId = new Map();
35
+ orphanRuntimeDisposals = new WeakSet();
36
+ lifecycleMutationQueue = [];
32
37
  activeTabId;
33
38
  pendingActiveTabId;
34
39
  historyLoadGeneration = 0;
40
+ historyInvalidationGeneration = 0;
41
+ runtimeOwnershipGeneration = 0;
42
+ lifecycleGeneration = 0;
43
+ lifecycleMutationRunning = false;
44
+ saveTabsWriteTail = Promise.resolve();
35
45
  restored = false;
36
46
  retentionCleanupRunning = false;
37
47
  retentionCleanupScheduled = false;
@@ -40,6 +50,48 @@ export class AppTabsController {
40
50
  constructor(host) {
41
51
  this.host = host;
42
52
  }
53
+ runLifecycleMutation(operation) {
54
+ return new Promise((resolveOperation, rejectOperation) => {
55
+ const run = () => {
56
+ if (!this.host.isRunning()) {
57
+ resolveOperation(undefined);
58
+ const next = this.lifecycleMutationQueue.shift();
59
+ if (next)
60
+ queueMicrotask(next);
61
+ return;
62
+ }
63
+ this.lifecycleMutationRunning = true;
64
+ const generation = ++this.lifecycleGeneration;
65
+ let result;
66
+ try {
67
+ result = operation(generation);
68
+ }
69
+ catch (error) {
70
+ result = Promise.reject(error);
71
+ }
72
+ void result.then(resolveOperation, rejectOperation).finally(() => {
73
+ this.lifecycleMutationRunning = false;
74
+ this.lifecycleMutationQueue.shift()?.();
75
+ });
76
+ };
77
+ if (this.lifecycleMutationRunning)
78
+ this.lifecycleMutationQueue.push(run);
79
+ else
80
+ run();
81
+ });
82
+ }
83
+ isLifecycleOwner(generation, runtime, session) {
84
+ return this.host.isRunning()
85
+ && generation === this.lifecycleGeneration
86
+ && this.host.runtime() === runtime
87
+ && runtime.session === session;
88
+ }
89
+ cancelPendingLifecycleWork() {
90
+ this.lifecycleGeneration += 1;
91
+ this.cancelHistoryLoad();
92
+ this.pendingActiveTabId = undefined;
93
+ this.clearRuntimeSubscriptions();
94
+ }
43
95
  tabs() {
44
96
  if (!this.pendingActiveTabId)
45
97
  this.syncActiveTabFromRuntime({ save: false });
@@ -122,6 +174,24 @@ export class AppTabsController {
122
174
  this.storeActiveDeferredUserMessages();
123
175
  void this.saveTabs();
124
176
  }
177
+ requeueAutoUserMessageForSession(session, message) {
178
+ if (this.host.runtime()?.session === session)
179
+ return;
180
+ const tab = this.findTabForSession(session);
181
+ if (!tab)
182
+ return;
183
+ const messages = this.autoUserMessagesByTabId.get(tab.id) ?? [];
184
+ messages.unshift(cloneSubmittedUserMessage(message));
185
+ this.autoUserMessagesByTabId.set(tab.id, messages);
186
+ void this.saveTabs();
187
+ }
188
+ inputTabOwnedByRuntime(tabId, runtime, session) {
189
+ if (runtime.session !== session)
190
+ return false;
191
+ if (tabId === undefined)
192
+ return this.host.runtime() === runtime;
193
+ return this.runtimesByTabId.get(tabId) === runtime;
194
+ }
125
195
  syncActiveTabFromRuntime(options = {}) {
126
196
  if (this.pendingActiveTabId && options.force !== true)
127
197
  return;
@@ -163,19 +233,27 @@ export class AppTabsController {
163
233
  if (options.save !== false)
164
234
  void this.saveTabs();
165
235
  }
166
- async restoreAfterStartup() {
236
+ restoreAfterStartup() {
237
+ return this.runLifecycleMutation((generation) => this.restoreAfterStartupMutation(generation));
238
+ }
239
+ async restoreAfterStartupMutation(generation) {
167
240
  if (this.restored)
168
241
  return;
169
242
  this.restored = true;
170
243
  const runtime = this.host.runtime();
171
244
  if (!runtime)
172
245
  return;
246
+ const startupSession = runtime.session;
173
247
  this.syncActiveTabFromRuntime({ save: false });
174
248
  if (this.host.options.noSession) {
175
249
  this.clearStartupTabPlaceholders();
176
250
  return;
177
251
  }
178
252
  const saved = await this.loadTabs();
253
+ if (!this.isLifecycleOwner(generation, runtime, startupSession)) {
254
+ this.syncActiveTabFromRuntime({ save: false, force: true });
255
+ return;
256
+ }
179
257
  if (!saved || saved.tabs.length === 0) {
180
258
  this.clearStartupTabPlaceholders();
181
259
  await this.saveTabs();
@@ -183,6 +261,10 @@ export class AppTabsController {
183
261
  }
184
262
  const restoredSessionPaths = saved.tabs.map((tab) => tab.path);
185
263
  const forkedSessionPaths = await this.loadForkedSessionPaths(restoredSessionPaths);
264
+ if (!this.isLifecycleOwner(generation, runtime, startupSession)) {
265
+ this.syncActiveTabFromRuntime({ save: false, force: true });
266
+ return;
267
+ }
186
268
  const restoredTabs = this.restoredTabs(saved, new Map(), forkedSessionPaths);
187
269
  if (restoredTabs.length === 0) {
188
270
  this.clearStartupTabPlaceholders();
@@ -190,7 +272,7 @@ export class AppTabsController {
190
272
  this.scheduleProjectSessionRetention();
191
273
  return;
192
274
  }
193
- const currentPath = runtime.session.sessionFile ? resolve(runtime.session.sessionFile) : undefined;
275
+ const currentPath = startupSession.sessionFile ? resolve(startupSession.sessionFile) : undefined;
194
276
  const explicitSessionPath = this.host.options.sessionPath ? resolve(this.host.options.sessionPath) : undefined;
195
277
  const savedActivePath = saved.activePath ? resolve(saved.activePath) : undefined;
196
278
  const desiredPath = explicitSessionPath && currentPath
@@ -202,11 +284,11 @@ export class AppTabsController {
202
284
  this.restorePersistedInputStates(saved);
203
285
  this.restorePersistedQueuedUserMessages(saved);
204
286
  if (explicitSessionPath && currentPath)
205
- this.ensureCurrentSessionTab(runtime.session);
287
+ this.ensureCurrentSessionTab(startupSession);
206
288
  if (!desiredPath) {
207
289
  this.clearStartupTabPlaceholders();
208
290
  await this.saveTabs();
209
- this.scheduleRestoredTabTitleRefresh(restoredSessionPaths);
291
+ this.scheduleRestoredTabTitleRefresh(restoredSessionPaths, generation);
210
292
  this.scheduleProjectSessionRetention();
211
293
  this.scheduleTabPrewarm();
212
294
  return;
@@ -215,22 +297,51 @@ export class AppTabsController {
215
297
  if (currentPath !== desiredPath) {
216
298
  this.host.setStatus("restoring tabs");
217
299
  this.host.render();
300
+ let candidateRuntime;
218
301
  try {
219
- restoredRuntime = await this.host.createRuntimeForSession(desiredPath);
220
- await this.host.activateRuntime(restoredRuntime, { awaitExtensions: false });
302
+ candidateRuntime = await this.host.createRuntimeForSession(desiredPath);
303
+ if (!this.isLifecycleOwner(generation, runtime, startupSession)) {
304
+ await this.disposeRuntimeIfOrphan(candidateRuntime);
305
+ this.syncActiveTabFromRuntime({ save: false, force: true });
306
+ return;
307
+ }
308
+ const candidateSession = candidateRuntime.session;
309
+ await this.host.activateRuntime(candidateRuntime, { awaitExtensions: false });
310
+ if (this.host.runtime() !== candidateRuntime || candidateRuntime.session !== candidateSession) {
311
+ await this.disposeRuntimeIfOrphan(candidateRuntime);
312
+ this.syncActiveTabFromRuntime({ save: false, force: true });
313
+ return;
314
+ }
315
+ restoredRuntime = candidateRuntime;
221
316
  }
222
317
  catch {
318
+ if (candidateRuntime && this.host.runtime() === candidateRuntime) {
319
+ try {
320
+ await this.host.activateRuntime(runtime, { awaitExtensions: false });
321
+ }
322
+ catch {
323
+ // Keep the best available runtime below and avoid overwriting a newer owner.
324
+ }
325
+ }
326
+ if (candidateRuntime)
327
+ await this.disposeRuntimeIfOrphan(candidateRuntime);
328
+ if (this.host.runtime() !== runtime || runtime.session !== startupSession) {
329
+ this.syncActiveTabFromRuntime({ save: false, force: true });
330
+ return;
331
+ }
223
332
  this.host.showToast("Could not restore the previous active tab", "warning");
224
- this.replaceTabs([this.tabFromSession(runtime.session), ...restoredTabs], currentPath);
333
+ this.replaceTabs([this.tabFromSession(startupSession), ...restoredTabs], currentPath);
225
334
  this.storeActiveRuntime(runtime);
226
335
  this.clearStartupTabPlaceholders();
227
336
  await this.saveTabs();
228
- this.scheduleRestoredTabTitleRefresh(restoredSessionPaths);
337
+ this.scheduleRestoredTabTitleRefresh(restoredSessionPaths, generation);
229
338
  this.scheduleProjectSessionRetention();
230
339
  return;
231
340
  }
232
341
  }
233
342
  this.syncActiveTabFromRuntime({ save: false });
343
+ if (restoredRuntime !== runtime)
344
+ await this.disposeRuntimeIfOrphan(runtime);
234
345
  this.clearStartupTabPlaceholders();
235
346
  if (this.activeTabId)
236
347
  this.restoreInputState(this.activeTabId);
@@ -238,10 +349,13 @@ export class AppTabsController {
238
349
  await this.saveTabs();
239
350
  this.scheduleProjectSessionRetention();
240
351
  this.scheduleTabPrewarm();
241
- await this.loadActiveSessionHistory(restoredRuntime);
242
- this.scheduleRestoredTabTitleRefresh(restoredSessionPaths);
352
+ await this.loadActiveSessionHistory(restoredRuntime, generation);
353
+ this.scheduleRestoredTabTitleRefresh(restoredSessionPaths, generation);
354
+ }
355
+ openNewTab() {
356
+ return this.runLifecycleMutation((generation) => this.openNewTabMutation(generation));
243
357
  }
244
- async openNewTab() {
358
+ async openNewTabMutation(generation) {
245
359
  if (this.pendingActiveTabId) {
246
360
  this.host.showToast("Wait for the tab to finish loading", "info");
247
361
  return;
@@ -258,6 +372,7 @@ export class AppTabsController {
258
372
  this.storeActiveDeferredUserMessages();
259
373
  const previousTabId = this.activeTabId;
260
374
  const previousRuntime = runtime;
375
+ const previousSession = runtime.session;
261
376
  const tab = {
262
377
  id: createId("tab"),
263
378
  title: "new",
@@ -297,6 +412,15 @@ export class AppTabsController {
297
412
  this.host.render();
298
413
  throw error;
299
414
  }
415
+ if (!this.isLifecycleOwner(generation, previousRuntime, previousSession)) {
416
+ if (this.pendingActiveTabId === tab.id)
417
+ this.pendingActiveTabId = undefined;
418
+ this.removeTab(tab.id);
419
+ this.activeTabId = previousTabId;
420
+ await this.disposeRuntimeIfOrphan(newRuntime);
421
+ this.syncActiveTabFromRuntime({ save: false, force: true });
422
+ return;
423
+ }
300
424
  const existingTab = this.findTabForSession(newRuntime.session);
301
425
  const targetTab = existingTab && existingTab.id !== tab.id ? existingTab : tab;
302
426
  if (targetTab !== tab)
@@ -305,7 +429,6 @@ export class AppTabsController {
305
429
  this.pendingActiveTabId = targetTab.id;
306
430
  this.clearTabAttention(targetTab);
307
431
  this.updateTabFromSession(targetTab, newRuntime.session);
308
- this.setRuntimeForTab(targetTab.id, newRuntime);
309
432
  this.restoreInputState(targetTab.id);
310
433
  this.host.resetSessionView();
311
434
  this.restoreDeferredUserMessages(targetTab.id);
@@ -314,10 +437,42 @@ export class AppTabsController {
314
437
  try {
315
438
  await this.host.activateRuntime(newRuntime, { awaitExtensions: false });
316
439
  }
317
- finally {
440
+ catch (error) {
318
441
  if (this.pendingActiveTabId === targetTab.id)
319
442
  this.pendingActiveTabId = undefined;
443
+ if (targetTab === tab)
444
+ this.removeTab(tab.id);
445
+ this.activeTabId = previousTabId;
446
+ if (this.host.runtime() !== previousRuntime) {
447
+ try {
448
+ await this.host.activateRuntime(previousRuntime, { awaitExtensions: false });
449
+ }
450
+ catch {
451
+ // Keep the best available runtime and preserve the original activation error.
452
+ }
453
+ }
454
+ await this.disposeRuntimeIfOrphan(newRuntime);
455
+ if (this.adoptCurrentRuntimeAfterFailedRollback(targetTab, newRuntime)) {
456
+ this.host.setSessionStatus(newRuntime.session);
457
+ this.host.setSessionActivity(this.sessionActivity(newRuntime.session));
458
+ this.host.render();
459
+ throw error;
460
+ }
461
+ if (previousTabId)
462
+ this.restoreInputState(previousTabId);
463
+ this.host.closeMenusForTabSwitch?.();
464
+ this.host.resetSessionView();
465
+ if (previousTabId)
466
+ this.restoreDeferredUserMessages(previousTabId);
467
+ this.host.loadSessionHistory();
468
+ this.host.setSessionStatus(this.host.runtime()?.session);
469
+ this.host.setSessionActivity(this.sessionActivity(this.host.runtime()?.session));
470
+ this.host.render();
471
+ throw error;
320
472
  }
473
+ if (this.pendingActiveTabId === targetTab.id)
474
+ this.pendingActiveTabId = undefined;
475
+ this.setRuntimeForTab(targetTab.id, newRuntime);
321
476
  void this.saveTabs();
322
477
  this.scheduleProjectSessionRetention();
323
478
  this.host.resetSessionView();
@@ -333,7 +488,10 @@ export class AppTabsController {
333
488
  this.host.setSessionActivity(this.sessionActivity(newRuntime.session));
334
489
  this.host.render();
335
490
  }
336
- async openSessionInNewTab(sessionPath) {
491
+ openSessionInNewTab(sessionPath) {
492
+ return this.runLifecycleMutation((generation) => this.openSessionInNewTabMutation(sessionPath, generation));
493
+ }
494
+ async openSessionInNewTabMutation(sessionPath, generation) {
337
495
  if (this.pendingActiveTabId) {
338
496
  this.host.showToast("Wait for the tab to finish loading", "info");
339
497
  return false;
@@ -349,7 +507,7 @@ export class AppTabsController {
349
507
  const resolvedSessionPath = resolve(runtime.cwd, sessionPath);
350
508
  const existingTab = this.findTabBySessionPath(resolvedSessionPath);
351
509
  if (existingTab) {
352
- await this.switchToTab(existingTab.id);
510
+ await this.switchToTabMutation(existingTab.id, generation);
353
511
  return true;
354
512
  }
355
513
  this.cancelHistoryLoad();
@@ -358,6 +516,7 @@ export class AppTabsController {
358
516
  this.storeActiveDeferredUserMessages();
359
517
  const previousTabId = this.activeTabId;
360
518
  const previousRuntime = runtime;
519
+ const previousSession = runtime.session;
361
520
  this.host.setStatus("opening session tab");
362
521
  this.host.render();
363
522
  const tab = {
@@ -399,8 +558,16 @@ export class AppTabsController {
399
558
  this.host.render();
400
559
  return false;
401
560
  }
561
+ if (!this.isLifecycleOwner(generation, previousRuntime, previousSession)) {
562
+ if (this.pendingActiveTabId === tab.id)
563
+ this.pendingActiveTabId = undefined;
564
+ this.removeTab(tab.id);
565
+ this.activeTabId = previousTabId;
566
+ await this.disposeRuntimeIfOrphan(newRuntime);
567
+ this.syncActiveTabFromRuntime({ save: false, force: true });
568
+ return false;
569
+ }
402
570
  this.updateTabFromSession(tab, newRuntime.session);
403
- this.setRuntimeForTab(tab.id, newRuntime);
404
571
  this.host.render();
405
572
  try {
406
573
  await this.host.activateRuntime(newRuntime, { awaitExtensions: false });
@@ -420,7 +587,14 @@ export class AppTabsController {
420
587
  // Keep the best available runtime below and surface the switch failure.
421
588
  }
422
589
  }
423
- void this.host.disposeRuntime(newRuntime);
590
+ await this.disposeRuntimeIfOrphan(newRuntime);
591
+ if (this.adoptCurrentRuntimeAfterFailedRollback(tab, newRuntime)) {
592
+ this.host.showToast("Could not initialize session tab", "warning");
593
+ this.host.setSessionStatus(newRuntime.session);
594
+ this.host.setSessionActivity(this.sessionActivity(newRuntime.session));
595
+ this.host.render();
596
+ return false;
597
+ }
424
598
  this.host.showToast("Could not open session tab", "warning");
425
599
  this.host.resetSessionView();
426
600
  if (previousTabId)
@@ -440,10 +614,13 @@ export class AppTabsController {
440
614
  this.restoreInputState(tab.id);
441
615
  void this.saveTabs();
442
616
  this.scheduleTabPrewarm();
443
- await this.loadActiveSessionHistory(newRuntime);
617
+ await this.loadActiveSessionHistory(newRuntime, generation);
444
618
  return true;
445
619
  }
446
- async forkSessionEntryInNewTab(entryId) {
620
+ forkSessionEntryInNewTab(entryId) {
621
+ return this.runLifecycleMutation((generation) => this.forkSessionEntryInNewTabMutation(entryId, generation));
622
+ }
623
+ async forkSessionEntryInNewTabMutation(entryId, generation) {
447
624
  if (this.pendingActiveTabId) {
448
625
  this.host.showToast("Wait for the tab to finish loading", "info");
449
626
  return false;
@@ -467,6 +644,7 @@ export class AppTabsController {
467
644
  this.storeActiveDeferredUserMessages();
468
645
  const previousTabId = this.activeTabId;
469
646
  const previousRuntime = runtime;
647
+ const previousSession = runtime.session;
470
648
  this.host.setStatus("forking session tab");
471
649
  this.host.render();
472
650
  let forkRuntime;
@@ -484,22 +662,28 @@ export class AppTabsController {
484
662
  result = await forkRuntime.fork(entryId);
485
663
  }
486
664
  catch (error) {
487
- void this.host.disposeRuntime(forkRuntime);
665
+ await this.disposeRuntimeIfOrphan(forkRuntime);
488
666
  throw error;
489
667
  }
490
668
  if (result.cancelled) {
491
- void this.host.disposeRuntime(forkRuntime);
669
+ await this.disposeRuntimeIfOrphan(forkRuntime);
492
670
  this.host.addEntry({ id: createId("system"), kind: "system", text: "Fork cancelled." });
493
671
  this.host.setSessionStatus(previousRuntime.session);
494
672
  this.host.render();
495
673
  return false;
496
674
  }
675
+ if (generation !== this.lifecycleGeneration
676
+ || this.host.runtime() !== previousRuntime
677
+ || (forkRuntime !== previousRuntime && previousRuntime.session !== previousSession)) {
678
+ await this.disposeRuntimeIfOrphan(forkRuntime);
679
+ return false;
680
+ }
497
681
  const existingTab = this.findTabForSession(forkRuntime.session);
498
682
  if (existingTab) {
499
683
  if (result.selectedText)
500
684
  this.inputStatesByTabId.set(existingTab.id, this.inputStateFromText(result.selectedText));
501
- void this.host.disposeRuntime(forkRuntime);
502
- await this.switchToTab(existingTab.id);
685
+ await this.disposeRuntimeIfOrphan(forkRuntime);
686
+ await this.switchToTabMutation(existingTab.id, generation);
503
687
  this.host.showToast("Fork opened in existing tab", "success");
504
688
  return true;
505
689
  }
@@ -509,7 +693,6 @@ export class AppTabsController {
509
693
  this.pendingActiveTabId = tab.id;
510
694
  this.clearTabAttention(tab);
511
695
  this.updateTabFromSession(tab, forkRuntime.session);
512
- this.setRuntimeForTab(tab.id, forkRuntime);
513
696
  if (result.selectedText)
514
697
  this.inputStatesByTabId.set(tab.id, this.inputStateFromText(result.selectedText));
515
698
  this.restoreInputState(tab.id);
@@ -536,7 +719,14 @@ export class AppTabsController {
536
719
  // Keep the best available runtime below and surface the switch failure.
537
720
  }
538
721
  }
539
- void this.host.disposeRuntime(forkRuntime);
722
+ await this.disposeRuntimeIfOrphan(forkRuntime);
723
+ if (this.adoptCurrentRuntimeAfterFailedRollback(tab, forkRuntime)) {
724
+ this.host.showToast("Could not initialize fork tab", "warning");
725
+ this.host.setSessionStatus(forkRuntime.session);
726
+ this.host.setSessionActivity(this.sessionActivity(forkRuntime.session));
727
+ this.host.render();
728
+ return false;
729
+ }
540
730
  this.host.showToast("Could not open fork tab", "warning");
541
731
  this.host.resetSessionView();
542
732
  if (previousTabId)
@@ -556,14 +746,17 @@ export class AppTabsController {
556
746
  void this.saveTabs();
557
747
  this.scheduleProjectSessionRetention();
558
748
  this.scheduleTabPrewarm();
559
- await this.loadActiveSessionHistory(forkRuntime);
749
+ await this.loadActiveSessionHistory(forkRuntime, generation);
560
750
  this.host.addEntry({ id: createId("system"), kind: "system", text: `Forked from entry ${entryId} in a new tab.` });
561
751
  this.host.setSessionStatus(forkRuntime.session);
562
752
  this.host.showToast("Fork opened in new tab", "success");
563
753
  this.host.render();
564
754
  return true;
565
755
  }
566
- async switchToTab(tabId) {
756
+ switchToTab(tabId) {
757
+ return this.runLifecycleMutation((generation) => this.switchToTabMutation(tabId, generation));
758
+ }
759
+ async switchToTabMutation(tabId, generation) {
567
760
  if (this.pendingActiveTabId) {
568
761
  this.host.showToast("Wait for the tab to finish loading", "info");
569
762
  return;
@@ -584,6 +777,7 @@ export class AppTabsController {
584
777
  this.cancelHistoryLoad();
585
778
  const previousTabId = this.activeTabId;
586
779
  const previousRuntime = runtime;
780
+ const previousSession = runtime.session;
587
781
  const previousTargetActivity = target.activity;
588
782
  this.storeActiveRuntime(runtime);
589
783
  this.storeActiveSessionView();
@@ -603,6 +797,9 @@ export class AppTabsController {
603
797
  targetRuntime = await this.runtimeForTab(target);
604
798
  if (!targetRuntime)
605
799
  throw new Error("Could not load tab runtime");
800
+ if (!this.isLifecycleOwner(generation, previousRuntime, previousSession)) {
801
+ throw new Error("Tab ownership changed while loading the runtime");
802
+ }
606
803
  await this.host.activateRuntime(targetRuntime, { awaitExtensions: false });
607
804
  }
608
805
  catch {
@@ -652,12 +849,19 @@ export class AppTabsController {
652
849
  this.host.render();
653
850
  }
654
851
  else {
655
- await this.loadActiveSessionHistory(targetRuntime);
656
- this.tabIdsNeedingHistoryReload.delete(target.id);
852
+ const invalidationGeneration = this.historyInvalidationGenerationByTabId.get(target.id);
853
+ const loaded = await this.loadActiveSessionHistory(targetRuntime, generation);
854
+ if (loaded && this.historyInvalidationGenerationByTabId.get(target.id) === invalidationGeneration) {
855
+ this.tabIdsNeedingHistoryReload.delete(target.id);
856
+ this.historyInvalidationGenerationByTabId.delete(target.id);
857
+ }
657
858
  }
658
- this.scheduleDelayedHistoryReload(target.id, targetRuntime);
859
+ this.scheduleDelayedHistoryReload(target.id, targetRuntime, generation);
860
+ }
861
+ closeTab(tabId) {
862
+ return this.runLifecycleMutation((generation) => this.closeTabMutation(tabId, generation));
659
863
  }
660
- async closeTab(tabId) {
864
+ async closeTabMutation(tabId, generation) {
661
865
  if (this.pendingActiveTabId) {
662
866
  this.host.showToast("Wait for the tab to finish loading", "info");
663
867
  return;
@@ -667,7 +871,7 @@ export class AppTabsController {
667
871
  return;
668
872
  this.cancelHistoryLoad();
669
873
  if (this.tabItems.length <= 1) {
670
- await this.replaceLastTabWithNewSession(tabId);
874
+ await this.replaceLastTabWithNewSession(tabId, generation);
671
875
  return;
672
876
  }
673
877
  if (tabId !== this.activeTabId) {
@@ -685,7 +889,7 @@ export class AppTabsController {
685
889
  this.storeActiveDeferredUserMessages();
686
890
  this.stopAttentionBlinkIfIdle();
687
891
  if (tabRuntime)
688
- void this.host.disposeRuntime(tabRuntime);
892
+ await this.disposeRuntimeIfOrphan(tabRuntime);
689
893
  void this.saveTabs();
690
894
  this.host.render();
691
895
  return;
@@ -700,10 +904,41 @@ export class AppTabsController {
700
904
  }
701
905
  this.host.setStatus("closing tab");
702
906
  this.host.render();
907
+ const session = runtime.session;
703
908
  const nextRuntime = await this.runtimeForTab(nextTab);
704
909
  if (!nextRuntime)
705
910
  return;
706
- await this.host.activateRuntime(nextRuntime, { awaitExtensions: false });
911
+ if (!this.isLifecycleOwner(generation, runtime, session))
912
+ return;
913
+ this.pendingActiveTabId = nextTab.id;
914
+ try {
915
+ await this.host.activateRuntime(nextRuntime, { awaitExtensions: false });
916
+ }
917
+ catch {
918
+ if (this.pendingActiveTabId === nextTab.id)
919
+ this.pendingActiveTabId = undefined;
920
+ if (this.host.runtime() !== runtime) {
921
+ try {
922
+ await this.host.activateRuntime(runtime, { awaitExtensions: false });
923
+ }
924
+ catch {
925
+ // Keep the best available runtime below and surface the close failure.
926
+ }
927
+ }
928
+ if (this.host.runtime() === nextRuntime) {
929
+ this.activeTabId = nextTab.id;
930
+ this.clearTabAttention(nextTab);
931
+ this.setRuntimeForTab(nextTab.id, nextRuntime);
932
+ this.restoreInputState(nextTab.id);
933
+ }
934
+ this.host.showToast("Could not close tab", "warning");
935
+ this.host.setSessionStatus(this.host.runtime()?.session);
936
+ this.host.setSessionActivity(this.sessionActivity(this.host.runtime()?.session));
937
+ this.host.render();
938
+ return;
939
+ }
940
+ if (this.pendingActiveTabId === nextTab.id)
941
+ this.pendingActiveTabId = undefined;
707
942
  this.tabItems.splice(index, 1);
708
943
  this.deleteRuntimeForTab(tabId);
709
944
  this.inputStatesByTabId.delete(tabId);
@@ -716,23 +951,34 @@ export class AppTabsController {
716
951
  this.setRuntimeForTab(nextTab.id, nextRuntime);
717
952
  this.restoreInputState(nextTab.id);
718
953
  this.host.closeMenusForTabSwitch?.();
719
- void this.host.disposeRuntime(runtime);
954
+ await this.disposeRuntimeIfOrphan(runtime);
720
955
  void this.saveTabs();
721
956
  this.scheduleTabPrewarm();
722
- await this.loadActiveSessionHistory(nextRuntime);
957
+ await this.loadActiveSessionHistory(nextRuntime, generation);
723
958
  }
724
- async replaceLastTabWithNewSession(tabId) {
959
+ async replaceLastTabWithNewSession(tabId, generation) {
725
960
  const tab = this.tabItems.find((item) => item.id === tabId);
726
961
  if (!tab)
727
962
  return;
728
963
  const runtime = this.idleRuntime("new");
729
964
  if (!runtime)
730
965
  return;
966
+ const session = runtime.session;
731
967
  this.activeTabId = tab.id;
968
+ this.pendingActiveTabId = tab.id;
732
969
  this.host.setStatus("starting new session");
733
970
  this.host.render();
734
- await this.host.awaitCurrentSessionExtensions?.(runtime);
735
- const result = await runtime.newSession();
971
+ let result;
972
+ try {
973
+ await this.host.awaitCurrentSessionExtensions?.(runtime);
974
+ if (!this.isLifecycleOwner(generation, runtime, session))
975
+ return;
976
+ result = await runtime.newSession();
977
+ }
978
+ finally {
979
+ if (this.pendingActiveTabId === tab.id)
980
+ this.pendingActiveTabId = undefined;
981
+ }
736
982
  if (result.cancelled) {
737
983
  this.host.addEntry({ id: createId("system"), kind: "system", text: "New session cancelled." });
738
984
  this.host.setSessionStatus(runtime.session);
@@ -764,12 +1010,25 @@ export class AppTabsController {
764
1010
  this.scheduleProjectSessionRetention();
765
1011
  this.host.render();
766
1012
  }
767
- async loadActiveSessionHistory(runtime) {
768
- const generation = ++this.historyLoadGeneration;
769
- const isCancelled = () => generation !== this.historyLoadGeneration || this.host.runtime() !== runtime;
1013
+ async loadActiveSessionHistory(runtime, lifecycleGeneration) {
1014
+ const tabId = this.activeTabId;
1015
+ if (!tabId)
1016
+ return false;
1017
+ const session = runtime.session;
1018
+ const ownershipGeneration = this.runtimeOwnershipGenerationByTabId.get(tabId);
1019
+ const historyGeneration = ++this.historyLoadGeneration;
1020
+ const isCancelled = () => ownershipGeneration === undefined
1021
+ || !this.host.isRunning()
1022
+ || historyGeneration !== this.historyLoadGeneration
1023
+ || lifecycleGeneration !== this.lifecycleGeneration
1024
+ || this.pendingActiveTabId !== undefined
1025
+ || this.activeTabId !== tabId
1026
+ || this.host.runtime() !== runtime
1027
+ || !this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration);
1028
+ if (isCancelled())
1029
+ return false;
770
1030
  this.host.resetSessionView();
771
- if (this.activeTabId)
772
- this.restoreDeferredUserMessages(this.activeTabId);
1031
+ this.restoreDeferredUserMessages(tabId);
773
1032
  this.host.setStatus("loading session history");
774
1033
  this.host.setSessionActivity("thinking");
775
1034
  this.host.render();
@@ -782,12 +1041,15 @@ export class AppTabsController {
782
1041
  lazyOlderHistory: true,
783
1042
  });
784
1043
  if (!completed || isCancelled())
785
- return;
786
- this.restoreStoredScrollState(this.activeTabId);
787
- this.host.setSessionStatus(runtime.session);
1044
+ return false;
1045
+ this.restoreStoredScrollState(tabId);
1046
+ this.host.setSessionStatus(session);
1047
+ if (isCancelled())
1048
+ return false;
788
1049
  this.host.syncUserSessionEntryMetadata();
789
- this.host.setSessionActivity(this.sessionActivity(runtime.session));
1050
+ this.host.setSessionActivity(this.sessionActivity(session));
790
1051
  this.host.render();
1052
+ return true;
791
1053
  }
792
1054
  cancelHistoryLoad() {
793
1055
  this.historyLoadGeneration += 1;
@@ -851,17 +1113,39 @@ export class AppTabsController {
851
1113
  this.host.restoreScrollState(scrollState);
852
1114
  }
853
1115
  setRuntimeForTab(tabId, runtime) {
1116
+ const session = runtime.session;
1117
+ const existingRuntime = this.runtimesByTabId.get(tabId);
1118
+ const existingSubscription = this.runtimeSubscriptionsByTabId.get(tabId);
1119
+ if (existingRuntime === runtime && existingSubscription?.session === session)
1120
+ return;
1121
+ for (const [ownerTabId, ownedRuntime] of this.runtimesByTabId) {
1122
+ if (ownerTabId === tabId || ownedRuntime !== runtime)
1123
+ continue;
1124
+ this.releaseRuntimeOwnership(ownerTabId);
1125
+ }
1126
+ if (existingRuntime && existingRuntime !== runtime)
1127
+ this.queueOrphanRuntimeDisposal(existingRuntime);
1128
+ this.clearRuntimeRefreshTimers(tabId);
1129
+ this.clearHistoryReloadTimers(tabId);
1130
+ existingSubscription?.unsubscribe();
1131
+ this.runtimeSubscriptionsByTabId.delete(tabId);
854
1132
  this.runtimesByTabId.set(tabId, runtime);
1133
+ this.runtimeOwnershipGenerationByTabId.set(tabId, ++this.runtimeOwnershipGeneration);
855
1134
  this.observeRuntimeForTab(tabId, runtime);
856
1135
  }
857
1136
  deleteRuntimeForTab(tabId) {
858
- this.runtimesByTabId.delete(tabId);
1137
+ this.releaseRuntimeOwnership(tabId);
859
1138
  this.runtimeLoadsByTabId.delete(tabId);
860
1139
  this.sessionViewsByTabId.delete(tabId);
861
1140
  this.scrollStatesByTabId.delete(tabId);
1141
+ this.tabIdsNeedingHistoryReload.delete(tabId);
1142
+ this.historyInvalidationGenerationByTabId.delete(tabId);
1143
+ }
1144
+ releaseRuntimeOwnership(tabId) {
1145
+ this.runtimesByTabId.delete(tabId);
1146
+ this.runtimeOwnershipGenerationByTabId.delete(tabId);
862
1147
  this.clearRuntimeRefreshTimers(tabId);
863
1148
  this.clearHistoryReloadTimers(tabId);
864
- this.tabIdsNeedingHistoryReload.delete(tabId);
865
1149
  const subscription = this.runtimeSubscriptionsByTabId.get(tabId);
866
1150
  subscription?.unsubscribe();
867
1151
  this.runtimeSubscriptionsByTabId.delete(tabId);
@@ -877,24 +1161,64 @@ export class AppTabsController {
877
1161
  subscription.unsubscribe();
878
1162
  }
879
1163
  this.runtimeSubscriptionsByTabId.clear();
1164
+ this.runtimeOwnershipGenerationByTabId.clear();
880
1165
  }
881
1166
  observeRuntimeForTab(tabId, runtime) {
882
1167
  const existing = this.runtimeSubscriptionsByTabId.get(tabId);
883
1168
  if (existing?.runtime === runtime && existing.session === runtime.session)
884
1169
  return;
885
1170
  existing?.unsubscribe();
886
- const unsubscribe = runtime.session.subscribe((event) => {
1171
+ const session = runtime.session;
1172
+ const ownershipGeneration = this.runtimeOwnershipGenerationByTabId.get(tabId);
1173
+ if (ownershipGeneration === undefined)
1174
+ return;
1175
+ const unsubscribe = session.subscribe((event) => {
1176
+ if (!this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration))
1177
+ return;
887
1178
  if (this.shouldInvalidateCachedViewForRuntimeEvent(event)) {
888
1179
  this.tabIdsNeedingHistoryReload.add(tabId);
1180
+ this.historyInvalidationGenerationByTabId.set(tabId, ++this.historyInvalidationGeneration);
889
1181
  }
890
1182
  if (this.shouldScheduleDelayedSyncForRuntimeEvent(event)) {
891
- this.scheduleDelayedRuntimeSync(tabId, runtime);
1183
+ this.scheduleDelayedRuntimeSync(tabId, runtime, session, ownershipGeneration);
892
1184
  }
893
1185
  if (!this.shouldSyncTabFromRuntimeEvent(event))
894
1186
  return;
895
- this.syncTabFromObservedRuntime(tabId, runtime);
1187
+ this.syncTabFromObservedRuntime(tabId, runtime, session, ownershipGeneration);
896
1188
  });
897
- this.runtimeSubscriptionsByTabId.set(tabId, { runtime, session: runtime.session, unsubscribe });
1189
+ this.runtimeSubscriptionsByTabId.set(tabId, { runtime, session, unsubscribe });
1190
+ }
1191
+ isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration) {
1192
+ return this.runtimesByTabId.get(tabId) === runtime
1193
+ && runtime.session === session
1194
+ && this.runtimeOwnershipGenerationByTabId.get(tabId) === ownershipGeneration;
1195
+ }
1196
+ queueOrphanRuntimeDisposal(runtime) {
1197
+ if (this.orphanRuntimeDisposals.has(runtime))
1198
+ return;
1199
+ this.orphanRuntimeDisposals.add(runtime);
1200
+ queueMicrotask(() => {
1201
+ void this.disposeRuntimeIfOrphan(runtime).finally(() => {
1202
+ this.orphanRuntimeDisposals.delete(runtime);
1203
+ });
1204
+ });
1205
+ }
1206
+ async disposeRuntimeIfOrphan(runtime) {
1207
+ if (this.host.runtime() === runtime || [...this.runtimesByTabId.values()].includes(runtime))
1208
+ return;
1209
+ await this.host.disposeRuntime(runtime).catch(() => undefined);
1210
+ }
1211
+ adoptCurrentRuntimeAfterFailedRollback(tab, runtime) {
1212
+ if (this.host.runtime() !== runtime)
1213
+ return false;
1214
+ if (!this.tabItems.some((item) => item.id === tab.id))
1215
+ this.tabItems.push(tab);
1216
+ this.activeTabId = tab.id;
1217
+ this.clearTabAttention(tab);
1218
+ this.updateTabFromSession(tab, runtime.session);
1219
+ this.setRuntimeForTab(tab.id, runtime);
1220
+ this.restoreInputState(tab.id);
1221
+ return true;
898
1222
  }
899
1223
  shouldSyncTabFromRuntimeEvent(event) {
900
1224
  return event.type === "session_info_changed"
@@ -922,12 +1246,12 @@ export class AppTabsController {
922
1246
  || event.type === "turn_end"
923
1247
  || event.type === "compaction_end";
924
1248
  }
925
- scheduleDelayedRuntimeSync(tabId, runtime) {
1249
+ scheduleDelayedRuntimeSync(tabId, runtime, session, ownershipGeneration) {
926
1250
  this.clearRuntimeRefreshTimers(tabId);
927
1251
  for (const delayMs of [0, 100, 500, 1500, 3000]) {
928
1252
  const timer = setTimeout(() => {
929
1253
  this.runtimeRefreshTimersByTabId.get(tabId)?.delete(timer);
930
- this.syncTabFromObservedRuntime(tabId, runtime);
1254
+ this.syncTabFromObservedRuntime(tabId, runtime, session, ownershipGeneration);
931
1255
  }, delayMs);
932
1256
  timer.unref?.();
933
1257
  let timers = this.runtimeRefreshTimersByTabId.get(tabId);
@@ -954,12 +1278,16 @@ export class AppTabsController {
954
1278
  clearTimeout(timer);
955
1279
  this.historyReloadTimersByTabId.delete(tabId);
956
1280
  }
957
- scheduleDelayedHistoryReload(tabId, runtime) {
1281
+ scheduleDelayedHistoryReload(tabId, runtime, lifecycleGeneration) {
958
1282
  if (!this.tabIdsNeedingHistoryReload.has(tabId))
959
1283
  return;
960
1284
  if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined)
961
1285
  return;
962
- if (this.sessionActivity(runtime.session) === "running") {
1286
+ const session = runtime.session;
1287
+ const ownershipGeneration = this.runtimeOwnershipGenerationByTabId.get(tabId);
1288
+ if (ownershipGeneration === undefined || !this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration))
1289
+ return;
1290
+ if (this.sessionActivity(session) === "running") {
963
1291
  this.clearHistoryReloadTimers(tabId);
964
1292
  return;
965
1293
  }
@@ -967,7 +1295,7 @@ export class AppTabsController {
967
1295
  for (const delayMs of [150, 1000, 3000]) {
968
1296
  const timer = setTimeout(() => {
969
1297
  this.historyReloadTimersByTabId.get(tabId)?.delete(timer);
970
- void this.reloadActiveTabHistoryIfNeeded(tabId, runtime);
1298
+ void this.reloadActiveTabHistoryIfNeeded(tabId, runtime, session, ownershipGeneration, lifecycleGeneration);
971
1299
  }, delayMs);
972
1300
  timer.unref?.();
973
1301
  let timers = this.historyReloadTimersByTabId.get(tabId);
@@ -978,19 +1306,29 @@ export class AppTabsController {
978
1306
  timers.add(timer);
979
1307
  }
980
1308
  }
981
- async reloadActiveTabHistoryIfNeeded(tabId, runtime) {
1309
+ async reloadActiveTabHistoryIfNeeded(tabId, runtime, session, ownershipGeneration, lifecycleGeneration) {
1310
+ if (lifecycleGeneration !== this.lifecycleGeneration)
1311
+ return;
982
1312
  if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined || this.host.runtime() !== runtime)
983
1313
  return;
1314
+ if (!this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration))
1315
+ return;
984
1316
  if (!this.tabIdsNeedingHistoryReload.has(tabId))
985
1317
  return;
986
- if (this.sessionActivity(runtime.session) === "running")
1318
+ if (this.sessionActivity(session) === "running")
987
1319
  return;
988
- await this.loadActiveSessionHistory(runtime);
989
- if (tabId === this.activeTabId && this.host.runtime() === runtime) {
1320
+ const invalidationGeneration = this.historyInvalidationGenerationByTabId.get(tabId);
1321
+ const loaded = await this.loadActiveSessionHistory(runtime, lifecycleGeneration);
1322
+ if (loaded
1323
+ && this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration)
1324
+ && this.historyInvalidationGenerationByTabId.get(tabId) === invalidationGeneration) {
990
1325
  this.tabIdsNeedingHistoryReload.delete(tabId);
1326
+ this.historyInvalidationGenerationByTabId.delete(tabId);
991
1327
  }
992
1328
  }
993
- syncTabFromObservedRuntime(tabId, runtime) {
1329
+ syncTabFromObservedRuntime(tabId, runtime, session, ownershipGeneration) {
1330
+ if (!this.isRuntimeOwnedByTab(tabId, runtime, session, ownershipGeneration))
1331
+ return;
994
1332
  const tab = this.tabItems.find((item) => item.id === tabId);
995
1333
  if (!tab) {
996
1334
  this.deleteRuntimeForTab(tabId);
@@ -999,7 +1337,7 @@ export class AppTabsController {
999
1337
  const previousTitle = tab.title;
1000
1338
  const previousActivity = tab.activity;
1001
1339
  const previousSessionPath = tab.sessionPath;
1002
- this.updateTabFromSession(tab, runtime.session);
1340
+ this.updateTabFromSession(tab, session);
1003
1341
  if (tab.title === previousTitle && tab.activity === previousActivity && tab.sessionPath === previousSessionPath)
1004
1342
  return;
1005
1343
  if (tab.title !== previousTitle || tab.sessionPath !== previousSessionPath)
@@ -1046,9 +1384,15 @@ export class AppTabsController {
1046
1384
  this.host.restoreDeferredUserMessages?.(this.deferredUserMessagesByTabId.get(tabId) ?? []);
1047
1385
  }
1048
1386
  async runtimeForTab(tab) {
1387
+ if (!this.host.isRunning())
1388
+ return undefined;
1049
1389
  const existing = this.runtimesByTabId.get(tab.id);
1050
- if (existing)
1390
+ if (existing && tab.sessionPath && this.sessionPath(existing.session) === resolve(tab.sessionPath))
1051
1391
  return existing;
1392
+ if (existing) {
1393
+ this.releaseRuntimeOwnership(tab.id);
1394
+ await this.disposeRuntimeIfOrphan(existing);
1395
+ }
1052
1396
  const loading = this.runtimeLoadsByTabId.get(tab.id);
1053
1397
  if (loading)
1054
1398
  return await loading;
@@ -1059,9 +1403,13 @@ export class AppTabsController {
1059
1403
  const expectedPath = resolve(tab.sessionPath);
1060
1404
  const pending = (async () => {
1061
1405
  const runtime = await this.host.createRuntimeForSession(expectedPath);
1406
+ if (!this.host.isRunning()) {
1407
+ await this.disposeRuntimeIfOrphan(runtime);
1408
+ return undefined;
1409
+ }
1062
1410
  const liveTab = this.tabItems.find((item) => item.id === tab.id);
1063
1411
  if (!liveTab || !liveTab.sessionPath || resolve(liveTab.sessionPath) !== expectedPath) {
1064
- void this.host.disposeRuntime(runtime);
1412
+ await this.disposeRuntimeIfOrphan(runtime);
1065
1413
  return undefined;
1066
1414
  }
1067
1415
  this.setRuntimeForTab(tab.id, runtime);
@@ -1087,14 +1435,18 @@ export class AppTabsController {
1087
1435
  && resolve(tab.sessionPath) === normalizedPath));
1088
1436
  }
1089
1437
  replaceTabs(tabs, activeSessionPath) {
1438
+ const displacedRuntimes = [...new Set(this.runtimesByTabId.values())];
1090
1439
  this.tabItems.length = 0;
1091
1440
  this.runtimesByTabId.clear();
1441
+ this.runtimeLoadsByTabId.clear();
1092
1442
  this.clearRuntimeSubscriptions();
1093
1443
  this.inputStatesByTabId.clear();
1094
1444
  this.autoUserMessagesByTabId.clear();
1095
1445
  this.deferredUserMessagesByTabId.clear();
1096
1446
  this.sessionViewsByTabId.clear();
1097
1447
  this.scrollStatesByTabId.clear();
1448
+ this.tabIdsNeedingHistoryReload.clear();
1449
+ this.historyInvalidationGenerationByTabId.clear();
1098
1450
  const seen = new Set();
1099
1451
  for (const tab of tabs) {
1100
1452
  const sessionPath = tab.sessionPath ? resolve(tab.sessionPath) : undefined;
@@ -1117,6 +1469,8 @@ export class AppTabsController {
1117
1469
  ? this.tabItems.find((tab) => tab.sessionPath === activePath)?.id
1118
1470
  : this.tabItems[0]?.id;
1119
1471
  this.activeTabId ??= this.tabItems[0]?.id;
1472
+ for (const runtime of displacedRuntimes)
1473
+ this.queueOrphanRuntimeDisposal(runtime);
1120
1474
  }
1121
1475
  removeTab(tabId) {
1122
1476
  const index = this.tabItems.findIndex((tab) => tab.id === tabId);
@@ -1322,18 +1676,20 @@ export class AppTabsController {
1322
1676
  }));
1323
1677
  return new Set(entries.filter((entry) => entry !== undefined));
1324
1678
  }
1325
- scheduleRestoredTabTitleRefresh(sessionPaths) {
1679
+ scheduleRestoredTabTitleRefresh(sessionPaths, lifecycleGeneration) {
1326
1680
  if (sessionPaths.length === 0)
1327
1681
  return;
1328
1682
  setTimeout(() => {
1329
- void this.refreshRestoredTabTitles(sessionPaths);
1683
+ void this.refreshRestoredTabTitles(sessionPaths, lifecycleGeneration);
1330
1684
  }, 0).unref?.();
1331
1685
  }
1332
- async refreshRestoredTabTitles(sessionPaths) {
1686
+ async refreshRestoredTabTitles(sessionPaths, lifecycleGeneration) {
1333
1687
  const [titles, forkedSessionPaths] = await Promise.all([
1334
1688
  this.loadSessionTitles(sessionPaths),
1335
1689
  this.loadForkedSessionPaths(sessionPaths),
1336
1690
  ]);
1691
+ if (!this.host.isRunning() || lifecycleGeneration !== this.lifecycleGeneration)
1692
+ return;
1337
1693
  if (titles.size === 0 && forkedSessionPaths.size === 0)
1338
1694
  return;
1339
1695
  let changed = false;
@@ -1439,9 +1795,9 @@ export class AppTabsController {
1439
1795
  }
1440
1796
  return messages;
1441
1797
  }
1442
- async saveTabs() {
1798
+ saveTabs() {
1443
1799
  if (this.host.options.noSession)
1444
- return;
1800
+ return Promise.resolve();
1445
1801
  try {
1446
1802
  const tabs = [];
1447
1803
  const seen = new Set();
@@ -1477,7 +1833,7 @@ export class AppTabsController {
1477
1833
  tabs.push(persistedTab);
1478
1834
  }
1479
1835
  if (tabs.length === 0)
1480
- return;
1836
+ return Promise.resolve();
1481
1837
  const activePath = this.activeTab()?.sessionPath;
1482
1838
  const payload = JSON.stringify({
1483
1839
  version: TAB_STATE_VERSION,
@@ -1486,13 +1842,28 @@ export class AppTabsController {
1486
1842
  ...(activePath ? { activePath: resolve(activePath) } : {}),
1487
1843
  }, null, 2);
1488
1844
  const filePath = this.filePath();
1845
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
1846
+ const write = this.saveTabsWriteTail.then(async () => {
1847
+ await this.writeTabsSnapshot(filePath, tempPath, payload);
1848
+ }).catch(() => {
1849
+ // Tab state should never interrupt the terminal UI.
1850
+ });
1851
+ this.saveTabsWriteTail = write;
1852
+ return write;
1853
+ }
1854
+ catch {
1855
+ // Tab state should never interrupt the terminal UI.
1856
+ return Promise.resolve();
1857
+ }
1858
+ }
1859
+ async writeTabsSnapshot(filePath, tempPath, payload) {
1860
+ try {
1489
1861
  await mkdir(dirname(filePath), { recursive: true });
1490
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1491
1862
  await writeFile(tempPath, payload, "utf8");
1492
1863
  await rename(tempPath, filePath);
1493
1864
  }
1494
- catch {
1495
- // Tab state should never interrupt the terminal UI.
1865
+ finally {
1866
+ await unlink(tempPath).catch(() => undefined);
1496
1867
  }
1497
1868
  }
1498
1869
  filePath() {
@@ -1504,7 +1875,7 @@ export class AppTabsController {
1504
1875
  return join(getAgentDir(), "sessions", safePath);
1505
1876
  }
1506
1877
  scheduleProjectSessionRetention() {
1507
- if (this.host.options.noSession || this.maxProjectSessions() <= 0 || this.retentionCleanupScheduled || this.retentionCleanupRunning)
1878
+ if (!this.host.isRunning() || this.host.options.noSession || this.maxProjectSessions() <= 0 || this.retentionCleanupScheduled || this.retentionCleanupRunning)
1508
1879
  return;
1509
1880
  this.retentionCleanupScheduled = true;
1510
1881
  setTimeout(() => {
@@ -1513,7 +1884,7 @@ export class AppTabsController {
1513
1884
  }, 0);
1514
1885
  }
1515
1886
  scheduleTabPrewarm() {
1516
- if (this.host.options.noSession || this.prewarmScheduled || this.prewarmRunning)
1887
+ if (!this.host.isRunning() || this.host.options.noSession || this.prewarmScheduled || this.prewarmRunning)
1517
1888
  return;
1518
1889
  this.prewarmScheduled = true;
1519
1890
  setTimeout(() => {
@@ -1528,6 +1899,8 @@ export class AppTabsController {
1528
1899
  try {
1529
1900
  let warmed = 0;
1530
1901
  for (const tab of this.tabItems) {
1902
+ if (!this.host.isRunning())
1903
+ break;
1531
1904
  if (warmed >= BACKGROUND_PREWARM_TAB_LIMIT)
1532
1905
  break;
1533
1906
  if (tab.id === this.activeTabId || !tab.sessionPath)
@@ -1599,17 +1972,9 @@ export class AppTabsController {
1599
1972
  */
1600
1973
  async unlinkSessionAndDcpSidecar(sessionPath) {
1601
1974
  let sidecarPath;
1602
- try {
1603
- const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]?.trim();
1604
- if (firstLine) {
1605
- const parsed = JSON.parse(firstLine);
1606
- if (parsed.type === "session" && typeof parsed.id === "string" && parsed.id) {
1607
- sidecarPath = join(dirname(sessionPath), "dcp-state", parsed.id.replace(/[^a-zA-Z0-9._-]/g, "_") + ".json");
1608
- }
1609
- }
1610
- }
1611
- catch {
1612
- // Reading the session id is best-effort; proceed to unlink the file.
1975
+ const header = await readSessionHeader(sessionPath);
1976
+ if (header?.type === "session" && typeof header.id === "string" && header.id) {
1977
+ sidecarPath = join(dirname(sessionPath), "dcp-state", header.id.replace(/[^a-zA-Z0-9._-]/g, "_") + ".json");
1613
1978
  }
1614
1979
  try {
1615
1980
  await unlink(sessionPath);
@@ -1726,21 +2091,29 @@ async function readSessionTitleChunk(file, position, byteCount, options = {}) {
1726
2091
  return undefined;
1727
2092
  }
1728
2093
  async function readSessionHasParent(sessionPath) {
2094
+ const header = await readSessionHeader(sessionPath);
2095
+ return header?.type === "session" && typeof header.parentSession === "string";
2096
+ }
2097
+ async function readSessionHeader(sessionPath) {
1729
2098
  let file;
1730
2099
  try {
1731
2100
  file = await openFile(sessionPath, "r");
1732
- const buffer = Buffer.alloc(4096);
2101
+ const buffer = Buffer.alloc(SESSION_HEADER_SCAN_MAX_BYTES);
1733
2102
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
1734
2103
  if (bytesRead <= 0)
1735
- return false;
1736
- const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0]?.trim();
2104
+ return undefined;
2105
+ const content = buffer.subarray(0, bytesRead);
2106
+ const newlineIndex = content.indexOf(0x0a);
2107
+ if (newlineIndex < 0 && bytesRead === buffer.length)
2108
+ return undefined;
2109
+ const firstLine = content.subarray(0, newlineIndex >= 0 ? newlineIndex : bytesRead).toString("utf8").trim();
1737
2110
  if (!firstLine)
1738
- return false;
2111
+ return undefined;
1739
2112
  const parsed = JSON.parse(firstLine);
1740
- return isRecord(parsed) && parsed.type === "session" && typeof parsed.parentSession === "string";
2113
+ return isRecord(parsed) ? parsed : undefined;
1741
2114
  }
1742
2115
  catch {
1743
- return false;
2116
+ return undefined;
1744
2117
  }
1745
2118
  finally {
1746
2119
  await file?.close();