termdock 1.4.113 → 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.
- package/dist/client/assets/{ModelPreview-aRiv3VyI.js → ModelPreview-TFTI5WuS.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-BSeclZa2.js → abnfDiagram-VRR7QNED-BhVwqb42.js} +1 -1
- package/dist/client/assets/{arc-BFrSt5wm.js → arc-Cia3l37B.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-BTUzp9wa.js → architectureDiagram-ZJ3FMSHR-JIoYyeMu.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-SOBUReGq.js → blockDiagram-677ZJIJ3-BfYjZYyZ.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-DM4zhiZk.js → c4Diagram-LMCZKHZV-C4xr-lRb.js} +1 -1
- package/dist/client/assets/channel-CULuggLw.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-Caun4f6Z.js → chunk-2Q5K7J3B-BkEbBIMC.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-CM0l3Ihd.js → chunk-32BRIVSS-DN28GD24.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-BDIAjadQ.js → chunk-5VM5RSS4-BCTFfYEW.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-CWxkBddU.js → chunk-EX3LRPZG-4Tj_gqow.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-CFVZXFsx.js → chunk-JWPE2WC7-DQ6Vqsm4.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-CbaT3uMN.js → chunk-MOJQB5TN-8gTQyH1j.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-Cj8TIX5y.js → chunk-RYQCIY6F-ufx4QMhz.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-CKwOk4Ma.js → chunk-V7JOEXUC-Cub2ypjw.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-DFmTDyFb.js → chunk-VR4S4FIN-yGRJdFR9.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-x3YG8-ha.js → chunk-XXDRQBXY-Dxk9pYfc.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-Bl4UmP7x.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-Bl4UmP7x.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-XA98byjZ.js → cose-bilkent-JH36ORCC-0zEuEsmg.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-CUvb2-IS.js → cynefin-VYW2F7L2-BEZJ_e9H.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-J9_pj2mu.js → cynefinDiagram-TSTJHNR4-CtNTAcJO.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DmNu9HVB.js → dagre-VKFMJZFB-CzYfYyFy.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DdQ57IG5.js → diagram-FQU43EPY-CvF4CTvK.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CeupSJ08.js → diagram-G47NLZAW-GIm7u3AD.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-BWe5RV_L.js → diagram-NH7WQ7WH-QYwMvBHK.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-DEU45jL_.js → diagram-OA4YK3LP-FJyrhUOo.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-SdkUpcCT.js → diagram-WEI45ONY-CSwtwLQv.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DObmPckn.js → ebnfDiagram-CCIWWBDH-DGxm7mEr.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-B3viL24X.js → erDiagram-Q63AITRT-C7t8PuDW.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-CaMVMUJQ.js → flowDiagram-23GEKE2U-BPnXE2MD.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-OUds5A45.js → ganttDiagram-NO4QXBWP-CNlXSowD.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bm6bpzjq.js → gitGraphDiagram-IHSO6WYX-DOw_SYGf.js} +1 -1
- package/dist/client/assets/index-CsZ9UjAY.css +1 -0
- package/dist/client/assets/index-D-k2HJWc.js +457 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-Ub0dfReE.js → infoDiagram-FWYZ7A6U-mp8cBgyz.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BRC3NKAb.js → ishikawaDiagram-FXEZZL3T-D_exGyO9.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-DRhi_RAn.js → journeyDiagram-5HDEW3XC-CQRTZIM8.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-MJwQ_3lr.js → kanban-definition-HUTT4EX6-DHzK5wpf.js} +1 -1
- package/dist/client/assets/{linear-Bm7e3IJm.js → linear-COfasUKQ.js} +1 -1
- package/dist/client/assets/{mermaid.core-DS6HGJsv.js → mermaid.core-CWF0ftUx.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-jfF4Ah_g.js → mindmap-definition-LN4V7U3C-CrYH7yb_.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-DItfIx0G.js → pegDiagram-2B236MQR-BXIPYBB7.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DYsVowsz.js → pieDiagram-ENE6RG2P-D6YSwWrB.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CqwBSt-i.js → quadrantDiagram-ABIIQ3AL-CNxUsWJH.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-BXYGMARD.js → railroadDiagram-RFXS5EU6-Bk5SzdMg.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-OlhYo38c.js → requirementDiagram-TGXJPOKE-BKWOPbLf.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-BytDmytE.js → sankeyDiagram-HTMAVEWB-DvvyztZU.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-C5IfDQCX.js → sequenceDiagram-DBY2YBRQ-B9BXO3iW.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-IQqO80BP.js → sizeCapture-X5ZJPWSS-C_XPbltB.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Dz8Zwv1f.js → stateDiagram-2N3HPSRC-DvkjLyxY.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BnVMlc0d.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-By1JQ2Vn.js → swimlanes-5IMT3BWC-DDbqDzNu.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-Ck0HuzCJ.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-COV2kNQq.js → timeline-definition-FHXFAJF6-CPtaZOJq.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-6_6O4S_o.js → vennDiagram-L72KCM5P-2_n0q45b.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-B6WaMN3B.js → wardleyDiagram-EHGQE667-BlsAbk4A.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-CAZgyI42.js → xychartDiagram-FW5EYKEG-gjWfqekv.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/registry.js +40 -0
- package/dist/server/cli.js +9 -6
- package/dist/server/routes/terminal.js +371 -70
- package/dist/server/utils/tmuxLifecycle.js +22 -0
- package/dist/server/utils/tmuxRecovery.js +47 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-BzCBmRI-.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-KzxRTl2q.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-KzxRTl2q.js +0 -1
- package/dist/client/assets/index-BKR1zlOs.css +0 -1
- package/dist/client/assets/index-H--Bak2U.js +0 -457
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BiW7fZxb.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-Be_r6ANc.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
|
-
&&
|
|
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
|
-
//
|
|
2651
|
-
//
|
|
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
|
-
|
|
2655
|
-
|
|
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({
|
|
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
|
//
|
|
@@ -3367,12 +3533,16 @@ async function injectTmuxShellIntegration(sessionName) {
|
|
|
3367
3533
|
await runTmux(['set-environment', '-t', sessionName, 'TERMDOCK_FISH_INTEGRATION', scriptPath]);
|
|
3368
3534
|
}
|
|
3369
3535
|
}
|
|
3370
|
-
async function
|
|
3536
|
+
async function disableTmuxFocusEventsForSafety() {
|
|
3371
3537
|
const current = (await runTmux(['show-options', '-gqv', 'focus-events'])).trim();
|
|
3372
|
-
if (current === '
|
|
3538
|
+
if (current === 'off')
|
|
3373
3539
|
return;
|
|
3374
|
-
|
|
3375
|
-
|
|
3540
|
+
// tmux/tmux#5022: a queued focus event can dereference a NULL client
|
|
3541
|
+
// session during teardown and crash the shared tmux server. Until tmux
|
|
3542
|
+
// ships a confirmed fix, losing pane focus notifications is preferable to
|
|
3543
|
+
// losing every Termdock session and Agent on that server.
|
|
3544
|
+
await runTmux(['set-option', '-g', 'focus-events', 'off']);
|
|
3545
|
+
console.log('[tmux-focus] disabled global focus-events for crash safety');
|
|
3376
3546
|
}
|
|
3377
3547
|
// Let tmux forward modified Enter keys (Ctrl/Shift/Alt+Enter) to pane apps.
|
|
3378
3548
|
// The option only exists in tmux >= 3.2; on older versions the probe fails
|
|
@@ -3392,7 +3562,7 @@ async function enableTmuxExtendedKeys() {
|
|
|
3392
3562
|
async function ensureSharedTmuxServerReady() {
|
|
3393
3563
|
await ensureTmuxColorEnvironment();
|
|
3394
3564
|
await applyTmuxScrollbackProfile();
|
|
3395
|
-
await
|
|
3565
|
+
await disableTmuxFocusEventsForSafety();
|
|
3396
3566
|
await enableTmuxExtendedKeys();
|
|
3397
3567
|
await configureTmuxWheelBindings();
|
|
3398
3568
|
}
|
|
@@ -3442,11 +3612,14 @@ async function ensureManagedTmuxSessionReady(sessionName) {
|
|
|
3442
3612
|
console.warn(`Failed to enable agent-hook plumbing for ${sessionName}: ${getErrorMessage(error)}`);
|
|
3443
3613
|
}
|
|
3444
3614
|
}
|
|
3445
|
-
async function
|
|
3615
|
+
async function prepareManagedTmuxSessionUnlocked(sessionName, cwd) {
|
|
3446
3616
|
await ensureTmuxSessionExists(sessionName, cwd);
|
|
3447
3617
|
await ensureSharedTmuxServerReady();
|
|
3448
3618
|
await ensureManagedTmuxSessionReady(sessionName);
|
|
3449
3619
|
}
|
|
3620
|
+
async function prepareManagedTmuxSession(sessionName, cwd) {
|
|
3621
|
+
await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
|
|
3622
|
+
}
|
|
3450
3623
|
async function getTmuxLayout(sessionName) {
|
|
3451
3624
|
const sessionInfoRaw = (await runTmux([
|
|
3452
3625
|
'display-message',
|
|
@@ -4649,6 +4822,165 @@ router.get('/tmux/status', async (_req, res) => {
|
|
|
4649
4822
|
const status = await getTmuxStatus();
|
|
4650
4823
|
res.json(status);
|
|
4651
4824
|
});
|
|
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) });
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
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
|
+
}
|
|
4926
|
+
for (const id of affectedSessionIds) {
|
|
4927
|
+
try {
|
|
4928
|
+
cleanupSession(id, { killProcess: true });
|
|
4929
|
+
}
|
|
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
|
+
}
|
|
4950
|
+
}
|
|
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
|
+
};
|
|
4978
|
+
}
|
|
4979
|
+
finally {
|
|
4980
|
+
intentionallyDeletingTmuxSessions.delete(rawName);
|
|
4981
|
+
}
|
|
4982
|
+
});
|
|
4983
|
+
}
|
|
4652
4984
|
router.delete('/tmux/sessions/:name', async (req, res) => {
|
|
4653
4985
|
const rawName = typeof req.params.name === 'string' ? req.params.name.trim() : '';
|
|
4654
4986
|
if (!rawName) {
|
|
@@ -4658,57 +4990,14 @@ router.delete('/tmux/sessions/:name', async (req, res) => {
|
|
|
4658
4990
|
if (/[:.\s]/.test(rawName)) {
|
|
4659
4991
|
return res.status(400).json({ error: 'invalid tmux session name' });
|
|
4660
4992
|
}
|
|
4661
|
-
// Detach any local terminal sessions still wired to this tmux session so that
|
|
4662
|
-
// their pty (the tmux client) is cleaned up alongside the kill-session call.
|
|
4663
|
-
const affectedSessionIds = [];
|
|
4664
|
-
for (const [sessionId, session] of terminalSessions.entries()) {
|
|
4665
|
-
if (session.mode === 'tmux' && session.tmuxSessionName === rawName) {
|
|
4666
|
-
affectedSessionIds.push(sessionId);
|
|
4667
|
-
}
|
|
4668
|
-
}
|
|
4669
4993
|
try {
|
|
4670
|
-
await
|
|
4994
|
+
res.json(await destroyTmuxSessionSafely(rawName));
|
|
4671
4995
|
}
|
|
4672
4996
|
catch (error) {
|
|
4673
4997
|
const errorMessage = getErrorMessage(error);
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
for (const id of affectedSessionIds) {
|
|
4677
|
-
try {
|
|
4678
|
-
cleanupSession(id, { killProcess: true });
|
|
4679
|
-
}
|
|
4680
|
-
catch { }
|
|
4681
|
-
}
|
|
4682
|
-
return res.json({ success: true, alreadyGone: true, cleanedSessions: affectedSessionIds });
|
|
4683
|
-
}
|
|
4684
|
-
if (isTmuxUnavailableMessage(errorMessage)) {
|
|
4685
|
-
return res.status(503).json({ error: 'tmux is not installed or not available in PATH.' });
|
|
4686
|
-
}
|
|
4687
|
-
return res.status(500).json({ error: errorMessage || 'Failed to kill tmux session' });
|
|
4688
|
-
}
|
|
4689
|
-
for (const id of affectedSessionIds) {
|
|
4690
|
-
try {
|
|
4691
|
-
cleanupSession(id, { killProcess: true });
|
|
4692
|
-
}
|
|
4693
|
-
catch (error) {
|
|
4694
|
-
console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
|
|
4695
|
-
}
|
|
4696
|
-
}
|
|
4697
|
-
// Also drop any persisted client-state entries that pointed at this tmux
|
|
4698
|
-
// session. Without this, every connected browser would still see a tab
|
|
4699
|
-
// whose backing tmux server is gone, and only the 30s reconciler would
|
|
4700
|
-
// notice. Doing it inline + broadcasting keeps cross-device UX instant.
|
|
4701
|
-
const beforeCount = globalSessionState.sessions.length;
|
|
4702
|
-
globalSessionState = {
|
|
4703
|
-
sessions: globalSessionState.sessions.filter((s) => !(s.mode === 'tmux' && s.tmuxSessionName === rawName)),
|
|
4704
|
-
updatedAt: Date.now(),
|
|
4705
|
-
};
|
|
4706
|
-
if (globalSessionState.sessions.length !== beforeCount) {
|
|
4707
|
-
schedulePersistGlobalState();
|
|
4708
|
-
broadcastClientState();
|
|
4998
|
+
const status = error instanceof HttpStatusError ? error.statusCode : 500;
|
|
4999
|
+
res.status(status).json({ error: errorMessage || 'Failed to kill tmux session' });
|
|
4709
5000
|
}
|
|
4710
|
-
console.log(`[tmux] killed session: ${rawName} (cleaned ${affectedSessionIds.length} attached pty, dropped ${beforeCount - globalSessionState.sessions.length} client-state entries)`);
|
|
4711
|
-
res.json({ success: true, cleanedSessions: affectedSessionIds });
|
|
4712
5001
|
});
|
|
4713
5002
|
router.post('/serialize-state', async (req, res) => {
|
|
4714
5003
|
const ids = Array.isArray(req.body?.ids)
|
|
@@ -5957,7 +6246,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5957
6246
|
previousMetadata: lastTmuxMetadata,
|
|
5958
6247
|
lastActiveWriteAt: lastTmuxMetaWriteAt,
|
|
5959
6248
|
});
|
|
5960
|
-
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
|
|
6249
|
+
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
|
|
5961
6250
|
lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
|
|
5962
6251
|
const snapshot = JSON.stringify(layout);
|
|
5963
6252
|
if (snapshot === lastTmuxLayoutSnapshot) {
|
|
@@ -6662,7 +6951,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
6662
6951
|
previousMetadata: lastTmuxMetadata,
|
|
6663
6952
|
lastActiveWriteAt: lastTmuxMetaWriteAt,
|
|
6664
6953
|
});
|
|
6665
|
-
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
|
|
6954
|
+
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
|
|
6666
6955
|
lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
|
|
6667
6956
|
const snapshot = JSON.stringify(layout);
|
|
6668
6957
|
if (snapshot !== lastTmuxLayoutSnapshot) {
|
|
@@ -6945,6 +7234,11 @@ automationTimer.unref?.();
|
|
|
6945
7234
|
async function reconcileClientState() {
|
|
6946
7235
|
if (globalSessionState.sessions.length === 0)
|
|
6947
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
|
+
});
|
|
6948
7242
|
const toRemove = [];
|
|
6949
7243
|
const toDetach = [];
|
|
6950
7244
|
for (const entry of globalSessionState.sessions) {
|
|
@@ -6969,8 +7263,15 @@ async function reconcileClientState() {
|
|
|
6969
7263
|
// the tmux session.
|
|
6970
7264
|
try {
|
|
6971
7265
|
const alive = await tmuxSessionExists(entry.tmuxSessionName);
|
|
6972
|
-
if (!alive)
|
|
6973
|
-
|
|
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
|
+
}
|
|
6974
7275
|
}
|
|
6975
7276
|
catch {
|
|
6976
7277
|
// tmux itself is down — leave the entry alone; the next tick (or
|
|
@@ -6993,7 +7294,7 @@ async function reconcileClientState() {
|
|
|
6993
7294
|
console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
|
|
6994
7295
|
}
|
|
6995
7296
|
if (toDetach.length > 0) {
|
|
6996
|
-
console.log(`[reconcile] detached ${toDetach.length} resumable Agent
|
|
7297
|
+
console.log(`[reconcile] detached ${toDetach.length} resumable Agent entries: ${toDetach.join(', ')}`);
|
|
6997
7298
|
}
|
|
6998
7299
|
}
|
|
6999
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
|
+
}
|