termdock 1.4.114 → 1.4.116
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-D_ePHuIb.js → ModelPreview-D3WI8Egu.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-BZI0kiea.js → abnfDiagram-VRR7QNED-B569LPGl.js} +1 -1
- package/dist/client/assets/{arc-Bf2XlwFP.js → arc-7MUc4XGn.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DftQsQGl.js → architectureDiagram-ZJ3FMSHR-Doz58q1g.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-ZUmEG-dB.js → blockDiagram-677ZJIJ3-CkxjBXBO.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-sOeMyyUg.js → c4Diagram-LMCZKHZV-DLJNQ9lI.js} +1 -1
- package/dist/client/assets/channel-CQup56Qu.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-_arq_pE8.js → chunk-2Q5K7J3B-BDgPfOVV.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-DjbhCFxb.js → chunk-32BRIVSS-BBSn7ckl.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-lzD-6U8i.js → chunk-5VM5RSS4-pNZrrZ4N.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BqoUr3Kb.js → chunk-EX3LRPZG-CTatM-fO.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-Y41_6y44.js → chunk-JWPE2WC7-B8XdEpzH.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-D_OcIm_v.js → chunk-MOJQB5TN-C8kC8KwF.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-jFCEdSIZ.js → chunk-RYQCIY6F-dc9CJJ2J.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BKhbGZNQ.js → chunk-V7JOEXUC-CxS2s9hI.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-CpVMtJAx.js → chunk-VR4S4FIN-B4I8ngmJ.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-uhRjjTen.js → chunk-XXDRQBXY-BJPmETsH.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BJ-FlsJL.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BJ-FlsJL.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-D27whsXo.js → cose-bilkent-JH36ORCC-D9x7P4Eq.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-DjPmiAlB.js → cynefin-VYW2F7L2-DvWT8h0R.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-D4fIR8xL.js → cynefinDiagram-TSTJHNR4-CUv59Bw3.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-RE7TM3YY.js → dagre-VKFMJZFB-s5bEdt1w.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-c68o-BoX.js → diagram-FQU43EPY-CKa0G8xF.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-C9zQgh2h.js → diagram-G47NLZAW-r5YSFYsR.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-D9jATmfe.js → diagram-NH7WQ7WH-BveQN9DC.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-DIg_X7Gm.js → diagram-OA4YK3LP-C5Aro8ZK.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-512KTyNr.js → diagram-WEI45ONY-BAWeZapE.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DIQduMdo.js → ebnfDiagram-CCIWWBDH-CYE9Chvd.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-DJaJOquE.js → erDiagram-Q63AITRT-DUZJtm4q.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-BleT-GIU.js → flowDiagram-23GEKE2U-BQR9a6DK.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-Bi9uo6X5.js → ganttDiagram-NO4QXBWP-DuFzszpU.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BfaYIRkh.js → gitGraphDiagram-IHSO6WYX-DABzuPXe.js} +1 -1
- package/dist/client/assets/index-8uMWsajx.js +457 -0
- package/dist/client/assets/index-CsZ9UjAY.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-Ck_MEUjM.js → infoDiagram-FWYZ7A6U-DRva3Ien.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Ct3e_G59.js → ishikawaDiagram-FXEZZL3T-CNRXrrLI.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BiXisFVi.js → journeyDiagram-5HDEW3XC-D3rI6RBT.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CTAX2EZQ.js → kanban-definition-HUTT4EX6-BpRQtZjC.js} +1 -1
- package/dist/client/assets/{linear-gtzA009p.js → linear-CnEUJ7dP.js} +1 -1
- package/dist/client/assets/{mermaid.core-BdXk5TA4.js → mermaid.core-CbGyS7XX.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-C7XVfI-0.js → mindmap-definition-LN4V7U3C-DZU8JwLn.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-f9OFTdAF.js → pegDiagram-2B236MQR-CDy2GzZq.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-CexwcGUT.js → pieDiagram-ENE6RG2P-ChvqKPQc.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-rEb1d4K9.js → quadrantDiagram-ABIIQ3AL-CEsXtFFO.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-uayZHK87.js → railroadDiagram-RFXS5EU6-2lIhT345.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-DWPla6v3.js → requirementDiagram-TGXJPOKE-0k84frRM.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-LPc9mQuH.js → sankeyDiagram-HTMAVEWB-mfgLZaap.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BgHvqqFK.js → sequenceDiagram-DBY2YBRQ-C-KseWZC.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-da41k81P.js → sizeCapture-X5ZJPWSS-DpId_v-j.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Cls0ucKe.js → stateDiagram-2N3HPSRC-D80yi9Jm.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-rTC_aFkU.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-D0Pk83r7.js → swimlanes-5IMT3BWC-CDAhI-vs.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-D_we2o4F.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DYo44Edu.js → timeline-definition-FHXFAJF6-Da0mJHAf.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-CNhw73G2.js → vennDiagram-L72KCM5P-BoXSxedu.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-D2jq9_Nm.js → wardleyDiagram-EHGQE667-wwp1hsPG.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-3DLaKkZv.js → xychartDiagram-FW5EYKEG-BhQ4kWvA.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/automationStore.js +15 -0
- package/dist/server/agent/registry.js +40 -0
- package/dist/server/cli.js +13 -38
- package/dist/server/routes/terminal.js +376 -100
- package/dist/server/utils/terminalColorEnvironment.js +26 -0
- 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-CcMim_aT.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-xZk0_-0_.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-xZk0_-0_.js +0 -1
- package/dist/client/assets/index-BKR1zlOs.css +0 -1
- package/dist/client/assets/index-BPNZub0Z.js +0 -457
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BNqH077I.js +0 -1
- 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,9 @@ 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 { buildInteractiveColorEnvironment, buildTmuxColorEnvironmentCommands, } from '../utils/terminalColorEnvironment.js';
|
|
34
|
+
import { detectTmuxRecoveryIncident, normalizeTmuxRecoveryIncident, } from '../utils/tmuxRecovery.js';
|
|
32
35
|
import { checkPluginPackageUpdate, commitPreparedPlugin, preparePluginPackage, } from '../agent/pluginPackages.js';
|
|
33
36
|
import { clearPluginAgents, registerPluginAgents } from '../agent/registry.js';
|
|
34
37
|
import { notifyAgentTransition, notifyTerminalExit } from '../notifications/pushService.js';
|
|
@@ -119,6 +122,10 @@ let globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
|
119
122
|
const GLOBAL_SESSION_STATE_FILE = `${TERMDOCK_DIR}/global-session-state.json`;
|
|
120
123
|
const CLIENT_STATES_FILE = `${TERMDOCK_DIR}/client-states.json`; // 保留用于迁移
|
|
121
124
|
const agentResumeHistory = new AgentResumeHistoryStore(`${TERMDOCK_DIR}/agent-resume-history.json`);
|
|
125
|
+
const TMUX_RECOVERY_STATE_FILE = `${TERMDOCK_DIR}/tmux-recovery-state.json`;
|
|
126
|
+
const tmuxLifecycle = new TmuxLifecycleCoordinator();
|
|
127
|
+
const intentionallyDeletingTmuxSessions = new Set();
|
|
128
|
+
let tmuxRecoveryState = { lastServerPid: null, incident: null };
|
|
122
129
|
let persistGlobalStateTimer = null;
|
|
123
130
|
let globalSessionStateWatcher = null;
|
|
124
131
|
let globalSessionStateReloadTimer = null;
|
|
@@ -203,6 +210,7 @@ function getClientStateSemanticSignature(state, inventory) {
|
|
|
203
210
|
clientCount: session.clientCount,
|
|
204
211
|
})),
|
|
205
212
|
tmuxStatus: inventory.tmuxStatus,
|
|
213
|
+
tmuxRecovery: inventory.tmuxRecovery,
|
|
206
214
|
} : null,
|
|
207
215
|
});
|
|
208
216
|
}
|
|
@@ -398,6 +406,30 @@ async function loadGlobalSessionStateFromDisk() {
|
|
|
398
406
|
console.warn('[session-persist] Failed to load global state:', getErrorMessage(error));
|
|
399
407
|
}
|
|
400
408
|
}
|
|
409
|
+
async function loadTmuxRecoveryStateFromDisk() {
|
|
410
|
+
try {
|
|
411
|
+
const data = await readJsonFileIfExists(TMUX_RECOVERY_STATE_FILE);
|
|
412
|
+
if (!data)
|
|
413
|
+
return;
|
|
414
|
+
tmuxRecoveryState = {
|
|
415
|
+
lastServerPid: typeof data.lastServerPid === 'number' && Number.isInteger(data.lastServerPid)
|
|
416
|
+
? data.lastServerPid
|
|
417
|
+
: null,
|
|
418
|
+
incident: normalizeTmuxRecoveryIncident(data.incident),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
console.warn('[tmux-recovery] Failed to load recovery state:', getErrorMessage(error));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function persistTmuxRecoveryState() {
|
|
426
|
+
try {
|
|
427
|
+
await writeJsonFile(TMUX_RECOVERY_STATE_FILE, tmuxRecoveryState);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
console.warn('[tmux-recovery] Failed to persist recovery state:', getErrorMessage(error));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
401
433
|
async function reloadGlobalSessionStateFromDisk(source) {
|
|
402
434
|
try {
|
|
403
435
|
const previous = JSON.stringify(globalSessionState);
|
|
@@ -477,6 +509,7 @@ function flushPersistAndExit() {
|
|
|
477
509
|
try {
|
|
478
510
|
fs.mkdirSync(TERMDOCK_DIR, { recursive: true });
|
|
479
511
|
fs.writeFileSync(GLOBAL_SESSION_STATE_FILE, JSON.stringify(globalSessionState, null, 2), 'utf-8');
|
|
512
|
+
fs.writeFileSync(TMUX_RECOVERY_STATE_FILE, JSON.stringify(tmuxRecoveryState, null, 2), 'utf-8');
|
|
480
513
|
}
|
|
481
514
|
catch { /* best effort */ }
|
|
482
515
|
}
|
|
@@ -484,7 +517,7 @@ process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsN
|
|
|
484
517
|
process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
|
|
485
518
|
// 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
|
|
486
519
|
void (async () => {
|
|
487
|
-
await loadGlobalSessionStateFromDisk();
|
|
520
|
+
await Promise.all([loadGlobalSessionStateFromDisk(), loadTmuxRecoveryStateFromDisk()]);
|
|
488
521
|
await watchGlobalSessionStateFile();
|
|
489
522
|
// pty-host adoption runs after the state load (its record upserts must not
|
|
490
523
|
// be clobbered) and before pruning (surviving shell records' backends are
|
|
@@ -809,7 +842,7 @@ function buildRuntimeTmuxMetadata(input) {
|
|
|
809
842
|
cwd,
|
|
810
843
|
sessionName: input.tmuxSessionName,
|
|
811
844
|
});
|
|
812
|
-
return { program, cwd, label };
|
|
845
|
+
return { program, cwd, label, rawArgs: input.rawArgs ?? null };
|
|
813
846
|
}
|
|
814
847
|
function maybeRepairTmuxOptions(sessionName, current, next) {
|
|
815
848
|
const currentSnapshot = {
|
|
@@ -978,9 +1011,8 @@ function getClientSessionView(inventory, frontendSessionId) {
|
|
|
978
1011
|
return session;
|
|
979
1012
|
}
|
|
980
1013
|
async function ensureBackendSessionForRecord(req, record, options = {}) {
|
|
981
|
-
const shouldOfferRecoveredAgent = record.mode === 'shell'
|
|
982
|
-
&&
|
|
983
|
-
&& (!record.backendSessionId || !terminalSessions.has(record.backendSessionId));
|
|
1014
|
+
const shouldOfferRecoveredAgent = Boolean(record.cwd && record.agentResume?.sessionId) && ((record.mode === 'shell' && (!record.backendSessionId || !terminalSessions.has(record.backendSessionId)))
|
|
1015
|
+
|| (record.mode === 'tmux' && Boolean(tmuxRecoveryState.incident?.affectedSessionIds.includes(record.sessionId))));
|
|
984
1016
|
if (record.backendSessionId) {
|
|
985
1017
|
const existing = terminalSessions.get(record.backendSessionId);
|
|
986
1018
|
if (existing) {
|
|
@@ -1336,6 +1368,7 @@ async function runTmux(args) {
|
|
|
1336
1368
|
const { stdout } = await execFileAsync(getTmuxBinary(), args, {
|
|
1337
1369
|
timeout: 5000,
|
|
1338
1370
|
maxBuffer: 2 * 1024 * 1024,
|
|
1371
|
+
env: buildInteractiveColorEnvironment(process.env),
|
|
1339
1372
|
});
|
|
1340
1373
|
return stdout;
|
|
1341
1374
|
}
|
|
@@ -1708,24 +1741,8 @@ async function tmuxSessionExists(sessionName) {
|
|
|
1708
1741
|
}
|
|
1709
1742
|
async function ensureTmuxColorEnvironment(sessionName) {
|
|
1710
1743
|
const forceColor = process.env.TERMDOCK_FORCE_COLOR === '1';
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
await runTmux(['set-environment', '-g', 'FORCE_COLOR', '1']);
|
|
1714
|
-
await runTmux(['set-environment', '-g', '-u', 'NO_COLOR']);
|
|
1715
|
-
}
|
|
1716
|
-
else {
|
|
1717
|
-
// Clear the legacy Termdock override so tmux sessions can respect user color prefs.
|
|
1718
|
-
await runTmux(['set-environment', '-g', '-u', 'FORCE_COLOR']);
|
|
1719
|
-
}
|
|
1720
|
-
if (sessionName) {
|
|
1721
|
-
await runTmux(['set-environment', '-t', sessionName, 'COLORTERM', 'truecolor']);
|
|
1722
|
-
if (forceColor) {
|
|
1723
|
-
await runTmux(['set-environment', '-t', sessionName, 'FORCE_COLOR', '1']);
|
|
1724
|
-
await runTmux(['set-environment', '-t', sessionName, '-u', 'NO_COLOR']);
|
|
1725
|
-
}
|
|
1726
|
-
else {
|
|
1727
|
-
await runTmux(['set-environment', '-t', sessionName, '-u', 'FORCE_COLOR']);
|
|
1728
|
-
}
|
|
1744
|
+
for (const args of buildTmuxColorEnvironmentCommands(sessionName, forceColor)) {
|
|
1745
|
+
await runTmux(args);
|
|
1729
1746
|
}
|
|
1730
1747
|
}
|
|
1731
1748
|
async function listLiveTmuxInventorySessions() {
|
|
@@ -1785,6 +1802,85 @@ async function listLiveTmuxInventorySessions() {
|
|
|
1785
1802
|
throw error;
|
|
1786
1803
|
}
|
|
1787
1804
|
}
|
|
1805
|
+
async function getTmuxServerPid() {
|
|
1806
|
+
try {
|
|
1807
|
+
const raw = (await runTmux(['display-message', '-p', '#{pid}'])).trim();
|
|
1808
|
+
const pid = Number.parseInt(raw, 10);
|
|
1809
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1810
|
+
}
|
|
1811
|
+
catch (error) {
|
|
1812
|
+
if (isTmuxServerMissingMessage(getErrorMessage(error)))
|
|
1813
|
+
return null;
|
|
1814
|
+
throw error;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function getTmuxRecoveryView() {
|
|
1818
|
+
const incident = tmuxRecoveryState.incident;
|
|
1819
|
+
if (!incident)
|
|
1820
|
+
return null;
|
|
1821
|
+
const affected = new Set(incident.affectedSessionIds);
|
|
1822
|
+
const sessions = globalSessionState.sessions.flatMap((record) => {
|
|
1823
|
+
if (!affected.has(record.sessionId) || record.mode !== 'tmux' || !record.tmuxSessionName
|
|
1824
|
+
|| !record.cwd || !record.agentResume?.sessionId)
|
|
1825
|
+
return [];
|
|
1826
|
+
const agent = agentBySlug(record.agentResume.slug);
|
|
1827
|
+
if (!agent)
|
|
1828
|
+
return [];
|
|
1829
|
+
return [{
|
|
1830
|
+
sessionId: record.sessionId,
|
|
1831
|
+
name: record.name,
|
|
1832
|
+
tmuxSessionName: record.tmuxSessionName,
|
|
1833
|
+
cwd: record.cwd,
|
|
1834
|
+
agentSlug: agent.slug,
|
|
1835
|
+
agentDisplayName: agent.displayName,
|
|
1836
|
+
}];
|
|
1837
|
+
});
|
|
1838
|
+
return sessions.length > 0
|
|
1839
|
+
? { id: incident.id, detectedAt: incident.detectedAt, sessions }
|
|
1840
|
+
: null;
|
|
1841
|
+
}
|
|
1842
|
+
async function observeTmuxServerGeneration(liveTmuxSessions) {
|
|
1843
|
+
let currentServerPid = null;
|
|
1844
|
+
try {
|
|
1845
|
+
currentServerPid = await getTmuxServerPid();
|
|
1846
|
+
}
|
|
1847
|
+
catch (error) {
|
|
1848
|
+
console.warn('[tmux-recovery] Failed to read tmux server pid:', getErrorMessage(error));
|
|
1849
|
+
}
|
|
1850
|
+
const liveSessionNames = new Set(liveTmuxSessions.map((session) => session.name));
|
|
1851
|
+
const candidates = globalSessionState.sessions.flatMap((record) => {
|
|
1852
|
+
if (record.mode !== 'tmux' || !record.tmuxSessionName)
|
|
1853
|
+
return [];
|
|
1854
|
+
const backend = record.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1855
|
+
return [{
|
|
1856
|
+
sessionId: record.sessionId,
|
|
1857
|
+
tmuxSessionName: record.tmuxSessionName,
|
|
1858
|
+
resumable: Boolean(record.cwd && record.agentResume?.sessionId && !backend?.agent),
|
|
1859
|
+
}];
|
|
1860
|
+
});
|
|
1861
|
+
const incident = detectTmuxRecoveryIncident({
|
|
1862
|
+
previousServerPid: tmuxRecoveryState.lastServerPid,
|
|
1863
|
+
currentServerPid,
|
|
1864
|
+
candidates,
|
|
1865
|
+
liveSessionNames,
|
|
1866
|
+
intentionallyDeleting: intentionallyDeletingTmuxSessions,
|
|
1867
|
+
existingIncident: tmuxRecoveryState.incident,
|
|
1868
|
+
});
|
|
1869
|
+
let changed = false;
|
|
1870
|
+
if (incident !== tmuxRecoveryState.incident) {
|
|
1871
|
+
tmuxRecoveryState.incident = incident;
|
|
1872
|
+
changed = true;
|
|
1873
|
+
if (incident) {
|
|
1874
|
+
console.error(`[tmux-recovery] shared server loss detected; ${incident.affectedSessionIds.length} Agent session(s) can be restored`);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (currentServerPid !== null && currentServerPid !== tmuxRecoveryState.lastServerPid) {
|
|
1878
|
+
tmuxRecoveryState.lastServerPid = currentServerPid;
|
|
1879
|
+
changed = true;
|
|
1880
|
+
}
|
|
1881
|
+
if (changed)
|
|
1882
|
+
await persistTmuxRecoveryState();
|
|
1883
|
+
}
|
|
1788
1884
|
async function buildSessionInventory() {
|
|
1789
1885
|
const tmuxStatus = await getTmuxStatus();
|
|
1790
1886
|
let liveTmuxSessions = [];
|
|
@@ -1800,6 +1896,7 @@ async function buildSessionInventory() {
|
|
|
1800
1896
|
liveTmuxSessions = [];
|
|
1801
1897
|
}
|
|
1802
1898
|
}
|
|
1899
|
+
await observeTmuxServerGeneration(liveTmuxSessions);
|
|
1803
1900
|
const refreshedTmuxSessions = await Promise.all(liveTmuxSessions.map(async (tmux) => {
|
|
1804
1901
|
if (!isTermdockManagedTmuxSession(tmux)) {
|
|
1805
1902
|
return tmux;
|
|
@@ -1809,6 +1906,7 @@ async function buildSessionInventory() {
|
|
|
1809
1906
|
if (!metadata) {
|
|
1810
1907
|
return tmux;
|
|
1811
1908
|
}
|
|
1909
|
+
backfillAgentResumeFromTmuxProcess(tmux.name, metadata.rawArgs);
|
|
1812
1910
|
maybeRepairTmuxOptions(tmux.name, tmux, metadata);
|
|
1813
1911
|
return {
|
|
1814
1912
|
...tmux,
|
|
@@ -1902,6 +2000,9 @@ async function buildSessionInventory() {
|
|
|
1902
2000
|
connected: backendLive,
|
|
1903
2001
|
live,
|
|
1904
2002
|
restorable: !backendLive && ((session.mode === 'tmux' && tmuxLive)
|
|
2003
|
+
|| (session.mode === 'tmux' && Boolean(tmuxRecoveryState.incident?.affectedSessionIds.includes(session.sessionId)
|
|
2004
|
+
&& session.cwd
|
|
2005
|
+
&& session.agentResume?.sessionId))
|
|
1905
2006
|
|| (session.mode === 'shell' && canRestoreDeadAgentShell(session))),
|
|
1906
2007
|
activeProgram,
|
|
1907
2008
|
cwd,
|
|
@@ -1956,6 +2057,7 @@ async function buildSessionInventory() {
|
|
|
1956
2057
|
clientSessions,
|
|
1957
2058
|
tmuxSessions,
|
|
1958
2059
|
tmuxStatus,
|
|
2060
|
+
tmuxRecovery: getTmuxRecoveryView(),
|
|
1959
2061
|
updatedAt: Date.now(),
|
|
1960
2062
|
};
|
|
1961
2063
|
}
|
|
@@ -2299,6 +2401,40 @@ function splitCommandToArgv(command) {
|
|
|
2299
2401
|
.map((t) => t.replace(/^["']+|["']+$/g, ''))
|
|
2300
2402
|
.filter((t) => t.length > 0);
|
|
2301
2403
|
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Persist native resume metadata even when no browser PTY is currently
|
|
2406
|
+
* attached. The tmux pane remains authoritative in that state, and its
|
|
2407
|
+
* foreground argv is enough to recover an already-resumed Agent session.
|
|
2408
|
+
*/
|
|
2409
|
+
function backfillAgentResumeFromTmuxProcess(tmuxSessionName, rawArgs) {
|
|
2410
|
+
if (!rawArgs)
|
|
2411
|
+
return;
|
|
2412
|
+
const agent = detectAgentFromCommand(rawArgs, agentCustomCommands());
|
|
2413
|
+
if (!agent)
|
|
2414
|
+
return;
|
|
2415
|
+
const argv = splitCommandToArgv(rawArgs);
|
|
2416
|
+
const sessionId = inferResumeSessionId(agent, argv);
|
|
2417
|
+
if (!sessionId)
|
|
2418
|
+
return;
|
|
2419
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.mode === 'tmux' && candidate.tmuxSessionName === tmuxSessionName);
|
|
2420
|
+
if (!record)
|
|
2421
|
+
return;
|
|
2422
|
+
if (record.agentResume?.slug === agent.slug
|
|
2423
|
+
&& record.agentResume.sessionId === sessionId
|
|
2424
|
+
&& JSON.stringify(record.agentResume.launchArgv ?? null) === JSON.stringify(argv)) {
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
upsertGlobalSessionRecord({
|
|
2428
|
+
...record,
|
|
2429
|
+
agentResume: {
|
|
2430
|
+
slug: agent.slug,
|
|
2431
|
+
sessionId,
|
|
2432
|
+
launchArgv: argv,
|
|
2433
|
+
updatedAt: Date.now(),
|
|
2434
|
+
},
|
|
2435
|
+
});
|
|
2436
|
+
schedulePersistGlobalState();
|
|
2437
|
+
}
|
|
2302
2438
|
/** The coding agent running in this session's foreground, per activeProgram. */
|
|
2303
2439
|
function detectSessionAgent(session) {
|
|
2304
2440
|
const ap = session.activeProgram;
|
|
@@ -2641,18 +2777,29 @@ function syncAgentIdentity(sessionId, session) {
|
|
|
2641
2777
|
const argv = splitCommandToArgv(session.activeProgram.rawArgs);
|
|
2642
2778
|
if (argv.length > 0) {
|
|
2643
2779
|
const sess = session.agentSession;
|
|
2780
|
+
const inferredSessionId = inferResumeSessionId(detected, argv);
|
|
2781
|
+
if (sess && !sess.sessionId && inferredSessionId) {
|
|
2782
|
+
sess.sessionId = inferredSessionId;
|
|
2783
|
+
}
|
|
2644
2784
|
if (sess && sess.launchArgv === null) {
|
|
2645
2785
|
sess.launchArgv = argv;
|
|
2646
2786
|
persistAgentResumeBinding(sessionId, session);
|
|
2647
2787
|
broadcastAgentStatus(sessionId, session);
|
|
2648
2788
|
}
|
|
2649
|
-
else if (!sess) {
|
|
2650
|
-
//
|
|
2651
|
-
//
|
|
2652
|
-
// resumed with its flags even if hooks never fired.
|
|
2789
|
+
else if (!sess && inferredSessionId) {
|
|
2790
|
+
// The session-start hook may predate a server/tmux reattach. Recover
|
|
2791
|
+
// both the native id and flags from a live `agent resume <id>` argv.
|
|
2653
2792
|
const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
|
|
2654
|
-
|
|
2655
|
-
|
|
2793
|
+
const nextResume = {
|
|
2794
|
+
slug: detected.slug,
|
|
2795
|
+
sessionId: inferredSessionId,
|
|
2796
|
+
launchArgv: argv,
|
|
2797
|
+
updatedAt: Date.now(),
|
|
2798
|
+
};
|
|
2799
|
+
if (record && (record.agentResume?.slug !== nextResume.slug
|
|
2800
|
+
|| record.agentResume?.sessionId !== nextResume.sessionId
|
|
2801
|
+
|| JSON.stringify(record.agentResume?.launchArgv ?? null) !== JSON.stringify(nextResume.launchArgv))) {
|
|
2802
|
+
upsertGlobalSessionRecord({ ...record, agentResume: nextResume });
|
|
2656
2803
|
schedulePersistGlobalState();
|
|
2657
2804
|
}
|
|
2658
2805
|
}
|
|
@@ -3117,7 +3264,12 @@ async function resolveLiveTmuxMetadata(tmuxSessionName) {
|
|
|
3117
3264
|
const fallback = getActiveProgramFromTmuxLayout(layout);
|
|
3118
3265
|
const program = resolved?.command ?? fallback?.command ?? null;
|
|
3119
3266
|
const cwd = getCwdFromTmuxLayout(layout);
|
|
3120
|
-
return buildRuntimeTmuxMetadata({
|
|
3267
|
+
return buildRuntimeTmuxMetadata({
|
|
3268
|
+
tmuxSessionName,
|
|
3269
|
+
program,
|
|
3270
|
+
cwd,
|
|
3271
|
+
rawArgs: resolved?.rawArgs ?? null,
|
|
3272
|
+
});
|
|
3121
3273
|
}
|
|
3122
3274
|
// ── label builder (mirrors the frontend `getSessionDisplayLines` semantics) ──
|
|
3123
3275
|
//
|
|
@@ -3446,11 +3598,14 @@ async function ensureManagedTmuxSessionReady(sessionName) {
|
|
|
3446
3598
|
console.warn(`Failed to enable agent-hook plumbing for ${sessionName}: ${getErrorMessage(error)}`);
|
|
3447
3599
|
}
|
|
3448
3600
|
}
|
|
3449
|
-
async function
|
|
3601
|
+
async function prepareManagedTmuxSessionUnlocked(sessionName, cwd) {
|
|
3450
3602
|
await ensureTmuxSessionExists(sessionName, cwd);
|
|
3451
3603
|
await ensureSharedTmuxServerReady();
|
|
3452
3604
|
await ensureManagedTmuxSessionReady(sessionName);
|
|
3453
3605
|
}
|
|
3606
|
+
async function prepareManagedTmuxSession(sessionName, cwd) {
|
|
3607
|
+
await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
|
|
3608
|
+
}
|
|
3454
3609
|
async function getTmuxLayout(sessionName) {
|
|
3455
3610
|
const sessionInfoRaw = (await runTmux([
|
|
3456
3611
|
'display-message',
|
|
@@ -4178,7 +4333,10 @@ async function spawnTerminalSession(req, input) {
|
|
|
4178
4333
|
? ['attach-session', '-t', tmuxSessionName]
|
|
4179
4334
|
: (process.platform === 'win32' ? buildPowerShellCwdHookArgs() : []);
|
|
4180
4335
|
const envPath = buildAugmentedPath();
|
|
4181
|
-
const resolvedEnv = {
|
|
4336
|
+
const resolvedEnv = buildInteractiveColorEnvironment({
|
|
4337
|
+
...process.env,
|
|
4338
|
+
PATH: envPath,
|
|
4339
|
+
});
|
|
4182
4340
|
// The server itself may run under tmux; spawned shells are NOT tmux panes.
|
|
4183
4341
|
// Inheriting TMUX/TMUX_PANE makes tmux-aware programs (incl. our own
|
|
4184
4342
|
// agent-hook emitter's passthrough decision) misdetect their context.
|
|
@@ -4653,84 +4811,182 @@ router.get('/tmux/status', async (_req, res) => {
|
|
|
4653
4811
|
const status = await getTmuxStatus();
|
|
4654
4812
|
res.json(status);
|
|
4655
4813
|
});
|
|
4656
|
-
router.
|
|
4657
|
-
const
|
|
4658
|
-
if (!
|
|
4659
|
-
return res.status(
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4814
|
+
router.post('/tmux/recovery/restore-all', async (_req, res) => {
|
|
4815
|
+
const incident = tmuxRecoveryState.incident;
|
|
4816
|
+
if (!incident)
|
|
4817
|
+
return res.status(404).json({ error: 'no tmux recovery is pending' });
|
|
4818
|
+
const outcomes = [];
|
|
4819
|
+
for (const frontendSessionId of incident.affectedSessionIds) {
|
|
4820
|
+
const record = globalSessionState.sessions.find((session) => session.sessionId === frontendSessionId);
|
|
4821
|
+
if (!record || record.mode !== 'tmux' || !record.tmuxSessionName || !record.cwd
|
|
4822
|
+
|| !record.agentResume?.sessionId) {
|
|
4823
|
+
outcomes.push({ sessionId: frontendSessionId, restored: false, error: 'recovery metadata is incomplete' });
|
|
4824
|
+
continue;
|
|
4825
|
+
}
|
|
4826
|
+
const agent = agentBySlug(record.agentResume.slug);
|
|
4827
|
+
const command = agent
|
|
4828
|
+
? buildResumeCommand(agent, record.agentResume.sessionId, record.agentResume.launchArgv)
|
|
4829
|
+
: null;
|
|
4830
|
+
if (!agent || !command) {
|
|
4831
|
+
outcomes.push({ sessionId: frontendSessionId, restored: false, error: 'Agent does not support resume' });
|
|
4832
|
+
continue;
|
|
4833
|
+
}
|
|
4834
|
+
const target = {
|
|
4835
|
+
slug: agent.slug,
|
|
4836
|
+
nativeSessionId: record.agentResume.sessionId,
|
|
4837
|
+
command,
|
|
4838
|
+
};
|
|
4839
|
+
const activeBackend = findActiveAgentResumeOwner('', target);
|
|
4840
|
+
const activeWriterPid = activeBackend ? null : await findExternalCodexWriter(target);
|
|
4841
|
+
if (activeBackend || activeWriterPid) {
|
|
4842
|
+
outcomes.push({ sessionId: frontendSessionId, restored: true });
|
|
4843
|
+
continue;
|
|
4844
|
+
}
|
|
4845
|
+
try {
|
|
4846
|
+
await tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
|
|
4847
|
+
await prepareManagedTmuxSessionUnlocked(record.tmuxSessionName, record.cwd);
|
|
4848
|
+
const metadata = await resolveLiveTmuxMetadata(record.tmuxSessionName);
|
|
4849
|
+
const foreground = metadata?.program?.toLowerCase() ?? null;
|
|
4850
|
+
if (foreground && !shellNamesBackend.has(foreground)) {
|
|
4851
|
+
throw new Error(`pane is busy with ${foreground}`);
|
|
4852
|
+
}
|
|
4853
|
+
await runTmux(['send-keys', '-t', record.tmuxSessionName, '-l', '--', command]);
|
|
4854
|
+
await runTmux(['send-keys', '-t', record.tmuxSessionName, 'Enter']);
|
|
4855
|
+
});
|
|
4856
|
+
outcomes.push({ sessionId: frontendSessionId, restored: true });
|
|
4857
|
+
}
|
|
4858
|
+
catch (error) {
|
|
4859
|
+
outcomes.push({ sessionId: frontendSessionId, restored: false, error: getErrorMessage(error) });
|
|
4676
4860
|
}
|
|
4677
4861
|
}
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4862
|
+
const restoredIds = new Set(outcomes.filter((outcome) => outcome.restored).map((outcome) => outcome.sessionId));
|
|
4863
|
+
const remaining = incident.affectedSessionIds.filter((sessionId) => !restoredIds.has(sessionId));
|
|
4864
|
+
tmuxRecoveryState.incident = remaining.length > 0
|
|
4865
|
+
? { ...incident, affectedSessionIds: remaining }
|
|
4866
|
+
: null;
|
|
4867
|
+
await persistTmuxRecoveryState();
|
|
4868
|
+
latestSessionInventoryAt = 0;
|
|
4869
|
+
broadcastClientState();
|
|
4870
|
+
res.status(remaining.length > 0 ? 207 : 200).json({
|
|
4871
|
+
restored: restoredIds.size,
|
|
4872
|
+
failed: remaining.length,
|
|
4873
|
+
outcomes,
|
|
4874
|
+
});
|
|
4875
|
+
});
|
|
4876
|
+
router.delete('/tmux/recovery', async (_req, res) => {
|
|
4877
|
+
tmuxRecoveryState.incident = null;
|
|
4878
|
+
await persistTmuxRecoveryState();
|
|
4879
|
+
latestSessionInventoryAt = 0;
|
|
4880
|
+
broadcastClientState();
|
|
4881
|
+
res.status(204).send();
|
|
4882
|
+
});
|
|
4883
|
+
async function destroyTmuxSessionSafely(rawName) {
|
|
4884
|
+
return tmuxLifecycle.run(`destroy:${rawName}`, async () => {
|
|
4885
|
+
intentionallyDeletingTmuxSessions.add(rawName);
|
|
4886
|
+
try {
|
|
4887
|
+
const affectedSessionIds = [];
|
|
4888
|
+
for (const [sessionId, session] of terminalSessions.entries()) {
|
|
4889
|
+
if (session.mode === 'tmux' && session.tmuxSessionName === rawName) {
|
|
4890
|
+
session.focusTrackingRequested = false;
|
|
4891
|
+
session.focusAggregation.focusedClients.clear();
|
|
4892
|
+
session.focusAggregation.effectiveFocused = false;
|
|
4893
|
+
affectedSessionIds.push(sessionId);
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
let alreadyGone = false;
|
|
4897
|
+
try {
|
|
4898
|
+
// Reassert the safe global setting immediately before teardown in case
|
|
4899
|
+
// a user tmux command or config reload turned it back on at runtime.
|
|
4900
|
+
await disableTmuxFocusEventsForSafety();
|
|
4901
|
+
await runTmux(['detach-client', '-s', rawName]);
|
|
4902
|
+
}
|
|
4903
|
+
catch (error) {
|
|
4904
|
+
const errorMessage = getErrorMessage(error);
|
|
4905
|
+
if (isTmuxSessionMissingMessage(errorMessage) || isTmuxServerMissingMessage(errorMessage)) {
|
|
4906
|
+
alreadyGone = true;
|
|
4907
|
+
}
|
|
4908
|
+
else if (isTmuxUnavailableMessage(errorMessage)) {
|
|
4909
|
+
throw new HttpStatusError(503, 'tmux is not installed or not available in PATH.');
|
|
4910
|
+
}
|
|
4911
|
+
else {
|
|
4912
|
+
throw new HttpStatusError(500, errorMessage || 'Failed to detach tmux session clients');
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4686
4915
|
for (const id of affectedSessionIds) {
|
|
4687
4916
|
try {
|
|
4688
4917
|
cleanupSession(id, { killProcess: true });
|
|
4689
4918
|
}
|
|
4690
|
-
catch {
|
|
4919
|
+
catch (error) {
|
|
4920
|
+
console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
|
|
4921
|
+
}
|
|
4691
4922
|
}
|
|
4692
|
-
|
|
4923
|
+
if (!alreadyGone) {
|
|
4924
|
+
try {
|
|
4925
|
+
await runTmux(['kill-session', '-t', rawName]);
|
|
4926
|
+
}
|
|
4927
|
+
catch (error) {
|
|
4928
|
+
const errorMessage = getErrorMessage(error);
|
|
4929
|
+
if (isTmuxSessionMissingMessage(errorMessage) || isTmuxServerMissingMessage(errorMessage)) {
|
|
4930
|
+
alreadyGone = true;
|
|
4931
|
+
}
|
|
4932
|
+
else if (isTmuxUnavailableMessage(errorMessage)) {
|
|
4933
|
+
throw new HttpStatusError(503, 'tmux is not installed or not available in PATH.');
|
|
4934
|
+
}
|
|
4935
|
+
else {
|
|
4936
|
+
throw new HttpStatusError(500, errorMessage || 'Failed to kill tmux session');
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
const removedFrontendIds = globalSessionState.sessions
|
|
4941
|
+
.filter((session) => session.mode === 'tmux' && session.tmuxSessionName === rawName)
|
|
4942
|
+
.map((session) => session.sessionId);
|
|
4943
|
+
const removedSet = new Set(removedFrontendIds);
|
|
4944
|
+
const beforeCount = globalSessionState.sessions.length;
|
|
4945
|
+
globalSessionState = {
|
|
4946
|
+
sessions: globalSessionState.sessions.filter((session) => !removedSet.has(session.sessionId)),
|
|
4947
|
+
updatedAt: Date.now(),
|
|
4948
|
+
};
|
|
4949
|
+
if (tmuxRecoveryState.incident && removedSet.size > 0) {
|
|
4950
|
+
const remaining = tmuxRecoveryState.incident.affectedSessionIds
|
|
4951
|
+
.filter((sessionId) => !removedSet.has(sessionId));
|
|
4952
|
+
tmuxRecoveryState.incident = remaining.length > 0
|
|
4953
|
+
? { ...tmuxRecoveryState.incident, affectedSessionIds: remaining }
|
|
4954
|
+
: null;
|
|
4955
|
+
await persistTmuxRecoveryState();
|
|
4956
|
+
}
|
|
4957
|
+
if (globalSessionState.sessions.length !== beforeCount) {
|
|
4958
|
+
await persistGlobalStateNow();
|
|
4959
|
+
broadcastClientState();
|
|
4960
|
+
}
|
|
4961
|
+
console.log(`[tmux] ${alreadyGone ? 'removed stale' : 'killed'} session: ${rawName} (cleaned ${affectedSessionIds.length} attached pty, dropped ${beforeCount - globalSessionState.sessions.length} client-state entries)`);
|
|
4962
|
+
return {
|
|
4963
|
+
success: true,
|
|
4964
|
+
...(alreadyGone ? { alreadyGone: true } : {}),
|
|
4965
|
+
cleanedSessions: affectedSessionIds,
|
|
4966
|
+
};
|
|
4693
4967
|
}
|
|
4694
|
-
|
|
4695
|
-
|
|
4968
|
+
finally {
|
|
4969
|
+
intentionallyDeletingTmuxSessions.delete(rawName);
|
|
4696
4970
|
}
|
|
4697
|
-
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4973
|
+
router.delete('/tmux/sessions/:name', async (req, res) => {
|
|
4974
|
+
const rawName = typeof req.params.name === 'string' ? req.params.name.trim() : '';
|
|
4975
|
+
if (!rawName) {
|
|
4976
|
+
return res.status(400).json({ error: 'tmux session name is required' });
|
|
4698
4977
|
}
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
}
|
|
4703
|
-
catch (error) {
|
|
4704
|
-
console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
|
|
4705
|
-
}
|
|
4978
|
+
// tmux session names cannot contain ':' or '.'; reject anything that doesn't look right.
|
|
4979
|
+
if (/[:.\s]/.test(rawName)) {
|
|
4980
|
+
return res.status(400).json({ error: 'invalid tmux session name' });
|
|
4706
4981
|
}
|
|
4707
4982
|
try {
|
|
4708
|
-
await
|
|
4983
|
+
res.json(await destroyTmuxSessionSafely(rawName));
|
|
4709
4984
|
}
|
|
4710
4985
|
catch (error) {
|
|
4711
4986
|
const errorMessage = getErrorMessage(error);
|
|
4712
|
-
|
|
4713
|
-
|
|
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
|
-
}
|
|
4987
|
+
const status = error instanceof HttpStatusError ? error.statusCode : 500;
|
|
4988
|
+
res.status(status).json({ error: errorMessage || 'Failed to kill tmux session' });
|
|
4718
4989
|
}
|
|
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();
|
|
4731
|
-
}
|
|
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
4990
|
});
|
|
4735
4991
|
router.post('/serialize-state', async (req, res) => {
|
|
4736
4992
|
const ids = Array.isArray(req.body?.ids)
|
|
@@ -4819,6 +5075,14 @@ router.post('/operations/automations/:automationId/run', async (req, res) => {
|
|
|
4819
5075
|
res.status(409).json({ error: getErrorMessage(error), automation: automationStore.get(automation.id) });
|
|
4820
5076
|
}
|
|
4821
5077
|
});
|
|
5078
|
+
router.post('/operations/automations/:automationId/enabled', (req, res) => {
|
|
5079
|
+
if (typeof req.body?.enabled !== 'boolean')
|
|
5080
|
+
return res.status(400).json({ error: '启用状态无效' });
|
|
5081
|
+
const automation = automationStore.setEnabled(req.params.automationId, req.body.enabled);
|
|
5082
|
+
if (!automation)
|
|
5083
|
+
return res.status(404).json({ error: '自动任务不存在' });
|
|
5084
|
+
res.json({ automation });
|
|
5085
|
+
});
|
|
4822
5086
|
router.delete('/operations/automations/:automationId', (req, res) => {
|
|
4823
5087
|
if (!automationStore.remove(req.params.automationId))
|
|
4824
5088
|
return res.status(404).json({ error: '自动任务不存在' });
|
|
@@ -5979,7 +6243,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5979
6243
|
previousMetadata: lastTmuxMetadata,
|
|
5980
6244
|
lastActiveWriteAt: lastTmuxMetaWriteAt,
|
|
5981
6245
|
});
|
|
5982
|
-
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
|
|
6246
|
+
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
|
|
5983
6247
|
lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
|
|
5984
6248
|
const snapshot = JSON.stringify(layout);
|
|
5985
6249
|
if (snapshot === lastTmuxLayoutSnapshot) {
|
|
@@ -6684,7 +6948,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
6684
6948
|
previousMetadata: lastTmuxMetadata,
|
|
6685
6949
|
lastActiveWriteAt: lastTmuxMetaWriteAt,
|
|
6686
6950
|
});
|
|
6687
|
-
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
|
|
6951
|
+
lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label, rawArgs: null };
|
|
6688
6952
|
lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
|
|
6689
6953
|
const snapshot = JSON.stringify(layout);
|
|
6690
6954
|
if (snapshot !== lastTmuxLayoutSnapshot) {
|
|
@@ -6967,6 +7231,11 @@ automationTimer.unref?.();
|
|
|
6967
7231
|
async function reconcileClientState() {
|
|
6968
7232
|
if (globalSessionState.sessions.length === 0)
|
|
6969
7233
|
return;
|
|
7234
|
+
// Refresh first so a shared tmux crash is recorded before orphan cleanup
|
|
7235
|
+
// can discard the metadata needed to recover Agent conversations.
|
|
7236
|
+
await getSessionInventorySnapshot({ refresh: true }).catch((error) => {
|
|
7237
|
+
console.warn('[tmux-recovery] reconcile observation failed:', getErrorMessage(error));
|
|
7238
|
+
});
|
|
6970
7239
|
const toRemove = [];
|
|
6971
7240
|
const toDetach = [];
|
|
6972
7241
|
for (const entry of globalSessionState.sessions) {
|
|
@@ -6991,8 +7260,15 @@ async function reconcileClientState() {
|
|
|
6991
7260
|
// the tmux session.
|
|
6992
7261
|
try {
|
|
6993
7262
|
const alive = await tmuxSessionExists(entry.tmuxSessionName);
|
|
6994
|
-
if (!alive)
|
|
6995
|
-
|
|
7263
|
+
if (!alive) {
|
|
7264
|
+
if (entry.cwd && entry.agentResume?.sessionId) {
|
|
7265
|
+
if (entry.backendSessionId)
|
|
7266
|
+
toDetach.push(entry.sessionId);
|
|
7267
|
+
}
|
|
7268
|
+
else {
|
|
7269
|
+
toRemove.push(entry.sessionId);
|
|
7270
|
+
}
|
|
7271
|
+
}
|
|
6996
7272
|
}
|
|
6997
7273
|
catch {
|
|
6998
7274
|
// tmux itself is down — leave the entry alone; the next tick (or
|
|
@@ -7015,7 +7291,7 @@ async function reconcileClientState() {
|
|
|
7015
7291
|
console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
|
|
7016
7292
|
}
|
|
7017
7293
|
if (toDetach.length > 0) {
|
|
7018
|
-
console.log(`[reconcile] detached ${toDetach.length} resumable Agent
|
|
7294
|
+
console.log(`[reconcile] detached ${toDetach.length} resumable Agent entries: ${toDetach.join(', ')}`);
|
|
7019
7295
|
}
|
|
7020
7296
|
}
|
|
7021
7297
|
export default router;
|