termdock 1.4.97 → 1.4.99
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/.desktop-dist/main.js +211 -34
- package/dist/client/assets/{ModelPreview-DCC_cgBs.js → ModelPreview-CJK3Nmg4.js} +165 -165
- package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-C-QKkcSr.js} +1 -1
- package/dist/client/assets/{arc-oaeK9Fn4.js → arc-BpjFutHZ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-DeeuzGsk.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-BkDiKR3-.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-BX6424vk.js} +1 -1
- package/dist/client/assets/channel-BiaGwVDJ.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-DwNMfRvw.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-D5g3chKu.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-B2rw8Ldu.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-j3CVdb45.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-iHTR9CAX.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-DVD1wXze.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-zZRopnRd.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-h4EZpc4k.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-C9MZH2Oy.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BNPDO5fQ.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DYljVZD_.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DYljVZD_.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-DlqULumX.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-D6pWZSwi.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-B6SI7XOe.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-DXnKJ4XD.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-B5NTAhw6.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-Dihaal0B.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-Be987DPJ.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-CfgTeXAJ.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-BgrK8rP2.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-BPsH1_QO.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-F3s7Ytrc.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ByL-8svD.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-Dtv-fi06.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-CeD48POl.js} +1 -1
- package/dist/client/assets/index-C82Uc6Vy.js +451 -0
- package/dist/client/assets/index-RO2b079_.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-BxvLe33p.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-XPW1aoJK.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-Bhiae04Z.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-D5iOY3Zc.js} +1 -1
- package/dist/client/assets/{linear-BDTQPYjb.js → linear-PCgLsFTe.js} +1 -1
- package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-DWuc-xG5.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-C0EFK91v.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-ClTz8aHa.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-BuTv1DGN.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-FLZC-cqI.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DK68FlFh.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-B_701AJ1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Bk87L7JG.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-Biy5tPTS.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-__4zzUuh.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-B_400cf9.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-CK77br3V.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-CxWZz0n0.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DbYe8ax3.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-DSrIUp8H.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-CdwaUmpL.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-DwxlF0Xk.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CQNmEc_n.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw-notifications.js +7 -3
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/automationStore.js +151 -0
- package/dist/server/agent/collaborationStore.js +145 -0
- package/dist/server/agent/installers.js +50 -12
- package/dist/server/agent/plugins.js +9 -0
- package/dist/server/agent/registry.js +1 -0
- package/dist/server/agent/resumeHistory.js +100 -0
- package/dist/server/agent/resumePersistence.js +35 -0
- package/dist/server/agent/sessionSearchStore.js +124 -0
- package/dist/server/cli.js +139 -0
- package/dist/server/routes/terminal.js +709 -46
- package/package.json +3 -2
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-Cpdbh5br.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DWre1xlG.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DWre1xlG.js +0 -1
- package/dist/client/assets/index-BYhY8AKn.css +0 -1
- package/dist/client/assets/index-CshWxisE.js +0 -451
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DzD8Rob4.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-D8QuXUOK.js +0 -8
|
@@ -21,6 +21,11 @@ import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setCli
|
|
|
21
21
|
import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
|
|
22
22
|
import { agentBySlug, buildResumeCommand, detectAgentFromCommand, listAgents, } from '../agent/registry.js';
|
|
23
23
|
import { applyAgentEvent, agentEventMatchesCurrentAgent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
|
|
24
|
+
import { canRestoreDeadAgentShell, normalizePersistedAgentResumeInfo, } from '../agent/resumePersistence.js';
|
|
25
|
+
import { AgentResumeHistoryStore } from '../agent/resumeHistory.js';
|
|
26
|
+
import { AutomationStore, normalizeAutomationSchedule } from '../agent/automationStore.js';
|
|
27
|
+
import { CollaborationStore } from '../agent/collaborationStore.js';
|
|
28
|
+
import { SessionSearchStore } from '../agent/sessionSearchStore.js';
|
|
24
29
|
import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
|
|
25
30
|
import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
|
|
26
31
|
import { checkPluginPackageUpdate, commitPreparedPlugin, preparePluginPackage, } from '../agent/pluginPackages.js';
|
|
@@ -35,6 +40,10 @@ import { RenderedTerminalContext } from '../agent/renderedTerminalContext.js';
|
|
|
35
40
|
const router = express.Router();
|
|
36
41
|
const execFileAsync = promisify(execFile);
|
|
37
42
|
const TERMDOCK_DIR = `${os.homedir()}/.termdock`;
|
|
43
|
+
const automationStore = new AutomationStore(`${TERMDOCK_DIR}/automations.json`);
|
|
44
|
+
const collaborationStore = new CollaborationStore(`${TERMDOCK_DIR}/collaboration-groups.json`);
|
|
45
|
+
const sessionSearchStore = new SessionSearchStore(`${TERMDOCK_DIR}/session-search`);
|
|
46
|
+
let atomicJsonWriteSequence = 0;
|
|
38
47
|
async function readJsonFileIfExists(filePath) {
|
|
39
48
|
try {
|
|
40
49
|
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
|
@@ -48,7 +57,21 @@ async function readJsonFileIfExists(filePath) {
|
|
|
48
57
|
}
|
|
49
58
|
async function writeJsonFile(filePath, value) {
|
|
50
59
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
51
|
-
|
|
60
|
+
const temporaryPath = `${filePath}.${process.pid}.${++atomicJsonWriteSequence}.tmp`;
|
|
61
|
+
let handle = null;
|
|
62
|
+
try {
|
|
63
|
+
handle = await fs.promises.open(temporaryPath, 'w');
|
|
64
|
+
await handle.writeFile(JSON.stringify(value, null, 2), 'utf-8');
|
|
65
|
+
await handle.sync();
|
|
66
|
+
await handle.close();
|
|
67
|
+
handle = null;
|
|
68
|
+
await fs.promises.rename(temporaryPath, filePath);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await handle?.close().catch(() => undefined);
|
|
72
|
+
await fs.promises.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
52
75
|
}
|
|
53
76
|
// Termdock metadata constants used to populate tmux user options
|
|
54
77
|
// (`@termdock-*`) so external tools (e.g. `termdock --tls`) can identify
|
|
@@ -94,6 +117,7 @@ let globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
|
94
117
|
// ── 持久化 globalSessionState 到磁盘,防止服务重启后丢失 ──
|
|
95
118
|
const GLOBAL_SESSION_STATE_FILE = `${TERMDOCK_DIR}/global-session-state.json`;
|
|
96
119
|
const CLIENT_STATES_FILE = `${TERMDOCK_DIR}/client-states.json`; // 保留用于迁移
|
|
120
|
+
const agentResumeHistory = new AgentResumeHistoryStore(`${TERMDOCK_DIR}/agent-resume-history.json`);
|
|
97
121
|
let persistGlobalStateTimer = null;
|
|
98
122
|
let globalSessionStateWatcher = null;
|
|
99
123
|
let globalSessionStateReloadTimer = null;
|
|
@@ -473,21 +497,25 @@ void (async () => {
|
|
|
473
497
|
caffeinateManager.startNetworkMonitor();
|
|
474
498
|
// 清理磁盘恢复后后端已不存在的 session 引用。
|
|
475
499
|
// 服务重启时 terminalSessions 是空的,持久化的 global state
|
|
476
|
-
// 全部指向已销毁的 session
|
|
477
|
-
//
|
|
500
|
+
// 全部指向已销毁的 session。普通 shell session 的 PTY 已死无法复用;若记录
|
|
501
|
+
// 带有 Agent 原生 session id,则保留条目并清空 backendSessionId,让前端在原 cwd
|
|
502
|
+
// 建一个新 shell 后继续恢复 Agent。tmux 仍由独立进程负责存活。
|
|
478
503
|
function pruneOrphanSessions() {
|
|
479
504
|
let changed = false;
|
|
480
505
|
const cleaned = globalSessionState.sessions.filter((s) => {
|
|
481
|
-
//
|
|
506
|
+
// A shell PTY cannot be reattached after a host reboot, but its Agent
|
|
507
|
+
// conversation can be resumed in a newly-created shell.
|
|
482
508
|
if (s.mode !== 'tmux') {
|
|
483
509
|
if (s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
484
510
|
changed = true;
|
|
485
|
-
return
|
|
511
|
+
return canRestoreDeadAgentShell(s);
|
|
486
512
|
}
|
|
487
|
-
// backendSessionId already null
|
|
513
|
+
// backendSessionId already null: only a resumable Agent record is useful.
|
|
488
514
|
if (s.backendSessionId == null) {
|
|
489
|
-
|
|
490
|
-
|
|
515
|
+
const keep = canRestoreDeadAgentShell(s);
|
|
516
|
+
if (!keep)
|
|
517
|
+
changed = true;
|
|
518
|
+
return keep;
|
|
491
519
|
}
|
|
492
520
|
return true;
|
|
493
521
|
}
|
|
@@ -497,6 +525,9 @@ function pruneOrphanSessions() {
|
|
|
497
525
|
}
|
|
498
526
|
return true;
|
|
499
527
|
}).map((s) => {
|
|
528
|
+
if (s.mode === 'shell' && s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
529
|
+
return { ...s, backendSessionId: null };
|
|
530
|
+
}
|
|
500
531
|
if (s.mode === 'tmux' && s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
501
532
|
return { ...s, backendSessionId: null };
|
|
502
533
|
}
|
|
@@ -620,6 +651,8 @@ const CLEANUP_INTERVAL = TERMINAL.cleanupInterval;
|
|
|
620
651
|
const RECONNECT_SCROLLBACK = TERMINAL.reconnectScrollback;
|
|
621
652
|
const TMUX_POLL_INTERVAL = parseInt(process.env.TMUX_POLL_INTERVAL || '500', 10);
|
|
622
653
|
const ACTIVE_PROGRAM_POLL_INTERVAL = parseInt(process.env.TERMINAL_ACTIVE_PROGRAM_POLL_INTERVAL || '1200', 10);
|
|
654
|
+
const TMUX_LAYOUT_CACHE_TTL_MS = Math.max(TMUX_POLL_INTERVAL, 1000);
|
|
655
|
+
const PROCESS_SNAPSHOT_CACHE_TTL_MS = Math.max(ACTIVE_PROGRAM_POLL_INTERVAL, 2500);
|
|
623
656
|
const FLOW_CONTROL_PAUSE_LEASE_MS = TERMINAL.flowControlPauseLeaseMs;
|
|
624
657
|
const TMUX_DELIMITER = '\x1f';
|
|
625
658
|
const TERMDOCK_TMUX_HISTORY_LIMIT = TMUX.historyLimit;
|
|
@@ -734,6 +767,7 @@ function normalizePersistedClientSession(input) {
|
|
|
734
767
|
activeProgram: typeof candidate.activeProgram === 'string' && candidate.activeProgram.trim().length > 0
|
|
735
768
|
? candidate.activeProgram
|
|
736
769
|
: null,
|
|
770
|
+
agentResume: normalizePersistedAgentResumeInfo(candidate.agentResume),
|
|
737
771
|
};
|
|
738
772
|
}
|
|
739
773
|
function generateTmuxSessionName() {
|
|
@@ -893,7 +927,21 @@ function removeGlobalSessionRecord(frontendSessionId) {
|
|
|
893
927
|
sessions: globalSessionState.sessions.filter((session) => session.sessionId !== frontendSessionId),
|
|
894
928
|
updatedAt: Date.now(),
|
|
895
929
|
};
|
|
896
|
-
|
|
930
|
+
const changed = globalSessionState.sessions.length !== before;
|
|
931
|
+
if (changed)
|
|
932
|
+
collaborationStore.removeSession(frontendSessionId);
|
|
933
|
+
return changed;
|
|
934
|
+
}
|
|
935
|
+
function archiveAgentResumeRecord(record, reason) {
|
|
936
|
+
if (!record || record.mode !== 'shell' || !record.agentResume?.sessionId)
|
|
937
|
+
return false;
|
|
938
|
+
return agentResumeHistory.archive({
|
|
939
|
+
title: record.name.trim() || 'Terminal',
|
|
940
|
+
titleSource: record.customName === true ? 'custom' : record.autoTitle ? 'auto' : 'default',
|
|
941
|
+
agent: record.agentResume,
|
|
942
|
+
cwd: record.cwd ?? '',
|
|
943
|
+
reason,
|
|
944
|
+
}) !== null;
|
|
897
945
|
}
|
|
898
946
|
function getTrustedCwdFromRecord(record) {
|
|
899
947
|
return typeof record.cwd === 'string' && record.cwd.trim().length > 0
|
|
@@ -929,6 +977,9 @@ function getClientSessionView(inventory, frontendSessionId) {
|
|
|
929
977
|
return session;
|
|
930
978
|
}
|
|
931
979
|
async function ensureBackendSessionForRecord(req, record, options = {}) {
|
|
980
|
+
const shouldOfferRecoveredAgent = record.mode === 'shell'
|
|
981
|
+
&& canRestoreDeadAgentShell(record)
|
|
982
|
+
&& (!record.backendSessionId || !terminalSessions.has(record.backendSessionId));
|
|
932
983
|
if (record.backendSessionId) {
|
|
933
984
|
const existing = terminalSessions.get(record.backendSessionId);
|
|
934
985
|
if (existing) {
|
|
@@ -994,6 +1045,7 @@ async function ensureBackendSessionForRecord(req, record, options = {}) {
|
|
|
994
1045
|
tmuxSessionName: record.tmuxSessionName ?? undefined,
|
|
995
1046
|
termType: options.termType,
|
|
996
1047
|
});
|
|
1048
|
+
spawned.session.agentResumeRecovered = shouldOfferRecoveredAgent;
|
|
997
1049
|
record.backendSessionId = spawned.sessionId;
|
|
998
1050
|
record.mode = spawned.session.mode;
|
|
999
1051
|
record.tmuxSessionName = spawned.session.tmuxSessionName;
|
|
@@ -1128,6 +1180,138 @@ async function openInventorySession(req, input) {
|
|
|
1128
1180
|
reused,
|
|
1129
1181
|
};
|
|
1130
1182
|
}
|
|
1183
|
+
function orchestrationSessionSnapshot(record) {
|
|
1184
|
+
const backend = record.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1185
|
+
const agent = backend?.agent ?? (record.agentResume?.slug ? agentBySlug(record.agentResume.slug) : null);
|
|
1186
|
+
const latestPrompt = backend?.autoTitlePromptPayloads.at(-1)?.trim() ?? '';
|
|
1187
|
+
return {
|
|
1188
|
+
sessionId: record.sessionId,
|
|
1189
|
+
backendSessionId: record.backendSessionId ?? null,
|
|
1190
|
+
name: record.name,
|
|
1191
|
+
cwd: backend?.cwd ?? record.cwd ?? '',
|
|
1192
|
+
agent: agent ? { slug: agent.slug, displayName: agent.displayName } : null,
|
|
1193
|
+
status: backend?.agentSession?.status ?? (backend ? 'shell' : 'offline'),
|
|
1194
|
+
capability: agent
|
|
1195
|
+
? [agent.displayName, ...(agent.capabilities ?? []), backend?.activeProgram?.command || record.activeProgram || 'Agent 会话'].join(' · ')
|
|
1196
|
+
: (backend?.activeProgram?.command || record.activeProgram || 'Shell 终端'),
|
|
1197
|
+
currentTask: latestPrompt || record.name,
|
|
1198
|
+
updatedAt: backend?.lastActivity ?? record.lastActivity,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
function searchMetadataForBackend(backendSessionId, session) {
|
|
1202
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.backendSessionId === backendSessionId);
|
|
1203
|
+
if (!record)
|
|
1204
|
+
return null;
|
|
1205
|
+
return {
|
|
1206
|
+
sessionId: record.sessionId,
|
|
1207
|
+
backendSessionId,
|
|
1208
|
+
title: record.name,
|
|
1209
|
+
cwd: session.cwd ?? record.cwd ?? '',
|
|
1210
|
+
agentSlug: session.agent?.slug ?? record.agentResume?.slug ?? null,
|
|
1211
|
+
agentNativeSessionId: session.agentSession?.sessionId ?? record.agentResume?.sessionId ?? null,
|
|
1212
|
+
updatedAt: session.lastActivity,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function writeTerminalInput(session, value) {
|
|
1216
|
+
session.ptyProcess.write(value.replace(/\r?\n/g, '\r'));
|
|
1217
|
+
}
|
|
1218
|
+
function deliverAutomationPromptWhenReady(backendSessionId, prompt, attempt = 0) {
|
|
1219
|
+
const session = terminalSessions.get(backendSessionId);
|
|
1220
|
+
if (!session)
|
|
1221
|
+
return;
|
|
1222
|
+
if (session.agent || session.agentSession || attempt >= 60) {
|
|
1223
|
+
writeTerminalInput(session, `${prompt}\r`);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
setTimeout(() => deliverAutomationPromptWhenReady(backendSessionId, prompt, attempt + 1), 250);
|
|
1227
|
+
}
|
|
1228
|
+
async function runAgentAutomation(automation, req) {
|
|
1229
|
+
const run = automationStore.beginRun(automation);
|
|
1230
|
+
let frontendSessionId = null;
|
|
1231
|
+
try {
|
|
1232
|
+
if (automation.targetSessionId) {
|
|
1233
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.sessionId === automation.targetSessionId);
|
|
1234
|
+
const backend = record?.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1235
|
+
if (!record || !backend)
|
|
1236
|
+
throw new Error('目标会话当前不在线');
|
|
1237
|
+
if (!backend.agentSession)
|
|
1238
|
+
throw new Error('目标会话当前没有运行中的 Agent');
|
|
1239
|
+
frontendSessionId = record.sessionId;
|
|
1240
|
+
const message = automation.prompt || automation.command;
|
|
1241
|
+
if (!message)
|
|
1242
|
+
throw new Error('自动任务没有可发送的内容');
|
|
1243
|
+
writeTerminalInput(backend, `${message}\r`);
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
const request = req ?? {};
|
|
1247
|
+
const opened = await openInventorySession(request, {
|
|
1248
|
+
name: automation.name,
|
|
1249
|
+
customName: true,
|
|
1250
|
+
mode: 'shell',
|
|
1251
|
+
cwd: automation.cwd,
|
|
1252
|
+
});
|
|
1253
|
+
frontendSessionId = opened.session.sessionId;
|
|
1254
|
+
const backend = terminalSessions.get(opened.terminalSession.sessionId);
|
|
1255
|
+
if (!backend)
|
|
1256
|
+
throw new Error('自动任务会话创建失败');
|
|
1257
|
+
if (automation.command)
|
|
1258
|
+
writeTerminalInput(backend, `${automation.command}\r`);
|
|
1259
|
+
if (automation.prompt) {
|
|
1260
|
+
if (automation.command)
|
|
1261
|
+
deliverAutomationPromptWhenReady(opened.terminalSession.sessionId, automation.prompt);
|
|
1262
|
+
else
|
|
1263
|
+
writeTerminalInput(backend, `${automation.prompt}\r`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
automationStore.finishRun(run.id, 'success', frontendSessionId, '任务已投递');
|
|
1267
|
+
}
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
automationStore.finishRun(run.id, 'failed', frontendSessionId, getErrorMessage(error));
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function collaborationMessageLabel(kind) {
|
|
1274
|
+
return { message: '消息', ask: '问题', reply: '回复', task: '任务', handoff: '交接', done: '完成' }[kind];
|
|
1275
|
+
}
|
|
1276
|
+
function resolveFrontendSessionId(input) {
|
|
1277
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : '';
|
|
1278
|
+
if (sessionId && globalSessionState.sessions.some((record) => record.sessionId === sessionId))
|
|
1279
|
+
return sessionId;
|
|
1280
|
+
const backendSessionId = typeof input.backendSessionId === 'string' ? input.backendSessionId.trim() : '';
|
|
1281
|
+
if (backendSessionId) {
|
|
1282
|
+
const resolved = globalSessionState.sessions.find((record) => record.backendSessionId === backendSessionId)?.sessionId;
|
|
1283
|
+
if (resolved)
|
|
1284
|
+
return resolved;
|
|
1285
|
+
}
|
|
1286
|
+
const tmuxSessionName = typeof input.tmuxSessionName === 'string' ? input.tmuxSessionName.trim() : '';
|
|
1287
|
+
return globalSessionState.sessions.find((record) => record.tmuxSessionName === tmuxSessionName)?.sessionId ?? null;
|
|
1288
|
+
}
|
|
1289
|
+
function formatCollaborationDelivery(targetSessionId, messages) {
|
|
1290
|
+
const targetGroups = new Map(collaborationStore.groupsForSession(targetSessionId).map((group) => [group.id, group]));
|
|
1291
|
+
const lines = messages.map((message) => {
|
|
1292
|
+
const from = message.fromSessionId
|
|
1293
|
+
? globalSessionState.sessions.find((record) => record.sessionId === message.fromSessionId)?.name ?? message.fromSessionId
|
|
1294
|
+
: '用户';
|
|
1295
|
+
const group = targetGroups.get(message.groupId)?.name ?? '协作组';
|
|
1296
|
+
return `- [${collaborationMessageLabel(message.kind)} #${message.id}] ${from} → ${group}: ${message.content}`;
|
|
1297
|
+
});
|
|
1298
|
+
return `[Termdock 协作收件箱]\n${lines.join('\n')}\n请处理这些消息。需要回复时运行:td collab reply <消息ID> "回复内容";查看成员和未读消息运行:td collab status / td collab inbox。`;
|
|
1299
|
+
}
|
|
1300
|
+
function tryDeliverCollaborationInbox(frontendSessionId) {
|
|
1301
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.sessionId === frontendSessionId);
|
|
1302
|
+
const session = record?.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1303
|
+
const pending = collaborationStore.inbox(frontendSessionId, { pendingOnly: true, limit: 10 });
|
|
1304
|
+
// Rich hooks let us avoid interrupting an active turn. Third-party plugins
|
|
1305
|
+
// without hooks still participate through the universal detected-Agent PTY
|
|
1306
|
+
// path; their busy state is simply unknown, so delivery is immediate.
|
|
1307
|
+
if (!session?.agent || pending.length === 0
|
|
1308
|
+
|| (session.agentSession?.rich && !['idle', 'done'].includes(session.agentSession.status))) {
|
|
1309
|
+
return { delivered: [], pending: pending.length };
|
|
1310
|
+
}
|
|
1311
|
+
writeTerminalInput(session, `${formatCollaborationDelivery(frontendSessionId, pending)}\r`);
|
|
1312
|
+
collaborationStore.markDelivered(pending.map((message) => message.id));
|
|
1313
|
+
return { delivered: pending.map((message) => message.id), pending: 0 };
|
|
1314
|
+
}
|
|
1131
1315
|
function getTmuxBinary() {
|
|
1132
1316
|
if (process.env.TMUX_BIN)
|
|
1133
1317
|
return process.env.TMUX_BIN;
|
|
@@ -1716,7 +1900,8 @@ async function buildSessionInventory() {
|
|
|
1716
1900
|
customName: session.customName === true,
|
|
1717
1901
|
connected: backendLive,
|
|
1718
1902
|
live,
|
|
1719
|
-
restorable: session.mode === 'tmux' && tmuxLive
|
|
1903
|
+
restorable: !backendLive && ((session.mode === 'tmux' && tmuxLive)
|
|
1904
|
+
|| (session.mode === 'shell' && canRestoreDeadAgentShell(session))),
|
|
1720
1905
|
activeProgram,
|
|
1721
1906
|
cwd,
|
|
1722
1907
|
};
|
|
@@ -2360,6 +2545,7 @@ function buildAgentStatusPayload(sessionId, session) {
|
|
|
2360
2545
|
agentMessage: state?.message ?? null,
|
|
2361
2546
|
reviewed: state?.reviewed ?? session.lastAgentReviewed ?? null,
|
|
2362
2547
|
agentNativeSessionId: nativeSessionId,
|
|
2548
|
+
agentResumeRecovered: session.agentResumeRecovered,
|
|
2363
2549
|
agentRich: state?.rich ?? false,
|
|
2364
2550
|
agentActivity: state?.activity ?? 0,
|
|
2365
2551
|
agentCwd: state?.agentCwd ?? null,
|
|
@@ -2439,6 +2625,11 @@ function syncAgentIdentity(sessionId, session) {
|
|
|
2439
2625
|
}
|
|
2440
2626
|
session.agent = detected;
|
|
2441
2627
|
broadcastAgentStatus(sessionId, session);
|
|
2628
|
+
if (detected) {
|
|
2629
|
+
const frontendSessionId = globalSessionState.sessions.find((record) => record.backendSessionId === sessionId)?.sessionId;
|
|
2630
|
+
if (frontendSessionId)
|
|
2631
|
+
setTimeout(() => tryDeliverCollaborationInbox(frontendSessionId), 300).unref?.();
|
|
2632
|
+
}
|
|
2442
2633
|
}
|
|
2443
2634
|
else if (detected && session.agentLeftAt !== null) {
|
|
2444
2635
|
// Agent re-detected before debounce expired — false alarm, restore.
|
|
@@ -2499,6 +2690,7 @@ function applyAgentSignals(sessionId, session, events, notification) {
|
|
|
2499
2690
|
clearAutoTitleForNewAgentSession(sessionId, session);
|
|
2500
2691
|
}
|
|
2501
2692
|
if (event.kind === 'session-start') {
|
|
2693
|
+
session.agentResumeRecovered = false;
|
|
2502
2694
|
session.autoTitleObservedPrompt = false;
|
|
2503
2695
|
session.autoTitleTurnActive = false;
|
|
2504
2696
|
session.autoTitlePromptPayloads = [];
|
|
@@ -2560,6 +2752,12 @@ function applyAgentSignals(sessionId, session, events, notification) {
|
|
|
2560
2752
|
// one of the git probe's triggers.
|
|
2561
2753
|
maybeRefreshGitStatusForAgent(sessionId, session);
|
|
2562
2754
|
broadcastAgentStatus(sessionId, session, identityChanged);
|
|
2755
|
+
if (session.agentSession && ['idle', 'done'].includes(session.agentSession.status)) {
|
|
2756
|
+
const frontendSessionId = globalSessionState.sessions.find((record) => record.backendSessionId === sessionId)?.sessionId;
|
|
2757
|
+
if (frontendSessionId) {
|
|
2758
|
+
setTimeout(() => tryDeliverCollaborationInbox(frontendSessionId), 300).unref?.();
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2563
2761
|
if (completedTurnAgent) {
|
|
2564
2762
|
const agent = completedTurnAgent;
|
|
2565
2763
|
const forceReevaluation = session.autoTitleGeneratedMidTurn;
|
|
@@ -2773,6 +2971,51 @@ try {
|
|
|
2773
2971
|
}
|
|
2774
2972
|
}
|
|
2775
2973
|
catch { /* plugin loading must never block startup */ }
|
|
2974
|
+
let processSnapshot = null;
|
|
2975
|
+
let processSnapshotPromise = null;
|
|
2976
|
+
function parseProcessSnapshot(stdout) {
|
|
2977
|
+
return stdout
|
|
2978
|
+
.split('\n')
|
|
2979
|
+
.map((line) => line.trim())
|
|
2980
|
+
.filter(Boolean)
|
|
2981
|
+
.map((line) => {
|
|
2982
|
+
// ps -o format: TTY PID PPID PGID TPGID STAT COMM ARGS
|
|
2983
|
+
const match = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
|
|
2984
|
+
if (!match)
|
|
2985
|
+
return null;
|
|
2986
|
+
return {
|
|
2987
|
+
tty: match[1] || '',
|
|
2988
|
+
pid: Number.parseInt(match[2] || '0', 10),
|
|
2989
|
+
ppid: Number.parseInt(match[3] || '0', 10),
|
|
2990
|
+
pgid: Number.parseInt(match[4] || '0', 10),
|
|
2991
|
+
tpgid: Number.parseInt(match[5] || '0', 10),
|
|
2992
|
+
stat: match[6] || '',
|
|
2993
|
+
comm: match[7] || '',
|
|
2994
|
+
args: match[8]?.trim() || '',
|
|
2995
|
+
};
|
|
2996
|
+
})
|
|
2997
|
+
.filter((row) => row !== null);
|
|
2998
|
+
}
|
|
2999
|
+
async function getProcessSnapshot() {
|
|
3000
|
+
const now = Date.now();
|
|
3001
|
+
if (processSnapshot && now - processSnapshot.fetchedAt < PROCESS_SNAPSHOT_CACHE_TTL_MS) {
|
|
3002
|
+
return processSnapshot.rows;
|
|
3003
|
+
}
|
|
3004
|
+
if (processSnapshotPromise)
|
|
3005
|
+
return processSnapshotPromise;
|
|
3006
|
+
processSnapshotPromise = execFileAsync('ps', [
|
|
3007
|
+
'-e',
|
|
3008
|
+
'-o',
|
|
3009
|
+
'tty=,pid=,ppid=,pgid=,tpgid=,stat=,comm=,args=',
|
|
3010
|
+
], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 }).then(({ stdout }) => {
|
|
3011
|
+
const rows = parseProcessSnapshot(stdout);
|
|
3012
|
+
processSnapshot = { rows, fetchedAt: Date.now() };
|
|
3013
|
+
return rows;
|
|
3014
|
+
}).finally(() => {
|
|
3015
|
+
processSnapshotPromise = null;
|
|
3016
|
+
});
|
|
3017
|
+
return processSnapshotPromise;
|
|
3018
|
+
}
|
|
2776
3019
|
async function resolveTmuxPaneProgram(pane) {
|
|
2777
3020
|
// If pane command is a known shell, try to find a child foreground process
|
|
2778
3021
|
const command = normalizeProgramName(pane.command);
|
|
@@ -2788,31 +3031,36 @@ async function resolveTmuxPaneProgram(pane) {
|
|
|
2788
3031
|
return { command, source: 'tmux-pane', rawArgs: null };
|
|
2789
3032
|
}
|
|
2790
3033
|
try {
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
3034
|
+
let rows;
|
|
3035
|
+
if (process.platform === 'linux' && pane.tty) {
|
|
3036
|
+
const paneTty = pane.tty.replace(/^\/dev\//, '');
|
|
3037
|
+
rows = (await getProcessSnapshot()).filter((row) => row.tty === paneTty);
|
|
3038
|
+
}
|
|
3039
|
+
else {
|
|
3040
|
+
const psArgs = pane.tty
|
|
3041
|
+
? ['-t', pane.tty.replace(/^\/dev\//, ''), '-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args=']
|
|
3042
|
+
: ['-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args='];
|
|
3043
|
+
const { stdout } = await execFileAsync('ps', psArgs, { timeout: 3000, maxBuffer: 512 * 1024 });
|
|
3044
|
+
rows = stdout
|
|
3045
|
+
.split('\n')
|
|
3046
|
+
.map((line) => line.trim())
|
|
3047
|
+
.filter(Boolean)
|
|
3048
|
+
.map((line) => {
|
|
3049
|
+
const match = line.match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
|
|
3050
|
+
if (!match)
|
|
3051
|
+
return null;
|
|
3052
|
+
return {
|
|
3053
|
+
pid: Number.parseInt(match[1] || '0', 10),
|
|
3054
|
+
ppid: Number.parseInt(match[2] || '0', 10),
|
|
3055
|
+
pgid: Number.parseInt(match[3] || '0', 10),
|
|
3056
|
+
tpgid: Number.parseInt(match[4] || '0', 10),
|
|
3057
|
+
stat: match[5] || '',
|
|
3058
|
+
comm: match[6] || '',
|
|
3059
|
+
args: match[7]?.trim() || '',
|
|
3060
|
+
};
|
|
3061
|
+
})
|
|
3062
|
+
.filter((row) => row !== null);
|
|
3063
|
+
}
|
|
2816
3064
|
const selected = selectTmuxForegroundProgram({
|
|
2817
3065
|
panePid: pane.pid,
|
|
2818
3066
|
rows,
|
|
@@ -3282,6 +3530,115 @@ async function getTmuxLayout(sessionName) {
|
|
|
3282
3530
|
inCopyMode: paneInMode === '1',
|
|
3283
3531
|
};
|
|
3284
3532
|
}
|
|
3533
|
+
let tmuxLayoutsSnapshot = null;
|
|
3534
|
+
let tmuxLayoutsSnapshotPromise = null;
|
|
3535
|
+
async function buildTmuxLayoutsSnapshot() {
|
|
3536
|
+
const [sessionsRaw, windowsRaw, panesRaw] = await Promise.all([
|
|
3537
|
+
runTmux([
|
|
3538
|
+
'list-sessions',
|
|
3539
|
+
'-F',
|
|
3540
|
+
`#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
|
|
3541
|
+
]),
|
|
3542
|
+
runTmux([
|
|
3543
|
+
'list-windows',
|
|
3544
|
+
'-a',
|
|
3545
|
+
'-F',
|
|
3546
|
+
`#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
|
|
3547
|
+
]),
|
|
3548
|
+
runTmux([
|
|
3549
|
+
'list-panes',
|
|
3550
|
+
'-a',
|
|
3551
|
+
'-F',
|
|
3552
|
+
`#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
|
|
3553
|
+
]),
|
|
3554
|
+
]);
|
|
3555
|
+
const layouts = new Map();
|
|
3556
|
+
for (const line of sessionsRaw.trim().split('\n')) {
|
|
3557
|
+
if (!line)
|
|
3558
|
+
continue;
|
|
3559
|
+
const row = parseDelimitedRow(line, 5);
|
|
3560
|
+
if (!row)
|
|
3561
|
+
continue;
|
|
3562
|
+
const [sessionId, sessionName, activeWindowId, activePaneId, paneInMode] = row;
|
|
3563
|
+
layouts.set(sessionName, {
|
|
3564
|
+
sessionId,
|
|
3565
|
+
sessionName,
|
|
3566
|
+
windows: [],
|
|
3567
|
+
activeWindowId,
|
|
3568
|
+
activePaneId,
|
|
3569
|
+
inCopyMode: paneInMode === '1',
|
|
3570
|
+
});
|
|
3571
|
+
}
|
|
3572
|
+
const windowsById = new Map();
|
|
3573
|
+
for (const line of windowsRaw.trim().split('\n')) {
|
|
3574
|
+
if (!line)
|
|
3575
|
+
continue;
|
|
3576
|
+
const row = parseDelimitedRow(line, 5);
|
|
3577
|
+
if (!row)
|
|
3578
|
+
continue;
|
|
3579
|
+
const [sessionName, windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
|
|
3580
|
+
const layout = layouts.get(sessionName);
|
|
3581
|
+
if (!layout)
|
|
3582
|
+
continue;
|
|
3583
|
+
const window = {
|
|
3584
|
+
id: windowId,
|
|
3585
|
+
name: windowName || '',
|
|
3586
|
+
index: parseInt(windowIndexRaw || '0', 10),
|
|
3587
|
+
active: windowActiveRaw === '1',
|
|
3588
|
+
panes: [],
|
|
3589
|
+
};
|
|
3590
|
+
layout.windows.push(window);
|
|
3591
|
+
windowsById.set(`${sessionName}\0${windowId}`, window);
|
|
3592
|
+
}
|
|
3593
|
+
for (const line of panesRaw.trim().split('\n')) {
|
|
3594
|
+
if (!line)
|
|
3595
|
+
continue;
|
|
3596
|
+
const row = parseDelimitedRow(line, 14);
|
|
3597
|
+
if (!row)
|
|
3598
|
+
continue;
|
|
3599
|
+
const [sessionName, windowId, paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = row;
|
|
3600
|
+
const window = windowsById.get(`${sessionName}\0${windowId}`);
|
|
3601
|
+
if (!window)
|
|
3602
|
+
continue;
|
|
3603
|
+
window.panes.push({
|
|
3604
|
+
id: paneId,
|
|
3605
|
+
index: parseInt(paneIndexRaw || '0', 10),
|
|
3606
|
+
active: paneActiveRaw === '1',
|
|
3607
|
+
width: parseInt(widthRaw || '0', 10),
|
|
3608
|
+
height: parseInt(heightRaw || '0', 10),
|
|
3609
|
+
top: parseInt(topRaw || '0', 10),
|
|
3610
|
+
left: parseInt(leftRaw || '0', 10),
|
|
3611
|
+
command: command || '',
|
|
3612
|
+
pid: parseInt(pidRaw || '0', 10) || 0,
|
|
3613
|
+
tty: tty || '',
|
|
3614
|
+
title: title || '',
|
|
3615
|
+
currentPath: currentPath || '',
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
return layouts;
|
|
3619
|
+
}
|
|
3620
|
+
async function getCachedTmuxLayout(sessionName) {
|
|
3621
|
+
if (tmuxLayoutsSnapshot && Date.now() - tmuxLayoutsSnapshot.fetchedAt < TMUX_LAYOUT_CACHE_TTL_MS) {
|
|
3622
|
+
const cached = tmuxLayoutsSnapshot.layouts.get(sessionName);
|
|
3623
|
+
if (cached)
|
|
3624
|
+
return cached;
|
|
3625
|
+
}
|
|
3626
|
+
if (!tmuxLayoutsSnapshotPromise) {
|
|
3627
|
+
tmuxLayoutsSnapshotPromise = buildTmuxLayoutsSnapshot().then((layouts) => {
|
|
3628
|
+
tmuxLayoutsSnapshot = { layouts, fetchedAt: Date.now() };
|
|
3629
|
+
return layouts;
|
|
3630
|
+
}).finally(() => {
|
|
3631
|
+
tmuxLayoutsSnapshotPromise = null;
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
const layouts = await tmuxLayoutsSnapshotPromise;
|
|
3635
|
+
const layout = layouts.get(sessionName);
|
|
3636
|
+
if (layout)
|
|
3637
|
+
return layout;
|
|
3638
|
+
// A just-created session can race the global snapshot. Keep that rare path
|
|
3639
|
+
// correct without returning stale metadata for the new terminal.
|
|
3640
|
+
return getTmuxLayout(sessionName);
|
|
3641
|
+
}
|
|
3285
3642
|
async function getRestoreHistory(sessionId, session) {
|
|
3286
3643
|
// In tmux mode, scrollback belongs to tmux itself rather than the app layer,
|
|
3287
3644
|
// so capture the active pane to rebuild the visible screen on refresh.
|
|
@@ -3453,6 +3810,8 @@ function cleanupSession(sessionId, options) {
|
|
|
3453
3810
|
// exiting is normal (the user can detach/reconnect) and the tmux daemon
|
|
3454
3811
|
// itself is independent of the wrapper.
|
|
3455
3812
|
if (options.killProcess === false && session.mode === 'shell') {
|
|
3813
|
+
const closedRecord = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId) ?? null;
|
|
3814
|
+
archiveAgentResumeRecord(closedRecord, 'exited');
|
|
3456
3815
|
const beforeCount = globalSessionState.sessions.length;
|
|
3457
3816
|
globalSessionState = {
|
|
3458
3817
|
sessions: globalSessionState.sessions.filter((s) => s.backendSessionId !== sessionId),
|
|
@@ -3742,6 +4101,11 @@ function processSessionOutput(sessionId, session, data, opts) {
|
|
|
3742
4101
|
}
|
|
3743
4102
|
catch { /* sniff failure should never block data */ }
|
|
3744
4103
|
appendAutoTitleContext(sessionId, session, data, opts.broadcast);
|
|
4104
|
+
if (opts.broadcast) {
|
|
4105
|
+
const searchMetadata = searchMetadataForBackend(sessionId, session);
|
|
4106
|
+
if (searchMetadata)
|
|
4107
|
+
sessionSearchStore.append(searchMetadata, cleanTerminalContext(data));
|
|
4108
|
+
}
|
|
3745
4109
|
if (opts.broadcast) {
|
|
3746
4110
|
broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
|
|
3747
4111
|
}
|
|
@@ -3833,6 +4197,9 @@ async function spawnTerminalSession(req, input) {
|
|
|
3833
4197
|
// Gates the agent-hook emitter: hooks installed globally stay silent
|
|
3834
4198
|
// unless the agent runs inside a termdock-spawned shell.
|
|
3835
4199
|
TERMDOCK: '1',
|
|
4200
|
+
// Lets `td collab` address this exact Termdock conversation without the
|
|
4201
|
+
// user or Agent having to copy a session id.
|
|
4202
|
+
TERMDOCK_BACKEND_SESSION_ID: sessionId,
|
|
3836
4203
|
};
|
|
3837
4204
|
let ptyProcess = null;
|
|
3838
4205
|
if (mode === 'shell' && process.platform !== 'win32') {
|
|
@@ -3913,6 +4280,7 @@ async function spawnTerminalSession(req, input) {
|
|
|
3913
4280
|
activeProgram: null,
|
|
3914
4281
|
agent: null,
|
|
3915
4282
|
agentSession: null,
|
|
4283
|
+
agentResumeRecovered: false,
|
|
3916
4284
|
autoTitleContext: '',
|
|
3917
4285
|
autoTitleTerminal: new RenderedTerminalContext(cols, rows),
|
|
3918
4286
|
autoTitlePromptPayloads: [],
|
|
@@ -4001,6 +4369,7 @@ async function adoptPtyHostSessions() {
|
|
|
4001
4369
|
activeProgram: null,
|
|
4002
4370
|
agent: null,
|
|
4003
4371
|
agentSession: null,
|
|
4372
|
+
agentResumeRecovered: false,
|
|
4004
4373
|
autoTitleContext: '',
|
|
4005
4374
|
autoTitleTerminal: new RenderedTerminalContext(meta.cols, meta.rows),
|
|
4006
4375
|
autoTitlePromptPayloads: [],
|
|
@@ -4384,6 +4753,269 @@ router.get('/session-inventory', async (_req, res) => {
|
|
|
4384
4753
|
res.status(500).json({ error: errorMessage || 'Failed to build session inventory' });
|
|
4385
4754
|
}
|
|
4386
4755
|
});
|
|
4756
|
+
router.get('/operations/automations', (_req, res) => {
|
|
4757
|
+
res.json({ automations: automationStore.list(), runs: automationStore.listRuns() });
|
|
4758
|
+
});
|
|
4759
|
+
router.post('/operations/automations', async (req, res) => {
|
|
4760
|
+
try {
|
|
4761
|
+
const schedule = normalizeAutomationSchedule(req.body?.schedule);
|
|
4762
|
+
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
4763
|
+
const command = typeof req.body?.command === 'string' ? req.body.command.trim() : '';
|
|
4764
|
+
const prompt = typeof req.body?.prompt === 'string' ? req.body.prompt.trim() : '';
|
|
4765
|
+
const targetSessionId = typeof req.body?.targetSessionId === 'string' ? req.body.targetSessionId.trim() : '';
|
|
4766
|
+
if (!name || !schedule || (!command && !prompt)) {
|
|
4767
|
+
return res.status(400).json({ error: '名称、有效计划以及命令或提示词不能为空' });
|
|
4768
|
+
}
|
|
4769
|
+
const targetRecord = targetSessionId
|
|
4770
|
+
? globalSessionState.sessions.find((candidate) => candidate.sessionId === targetSessionId)
|
|
4771
|
+
: null;
|
|
4772
|
+
if (targetSessionId && !targetRecord)
|
|
4773
|
+
return res.status(400).json({ error: '目标会话不存在' });
|
|
4774
|
+
if (!targetSessionId && !command)
|
|
4775
|
+
return res.status(400).json({ error: '创建新会话时必须填写 Agent 启动命令' });
|
|
4776
|
+
const requestedCwd = typeof req.body?.cwd === 'string' && req.body.cwd.trim()
|
|
4777
|
+
? req.body.cwd.trim()
|
|
4778
|
+
: (targetRecord?.cwd || os.homedir());
|
|
4779
|
+
const cwd = await resolveWorkingDirectory(req, requestedCwd);
|
|
4780
|
+
const automation = automationStore.save({
|
|
4781
|
+
id: typeof req.body?.id === 'string' ? req.body.id : undefined,
|
|
4782
|
+
name,
|
|
4783
|
+
enabled: req.body?.enabled !== false,
|
|
4784
|
+
cwd,
|
|
4785
|
+
command,
|
|
4786
|
+
prompt,
|
|
4787
|
+
targetSessionId: targetSessionId || null,
|
|
4788
|
+
schedule,
|
|
4789
|
+
});
|
|
4790
|
+
res.json({ automation });
|
|
4791
|
+
}
|
|
4792
|
+
catch (error) {
|
|
4793
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4794
|
+
}
|
|
4795
|
+
});
|
|
4796
|
+
router.post('/operations/automations/:automationId/run', async (req, res) => {
|
|
4797
|
+
const automation = automationStore.get(req.params.automationId);
|
|
4798
|
+
if (!automation)
|
|
4799
|
+
return res.status(404).json({ error: '自动任务不存在' });
|
|
4800
|
+
try {
|
|
4801
|
+
await runAgentAutomation(automation, req);
|
|
4802
|
+
res.json({ ok: true, automation: automationStore.get(automation.id), runs: automationStore.listRuns(automation.id) });
|
|
4803
|
+
}
|
|
4804
|
+
catch (error) {
|
|
4805
|
+
res.status(409).json({ error: getErrorMessage(error), automation: automationStore.get(automation.id) });
|
|
4806
|
+
}
|
|
4807
|
+
});
|
|
4808
|
+
router.delete('/operations/automations/:automationId', (req, res) => {
|
|
4809
|
+
if (!automationStore.remove(req.params.automationId))
|
|
4810
|
+
return res.status(404).json({ error: '自动任务不存在' });
|
|
4811
|
+
res.status(204).send();
|
|
4812
|
+
});
|
|
4813
|
+
router.get('/operations/collaboration-groups', (_req, res) => {
|
|
4814
|
+
res.json({
|
|
4815
|
+
groups: collaborationStore.list(),
|
|
4816
|
+
sessions: globalSessionState.sessions.map(orchestrationSessionSnapshot),
|
|
4817
|
+
});
|
|
4818
|
+
});
|
|
4819
|
+
router.post('/operations/collaboration-groups', (req, res) => {
|
|
4820
|
+
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
4821
|
+
const sessionIds = Array.isArray(req.body?.sessionIds)
|
|
4822
|
+
? req.body.sessionIds.filter((id) => typeof id === 'string')
|
|
4823
|
+
: [];
|
|
4824
|
+
const knownIds = new Set(globalSessionState.sessions.map((session) => session.sessionId));
|
|
4825
|
+
const normalizedIds = Array.from(new Set(sessionIds.filter((id) => knownIds.has(id))));
|
|
4826
|
+
if (!name || normalizedIds.length < 2)
|
|
4827
|
+
return res.status(400).json({ error: '协作组至少需要两个有效会话' });
|
|
4828
|
+
const group = collaborationStore.save({
|
|
4829
|
+
id: typeof req.body?.id === 'string' ? req.body.id : undefined,
|
|
4830
|
+
name,
|
|
4831
|
+
sessionIds: normalizedIds,
|
|
4832
|
+
});
|
|
4833
|
+
res.json({ group });
|
|
4834
|
+
});
|
|
4835
|
+
router.delete('/operations/collaboration-groups/:groupId', (req, res) => {
|
|
4836
|
+
if (!collaborationStore.remove(req.params.groupId))
|
|
4837
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4838
|
+
res.status(204).send();
|
|
4839
|
+
});
|
|
4840
|
+
router.get('/operations/collaboration-groups/:groupId/messages', (req, res) => {
|
|
4841
|
+
const group = collaborationStore.getGroup(req.params.groupId);
|
|
4842
|
+
if (!group)
|
|
4843
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4844
|
+
res.json({ messages: collaborationStore.listMessages(group.id, Number(req.query.limit) || 200) });
|
|
4845
|
+
});
|
|
4846
|
+
router.post('/operations/collaboration-groups/:groupId/messages', (req, res) => {
|
|
4847
|
+
try {
|
|
4848
|
+
const group = collaborationStore.getGroup(req.params.groupId);
|
|
4849
|
+
if (!group)
|
|
4850
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4851
|
+
const fromSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4852
|
+
const kind = typeof req.body?.kind === 'string' ? req.body.kind : 'message';
|
|
4853
|
+
const content = typeof req.body?.content === 'string' ? req.body.content.slice(0, 20_000) : '';
|
|
4854
|
+
const requestedTargets = Array.isArray(req.body?.toSessionIds)
|
|
4855
|
+
? req.body.toSessionIds.filter((id) => typeof id === 'string')
|
|
4856
|
+
: [];
|
|
4857
|
+
const toSessionIds = requestedTargets.length > 0
|
|
4858
|
+
? requestedTargets
|
|
4859
|
+
: group.sessionIds.filter((id) => id !== fromSessionId);
|
|
4860
|
+
const messages = collaborationStore.send({
|
|
4861
|
+
groupId: group.id,
|
|
4862
|
+
fromSessionId,
|
|
4863
|
+
toSessionIds,
|
|
4864
|
+
kind,
|
|
4865
|
+
content,
|
|
4866
|
+
threadId: typeof req.body?.threadId === 'string' ? req.body.threadId : null,
|
|
4867
|
+
replyTo: typeof req.body?.replyTo === 'string' ? req.body.replyTo : null,
|
|
4868
|
+
});
|
|
4869
|
+
const deliveries = Array.from(new Set(messages.map((message) => message.toSessionId))).map((sessionId) => ({
|
|
4870
|
+
sessionId,
|
|
4871
|
+
...tryDeliverCollaborationInbox(sessionId),
|
|
4872
|
+
}));
|
|
4873
|
+
res.json({ messages, deliveries });
|
|
4874
|
+
}
|
|
4875
|
+
catch (error) {
|
|
4876
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4877
|
+
}
|
|
4878
|
+
});
|
|
4879
|
+
router.get('/operations/orchestration/peers', (req, res) => {
|
|
4880
|
+
const sourceSessionId = resolveFrontendSessionId(req.query);
|
|
4881
|
+
const source = globalSessionState.sessions.find((candidate) => candidate.sessionId === sourceSessionId);
|
|
4882
|
+
if (!source)
|
|
4883
|
+
return res.status(404).json({ error: '会话不存在' });
|
|
4884
|
+
const groups = collaborationStore.groupsForSession(source.sessionId);
|
|
4885
|
+
const peerIds = new Set(groups.flatMap((group) => group.sessionIds).filter((id) => id !== source.sessionId));
|
|
4886
|
+
res.json({ source: orchestrationSessionSnapshot(source), groups, peers: globalSessionState.sessions.filter((record) => peerIds.has(record.sessionId)).map(orchestrationSessionSnapshot) });
|
|
4887
|
+
});
|
|
4888
|
+
router.post('/operations/orchestration/send', (req, res) => {
|
|
4889
|
+
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4890
|
+
const targetSessionId = typeof req.body?.targetSessionId === 'string' ? req.body.targetSessionId.trim() : '';
|
|
4891
|
+
const content = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 20_000) : '';
|
|
4892
|
+
if (!sourceSessionId)
|
|
4893
|
+
return res.status(400).json({ error: '无法识别发送会话;请从 Termdock 会话内运行 td collab' });
|
|
4894
|
+
const group = collaborationStore.groupsForSession(sourceSessionId).find((candidate) => candidate.sessionIds.includes(targetSessionId));
|
|
4895
|
+
if (!group)
|
|
4896
|
+
return res.status(400).json({ error: '发送方和接收方不在同一协作组' });
|
|
4897
|
+
try {
|
|
4898
|
+
const messages = collaborationStore.send({
|
|
4899
|
+
groupId: group.id, fromSessionId: sourceSessionId, toSessionIds: [targetSessionId],
|
|
4900
|
+
kind: typeof req.body?.kind === 'string' ? req.body.kind : 'message',
|
|
4901
|
+
content,
|
|
4902
|
+
});
|
|
4903
|
+
res.json({ ok: true, messages, delivery: tryDeliverCollaborationInbox(targetSessionId) });
|
|
4904
|
+
}
|
|
4905
|
+
catch (error) {
|
|
4906
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4907
|
+
}
|
|
4908
|
+
});
|
|
4909
|
+
router.get('/operations/orchestration/inbox', (req, res) => {
|
|
4910
|
+
const sessionId = resolveFrontendSessionId(req.query);
|
|
4911
|
+
if (!sessionId)
|
|
4912
|
+
return res.status(404).json({ error: '会话不存在' });
|
|
4913
|
+
const messages = collaborationStore.inbox(sessionId, { limit: Number(req.query.limit) || 50 });
|
|
4914
|
+
if (req.query.markRead === 'true')
|
|
4915
|
+
collaborationStore.markRead(messages.map((message) => message.id));
|
|
4916
|
+
const groups = collaborationStore.groupsForSession(sessionId);
|
|
4917
|
+
const peerIds = new Set(groups.flatMap((group) => group.sessionIds).filter((id) => id !== sessionId));
|
|
4918
|
+
res.json({
|
|
4919
|
+
session: orchestrationSessionSnapshot(globalSessionState.sessions.find((record) => record.sessionId === sessionId)),
|
|
4920
|
+
groups,
|
|
4921
|
+
peers: globalSessionState.sessions.filter((record) => peerIds.has(record.sessionId)).map(orchestrationSessionSnapshot),
|
|
4922
|
+
messages,
|
|
4923
|
+
});
|
|
4924
|
+
});
|
|
4925
|
+
router.post('/operations/orchestration/reply', (req, res) => {
|
|
4926
|
+
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4927
|
+
const messageId = typeof req.body?.messageId === 'string' ? req.body.messageId.trim() : '';
|
|
4928
|
+
const content = typeof req.body?.content === 'string' ? req.body.content.trim().slice(0, 20_000) : '';
|
|
4929
|
+
const original = collaborationStore.getMessage(messageId);
|
|
4930
|
+
if (!sourceSessionId || !original || original.toSessionId !== sourceSessionId) {
|
|
4931
|
+
return res.status(404).json({ error: '协作消息不存在或不属于当前会话' });
|
|
4932
|
+
}
|
|
4933
|
+
if (!original.fromSessionId)
|
|
4934
|
+
return res.status(400).json({ error: '这条用户消息无需回复到其他会话' });
|
|
4935
|
+
try {
|
|
4936
|
+
collaborationStore.markRead([original.id]);
|
|
4937
|
+
const messages = collaborationStore.send({
|
|
4938
|
+
groupId: original.groupId,
|
|
4939
|
+
fromSessionId: sourceSessionId,
|
|
4940
|
+
toSessionIds: [original.fromSessionId],
|
|
4941
|
+
kind: 'reply',
|
|
4942
|
+
content,
|
|
4943
|
+
threadId: original.threadId,
|
|
4944
|
+
replyTo: original.id,
|
|
4945
|
+
});
|
|
4946
|
+
res.json({ ok: true, messages, delivery: tryDeliverCollaborationInbox(original.fromSessionId) });
|
|
4947
|
+
}
|
|
4948
|
+
catch (error) {
|
|
4949
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4950
|
+
}
|
|
4951
|
+
});
|
|
4952
|
+
router.get('/operations/session-search', (req, res) => {
|
|
4953
|
+
const query = typeof req.query.q === 'string' ? req.query.q : '';
|
|
4954
|
+
const results = sessionSearchStore.search(query, Number(req.query.limit) || 30).map((result) => {
|
|
4955
|
+
const live = globalSessionState.sessions.some((candidate) => candidate.sessionId === result.sessionId);
|
|
4956
|
+
const resumeEntry = result.agentNativeSessionId
|
|
4957
|
+
? agentResumeHistory.list().find((entry) => entry.agent.sessionId === result.agentNativeSessionId)
|
|
4958
|
+
: null;
|
|
4959
|
+
return { ...result, live, resumeHistoryId: resumeEntry?.id ?? null };
|
|
4960
|
+
});
|
|
4961
|
+
res.json({ query, results });
|
|
4962
|
+
});
|
|
4963
|
+
router.get('/agent-resume-history', (_req, res) => {
|
|
4964
|
+
const entries = agentResumeHistory.list().flatMap((entry) => {
|
|
4965
|
+
const agent = agentBySlug(entry.agent.slug);
|
|
4966
|
+
const command = agent ? buildResumeCommand(agent, entry.agent.sessionId, entry.agent.launchArgv) : null;
|
|
4967
|
+
if (!agent || !command)
|
|
4968
|
+
return [];
|
|
4969
|
+
return [{
|
|
4970
|
+
id: entry.id,
|
|
4971
|
+
title: entry.title,
|
|
4972
|
+
titleSource: entry.titleSource,
|
|
4973
|
+
agent,
|
|
4974
|
+
cwd: entry.cwd,
|
|
4975
|
+
closedAt: entry.closedAt,
|
|
4976
|
+
reason: entry.reason,
|
|
4977
|
+
}];
|
|
4978
|
+
});
|
|
4979
|
+
res.json({ entries });
|
|
4980
|
+
});
|
|
4981
|
+
router.post('/agent-resume-history/:historyId/prepare', async (req, res) => {
|
|
4982
|
+
const historyId = typeof req.params.historyId === 'string' ? req.params.historyId.trim() : '';
|
|
4983
|
+
const entry = historyId ? agentResumeHistory.get(historyId) : null;
|
|
4984
|
+
if (!entry)
|
|
4985
|
+
return res.status(404).json({ error: 'Agent resume history entry not found' });
|
|
4986
|
+
const agent = agentBySlug(entry.agent.slug);
|
|
4987
|
+
const command = agent ? buildResumeCommand(agent, entry.agent.sessionId, entry.agent.launchArgv) : null;
|
|
4988
|
+
if (!agent || !command) {
|
|
4989
|
+
return res.status(410).json({
|
|
4990
|
+
error: 'This Agent no longer has a valid resume command',
|
|
4991
|
+
code: 'AGENT_RESUME_UNAVAILABLE',
|
|
4992
|
+
});
|
|
4993
|
+
}
|
|
4994
|
+
const target = {
|
|
4995
|
+
slug: agent.slug,
|
|
4996
|
+
nativeSessionId: entry.agent.sessionId,
|
|
4997
|
+
command,
|
|
4998
|
+
};
|
|
4999
|
+
const activeOwner = findActiveAgentResumeOwner('', target);
|
|
5000
|
+
if (activeOwner || await findExternalCodexWriter(target)) {
|
|
5001
|
+
return res.status(409).json({
|
|
5002
|
+
error: 'this agent session is already open in another terminal',
|
|
5003
|
+
code: 'AGENT_SESSION_ACTIVE_ELSEWHERE',
|
|
5004
|
+
});
|
|
5005
|
+
}
|
|
5006
|
+
res.json({ command, cwd: entry.cwd, title: entry.title, agent });
|
|
5007
|
+
});
|
|
5008
|
+
router.delete('/agent-resume-history/:historyId', (req, res) => {
|
|
5009
|
+
const historyId = typeof req.params.historyId === 'string' ? req.params.historyId.trim() : '';
|
|
5010
|
+
if (!historyId || !agentResumeHistory.remove(historyId)) {
|
|
5011
|
+
return res.status(404).json({ error: 'Agent resume history entry not found' });
|
|
5012
|
+
}
|
|
5013
|
+
res.status(204).send();
|
|
5014
|
+
});
|
|
5015
|
+
router.delete('/agent-resume-history', (_req, res) => {
|
|
5016
|
+
agentResumeHistory.clear();
|
|
5017
|
+
res.status(204).send();
|
|
5018
|
+
});
|
|
4387
5019
|
router.post('/session-inventory/open', async (req, res) => {
|
|
4388
5020
|
try {
|
|
4389
5021
|
const input = req.body ?? {};
|
|
@@ -4474,6 +5106,7 @@ router.delete('/session-inventory/sessions/:frontendSessionId', async (req, res)
|
|
|
4474
5106
|
return res.status(400).json({ error: 'frontendSessionId is required' });
|
|
4475
5107
|
}
|
|
4476
5108
|
const removedSession = globalSessionState.sessions.find((session) => session.sessionId === frontendSessionId) ?? null;
|
|
5109
|
+
archiveAgentResumeRecord(removedSession, 'closed');
|
|
4477
5110
|
const changed = removeGlobalSessionRecord(frontendSessionId);
|
|
4478
5111
|
if (changed) {
|
|
4479
5112
|
if (removedSession?.mode === 'tmux' && removedSession.tmuxSessionName) {
|
|
@@ -4486,7 +5119,10 @@ router.delete('/session-inventory/sessions/:frontendSessionId', async (req, res)
|
|
|
4486
5119
|
});
|
|
4487
5120
|
router.delete('/session-inventory/sessions', async (_req, res) => {
|
|
4488
5121
|
await markAllPersistedTmuxSessionsDetached();
|
|
5122
|
+
for (const session of globalSessionState.sessions)
|
|
5123
|
+
archiveAgentResumeRecord(session, 'closed');
|
|
4489
5124
|
globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
5125
|
+
collaborationStore.clear();
|
|
4490
5126
|
schedulePersistGlobalState();
|
|
4491
5127
|
broadcastClientState();
|
|
4492
5128
|
res.status(204).send();
|
|
@@ -4500,6 +5136,7 @@ router.put('/client-state', (_req, res) => {
|
|
|
4500
5136
|
router.delete('/client-state', async (_req, res) => {
|
|
4501
5137
|
await markAllPersistedTmuxSessionsDetached();
|
|
4502
5138
|
globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
5139
|
+
collaborationStore.clear();
|
|
4503
5140
|
schedulePersistGlobalState();
|
|
4504
5141
|
broadcastClientState();
|
|
4505
5142
|
res.status(204).send();
|
|
@@ -4739,6 +5376,7 @@ router.get('/agent-launchers', async (_req, res) => {
|
|
|
4739
5376
|
slug: agent.slug,
|
|
4740
5377
|
displayName: agent.displayName,
|
|
4741
5378
|
command,
|
|
5379
|
+
capabilities: agent.capabilities,
|
|
4742
5380
|
accentColor: agent.accentColor,
|
|
4743
5381
|
icon: agent.icon,
|
|
4744
5382
|
isPlugin: agent.isPlugin ?? false,
|
|
@@ -4819,6 +5457,7 @@ router.get('/agent-plugins', (_req, res) => {
|
|
|
4819
5457
|
slug: p.manifest.slug,
|
|
4820
5458
|
displayName: p.manifest.displayName,
|
|
4821
5459
|
aliases: p.manifest.aliases,
|
|
5460
|
+
capabilities: p.manifest.capabilities ?? [],
|
|
4822
5461
|
accentColor: p.manifest.accentColor,
|
|
4823
5462
|
iconMode: p.manifest.iconMode ?? 'mask',
|
|
4824
5463
|
hasHooks: p.manifest.hooks !== undefined,
|
|
@@ -5187,7 +5826,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5187
5826
|
session.activeProgram = ap;
|
|
5188
5827
|
}
|
|
5189
5828
|
else if (session.mode === 'tmux' && session.tmuxSessionName) {
|
|
5190
|
-
const layout = await
|
|
5829
|
+
const layout = await getCachedTmuxLayout(session.tmuxSessionName);
|
|
5191
5830
|
const activePane = getActivePaneFromLayout(layout);
|
|
5192
5831
|
const ap = activePane
|
|
5193
5832
|
? await resolveTmuxPaneProgram(activePane)
|
|
@@ -5229,7 +5868,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5229
5868
|
});
|
|
5230
5869
|
// Rich agent state rides its own message so the connected payload stays
|
|
5231
5870
|
// backward compatible; force past the dedupe snapshot.
|
|
5232
|
-
if (session.agent || session.agentSession) {
|
|
5871
|
+
if (session.agent || session.agentSession || session.agentResumeRecovered) {
|
|
5233
5872
|
writeSse(res, buildAgentStatusPayload(sessionId, session));
|
|
5234
5873
|
}
|
|
5235
5874
|
if (session.gitStatus) {
|
|
@@ -5264,7 +5903,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5264
5903
|
return;
|
|
5265
5904
|
}
|
|
5266
5905
|
try {
|
|
5267
|
-
const layout = await
|
|
5906
|
+
const layout = await getCachedTmuxLayout(session.tmuxSessionName);
|
|
5268
5907
|
// Resolve the active program — try ps-based detection for generic commands
|
|
5269
5908
|
const activePane = getActivePaneFromLayout(layout);
|
|
5270
5909
|
if (activePane) {
|
|
@@ -5873,7 +6512,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
5873
6512
|
session.activeProgram = ap;
|
|
5874
6513
|
}
|
|
5875
6514
|
else if (session.mode === 'tmux' && session.tmuxSessionName) {
|
|
5876
|
-
const layout = await
|
|
6515
|
+
const layout = await getCachedTmuxLayout(session.tmuxSessionName);
|
|
5877
6516
|
const ap = getActiveProgramFromTmuxLayout(layout);
|
|
5878
6517
|
if (ap)
|
|
5879
6518
|
session.activeProgram = ap;
|
|
@@ -5937,7 +6576,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
5937
6576
|
replayOutOfWindow,
|
|
5938
6577
|
}));
|
|
5939
6578
|
// Rich agent state rides its own message (see the SSE path above).
|
|
5940
|
-
if (session.agent || session.agentSession) {
|
|
6579
|
+
if (session.agent || session.agentSession || session.agentResumeRecovered) {
|
|
5941
6580
|
ws.send(JSON.stringify(buildAgentStatusPayload(sessionId, session)));
|
|
5942
6581
|
}
|
|
5943
6582
|
if (session.gitStatus) {
|
|
@@ -5956,7 +6595,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
5956
6595
|
if (ws.readyState !== ws.OPEN)
|
|
5957
6596
|
return;
|
|
5958
6597
|
try {
|
|
5959
|
-
const layout = await
|
|
6598
|
+
const layout = await getCachedTmuxLayout(session.tmuxSessionName);
|
|
5960
6599
|
// Update active program — try ps-based detection for generic commands
|
|
5961
6600
|
const activePane = getActivePaneFromLayout(layout);
|
|
5962
6601
|
let ap = null;
|
|
@@ -6299,16 +6938,32 @@ const reconcileTimer = setInterval(() => {
|
|
|
6299
6938
|
}, CLIENT_STATE_RECONCILE_INTERVAL_MS);
|
|
6300
6939
|
// Don't keep the event loop alive for housekeeping.
|
|
6301
6940
|
reconcileTimer.unref?.();
|
|
6941
|
+
const automationTimer = setInterval(() => {
|
|
6942
|
+
for (const automation of automationStore.due()) {
|
|
6943
|
+
void runAgentAutomation(automation).catch((error) => {
|
|
6944
|
+
console.warn(`[automation] ${automation.id} failed:`, getErrorMessage(error));
|
|
6945
|
+
});
|
|
6946
|
+
}
|
|
6947
|
+
}, 30_000);
|
|
6948
|
+
automationTimer.unref?.();
|
|
6302
6949
|
async function reconcileClientState() {
|
|
6303
6950
|
if (globalSessionState.sessions.length === 0)
|
|
6304
6951
|
return;
|
|
6305
6952
|
const toRemove = [];
|
|
6953
|
+
const toDetach = [];
|
|
6306
6954
|
for (const entry of globalSessionState.sessions) {
|
|
6307
6955
|
if (entry.mode === 'shell') {
|
|
6308
6956
|
// Shell wrapper: backendSessionId must map to a live terminal session.
|
|
6309
|
-
//
|
|
6957
|
+
// A resumable Agent record survives with a detached backend so the tab
|
|
6958
|
+
// can rebuild its shell; ordinary dead shells still disappear.
|
|
6310
6959
|
if (!entry.backendSessionId || !terminalSessions.has(entry.backendSessionId)) {
|
|
6311
|
-
|
|
6960
|
+
if (canRestoreDeadAgentShell(entry)) {
|
|
6961
|
+
if (entry.backendSessionId)
|
|
6962
|
+
toDetach.push(entry.sessionId);
|
|
6963
|
+
}
|
|
6964
|
+
else {
|
|
6965
|
+
toRemove.push(entry.sessionId);
|
|
6966
|
+
}
|
|
6312
6967
|
}
|
|
6313
6968
|
}
|
|
6314
6969
|
else if (entry.mode === 'tmux' && entry.tmuxSessionName) {
|
|
@@ -6327,14 +6982,22 @@ async function reconcileClientState() {
|
|
|
6327
6982
|
}
|
|
6328
6983
|
}
|
|
6329
6984
|
}
|
|
6330
|
-
if (toRemove.length === 0)
|
|
6985
|
+
if (toRemove.length === 0 && toDetach.length === 0)
|
|
6331
6986
|
return;
|
|
6987
|
+
const detached = new Set(toDetach);
|
|
6332
6988
|
globalSessionState = {
|
|
6333
|
-
sessions: globalSessionState.sessions
|
|
6989
|
+
sessions: globalSessionState.sessions
|
|
6990
|
+
.filter((s) => !toRemove.includes(s.sessionId))
|
|
6991
|
+
.map((s) => detached.has(s.sessionId) ? { ...s, backendSessionId: null } : s),
|
|
6334
6992
|
updatedAt: Date.now(),
|
|
6335
6993
|
};
|
|
6336
6994
|
schedulePersistGlobalState();
|
|
6337
6995
|
broadcastClientState();
|
|
6338
|
-
|
|
6996
|
+
if (toRemove.length > 0) {
|
|
6997
|
+
console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
|
|
6998
|
+
}
|
|
6999
|
+
if (toDetach.length > 0) {
|
|
7000
|
+
console.log(`[reconcile] detached ${toDetach.length} resumable Agent shell entries: ${toDetach.join(', ')}`);
|
|
7001
|
+
}
|
|
6339
7002
|
}
|
|
6340
7003
|
export default router;
|