termdock 1.4.97 → 1.4.98
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-DCC_cgBs.js → ModelPreview-Drqv0OTg.js} +165 -165
- package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-Cl8CT45X.js} +1 -1
- package/dist/client/assets/{arc-oaeK9Fn4.js → arc-2Sc638oe.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-CNH9w9eN.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-ClvE_yP1.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-CxIMU8l9.js} +1 -1
- package/dist/client/assets/channel-DPt818Jx.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-CdvXmc6Z.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-B1dA4l5A.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-CVk_pzpf.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-B46zhCa0.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-1wRPE3eO.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-BzPrhNIS.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-D0sDGnBf.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-BRo0103H.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-CW-wSkDE.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BuWiQ0uq.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C2hZbTnO.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C2hZbTnO.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-WzKVd03Q.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-CfXYAqnq.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-BnJGle_X.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-CDDlGCQy.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-t8DNZS7P.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-CZpQd91k.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-OET0EFlt.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-COgju4NX.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-6gNSoIek.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-CTlIMc8W.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-BDsEvZvq.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ggl4zaY9.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-D9Ca5oWS.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-THPUH6Bg.js} +1 -1
- package/dist/client/assets/index-BuMMW0wS.js +451 -0
- package/dist/client/assets/index-RO2b079_.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-CKF-vSTz.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-CBFlmgI3.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-lbXIqX5a.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-BUuO_e54.js} +1 -1
- package/dist/client/assets/{linear-BDTQPYjb.js → linear-D_uoNzR8.js} +1 -1
- package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-D4Yy5OxZ.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-B0Ml-jaq.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-CKF9cuqx.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-DL3XisAD.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-DrsHXXqd.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DMAz8hLg.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-CCHretnQ.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Btb7ZNwU.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-DcZk1PdX.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-vMYb_VZU.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-CltexqJj.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BslbFNw3.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-DPnwlpXX.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-B0V1XVQb.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-l78vfKln.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-BggNEe_f.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-v04USz2e.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CInHBOpK.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 +96 -0
- package/dist/server/cli.js +139 -0
- package/dist/server/routes/terminal.js +503 -16
- package/package.json +1 -1
- 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,9 @@ 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`);
|
|
38
46
|
async function readJsonFileIfExists(filePath) {
|
|
39
47
|
try {
|
|
40
48
|
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
|
@@ -94,6 +102,7 @@ let globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
|
94
102
|
// ── 持久化 globalSessionState 到磁盘,防止服务重启后丢失 ──
|
|
95
103
|
const GLOBAL_SESSION_STATE_FILE = `${TERMDOCK_DIR}/global-session-state.json`;
|
|
96
104
|
const CLIENT_STATES_FILE = `${TERMDOCK_DIR}/client-states.json`; // 保留用于迁移
|
|
105
|
+
const agentResumeHistory = new AgentResumeHistoryStore(`${TERMDOCK_DIR}/agent-resume-history.json`);
|
|
97
106
|
let persistGlobalStateTimer = null;
|
|
98
107
|
let globalSessionStateWatcher = null;
|
|
99
108
|
let globalSessionStateReloadTimer = null;
|
|
@@ -473,21 +482,25 @@ void (async () => {
|
|
|
473
482
|
caffeinateManager.startNetworkMonitor();
|
|
474
483
|
// 清理磁盘恢复后后端已不存在的 session 引用。
|
|
475
484
|
// 服务重启时 terminalSessions 是空的,持久化的 global state
|
|
476
|
-
// 全部指向已销毁的 session
|
|
477
|
-
//
|
|
485
|
+
// 全部指向已销毁的 session。普通 shell session 的 PTY 已死无法复用;若记录
|
|
486
|
+
// 带有 Agent 原生 session id,则保留条目并清空 backendSessionId,让前端在原 cwd
|
|
487
|
+
// 建一个新 shell 后继续恢复 Agent。tmux 仍由独立进程负责存活。
|
|
478
488
|
function pruneOrphanSessions() {
|
|
479
489
|
let changed = false;
|
|
480
490
|
const cleaned = globalSessionState.sessions.filter((s) => {
|
|
481
|
-
//
|
|
491
|
+
// A shell PTY cannot be reattached after a host reboot, but its Agent
|
|
492
|
+
// conversation can be resumed in a newly-created shell.
|
|
482
493
|
if (s.mode !== 'tmux') {
|
|
483
494
|
if (s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
484
495
|
changed = true;
|
|
485
|
-
return
|
|
496
|
+
return canRestoreDeadAgentShell(s);
|
|
486
497
|
}
|
|
487
|
-
// backendSessionId already null
|
|
498
|
+
// backendSessionId already null: only a resumable Agent record is useful.
|
|
488
499
|
if (s.backendSessionId == null) {
|
|
489
|
-
|
|
490
|
-
|
|
500
|
+
const keep = canRestoreDeadAgentShell(s);
|
|
501
|
+
if (!keep)
|
|
502
|
+
changed = true;
|
|
503
|
+
return keep;
|
|
491
504
|
}
|
|
492
505
|
return true;
|
|
493
506
|
}
|
|
@@ -497,6 +510,9 @@ function pruneOrphanSessions() {
|
|
|
497
510
|
}
|
|
498
511
|
return true;
|
|
499
512
|
}).map((s) => {
|
|
513
|
+
if (s.mode === 'shell' && s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
514
|
+
return { ...s, backendSessionId: null };
|
|
515
|
+
}
|
|
500
516
|
if (s.mode === 'tmux' && s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
|
|
501
517
|
return { ...s, backendSessionId: null };
|
|
502
518
|
}
|
|
@@ -734,6 +750,7 @@ function normalizePersistedClientSession(input) {
|
|
|
734
750
|
activeProgram: typeof candidate.activeProgram === 'string' && candidate.activeProgram.trim().length > 0
|
|
735
751
|
? candidate.activeProgram
|
|
736
752
|
: null,
|
|
753
|
+
agentResume: normalizePersistedAgentResumeInfo(candidate.agentResume),
|
|
737
754
|
};
|
|
738
755
|
}
|
|
739
756
|
function generateTmuxSessionName() {
|
|
@@ -893,7 +910,21 @@ function removeGlobalSessionRecord(frontendSessionId) {
|
|
|
893
910
|
sessions: globalSessionState.sessions.filter((session) => session.sessionId !== frontendSessionId),
|
|
894
911
|
updatedAt: Date.now(),
|
|
895
912
|
};
|
|
896
|
-
|
|
913
|
+
const changed = globalSessionState.sessions.length !== before;
|
|
914
|
+
if (changed)
|
|
915
|
+
collaborationStore.removeSession(frontendSessionId);
|
|
916
|
+
return changed;
|
|
917
|
+
}
|
|
918
|
+
function archiveAgentResumeRecord(record, reason) {
|
|
919
|
+
if (!record || record.mode !== 'shell' || !record.agentResume?.sessionId)
|
|
920
|
+
return false;
|
|
921
|
+
return agentResumeHistory.archive({
|
|
922
|
+
title: record.name.trim() || 'Terminal',
|
|
923
|
+
titleSource: record.customName === true ? 'custom' : record.autoTitle ? 'auto' : 'default',
|
|
924
|
+
agent: record.agentResume,
|
|
925
|
+
cwd: record.cwd ?? '',
|
|
926
|
+
reason,
|
|
927
|
+
}) !== null;
|
|
897
928
|
}
|
|
898
929
|
function getTrustedCwdFromRecord(record) {
|
|
899
930
|
return typeof record.cwd === 'string' && record.cwd.trim().length > 0
|
|
@@ -929,6 +960,9 @@ function getClientSessionView(inventory, frontendSessionId) {
|
|
|
929
960
|
return session;
|
|
930
961
|
}
|
|
931
962
|
async function ensureBackendSessionForRecord(req, record, options = {}) {
|
|
963
|
+
const shouldOfferRecoveredAgent = record.mode === 'shell'
|
|
964
|
+
&& canRestoreDeadAgentShell(record)
|
|
965
|
+
&& (!record.backendSessionId || !terminalSessions.has(record.backendSessionId));
|
|
932
966
|
if (record.backendSessionId) {
|
|
933
967
|
const existing = terminalSessions.get(record.backendSessionId);
|
|
934
968
|
if (existing) {
|
|
@@ -994,6 +1028,7 @@ async function ensureBackendSessionForRecord(req, record, options = {}) {
|
|
|
994
1028
|
tmuxSessionName: record.tmuxSessionName ?? undefined,
|
|
995
1029
|
termType: options.termType,
|
|
996
1030
|
});
|
|
1031
|
+
spawned.session.agentResumeRecovered = shouldOfferRecoveredAgent;
|
|
997
1032
|
record.backendSessionId = spawned.sessionId;
|
|
998
1033
|
record.mode = spawned.session.mode;
|
|
999
1034
|
record.tmuxSessionName = spawned.session.tmuxSessionName;
|
|
@@ -1128,6 +1163,138 @@ async function openInventorySession(req, input) {
|
|
|
1128
1163
|
reused,
|
|
1129
1164
|
};
|
|
1130
1165
|
}
|
|
1166
|
+
function orchestrationSessionSnapshot(record) {
|
|
1167
|
+
const backend = record.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1168
|
+
const agent = backend?.agent ?? (record.agentResume?.slug ? agentBySlug(record.agentResume.slug) : null);
|
|
1169
|
+
const latestPrompt = backend?.autoTitlePromptPayloads.at(-1)?.trim() ?? '';
|
|
1170
|
+
return {
|
|
1171
|
+
sessionId: record.sessionId,
|
|
1172
|
+
backendSessionId: record.backendSessionId ?? null,
|
|
1173
|
+
name: record.name,
|
|
1174
|
+
cwd: backend?.cwd ?? record.cwd ?? '',
|
|
1175
|
+
agent: agent ? { slug: agent.slug, displayName: agent.displayName } : null,
|
|
1176
|
+
status: backend?.agentSession?.status ?? (backend ? 'shell' : 'offline'),
|
|
1177
|
+
capability: agent
|
|
1178
|
+
? [agent.displayName, ...(agent.capabilities ?? []), backend?.activeProgram?.command || record.activeProgram || 'Agent 会话'].join(' · ')
|
|
1179
|
+
: (backend?.activeProgram?.command || record.activeProgram || 'Shell 终端'),
|
|
1180
|
+
currentTask: latestPrompt || record.name,
|
|
1181
|
+
updatedAt: backend?.lastActivity ?? record.lastActivity,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function searchMetadataForBackend(backendSessionId, session) {
|
|
1185
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.backendSessionId === backendSessionId);
|
|
1186
|
+
if (!record)
|
|
1187
|
+
return null;
|
|
1188
|
+
return {
|
|
1189
|
+
sessionId: record.sessionId,
|
|
1190
|
+
backendSessionId,
|
|
1191
|
+
title: record.name,
|
|
1192
|
+
cwd: session.cwd ?? record.cwd ?? '',
|
|
1193
|
+
agentSlug: session.agent?.slug ?? record.agentResume?.slug ?? null,
|
|
1194
|
+
agentNativeSessionId: session.agentSession?.sessionId ?? record.agentResume?.sessionId ?? null,
|
|
1195
|
+
updatedAt: session.lastActivity,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function writeTerminalInput(session, value) {
|
|
1199
|
+
session.ptyProcess.write(value.replace(/\r?\n/g, '\r'));
|
|
1200
|
+
}
|
|
1201
|
+
function deliverAutomationPromptWhenReady(backendSessionId, prompt, attempt = 0) {
|
|
1202
|
+
const session = terminalSessions.get(backendSessionId);
|
|
1203
|
+
if (!session)
|
|
1204
|
+
return;
|
|
1205
|
+
if (session.agent || session.agentSession || attempt >= 60) {
|
|
1206
|
+
writeTerminalInput(session, `${prompt}\r`);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
setTimeout(() => deliverAutomationPromptWhenReady(backendSessionId, prompt, attempt + 1), 250);
|
|
1210
|
+
}
|
|
1211
|
+
async function runAgentAutomation(automation, req) {
|
|
1212
|
+
const run = automationStore.beginRun(automation);
|
|
1213
|
+
let frontendSessionId = null;
|
|
1214
|
+
try {
|
|
1215
|
+
if (automation.targetSessionId) {
|
|
1216
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.sessionId === automation.targetSessionId);
|
|
1217
|
+
const backend = record?.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1218
|
+
if (!record || !backend)
|
|
1219
|
+
throw new Error('目标会话当前不在线');
|
|
1220
|
+
if (!backend.agentSession)
|
|
1221
|
+
throw new Error('目标会话当前没有运行中的 Agent');
|
|
1222
|
+
frontendSessionId = record.sessionId;
|
|
1223
|
+
const message = automation.prompt || automation.command;
|
|
1224
|
+
if (!message)
|
|
1225
|
+
throw new Error('自动任务没有可发送的内容');
|
|
1226
|
+
writeTerminalInput(backend, `${message}\r`);
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
const request = req ?? {};
|
|
1230
|
+
const opened = await openInventorySession(request, {
|
|
1231
|
+
name: automation.name,
|
|
1232
|
+
customName: true,
|
|
1233
|
+
mode: 'shell',
|
|
1234
|
+
cwd: automation.cwd,
|
|
1235
|
+
});
|
|
1236
|
+
frontendSessionId = opened.session.sessionId;
|
|
1237
|
+
const backend = terminalSessions.get(opened.terminalSession.sessionId);
|
|
1238
|
+
if (!backend)
|
|
1239
|
+
throw new Error('自动任务会话创建失败');
|
|
1240
|
+
if (automation.command)
|
|
1241
|
+
writeTerminalInput(backend, `${automation.command}\r`);
|
|
1242
|
+
if (automation.prompt) {
|
|
1243
|
+
if (automation.command)
|
|
1244
|
+
deliverAutomationPromptWhenReady(opened.terminalSession.sessionId, automation.prompt);
|
|
1245
|
+
else
|
|
1246
|
+
writeTerminalInput(backend, `${automation.prompt}\r`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
automationStore.finishRun(run.id, 'success', frontendSessionId, '任务已投递');
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
automationStore.finishRun(run.id, 'failed', frontendSessionId, getErrorMessage(error));
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
function collaborationMessageLabel(kind) {
|
|
1257
|
+
return { message: '消息', ask: '问题', reply: '回复', task: '任务', handoff: '交接', done: '完成' }[kind];
|
|
1258
|
+
}
|
|
1259
|
+
function resolveFrontendSessionId(input) {
|
|
1260
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : '';
|
|
1261
|
+
if (sessionId && globalSessionState.sessions.some((record) => record.sessionId === sessionId))
|
|
1262
|
+
return sessionId;
|
|
1263
|
+
const backendSessionId = typeof input.backendSessionId === 'string' ? input.backendSessionId.trim() : '';
|
|
1264
|
+
if (backendSessionId) {
|
|
1265
|
+
const resolved = globalSessionState.sessions.find((record) => record.backendSessionId === backendSessionId)?.sessionId;
|
|
1266
|
+
if (resolved)
|
|
1267
|
+
return resolved;
|
|
1268
|
+
}
|
|
1269
|
+
const tmuxSessionName = typeof input.tmuxSessionName === 'string' ? input.tmuxSessionName.trim() : '';
|
|
1270
|
+
return globalSessionState.sessions.find((record) => record.tmuxSessionName === tmuxSessionName)?.sessionId ?? null;
|
|
1271
|
+
}
|
|
1272
|
+
function formatCollaborationDelivery(targetSessionId, messages) {
|
|
1273
|
+
const targetGroups = new Map(collaborationStore.groupsForSession(targetSessionId).map((group) => [group.id, group]));
|
|
1274
|
+
const lines = messages.map((message) => {
|
|
1275
|
+
const from = message.fromSessionId
|
|
1276
|
+
? globalSessionState.sessions.find((record) => record.sessionId === message.fromSessionId)?.name ?? message.fromSessionId
|
|
1277
|
+
: '用户';
|
|
1278
|
+
const group = targetGroups.get(message.groupId)?.name ?? '协作组';
|
|
1279
|
+
return `- [${collaborationMessageLabel(message.kind)} #${message.id}] ${from} → ${group}: ${message.content}`;
|
|
1280
|
+
});
|
|
1281
|
+
return `[Termdock 协作收件箱]\n${lines.join('\n')}\n请处理这些消息。需要回复时运行:td collab reply <消息ID> "回复内容";查看成员和未读消息运行:td collab status / td collab inbox。`;
|
|
1282
|
+
}
|
|
1283
|
+
function tryDeliverCollaborationInbox(frontendSessionId) {
|
|
1284
|
+
const record = globalSessionState.sessions.find((candidate) => candidate.sessionId === frontendSessionId);
|
|
1285
|
+
const session = record?.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1286
|
+
const pending = collaborationStore.inbox(frontendSessionId, { pendingOnly: true, limit: 10 });
|
|
1287
|
+
// Rich hooks let us avoid interrupting an active turn. Third-party plugins
|
|
1288
|
+
// without hooks still participate through the universal detected-Agent PTY
|
|
1289
|
+
// path; their busy state is simply unknown, so delivery is immediate.
|
|
1290
|
+
if (!session?.agent || pending.length === 0
|
|
1291
|
+
|| (session.agentSession?.rich && !['idle', 'done'].includes(session.agentSession.status))) {
|
|
1292
|
+
return { delivered: [], pending: pending.length };
|
|
1293
|
+
}
|
|
1294
|
+
writeTerminalInput(session, `${formatCollaborationDelivery(frontendSessionId, pending)}\r`);
|
|
1295
|
+
collaborationStore.markDelivered(pending.map((message) => message.id));
|
|
1296
|
+
return { delivered: pending.map((message) => message.id), pending: 0 };
|
|
1297
|
+
}
|
|
1131
1298
|
function getTmuxBinary() {
|
|
1132
1299
|
if (process.env.TMUX_BIN)
|
|
1133
1300
|
return process.env.TMUX_BIN;
|
|
@@ -1716,7 +1883,8 @@ async function buildSessionInventory() {
|
|
|
1716
1883
|
customName: session.customName === true,
|
|
1717
1884
|
connected: backendLive,
|
|
1718
1885
|
live,
|
|
1719
|
-
restorable: session.mode === 'tmux' && tmuxLive
|
|
1886
|
+
restorable: !backendLive && ((session.mode === 'tmux' && tmuxLive)
|
|
1887
|
+
|| (session.mode === 'shell' && canRestoreDeadAgentShell(session))),
|
|
1720
1888
|
activeProgram,
|
|
1721
1889
|
cwd,
|
|
1722
1890
|
};
|
|
@@ -2360,6 +2528,7 @@ function buildAgentStatusPayload(sessionId, session) {
|
|
|
2360
2528
|
agentMessage: state?.message ?? null,
|
|
2361
2529
|
reviewed: state?.reviewed ?? session.lastAgentReviewed ?? null,
|
|
2362
2530
|
agentNativeSessionId: nativeSessionId,
|
|
2531
|
+
agentResumeRecovered: session.agentResumeRecovered,
|
|
2363
2532
|
agentRich: state?.rich ?? false,
|
|
2364
2533
|
agentActivity: state?.activity ?? 0,
|
|
2365
2534
|
agentCwd: state?.agentCwd ?? null,
|
|
@@ -2439,6 +2608,11 @@ function syncAgentIdentity(sessionId, session) {
|
|
|
2439
2608
|
}
|
|
2440
2609
|
session.agent = detected;
|
|
2441
2610
|
broadcastAgentStatus(sessionId, session);
|
|
2611
|
+
if (detected) {
|
|
2612
|
+
const frontendSessionId = globalSessionState.sessions.find((record) => record.backendSessionId === sessionId)?.sessionId;
|
|
2613
|
+
if (frontendSessionId)
|
|
2614
|
+
setTimeout(() => tryDeliverCollaborationInbox(frontendSessionId), 300).unref?.();
|
|
2615
|
+
}
|
|
2442
2616
|
}
|
|
2443
2617
|
else if (detected && session.agentLeftAt !== null) {
|
|
2444
2618
|
// Agent re-detected before debounce expired — false alarm, restore.
|
|
@@ -2499,6 +2673,7 @@ function applyAgentSignals(sessionId, session, events, notification) {
|
|
|
2499
2673
|
clearAutoTitleForNewAgentSession(sessionId, session);
|
|
2500
2674
|
}
|
|
2501
2675
|
if (event.kind === 'session-start') {
|
|
2676
|
+
session.agentResumeRecovered = false;
|
|
2502
2677
|
session.autoTitleObservedPrompt = false;
|
|
2503
2678
|
session.autoTitleTurnActive = false;
|
|
2504
2679
|
session.autoTitlePromptPayloads = [];
|
|
@@ -2560,6 +2735,12 @@ function applyAgentSignals(sessionId, session, events, notification) {
|
|
|
2560
2735
|
// one of the git probe's triggers.
|
|
2561
2736
|
maybeRefreshGitStatusForAgent(sessionId, session);
|
|
2562
2737
|
broadcastAgentStatus(sessionId, session, identityChanged);
|
|
2738
|
+
if (session.agentSession && ['idle', 'done'].includes(session.agentSession.status)) {
|
|
2739
|
+
const frontendSessionId = globalSessionState.sessions.find((record) => record.backendSessionId === sessionId)?.sessionId;
|
|
2740
|
+
if (frontendSessionId) {
|
|
2741
|
+
setTimeout(() => tryDeliverCollaborationInbox(frontendSessionId), 300).unref?.();
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2563
2744
|
if (completedTurnAgent) {
|
|
2564
2745
|
const agent = completedTurnAgent;
|
|
2565
2746
|
const forceReevaluation = session.autoTitleGeneratedMidTurn;
|
|
@@ -3453,6 +3634,8 @@ function cleanupSession(sessionId, options) {
|
|
|
3453
3634
|
// exiting is normal (the user can detach/reconnect) and the tmux daemon
|
|
3454
3635
|
// itself is independent of the wrapper.
|
|
3455
3636
|
if (options.killProcess === false && session.mode === 'shell') {
|
|
3637
|
+
const closedRecord = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId) ?? null;
|
|
3638
|
+
archiveAgentResumeRecord(closedRecord, 'exited');
|
|
3456
3639
|
const beforeCount = globalSessionState.sessions.length;
|
|
3457
3640
|
globalSessionState = {
|
|
3458
3641
|
sessions: globalSessionState.sessions.filter((s) => s.backendSessionId !== sessionId),
|
|
@@ -3742,6 +3925,11 @@ function processSessionOutput(sessionId, session, data, opts) {
|
|
|
3742
3925
|
}
|
|
3743
3926
|
catch { /* sniff failure should never block data */ }
|
|
3744
3927
|
appendAutoTitleContext(sessionId, session, data, opts.broadcast);
|
|
3928
|
+
if (opts.broadcast) {
|
|
3929
|
+
const searchMetadata = searchMetadataForBackend(sessionId, session);
|
|
3930
|
+
if (searchMetadata)
|
|
3931
|
+
sessionSearchStore.append(searchMetadata, cleanTerminalContext(data));
|
|
3932
|
+
}
|
|
3745
3933
|
if (opts.broadcast) {
|
|
3746
3934
|
broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
|
|
3747
3935
|
}
|
|
@@ -3833,6 +4021,9 @@ async function spawnTerminalSession(req, input) {
|
|
|
3833
4021
|
// Gates the agent-hook emitter: hooks installed globally stay silent
|
|
3834
4022
|
// unless the agent runs inside a termdock-spawned shell.
|
|
3835
4023
|
TERMDOCK: '1',
|
|
4024
|
+
// Lets `td collab` address this exact Termdock conversation without the
|
|
4025
|
+
// user or Agent having to copy a session id.
|
|
4026
|
+
TERMDOCK_BACKEND_SESSION_ID: sessionId,
|
|
3836
4027
|
};
|
|
3837
4028
|
let ptyProcess = null;
|
|
3838
4029
|
if (mode === 'shell' && process.platform !== 'win32') {
|
|
@@ -3913,6 +4104,7 @@ async function spawnTerminalSession(req, input) {
|
|
|
3913
4104
|
activeProgram: null,
|
|
3914
4105
|
agent: null,
|
|
3915
4106
|
agentSession: null,
|
|
4107
|
+
agentResumeRecovered: false,
|
|
3916
4108
|
autoTitleContext: '',
|
|
3917
4109
|
autoTitleTerminal: new RenderedTerminalContext(cols, rows),
|
|
3918
4110
|
autoTitlePromptPayloads: [],
|
|
@@ -4001,6 +4193,7 @@ async function adoptPtyHostSessions() {
|
|
|
4001
4193
|
activeProgram: null,
|
|
4002
4194
|
agent: null,
|
|
4003
4195
|
agentSession: null,
|
|
4196
|
+
agentResumeRecovered: false,
|
|
4004
4197
|
autoTitleContext: '',
|
|
4005
4198
|
autoTitleTerminal: new RenderedTerminalContext(meta.cols, meta.rows),
|
|
4006
4199
|
autoTitlePromptPayloads: [],
|
|
@@ -4384,6 +4577,269 @@ router.get('/session-inventory', async (_req, res) => {
|
|
|
4384
4577
|
res.status(500).json({ error: errorMessage || 'Failed to build session inventory' });
|
|
4385
4578
|
}
|
|
4386
4579
|
});
|
|
4580
|
+
router.get('/operations/automations', (_req, res) => {
|
|
4581
|
+
res.json({ automations: automationStore.list(), runs: automationStore.listRuns() });
|
|
4582
|
+
});
|
|
4583
|
+
router.post('/operations/automations', async (req, res) => {
|
|
4584
|
+
try {
|
|
4585
|
+
const schedule = normalizeAutomationSchedule(req.body?.schedule);
|
|
4586
|
+
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
4587
|
+
const command = typeof req.body?.command === 'string' ? req.body.command.trim() : '';
|
|
4588
|
+
const prompt = typeof req.body?.prompt === 'string' ? req.body.prompt.trim() : '';
|
|
4589
|
+
const targetSessionId = typeof req.body?.targetSessionId === 'string' ? req.body.targetSessionId.trim() : '';
|
|
4590
|
+
if (!name || !schedule || (!command && !prompt)) {
|
|
4591
|
+
return res.status(400).json({ error: '名称、有效计划以及命令或提示词不能为空' });
|
|
4592
|
+
}
|
|
4593
|
+
const targetRecord = targetSessionId
|
|
4594
|
+
? globalSessionState.sessions.find((candidate) => candidate.sessionId === targetSessionId)
|
|
4595
|
+
: null;
|
|
4596
|
+
if (targetSessionId && !targetRecord)
|
|
4597
|
+
return res.status(400).json({ error: '目标会话不存在' });
|
|
4598
|
+
if (!targetSessionId && !command)
|
|
4599
|
+
return res.status(400).json({ error: '创建新会话时必须填写 Agent 启动命令' });
|
|
4600
|
+
const requestedCwd = typeof req.body?.cwd === 'string' && req.body.cwd.trim()
|
|
4601
|
+
? req.body.cwd.trim()
|
|
4602
|
+
: (targetRecord?.cwd || os.homedir());
|
|
4603
|
+
const cwd = await resolveWorkingDirectory(req, requestedCwd);
|
|
4604
|
+
const automation = automationStore.save({
|
|
4605
|
+
id: typeof req.body?.id === 'string' ? req.body.id : undefined,
|
|
4606
|
+
name,
|
|
4607
|
+
enabled: req.body?.enabled !== false,
|
|
4608
|
+
cwd,
|
|
4609
|
+
command,
|
|
4610
|
+
prompt,
|
|
4611
|
+
targetSessionId: targetSessionId || null,
|
|
4612
|
+
schedule,
|
|
4613
|
+
});
|
|
4614
|
+
res.json({ automation });
|
|
4615
|
+
}
|
|
4616
|
+
catch (error) {
|
|
4617
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4618
|
+
}
|
|
4619
|
+
});
|
|
4620
|
+
router.post('/operations/automations/:automationId/run', async (req, res) => {
|
|
4621
|
+
const automation = automationStore.get(req.params.automationId);
|
|
4622
|
+
if (!automation)
|
|
4623
|
+
return res.status(404).json({ error: '自动任务不存在' });
|
|
4624
|
+
try {
|
|
4625
|
+
await runAgentAutomation(automation, req);
|
|
4626
|
+
res.json({ ok: true, automation: automationStore.get(automation.id), runs: automationStore.listRuns(automation.id) });
|
|
4627
|
+
}
|
|
4628
|
+
catch (error) {
|
|
4629
|
+
res.status(409).json({ error: getErrorMessage(error), automation: automationStore.get(automation.id) });
|
|
4630
|
+
}
|
|
4631
|
+
});
|
|
4632
|
+
router.delete('/operations/automations/:automationId', (req, res) => {
|
|
4633
|
+
if (!automationStore.remove(req.params.automationId))
|
|
4634
|
+
return res.status(404).json({ error: '自动任务不存在' });
|
|
4635
|
+
res.status(204).send();
|
|
4636
|
+
});
|
|
4637
|
+
router.get('/operations/collaboration-groups', (_req, res) => {
|
|
4638
|
+
res.json({
|
|
4639
|
+
groups: collaborationStore.list(),
|
|
4640
|
+
sessions: globalSessionState.sessions.map(orchestrationSessionSnapshot),
|
|
4641
|
+
});
|
|
4642
|
+
});
|
|
4643
|
+
router.post('/operations/collaboration-groups', (req, res) => {
|
|
4644
|
+
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
4645
|
+
const sessionIds = Array.isArray(req.body?.sessionIds)
|
|
4646
|
+
? req.body.sessionIds.filter((id) => typeof id === 'string')
|
|
4647
|
+
: [];
|
|
4648
|
+
const knownIds = new Set(globalSessionState.sessions.map((session) => session.sessionId));
|
|
4649
|
+
const normalizedIds = Array.from(new Set(sessionIds.filter((id) => knownIds.has(id))));
|
|
4650
|
+
if (!name || normalizedIds.length < 2)
|
|
4651
|
+
return res.status(400).json({ error: '协作组至少需要两个有效会话' });
|
|
4652
|
+
const group = collaborationStore.save({
|
|
4653
|
+
id: typeof req.body?.id === 'string' ? req.body.id : undefined,
|
|
4654
|
+
name,
|
|
4655
|
+
sessionIds: normalizedIds,
|
|
4656
|
+
});
|
|
4657
|
+
res.json({ group });
|
|
4658
|
+
});
|
|
4659
|
+
router.delete('/operations/collaboration-groups/:groupId', (req, res) => {
|
|
4660
|
+
if (!collaborationStore.remove(req.params.groupId))
|
|
4661
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4662
|
+
res.status(204).send();
|
|
4663
|
+
});
|
|
4664
|
+
router.get('/operations/collaboration-groups/:groupId/messages', (req, res) => {
|
|
4665
|
+
const group = collaborationStore.getGroup(req.params.groupId);
|
|
4666
|
+
if (!group)
|
|
4667
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4668
|
+
res.json({ messages: collaborationStore.listMessages(group.id, Number(req.query.limit) || 200) });
|
|
4669
|
+
});
|
|
4670
|
+
router.post('/operations/collaboration-groups/:groupId/messages', (req, res) => {
|
|
4671
|
+
try {
|
|
4672
|
+
const group = collaborationStore.getGroup(req.params.groupId);
|
|
4673
|
+
if (!group)
|
|
4674
|
+
return res.status(404).json({ error: '协作组不存在' });
|
|
4675
|
+
const fromSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4676
|
+
const kind = typeof req.body?.kind === 'string' ? req.body.kind : 'message';
|
|
4677
|
+
const content = typeof req.body?.content === 'string' ? req.body.content.slice(0, 20_000) : '';
|
|
4678
|
+
const requestedTargets = Array.isArray(req.body?.toSessionIds)
|
|
4679
|
+
? req.body.toSessionIds.filter((id) => typeof id === 'string')
|
|
4680
|
+
: [];
|
|
4681
|
+
const toSessionIds = requestedTargets.length > 0
|
|
4682
|
+
? requestedTargets
|
|
4683
|
+
: group.sessionIds.filter((id) => id !== fromSessionId);
|
|
4684
|
+
const messages = collaborationStore.send({
|
|
4685
|
+
groupId: group.id,
|
|
4686
|
+
fromSessionId,
|
|
4687
|
+
toSessionIds,
|
|
4688
|
+
kind,
|
|
4689
|
+
content,
|
|
4690
|
+
threadId: typeof req.body?.threadId === 'string' ? req.body.threadId : null,
|
|
4691
|
+
replyTo: typeof req.body?.replyTo === 'string' ? req.body.replyTo : null,
|
|
4692
|
+
});
|
|
4693
|
+
const deliveries = Array.from(new Set(messages.map((message) => message.toSessionId))).map((sessionId) => ({
|
|
4694
|
+
sessionId,
|
|
4695
|
+
...tryDeliverCollaborationInbox(sessionId),
|
|
4696
|
+
}));
|
|
4697
|
+
res.json({ messages, deliveries });
|
|
4698
|
+
}
|
|
4699
|
+
catch (error) {
|
|
4700
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4701
|
+
}
|
|
4702
|
+
});
|
|
4703
|
+
router.get('/operations/orchestration/peers', (req, res) => {
|
|
4704
|
+
const sourceSessionId = resolveFrontendSessionId(req.query);
|
|
4705
|
+
const source = globalSessionState.sessions.find((candidate) => candidate.sessionId === sourceSessionId);
|
|
4706
|
+
if (!source)
|
|
4707
|
+
return res.status(404).json({ error: '会话不存在' });
|
|
4708
|
+
const groups = collaborationStore.groupsForSession(source.sessionId);
|
|
4709
|
+
const peerIds = new Set(groups.flatMap((group) => group.sessionIds).filter((id) => id !== source.sessionId));
|
|
4710
|
+
res.json({ source: orchestrationSessionSnapshot(source), groups, peers: globalSessionState.sessions.filter((record) => peerIds.has(record.sessionId)).map(orchestrationSessionSnapshot) });
|
|
4711
|
+
});
|
|
4712
|
+
router.post('/operations/orchestration/send', (req, res) => {
|
|
4713
|
+
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4714
|
+
const targetSessionId = typeof req.body?.targetSessionId === 'string' ? req.body.targetSessionId.trim() : '';
|
|
4715
|
+
const content = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 20_000) : '';
|
|
4716
|
+
if (!sourceSessionId)
|
|
4717
|
+
return res.status(400).json({ error: '无法识别发送会话;请从 Termdock 会话内运行 td collab' });
|
|
4718
|
+
const group = collaborationStore.groupsForSession(sourceSessionId).find((candidate) => candidate.sessionIds.includes(targetSessionId));
|
|
4719
|
+
if (!group)
|
|
4720
|
+
return res.status(400).json({ error: '发送方和接收方不在同一协作组' });
|
|
4721
|
+
try {
|
|
4722
|
+
const messages = collaborationStore.send({
|
|
4723
|
+
groupId: group.id, fromSessionId: sourceSessionId, toSessionIds: [targetSessionId],
|
|
4724
|
+
kind: typeof req.body?.kind === 'string' ? req.body.kind : 'message',
|
|
4725
|
+
content,
|
|
4726
|
+
});
|
|
4727
|
+
res.json({ ok: true, messages, delivery: tryDeliverCollaborationInbox(targetSessionId) });
|
|
4728
|
+
}
|
|
4729
|
+
catch (error) {
|
|
4730
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4731
|
+
}
|
|
4732
|
+
});
|
|
4733
|
+
router.get('/operations/orchestration/inbox', (req, res) => {
|
|
4734
|
+
const sessionId = resolveFrontendSessionId(req.query);
|
|
4735
|
+
if (!sessionId)
|
|
4736
|
+
return res.status(404).json({ error: '会话不存在' });
|
|
4737
|
+
const messages = collaborationStore.inbox(sessionId, { limit: Number(req.query.limit) || 50 });
|
|
4738
|
+
if (req.query.markRead === 'true')
|
|
4739
|
+
collaborationStore.markRead(messages.map((message) => message.id));
|
|
4740
|
+
const groups = collaborationStore.groupsForSession(sessionId);
|
|
4741
|
+
const peerIds = new Set(groups.flatMap((group) => group.sessionIds).filter((id) => id !== sessionId));
|
|
4742
|
+
res.json({
|
|
4743
|
+
session: orchestrationSessionSnapshot(globalSessionState.sessions.find((record) => record.sessionId === sessionId)),
|
|
4744
|
+
groups,
|
|
4745
|
+
peers: globalSessionState.sessions.filter((record) => peerIds.has(record.sessionId)).map(orchestrationSessionSnapshot),
|
|
4746
|
+
messages,
|
|
4747
|
+
});
|
|
4748
|
+
});
|
|
4749
|
+
router.post('/operations/orchestration/reply', (req, res) => {
|
|
4750
|
+
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
4751
|
+
const messageId = typeof req.body?.messageId === 'string' ? req.body.messageId.trim() : '';
|
|
4752
|
+
const content = typeof req.body?.content === 'string' ? req.body.content.trim().slice(0, 20_000) : '';
|
|
4753
|
+
const original = collaborationStore.getMessage(messageId);
|
|
4754
|
+
if (!sourceSessionId || !original || original.toSessionId !== sourceSessionId) {
|
|
4755
|
+
return res.status(404).json({ error: '协作消息不存在或不属于当前会话' });
|
|
4756
|
+
}
|
|
4757
|
+
if (!original.fromSessionId)
|
|
4758
|
+
return res.status(400).json({ error: '这条用户消息无需回复到其他会话' });
|
|
4759
|
+
try {
|
|
4760
|
+
collaborationStore.markRead([original.id]);
|
|
4761
|
+
const messages = collaborationStore.send({
|
|
4762
|
+
groupId: original.groupId,
|
|
4763
|
+
fromSessionId: sourceSessionId,
|
|
4764
|
+
toSessionIds: [original.fromSessionId],
|
|
4765
|
+
kind: 'reply',
|
|
4766
|
+
content,
|
|
4767
|
+
threadId: original.threadId,
|
|
4768
|
+
replyTo: original.id,
|
|
4769
|
+
});
|
|
4770
|
+
res.json({ ok: true, messages, delivery: tryDeliverCollaborationInbox(original.fromSessionId) });
|
|
4771
|
+
}
|
|
4772
|
+
catch (error) {
|
|
4773
|
+
res.status(400).json({ error: getErrorMessage(error) });
|
|
4774
|
+
}
|
|
4775
|
+
});
|
|
4776
|
+
router.get('/operations/session-search', (req, res) => {
|
|
4777
|
+
const query = typeof req.query.q === 'string' ? req.query.q : '';
|
|
4778
|
+
const results = sessionSearchStore.search(query, Number(req.query.limit) || 30).map((result) => {
|
|
4779
|
+
const live = globalSessionState.sessions.some((candidate) => candidate.sessionId === result.sessionId);
|
|
4780
|
+
const resumeEntry = result.agentNativeSessionId
|
|
4781
|
+
? agentResumeHistory.list().find((entry) => entry.agent.sessionId === result.agentNativeSessionId)
|
|
4782
|
+
: null;
|
|
4783
|
+
return { ...result, live, resumeHistoryId: resumeEntry?.id ?? null };
|
|
4784
|
+
});
|
|
4785
|
+
res.json({ query, results });
|
|
4786
|
+
});
|
|
4787
|
+
router.get('/agent-resume-history', (_req, res) => {
|
|
4788
|
+
const entries = agentResumeHistory.list().flatMap((entry) => {
|
|
4789
|
+
const agent = agentBySlug(entry.agent.slug);
|
|
4790
|
+
const command = agent ? buildResumeCommand(agent, entry.agent.sessionId, entry.agent.launchArgv) : null;
|
|
4791
|
+
if (!agent || !command)
|
|
4792
|
+
return [];
|
|
4793
|
+
return [{
|
|
4794
|
+
id: entry.id,
|
|
4795
|
+
title: entry.title,
|
|
4796
|
+
titleSource: entry.titleSource,
|
|
4797
|
+
agent,
|
|
4798
|
+
cwd: entry.cwd,
|
|
4799
|
+
closedAt: entry.closedAt,
|
|
4800
|
+
reason: entry.reason,
|
|
4801
|
+
}];
|
|
4802
|
+
});
|
|
4803
|
+
res.json({ entries });
|
|
4804
|
+
});
|
|
4805
|
+
router.post('/agent-resume-history/:historyId/prepare', async (req, res) => {
|
|
4806
|
+
const historyId = typeof req.params.historyId === 'string' ? req.params.historyId.trim() : '';
|
|
4807
|
+
const entry = historyId ? agentResumeHistory.get(historyId) : null;
|
|
4808
|
+
if (!entry)
|
|
4809
|
+
return res.status(404).json({ error: 'Agent resume history entry not found' });
|
|
4810
|
+
const agent = agentBySlug(entry.agent.slug);
|
|
4811
|
+
const command = agent ? buildResumeCommand(agent, entry.agent.sessionId, entry.agent.launchArgv) : null;
|
|
4812
|
+
if (!agent || !command) {
|
|
4813
|
+
return res.status(410).json({
|
|
4814
|
+
error: 'This Agent no longer has a valid resume command',
|
|
4815
|
+
code: 'AGENT_RESUME_UNAVAILABLE',
|
|
4816
|
+
});
|
|
4817
|
+
}
|
|
4818
|
+
const target = {
|
|
4819
|
+
slug: agent.slug,
|
|
4820
|
+
nativeSessionId: entry.agent.sessionId,
|
|
4821
|
+
command,
|
|
4822
|
+
};
|
|
4823
|
+
const activeOwner = findActiveAgentResumeOwner('', target);
|
|
4824
|
+
if (activeOwner || await findExternalCodexWriter(target)) {
|
|
4825
|
+
return res.status(409).json({
|
|
4826
|
+
error: 'this agent session is already open in another terminal',
|
|
4827
|
+
code: 'AGENT_SESSION_ACTIVE_ELSEWHERE',
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
res.json({ command, cwd: entry.cwd, title: entry.title, agent });
|
|
4831
|
+
});
|
|
4832
|
+
router.delete('/agent-resume-history/:historyId', (req, res) => {
|
|
4833
|
+
const historyId = typeof req.params.historyId === 'string' ? req.params.historyId.trim() : '';
|
|
4834
|
+
if (!historyId || !agentResumeHistory.remove(historyId)) {
|
|
4835
|
+
return res.status(404).json({ error: 'Agent resume history entry not found' });
|
|
4836
|
+
}
|
|
4837
|
+
res.status(204).send();
|
|
4838
|
+
});
|
|
4839
|
+
router.delete('/agent-resume-history', (_req, res) => {
|
|
4840
|
+
agentResumeHistory.clear();
|
|
4841
|
+
res.status(204).send();
|
|
4842
|
+
});
|
|
4387
4843
|
router.post('/session-inventory/open', async (req, res) => {
|
|
4388
4844
|
try {
|
|
4389
4845
|
const input = req.body ?? {};
|
|
@@ -4474,6 +4930,7 @@ router.delete('/session-inventory/sessions/:frontendSessionId', async (req, res)
|
|
|
4474
4930
|
return res.status(400).json({ error: 'frontendSessionId is required' });
|
|
4475
4931
|
}
|
|
4476
4932
|
const removedSession = globalSessionState.sessions.find((session) => session.sessionId === frontendSessionId) ?? null;
|
|
4933
|
+
archiveAgentResumeRecord(removedSession, 'closed');
|
|
4477
4934
|
const changed = removeGlobalSessionRecord(frontendSessionId);
|
|
4478
4935
|
if (changed) {
|
|
4479
4936
|
if (removedSession?.mode === 'tmux' && removedSession.tmuxSessionName) {
|
|
@@ -4486,7 +4943,10 @@ router.delete('/session-inventory/sessions/:frontendSessionId', async (req, res)
|
|
|
4486
4943
|
});
|
|
4487
4944
|
router.delete('/session-inventory/sessions', async (_req, res) => {
|
|
4488
4945
|
await markAllPersistedTmuxSessionsDetached();
|
|
4946
|
+
for (const session of globalSessionState.sessions)
|
|
4947
|
+
archiveAgentResumeRecord(session, 'closed');
|
|
4489
4948
|
globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
4949
|
+
collaborationStore.clear();
|
|
4490
4950
|
schedulePersistGlobalState();
|
|
4491
4951
|
broadcastClientState();
|
|
4492
4952
|
res.status(204).send();
|
|
@@ -4500,6 +4960,7 @@ router.put('/client-state', (_req, res) => {
|
|
|
4500
4960
|
router.delete('/client-state', async (_req, res) => {
|
|
4501
4961
|
await markAllPersistedTmuxSessionsDetached();
|
|
4502
4962
|
globalSessionState = { sessions: [], updatedAt: Date.now() };
|
|
4963
|
+
collaborationStore.clear();
|
|
4503
4964
|
schedulePersistGlobalState();
|
|
4504
4965
|
broadcastClientState();
|
|
4505
4966
|
res.status(204).send();
|
|
@@ -4739,6 +5200,7 @@ router.get('/agent-launchers', async (_req, res) => {
|
|
|
4739
5200
|
slug: agent.slug,
|
|
4740
5201
|
displayName: agent.displayName,
|
|
4741
5202
|
command,
|
|
5203
|
+
capabilities: agent.capabilities,
|
|
4742
5204
|
accentColor: agent.accentColor,
|
|
4743
5205
|
icon: agent.icon,
|
|
4744
5206
|
isPlugin: agent.isPlugin ?? false,
|
|
@@ -4819,6 +5281,7 @@ router.get('/agent-plugins', (_req, res) => {
|
|
|
4819
5281
|
slug: p.manifest.slug,
|
|
4820
5282
|
displayName: p.manifest.displayName,
|
|
4821
5283
|
aliases: p.manifest.aliases,
|
|
5284
|
+
capabilities: p.manifest.capabilities ?? [],
|
|
4822
5285
|
accentColor: p.manifest.accentColor,
|
|
4823
5286
|
iconMode: p.manifest.iconMode ?? 'mask',
|
|
4824
5287
|
hasHooks: p.manifest.hooks !== undefined,
|
|
@@ -5229,7 +5692,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
5229
5692
|
});
|
|
5230
5693
|
// Rich agent state rides its own message so the connected payload stays
|
|
5231
5694
|
// backward compatible; force past the dedupe snapshot.
|
|
5232
|
-
if (session.agent || session.agentSession) {
|
|
5695
|
+
if (session.agent || session.agentSession || session.agentResumeRecovered) {
|
|
5233
5696
|
writeSse(res, buildAgentStatusPayload(sessionId, session));
|
|
5234
5697
|
}
|
|
5235
5698
|
if (session.gitStatus) {
|
|
@@ -5937,7 +6400,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
5937
6400
|
replayOutOfWindow,
|
|
5938
6401
|
}));
|
|
5939
6402
|
// Rich agent state rides its own message (see the SSE path above).
|
|
5940
|
-
if (session.agent || session.agentSession) {
|
|
6403
|
+
if (session.agent || session.agentSession || session.agentResumeRecovered) {
|
|
5941
6404
|
ws.send(JSON.stringify(buildAgentStatusPayload(sessionId, session)));
|
|
5942
6405
|
}
|
|
5943
6406
|
if (session.gitStatus) {
|
|
@@ -6299,16 +6762,32 @@ const reconcileTimer = setInterval(() => {
|
|
|
6299
6762
|
}, CLIENT_STATE_RECONCILE_INTERVAL_MS);
|
|
6300
6763
|
// Don't keep the event loop alive for housekeeping.
|
|
6301
6764
|
reconcileTimer.unref?.();
|
|
6765
|
+
const automationTimer = setInterval(() => {
|
|
6766
|
+
for (const automation of automationStore.due()) {
|
|
6767
|
+
void runAgentAutomation(automation).catch((error) => {
|
|
6768
|
+
console.warn(`[automation] ${automation.id} failed:`, getErrorMessage(error));
|
|
6769
|
+
});
|
|
6770
|
+
}
|
|
6771
|
+
}, 30_000);
|
|
6772
|
+
automationTimer.unref?.();
|
|
6302
6773
|
async function reconcileClientState() {
|
|
6303
6774
|
if (globalSessionState.sessions.length === 0)
|
|
6304
6775
|
return;
|
|
6305
6776
|
const toRemove = [];
|
|
6777
|
+
const toDetach = [];
|
|
6306
6778
|
for (const entry of globalSessionState.sessions) {
|
|
6307
6779
|
if (entry.mode === 'shell') {
|
|
6308
6780
|
// Shell wrapper: backendSessionId must map to a live terminal session.
|
|
6309
|
-
//
|
|
6781
|
+
// A resumable Agent record survives with a detached backend so the tab
|
|
6782
|
+
// can rebuild its shell; ordinary dead shells still disappear.
|
|
6310
6783
|
if (!entry.backendSessionId || !terminalSessions.has(entry.backendSessionId)) {
|
|
6311
|
-
|
|
6784
|
+
if (canRestoreDeadAgentShell(entry)) {
|
|
6785
|
+
if (entry.backendSessionId)
|
|
6786
|
+
toDetach.push(entry.sessionId);
|
|
6787
|
+
}
|
|
6788
|
+
else {
|
|
6789
|
+
toRemove.push(entry.sessionId);
|
|
6790
|
+
}
|
|
6312
6791
|
}
|
|
6313
6792
|
}
|
|
6314
6793
|
else if (entry.mode === 'tmux' && entry.tmuxSessionName) {
|
|
@@ -6327,14 +6806,22 @@ async function reconcileClientState() {
|
|
|
6327
6806
|
}
|
|
6328
6807
|
}
|
|
6329
6808
|
}
|
|
6330
|
-
if (toRemove.length === 0)
|
|
6809
|
+
if (toRemove.length === 0 && toDetach.length === 0)
|
|
6331
6810
|
return;
|
|
6811
|
+
const detached = new Set(toDetach);
|
|
6332
6812
|
globalSessionState = {
|
|
6333
|
-
sessions: globalSessionState.sessions
|
|
6813
|
+
sessions: globalSessionState.sessions
|
|
6814
|
+
.filter((s) => !toRemove.includes(s.sessionId))
|
|
6815
|
+
.map((s) => detached.has(s.sessionId) ? { ...s, backendSessionId: null } : s),
|
|
6334
6816
|
updatedAt: Date.now(),
|
|
6335
6817
|
};
|
|
6336
6818
|
schedulePersistGlobalState();
|
|
6337
6819
|
broadcastClientState();
|
|
6338
|
-
|
|
6820
|
+
if (toRemove.length > 0) {
|
|
6821
|
+
console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
|
|
6822
|
+
}
|
|
6823
|
+
if (toDetach.length > 0) {
|
|
6824
|
+
console.log(`[reconcile] detached ${toDetach.length} resumable Agent shell entries: ${toDetach.join(', ')}`);
|
|
6825
|
+
}
|
|
6339
6826
|
}
|
|
6340
6827
|
export default router;
|