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.
Files changed (80) hide show
  1. package/dist/client/assets/{ModelPreview-DCC_cgBs.js → ModelPreview-Drqv0OTg.js} +165 -165
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-Cl8CT45X.js} +1 -1
  3. package/dist/client/assets/{arc-oaeK9Fn4.js → arc-2Sc638oe.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-CNH9w9eN.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-ClvE_yP1.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-CxIMU8l9.js} +1 -1
  7. package/dist/client/assets/channel-DPt818Jx.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-CdvXmc6Z.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-B1dA4l5A.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-CVk_pzpf.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-B46zhCa0.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-1wRPE3eO.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-BzPrhNIS.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-D0sDGnBf.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-BRo0103H.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-CW-wSkDE.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BuWiQ0uq.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-C2hZbTnO.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-C2hZbTnO.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-WzKVd03Q.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-CfXYAqnq.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-BnJGle_X.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-CDDlGCQy.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-t8DNZS7P.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-CZpQd91k.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-OET0EFlt.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-COgju4NX.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-6gNSoIek.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-CTlIMc8W.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-BDsEvZvq.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ggl4zaY9.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-D9Ca5oWS.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-THPUH6Bg.js} +1 -1
  34. package/dist/client/assets/index-BuMMW0wS.js +451 -0
  35. package/dist/client/assets/index-RO2b079_.css +1 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-CKF-vSTz.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-CBFlmgI3.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-lbXIqX5a.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-BUuO_e54.js} +1 -1
  40. package/dist/client/assets/{linear-BDTQPYjb.js → linear-D_uoNzR8.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-D4Yy5OxZ.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-B0Ml-jaq.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-CKF9cuqx.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-DL3XisAD.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-DrsHXXqd.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DMAz8hLg.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-CCHretnQ.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Btb7ZNwU.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-DcZk1PdX.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-vMYb_VZU.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-CltexqJj.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BslbFNw3.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-DPnwlpXX.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-B0V1XVQb.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-l78vfKln.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-BggNEe_f.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-v04USz2e.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CInHBOpK.js} +1 -1
  59. package/dist/client/index.html +2 -2
  60. package/dist/client/sw-notifications.js +7 -3
  61. package/dist/client/sw.js +1 -1
  62. package/dist/server/agent/automationStore.js +151 -0
  63. package/dist/server/agent/collaborationStore.js +145 -0
  64. package/dist/server/agent/installers.js +50 -12
  65. package/dist/server/agent/plugins.js +9 -0
  66. package/dist/server/agent/registry.js +1 -0
  67. package/dist/server/agent/resumeHistory.js +100 -0
  68. package/dist/server/agent/resumePersistence.js +35 -0
  69. package/dist/server/agent/sessionSearchStore.js +96 -0
  70. package/dist/server/cli.js +139 -0
  71. package/dist/server/routes/terminal.js +503 -16
  72. package/package.json +1 -1
  73. package/runtime-manifest.json +2 -2
  74. package/dist/client/assets/channel-Cpdbh5br.js +0 -1
  75. package/dist/client/assets/classDiagram-OUVF2IWQ-DWre1xlG.js +0 -1
  76. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DWre1xlG.js +0 -1
  77. package/dist/client/assets/index-BYhY8AKn.css +0 -1
  78. package/dist/client/assets/index-CshWxisE.js +0 -451
  79. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DzD8Rob4.js +0 -1
  80. 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
- // 全部指向已销毁的 sessionshell session 的 PTY 已死无法复用,直接删除;
477
- // tmux session tmux 进程独立于 termdock,保留条目但清空 backendSessionId。
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
- // Shell sessions with no live backend: PTY is dead, can't be reattached — remove entirely
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 false;
496
+ return canRestoreDeadAgentShell(s);
486
497
  }
487
- // backendSessionId already null but no live backend either also dead
498
+ // backendSessionId already null: only a resumable Agent record is useful.
488
499
  if (s.backendSessionId == null) {
489
- changed = true;
490
- return false;
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
- return globalSessionState.sessions.length !== before;
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 && !backendLive,
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
- // No live wrapper the shell process is gone, can't reattach.
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
- toRemove.push(entry.sessionId);
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.filter((s) => !toRemove.includes(s.sessionId)),
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
- console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
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;