termdock 1.4.114 → 1.4.115

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 (73) hide show
  1. package/dist/client/assets/{ModelPreview-D_ePHuIb.js → ModelPreview-TFTI5WuS.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-BZI0kiea.js → abnfDiagram-VRR7QNED-BhVwqb42.js} +1 -1
  3. package/dist/client/assets/{arc-Bf2XlwFP.js → arc-Cia3l37B.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DftQsQGl.js → architectureDiagram-ZJ3FMSHR-JIoYyeMu.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-ZUmEG-dB.js → blockDiagram-677ZJIJ3-BfYjZYyZ.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-sOeMyyUg.js → c4Diagram-LMCZKHZV-C4xr-lRb.js} +1 -1
  7. package/dist/client/assets/channel-CULuggLw.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-_arq_pE8.js → chunk-2Q5K7J3B-BkEbBIMC.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-DjbhCFxb.js → chunk-32BRIVSS-DN28GD24.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-lzD-6U8i.js → chunk-5VM5RSS4-BCTFfYEW.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-BqoUr3Kb.js → chunk-EX3LRPZG-4Tj_gqow.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-Y41_6y44.js → chunk-JWPE2WC7-DQ6Vqsm4.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-D_OcIm_v.js → chunk-MOJQB5TN-8gTQyH1j.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-jFCEdSIZ.js → chunk-RYQCIY6F-ufx4QMhz.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-BKhbGZNQ.js → chunk-V7JOEXUC-Cub2ypjw.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-CpVMtJAx.js → chunk-VR4S4FIN-yGRJdFR9.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-uhRjjTen.js → chunk-XXDRQBXY-Dxk9pYfc.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-Bl4UmP7x.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-Bl4UmP7x.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-D27whsXo.js → cose-bilkent-JH36ORCC-0zEuEsmg.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-DjPmiAlB.js → cynefin-VYW2F7L2-BEZJ_e9H.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-D4fIR8xL.js → cynefinDiagram-TSTJHNR4-CtNTAcJO.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-RE7TM3YY.js → dagre-VKFMJZFB-CzYfYyFy.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-c68o-BoX.js → diagram-FQU43EPY-CvF4CTvK.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-C9zQgh2h.js → diagram-G47NLZAW-GIm7u3AD.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-D9jATmfe.js → diagram-NH7WQ7WH-QYwMvBHK.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-DIg_X7Gm.js → diagram-OA4YK3LP-FJyrhUOo.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-512KTyNr.js → diagram-WEI45ONY-CSwtwLQv.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DIQduMdo.js → ebnfDiagram-CCIWWBDH-DGxm7mEr.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-DJaJOquE.js → erDiagram-Q63AITRT-C7t8PuDW.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-BleT-GIU.js → flowDiagram-23GEKE2U-BPnXE2MD.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-Bi9uo6X5.js → ganttDiagram-NO4QXBWP-CNlXSowD.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BfaYIRkh.js → gitGraphDiagram-IHSO6WYX-DOw_SYGf.js} +1 -1
  34. package/dist/client/assets/index-CsZ9UjAY.css +1 -0
  35. package/dist/client/assets/index-D-k2HJWc.js +457 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-Ck_MEUjM.js → infoDiagram-FWYZ7A6U-mp8cBgyz.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Ct3e_G59.js → ishikawaDiagram-FXEZZL3T-D_exGyO9.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BiXisFVi.js → journeyDiagram-5HDEW3XC-CQRTZIM8.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-CTAX2EZQ.js → kanban-definition-HUTT4EX6-DHzK5wpf.js} +1 -1
  40. package/dist/client/assets/{linear-gtzA009p.js → linear-COfasUKQ.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-BdXk5TA4.js → mermaid.core-CWF0ftUx.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-C7XVfI-0.js → mindmap-definition-LN4V7U3C-CrYH7yb_.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-f9OFTdAF.js → pegDiagram-2B236MQR-BXIPYBB7.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-CexwcGUT.js → pieDiagram-ENE6RG2P-D6YSwWrB.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-rEb1d4K9.js → quadrantDiagram-ABIIQ3AL-CNxUsWJH.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-uayZHK87.js → railroadDiagram-RFXS5EU6-Bk5SzdMg.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DWPla6v3.js → requirementDiagram-TGXJPOKE-BKWOPbLf.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-LPc9mQuH.js → sankeyDiagram-HTMAVEWB-DvvyztZU.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BgHvqqFK.js → sequenceDiagram-DBY2YBRQ-B9BXO3iW.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-da41k81P.js → sizeCapture-X5ZJPWSS-C_XPbltB.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-Cls0ucKe.js → stateDiagram-2N3HPSRC-DvkjLyxY.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BnVMlc0d.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-D0Pk83r7.js → swimlanes-5IMT3BWC-DDbqDzNu.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-Ck0HuzCJ.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-DYo44Edu.js → timeline-definition-FHXFAJF6-CPtaZOJq.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-CNhw73G2.js → vennDiagram-L72KCM5P-2_n0q45b.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-D2jq9_Nm.js → wardleyDiagram-EHGQE667-BlsAbk4A.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-3DLaKkZv.js → xychartDiagram-FW5EYKEG-gjWfqekv.js} +1 -1
  59. package/dist/client/index.html +2 -2
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/agent/registry.js +40 -0
  62. package/dist/server/routes/terminal.js +360 -81
  63. package/dist/server/utils/tmuxLifecycle.js +22 -0
  64. package/dist/server/utils/tmuxRecovery.js +47 -0
  65. package/package.json +1 -1
  66. package/runtime-manifest.json +2 -2
  67. package/dist/client/assets/channel-CcMim_aT.js +0 -1
  68. package/dist/client/assets/classDiagram-OUVF2IWQ-xZk0_-0_.js +0 -1
  69. package/dist/client/assets/classDiagram-v2-EOCWNBFH-xZk0_-0_.js +0 -1
  70. package/dist/client/assets/index-BKR1zlOs.css +0 -1
  71. package/dist/client/assets/index-BPNZub0Z.js +0 -457
  72. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BNqH077I.js +0 -1
  73. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CcVt_oli.js +0 -8
@@ -19,7 +19,7 @@ import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
19
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
20
20
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
21
21
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
22
- import { agentBySlug, buildResumeCommand, detectAgentFromCommand, listAgents, } from '../agent/registry.js';
22
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
23
23
  import { applyAgentEvent, agentEventMatchesCurrentAgent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
24
24
  import { canRestoreDeadAgentShell, normalizePersistedAgentResumeInfo, } from '../agent/resumePersistence.js';
25
25
  import { AgentResumeHistoryStore } from '../agent/resumeHistory.js';
@@ -29,6 +29,8 @@ import { CollaborationStore } from '../agent/collaborationStore.js';
29
29
  import { SessionSearchStore } from '../agent/sessionSearchStore.js';
30
30
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
31
31
  import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
32
+ import { TmuxLifecycleCoordinator } from '../utils/tmuxLifecycle.js';
33
+ import { detectTmuxRecoveryIncident, normalizeTmuxRecoveryIncident, } from '../utils/tmuxRecovery.js';
32
34
  import { checkPluginPackageUpdate, commitPreparedPlugin, preparePluginPackage, } from '../agent/pluginPackages.js';
33
35
  import { clearPluginAgents, registerPluginAgents } from '../agent/registry.js';
34
36
  import { notifyAgentTransition, notifyTerminalExit } from '../notifications/pushService.js';
@@ -119,6 +121,10 @@ let globalSessionState = { sessions: [], updatedAt: Date.now() };
119
121
  const GLOBAL_SESSION_STATE_FILE = `${TERMDOCK_DIR}/global-session-state.json`;
120
122
  const CLIENT_STATES_FILE = `${TERMDOCK_DIR}/client-states.json`; // 保留用于迁移
121
123
  const agentResumeHistory = new AgentResumeHistoryStore(`${TERMDOCK_DIR}/agent-resume-history.json`);
124
+ const TMUX_RECOVERY_STATE_FILE = `${TERMDOCK_DIR}/tmux-recovery-state.json`;
125
+ const tmuxLifecycle = new TmuxLifecycleCoordinator();
126
+ const intentionallyDeletingTmuxSessions = new Set();
127
+ let tmuxRecoveryState = { lastServerPid: null, incident: null };
122
128
  let persistGlobalStateTimer = null;
123
129
  let globalSessionStateWatcher = null;
124
130
  let globalSessionStateReloadTimer = null;
@@ -203,6 +209,7 @@ function getClientStateSemanticSignature(state, inventory) {
203
209
  clientCount: session.clientCount,
204
210
  })),
205
211
  tmuxStatus: inventory.tmuxStatus,
212
+ tmuxRecovery: inventory.tmuxRecovery,
206
213
  } : null,
207
214
  });
208
215
  }
@@ -398,6 +405,30 @@ async function loadGlobalSessionStateFromDisk() {
398
405
  console.warn('[session-persist] Failed to load global state:', getErrorMessage(error));
399
406
  }
400
407
  }
408
+ async function loadTmuxRecoveryStateFromDisk() {
409
+ try {
410
+ const data = await readJsonFileIfExists(TMUX_RECOVERY_STATE_FILE);
411
+ if (!data)
412
+ return;
413
+ tmuxRecoveryState = {
414
+ lastServerPid: typeof data.lastServerPid === 'number' && Number.isInteger(data.lastServerPid)
415
+ ? data.lastServerPid
416
+ : null,
417
+ incident: normalizeTmuxRecoveryIncident(data.incident),
418
+ };
419
+ }
420
+ catch (error) {
421
+ console.warn('[tmux-recovery] Failed to load recovery state:', getErrorMessage(error));
422
+ }
423
+ }
424
+ async function persistTmuxRecoveryState() {
425
+ try {
426
+ await writeJsonFile(TMUX_RECOVERY_STATE_FILE, tmuxRecoveryState);
427
+ }
428
+ catch (error) {
429
+ console.warn('[tmux-recovery] Failed to persist recovery state:', getErrorMessage(error));
430
+ }
431
+ }
401
432
  async function reloadGlobalSessionStateFromDisk(source) {
402
433
  try {
403
434
  const previous = JSON.stringify(globalSessionState);
@@ -477,6 +508,7 @@ function flushPersistAndExit() {
477
508
  try {
478
509
  fs.mkdirSync(TERMDOCK_DIR, { recursive: true });
479
510
  fs.writeFileSync(GLOBAL_SESSION_STATE_FILE, JSON.stringify(globalSessionState, null, 2), 'utf-8');
511
+ fs.writeFileSync(TMUX_RECOVERY_STATE_FILE, JSON.stringify(tmuxRecoveryState, null, 2), 'utf-8');
480
512
  }
481
513
  catch { /* best effort */ }
482
514
  }
@@ -484,7 +516,7 @@ process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsN
484
516
  process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
485
517
  // 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
486
518
  void (async () => {
487
- await loadGlobalSessionStateFromDisk();
519
+ await Promise.all([loadGlobalSessionStateFromDisk(), loadTmuxRecoveryStateFromDisk()]);
488
520
  await watchGlobalSessionStateFile();
489
521
  // pty-host adoption runs after the state load (its record upserts must not
490
522
  // be clobbered) and before pruning (surviving shell records' backends are
@@ -809,7 +841,7 @@ function buildRuntimeTmuxMetadata(input) {
809
841
  cwd,
810
842
  sessionName: input.tmuxSessionName,
811
843
  });
812
- return { program, cwd, label };
844
+ return { program, cwd, label, rawArgs: input.rawArgs ?? null };
813
845
  }
814
846
  function maybeRepairTmuxOptions(sessionName, current, next) {
815
847
  const currentSnapshot = {
@@ -978,9 +1010,8 @@ function getClientSessionView(inventory, frontendSessionId) {
978
1010
  return session;
979
1011
  }
980
1012
  async function ensureBackendSessionForRecord(req, record, options = {}) {
981
- const shouldOfferRecoveredAgent = record.mode === 'shell'
982
- && canRestoreDeadAgentShell(record)
983
- && (!record.backendSessionId || !terminalSessions.has(record.backendSessionId));
1013
+ const shouldOfferRecoveredAgent = Boolean(record.cwd && record.agentResume?.sessionId) && ((record.mode === 'shell' && (!record.backendSessionId || !terminalSessions.has(record.backendSessionId)))
1014
+ || (record.mode === 'tmux' && Boolean(tmuxRecoveryState.incident?.affectedSessionIds.includes(record.sessionId))));
984
1015
  if (record.backendSessionId) {
985
1016
  const existing = terminalSessions.get(record.backendSessionId);
986
1017
  if (existing) {
@@ -1785,6 +1816,85 @@ async function listLiveTmuxInventorySessions() {
1785
1816
  throw error;
1786
1817
  }
1787
1818
  }
1819
+ async function getTmuxServerPid() {
1820
+ try {
1821
+ const raw = (await runTmux(['display-message', '-p', '#{pid}'])).trim();
1822
+ const pid = Number.parseInt(raw, 10);
1823
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
1824
+ }
1825
+ catch (error) {
1826
+ if (isTmuxServerMissingMessage(getErrorMessage(error)))
1827
+ return null;
1828
+ throw error;
1829
+ }
1830
+ }
1831
+ function getTmuxRecoveryView() {
1832
+ const incident = tmuxRecoveryState.incident;
1833
+ if (!incident)
1834
+ return null;
1835
+ const affected = new Set(incident.affectedSessionIds);
1836
+ const sessions = globalSessionState.sessions.flatMap((record) => {
1837
+ if (!affected.has(record.sessionId) || record.mode !== 'tmux' || !record.tmuxSessionName
1838
+ || !record.cwd || !record.agentResume?.sessionId)
1839
+ return [];
1840
+ const agent = agentBySlug(record.agentResume.slug);
1841
+ if (!agent)
1842
+ return [];
1843
+ return [{
1844
+ sessionId: record.sessionId,
1845
+ name: record.name,
1846
+ tmuxSessionName: record.tmuxSessionName,
1847
+ cwd: record.cwd,
1848
+ agentSlug: agent.slug,
1849
+ agentDisplayName: agent.displayName,
1850
+ }];
1851
+ });
1852
+ return sessions.length > 0
1853
+ ? { id: incident.id, detectedAt: incident.detectedAt, sessions }
1854
+ : null;
1855
+ }
1856
+ async function observeTmuxServerGeneration(liveTmuxSessions) {
1857
+ let currentServerPid = null;
1858
+ try {
1859
+ currentServerPid = await getTmuxServerPid();
1860
+ }
1861
+ catch (error) {
1862
+ console.warn('[tmux-recovery] Failed to read tmux server pid:', getErrorMessage(error));
1863
+ }
1864
+ const liveSessionNames = new Set(liveTmuxSessions.map((session) => session.name));
1865
+ const candidates = globalSessionState.sessions.flatMap((record) => {
1866
+ if (record.mode !== 'tmux' || !record.tmuxSessionName)
1867
+ return [];
1868
+ const backend = record.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
1869
+ return [{
1870
+ sessionId: record.sessionId,
1871
+ tmuxSessionName: record.tmuxSessionName,
1872
+ resumable: Boolean(record.cwd && record.agentResume?.sessionId && !backend?.agent),
1873
+ }];
1874
+ });
1875
+ const incident = detectTmuxRecoveryIncident({
1876
+ previousServerPid: tmuxRecoveryState.lastServerPid,
1877
+ currentServerPid,
1878
+ candidates,
1879
+ liveSessionNames,
1880
+ intentionallyDeleting: intentionallyDeletingTmuxSessions,
1881
+ existingIncident: tmuxRecoveryState.incident,
1882
+ });
1883
+ let changed = false;
1884
+ if (incident !== tmuxRecoveryState.incident) {
1885
+ tmuxRecoveryState.incident = incident;
1886
+ changed = true;
1887
+ if (incident) {
1888
+ console.error(`[tmux-recovery] shared server loss detected; ${incident.affectedSessionIds.length} Agent session(s) can be restored`);
1889
+ }
1890
+ }
1891
+ if (currentServerPid !== null && currentServerPid !== tmuxRecoveryState.lastServerPid) {
1892
+ tmuxRecoveryState.lastServerPid = currentServerPid;
1893
+ changed = true;
1894
+ }
1895
+ if (changed)
1896
+ await persistTmuxRecoveryState();
1897
+ }
1788
1898
  async function buildSessionInventory() {
1789
1899
  const tmuxStatus = await getTmuxStatus();
1790
1900
  let liveTmuxSessions = [];
@@ -1800,6 +1910,7 @@ async function buildSessionInventory() {
1800
1910
  liveTmuxSessions = [];
1801
1911
  }
1802
1912
  }
1913
+ await observeTmuxServerGeneration(liveTmuxSessions);
1803
1914
  const refreshedTmuxSessions = await Promise.all(liveTmuxSessions.map(async (tmux) => {
1804
1915
  if (!isTermdockManagedTmuxSession(tmux)) {
1805
1916
  return tmux;
@@ -1809,6 +1920,7 @@ async function buildSessionInventory() {
1809
1920
  if (!metadata) {
1810
1921
  return tmux;
1811
1922
  }
1923
+ backfillAgentResumeFromTmuxProcess(tmux.name, metadata.rawArgs);
1812
1924
  maybeRepairTmuxOptions(tmux.name, tmux, metadata);
1813
1925
  return {
1814
1926
  ...tmux,
@@ -1902,6 +2014,9 @@ async function buildSessionInventory() {
1902
2014
  connected: backendLive,
1903
2015
  live,
1904
2016
  restorable: !backendLive && ((session.mode === 'tmux' && tmuxLive)
2017
+ || (session.mode === 'tmux' && Boolean(tmuxRecoveryState.incident?.affectedSessionIds.includes(session.sessionId)
2018
+ && session.cwd
2019
+ && session.agentResume?.sessionId))
1905
2020
  || (session.mode === 'shell' && canRestoreDeadAgentShell(session))),
1906
2021
  activeProgram,
1907
2022
  cwd,
@@ -1956,6 +2071,7 @@ async function buildSessionInventory() {
1956
2071
  clientSessions,
1957
2072
  tmuxSessions,
1958
2073
  tmuxStatus,
2074
+ tmuxRecovery: getTmuxRecoveryView(),
1959
2075
  updatedAt: Date.now(),
1960
2076
  };
1961
2077
  }
@@ -2299,6 +2415,40 @@ function splitCommandToArgv(command) {
2299
2415
  .map((t) => t.replace(/^["']+|["']+$/g, ''))
2300
2416
  .filter((t) => t.length > 0);
2301
2417
  }
2418
+ /**
2419
+ * Persist native resume metadata even when no browser PTY is currently
2420
+ * attached. The tmux pane remains authoritative in that state, and its
2421
+ * foreground argv is enough to recover an already-resumed Agent session.
2422
+ */
2423
+ function backfillAgentResumeFromTmuxProcess(tmuxSessionName, rawArgs) {
2424
+ if (!rawArgs)
2425
+ return;
2426
+ const agent = detectAgentFromCommand(rawArgs, agentCustomCommands());
2427
+ if (!agent)
2428
+ return;
2429
+ const argv = splitCommandToArgv(rawArgs);
2430
+ const sessionId = inferResumeSessionId(agent, argv);
2431
+ if (!sessionId)
2432
+ return;
2433
+ const record = globalSessionState.sessions.find((candidate) => candidate.mode === 'tmux' && candidate.tmuxSessionName === tmuxSessionName);
2434
+ if (!record)
2435
+ return;
2436
+ if (record.agentResume?.slug === agent.slug
2437
+ && record.agentResume.sessionId === sessionId
2438
+ && JSON.stringify(record.agentResume.launchArgv ?? null) === JSON.stringify(argv)) {
2439
+ return;
2440
+ }
2441
+ upsertGlobalSessionRecord({
2442
+ ...record,
2443
+ agentResume: {
2444
+ slug: agent.slug,
2445
+ sessionId,
2446
+ launchArgv: argv,
2447
+ updatedAt: Date.now(),
2448
+ },
2449
+ });
2450
+ schedulePersistGlobalState();
2451
+ }
2302
2452
  /** The coding agent running in this session's foreground, per activeProgram. */
2303
2453
  function detectSessionAgent(session) {
2304
2454
  const ap = session.activeProgram;
@@ -2641,18 +2791,29 @@ function syncAgentIdentity(sessionId, session) {
2641
2791
  const argv = splitCommandToArgv(session.activeProgram.rawArgs);
2642
2792
  if (argv.length > 0) {
2643
2793
  const sess = session.agentSession;
2794
+ const inferredSessionId = inferResumeSessionId(detected, argv);
2795
+ if (sess && !sess.sessionId && inferredSessionId) {
2796
+ sess.sessionId = inferredSessionId;
2797
+ }
2644
2798
  if (sess && sess.launchArgv === null) {
2645
2799
  sess.launchArgv = argv;
2646
2800
  persistAgentResumeBinding(sessionId, session);
2647
2801
  broadcastAgentStatus(sessionId, session);
2648
2802
  }
2649
- else if (!sess) {
2650
- // No session state yet (no hook events seen): remember the argv on a
2651
- // pending field via the resume record so an exited agent can still be
2652
- // resumed with its flags even if hooks never fired.
2803
+ else if (!sess && inferredSessionId) {
2804
+ // The session-start hook may predate a server/tmux reattach. Recover
2805
+ // both the native id and flags from a live `agent resume <id>` argv.
2653
2806
  const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
2654
- if (record?.agentResume && record.agentResume.slug === detected.slug && !record.agentResume.launchArgv) {
2655
- upsertGlobalSessionRecord({ ...record, agentResume: { ...record.agentResume, launchArgv: argv } });
2807
+ const nextResume = {
2808
+ slug: detected.slug,
2809
+ sessionId: inferredSessionId,
2810
+ launchArgv: argv,
2811
+ updatedAt: Date.now(),
2812
+ };
2813
+ if (record && (record.agentResume?.slug !== nextResume.slug
2814
+ || record.agentResume?.sessionId !== nextResume.sessionId
2815
+ || JSON.stringify(record.agentResume?.launchArgv ?? null) !== JSON.stringify(nextResume.launchArgv))) {
2816
+ upsertGlobalSessionRecord({ ...record, agentResume: nextResume });
2656
2817
  schedulePersistGlobalState();
2657
2818
  }
2658
2819
  }
@@ -3117,7 +3278,12 @@ async function resolveLiveTmuxMetadata(tmuxSessionName) {
3117
3278
  const fallback = getActiveProgramFromTmuxLayout(layout);
3118
3279
  const program = resolved?.command ?? fallback?.command ?? null;
3119
3280
  const cwd = getCwdFromTmuxLayout(layout);
3120
- return buildRuntimeTmuxMetadata({ tmuxSessionName, program, cwd });
3281
+ return buildRuntimeTmuxMetadata({
3282
+ tmuxSessionName,
3283
+ program,
3284
+ cwd,
3285
+ rawArgs: resolved?.rawArgs ?? null,
3286
+ });
3121
3287
  }
3122
3288
  // ── label builder (mirrors the frontend `getSessionDisplayLines` semantics) ──
3123
3289
  //
@@ -3446,11 +3612,14 @@ async function ensureManagedTmuxSessionReady(sessionName) {
3446
3612
  console.warn(`Failed to enable agent-hook plumbing for ${sessionName}: ${getErrorMessage(error)}`);
3447
3613
  }
3448
3614
  }
3449
- async function prepareManagedTmuxSession(sessionName, cwd) {
3615
+ async function prepareManagedTmuxSessionUnlocked(sessionName, cwd) {
3450
3616
  await ensureTmuxSessionExists(sessionName, cwd);
3451
3617
  await ensureSharedTmuxServerReady();
3452
3618
  await ensureManagedTmuxSessionReady(sessionName);
3453
3619
  }
3620
+ async function prepareManagedTmuxSession(sessionName, cwd) {
3621
+ await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
3622
+ }
3454
3623
  async function getTmuxLayout(sessionName) {
3455
3624
  const sessionInfoRaw = (await runTmux([
3456
3625
  'display-message',
@@ -4653,84 +4822,182 @@ router.get('/tmux/status', async (_req, res) => {
4653
4822
  const status = await getTmuxStatus();
4654
4823
  res.json(status);
4655
4824
  });
4656
- router.delete('/tmux/sessions/:name', async (req, res) => {
4657
- const rawName = typeof req.params.name === 'string' ? req.params.name.trim() : '';
4658
- if (!rawName) {
4659
- return res.status(400).json({ error: 'tmux session name is required' });
4660
- }
4661
- // tmux session names cannot contain ':' or '.'; reject anything that doesn't look right.
4662
- if (/[:.\s]/.test(rawName)) {
4663
- return res.status(400).json({ error: 'invalid tmux session name' });
4664
- }
4665
- // Find local terminal sessions wired to this tmux session. They must be
4666
- // detached before kill-session: tmux can otherwise process a queued focus
4667
- // event after clearing the client's session pointer and crash the whole
4668
- // shared server (tmux/tmux#5022), taking every unrelated pane with it.
4669
- const affectedSessionIds = [];
4670
- for (const [sessionId, session] of terminalSessions.entries()) {
4671
- if (session.mode === 'tmux' && session.tmuxSessionName === rawName) {
4672
- session.focusTrackingRequested = false;
4673
- session.focusAggregation.focusedClients.clear();
4674
- session.focusAggregation.effectiveFocused = false;
4675
- affectedSessionIds.push(sessionId);
4825
+ router.post('/tmux/recovery/restore-all', async (_req, res) => {
4826
+ const incident = tmuxRecoveryState.incident;
4827
+ if (!incident)
4828
+ return res.status(404).json({ error: 'no tmux recovery is pending' });
4829
+ const outcomes = [];
4830
+ for (const frontendSessionId of incident.affectedSessionIds) {
4831
+ const record = globalSessionState.sessions.find((session) => session.sessionId === frontendSessionId);
4832
+ if (!record || record.mode !== 'tmux' || !record.tmuxSessionName || !record.cwd
4833
+ || !record.agentResume?.sessionId) {
4834
+ outcomes.push({ sessionId: frontendSessionId, restored: false, error: 'recovery metadata is incomplete' });
4835
+ continue;
4836
+ }
4837
+ const agent = agentBySlug(record.agentResume.slug);
4838
+ const command = agent
4839
+ ? buildResumeCommand(agent, record.agentResume.sessionId, record.agentResume.launchArgv)
4840
+ : null;
4841
+ if (!agent || !command) {
4842
+ outcomes.push({ sessionId: frontendSessionId, restored: false, error: 'Agent does not support resume' });
4843
+ continue;
4844
+ }
4845
+ const target = {
4846
+ slug: agent.slug,
4847
+ nativeSessionId: record.agentResume.sessionId,
4848
+ command,
4849
+ };
4850
+ const activeBackend = findActiveAgentResumeOwner('', target);
4851
+ const activeWriterPid = activeBackend ? null : await findExternalCodexWriter(target);
4852
+ if (activeBackend || activeWriterPid) {
4853
+ outcomes.push({ sessionId: frontendSessionId, restored: true });
4854
+ continue;
4855
+ }
4856
+ try {
4857
+ await tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
4858
+ await prepareManagedTmuxSessionUnlocked(record.tmuxSessionName, record.cwd);
4859
+ const metadata = await resolveLiveTmuxMetadata(record.tmuxSessionName);
4860
+ const foreground = metadata?.program?.toLowerCase() ?? null;
4861
+ if (foreground && !shellNamesBackend.has(foreground)) {
4862
+ throw new Error(`pane is busy with ${foreground}`);
4863
+ }
4864
+ await runTmux(['send-keys', '-t', record.tmuxSessionName, '-l', '--', command]);
4865
+ await runTmux(['send-keys', '-t', record.tmuxSessionName, 'Enter']);
4866
+ });
4867
+ outcomes.push({ sessionId: frontendSessionId, restored: true });
4868
+ }
4869
+ catch (error) {
4870
+ outcomes.push({ sessionId: frontendSessionId, restored: false, error: getErrorMessage(error) });
4676
4871
  }
4677
4872
  }
4678
- try {
4679
- // detach-client -s completes only after tmux has removed clients from the
4680
- // live session, closing the NULL-session focus race before teardown.
4681
- await runTmux(['detach-client', '-s', rawName]);
4682
- }
4683
- catch (error) {
4684
- const errorMessage = getErrorMessage(error);
4685
- if (isTmuxSessionMissingMessage(errorMessage)) {
4873
+ const restoredIds = new Set(outcomes.filter((outcome) => outcome.restored).map((outcome) => outcome.sessionId));
4874
+ const remaining = incident.affectedSessionIds.filter((sessionId) => !restoredIds.has(sessionId));
4875
+ tmuxRecoveryState.incident = remaining.length > 0
4876
+ ? { ...incident, affectedSessionIds: remaining }
4877
+ : null;
4878
+ await persistTmuxRecoveryState();
4879
+ latestSessionInventoryAt = 0;
4880
+ broadcastClientState();
4881
+ res.status(remaining.length > 0 ? 207 : 200).json({
4882
+ restored: restoredIds.size,
4883
+ failed: remaining.length,
4884
+ outcomes,
4885
+ });
4886
+ });
4887
+ router.delete('/tmux/recovery', async (_req, res) => {
4888
+ tmuxRecoveryState.incident = null;
4889
+ await persistTmuxRecoveryState();
4890
+ latestSessionInventoryAt = 0;
4891
+ broadcastClientState();
4892
+ res.status(204).send();
4893
+ });
4894
+ async function destroyTmuxSessionSafely(rawName) {
4895
+ return tmuxLifecycle.run(`destroy:${rawName}`, async () => {
4896
+ intentionallyDeletingTmuxSessions.add(rawName);
4897
+ try {
4898
+ const affectedSessionIds = [];
4899
+ for (const [sessionId, session] of terminalSessions.entries()) {
4900
+ if (session.mode === 'tmux' && session.tmuxSessionName === rawName) {
4901
+ session.focusTrackingRequested = false;
4902
+ session.focusAggregation.focusedClients.clear();
4903
+ session.focusAggregation.effectiveFocused = false;
4904
+ affectedSessionIds.push(sessionId);
4905
+ }
4906
+ }
4907
+ let alreadyGone = false;
4908
+ try {
4909
+ // Reassert the safe global setting immediately before teardown in case
4910
+ // a user tmux command or config reload turned it back on at runtime.
4911
+ await disableTmuxFocusEventsForSafety();
4912
+ await runTmux(['detach-client', '-s', rawName]);
4913
+ }
4914
+ catch (error) {
4915
+ const errorMessage = getErrorMessage(error);
4916
+ if (isTmuxSessionMissingMessage(errorMessage) || isTmuxServerMissingMessage(errorMessage)) {
4917
+ alreadyGone = true;
4918
+ }
4919
+ else if (isTmuxUnavailableMessage(errorMessage)) {
4920
+ throw new HttpStatusError(503, 'tmux is not installed or not available in PATH.');
4921
+ }
4922
+ else {
4923
+ throw new HttpStatusError(500, errorMessage || 'Failed to detach tmux session clients');
4924
+ }
4925
+ }
4686
4926
  for (const id of affectedSessionIds) {
4687
4927
  try {
4688
4928
  cleanupSession(id, { killProcess: true });
4689
4929
  }
4690
- catch { }
4930
+ catch (error) {
4931
+ console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
4932
+ }
4933
+ }
4934
+ if (!alreadyGone) {
4935
+ try {
4936
+ await runTmux(['kill-session', '-t', rawName]);
4937
+ }
4938
+ catch (error) {
4939
+ const errorMessage = getErrorMessage(error);
4940
+ if (isTmuxSessionMissingMessage(errorMessage) || isTmuxServerMissingMessage(errorMessage)) {
4941
+ alreadyGone = true;
4942
+ }
4943
+ else if (isTmuxUnavailableMessage(errorMessage)) {
4944
+ throw new HttpStatusError(503, 'tmux is not installed or not available in PATH.');
4945
+ }
4946
+ else {
4947
+ throw new HttpStatusError(500, errorMessage || 'Failed to kill tmux session');
4948
+ }
4949
+ }
4691
4950
  }
4692
- return res.json({ success: true, alreadyGone: true, cleanedSessions: affectedSessionIds });
4951
+ const removedFrontendIds = globalSessionState.sessions
4952
+ .filter((session) => session.mode === 'tmux' && session.tmuxSessionName === rawName)
4953
+ .map((session) => session.sessionId);
4954
+ const removedSet = new Set(removedFrontendIds);
4955
+ const beforeCount = globalSessionState.sessions.length;
4956
+ globalSessionState = {
4957
+ sessions: globalSessionState.sessions.filter((session) => !removedSet.has(session.sessionId)),
4958
+ updatedAt: Date.now(),
4959
+ };
4960
+ if (tmuxRecoveryState.incident && removedSet.size > 0) {
4961
+ const remaining = tmuxRecoveryState.incident.affectedSessionIds
4962
+ .filter((sessionId) => !removedSet.has(sessionId));
4963
+ tmuxRecoveryState.incident = remaining.length > 0
4964
+ ? { ...tmuxRecoveryState.incident, affectedSessionIds: remaining }
4965
+ : null;
4966
+ await persistTmuxRecoveryState();
4967
+ }
4968
+ if (globalSessionState.sessions.length !== beforeCount) {
4969
+ await persistGlobalStateNow();
4970
+ broadcastClientState();
4971
+ }
4972
+ console.log(`[tmux] ${alreadyGone ? 'removed stale' : 'killed'} session: ${rawName} (cleaned ${affectedSessionIds.length} attached pty, dropped ${beforeCount - globalSessionState.sessions.length} client-state entries)`);
4973
+ return {
4974
+ success: true,
4975
+ ...(alreadyGone ? { alreadyGone: true } : {}),
4976
+ cleanedSessions: affectedSessionIds,
4977
+ };
4693
4978
  }
4694
- if (isTmuxUnavailableMessage(errorMessage)) {
4695
- return res.status(503).json({ error: 'tmux is not installed or not available in PATH.' });
4979
+ finally {
4980
+ intentionallyDeletingTmuxSessions.delete(rawName);
4696
4981
  }
4697
- return res.status(500).json({ error: errorMessage || 'Failed to detach tmux session clients' });
4982
+ });
4983
+ }
4984
+ router.delete('/tmux/sessions/:name', async (req, res) => {
4985
+ const rawName = typeof req.params.name === 'string' ? req.params.name.trim() : '';
4986
+ if (!rawName) {
4987
+ return res.status(400).json({ error: 'tmux session name is required' });
4698
4988
  }
4699
- for (const id of affectedSessionIds) {
4700
- try {
4701
- cleanupSession(id, { killProcess: true });
4702
- }
4703
- catch (error) {
4704
- console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
4705
- }
4989
+ // tmux session names cannot contain ':' or '.'; reject anything that doesn't look right.
4990
+ if (/[:.\s]/.test(rawName)) {
4991
+ return res.status(400).json({ error: 'invalid tmux session name' });
4706
4992
  }
4707
4993
  try {
4708
- await runTmux(['kill-session', '-t', rawName]);
4994
+ res.json(await destroyTmuxSessionSafely(rawName));
4709
4995
  }
4710
4996
  catch (error) {
4711
4997
  const errorMessage = getErrorMessage(error);
4712
- if (!isTmuxSessionMissingMessage(errorMessage)) {
4713
- if (isTmuxUnavailableMessage(errorMessage)) {
4714
- return res.status(503).json({ error: 'tmux is not installed or not available in PATH.' });
4715
- }
4716
- return res.status(500).json({ error: errorMessage || 'Failed to kill tmux session' });
4717
- }
4718
- }
4719
- // Also drop any persisted client-state entries that pointed at this tmux
4720
- // session. Without this, every connected browser would still see a tab
4721
- // whose backing tmux server is gone, and only the 30s reconciler would
4722
- // notice. Doing it inline + broadcasting keeps cross-device UX instant.
4723
- const beforeCount = globalSessionState.sessions.length;
4724
- globalSessionState = {
4725
- sessions: globalSessionState.sessions.filter((s) => !(s.mode === 'tmux' && s.tmuxSessionName === rawName)),
4726
- updatedAt: Date.now(),
4727
- };
4728
- if (globalSessionState.sessions.length !== beforeCount) {
4729
- schedulePersistGlobalState();
4730
- broadcastClientState();
4998
+ const status = error instanceof HttpStatusError ? error.statusCode : 500;
4999
+ res.status(status).json({ error: errorMessage || 'Failed to kill tmux session' });
4731
5000
  }
4732
- console.log(`[tmux] killed session: ${rawName} (cleaned ${affectedSessionIds.length} attached pty, dropped ${beforeCount - globalSessionState.sessions.length} client-state entries)`);
4733
- res.json({ success: true, cleanedSessions: affectedSessionIds });
4734
5001
  });
4735
5002
  router.post('/serialize-state', async (req, res) => {
4736
5003
  const ids = Array.isArray(req.body?.ids)
@@ -5979,7 +6246,7 @@ router.get('/:sessionId/stream', async (req, res) => {
5979
6246
  previousMetadata: lastTmuxMetadata,
5980
6247
  lastActiveWriteAt: lastTmuxMetaWriteAt,
5981
6248
  });
5982
- lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
6249
+ lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
5983
6250
  lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
5984
6251
  const snapshot = JSON.stringify(layout);
5985
6252
  if (snapshot === lastTmuxLayoutSnapshot) {
@@ -6684,7 +6951,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
6684
6951
  previousMetadata: lastTmuxMetadata,
6685
6952
  lastActiveWriteAt: lastTmuxMetaWriteAt,
6686
6953
  });
6687
- lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
6954
+ lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
6688
6955
  lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
6689
6956
  const snapshot = JSON.stringify(layout);
6690
6957
  if (snapshot !== lastTmuxLayoutSnapshot) {
@@ -6967,6 +7234,11 @@ automationTimer.unref?.();
6967
7234
  async function reconcileClientState() {
6968
7235
  if (globalSessionState.sessions.length === 0)
6969
7236
  return;
7237
+ // Refresh first so a shared tmux crash is recorded before orphan cleanup
7238
+ // can discard the metadata needed to recover Agent conversations.
7239
+ await getSessionInventorySnapshot({ refresh: true }).catch((error) => {
7240
+ console.warn('[tmux-recovery] reconcile observation failed:', getErrorMessage(error));
7241
+ });
6970
7242
  const toRemove = [];
6971
7243
  const toDetach = [];
6972
7244
  for (const entry of globalSessionState.sessions) {
@@ -6991,8 +7263,15 @@ async function reconcileClientState() {
6991
7263
  // the tmux session.
6992
7264
  try {
6993
7265
  const alive = await tmuxSessionExists(entry.tmuxSessionName);
6994
- if (!alive)
6995
- toRemove.push(entry.sessionId);
7266
+ if (!alive) {
7267
+ if (entry.cwd && entry.agentResume?.sessionId) {
7268
+ if (entry.backendSessionId)
7269
+ toDetach.push(entry.sessionId);
7270
+ }
7271
+ else {
7272
+ toRemove.push(entry.sessionId);
7273
+ }
7274
+ }
6996
7275
  }
6997
7276
  catch {
6998
7277
  // tmux itself is down — leave the entry alone; the next tick (or
@@ -7015,7 +7294,7 @@ async function reconcileClientState() {
7015
7294
  console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
7016
7295
  }
7017
7296
  if (toDetach.length > 0) {
7018
- console.log(`[reconcile] detached ${toDetach.length} resumable Agent shell entries: ${toDetach.join(', ')}`);
7297
+ console.log(`[reconcile] detached ${toDetach.length} resumable Agent entries: ${toDetach.join(', ')}`);
7019
7298
  }
7020
7299
  }
7021
7300
  export default router;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Serializes tmux lifecycle mutations across session names. tmux has one
3
+ * shared server, so create/detach/destroy operations are one failure domain
4
+ * even when they target different sessions.
5
+ */
6
+ export class TmuxLifecycleCoordinator {
7
+ tail = Promise.resolve();
8
+ pending = new Map();
9
+ run(key, operation) {
10
+ const duplicate = this.pending.get(key);
11
+ if (duplicate)
12
+ return duplicate;
13
+ const scheduled = this.tail.then(operation, operation);
14
+ this.tail = scheduled.then(() => undefined, () => undefined);
15
+ this.pending.set(key, scheduled);
16
+ void scheduled.finally(() => {
17
+ if (this.pending.get(key) === scheduled)
18
+ this.pending.delete(key);
19
+ }).catch(() => undefined);
20
+ return scheduled;
21
+ }
22
+ }