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.
Files changed (81) hide show
  1. package/.desktop-dist/main.js +211 -34
  2. package/dist/client/assets/{ModelPreview-DCC_cgBs.js → ModelPreview-CJK3Nmg4.js} +165 -165
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-C-QKkcSr.js} +1 -1
  4. package/dist/client/assets/{arc-oaeK9Fn4.js → arc-BpjFutHZ.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-DeeuzGsk.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-BkDiKR3-.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-BX6424vk.js} +1 -1
  8. package/dist/client/assets/channel-BiaGwVDJ.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-DwNMfRvw.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-D5g3chKu.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-B2rw8Ldu.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-j3CVdb45.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-iHTR9CAX.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-DVD1wXze.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-zZRopnRd.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-h4EZpc4k.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-C9MZH2Oy.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BNPDO5fQ.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-DYljVZD_.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DYljVZD_.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-DlqULumX.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-D6pWZSwi.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-B6SI7XOe.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-DXnKJ4XD.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-B5NTAhw6.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-Dihaal0B.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-Be987DPJ.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-CfgTeXAJ.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-BgrK8rP2.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-BPsH1_QO.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-F3s7Ytrc.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ByL-8svD.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-Dtv-fi06.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-CeD48POl.js} +1 -1
  35. package/dist/client/assets/index-C82Uc6Vy.js +451 -0
  36. package/dist/client/assets/index-RO2b079_.css +1 -0
  37. package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-BxvLe33p.js} +1 -1
  38. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-XPW1aoJK.js} +1 -1
  39. package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-Bhiae04Z.js} +1 -1
  40. package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-D5iOY3Zc.js} +1 -1
  41. package/dist/client/assets/{linear-BDTQPYjb.js → linear-PCgLsFTe.js} +1 -1
  42. package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-DWuc-xG5.js} +4 -4
  43. package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-C0EFK91v.js} +1 -1
  44. package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-ClTz8aHa.js} +1 -1
  45. package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-BuTv1DGN.js} +1 -1
  46. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-FLZC-cqI.js} +1 -1
  47. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DK68FlFh.js} +1 -1
  48. package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-B_701AJ1.js} +1 -1
  49. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Bk87L7JG.js} +1 -1
  50. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-Biy5tPTS.js} +1 -1
  51. package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-__4zzUuh.js} +1 -1
  52. package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-B_400cf9.js} +1 -1
  53. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-CK77br3V.js +1 -0
  54. package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-CxWZz0n0.js} +2 -2
  55. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DbYe8ax3.js +8 -0
  56. package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-DSrIUp8H.js} +1 -1
  57. package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-CdwaUmpL.js} +1 -1
  58. package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-DwxlF0Xk.js} +1 -1
  59. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CQNmEc_n.js} +1 -1
  60. package/dist/client/index.html +2 -2
  61. package/dist/client/sw-notifications.js +7 -3
  62. package/dist/client/sw.js +1 -1
  63. package/dist/server/agent/automationStore.js +151 -0
  64. package/dist/server/agent/collaborationStore.js +145 -0
  65. package/dist/server/agent/installers.js +50 -12
  66. package/dist/server/agent/plugins.js +9 -0
  67. package/dist/server/agent/registry.js +1 -0
  68. package/dist/server/agent/resumeHistory.js +100 -0
  69. package/dist/server/agent/resumePersistence.js +35 -0
  70. package/dist/server/agent/sessionSearchStore.js +124 -0
  71. package/dist/server/cli.js +139 -0
  72. package/dist/server/routes/terminal.js +709 -46
  73. package/package.json +3 -2
  74. package/runtime-manifest.json +2 -2
  75. package/dist/client/assets/channel-Cpdbh5br.js +0 -1
  76. package/dist/client/assets/classDiagram-OUVF2IWQ-DWre1xlG.js +0 -1
  77. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DWre1xlG.js +0 -1
  78. package/dist/client/assets/index-BYhY8AKn.css +0 -1
  79. package/dist/client/assets/index-CshWxisE.js +0 -451
  80. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DzD8Rob4.js +0 -1
  81. 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
- await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8');
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
- // 全部指向已销毁的 sessionshell session 的 PTY 已死无法复用,直接删除;
477
- // tmux session tmux 进程独立于 termdock,保留条目但清空 backendSessionId。
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
- // Shell sessions with no live backend: PTY is dead, can't be reattached — remove entirely
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 false;
511
+ return canRestoreDeadAgentShell(s);
486
512
  }
487
- // backendSessionId already null but no live backend either also dead
513
+ // backendSessionId already null: only a resumable Agent record is useful.
488
514
  if (s.backendSessionId == null) {
489
- changed = true;
490
- return false;
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
- return globalSessionState.sessions.length !== before;
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 && !backendLive,
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
- const psArgs = pane.tty
2792
- ? ['-t', pane.tty.replace(/^\/dev\//, ''), '-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args=']
2793
- : ['-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args='];
2794
- const { stdout } = await execFileAsync('ps', psArgs, { timeout: 3000, maxBuffer: 512 * 1024 });
2795
- const rows = stdout
2796
- .split('\n')
2797
- .map((line) => line.trim())
2798
- .filter(Boolean)
2799
- .map((line) => {
2800
- // ps -o format: PID PPID PGID TPGID STAT COMM ARGS
2801
- // COMM is a single token; ARGS is the rest of the line
2802
- const match = line.match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
2803
- if (!match)
2804
- return null;
2805
- return {
2806
- pid: Number.parseInt(match[1] || '0', 10),
2807
- ppid: Number.parseInt(match[2] || '0', 10),
2808
- pgid: Number.parseInt(match[3] || '0', 10),
2809
- tpgid: Number.parseInt(match[4] || '0', 10),
2810
- stat: match[5] || '',
2811
- comm: match[6] || '',
2812
- args: match[7]?.trim() || '',
2813
- };
2814
- })
2815
- .filter((row) => row !== null);
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 getTmuxLayout(session.tmuxSessionName);
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 getTmuxLayout(session.tmuxSessionName);
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 getTmuxLayout(session.tmuxSessionName);
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 getTmuxLayout(session.tmuxSessionName);
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
- // No live wrapper the shell process is gone, can't reattach.
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
- toRemove.push(entry.sessionId);
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.filter((s) => !toRemove.includes(s.sessionId)),
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
- console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
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;