termdock 1.3.1 → 1.4.0

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 (86) hide show
  1. package/dist/client/assets/{abnfDiagram-VRR7QNED-VDfhcxCs.js → abnfDiagram-VRR7QNED-G_YxqZwv.js} +1 -1
  2. package/dist/client/assets/{arc-CK3Ni_GA.js → arc-BuvKiJCC.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-BvkwEVZw.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-C3bxox0Z.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DLORcr8m.js} +1 -1
  6. package/dist/client/assets/channel-BnGIwxUi.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Dnqi-8g0.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D70B4HxL.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-Cl7t39aR.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-DKMD4DfE.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-CsN_pMOw.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-D8BHaQpE.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-B8BtUiHF.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-CdN7tlvn.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-UPjL_wl4.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.js → chunk-XXDRQBXY--eX5Na_l.js} +1 -1
  17. package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +1 -0
  18. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +1 -0
  19. package/dist/client/assets/{cose-bilkent-JH36ORCC-De1yyf-M.js → cose-bilkent-JH36ORCC-CEAcZx3N.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-up8zIMDo.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-DZ2SQpCq.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-DOoLtq9O.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-x8umATxt.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-Kle7Toxb.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-CezkkMqP.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CaU0u6UQ.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-B4m53TVj.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-Dpwjo8_v.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-BU4R1QVk.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BFtDsA0k.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DtSEfub5.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.js → gitGraphDiagram-IHSO6WYX-BGukYlT3.js} +1 -1
  33. package/dist/client/assets/index-CP3_puZd.js +395 -0
  34. package/dist/client/assets/index-CxBkmASY.css +1 -0
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-CE_9WW2K.js → infoDiagram-FWYZ7A6U-Bx1NQNsL.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-DHlxYJjl.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-BOZzlR6d.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-C2nWuUXX.js} +1 -1
  39. package/dist/client/assets/{linear-SWu8Le4S.js → linear-DtblW0g4.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-BKSuG721.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-Co3LLQGt.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DSUG1nB1.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-DvEr0UPf.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-C2diLBZ8.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-CqYZHCvf.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-CFAKdPQp.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-DV95NA9H.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-cbv0xzG-.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-DpkzYB1F.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.js → stateDiagram-2N3HPSRC-BnAwPP0V.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-D5QZ4K6L.js → swimlanes-5IMT3BWC-kObRIKfj.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-yeb4EUGj.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-DUhdUNzM.js → timeline-definition-FHXFAJF6-BbnAJ8pO.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-sIu_hWKx.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DpGdJtdb.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.js → xychartDiagram-FW5EYKEG-C5F9faN2.js} +1 -1
  58. package/dist/client/icons/agents/amp.svg +12 -0
  59. package/dist/client/icons/agents/claude.svg +10 -0
  60. package/dist/client/icons/agents/codex.svg +10 -0
  61. package/dist/client/icons/agents/copilot.svg +12 -0
  62. package/dist/client/icons/agents/cursor.svg +3 -0
  63. package/dist/client/icons/agents/droid.svg +10 -0
  64. package/dist/client/icons/agents/gemini.svg +5 -0
  65. package/dist/client/icons/agents/goose.svg +10 -0
  66. package/dist/client/icons/agents/grok.svg +5 -0
  67. package/dist/client/icons/agents/opencode.svg +3 -0
  68. package/dist/client/index.html +2 -2
  69. package/dist/client/sw.js +1 -1
  70. package/dist/server/agent/installers.js +551 -0
  71. package/dist/server/agent/registry.js +244 -0
  72. package/dist/server/agent/session.js +162 -0
  73. package/dist/server/agentHook.js +174 -0
  74. package/dist/server/ptyHost.js +261 -0
  75. package/dist/server/ptyhost/manager.js +396 -0
  76. package/dist/server/ptyhost/protocol.js +80 -0
  77. package/dist/server/routes/terminal.js +724 -393
  78. package/dist/server/utils/gitStatus.js +114 -0
  79. package/package.json +1 -1
  80. package/dist/client/assets/channel-B_qG4XEr.js +0 -1
  81. package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
  82. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
  83. package/dist/client/assets/index-BeJrvXOJ.js +0 -394
  84. package/dist/client/assets/index-iK4yVqAA.css +0 -1
  85. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
  86. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DsiBwV8W.js +0 -8
@@ -9,6 +9,8 @@ import { execFile, spawn } from 'child_process';
9
9
  import { randomUUID } from 'crypto';
10
10
  import { promisify } from 'util';
11
11
  import { caffeinateManager } from '../utils/caffeinate.js';
12
+ import { gitStatusCache } from '../utils/gitStatus.js';
13
+ import { getPtyHostManager } from '../ptyhost/manager.js';
12
14
  import { pathValidator } from '../utils/pathValidator.js';
13
15
  import { TERMINAL, TMUX } from '../config.js';
14
16
  import { localAccessManager } from '../utils/localAccess.js';
@@ -16,6 +18,9 @@ import { normalizeLocalAccessName } from '../utils/settings.js';
16
18
  import { getOnboardingServerUrl } from '../onboardingServer.js';
17
19
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
18
20
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
21
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, } from '../agent/registry.js';
22
+ import { applyAgentEvent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
23
+ import { installHooks, listHookAgents, refreshStaleHooksAtLaunch, uninstallHooks, } from '../agent/installers.js';
19
24
  const router = express.Router();
20
25
  const execFileAsync = promisify(execFile);
21
26
  const TERMDOCK_DIR = `${os.homedir()}/.termdock`;
@@ -459,6 +464,12 @@ process.on('SIGINT', () => { stopActivityReorderPeriodic(); flushPersistAndExit(
459
464
  void (async () => {
460
465
  await loadGlobalSessionStateFromDisk();
461
466
  await watchGlobalSessionStateFile();
467
+ // pty-host adoption runs after the state load (its record upserts must not
468
+ // be clobbered) and before pruning (surviving shell records' backends are
469
+ // live in the host, so they're not orphans).
470
+ await adoptPtyHostSessions().catch((error) => {
471
+ console.warn('[pty-host] adoption failed:', error.message);
472
+ });
462
473
  pruneOrphanSessions();
463
474
  await backfillPersistedTmuxMetadata();
464
475
  startActivityReorderPeriodic();
@@ -525,15 +536,40 @@ async function backfillPersistedTmuxMetadata() {
525
536
  '@termdock-version': TERMDOCK_VERSION,
526
537
  '@termdock-host': TERMDOCK_HOST,
527
538
  '@termdock-pid': TERMDOCK_PID,
539
+ // Agent-hook plumbing (see ensureManagedTmuxSessionReady): backfilled
540
+ // at startup so sessions created before this feature get it too.
541
+ 'allow-passthrough': 'on',
528
542
  };
529
543
  const existingCreatedAt = await getTmuxOption(s.tmuxSessionName, '@termdock-created-at');
530
544
  if (!existingCreatedAt) {
531
545
  baseOptions['@termdock-created-at'] = String(Date.now());
532
546
  }
533
547
  await setTmuxOptions(s.tmuxSessionName, baseOptions);
548
+ try {
549
+ await runTmux(['set-environment', '-t', s.tmuxSessionName, 'TERMDOCK', '1']);
550
+ }
551
+ catch { /* best effort */ }
534
552
  if (s.customName === true && typeof s.name === 'string' && s.name.trim().length > 0) {
535
553
  await setTmuxOption(s.tmuxSessionName, '@termdock-friendly-name', s.name);
536
554
  }
555
+ // label/program/cwd 是动态值,正常由在线轮询维护;这里只在缺失时补——
556
+ // 用持久化记录里的 last-known 计算,保证 server 重启后、首个客户端连上前,
557
+ // CLI(td --tls / tmux show)已经能看到名字。已有值不覆盖(可能更新鲜)。
558
+ const existingLabel = await getTmuxOption(s.tmuxSessionName, '@termdock-label');
559
+ if (!existingLabel) {
560
+ const program = normalizeMetadataProgram(s.activeProgram ?? null);
561
+ const label = buildTermdockLabel({
562
+ friendlyName: s.customName === true && s.name.trim().length > 0 ? s.name : null,
563
+ program,
564
+ cwd: s.cwd ?? null,
565
+ sessionName: s.tmuxSessionName,
566
+ });
567
+ await setTmuxOptions(s.tmuxSessionName, {
568
+ '@termdock-label': label,
569
+ '@termdock-program': program ?? '',
570
+ '@termdock-cwd': s.cwd ?? '',
571
+ });
572
+ }
537
573
  }
538
574
  catch (error) {
539
575
  console.warn(`[tmux] failed to backfill metadata on ${s.tmuxSessionName}: ${getErrorMessage(error)}`);
@@ -694,6 +730,9 @@ function normalizePersistedClientSession(input) {
694
730
  cwd: typeof candidate.cwd === 'string' && candidate.cwd.trim().length > 0
695
731
  ? candidate.cwd
696
732
  : null,
733
+ activeProgram: typeof candidate.activeProgram === 'string' && candidate.activeProgram.trim().length > 0
734
+ ? candidate.activeProgram
735
+ : null,
697
736
  };
698
737
  }
699
738
  function generateTmuxSessionName() {
@@ -759,7 +798,7 @@ function makeTerminalSessionPayload(backendSessionId, session, cols = 80, rows =
759
798
  rows,
760
799
  mode: session.mode,
761
800
  tmuxSessionName: session.tmuxSessionName,
762
- activeProgram: session.activeProgram?.command ?? null,
801
+ activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(backendSessionId),
763
802
  activeProgramRaw: session.activeProgram?.rawArgs ?? null,
764
803
  activeProgramSource: session.activeProgram?.source ?? null,
765
804
  cwd: session.cwd ?? null,
@@ -984,6 +1023,24 @@ function updateGlobalBindingForBackendSession(backendSessionId, patch) {
984
1023
  upsertGlobalSessionRecord(updated);
985
1024
  return true;
986
1025
  }
1026
+ // 把检测到的前台程序名写进持久化记录(last-known 语义):
1027
+ // - 只在非空且与已存值不同才写,null 不覆盖(检测瞬时空窗不该抹掉上次结果);
1028
+ // - 变化频率低(程序切换才触发),debounce 落盘即可。
1029
+ function persistActiveProgramBinding(backendSessionId, command) {
1030
+ if (!command)
1031
+ return;
1032
+ const record = globalSessionState.sessions.find((session) => session.backendSessionId === backendSessionId);
1033
+ if (!record || record.activeProgram === command)
1034
+ return;
1035
+ upsertGlobalSessionRecord({ ...record, activeProgram: command });
1036
+ schedulePersistGlobalState();
1037
+ }
1038
+ // connected / open 响应用的兜底:live 检测还没出结果时,回退到磁盘上的 last-known,
1039
+ // 前端首帧就能显示上次的程序名而不是裸 session 名。
1040
+ function getPersistedActiveProgramForBackend(backendSessionId) {
1041
+ const record = globalSessionState.sessions.find((session) => session.backendSessionId === backendSessionId);
1042
+ return record?.activeProgram ?? null;
1043
+ }
987
1044
  async function openInventorySession(req, input) {
988
1045
  const normalizedMode = normalizeMode(input.mode);
989
1046
  const normalizedTmuxName = normalizedMode === 'tmux'
@@ -1603,29 +1660,38 @@ async function buildSessionInventory() {
1603
1660
  schedulePersistGlobalState();
1604
1661
  }
1605
1662
  const liveTmuxByName = new Map(refreshedTmuxSessions.map((session) => [session.name, session]));
1606
- let synchronizedTmuxFriendlyName = false;
1663
+ let synchronizedPersistedFields = false;
1607
1664
  globalSessionState = {
1608
1665
  sessions: globalSessionState.sessions.map((session) => {
1609
- if (session.mode !== 'tmux' || !session.tmuxSessionName) {
1610
- return session;
1666
+ let next = session;
1667
+ // CLI 侧改了 @termdock-friendly-name → 反向同步进持久化记录(tmux 限定)。
1668
+ if (next.mode === 'tmux' && next.tmuxSessionName) {
1669
+ const friendlyName = liveTmuxByName.get(next.tmuxSessionName)?.friendlyName?.trim();
1670
+ if (friendlyName && !(next.customName === true && next.name === friendlyName)) {
1671
+ next = { ...next, name: friendlyName, customName: true };
1672
+ synchronizedPersistedFields = true;
1673
+ }
1611
1674
  }
1612
- const friendlyName = liveTmuxByName.get(session.tmuxSessionName)?.friendlyName?.trim();
1613
- if (!friendlyName) {
1614
- return session;
1675
+ // 展示名数据(activeProgram / cwd)回写:last-known 持久化,server 重启 /
1676
+ // backend 掉线后 inventory hint 仍能给出上次的程序名与目录。
1677
+ // live 值只以非空覆盖——检测空窗(null)不抹掉已存结果。
1678
+ const backend = next.backendSessionId ? terminalSessions.get(next.backendSessionId) : undefined;
1679
+ const tmuxMeta = next.tmuxSessionName ? liveTmuxByName.get(next.tmuxSessionName) : undefined;
1680
+ const liveProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? null;
1681
+ const liveCwd = backend?.cwd ?? tmuxMeta?.cwd ?? null;
1682
+ if (liveProgram && liveProgram !== (next.activeProgram ?? null)) {
1683
+ next = { ...next, activeProgram: liveProgram };
1684
+ synchronizedPersistedFields = true;
1615
1685
  }
1616
- if (session.customName === true && session.name === friendlyName) {
1617
- return session;
1686
+ if (liveCwd && liveCwd !== (next.cwd ?? null)) {
1687
+ next = { ...next, cwd: liveCwd };
1688
+ synchronizedPersistedFields = true;
1618
1689
  }
1619
- synchronizedTmuxFriendlyName = true;
1620
- return {
1621
- ...session,
1622
- name: friendlyName,
1623
- customName: true,
1624
- };
1690
+ return next;
1625
1691
  }),
1626
- updatedAt: synchronizedTmuxFriendlyName ? Date.now() : globalSessionState.updatedAt,
1692
+ updatedAt: synchronizedPersistedFields ? Date.now() : globalSessionState.updatedAt,
1627
1693
  };
1628
- if (synchronizedTmuxFriendlyName) {
1694
+ if (synchronizedPersistedFields) {
1629
1695
  schedulePersistGlobalState();
1630
1696
  }
1631
1697
  const clientSessions = globalSessionState.sessions.map((session) => {
@@ -1634,11 +1700,12 @@ async function buildSessionInventory() {
1634
1700
  const live = session.mode === 'tmux' ? tmuxLive : backendLive;
1635
1701
  // 展示名提示(activeProgram / cwd):优先取在线 backend session 的实时值,
1636
1702
  // 其次回退到 tmux 清单里的 program/cwd(tmux 模式即使 backend 未 attach,
1637
- // tmux 服务端仍能给出当前 pane 的程序与目录)。让前端 hydrate 即可显示。
1703
+ // tmux 服务端仍能给出当前 pane 的程序与目录),最后回退到持久化记录里的
1704
+ // last-known 值(server 重启 / tmux 已死也能显示上次的标题)。让前端 hydrate 即可显示。
1638
1705
  const backend = backendLive ? terminalSessions.get(session.backendSessionId) : undefined;
1639
1706
  const tmuxMeta = session.tmuxSessionName ? liveTmuxByName.get(session.tmuxSessionName) : undefined;
1640
- const activeProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? null;
1641
- const cwd = backend?.cwd ?? tmuxMeta?.cwd ?? null;
1707
+ const activeProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? session.activeProgram ?? null;
1708
+ const cwd = backend?.cwd ?? tmuxMeta?.cwd ?? session.cwd ?? null;
1642
1709
  return {
1643
1710
  ...session,
1644
1711
  frontendSessionId: session.sessionId,
@@ -1875,6 +1942,8 @@ function sniffOsc(buf, home) {
1875
1942
  let promptState = null;
1876
1943
  let exitCode = null;
1877
1944
  let tuiProgress = null;
1945
+ const agentEvents = [];
1946
+ let notification = null;
1878
1947
  let lastMatchEnd = 0;
1879
1948
  OSC_ANY_PATTERN.lastIndex = 0;
1880
1949
  while ((match = OSC_ANY_PATTERN.exec(buf)) !== null) {
@@ -1915,253 +1984,330 @@ function sniffOsc(buf, home) {
1915
1984
  }
1916
1985
  else if (oscNum === '9') {
1917
1986
  const progress = parseConEmuProgressReport(oscData);
1918
- if (progress)
1987
+ if (progress) {
1919
1988
  tuiProgress = progress;
1989
+ }
1990
+ else if (oscData.trim().length > 0) {
1991
+ // Plain OSC 9 notification (iTerm2-style): an agent pinging the user.
1992
+ notification = oscData;
1993
+ }
1994
+ }
1995
+ else if (oscNum === '777') {
1996
+ // OSC 777 notification: `notify;<title>;<body>`. termdock's agent hooks
1997
+ // use the sentinel title carrying a JSON event; anything else is an
1998
+ // opaque notification from an agent without hooks installed.
1999
+ const event = parseAgentEvent(`777;${oscData}`);
2000
+ if (event) {
2001
+ agentEvents.push(event);
2002
+ }
2003
+ else {
2004
+ const body = parseOsc777NotificationBody(oscData);
2005
+ if (body)
2006
+ notification = body;
2007
+ }
1920
2008
  }
1921
2009
  }
1922
2010
  const remaining = buf.slice(lastMatchEnd).slice(-128);
1923
- return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, remaining };
1924
- }
1925
- const AGENT_INDICATORS = new Set(['spinner', 'pulse', 'dot', 'ring', 'badge', 'terminal', 'question']);
1926
- const DEFAULT_AGENT_CLEAR_DELAY_MS = 450;
1927
- const MIN_AGENT_CLEAR_DELAY_MS = 80;
1928
- const MAX_AGENT_CLEAR_DELAY_MS = 10_000;
1929
- const COCO_WAITING_RULE_PATTERN = '(?:Tab/Arrow keys to navigate[\\s\\S]{0,200}(?:select ·|Enter))|Coco\\s*等待态采样';
1930
- // Built-in default rules
1931
- const BUILTIN_AGENT_RULES = [
1932
- {
1933
- program: 'claude',
1934
- rules: [
1935
- // 雪花符号(spinner) + 1-25 个中英文字母 + 3 个点(英文 ... 或中文 …)
1936
- // 严格按"结构"匹配,不依赖具体词,排除 token 数字/输入回显
1937
- { pattern: '[✢✶✻✽][A-Za-z\\u4E00-\\u9FA5]{1,25}(?:\\.{3}|…)', status: 'running', color: '#4ade80', indicator: 'spinner', clearDelayMs: 700 },
1938
- ],
1939
- },
1940
- {
1941
- program: 'opencode',
1942
- rules: [
1943
- { pattern: 'thinking|working|generating', status: 'running', color: '#4ade80', indicator: 'pulse', clearDelayMs: 900 },
1944
- { pattern: '~ Asking questions', status: 'waiting', color: '#facc15', indicator: 'question', clearDelayMs: 10000 },
1945
- ],
1946
- },
1947
- {
1948
- program: 'coco',
1949
- rules: [
1950
- { pattern: COCO_WAITING_RULE_PATTERN, status: 'waiting', color: '#facc15', indicator: 'question', clearDelayMs: 10000 },
1951
- { pattern: '[·✢❋❇✽] (thinking|working|generating)', status: 'running', color: '#4ade80', indicator: 'spinner', clearDelayMs: 700 },
1952
- ],
1953
- },
1954
- ];
1955
- function isSameAgentRule(a, b) {
1956
- return a.pattern === b.pattern &&
1957
- a.status === b.status &&
1958
- a.color === b.color &&
1959
- a.indicator === b.indicator &&
1960
- a.clearDelayMs === b.clearDelayMs;
1961
- }
1962
- function isSingleProgramConfig(config, program) {
1963
- const programs = Array.isArray(config.programs)
1964
- ? config.programs.filter((name) => typeof name === 'string' && name.trim().length > 0)
1965
- : (typeof config.program === 'string' && config.program.trim().length > 0 ? [config.program] : []);
1966
- return programs.length === 1 && programs[0]?.trim().toLowerCase() === program;
1967
- }
1968
- function migrateAgentRules(rules) {
1969
- let changed = false;
1970
- const deprecatedCocoConfirmRule = {
1971
- pattern: 'confirm|approve|permission|continue\\?',
1972
- status: 'waiting',
1973
- color: '#facc15',
1974
- indicator: 'question',
1975
- clearDelayMs: 10000,
1976
- };
1977
- const deprecatedCocoBroadWaitingRule = {
1978
- pattern: 'Tab/Arrow keys to navigate|Esc to|select ·|Coco 等待态采样|AskUserQuestion|User\'s answers',
1979
- status: 'waiting',
1980
- color: '#facc15',
1981
- indicator: 'question',
1982
- clearDelayMs: 10000,
1983
- };
1984
- const cocoWaitingRule = {
1985
- pattern: COCO_WAITING_RULE_PATTERN,
1986
- status: 'waiting',
1987
- color: '#facc15',
1988
- indicator: 'question',
1989
- clearDelayMs: 10000,
1990
- };
1991
- const nextRules = rules
1992
- .filter((config) => {
1993
- const shouldRemove = isSingleProgramConfig(config, 'aider') &&
1994
- config.rules.length === 1 &&
1995
- isSameAgentRule(config.rules[0], {
1996
- pattern: 'Thinking|Generating|Working',
1997
- status: 'running',
1998
- color: '#4ade80',
1999
- indicator: 'pulse',
2000
- clearDelayMs: 900,
2001
- });
2002
- if (shouldRemove)
2003
- changed = true;
2004
- return !shouldRemove;
2005
- })
2006
- .map((config) => {
2007
- if (!isSingleProgramConfig(config, 'coco'))
2008
- return config;
2009
- let nextRules = config.rules.filter((rule) => !isSameAgentRule(rule, deprecatedCocoConfirmRule));
2010
- nextRules = nextRules.map((rule) => isSameAgentRule(rule, deprecatedCocoBroadWaitingRule) ? cocoWaitingRule : rule);
2011
- if (nextRules.length === config.rules.length && nextRules.every((rule, index) => isSameAgentRule(rule, config.rules[index])))
2012
- return config;
2013
- changed = true;
2014
- return { ...config, rules: nextRules };
2015
- });
2016
- return { rules: nextRules, changed };
2017
- }
2018
- // Runtime cache: program → compiled rules
2019
- let agentRulesCache = new Map();
2020
- let agentRulesVersion = 0;
2021
- function normalizeAgentIndicator(value) {
2022
- return typeof value === 'string' && AGENT_INDICATORS.has(value)
2023
- ? value
2024
- : 'pulse';
2025
- }
2026
- function normalizeAgentClearDelay(value) {
2027
- const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_AGENT_CLEAR_DELAY_MS;
2028
- return Math.min(MAX_AGENT_CLEAR_DELAY_MS, Math.max(MIN_AGENT_CLEAR_DELAY_MS, n));
2029
- }
2030
- function compileAgentRules(rules) {
2031
- const map = new Map();
2032
- for (const config of rules) {
2033
- const compiled = config.rules.map(r => ({
2034
- status: r.status,
2035
- color: r.color,
2036
- indicator: normalizeAgentIndicator(r.indicator),
2037
- clearDelayMs: normalizeAgentClearDelay(r.clearDelayMs),
2038
- regex: new RegExp(r.pattern, 'i'),
2039
- }));
2040
- const programNames = Array.isArray(config.programs)
2041
- ? config.programs
2042
- : (typeof config.program === 'string' ? [config.program] : []);
2043
- for (const name of programNames) {
2044
- const normalized = typeof name === 'string' ? name.trim().toLowerCase() : '';
2045
- if (!normalized)
2046
- continue;
2047
- map.set(normalized, compiled);
2048
- }
2011
+ return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, agentEvents, notification, remaining };
2012
+ }
2013
+ /** The body of a `notify;<title>;<body>` OSC 777 payload, or null. */
2014
+ function parseOsc777NotificationBody(data) {
2015
+ if (!data.startsWith('notify;'))
2016
+ return null;
2017
+ const rest = data.slice('notify;'.length);
2018
+ const sep = rest.indexOf(';');
2019
+ const body = sep >= 0 ? rest.slice(sep + 1) : rest;
2020
+ return body.trim().length > 0 ? body : null;
2021
+ }
2022
+ // ── Rich agent session tracking (identity registry + OSC 777 hook events) ──
2023
+ //
2024
+ // Two tiers, ported from tty7:
2025
+ // 1. Identity: the activeProgram poll's rawArgs matched against the agent
2026
+ // registry answers "*which* agent runs here" (brand, accent, icon, resume).
2027
+ // 2. Rich status: agent-side hooks emit OSC 777 sentinel events that the
2028
+ // sniffer collects; they drive a per-pane state machine
2029
+ // (idle/working/waiting/done) pushed to clients as `agent-status`.
2030
+ // A plain OSC 9/777 notification is the no-hooks fallback: it only means
2031
+ // "the agent pinged you" and never overrides rich state.
2032
+ /** User-defined wrapper→agent rules from the program-detection config. */
2033
+ function agentCustomCommands() {
2034
+ return agentCommandsMap;
2035
+ }
2036
+ /** Naive whitespace split of a captured command line into argv tokens. */
2037
+ function splitCommandToArgv(command) {
2038
+ return command
2039
+ .split(/\s+/)
2040
+ .map((t) => t.replace(/^["']+|["']+$/g, ''))
2041
+ .filter((t) => t.length > 0);
2042
+ }
2043
+ /** The coding agent running in this session's foreground, per activeProgram. */
2044
+ function detectSessionAgent(session) {
2045
+ const ap = session.activeProgram;
2046
+ if (!ap)
2047
+ return null;
2048
+ const custom = agentCustomCommands();
2049
+ if (ap.rawArgs) {
2050
+ const hit = detectAgentFromCommand(ap.rawArgs, custom);
2051
+ if (hit)
2052
+ return hit;
2053
+ }
2054
+ if (ap.command) {
2055
+ return detectAgentFromCommand(ap.command, custom);
2049
2056
  }
2050
- agentRulesCache = map;
2051
- agentRulesVersion++;
2052
- return map;
2057
+ return null;
2053
2058
  }
2054
- const AGENT_RULES_FILE = `${TERMDOCK_DIR}/agent-rules.json`;
2055
- async function loadAgentRulesFromDisk() {
2056
- try {
2057
- const parsed = await readJsonFileIfExists(AGENT_RULES_FILE);
2058
- if (Array.isArray(parsed) && parsed.length > 0) {
2059
- const migrated = migrateAgentRules(parsed);
2060
- if (migrated.changed) {
2061
- await writeJsonFile(AGENT_RULES_FILE, migrated.rules);
2059
+ /** Status indicator glyph. Colors stay client-side (CSS theme vars) so
2060
+ * dark/light themes render correctly; the wire carries semantics only. */
2061
+ const AGENT_STATUS_INDICATOR = {
2062
+ idle: null,
2063
+ working: 'spinner',
2064
+ waiting: 'question',
2065
+ done: 'badge',
2066
+ };
2067
+ /** The pane sits at a shell prompt (no foreground program, or a shell). Only
2068
+ * then does an agent-resume offer make sense — pasting a resume command into
2069
+ * vim/htop would corrupt its input. */
2070
+ function isPaneAtShellPrompt(session) {
2071
+ const command = session.activeProgram?.command;
2072
+ if (!command)
2073
+ return true;
2074
+ return shellNamesBackend.has(command.toLowerCase());
2075
+ }
2076
+ function buildAgentStatusPayload(sessionId, session) {
2077
+ const state = session.agentSession;
2078
+ const status = state ? state.status : null;
2079
+ const indicator = status ? AGENT_STATUS_INDICATOR[status] : null;
2080
+ let agent = session.agent;
2081
+ let nativeSessionId = state?.sessionId ?? null;
2082
+ // The agent just exited: keep offering its last conversation for resume
2083
+ // (brand + native id from the persisted last-known record) while the pane
2084
+ // sits at a shell prompt.
2085
+ if (!agent && !nativeSessionId && isPaneAtShellPrompt(session)) {
2086
+ const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
2087
+ if (record?.agentResume?.sessionId) {
2088
+ const persistedAgent = agentBySlug(record.agentResume.slug);
2089
+ if (persistedAgent) {
2090
+ agent = persistedAgent;
2091
+ nativeSessionId = record.agentResume.sessionId;
2062
2092
  }
2063
- return migrated.rules;
2064
2093
  }
2065
2094
  }
2066
- catch { /* file doesn't exist or invalid, use builtins */ }
2067
- return BUILTIN_AGENT_RULES;
2068
- }
2069
- async function loadAgentRules() {
2070
- return compileAgentRules(await loadAgentRulesFromDisk());
2071
- }
2072
- async function saveAgentRulesToDisk(rules) {
2073
- await writeJsonFile(AGENT_RULES_FILE, rules);
2074
- compileAgentRules(rules);
2095
+ return {
2096
+ type: 'agent-status',
2097
+ agentStatus: status,
2098
+ agentIndicator: indicator ?? null,
2099
+ agent: agent
2100
+ ? { slug: agent.slug, displayName: agent.displayName, accentColor: agent.accentColor, icon: agent.icon }
2101
+ : null,
2102
+ agentMessage: state?.message ?? null,
2103
+ agentNativeSessionId: nativeSessionId,
2104
+ agentRich: state?.rich ?? false,
2105
+ agentActivity: state?.activity ?? 0,
2106
+ agentCwd: state?.agentCwd ?? null,
2107
+ };
2075
2108
  }
2076
- // Initialize on startup
2077
- void loadAgentRules();
2078
- function isAiToolProgram(command) {
2079
- if (!command)
2080
- return false;
2081
- return agentRulesCache.has(command.toLowerCase());
2109
+ let lastAgentStatusSnapshots = new Map();
2110
+ function broadcastAgentStatus(sessionId, session, force = false) {
2111
+ const payload = buildAgentStatusPayload(sessionId, session);
2112
+ const snapshot = JSON.stringify(payload);
2113
+ if (!force && lastAgentStatusSnapshots.get(sessionId) === snapshot)
2114
+ return;
2115
+ lastAgentStatusSnapshots.set(sessionId, snapshot);
2116
+ broadcastEvent(sessionId, payload);
2117
+ }
2118
+ /** Persist the info needed to resume this pane's agent conversation after the
2119
+ * agent exits / the server restarts (last-known semantics, debounced). */
2120
+ function persistAgentResumeBinding(backendSessionId, session) {
2121
+ const record = globalSessionState.sessions.find((s) => s.backendSessionId === backendSessionId);
2122
+ if (!record)
2123
+ return;
2124
+ const agent = session.agent;
2125
+ const sess = session.agentSession;
2126
+ if (!agent || !sess?.sessionId)
2127
+ return;
2128
+ const next = {
2129
+ slug: agent.slug,
2130
+ sessionId: sess.sessionId,
2131
+ launchArgv: sess.launchArgv,
2132
+ updatedAt: Date.now(),
2133
+ };
2134
+ if (JSON.stringify(record.agentResume ?? null) === JSON.stringify(next))
2135
+ return;
2136
+ upsertGlobalSessionRecord({ ...record, agentResume: next });
2137
+ schedulePersistGlobalState();
2082
2138
  }
2083
2139
  /**
2084
- * 去除 ANSI 转义序列,保留纯文本内容
2140
+ * React to activeProgram changes: detect agent identity, end the rich session
2141
+ * when the agent leaves the foreground, and stamp the launch argv (for resume
2142
+ * flag replay) while the agent is present. Called from every activeProgram
2143
+ * assignment site.
2085
2144
  */
2086
- function stripAnsi(data) {
2087
- return data
2088
- .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
2089
- .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC sequences
2090
- .replace(/\x1b[()][AB0-2]/g, '') // Charset
2091
- .replace(/\x1b[^[\]()0-9]/g, '') // Other escapes
2092
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // Control chars
2093
- }
2094
- // Retained for potential future re-enablement of agent status detection.
2095
- void stripAnsi;
2096
- /**
2097
- * 基于可配置规则匹配检测 AI 工具状态
2098
- * 只看实际输出文本,不受 resize/布局变化影响
2099
- */
2100
- function detectAgentStatus(command, buf) {
2101
- if (!buf || buf.length < 2)
2102
- return null;
2103
- const rules = agentRulesCache.get(command.toLowerCase());
2104
- if (!rules)
2105
- return null;
2106
- // 只看最后 1KB,这是最新的输出
2107
- const tail = buf.slice(-1024);
2108
- for (const rule of rules) {
2109
- if (rule.regex.test(tail)) {
2110
- return { status: rule.status, color: rule.color, indicator: rule.indicator, clearDelayMs: rule.clearDelayMs };
2145
+ function syncAgentIdentity(sessionId, session) {
2146
+ const detected = detectSessionAgent(session);
2147
+ if (detected !== session.agent) {
2148
+ // The agent leaving the foreground ends its session: clear the rich state
2149
+ // so a stale "waiting" dot can't outlive the process. The poll can blip
2150
+ // momentarily (an agent-spawned subcommand takes the foreground group),
2151
+ // but events re-establish state on the next signal.
2152
+ if (session.agent && session.agentSession) {
2153
+ persistAgentResumeBinding(sessionId, session);
2154
+ session.agentSession = null;
2155
+ }
2156
+ session.agent = detected;
2157
+ broadcastAgentStatus(sessionId, session);
2158
+ }
2159
+ // Stamp the launch argv the identity poll captured — resume gets the flags.
2160
+ if (detected && session.activeProgram?.rawArgs) {
2161
+ const argv = splitCommandToArgv(session.activeProgram.rawArgs);
2162
+ if (argv.length > 0) {
2163
+ const sess = session.agentSession;
2164
+ if (sess && sess.launchArgv === null) {
2165
+ sess.launchArgv = argv;
2166
+ persistAgentResumeBinding(sessionId, session);
2167
+ broadcastAgentStatus(sessionId, session);
2168
+ }
2169
+ else if (!sess) {
2170
+ // No session state yet (no hook events seen): remember the argv on a
2171
+ // pending field via the resume record so an exited agent can still be
2172
+ // resumed with its flags even if hooks never fired.
2173
+ const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
2174
+ if (record?.agentResume && record.agentResume.slug === detected.slug && !record.agentResume.launchArgv) {
2175
+ upsertGlobalSessionRecord({ ...record, agentResume: { ...record.agentResume, launchArgv: argv } });
2176
+ schedulePersistGlobalState();
2177
+ }
2178
+ }
2111
2179
  }
2112
2180
  }
2113
- return null;
2114
- }
2115
- function clearAgentStatusTimer(session) {
2116
- if (session.agentStatusTimer) {
2117
- clearTimeout(session.agentStatusTimer);
2118
- session.agentStatusTimer = null;
2119
- }
2120
2181
  }
2121
- function evaluateAgentStatus(sessionId, session, latestChunk) {
2122
- const previousStatus = session.agentStatus;
2123
- const command = session.activeProgram?.command;
2124
- if (!command || !isAiToolProgram(command)) {
2125
- if (previousStatus !== null) {
2126
- clearAgentStatusTimer(session);
2127
- session.agentStatus = null;
2128
- session.agentColor = null;
2129
- session.agentIndicator = null;
2130
- session.agentStatusClearDelayMs = DEFAULT_AGENT_CLEAR_DELAY_MS;
2131
- broadcastEvent(sessionId, { type: 'agent-status', agentStatus: null, agentColor: null, agentIndicator: null });
2132
- }
2182
+ /**
2183
+ * Fold the sniffer's agent signals into the pane's session state and push any
2184
+ * resulting change. Called from the PTY data path.
2185
+ */
2186
+ function applyAgentSignals(sessionId, session, events, notification) {
2187
+ if (events.length === 0 && !notification)
2133
2188
  return;
2134
- }
2135
- const detected = detectAgentStatus(command, latestChunk || session.agentStatusBuf);
2136
- if (detected) {
2137
- clearAgentStatusTimer(session);
2138
- const newStatus = detected.status;
2139
- const newColor = detected.color ?? null;
2140
- const newIndicator = detected.indicator;
2141
- session.agentStatusClearDelayMs = detected.clearDelayMs;
2142
- if (newStatus !== previousStatus || newColor !== session.agentColor || newIndicator !== session.agentIndicator) {
2143
- session.agentStatus = newStatus;
2144
- session.agentColor = newColor;
2145
- session.agentIndicator = newIndicator;
2146
- broadcastEvent(sessionId, { type: 'agent-status', agentStatus: newStatus, agentColor: newColor, agentIndicator: newIndicator });
2189
+ let identityChanged = false;
2190
+ for (const event of events) {
2191
+ // An event naming an agent brands the pane even when the process poll
2192
+ // can't see through a wrapper: identity via protocol.
2193
+ if (!session.agent && event.agent) {
2194
+ session.agent = event.agent;
2195
+ identityChanged = true;
2196
+ }
2197
+ const state = session.agentSession ?? defaultAgentSessionState();
2198
+ session.agentSession = state;
2199
+ applyAgentEvent(state, event);
2200
+ if (event.kind === 'session-start' || event.kind === 'session-end' || event.kind === 'stop') {
2201
+ persistAgentResumeBinding(sessionId, session);
2202
+ }
2203
+ // The agent's cwd claim: allow file APIs to follow it (worktree hops).
2204
+ if (state.agentCwd) {
2205
+ void pathValidator.allowSessionCwd(state.agentCwd);
2206
+ }
2207
+ }
2208
+ // Opaque fallback: only meaningful when we know an agent runs here, and
2209
+ // never on top of rich state (the hooks channel owns it then).
2210
+ if (notification && session.agent && !session.agentSession?.rich) {
2211
+ const state = session.agentSession ?? defaultAgentSessionState();
2212
+ session.agentSession = state;
2213
+ if (state.status !== 'waiting' || state.message !== notification) {
2214
+ state.status = 'waiting';
2215
+ state.message = notification;
2216
+ }
2217
+ }
2218
+ // Seed the launch argv the identity poll captured, so resume gets the
2219
+ // flags no matter which side observed the pane first.
2220
+ if (session.agentSession && session.agentSession.launchArgv === null && session.activeProgram?.rawArgs) {
2221
+ const argv = splitCommandToArgv(session.activeProgram.rawArgs);
2222
+ if (argv.length > 0) {
2223
+ session.agentSession.launchArgv = argv;
2224
+ persistAgentResumeBinding(sessionId, session);
2225
+ }
2226
+ }
2227
+ // Agent activity (a tool completed → the working tree may have changed) is
2228
+ // one of the git probe's triggers.
2229
+ maybeRefreshGitStatusForAgent(sessionId, session);
2230
+ broadcastAgentStatus(sessionId, session, identityChanged);
2231
+ }
2232
+ // ── Git status (branch + diff size) ──
2233
+ //
2234
+ // Probes are cheap (two git shell-outs) and shared through the repo-root
2235
+ // cache; triggers are event-driven rather than a timer: cwd change, command
2236
+ // end (prompt-state running→idle), and agent tool activity mid-turn.
2237
+ const lastGitStatusSnapshots = new Map();
2238
+ function refreshGitStatus(sessionId, session, opts = {}) {
2239
+ // The agent's own cwd claim (worktree hops) wins over the pane's proc cwd.
2240
+ const key = session.agentSession?.agentCwd ?? session.cwd ?? null;
2241
+ if (!key) {
2242
+ if (session.gitStatus !== null) {
2243
+ session.gitStatus = null;
2244
+ session.gitStatusKey = null;
2245
+ broadcastEvent(sessionId, { type: 'git-status', gitStatus: null });
2147
2246
  }
2148
2247
  return;
2149
2248
  }
2150
- if (previousStatus !== null && !session.agentStatusTimer) {
2151
- session.agentStatusTimer = setTimeout(() => {
2152
- session.agentStatusTimer = null;
2153
- const recheck = detectAgentStatus(command, session.agentStatusBuf.slice(-256));
2154
- if (!recheck) {
2155
- session.agentStatus = null;
2156
- session.agentColor = null;
2157
- session.agentIndicator = null;
2158
- session.agentStatusClearDelayMs = DEFAULT_AGENT_CLEAR_DELAY_MS;
2159
- broadcastEvent(sessionId, { type: 'agent-status', agentStatus: null, agentColor: null, agentIndicator: null });
2160
- }
2161
- }, session.agentStatusClearDelayMs);
2162
- }
2249
+ session.gitStatusKey = key;
2250
+ const probe = gitStatusCache.beginProbe(key, opts.minIntervalMs);
2251
+ if (!probe)
2252
+ return;
2253
+ void probe.then((status) => {
2254
+ // The pane may have moved on while the probe ran; publish only if the
2255
+ // probe's key is still the pane's tree (or the session switched away —
2256
+ // then the new cwd's own trigger will publish its own).
2257
+ if (session.gitStatusKey !== key)
2258
+ return;
2259
+ const snapshot = JSON.stringify(status);
2260
+ if (lastGitStatusSnapshots.get(sessionId) === snapshot)
2261
+ return;
2262
+ lastGitStatusSnapshots.set(sessionId, snapshot);
2263
+ session.gitStatus = status;
2264
+ broadcastEvent(sessionId, { type: 'git-status', gitStatus: status });
2265
+ }).catch(() => { });
2266
+ }
2267
+ /**
2268
+ * Git probe trigger: agent tool activity means the working tree may have
2269
+ * changed mid-turn.
2270
+ */
2271
+ function maybeRefreshGitStatusForAgent(sessionId, session) {
2272
+ const activity = session.agentSession?.activity ?? 0;
2273
+ if (activity === 0 || activity === session.gitAgentActivitySeen)
2274
+ return;
2275
+ session.gitAgentActivitySeen = activity;
2276
+ refreshGitStatus(sessionId, session);
2277
+ }
2278
+ /**
2279
+ * The command that resumes this pane's last agent conversation, or null.
2280
+ * Live state wins; falls back to the persisted last-known record.
2281
+ */
2282
+ function resolveAgentResumeCommand(sessionId, session) {
2283
+ const liveAgent = session.agent;
2284
+ const live = session.agentSession;
2285
+ if (liveAgent && live?.sessionId) {
2286
+ const cmd = buildResumeCommand(liveAgent, live.sessionId, live.launchArgv);
2287
+ if (cmd)
2288
+ return cmd;
2289
+ }
2290
+ if (!isPaneAtShellPrompt(session))
2291
+ return null;
2292
+ const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
2293
+ const persisted = record?.agentResume;
2294
+ if (!persisted?.sessionId)
2295
+ return null;
2296
+ const agent = agentBySlug(persisted.slug);
2297
+ if (!agent)
2298
+ return null;
2299
+ return buildResumeCommand(agent, persisted.sessionId, persisted.launchArgv);
2300
+ }
2301
+ /**
2302
+ * Deliver a prompt into an agent's PTY: bracketed paste (so multi-line
2303
+ * prompts insert as one block instead of submitting line by line — every
2304
+ * recognized agent's TUI enables bracketed paste), followed by CR to submit.
2305
+ * ESC bytes are stripped so embedded content can't fake the paste terminator.
2306
+ */
2307
+ function buildBracketedSubmitBytes(prompt) {
2308
+ return `\x1b[200~${prompt.replace(/\x1b/g, '')}\x1b[201~\r`;
2163
2309
  }
2164
- // ── end Agent status detection ──
2310
+ // ── end Rich agent session tracking ──
2165
2311
  /**
2166
2312
  * Resolve the "real" program name for a tmux pane.
2167
2313
  *
@@ -2176,10 +2322,12 @@ const DEFAULT_PROGRAM_DETECTION = {
2176
2322
  genericProgramNames: ['node', 'python', 'python3', 'ruby', 'perl', 'java'],
2177
2323
  wrapperScriptNames: ['aiden', 'ttadk', 'npx', 'yarn', 'dlx'],
2178
2324
  shellNames: ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu'],
2325
+ agentCommands: {},
2179
2326
  };
2180
2327
  let genericProgramNames = new Set(DEFAULT_PROGRAM_DETECTION.genericProgramNames);
2181
2328
  let wrapperScriptNames = new Set(DEFAULT_PROGRAM_DETECTION.wrapperScriptNames);
2182
2329
  let shellNamesBackend = new Set(DEFAULT_PROGRAM_DETECTION.shellNames);
2330
+ let agentCommandsMap = { ...DEFAULT_PROGRAM_DETECTION.agentCommands };
2183
2331
  async function loadProgramDetectionFromDisk() {
2184
2332
  try {
2185
2333
  const parsed = await readJsonFileIfExists(PROGRAM_DETECTION_FILE);
@@ -2189,6 +2337,10 @@ async function loadProgramDetectionFromDisk() {
2189
2337
  genericProgramNames: Array.isArray(parsed.genericProgramNames) ? parsed.genericProgramNames : DEFAULT_PROGRAM_DETECTION.genericProgramNames,
2190
2338
  wrapperScriptNames: Array.isArray(parsed.wrapperScriptNames) ? parsed.wrapperScriptNames : DEFAULT_PROGRAM_DETECTION.wrapperScriptNames,
2191
2339
  shellNames: Array.isArray(parsed.shellNames) ? parsed.shellNames : DEFAULT_PROGRAM_DETECTION.shellNames,
2340
+ agentCommands: parsed.agentCommands && typeof parsed.agentCommands === 'object' && !Array.isArray(parsed.agentCommands)
2341
+ ? Object.fromEntries(Object.entries(parsed.agentCommands)
2342
+ .filter(([k, v]) => typeof k === 'string' && typeof v === 'string'))
2343
+ : {},
2192
2344
  };
2193
2345
  }
2194
2346
  catch { /* file doesn't exist or invalid, use defaults */ }
@@ -2198,6 +2350,7 @@ function applyProgramDetectionConfig(config) {
2198
2350
  genericProgramNames = new Set(config.genericProgramNames);
2199
2351
  wrapperScriptNames = new Set(config.wrapperScriptNames);
2200
2352
  shellNamesBackend = new Set(config.shellNames);
2353
+ agentCommandsMap = { ...(config.agentCommands ?? {}) };
2201
2354
  }
2202
2355
  async function saveProgramDetectionToDisk(config) {
2203
2356
  await writeJsonFile(PROGRAM_DETECTION_FILE, config);
@@ -2205,6 +2358,12 @@ async function saveProgramDetectionToDisk(config) {
2205
2358
  }
2206
2359
  // Initialize on startup
2207
2360
  void loadProgramDetectionFromDisk().then(applyProgramDetectionConfig);
2361
+ // Startup keeper: rewrite hook integrations that point at a stale termdock
2362
+ // (package moved/updated) so they keep firing into the right emitter.
2363
+ try {
2364
+ refreshStaleHooksAtLaunch();
2365
+ }
2366
+ catch { /* hook refresh must never block startup */ }
2208
2367
  async function resolveTmuxPaneProgram(pane) {
2209
2368
  // If pane command is a known shell, try to find a child foreground process
2210
2369
  const command = normalizeProgramName(pane.command);
@@ -2605,6 +2764,18 @@ async function ensureManagedTmuxSessionReady(sessionName) {
2605
2764
  }
2606
2765
  await applyTmuxScrollbackProfile(sessionName);
2607
2766
  await stampTmuxMetadata(sessionName);
2767
+ // Agent-hook plumbing for panes in this session: TERMDOCK marks panes
2768
+ // spawned here as ours (the emitter's gate), and allow-passthrough lets
2769
+ // hooks' DCS-wrapped sentinel OSC reach us through tmux. Set here (not in
2770
+ // the shell-integration path, which early-returns) so every managed
2771
+ // session gets them regardless of shell type.
2772
+ try {
2773
+ await runTmux(['set-environment', '-t', sessionName, 'TERMDOCK', '1']);
2774
+ await runTmux(['set-option', '-t', sessionName, 'allow-passthrough', 'on']);
2775
+ }
2776
+ catch (error) {
2777
+ console.warn(`Failed to enable agent-hook plumbing for ${sessionName}: ${getErrorMessage(error)}`);
2778
+ }
2608
2779
  }
2609
2780
  async function prepareManagedTmuxSession(sessionName, cwd) {
2610
2781
  await ensureTmuxSessionExists(sessionName, cwd);
@@ -2819,12 +2990,10 @@ function cleanupSession(sessionId, options) {
2819
2990
  if (!session) {
2820
2991
  return;
2821
2992
  }
2822
- if (session.agentStatusTimer) {
2823
- clearTimeout(session.agentStatusTimer);
2824
- session.agentStatusTimer = null;
2825
- }
2826
2993
  session.dataDisposable?.dispose();
2827
2994
  session.exitDisposable?.dispose();
2995
+ lastAgentStatusSnapshots.delete(sessionId);
2996
+ lastGitStatusSnapshots.delete(sessionId);
2828
2997
  destroyTmuxControl(session.tmuxControl);
2829
2998
  session.tmuxControl = undefined;
2830
2999
  for (const timer of session.flowPausedClientTimers.values()) {
@@ -3077,75 +3246,88 @@ function updateFocusTrackingFromOutput(sessionId, session, data) {
3077
3246
  emitFocusSequenceIfNeeded(sessionId, session, true, 'focus-mode-enabled');
3078
3247
  }
3079
3248
  }
3080
- function setupPtyHandlers(sessionId, session) {
3249
+ /**
3250
+ * The PTY output pipeline: history append + OSC sniffing + push. Live output
3251
+ * runs with broadcast on; pty-host *replay* (adoption after a server restart)
3252
+ * runs with broadcast off — it rebuilds history and sniffed state (cwd,
3253
+ * prompt-state, agent sessions) without re-sending stale output to clients.
3254
+ */
3255
+ function processSessionOutput(sessionId, session, data, opts) {
3081
3256
  const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
3082
- session.dataDisposable = session.ptyProcess.onData((data) => {
3257
+ if (opts.broadcast) {
3083
3258
  session.lastActivity = Date.now();
3084
3259
  session.lastOutputAt = Date.now();
3085
3260
  session.hasWrittenData = true;
3086
3261
  scheduleActivityReorder();
3087
- let seq;
3088
- if (session.mode === 'shell') {
3089
- seq = addToHistory(sessionId, data);
3090
- }
3091
- if (session.mode === 'tmux') {
3092
- updateFocusTrackingFromOutput(sessionId, session, data);
3093
- }
3094
- // Sniff OSC sequences for CWD tracking + prompt state + title
3095
- try {
3096
- const buf = session.oscSniffBuf + data;
3097
- if (buf.length > OSC_SNIFF_CAP) {
3098
- session.oscSniffBuf = buf.slice(-OSC_SNIFF_CAP / 4); // trim
3099
- }
3100
- const result = sniffOsc(buf, home);
3101
- session.oscSniffBuf = result.remaining;
3102
- // CWD change
3103
- if (result.cwd && result.cwd !== session.lastOscCwd) {
3104
- session.lastOscCwd = result.cwd;
3105
- session.cwd = result.cwd;
3106
- // 用户在终端里 cd 到白名单外目录(如 Windows 其它盘符)时,动态放行,
3107
- // 否则侧边栏目录树/文件 API 会报 "Access denied: path not allowed"。
3108
- void pathValidator.allowSessionCwd(result.cwd);
3109
- if (updateGlobalBindingForBackendSession(sessionId, { cwd: result.cwd, lastActivity: session.lastActivity })) {
3110
- schedulePersistGlobalState();
3111
- }
3112
- broadcastEvent(sessionId, { type: 'cwd', cwd: result.cwd });
3113
- }
3114
- // Title change — broadcast so the frontend can update tab/sidebar
3115
- if (result.title !== null && result.title !== session.lastOscTitle) {
3116
- session.lastOscTitle = result.title;
3117
- broadcastJsonWs(sessionId, { type: 'shell-title', title: result.title });
3118
- }
3119
- // Prompt state change (OSC 133)
3120
- if (result.promptState !== null && result.promptState !== session.lastPromptState) {
3121
- session.lastPromptState = result.promptState;
3122
- broadcastJsonWs(sessionId, {
3123
- type: 'prompt-state',
3124
- state: result.promptState,
3125
- exitCode: result.exitCode,
3126
- });
3262
+ }
3263
+ let seq;
3264
+ if (session.mode === 'shell') {
3265
+ seq = addToHistory(sessionId, data);
3266
+ }
3267
+ if (session.mode === 'tmux') {
3268
+ updateFocusTrackingFromOutput(sessionId, session, data);
3269
+ }
3270
+ // Sniff OSC sequences for CWD tracking + prompt state + title
3271
+ try {
3272
+ const buf = session.oscSniffBuf + data;
3273
+ if (buf.length > OSC_SNIFF_CAP) {
3274
+ session.oscSniffBuf = buf.slice(-OSC_SNIFF_CAP / 4); // trim
3275
+ }
3276
+ const result = sniffOsc(buf, home);
3277
+ session.oscSniffBuf = result.remaining;
3278
+ // CWD change
3279
+ if (result.cwd && result.cwd !== session.lastOscCwd) {
3280
+ session.lastOscCwd = result.cwd;
3281
+ session.cwd = result.cwd;
3282
+ // 用户在终端里 cd 到白名单外目录(如 Windows 其它盘符)时,动态放行,
3283
+ // 否则侧边栏目录树/文件 API 会报 "Access denied: path not allowed"。
3284
+ void pathValidator.allowSessionCwd(result.cwd);
3285
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: result.cwd, lastActivity: session.lastActivity })) {
3286
+ schedulePersistGlobalState();
3127
3287
  }
3128
- if (result.tuiProgress !== null) {
3129
- session.tuiProgress = result.tuiProgress.state === 'remove' ? null : result.tuiProgress;
3130
- broadcastJsonWs(sessionId, {
3131
- type: 'tui-progress',
3132
- tuiProgress: session.tuiProgress,
3133
- });
3288
+ broadcastEvent(sessionId, { type: 'cwd', cwd: result.cwd });
3289
+ refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
3290
+ }
3291
+ // Title change — broadcast so the frontend can update tab/sidebar
3292
+ if (result.title !== null && result.title !== session.lastOscTitle) {
3293
+ session.lastOscTitle = result.title;
3294
+ broadcastJsonWs(sessionId, { type: 'shell-title', title: result.title });
3295
+ }
3296
+ // Prompt state change (OSC 133)
3297
+ if (result.promptState !== null && result.promptState !== session.lastPromptState) {
3298
+ const wasRunning = session.lastPromptState === 'running';
3299
+ session.lastPromptState = result.promptState;
3300
+ broadcastJsonWs(sessionId, {
3301
+ type: 'prompt-state',
3302
+ state: result.promptState,
3303
+ exitCode: result.exitCode,
3304
+ });
3305
+ // A command just finished — the working tree may have changed.
3306
+ if (wasRunning && result.promptState === 'idle') {
3307
+ refreshGitStatus(sessionId, session);
3134
3308
  }
3135
3309
  }
3136
- catch { /* sniff failure should never block data */ }
3137
- // Agent status content-based detection DISABLED.
3138
- // OSC 133 promptState now provides real-time running/idle state for all
3139
- // programs (not just AI tools). The content-based detection was inaccurate
3140
- // (false positives from spinner characters in normal output, false negatives
3141
- // when AI tools changed their output format). Keeping the code for potential
3142
- // future re-enablement but not calling it.
3143
- // try {
3144
- // if (isAiToolProgram(session.activeProgram?.command)) {
3145
- // ...evaluateAgentStatus...
3146
- // }
3147
- // } catch {}
3310
+ if (result.tuiProgress !== null) {
3311
+ session.tuiProgress = result.tuiProgress.state === 'remove' ? null : result.tuiProgress;
3312
+ broadcastJsonWs(sessionId, {
3313
+ type: 'tui-progress',
3314
+ tuiProgress: session.tuiProgress,
3315
+ });
3316
+ }
3317
+ // Rich agent events (OSC 777 sentinel from installed hooks) + the
3318
+ // opaque notification fallback drive the per-pane agent session state.
3319
+ if (result.agentEvents.length > 0 || result.notification) {
3320
+ applyAgentSignals(sessionId, session, result.agentEvents, result.notification);
3321
+ }
3322
+ }
3323
+ catch { /* sniff failure should never block data */ }
3324
+ if (opts.broadcast) {
3148
3325
  broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
3326
+ }
3327
+ }
3328
+ function setupPtyHandlers(sessionId, session) {
3329
+ session.dataDisposable = session.ptyProcess.onData((data) => {
3330
+ processSessionOutput(sessionId, session, data, { broadcast: true });
3149
3331
  });
3150
3332
  session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
3151
3333
  console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
@@ -3167,6 +3349,17 @@ function wrapPtySpawnError(error) {
3167
3349
  }
3168
3350
  return error;
3169
3351
  }
3352
+ /**
3353
+ * Shell-mode persistence via the pty-host daemon: shells live in a detached
3354
+ * host process and survive server restarts. Set TERMDOCK_PTY_HOST=off to
3355
+ * fall back to in-process PTYs (Windows always uses the in-process path).
3356
+ */
3357
+ function ptyHostShellModeEnabled() {
3358
+ // The host daemon always uses node-pty; under a Bun runtime that's the
3359
+ // backend with known issues, so keep the in-process path there.
3360
+ const isBun = typeof globalThis.Bun !== 'undefined';
3361
+ return process.platform !== 'win32' && !isBun && process.env.TERMDOCK_PTY_HOST !== 'off';
3362
+ }
3170
3363
  async function spawnTerminalSession(req, input) {
3171
3364
  const cwd = await resolveWorkingDirectory(req, input.cwd);
3172
3365
  const cols = input.cols || 80;
@@ -3194,12 +3387,20 @@ async function spawnTerminalSession(req, input) {
3194
3387
  : (process.platform === 'win32' ? buildPowerShellCwdHookArgs() : []);
3195
3388
  const envPath = buildAugmentedPath();
3196
3389
  const resolvedEnv = { ...process.env, PATH: envPath };
3390
+ // The server itself may run under tmux; spawned shells are NOT tmux panes.
3391
+ // Inheriting TMUX/TMUX_PANE makes tmux-aware programs (incl. our own
3392
+ // agent-hook emitter's passthrough decision) misdetect their context.
3393
+ delete resolvedEnv.TMUX;
3394
+ delete resolvedEnv.TMUX_PANE;
3197
3395
  const pty = await getPtyProvider();
3198
3396
  const termValue = resolveTerminalTermType(input.termType);
3199
3397
  const baseEnv = {
3200
3398
  ...resolvedEnv,
3201
3399
  TERM: termValue,
3202
3400
  COLORTERM: 'truecolor',
3401
+ // Gates the agent-hook emitter: hooks installed globally stay silent
3402
+ // unless the agent runs inside a termdock-spawned shell.
3403
+ TERMDOCK: '1',
3203
3404
  };
3204
3405
  let ptyProcess = null;
3205
3406
  if (mode === 'shell' && process.platform !== 'win32') {
@@ -3208,13 +3409,28 @@ async function spawnTerminalSession(req, input) {
3208
3409
  for (const shellCandidate of shellCandidates) {
3209
3410
  try {
3210
3411
  const env = await injectShellIntegration(shellCandidate, baseEnv);
3211
- ptyProcess = pty.spawn(shellCandidate, [], {
3212
- name: termValue,
3213
- cols,
3214
- rows,
3215
- cwd,
3216
- env,
3217
- });
3412
+ if (ptyHostShellModeEnabled()) {
3413
+ // The PTY lives in the detached pty-host daemon: this session
3414
+ // survives server restarts and is re-adopted on launch.
3415
+ ptyProcess = await getPtyHostManager().spawnChannel(sessionId, {
3416
+ shell: shellCandidate,
3417
+ args: [],
3418
+ cwd,
3419
+ cols,
3420
+ rows,
3421
+ env,
3422
+ termName: termValue,
3423
+ });
3424
+ }
3425
+ else {
3426
+ ptyProcess = pty.spawn(shellCandidate, [], {
3427
+ name: termValue,
3428
+ cols,
3429
+ rows,
3430
+ cwd,
3431
+ env,
3432
+ });
3433
+ }
3218
3434
  break;
3219
3435
  }
3220
3436
  catch (error) {
@@ -3251,7 +3467,7 @@ async function spawnTerminalSession(req, input) {
3251
3467
  }
3252
3468
  const session = {
3253
3469
  ptyProcess,
3254
- ptyBackend: pty.backend,
3470
+ ptyBackend: mode === 'shell' && ptyHostShellModeEnabled() ? 'pty-host' : pty.backend,
3255
3471
  cwd,
3256
3472
  mode,
3257
3473
  tmuxSessionName,
@@ -3263,17 +3479,16 @@ async function spawnTerminalSession(req, input) {
3263
3479
  createdAt: Date.now(),
3264
3480
  hasWrittenData: false,
3265
3481
  activeProgram: null,
3482
+ agent: null,
3483
+ agentSession: null,
3484
+ gitStatus: null,
3485
+ gitStatusKey: null,
3486
+ gitAgentActivitySeen: 0,
3266
3487
  oscSniffBuf: '',
3267
3488
  lastOscCwd: null,
3268
3489
  lastOscTitle: null,
3269
3490
  lastPromptState: null,
3270
3491
  tuiProgress: null,
3271
- agentStatus: null,
3272
- agentColor: null,
3273
- agentIndicator: null,
3274
- agentStatusBuf: '',
3275
- agentStatusTimer: null,
3276
- agentStatusClearDelayMs: DEFAULT_AGENT_CLEAR_DELAY_MS,
3277
3492
  focusTrackingRequested: false,
3278
3493
  focusModeSniffBuf: '',
3279
3494
  focusAggregation: {
@@ -3285,6 +3500,8 @@ async function spawnTerminalSession(req, input) {
3285
3500
  ptyPausedForFlowControl: false,
3286
3501
  };
3287
3502
  terminalSessions.set(sessionId, session);
3503
+ // First git snapshot for the pane's spawn cwd (throttle-free).
3504
+ refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
3288
3505
  setupPtyHandlers(sessionId, session);
3289
3506
  if (mode === 'tmux' && tmuxSessionName) {
3290
3507
  // Session was prepared before attach so the first tmux client sees the
@@ -3309,6 +3526,88 @@ async function spawnTerminalSession(req, input) {
3309
3526
  }
3310
3527
  return { sessionId, session, cols, rows };
3311
3528
  }
3529
+ // ── pty-host adoption (shell-mode persistence across server restarts) ──
3530
+ /**
3531
+ * Adopt every shell session that survived in the pty-host daemon. Rebuilds
3532
+ * the TerminalSession around a PtyHostClient, feeds the host's replay ring
3533
+ * through the output pipeline (history + OSC/agent state, no broadcast), and
3534
+ * re-links — or creates — the persisted client record so the session shows
3535
+ * up as a tab again.
3536
+ */
3537
+ async function adoptPtyHostSessions() {
3538
+ if (!ptyHostShellModeEnabled())
3539
+ return;
3540
+ const adopted = await getPtyHostManager().adoptChannels();
3541
+ if (adopted.length === 0)
3542
+ return;
3543
+ let createdRecords = 0;
3544
+ for (const { meta, client } of adopted) {
3545
+ if (terminalSessions.has(meta.id))
3546
+ continue;
3547
+ const session = {
3548
+ ptyProcess: client,
3549
+ ptyBackend: 'pty-host',
3550
+ cwd: meta.cwd,
3551
+ mode: 'shell',
3552
+ tmuxSessionName: null,
3553
+ cols: meta.cols,
3554
+ rows: meta.rows,
3555
+ lastActivity: Date.now(),
3556
+ lastOutputAt: Date.now(),
3557
+ clients: new Map(),
3558
+ createdAt: meta.startedAt,
3559
+ hasWrittenData: true,
3560
+ activeProgram: null,
3561
+ agent: null,
3562
+ agentSession: null,
3563
+ gitStatus: null,
3564
+ gitStatusKey: null,
3565
+ gitAgentActivitySeen: 0,
3566
+ oscSniffBuf: '',
3567
+ lastOscCwd: null,
3568
+ lastOscTitle: null,
3569
+ lastPromptState: null,
3570
+ tuiProgress: null,
3571
+ focusTrackingRequested: false,
3572
+ focusModeSniffBuf: '',
3573
+ focusAggregation: {
3574
+ focusedClients: new Map(),
3575
+ effectiveFocused: false,
3576
+ },
3577
+ flowPausedClients: new Set(),
3578
+ flowPausedClientTimers: new Map(),
3579
+ ptyPausedForFlowControl: false,
3580
+ };
3581
+ terminalSessions.set(meta.id, session);
3582
+ // The host's replay ring rebuilds this server's history + sniffed state
3583
+ // (cwd / prompt-state / agent sessions) without re-broadcasting.
3584
+ client.onReplay((data) => {
3585
+ processSessionOutput(meta.id, session, data, { broadcast: false });
3586
+ });
3587
+ setupPtyHandlers(meta.id, session);
3588
+ refreshGitStatus(meta.id, session, { minIntervalMs: 0 });
3589
+ // Re-link or create the persisted record so the session appears as a tab.
3590
+ const existing = globalSessionState.sessions.find((s) => s.backendSessionId === meta.id);
3591
+ if (!existing) {
3592
+ const now = Date.now();
3593
+ upsertGlobalSessionRecord({
3594
+ sessionId: randomUUID(),
3595
+ name: `terminal-${now.toString(36)}`,
3596
+ backendSessionId: meta.id,
3597
+ mode: 'shell',
3598
+ tmuxSessionName: null,
3599
+ createdAt: meta.startedAt,
3600
+ lastActivity: now,
3601
+ cwd: meta.cwd,
3602
+ });
3603
+ createdRecords++;
3604
+ }
3605
+ }
3606
+ console.log(`[pty-host] adopted ${adopted.length} surviving shell session(s)${createdRecords > 0 ? `, created ${createdRecords} record(s)` : ''}`);
3607
+ if (createdRecords > 0) {
3608
+ persistAndBroadcastGlobalState();
3609
+ }
3610
+ }
3312
3611
  function detectShellType(shellPath) {
3313
3612
  const base = shellPath.split('/').pop()?.toLowerCase() || '';
3314
3613
  if (base.includes('bash'))
@@ -3855,69 +4154,31 @@ router.put('/settings', async (req, res) => {
3855
4154
  }
3856
4155
  res.json(await getSettingsPayload());
3857
4156
  });
3858
- // ── Agent detection rules API ──
3859
- router.get('/agent-rules', async (_req, res) => {
3860
- res.json(await loadAgentRulesFromDisk());
4157
+ // ── Agent hooks API (rich status channel installers) ──
4158
+ router.get('/agent-hooks', (_req, res) => {
4159
+ res.json({ agents: listHookAgents() });
3861
4160
  });
3862
- router.put('/agent-rules', async (req, res) => {
3863
- const rules = req.body;
3864
- if (!Array.isArray(rules)) {
3865
- res.status(400).json({ error: 'Expected array of program configs' });
3866
- return;
4161
+ router.post('/agent-hooks/:agent/install', async (req, res) => {
4162
+ const agent = req.params.agent;
4163
+ try {
4164
+ const result = await installHooks(agent);
4165
+ res.json({ agent, ...result, state: 'installed' });
3867
4166
  }
3868
- // Basic validation
3869
- for (const config of rules) {
3870
- const hasProgram = typeof config.program === 'string' && config.program.trim().length > 0;
3871
- const hasPrograms = Array.isArray(config.programs) && config.programs.some((name) => typeof name === 'string' && name.trim().length > 0);
3872
- if ((!hasProgram && !hasPrograms) || !Array.isArray(config.rules)) {
3873
- res.status(400).json({ error: 'Each config must have program/programs and rules' });
3874
- return;
3875
- }
3876
- for (const rule of config.rules) {
3877
- if (!rule.pattern || !rule.status) {
3878
- res.status(400).json({ error: 'Each rule must have pattern and status' });
3879
- return;
3880
- }
3881
- // Validate regex
3882
- try {
3883
- new RegExp(rule.pattern, 'i');
3884
- }
3885
- catch {
3886
- res.status(400).json({ error: `Invalid regex: ${rule.pattern}` });
3887
- return;
3888
- }
3889
- if (rule.indicator !== undefined && !AGENT_INDICATORS.has(rule.indicator)) {
3890
- res.status(400).json({ error: `Invalid indicator: ${rule.indicator}` });
3891
- return;
3892
- }
3893
- if (rule.clearDelayMs !== undefined) {
3894
- const delay = Number(rule.clearDelayMs);
3895
- if (!Number.isFinite(delay) || delay < MIN_AGENT_CLEAR_DELAY_MS || delay > MAX_AGENT_CLEAR_DELAY_MS) {
3896
- res.status(400).json({ error: `clearDelayMs must be ${MIN_AGENT_CLEAR_DELAY_MS}-${MAX_AGENT_CLEAR_DELAY_MS}` });
3897
- return;
3898
- }
3899
- }
3900
- }
4167
+ catch (error) {
4168
+ res.status(400).json({ error: error.message });
3901
4169
  }
3902
- await saveAgentRulesToDisk(rules);
3903
- broadcastControlEvent({
3904
- type: 'config-updated',
3905
- key: 'agent-rules',
3906
- updatedAt: Date.now(),
3907
- });
3908
- res.json(rules);
3909
4170
  });
3910
- router.delete('/agent-rules', async (_req, res) => {
3911
- // Remove custom rules file so builtins take effect again
3912
- await fs.promises.unlink(AGENT_RULES_FILE).catch(() => undefined);
3913
- compileAgentRules(BUILTIN_AGENT_RULES);
3914
- broadcastControlEvent({
3915
- type: 'config-updated',
3916
- key: 'agent-rules',
3917
- updatedAt: Date.now(),
3918
- });
3919
- res.json(BUILTIN_AGENT_RULES);
4171
+ router.post('/agent-hooks/:agent/uninstall', async (req, res) => {
4172
+ const agent = req.params.agent;
4173
+ try {
4174
+ const summary = await uninstallHooks(agent);
4175
+ res.json({ agent, summary, state: 'not-installed' });
4176
+ }
4177
+ catch (error) {
4178
+ res.status(400).json({ error: error.message });
4179
+ }
3920
4180
  });
4181
+ // ── end Agent hooks API ──
3921
4182
  // ── Program detection config API ──
3922
4183
  router.get('/program-detection', async (_req, res) => {
3923
4184
  res.json(await loadProgramDetectionFromDisk());
@@ -4056,14 +4317,23 @@ router.get('/:sessionId/stream', async (req, res) => {
4056
4317
  mode: session.mode,
4057
4318
  tmuxSessionName: session.tmuxSessionName,
4058
4319
  cwd: session.cwd ?? null,
4059
- activeProgram: session.activeProgram?.command ?? null,
4320
+ activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
4060
4321
  activeProgramRaw: session.activeProgram?.rawArgs ?? null,
4061
4322
  activeProgramSource: session.activeProgram?.source ?? null,
4062
- agentStatus: session.agentStatus,
4063
- agentColor: session.agentColor,
4064
- agentIndicator: session.agentIndicator,
4065
4323
  tuiProgress: session.tuiProgress,
4324
+ // 标题/prompt 状态只在「变化」时广播,且去重基准在 server 端——刷新后的
4325
+ // 新客户端不会收到重发。连接即推当前值,否则 tab 名塌回默认直到下次变化。
4326
+ shellTitle: session.lastOscTitle,
4327
+ promptState: session.lastPromptState,
4066
4328
  });
4329
+ // Rich agent state rides its own message so the connected payload stays
4330
+ // backward compatible; force past the dedupe snapshot.
4331
+ if (session.agent || session.agentSession) {
4332
+ writeSse(res, buildAgentStatusPayload(sessionId, session));
4333
+ }
4334
+ if (session.gitStatus) {
4335
+ writeSse(res, { type: 'git-status', gitStatus: session.gitStatus });
4336
+ }
4067
4337
  let tmuxInterval = null;
4068
4338
  let activeProgramInterval = null;
4069
4339
  let lastTmuxLayoutSnapshot = '';
@@ -4077,8 +4347,9 @@ router.get('/:sessionId/stream', async (req, res) => {
4077
4347
  }
4078
4348
  lastActiveProgramSnapshot = snapshot;
4079
4349
  session.activeProgram = activeProgram;
4350
+ persistActiveProgramBinding(sessionId, activeProgram?.command);
4080
4351
  // Agent status: react to AI tool start/exit
4081
- evaluateAgentStatus(sessionId, session);
4352
+ syncAgentIdentity(sessionId, session);
4082
4353
  console.log(`[active-program][shell-sse] session=${sessionId} client=${clientId} cmd=${activeProgram?.command ?? null} source=${activeProgram?.source ?? null}`);
4083
4354
  writeSse(res, {
4084
4355
  type: 'active-program',
@@ -4120,6 +4391,7 @@ router.get('/:sessionId/stream', async (req, res) => {
4120
4391
  schedulePersistGlobalState();
4121
4392
  }
4122
4393
  writeSse(res, { type: 'cwd', cwd: newCwd });
4394
+ refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
4123
4395
  }
4124
4396
  // tmux 消费了 inner shell 发的 OSC 2/133,不透传到外层 PTY。
4125
4397
  // 从 tmux layout 提取 active pane 的 title 和 command 来推导。
@@ -4132,8 +4404,12 @@ router.get('/:sessionId/stream', async (req, res) => {
4132
4404
  const paneCmd = activePane.command || '';
4133
4405
  const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
4134
4406
  if (inferredState !== session.lastPromptState) {
4407
+ const wasRunning = session.lastPromptState === 'running';
4135
4408
  session.lastPromptState = inferredState;
4136
4409
  writeSse(res, { type: 'prompt-state', state: inferredState });
4410
+ if (wasRunning && inferredState === 'idle') {
4411
+ refreshGitStatus(sessionId, session);
4412
+ }
4137
4413
  }
4138
4414
  }
4139
4415
  // Mirror dynamic metadata onto tmux user options (cheap when nothing
@@ -4280,6 +4556,48 @@ router.post('/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
4280
4556
  res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
4281
4557
  }
4282
4558
  });
4559
+ /**
4560
+ * Resume the pane's last agent conversation: rebuild the agent's native
4561
+ * resume command (carrying the original launch flags) and deliver it via
4562
+ * bracketed paste. Refused while an agent turn is in flight — pasting a
4563
+ * resume into a busy TUI would corrupt its input.
4564
+ */
4565
+ router.post('/:sessionId/agent-resume', (req, res) => {
4566
+ const { sessionId } = req.params;
4567
+ const session = terminalSessions.get(sessionId);
4568
+ if (!session) {
4569
+ return res.status(404).json({ error: 'Terminal session not found' });
4570
+ }
4571
+ const live = session.agentSession;
4572
+ if (session.agent && live && (live.status === 'working' || live.status === 'waiting')) {
4573
+ return res.status(409).json({ error: 'agent is busy; wait for the turn to finish' });
4574
+ }
4575
+ const command = resolveAgentResumeCommand(sessionId, session);
4576
+ if (!command) {
4577
+ return res.status(404).json({ error: 'no resumable agent session for this pane' });
4578
+ }
4579
+ try {
4580
+ session.ptyProcess.write(buildBracketedSubmitBytes(command));
4581
+ session.lastActivity = Date.now();
4582
+ res.json({ success: true, command });
4583
+ }
4584
+ catch (error) {
4585
+ const errorMessage = error instanceof Error ? error.message : String(error);
4586
+ res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
4587
+ }
4588
+ });
4589
+ /** Whether this pane has a resumable agent conversation (for the UI affordance). */
4590
+ router.get('/:sessionId/agent-resume', (req, res) => {
4591
+ const { sessionId } = req.params;
4592
+ const session = terminalSessions.get(sessionId);
4593
+ if (!session) {
4594
+ return res.status(404).json({ error: 'Terminal session not found' });
4595
+ }
4596
+ const command = resolveAgentResumeCommand(sessionId, session);
4597
+ const busy = !!(session.agent && session.agentSession
4598
+ && (session.agentSession.status === 'working' || session.agentSession.status === 'waiting'));
4599
+ res.json({ available: command !== null, command, busy });
4600
+ });
4283
4601
  router.post('/:sessionId/resize', (req, res) => {
4284
4602
  const { sessionId } = req.params;
4285
4603
  const session = terminalSessions.get(sessionId);
@@ -4669,13 +4987,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4669
4987
  mode: session.mode,
4670
4988
  tmuxSessionName: session.tmuxSessionName,
4671
4989
  cwd: session.cwd ?? null,
4672
- activeProgram: session.activeProgram?.command ?? null,
4990
+ activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
4673
4991
  activeProgramRaw: session.activeProgram?.rawArgs ?? null,
4674
4992
  activeProgramSource: session.activeProgram?.source ?? null,
4675
- agentStatus: session.agentStatus,
4676
- agentColor: session.agentColor,
4677
- agentIndicator: session.agentIndicator,
4678
4993
  tuiProgress: session.tuiProgress,
4994
+ shellTitle: session.lastOscTitle,
4995
+ promptState: session.lastPromptState,
4679
4996
  focusTrackingRequested: session.focusTrackingRequested,
4680
4997
  // 短线重连补帧:
4681
4998
  // replayChunks 为补发数据;replayLastSeq 是客户端应记录的新基线;
@@ -4684,6 +5001,13 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4684
5001
  replayLastSeq,
4685
5002
  replayOutOfWindow,
4686
5003
  }));
5004
+ // Rich agent state rides its own message (see the SSE path above).
5005
+ if (session.agent || session.agentSession) {
5006
+ ws.send(JSON.stringify(buildAgentStatusPayload(sessionId, session)));
5007
+ }
5008
+ if (session.gitStatus) {
5009
+ ws.send(JSON.stringify({ type: 'git-status', gitStatus: session.gitStatus }));
5010
+ }
4687
5011
  })();
4688
5012
  // Tmux layout polling (per-client, like the SSE stream does)
4689
5013
  let tmuxInterval = null;
@@ -4717,8 +5041,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4717
5041
  if (apSnapshot !== lastActiveProgramSnapshot) {
4718
5042
  lastActiveProgramSnapshot = apSnapshot;
4719
5043
  session.activeProgram = ap;
5044
+ persistActiveProgramBinding(sessionId, ap?.command);
4720
5045
  // Agent status: react to AI tool start/exit
4721
- evaluateAgentStatus(sessionId, session);
5046
+ syncAgentIdentity(sessionId, session);
4722
5047
  console.log(`[active-program][ws] session=${sessionId} cmd=${ap?.command ?? null} source=${ap?.source ?? null}`);
4723
5048
  ws.send(JSON.stringify({
4724
5049
  type: 'active-program',
@@ -4736,6 +5061,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4736
5061
  schedulePersistGlobalState();
4737
5062
  }
4738
5063
  ws.send(JSON.stringify({ type: 'cwd', cwd: newCwd }));
5064
+ refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
4739
5065
  }
4740
5066
  // tmux 消费了 inner shell 发的 OSC 2(存入 pane_title)和 OSC 133,
4741
5067
  // 不透传到外层 PTY。因此从 tmux layout 提取 active pane 的 title
@@ -4750,8 +5076,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4750
5076
  const paneCmd = activePane.command || '';
4751
5077
  const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
4752
5078
  if (inferredState !== session.lastPromptState) {
5079
+ const wasRunning = session.lastPromptState === 'running';
4753
5080
  session.lastPromptState = inferredState;
4754
5081
  ws.send(JSON.stringify({ type: 'prompt-state', state: inferredState }));
5082
+ if (wasRunning && inferredState === 'idle') {
5083
+ refreshGitStatus(sessionId, session);
5084
+ }
4755
5085
  }
4756
5086
  }
4757
5087
  // Mirror dynamic metadata onto tmux user options.
@@ -4787,8 +5117,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4787
5117
  if (snapshot !== lastApSnapshot) {
4788
5118
  lastApSnapshot = snapshot;
4789
5119
  session.activeProgram = ap;
5120
+ persistActiveProgramBinding(sessionId, ap?.command);
4790
5121
  // Agent status: react to AI tool start/exit
4791
- evaluateAgentStatus(sessionId, session);
5122
+ syncAgentIdentity(sessionId, session);
4792
5123
  ws.send(JSON.stringify({
4793
5124
  type: 'active-program',
4794
5125
  activeProgram: ap?.command ?? null,