nterminal 1.3.25 → 1.3.26

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 (36) hide show
  1. package/README.md +2 -1
  2. package/dist/client/assets/MarkdownPreview-Dz-gstWx.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-BkgxZPQT.js → TranscriptMarkdownBody-wSYk8MdN.js} +1 -1
  4. package/dist/client/assets/{index-CT36T3oC.js → index-D7G-v5g3.js} +1 -1
  5. package/dist/client/assets/{index-BauayuRF.js → index-WcP7tpZM.js} +32 -32
  6. package/dist/client/index.html +1 -1
  7. package/dist/server/agent/agentRoutes.js +6 -1
  8. package/dist/server/agent/agentRoutes.js.map +1 -1
  9. package/dist/server/index.js +14 -3
  10. package/dist/server/index.js.map +1 -1
  11. package/dist/server/routes/activityRoutes.js +3 -2
  12. package/dist/server/routes/activityRoutes.js.map +1 -1
  13. package/dist/server/routes/agentManagementRoutes.js +19 -1
  14. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  15. package/dist/server/routes/terminalRoutes.d.ts +4 -1
  16. package/dist/server/routes/terminalRoutes.js +15 -1
  17. package/dist/server/routes/terminalRoutes.js.map +1 -1
  18. package/dist/server/routes/terminalWebSocket.js +4 -0
  19. package/dist/server/routes/terminalWebSocket.js.map +1 -1
  20. package/dist/server/storage/terminalLayoutCleanup.d.ts +4 -0
  21. package/dist/server/storage/terminalLayoutCleanup.js +103 -0
  22. package/dist/server/storage/terminalLayoutCleanup.js.map +1 -0
  23. package/dist/server/terminal/TerminalManager.d.ts +23 -1
  24. package/dist/server/terminal/TerminalManager.js +338 -77
  25. package/dist/server/terminal/TerminalManager.js.map +1 -1
  26. package/dist/server/terminal/terminalSessionStore.d.ts +27 -0
  27. package/dist/server/terminal/terminalSessionStore.js +124 -0
  28. package/dist/server/terminal/terminalSessionStore.js.map +1 -0
  29. package/dist/shared/layoutState.d.ts +2 -0
  30. package/dist/shared/layoutState.js +3 -0
  31. package/dist/shared/layoutState.js.map +1 -1
  32. package/dist/shared/protocol.d.ts +2 -0
  33. package/dist/shared/protocol.js.map +1 -1
  34. package/docs/configuration.md +53 -0
  35. package/package.json +1 -1
  36. package/dist/client/assets/MarkdownPreview-D7uk3GJX.js +0 -1
@@ -45,6 +45,7 @@ const BUSY_OUTPUT_WINDOW_MS = 2_500;
45
45
  // which is fast enough to feel responsive without spamming polls.
46
46
  const STATUS_POLL_INTERVAL_MS = 2_000;
47
47
  const RESTORE_RETRY_INTERVAL_MS = 2_000;
48
+ const RESTORE_RETRY_MAX_INTERVAL_MS = 30_000;
48
49
  const TRANSCRIPT_POLL_CONCURRENCY = 2;
49
50
  const TRANSCRIPT_SOURCE_MISS_GRACE_MS = 10_000;
50
51
  const TRANSCRIPT_SLOW_WARNING_MS = 3_000;
@@ -54,6 +55,8 @@ const HISTORY_PAGE_LINES = 1000;
54
55
  const HISTORY_PAGE_MAX_LINES = 5000;
55
56
  const MAX_PENDING_CONTROL_OPERATIONS = 256;
56
57
  const MAX_PENDING_CONTROL_BYTES = 16 * 1024 * 1024;
58
+ const RECOVERY_CHECKPOINT_INTERVAL_MS = 10_000;
59
+ const RECOVERY_SAVE_DELAY_MS = 1_000;
57
60
  export class TerminalManager {
58
61
  config;
59
62
  ptyAdapter;
@@ -92,6 +95,7 @@ export class TerminalManager {
92
95
  completionStates = new Map();
93
96
  statusPollTimer = null;
94
97
  restoreRetryTimer = null;
98
+ restoreRetryDelayMs = RESTORE_RETRY_INTERVAL_MS;
95
99
  restoreInflight = null;
96
100
  statusPollInflight = false;
97
101
  activeTranscriptWorkers = 0;
@@ -104,9 +108,18 @@ export class TerminalManager {
104
108
  terminalMutationGeneration = 0;
105
109
  shutdownRequested = false;
106
110
  disposed = false;
111
+ sessionStore;
112
+ recoveryReady = false;
113
+ recoveryTimer = null;
114
+ recoveryWriteTimer = null;
115
+ recoveryCheckpoint = null;
116
+ pendingSessionRestores = new Map();
117
+ creatingSessionIds = new Set();
118
+ pendingDeletionCancellations = new Map();
107
119
  constructor(config, ptyAdapter, options = {}) {
108
120
  this.config = config;
109
121
  this.ptyAdapter = ptyAdapter;
122
+ this.sessionStore = options.sessionStore;
110
123
  this.transcriptStore = new TranscriptStore(path.join(path.dirname(config.statePath), 'transcript-cache'));
111
124
  this.resolveCwd = options.resolveCwd ?? resolveProcessCwd;
112
125
  this.now = options.now ?? (() => new Date());
@@ -125,6 +138,14 @@ export class TerminalManager {
125
138
  if (typeof this.ptyAdapter.pollSessions === 'function') {
126
139
  this.startStatusPoller();
127
140
  }
141
+ if (this.sessionStore) {
142
+ this.recoveryTimer = setInterval(() => {
143
+ if (this.recoveryReady && !this.shutdownRequested && !this.disposed) {
144
+ void this.checkpointRecovery().catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }));
145
+ }
146
+ }, RECOVERY_CHECKPOINT_INTERVAL_MS);
147
+ this.recoveryTimer.unref?.();
148
+ }
128
149
  }
129
150
  startStatusPoller() {
130
151
  if (this.statusPollTimer)
@@ -393,6 +414,11 @@ export class TerminalManager {
393
414
  this.statusPollTimer = null;
394
415
  }
395
416
  this.clearRestoreRetryTimer();
417
+ if (this.recoveryTimer)
418
+ clearInterval(this.recoveryTimer);
419
+ if (this.recoveryWriteTimer)
420
+ clearTimeout(this.recoveryWriteTimer);
421
+ this.recoveryTimer = this.recoveryWriteTimer = null;
396
422
  this.transcriptQueue.length = 0;
397
423
  for (const record of this.terminals.values()) {
398
424
  record.transcriptRerun = false;
@@ -402,6 +428,7 @@ export class TerminalManager {
402
428
  }
403
429
  }
404
430
  async createTerminal(options = {}) {
431
+ this.assertRecoveryReady();
405
432
  if (this.shutdownRequested || this.terminalResetBlockers > 0) {
406
433
  throw new Error('terminal manager is not accepting new terminals');
407
434
  }
@@ -421,33 +448,57 @@ export class TerminalManager {
421
448
  const id = randomUUID();
422
449
  const timestamp = this.timestamp();
423
450
  const initialTitle = this.createTitle(cwd);
424
- const pty = await this.ptyAdapter.spawn({
425
- sessionId: id,
426
- shell: this.config.shell,
427
- cwd,
428
- cols,
429
- rows,
430
- env: terminalEnv(),
431
- // Pass the initial title in so persistent adapters (tmux) name the
432
- // session with it from the start — otherwise an immediate restart
433
- // before the operator interacted would show a placeholder pill.
434
- title: initialTitle
435
- });
436
- const record = {
437
- summary: {
438
- id,
439
- title: initialTitle,
440
- cwd,
441
- pid: pty.pid,
442
- cols,
443
- rows,
444
- createdAt: timestamp,
445
- updatedAt: timestamp,
446
- status: 'running',
447
- exitCode: null,
448
- ...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
449
- },
450
- pty,
451
+ const summary = {
452
+ id, title: initialTitle, cwd, pid: null, cols, rows,
453
+ createdAt: timestamp, updatedAt: timestamp,
454
+ status: 'running', exitCode: null,
455
+ ...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
456
+ };
457
+ this.creatingSessionIds.add(id);
458
+ try {
459
+ // An incomplete creation may be adopted if still alive, but must never
460
+ // recreate a missing shell after a failed spawn or an interrupted request.
461
+ await this.sessionStore?.prepareCreation(summary);
462
+ let pty;
463
+ try {
464
+ pty = await this.ptyAdapter.spawn({
465
+ sessionId: id, shell: this.config.shell, cwd, cols, rows,
466
+ env: terminalEnv(), title: initialTitle
467
+ });
468
+ }
469
+ catch (error) {
470
+ await this.forgetRecoverySession(id);
471
+ throw error;
472
+ }
473
+ const record = this.createRecord(summary);
474
+ this.wireUpPty(record, pty);
475
+ this.terminals.set(id, record);
476
+ try {
477
+ await this.sessionStore?.save([record.summary]);
478
+ }
479
+ catch (error) {
480
+ // Keep the real shell discoverable if its final checkpoint fails.
481
+ // Background persistence retries it; never replace or kill that shell.
482
+ this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
483
+ this.scheduleRestoreRetry();
484
+ throw error;
485
+ }
486
+ return cloneSummary(record.summary);
487
+ }
488
+ catch (error) {
489
+ // prepareCreation may have renamed its file before a later fsync failed.
490
+ if (this.sessionStore)
491
+ this.scheduleRestoreRetry();
492
+ throw error;
493
+ }
494
+ finally {
495
+ this.creatingSessionIds.delete(id);
496
+ }
497
+ }
498
+ createRecord(summary) {
499
+ return {
500
+ summary,
501
+ pty: null,
451
502
  output: new OutputBuffer(),
452
503
  subscribers: new Set(),
453
504
  dataDisposable: { dispose: () => undefined },
@@ -469,9 +520,6 @@ export class TerminalManager {
469
520
  closePromise: null,
470
521
  closing: false
471
522
  };
472
- this.wireUpPty(record, pty);
473
- this.terminals.set(id, record);
474
- return cloneSummary(record.summary);
475
523
  }
476
524
  // Repopulate the in-memory record map with any persistent sessions the
477
525
  // adapter is already holding for us — used at process start so that after
@@ -491,7 +539,7 @@ export class TerminalManager {
491
539
  return;
492
540
  }
493
541
  if (this.restoreInflight) {
494
- await this.restoreInflight;
542
+ await this.restoreInflight.catch(() => undefined);
495
543
  return;
496
544
  }
497
545
  const initialization = this.restoreSessionsUntracked();
@@ -501,6 +549,9 @@ export class TerminalManager {
501
549
  try {
502
550
  restored = await initialization;
503
551
  }
552
+ catch (error) {
553
+ this.reportDiagnostic({ event: 'terminal.restore_failed', error });
554
+ }
504
555
  finally {
505
556
  this.pendingInitializations.delete(initialization);
506
557
  if (this.restoreInflight === initialization) {
@@ -509,6 +560,7 @@ export class TerminalManager {
509
560
  }
510
561
  if (restored) {
511
562
  this.clearRestoreRetryTimer();
563
+ this.restoreRetryDelayMs = RESTORE_RETRY_INTERVAL_MS;
512
564
  }
513
565
  else {
514
566
  this.scheduleRestoreRetry();
@@ -527,62 +579,180 @@ export class TerminalManager {
527
579
  // A close/reset that completed while listExisting() was in flight makes
528
580
  // that catalog stale. Retry from a fresh adapter snapshot instead of
529
581
  // resurrecting a session the operator just deleted.
530
- if (restoreGeneration !== this.terminalMutationGeneration) {
582
+ if (restoreGeneration !== this.terminalMutationGeneration || this.shutdownRequested || this.disposed) {
531
583
  return false;
532
584
  }
585
+ const saved = new Map(this.sessionStore?.list().map(entry => [entry.id, entry]) ?? []);
586
+ const live = new Map(existing.map(session => [session.sessionId, session]));
533
587
  const now = this.timestamp();
534
588
  for (const session of existing) {
535
589
  const sessionId = session.sessionId;
536
- if (this.terminals.has(sessionId))
590
+ if (this.terminals.has(sessionId) || this.creatingSessionIds.has(sessionId))
537
591
  continue;
592
+ const recovery = saved.get(sessionId);
538
593
  // Use the adapter-provided title (tmux's window name) when the
539
594
  // operator had renamed the tab in a previous run — that's how a
540
595
  // human-readable label survives NTerminal restarts. Fall back to a
541
596
  // distinguishable placeholder when no name was set.
542
- const restoredTitle = normalizeTerminalTitle(session.title?.trim() || `Terminal ${sessionId.slice(0, 6)}`);
543
- this.terminals.set(sessionId, {
544
- summary: {
545
- id: sessionId,
546
- title: restoredTitle,
547
- cwd: session.cwd || this.config.workspaceRoot,
548
- pid: null,
549
- cols: 80,
550
- rows: 24,
551
- createdAt: now,
552
- updatedAt: now,
553
- status: 'running',
554
- exitCode: null,
597
+ const restoredTitle = normalizeTerminalTitle(session.title?.trim() || recovery?.title || `Terminal ${sessionId.slice(0, 6)}`);
598
+ this.terminals.set(sessionId, this.createRecord({
599
+ id: sessionId,
600
+ title: restoredTitle,
601
+ cwd: session.cwd || recovery?.cwd || this.config.workspaceRoot,
602
+ pid: null,
603
+ cols: recovery?.cols ?? 80,
604
+ rows: recovery?.rows ?? 24,
605
+ createdAt: recovery?.createdAt ?? now,
606
+ updatedAt: now,
607
+ status: 'running',
608
+ exitCode: null,
609
+ ...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
610
+ }));
611
+ // A failed deletion may have left its cancellation unwritten. Never
612
+ // destroy a surviving shell merely because an old intent is on disk.
613
+ if (recovery?.deleting)
614
+ this.pendingDeletionCancellations.set(sessionId, Symbol());
615
+ }
616
+ // Only startup-missing panes are eligible for creation. Keep placeholders
617
+ // and their subscribers across retries; ordinary exited shells stay exited.
618
+ if (!this.recoveryReady) {
619
+ for (const entry of saved.values()) {
620
+ if (entry.deleting || entry.creating || this.terminals.has(entry.id))
621
+ continue;
622
+ const record = this.createRecord({
623
+ id: entry.id, title: entry.title, cwd: entry.cwd, cols: entry.cols, rows: entry.rows,
624
+ createdAt: entry.createdAt, updatedAt: now, status: 'exited', exitCode: null, pid: null,
555
625
  ...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
556
- },
557
- pty: null, // null = persisted but no local client attached yet
558
- output: new OutputBuffer(),
559
- subscribers: new Set(),
560
- dataDisposable: { dispose: () => undefined },
561
- exitDisposable: { dispose: () => undefined },
562
- pauseCount: 0,
563
- lastOutputAt: 0,
564
- history: null,
565
- lastKernelBusy: false,
566
- transcriptGeneration: 0,
567
- transcriptRefreshState: 'idle',
568
- transcriptRerun: false,
569
- transcriptSourceMissingSince: null,
570
- cwdRefreshGeneration: 0,
571
- resizeGeneration: 0,
572
- controlTail: Promise.resolve(),
573
- pendingControlOperations: 0,
574
- pendingControlBytes: 0,
575
- pendingScroll: null,
576
- closePromise: null,
577
- closing: false
626
+ });
627
+ this.terminals.set(entry.id, record);
628
+ this.pendingSessionRestores.set(entry.id, record);
629
+ }
630
+ }
631
+ // The inventory is authoritative now, including every recoverable missing
632
+ // pane. Slow cwd/spawn/disk operations must not block healthy panes or HTTP.
633
+ this.recoveryReady = true;
634
+ let savedSuccessfully = true;
635
+ for (const entry of saved.values()) {
636
+ if (restoreGeneration !== this.terminalMutationGeneration)
637
+ return false;
638
+ if ((!entry.deleting && !entry.creating) || live.has(entry.id) || this.terminals.has(entry.id) ||
639
+ this.creatingSessionIds.has(entry.id))
640
+ continue;
641
+ if (!await this.forgetRecoverySession(entry.id))
642
+ savedSuccessfully = false;
643
+ }
644
+ await mapWithConcurrency([...this.pendingSessionRestores.values()], 4, record => this.retrySessionRestore(record, live.get(record.summary.id), restoreGeneration));
645
+ if (restoreGeneration !== this.terminalMutationGeneration)
646
+ return false;
647
+ try {
648
+ await this.saveRecoveryMetadata();
649
+ }
650
+ catch (error) {
651
+ savedSuccessfully = false;
652
+ this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error });
653
+ }
654
+ if (restoreGeneration !== this.terminalMutationGeneration)
655
+ return false;
656
+ return savedSuccessfully && this.pendingSessionRestores.size === 0 && this.pendingDeletionCancellations.size === 0;
657
+ }
658
+ hasCompleteInventory() {
659
+ return this.recoveryReady && !this.shutdownRequested && !this.disposed;
660
+ }
661
+ isRecoveryPending() {
662
+ return Boolean(this.sessionStore || this.ptyAdapter.listExisting) && !this.recoveryReady;
663
+ }
664
+ async retrySessionRestore(record, live, generation) {
665
+ const id = record.summary.id;
666
+ const canRestore = () => !this.shutdownRequested && !this.disposed && !this.terminalResetBlockers &&
667
+ generation === this.terminalMutationGeneration && !record.closing &&
668
+ this.terminals.get(id) === record && this.pendingSessionRestores.get(id) === record;
669
+ try {
670
+ await this.enqueueControl(record, async () => {
671
+ if (!canRestore())
672
+ return;
673
+ if (live) {
674
+ record.summary.status = 'running';
675
+ record.summary.exitCode = null;
676
+ record.summary.cwd = live.cwd || record.summary.cwd;
677
+ record.summary.updatedAt = this.timestamp();
678
+ this.pendingSessionRestores.delete(id);
679
+ if (record.subscribers.size > 0)
680
+ await this.ensureAttachedAsync(record);
681
+ }
682
+ else {
683
+ if (!(await statFile(record.summary.cwd)).isDirectory())
684
+ throw new Error('Saved terminal cwd is not a directory');
685
+ if (!canRestore())
686
+ return;
687
+ const pty = await this.ptyAdapter.spawn({
688
+ sessionId: id, shell: this.config.shell, cwd: record.summary.cwd,
689
+ cols: record.summary.cols, rows: record.summary.rows,
690
+ env: terminalEnv(), title: record.summary.title
691
+ });
692
+ this.pendingSessionRestores.delete(id);
693
+ if (this.disposed) {
694
+ pty.kill();
695
+ return;
696
+ }
697
+ // Close is serialized behind this spawn. If requested while it was
698
+ // pending, leave the new PTY on the same record for close to dispose.
699
+ this.wireUpPty(record, pty);
700
+ }
701
+ if (!record.closing)
702
+ this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
578
703
  });
579
704
  }
580
- return true;
705
+ catch (error) {
706
+ this.reportDiagnostic({ event: 'terminal.restore_failed', terminalId: id, error });
707
+ }
708
+ }
709
+ assertRecoveryReady() {
710
+ if (this.isRecoveryPending()) {
711
+ throw Object.assign(new Error('terminal_recovery_pending'), { statusCode: 503 });
712
+ }
713
+ }
714
+ async saveRecoveryMetadata() {
715
+ for (const [id, token] of this.pendingDeletionCancellations) {
716
+ await this.retryDeletionCancellation(id, token);
717
+ }
718
+ await this.sessionStore?.save([...this.terminals.values()].filter(record => !record.closing).map(record => record.summary));
719
+ }
720
+ scheduleRecoverySave() {
721
+ if (!this.sessionStore || !this.recoveryReady || this.recoveryWriteTimer || this.shutdownRequested || this.disposed)
722
+ return;
723
+ const timer = setTimeout(() => {
724
+ void this.saveRecoveryMetadata()
725
+ .catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }))
726
+ .finally(() => { if (this.recoveryWriteTimer === timer)
727
+ this.recoveryWriteTimer = null; });
728
+ }, RECOVERY_SAVE_DELAY_MS);
729
+ this.recoveryWriteTimer = timer;
730
+ timer.unref?.();
731
+ }
732
+ async checkpointRecovery() {
733
+ if (!this.sessionStore)
734
+ return;
735
+ if (this.recoveryCheckpoint)
736
+ return await this.recoveryCheckpoint;
737
+ const operation = (async () => {
738
+ await mapWithConcurrency([...this.terminals.values()], 4, record => this.refreshCwd(record));
739
+ await this.saveRecoveryMetadata();
740
+ })();
741
+ this.recoveryCheckpoint = operation;
742
+ try {
743
+ await operation;
744
+ }
745
+ finally {
746
+ if (this.recoveryCheckpoint === operation)
747
+ this.recoveryCheckpoint = null;
748
+ }
581
749
  }
582
750
  scheduleRestoreRetry() {
583
751
  if (this.restoreRetryTimer || this.shutdownRequested || this.disposed) {
584
752
  return;
585
753
  }
754
+ const delayMs = this.restoreRetryDelayMs;
755
+ this.restoreRetryDelayMs = Math.min(delayMs * 2, RESTORE_RETRY_MAX_INTERVAL_MS);
586
756
  this.restoreRetryTimer = setTimeout(() => {
587
757
  this.restoreRetryTimer = null;
588
758
  if (this.shutdownRequested || this.disposed) {
@@ -593,7 +763,7 @@ export class TerminalManager {
593
763
  return;
594
764
  }
595
765
  void this.restoreSessions();
596
- }, RESTORE_RETRY_INTERVAL_MS);
766
+ }, delayMs);
597
767
  this.restoreRetryTimer.unref?.();
598
768
  }
599
769
  clearRestoreRetryTimer() {
@@ -613,6 +783,11 @@ export class TerminalManager {
613
783
  record.summary.status = 'running';
614
784
  record.summary.exitCode = null;
615
785
  record.summary.updatedAt = this.timestamp();
786
+ record.summary.capabilities = {
787
+ ...record.summary.capabilities,
788
+ screenRedraw: typeof pty.redrawScreen === 'function',
789
+ screenRestore: typeof pty.redrawScreen === 'function' && typeof this.ptyAdapter.cancelScrollbackMode === 'function'
790
+ };
616
791
  record.dataDisposable = pty.onData((data) => {
617
792
  record.output.push(data);
618
793
  record.lastOutputAt = Date.now();
@@ -730,6 +905,7 @@ export class TerminalManager {
730
905
  this.wireUpPty(record, pty);
731
906
  }
732
907
  listTerminals() {
908
+ this.assertRecoveryReady();
733
909
  return [...this.terminals.values()].map((record) => cloneSummary(record.summary));
734
910
  }
735
911
  subscribeActivity(listener) {
@@ -1183,6 +1359,7 @@ export class TerminalManager {
1183
1359
  ? resolution : null;
1184
1360
  }
1185
1361
  getTerminal(id) {
1362
+ this.assertRecoveryReady();
1186
1363
  const record = this.terminals.get(id);
1187
1364
  return record ? cloneSummary(record.summary) : null;
1188
1365
  }
@@ -1409,6 +1586,7 @@ export class TerminalManager {
1409
1586
  // name). The adapter write and in-memory update share the terminal's control
1410
1587
  // queue so rename, input, scrollback, and close cannot overtake each other.
1411
1588
  async renameTerminal(id, title) {
1589
+ this.assertRecoveryReady();
1412
1590
  const record = this.terminals.get(id);
1413
1591
  if (!record)
1414
1592
  return { status: 'not_found' };
@@ -1443,6 +1621,14 @@ export class TerminalManager {
1443
1621
  // close later fails, memory and tmux must not diverge.
1444
1622
  record.summary.title = normalizedTitle;
1445
1623
  record.summary.updatedAt = this.timestamp();
1624
+ try {
1625
+ await this.sessionStore?.save([record.summary]);
1626
+ }
1627
+ catch (error) {
1628
+ // tmux already accepted the rename. Keep memory/UI consistent and
1629
+ // retry its recovery checkpoint rather than reporting a false rollback.
1630
+ this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
1631
+ }
1446
1632
  const terminal = cloneSummary(record.summary);
1447
1633
  this.publish(record, { type: 'terminal_updated', terminal });
1448
1634
  return { status: 'renamed', terminal };
@@ -1459,6 +1645,7 @@ export class TerminalManager {
1459
1645
  : null;
1460
1646
  }
1461
1647
  attachTerminal(id, subscriber, options = {}) {
1648
+ this.assertRecoveryReady();
1462
1649
  const record = this.terminals.get(id);
1463
1650
  if (!record || record.closing) {
1464
1651
  return null;
@@ -1474,6 +1661,7 @@ export class TerminalManager {
1474
1661
  return this.createAttachment(record, subscriber, options);
1475
1662
  }
1476
1663
  async attachTerminalAsync(id, subscriber, options = {}) {
1664
+ this.assertRecoveryReady();
1477
1665
  const record = this.terminals.get(id);
1478
1666
  if (!record || record.closing) {
1479
1667
  return null;
@@ -1737,6 +1925,7 @@ export class TerminalManager {
1737
1925
  return true;
1738
1926
  }
1739
1927
  async closeTerminal(id) {
1928
+ this.assertRecoveryReady();
1740
1929
  const record = this.terminals.get(id);
1741
1930
  if (!record) {
1742
1931
  return false;
@@ -1750,6 +1939,16 @@ export class TerminalManager {
1750
1939
  const queuedClose = this.enqueueControl(record, async () => {
1751
1940
  if (this.terminals.get(id) !== record)
1752
1941
  return false;
1942
+ try {
1943
+ await this.sessionStore?.setDeleting(id, true);
1944
+ }
1945
+ catch (error) {
1946
+ // Even an already-missing shell must keep its recovery entry if the
1947
+ // deletion intent could not be committed.
1948
+ await this.cancelFailedClose(record);
1949
+ this.reportDiagnostic({ event: 'terminal.close_failed', terminalId: id, error });
1950
+ return false;
1951
+ }
1753
1952
  if (this.ptyAdapter.destroySession) {
1754
1953
  let destroyed = false;
1755
1954
  try {
@@ -1764,7 +1963,10 @@ export class TerminalManager {
1764
1963
  }
1765
1964
  this.disposeRecord(record);
1766
1965
  this.terminals.delete(id);
1966
+ this.terminalMutationGeneration += 1;
1967
+ this.pendingSessionRestores.delete(id);
1767
1968
  this.publishRemovedActivity(record);
1969
+ await this.forgetRecoverySession(id);
1768
1970
  return true;
1769
1971
  }, 0, true);
1770
1972
  const close = queuedClose.catch(async (error) => {
@@ -1790,6 +1992,7 @@ export class TerminalManager {
1790
1992
  if (record.closing)
1791
1993
  return;
1792
1994
  record.closing = true;
1995
+ this.pendingDeletionCancellations.delete(record.summary.id);
1793
1996
  this.terminalMutationGeneration += 1;
1794
1997
  record.transcriptGeneration += 1;
1795
1998
  this.sealPendingScroll(record);
@@ -1797,28 +2000,70 @@ export class TerminalManager {
1797
2000
  this.cancelQueuedTranscriptRefresh(record);
1798
2001
  }
1799
2002
  async recoverOrFinalizeFailedClose(record) {
1800
- record.closing = false;
1801
2003
  if (record.pty) {
2004
+ await this.cancelFailedClose(record);
1802
2005
  return false;
1803
2006
  }
1804
2007
  const persistentState = await this.persistentSessionState(record.summary.id);
1805
2008
  if (persistentState === false) {
1806
2009
  this.disposeRecord(record);
1807
2010
  this.terminals.delete(record.summary.id);
2011
+ this.terminalMutationGeneration += 1;
2012
+ this.pendingSessionRestores.delete(record.summary.id);
1808
2013
  this.publishRemovedActivity(record);
2014
+ await this.forgetRecoverySession(record.summary.id);
1809
2015
  return true;
1810
2016
  }
1811
- record.summary.status = 'running';
1812
- record.summary.exitCode = null;
2017
+ if (!this.pendingSessionRestores.has(record.summary.id)) {
2018
+ record.summary.status = 'running';
2019
+ record.summary.exitCode = null;
2020
+ }
2021
+ await this.cancelFailedClose(record);
2022
+ return false;
2023
+ }
2024
+ async cancelFailedClose(record) {
2025
+ // Disk errors must not leave a surviving terminal permanently unwritable.
2026
+ record.closing = false;
1813
2027
  record.summary.updatedAt = this.timestamp();
1814
- if (record.subscribers.size > 0) {
2028
+ if (!record.pty && record.summary.status === 'running' && record.subscribers.size > 0) {
1815
2029
  void this.ensureAttachedAsync(record);
1816
2030
  }
1817
2031
  this.publish(record, {
1818
2032
  type: 'terminal_updated',
1819
2033
  terminal: cloneSummary(record.summary)
1820
2034
  });
1821
- return false;
2035
+ if (this.sessionStore) {
2036
+ const token = Symbol();
2037
+ this.pendingDeletionCancellations.set(record.summary.id, token);
2038
+ await this.retryDeletionCancellation(record.summary.id, token);
2039
+ }
2040
+ }
2041
+ async retryDeletionCancellation(id, token) {
2042
+ if (this.pendingDeletionCancellations.get(id) !== token || this.terminals.get(id)?.closing)
2043
+ return;
2044
+ try {
2045
+ // The store serializes writes. A later explicit delete is enqueued after
2046
+ // this cancellation, and invalidates its token before either can finish.
2047
+ await this.sessionStore?.setDeleting(id, false);
2048
+ if (this.pendingDeletionCancellations.get(id) === token)
2049
+ this.pendingDeletionCancellations.delete(id);
2050
+ }
2051
+ catch (error) {
2052
+ this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
2053
+ this.scheduleRestoreRetry();
2054
+ }
2055
+ }
2056
+ async forgetRecoverySession(id) {
2057
+ try {
2058
+ await this.sessionStore?.forget(id);
2059
+ return true;
2060
+ }
2061
+ catch (error) {
2062
+ // The durable deletion intent remains sufficient to prevent restoration.
2063
+ this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
2064
+ this.scheduleRestoreRetry();
2065
+ return false;
2066
+ }
1822
2067
  }
1823
2068
  async closeAll() {
1824
2069
  this.shutdownRequested = true;
@@ -1839,6 +2084,17 @@ export class TerminalManager {
1839
2084
  }
1840
2085
  await Promise.allSettled(pendingCloses);
1841
2086
  }
2087
+ await this.checkpointRecovery().catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }));
2088
+ // Requests can still arrive while the checkpoint awaits disk/cwd I/O.
2089
+ // Drain again, with no await between the last empty check and disposal.
2090
+ while (true) {
2091
+ const pendingCloses = [...this.terminals.values()]
2092
+ .map(record => record.closePromise)
2093
+ .filter((close) => close !== null);
2094
+ if (pendingCloses.length === 0)
2095
+ break;
2096
+ await Promise.allSettled(pendingCloses);
2097
+ }
1842
2098
  for (const record of this.terminals.values()) {
1843
2099
  this.disposeRecord(record);
1844
2100
  }
@@ -1975,11 +2231,14 @@ export class TerminalManager {
1975
2231
  }
1976
2232
  async refreshCwd(record) {
1977
2233
  const pty = record.pty;
1978
- if (!pty || record.summary.status !== 'running') {
2234
+ if (record.closing || record.summary.status !== 'running') {
1979
2235
  return;
1980
2236
  }
1981
2237
  const refreshGeneration = ++record.cwdRefreshGeneration;
1982
- const cwd = await this.safeResolveCwd(pty.pid);
2238
+ // Detached panes have no local tmux client. Read their backing shell PID
2239
+ // without attaching a client, so cwd changes over SSH are checkpointed too.
2240
+ const pid = pty?.pid ?? await this.ptyAdapter.getSessionPid?.(record.summary.id);
2241
+ const cwd = pid ? await this.safeResolveCwd(pid) : null;
1983
2242
  if (!cwd ||
1984
2243
  this.terminals.get(record.summary.id) !== record ||
1985
2244
  record.closing ||
@@ -2026,6 +2285,8 @@ export class TerminalManager {
2026
2285
  }).catch(() => undefined);
2027
2286
  }
2028
2287
  publish(record, message) {
2288
+ if (message.type === 'terminal_updated')
2289
+ this.scheduleRecoverySave();
2029
2290
  for (const subscriber of record.subscribers) {
2030
2291
  subscriber(message);
2031
2292
  }