termdock 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-VDfhcxCs.js → abnfDiagram-VRR7QNED-C7YJqmLC.js} +1 -1
- package/dist/client/assets/{arc-CK3Ni_GA.js → arc-B07DueP1.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-DRKN8EGo.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-BwR-JbpT.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DyRja9hI.js} +1 -1
- package/dist/client/assets/channel-DMZ6Nll6.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Cl0vEDWK.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D-cTwhqE.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-D0eRXduK.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-J_Nw8oeX.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-D73gFLxs.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-DZtboEtR.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-D7BwVcOs.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-B63BJcBW.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-C502LPuE.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.js → chunk-XXDRQBXY-C9puam3W.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C7oNngQj.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C7oNngQj.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-De1yyf-M.js → cose-bilkent-JH36ORCC-CZ24meVw.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-Y2fr06hq.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-OF_nmz5v.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-svS4Qj1B.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-Bim50nBZ.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-BDGzIPU6.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-DP8vmwhI.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CVwGpEzy.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-nS0Aj3Pp.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-BogvM--v.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-pBjz5SjG.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BINUCOud.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DITnKqUR.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.js → gitGraphDiagram-IHSO6WYX-C_C74_pU.js} +1 -1
- package/dist/client/assets/index-DgG9nafK.css +1 -0
- package/dist/client/assets/index-VDGjYWHR.js +408 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-CE_9WW2K.js → infoDiagram-FWYZ7A6U-D4WLtM02.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-tgP7oAl9.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-sK0S0EXp.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-BZVK0aeo.js} +1 -1
- package/dist/client/assets/{linear-SWu8Le4S.js → linear-CHubENPf.js} +1 -1
- package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-2dLzSZxi.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-C28O3edf.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DaTkErYV.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-CoUX2zjE.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-DdidrEKm.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-B4ua4pek.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-C7GkSVWe.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-D2qKgrSf.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-BKpN5Rg1.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-CdOyl0Mu.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.js → stateDiagram-2N3HPSRC-Pig7diHZ.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DaioXNnb.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-D5QZ4K6L.js → swimlanes-5IMT3BWC-PS6bSiiJ.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-B9vE1I0i.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DUhdUNzM.js → timeline-definition-FHXFAJF6-BjVebJ90.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-BtjZHsBe.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DmmuPtgr.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.js → xychartDiagram-FW5EYKEG-CPh2RG2Y.js} +1 -1
- package/dist/client/icons/agents/amp.svg +12 -0
- package/dist/client/icons/agents/claude.svg +10 -0
- package/dist/client/icons/agents/codex.svg +10 -0
- package/dist/client/icons/agents/copilot.svg +12 -0
- package/dist/client/icons/agents/cursor.svg +3 -0
- package/dist/client/icons/agents/droid.svg +10 -0
- package/dist/client/icons/agents/gemini.svg +5 -0
- package/dist/client/icons/agents/goose.svg +10 -0
- package/dist/client/icons/agents/grok.svg +5 -0
- package/dist/client/icons/agents/opencode.svg +3 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/installers.js +742 -0
- package/dist/server/agent/plugins.js +271 -0
- package/dist/server/agent/registry.js +306 -0
- package/dist/server/agent/session.js +162 -0
- package/dist/server/agentHook.js +174 -0
- package/dist/server/cli.js +122 -2
- package/dist/server/ptyHost.js +261 -0
- package/dist/server/ptyhost/manager.js +396 -0
- package/dist/server/ptyhost/protocol.js +80 -0
- package/dist/server/routes/filesystem.js +10 -8
- package/dist/server/routes/terminal.js +798 -390
- package/dist/server/utils/gitStatus.js +114 -0
- package/package.json +1 -1
- package/dist/client/assets/channel-B_qG4XEr.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
- package/dist/client/assets/index-BeJrvXOJ.js +0 -394
- package/dist/client/assets/index-iK4yVqAA.css +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
- 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,11 @@ 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 { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
|
|
24
|
+
import { loadPlugins, savePlugin, removePlugin, readPluginIcon, } from '../agent/plugins.js';
|
|
25
|
+
import { registerPluginAgents } from '../agent/registry.js';
|
|
19
26
|
const router = express.Router();
|
|
20
27
|
const execFileAsync = promisify(execFile);
|
|
21
28
|
const TERMDOCK_DIR = `${os.homedir()}/.termdock`;
|
|
@@ -459,6 +466,12 @@ process.on('SIGINT', () => { stopActivityReorderPeriodic(); flushPersistAndExit(
|
|
|
459
466
|
void (async () => {
|
|
460
467
|
await loadGlobalSessionStateFromDisk();
|
|
461
468
|
await watchGlobalSessionStateFile();
|
|
469
|
+
// pty-host adoption runs after the state load (its record upserts must not
|
|
470
|
+
// be clobbered) and before pruning (surviving shell records' backends are
|
|
471
|
+
// live in the host, so they're not orphans).
|
|
472
|
+
await adoptPtyHostSessions().catch((error) => {
|
|
473
|
+
console.warn('[pty-host] adoption failed:', error.message);
|
|
474
|
+
});
|
|
462
475
|
pruneOrphanSessions();
|
|
463
476
|
await backfillPersistedTmuxMetadata();
|
|
464
477
|
startActivityReorderPeriodic();
|
|
@@ -525,15 +538,40 @@ async function backfillPersistedTmuxMetadata() {
|
|
|
525
538
|
'@termdock-version': TERMDOCK_VERSION,
|
|
526
539
|
'@termdock-host': TERMDOCK_HOST,
|
|
527
540
|
'@termdock-pid': TERMDOCK_PID,
|
|
541
|
+
// Agent-hook plumbing (see ensureManagedTmuxSessionReady): backfilled
|
|
542
|
+
// at startup so sessions created before this feature get it too.
|
|
543
|
+
'allow-passthrough': 'on',
|
|
528
544
|
};
|
|
529
545
|
const existingCreatedAt = await getTmuxOption(s.tmuxSessionName, '@termdock-created-at');
|
|
530
546
|
if (!existingCreatedAt) {
|
|
531
547
|
baseOptions['@termdock-created-at'] = String(Date.now());
|
|
532
548
|
}
|
|
533
549
|
await setTmuxOptions(s.tmuxSessionName, baseOptions);
|
|
550
|
+
try {
|
|
551
|
+
await runTmux(['set-environment', '-t', s.tmuxSessionName, 'TERMDOCK', '1']);
|
|
552
|
+
}
|
|
553
|
+
catch { /* best effort */ }
|
|
534
554
|
if (s.customName === true && typeof s.name === 'string' && s.name.trim().length > 0) {
|
|
535
555
|
await setTmuxOption(s.tmuxSessionName, '@termdock-friendly-name', s.name);
|
|
536
556
|
}
|
|
557
|
+
// label/program/cwd 是动态值,正常由在线轮询维护;这里只在缺失时补——
|
|
558
|
+
// 用持久化记录里的 last-known 计算,保证 server 重启后、首个客户端连上前,
|
|
559
|
+
// CLI(td --tls / tmux show)已经能看到名字。已有值不覆盖(可能更新鲜)。
|
|
560
|
+
const existingLabel = await getTmuxOption(s.tmuxSessionName, '@termdock-label');
|
|
561
|
+
if (!existingLabel) {
|
|
562
|
+
const program = normalizeMetadataProgram(s.activeProgram ?? null);
|
|
563
|
+
const label = buildTermdockLabel({
|
|
564
|
+
friendlyName: s.customName === true && s.name.trim().length > 0 ? s.name : null,
|
|
565
|
+
program,
|
|
566
|
+
cwd: s.cwd ?? null,
|
|
567
|
+
sessionName: s.tmuxSessionName,
|
|
568
|
+
});
|
|
569
|
+
await setTmuxOptions(s.tmuxSessionName, {
|
|
570
|
+
'@termdock-label': label,
|
|
571
|
+
'@termdock-program': program ?? '',
|
|
572
|
+
'@termdock-cwd': s.cwd ?? '',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
537
575
|
}
|
|
538
576
|
catch (error) {
|
|
539
577
|
console.warn(`[tmux] failed to backfill metadata on ${s.tmuxSessionName}: ${getErrorMessage(error)}`);
|
|
@@ -694,6 +732,9 @@ function normalizePersistedClientSession(input) {
|
|
|
694
732
|
cwd: typeof candidate.cwd === 'string' && candidate.cwd.trim().length > 0
|
|
695
733
|
? candidate.cwd
|
|
696
734
|
: null,
|
|
735
|
+
activeProgram: typeof candidate.activeProgram === 'string' && candidate.activeProgram.trim().length > 0
|
|
736
|
+
? candidate.activeProgram
|
|
737
|
+
: null,
|
|
697
738
|
};
|
|
698
739
|
}
|
|
699
740
|
function generateTmuxSessionName() {
|
|
@@ -759,7 +800,7 @@ function makeTerminalSessionPayload(backendSessionId, session, cols = 80, rows =
|
|
|
759
800
|
rows,
|
|
760
801
|
mode: session.mode,
|
|
761
802
|
tmuxSessionName: session.tmuxSessionName,
|
|
762
|
-
activeProgram: session.activeProgram?.command ??
|
|
803
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(backendSessionId),
|
|
763
804
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
764
805
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
765
806
|
cwd: session.cwd ?? null,
|
|
@@ -984,6 +1025,24 @@ function updateGlobalBindingForBackendSession(backendSessionId, patch) {
|
|
|
984
1025
|
upsertGlobalSessionRecord(updated);
|
|
985
1026
|
return true;
|
|
986
1027
|
}
|
|
1028
|
+
// 把检测到的前台程序名写进持久化记录(last-known 语义):
|
|
1029
|
+
// - 只在非空且与已存值不同才写,null 不覆盖(检测瞬时空窗不该抹掉上次结果);
|
|
1030
|
+
// - 变化频率低(程序切换才触发),debounce 落盘即可。
|
|
1031
|
+
function persistActiveProgramBinding(backendSessionId, command) {
|
|
1032
|
+
if (!command)
|
|
1033
|
+
return;
|
|
1034
|
+
const record = globalSessionState.sessions.find((session) => session.backendSessionId === backendSessionId);
|
|
1035
|
+
if (!record || record.activeProgram === command)
|
|
1036
|
+
return;
|
|
1037
|
+
upsertGlobalSessionRecord({ ...record, activeProgram: command });
|
|
1038
|
+
schedulePersistGlobalState();
|
|
1039
|
+
}
|
|
1040
|
+
// connected / open 响应用的兜底:live 检测还没出结果时,回退到磁盘上的 last-known,
|
|
1041
|
+
// 前端首帧就能显示上次的程序名而不是裸 session 名。
|
|
1042
|
+
function getPersistedActiveProgramForBackend(backendSessionId) {
|
|
1043
|
+
const record = globalSessionState.sessions.find((session) => session.backendSessionId === backendSessionId);
|
|
1044
|
+
return record?.activeProgram ?? null;
|
|
1045
|
+
}
|
|
987
1046
|
async function openInventorySession(req, input) {
|
|
988
1047
|
const normalizedMode = normalizeMode(input.mode);
|
|
989
1048
|
const normalizedTmuxName = normalizedMode === 'tmux'
|
|
@@ -1603,29 +1662,38 @@ async function buildSessionInventory() {
|
|
|
1603
1662
|
schedulePersistGlobalState();
|
|
1604
1663
|
}
|
|
1605
1664
|
const liveTmuxByName = new Map(refreshedTmuxSessions.map((session) => [session.name, session]));
|
|
1606
|
-
let
|
|
1665
|
+
let synchronizedPersistedFields = false;
|
|
1607
1666
|
globalSessionState = {
|
|
1608
1667
|
sessions: globalSessionState.sessions.map((session) => {
|
|
1609
|
-
|
|
1610
|
-
|
|
1668
|
+
let next = session;
|
|
1669
|
+
// CLI 侧改了 @termdock-friendly-name → 反向同步进持久化记录(tmux 限定)。
|
|
1670
|
+
if (next.mode === 'tmux' && next.tmuxSessionName) {
|
|
1671
|
+
const friendlyName = liveTmuxByName.get(next.tmuxSessionName)?.friendlyName?.trim();
|
|
1672
|
+
if (friendlyName && !(next.customName === true && next.name === friendlyName)) {
|
|
1673
|
+
next = { ...next, name: friendlyName, customName: true };
|
|
1674
|
+
synchronizedPersistedFields = true;
|
|
1675
|
+
}
|
|
1611
1676
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1677
|
+
// 展示名数据(activeProgram / cwd)回写:last-known 持久化,server 重启 /
|
|
1678
|
+
// backend 掉线后 inventory hint 仍能给出上次的程序名与目录。
|
|
1679
|
+
// live 值只以非空覆盖——检测空窗(null)不抹掉已存结果。
|
|
1680
|
+
const backend = next.backendSessionId ? terminalSessions.get(next.backendSessionId) : undefined;
|
|
1681
|
+
const tmuxMeta = next.tmuxSessionName ? liveTmuxByName.get(next.tmuxSessionName) : undefined;
|
|
1682
|
+
const liveProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? null;
|
|
1683
|
+
const liveCwd = backend?.cwd ?? tmuxMeta?.cwd ?? null;
|
|
1684
|
+
if (liveProgram && liveProgram !== (next.activeProgram ?? null)) {
|
|
1685
|
+
next = { ...next, activeProgram: liveProgram };
|
|
1686
|
+
synchronizedPersistedFields = true;
|
|
1615
1687
|
}
|
|
1616
|
-
if (
|
|
1617
|
-
|
|
1688
|
+
if (liveCwd && liveCwd !== (next.cwd ?? null)) {
|
|
1689
|
+
next = { ...next, cwd: liveCwd };
|
|
1690
|
+
synchronizedPersistedFields = true;
|
|
1618
1691
|
}
|
|
1619
|
-
|
|
1620
|
-
return {
|
|
1621
|
-
...session,
|
|
1622
|
-
name: friendlyName,
|
|
1623
|
-
customName: true,
|
|
1624
|
-
};
|
|
1692
|
+
return next;
|
|
1625
1693
|
}),
|
|
1626
|
-
updatedAt:
|
|
1694
|
+
updatedAt: synchronizedPersistedFields ? Date.now() : globalSessionState.updatedAt,
|
|
1627
1695
|
};
|
|
1628
|
-
if (
|
|
1696
|
+
if (synchronizedPersistedFields) {
|
|
1629
1697
|
schedulePersistGlobalState();
|
|
1630
1698
|
}
|
|
1631
1699
|
const clientSessions = globalSessionState.sessions.map((session) => {
|
|
@@ -1634,11 +1702,12 @@ async function buildSessionInventory() {
|
|
|
1634
1702
|
const live = session.mode === 'tmux' ? tmuxLive : backendLive;
|
|
1635
1703
|
// 展示名提示(activeProgram / cwd):优先取在线 backend session 的实时值,
|
|
1636
1704
|
// 其次回退到 tmux 清单里的 program/cwd(tmux 模式即使 backend 未 attach,
|
|
1637
|
-
// tmux 服务端仍能给出当前 pane
|
|
1705
|
+
// tmux 服务端仍能给出当前 pane 的程序与目录),最后回退到持久化记录里的
|
|
1706
|
+
// last-known 值(server 重启 / tmux 已死也能显示上次的标题)。让前端 hydrate 即可显示。
|
|
1638
1707
|
const backend = backendLive ? terminalSessions.get(session.backendSessionId) : undefined;
|
|
1639
1708
|
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;
|
|
1709
|
+
const activeProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? session.activeProgram ?? null;
|
|
1710
|
+
const cwd = backend?.cwd ?? tmuxMeta?.cwd ?? session.cwd ?? null;
|
|
1642
1711
|
return {
|
|
1643
1712
|
...session,
|
|
1644
1713
|
frontendSessionId: session.sessionId,
|
|
@@ -1875,6 +1944,8 @@ function sniffOsc(buf, home) {
|
|
|
1875
1944
|
let promptState = null;
|
|
1876
1945
|
let exitCode = null;
|
|
1877
1946
|
let tuiProgress = null;
|
|
1947
|
+
const agentEvents = [];
|
|
1948
|
+
let notification = null;
|
|
1878
1949
|
let lastMatchEnd = 0;
|
|
1879
1950
|
OSC_ANY_PATTERN.lastIndex = 0;
|
|
1880
1951
|
while ((match = OSC_ANY_PATTERN.exec(buf)) !== null) {
|
|
@@ -1915,253 +1986,330 @@ function sniffOsc(buf, home) {
|
|
|
1915
1986
|
}
|
|
1916
1987
|
else if (oscNum === '9') {
|
|
1917
1988
|
const progress = parseConEmuProgressReport(oscData);
|
|
1918
|
-
if (progress)
|
|
1989
|
+
if (progress) {
|
|
1919
1990
|
tuiProgress = progress;
|
|
1991
|
+
}
|
|
1992
|
+
else if (oscData.trim().length > 0) {
|
|
1993
|
+
// Plain OSC 9 notification (iTerm2-style): an agent pinging the user.
|
|
1994
|
+
notification = oscData;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
else if (oscNum === '777') {
|
|
1998
|
+
// OSC 777 notification: `notify;<title>;<body>`. termdock's agent hooks
|
|
1999
|
+
// use the sentinel title carrying a JSON event; anything else is an
|
|
2000
|
+
// opaque notification from an agent without hooks installed.
|
|
2001
|
+
const event = parseAgentEvent(`777;${oscData}`);
|
|
2002
|
+
if (event) {
|
|
2003
|
+
agentEvents.push(event);
|
|
2004
|
+
}
|
|
2005
|
+
else {
|
|
2006
|
+
const body = parseOsc777NotificationBody(oscData);
|
|
2007
|
+
if (body)
|
|
2008
|
+
notification = body;
|
|
2009
|
+
}
|
|
1920
2010
|
}
|
|
1921
2011
|
}
|
|
1922
2012
|
const remaining = buf.slice(lastMatchEnd).slice(-128);
|
|
1923
|
-
return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, remaining };
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
],
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
}
|
|
2013
|
+
return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, agentEvents, notification, remaining };
|
|
2014
|
+
}
|
|
2015
|
+
/** The body of a `notify;<title>;<body>` OSC 777 payload, or null. */
|
|
2016
|
+
function parseOsc777NotificationBody(data) {
|
|
2017
|
+
if (!data.startsWith('notify;'))
|
|
2018
|
+
return null;
|
|
2019
|
+
const rest = data.slice('notify;'.length);
|
|
2020
|
+
const sep = rest.indexOf(';');
|
|
2021
|
+
const body = sep >= 0 ? rest.slice(sep + 1) : rest;
|
|
2022
|
+
return body.trim().length > 0 ? body : null;
|
|
2023
|
+
}
|
|
2024
|
+
// ── Rich agent session tracking (identity registry + OSC 777 hook events) ──
|
|
2025
|
+
//
|
|
2026
|
+
// Two tiers, ported from tty7:
|
|
2027
|
+
// 1. Identity: the activeProgram poll's rawArgs matched against the agent
|
|
2028
|
+
// registry answers "*which* agent runs here" (brand, accent, icon, resume).
|
|
2029
|
+
// 2. Rich status: agent-side hooks emit OSC 777 sentinel events that the
|
|
2030
|
+
// sniffer collects; they drive a per-pane state machine
|
|
2031
|
+
// (idle/working/waiting/done) pushed to clients as `agent-status`.
|
|
2032
|
+
// A plain OSC 9/777 notification is the no-hooks fallback: it only means
|
|
2033
|
+
// "the agent pinged you" and never overrides rich state.
|
|
2034
|
+
/** User-defined wrapper→agent rules from the program-detection config. */
|
|
2035
|
+
function agentCustomCommands() {
|
|
2036
|
+
return agentCommandsMap;
|
|
2037
|
+
}
|
|
2038
|
+
/** Naive whitespace split of a captured command line into argv tokens. */
|
|
2039
|
+
function splitCommandToArgv(command) {
|
|
2040
|
+
return command
|
|
2041
|
+
.split(/\s+/)
|
|
2042
|
+
.map((t) => t.replace(/^["']+|["']+$/g, ''))
|
|
2043
|
+
.filter((t) => t.length > 0);
|
|
2044
|
+
}
|
|
2045
|
+
/** The coding agent running in this session's foreground, per activeProgram. */
|
|
2046
|
+
function detectSessionAgent(session) {
|
|
2047
|
+
const ap = session.activeProgram;
|
|
2048
|
+
if (!ap)
|
|
2049
|
+
return null;
|
|
2050
|
+
const custom = agentCustomCommands();
|
|
2051
|
+
if (ap.rawArgs) {
|
|
2052
|
+
const hit = detectAgentFromCommand(ap.rawArgs, custom);
|
|
2053
|
+
if (hit)
|
|
2054
|
+
return hit;
|
|
2055
|
+
}
|
|
2056
|
+
if (ap.command) {
|
|
2057
|
+
return detectAgentFromCommand(ap.command, custom);
|
|
2049
2058
|
}
|
|
2050
|
-
|
|
2051
|
-
agentRulesVersion++;
|
|
2052
|
-
return map;
|
|
2059
|
+
return null;
|
|
2053
2060
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2061
|
+
/** Status → indicator glyph. Colors stay client-side (CSS theme vars) so
|
|
2062
|
+
* dark/light themes render correctly; the wire carries semantics only. */
|
|
2063
|
+
const AGENT_STATUS_INDICATOR = {
|
|
2064
|
+
idle: null,
|
|
2065
|
+
working: 'spinner',
|
|
2066
|
+
waiting: 'question',
|
|
2067
|
+
done: 'badge',
|
|
2068
|
+
};
|
|
2069
|
+
/** The pane sits at a shell prompt (no foreground program, or a shell). Only
|
|
2070
|
+
* then does an agent-resume offer make sense — pasting a resume command into
|
|
2071
|
+
* vim/htop would corrupt its input. */
|
|
2072
|
+
function isPaneAtShellPrompt(session) {
|
|
2073
|
+
const command = session.activeProgram?.command;
|
|
2074
|
+
if (!command)
|
|
2075
|
+
return true;
|
|
2076
|
+
return shellNamesBackend.has(command.toLowerCase());
|
|
2077
|
+
}
|
|
2078
|
+
function buildAgentStatusPayload(sessionId, session) {
|
|
2079
|
+
const state = session.agentSession;
|
|
2080
|
+
const status = state ? state.status : null;
|
|
2081
|
+
const indicator = status ? AGENT_STATUS_INDICATOR[status] : null;
|
|
2082
|
+
let agent = session.agent;
|
|
2083
|
+
let nativeSessionId = state?.sessionId ?? null;
|
|
2084
|
+
// The agent just exited: keep offering its last conversation for resume
|
|
2085
|
+
// (brand + native id from the persisted last-known record) while the pane
|
|
2086
|
+
// sits at a shell prompt.
|
|
2087
|
+
if (!agent && !nativeSessionId && isPaneAtShellPrompt(session)) {
|
|
2088
|
+
const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
|
|
2089
|
+
if (record?.agentResume?.sessionId) {
|
|
2090
|
+
const persistedAgent = agentBySlug(record.agentResume.slug);
|
|
2091
|
+
if (persistedAgent) {
|
|
2092
|
+
agent = persistedAgent;
|
|
2093
|
+
nativeSessionId = record.agentResume.sessionId;
|
|
2062
2094
|
}
|
|
2063
|
-
return migrated.rules;
|
|
2064
2095
|
}
|
|
2065
2096
|
}
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2097
|
+
return {
|
|
2098
|
+
type: 'agent-status',
|
|
2099
|
+
agentStatus: status,
|
|
2100
|
+
agentIndicator: indicator ?? null,
|
|
2101
|
+
agent: agent
|
|
2102
|
+
? { slug: agent.slug, displayName: agent.displayName, accentColor: agent.accentColor, icon: agent.icon, isPlugin: agent.isPlugin ?? false }
|
|
2103
|
+
: null,
|
|
2104
|
+
agentMessage: state?.message ?? null,
|
|
2105
|
+
agentNativeSessionId: nativeSessionId,
|
|
2106
|
+
agentRich: state?.rich ?? false,
|
|
2107
|
+
agentActivity: state?.activity ?? 0,
|
|
2108
|
+
agentCwd: state?.agentCwd ?? null,
|
|
2109
|
+
};
|
|
2075
2110
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2111
|
+
let lastAgentStatusSnapshots = new Map();
|
|
2112
|
+
function broadcastAgentStatus(sessionId, session, force = false) {
|
|
2113
|
+
const payload = buildAgentStatusPayload(sessionId, session);
|
|
2114
|
+
const snapshot = JSON.stringify(payload);
|
|
2115
|
+
if (!force && lastAgentStatusSnapshots.get(sessionId) === snapshot)
|
|
2116
|
+
return;
|
|
2117
|
+
lastAgentStatusSnapshots.set(sessionId, snapshot);
|
|
2118
|
+
broadcastEvent(sessionId, payload);
|
|
2119
|
+
}
|
|
2120
|
+
/** Persist the info needed to resume this pane's agent conversation after the
|
|
2121
|
+
* agent exits / the server restarts (last-known semantics, debounced). */
|
|
2122
|
+
function persistAgentResumeBinding(backendSessionId, session) {
|
|
2123
|
+
const record = globalSessionState.sessions.find((s) => s.backendSessionId === backendSessionId);
|
|
2124
|
+
if (!record)
|
|
2125
|
+
return;
|
|
2126
|
+
const agent = session.agent;
|
|
2127
|
+
const sess = session.agentSession;
|
|
2128
|
+
if (!agent || !sess?.sessionId)
|
|
2129
|
+
return;
|
|
2130
|
+
const next = {
|
|
2131
|
+
slug: agent.slug,
|
|
2132
|
+
sessionId: sess.sessionId,
|
|
2133
|
+
launchArgv: sess.launchArgv,
|
|
2134
|
+
updatedAt: Date.now(),
|
|
2135
|
+
};
|
|
2136
|
+
if (JSON.stringify(record.agentResume ?? null) === JSON.stringify(next))
|
|
2137
|
+
return;
|
|
2138
|
+
upsertGlobalSessionRecord({ ...record, agentResume: next });
|
|
2139
|
+
schedulePersistGlobalState();
|
|
2082
2140
|
}
|
|
2083
2141
|
/**
|
|
2084
|
-
*
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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/布局变化影响
|
|
2142
|
+
* React to activeProgram changes: detect agent identity, end the rich session
|
|
2143
|
+
* when the agent leaves the foreground, and stamp the launch argv (for resume
|
|
2144
|
+
* flag replay) while the agent is present. Called from every activeProgram
|
|
2145
|
+
* assignment site.
|
|
2099
2146
|
*/
|
|
2100
|
-
function
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2147
|
+
function syncAgentIdentity(sessionId, session) {
|
|
2148
|
+
const detected = detectSessionAgent(session);
|
|
2149
|
+
if (detected !== session.agent) {
|
|
2150
|
+
// The agent leaving the foreground ends its session: clear the rich state
|
|
2151
|
+
// so a stale "waiting" dot can't outlive the process. The poll can blip
|
|
2152
|
+
// momentarily (an agent-spawned subcommand takes the foreground group),
|
|
2153
|
+
// but events re-establish state on the next signal.
|
|
2154
|
+
if (session.agent && session.agentSession) {
|
|
2155
|
+
persistAgentResumeBinding(sessionId, session);
|
|
2156
|
+
session.agentSession = null;
|
|
2157
|
+
}
|
|
2158
|
+
session.agent = detected;
|
|
2159
|
+
broadcastAgentStatus(sessionId, session);
|
|
2160
|
+
}
|
|
2161
|
+
// Stamp the launch argv the identity poll captured — resume gets the flags.
|
|
2162
|
+
if (detected && session.activeProgram?.rawArgs) {
|
|
2163
|
+
const argv = splitCommandToArgv(session.activeProgram.rawArgs);
|
|
2164
|
+
if (argv.length > 0) {
|
|
2165
|
+
const sess = session.agentSession;
|
|
2166
|
+
if (sess && sess.launchArgv === null) {
|
|
2167
|
+
sess.launchArgv = argv;
|
|
2168
|
+
persistAgentResumeBinding(sessionId, session);
|
|
2169
|
+
broadcastAgentStatus(sessionId, session);
|
|
2170
|
+
}
|
|
2171
|
+
else if (!sess) {
|
|
2172
|
+
// No session state yet (no hook events seen): remember the argv on a
|
|
2173
|
+
// pending field via the resume record so an exited agent can still be
|
|
2174
|
+
// resumed with its flags even if hooks never fired.
|
|
2175
|
+
const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
|
|
2176
|
+
if (record?.agentResume && record.agentResume.slug === detected.slug && !record.agentResume.launchArgv) {
|
|
2177
|
+
upsertGlobalSessionRecord({ ...record, agentResume: { ...record.agentResume, launchArgv: argv } });
|
|
2178
|
+
schedulePersistGlobalState();
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2111
2181
|
}
|
|
2112
2182
|
}
|
|
2113
|
-
return null;
|
|
2114
2183
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
-
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Fold the sniffer's agent signals into the pane's session state and push any
|
|
2186
|
+
* resulting change. Called from the PTY data path.
|
|
2187
|
+
*/
|
|
2188
|
+
function applyAgentSignals(sessionId, session, events, notification) {
|
|
2189
|
+
if (events.length === 0 && !notification)
|
|
2133
2190
|
return;
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2191
|
+
let identityChanged = false;
|
|
2192
|
+
for (const event of events) {
|
|
2193
|
+
// An event naming an agent brands the pane even when the process poll
|
|
2194
|
+
// can't see through a wrapper: identity via protocol.
|
|
2195
|
+
if (!session.agent && event.agent) {
|
|
2196
|
+
session.agent = event.agent;
|
|
2197
|
+
identityChanged = true;
|
|
2198
|
+
}
|
|
2199
|
+
const state = session.agentSession ?? defaultAgentSessionState();
|
|
2200
|
+
session.agentSession = state;
|
|
2201
|
+
applyAgentEvent(state, event);
|
|
2202
|
+
if (event.kind === 'session-start' || event.kind === 'session-end' || event.kind === 'stop') {
|
|
2203
|
+
persistAgentResumeBinding(sessionId, session);
|
|
2204
|
+
}
|
|
2205
|
+
// The agent's cwd claim: allow file APIs to follow it (worktree hops).
|
|
2206
|
+
if (state.agentCwd) {
|
|
2207
|
+
void pathValidator.allowSessionCwd(state.agentCwd);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
// Opaque fallback: only meaningful when we know an agent runs here, and
|
|
2211
|
+
// never on top of rich state (the hooks channel owns it then).
|
|
2212
|
+
if (notification && session.agent && !session.agentSession?.rich) {
|
|
2213
|
+
const state = session.agentSession ?? defaultAgentSessionState();
|
|
2214
|
+
session.agentSession = state;
|
|
2215
|
+
if (state.status !== 'waiting' || state.message !== notification) {
|
|
2216
|
+
state.status = 'waiting';
|
|
2217
|
+
state.message = notification;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
// Seed the launch argv the identity poll captured, so resume gets the
|
|
2221
|
+
// flags no matter which side observed the pane first.
|
|
2222
|
+
if (session.agentSession && session.agentSession.launchArgv === null && session.activeProgram?.rawArgs) {
|
|
2223
|
+
const argv = splitCommandToArgv(session.activeProgram.rawArgs);
|
|
2224
|
+
if (argv.length > 0) {
|
|
2225
|
+
session.agentSession.launchArgv = argv;
|
|
2226
|
+
persistAgentResumeBinding(sessionId, session);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
// Agent activity (a tool completed → the working tree may have changed) is
|
|
2230
|
+
// one of the git probe's triggers.
|
|
2231
|
+
maybeRefreshGitStatusForAgent(sessionId, session);
|
|
2232
|
+
broadcastAgentStatus(sessionId, session, identityChanged);
|
|
2233
|
+
}
|
|
2234
|
+
// ── Git status (branch + diff size) ──
|
|
2235
|
+
//
|
|
2236
|
+
// Probes are cheap (two git shell-outs) and shared through the repo-root
|
|
2237
|
+
// cache; triggers are event-driven rather than a timer: cwd change, command
|
|
2238
|
+
// end (prompt-state running→idle), and agent tool activity mid-turn.
|
|
2239
|
+
const lastGitStatusSnapshots = new Map();
|
|
2240
|
+
function refreshGitStatus(sessionId, session, opts = {}) {
|
|
2241
|
+
// The agent's own cwd claim (worktree hops) wins over the pane's proc cwd.
|
|
2242
|
+
const key = session.agentSession?.agentCwd ?? session.cwd ?? null;
|
|
2243
|
+
if (!key) {
|
|
2244
|
+
if (session.gitStatus !== null) {
|
|
2245
|
+
session.gitStatus = null;
|
|
2246
|
+
session.gitStatusKey = null;
|
|
2247
|
+
broadcastEvent(sessionId, { type: 'git-status', gitStatus: null });
|
|
2147
2248
|
}
|
|
2148
2249
|
return;
|
|
2149
2250
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2251
|
+
session.gitStatusKey = key;
|
|
2252
|
+
const probe = gitStatusCache.beginProbe(key, opts.minIntervalMs);
|
|
2253
|
+
if (!probe)
|
|
2254
|
+
return;
|
|
2255
|
+
void probe.then((status) => {
|
|
2256
|
+
// The pane may have moved on while the probe ran; publish only if the
|
|
2257
|
+
// probe's key is still the pane's tree (or the session switched away —
|
|
2258
|
+
// then the new cwd's own trigger will publish its own).
|
|
2259
|
+
if (session.gitStatusKey !== key)
|
|
2260
|
+
return;
|
|
2261
|
+
const snapshot = JSON.stringify(status);
|
|
2262
|
+
if (lastGitStatusSnapshots.get(sessionId) === snapshot)
|
|
2263
|
+
return;
|
|
2264
|
+
lastGitStatusSnapshots.set(sessionId, snapshot);
|
|
2265
|
+
session.gitStatus = status;
|
|
2266
|
+
broadcastEvent(sessionId, { type: 'git-status', gitStatus: status });
|
|
2267
|
+
}).catch(() => { });
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Git probe trigger: agent tool activity means the working tree may have
|
|
2271
|
+
* changed mid-turn.
|
|
2272
|
+
*/
|
|
2273
|
+
function maybeRefreshGitStatusForAgent(sessionId, session) {
|
|
2274
|
+
const activity = session.agentSession?.activity ?? 0;
|
|
2275
|
+
if (activity === 0 || activity === session.gitAgentActivitySeen)
|
|
2276
|
+
return;
|
|
2277
|
+
session.gitAgentActivitySeen = activity;
|
|
2278
|
+
refreshGitStatus(sessionId, session);
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* The command that resumes this pane's last agent conversation, or null.
|
|
2282
|
+
* Live state wins; falls back to the persisted last-known record.
|
|
2283
|
+
*/
|
|
2284
|
+
function resolveAgentResumeCommand(sessionId, session) {
|
|
2285
|
+
const liveAgent = session.agent;
|
|
2286
|
+
const live = session.agentSession;
|
|
2287
|
+
if (liveAgent && live?.sessionId) {
|
|
2288
|
+
const cmd = buildResumeCommand(liveAgent, live.sessionId, live.launchArgv);
|
|
2289
|
+
if (cmd)
|
|
2290
|
+
return cmd;
|
|
2291
|
+
}
|
|
2292
|
+
if (!isPaneAtShellPrompt(session))
|
|
2293
|
+
return null;
|
|
2294
|
+
const record = globalSessionState.sessions.find((s) => s.backendSessionId === sessionId);
|
|
2295
|
+
const persisted = record?.agentResume;
|
|
2296
|
+
if (!persisted?.sessionId)
|
|
2297
|
+
return null;
|
|
2298
|
+
const agent = agentBySlug(persisted.slug);
|
|
2299
|
+
if (!agent)
|
|
2300
|
+
return null;
|
|
2301
|
+
return buildResumeCommand(agent, persisted.sessionId, persisted.launchArgv);
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Deliver a prompt into an agent's PTY: bracketed paste (so multi-line
|
|
2305
|
+
* prompts insert as one block instead of submitting line by line — every
|
|
2306
|
+
* recognized agent's TUI enables bracketed paste), followed by CR to submit.
|
|
2307
|
+
* ESC bytes are stripped so embedded content can't fake the paste terminator.
|
|
2308
|
+
*/
|
|
2309
|
+
function buildBracketedSubmitBytes(prompt) {
|
|
2310
|
+
return `\x1b[200~${prompt.replace(/\x1b/g, '')}\x1b[201~\r`;
|
|
2163
2311
|
}
|
|
2164
|
-
// ── end
|
|
2312
|
+
// ── end Rich agent session tracking ──
|
|
2165
2313
|
/**
|
|
2166
2314
|
* Resolve the "real" program name for a tmux pane.
|
|
2167
2315
|
*
|
|
@@ -2176,10 +2324,12 @@ const DEFAULT_PROGRAM_DETECTION = {
|
|
|
2176
2324
|
genericProgramNames: ['node', 'python', 'python3', 'ruby', 'perl', 'java'],
|
|
2177
2325
|
wrapperScriptNames: ['aiden', 'ttadk', 'npx', 'yarn', 'dlx'],
|
|
2178
2326
|
shellNames: ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu'],
|
|
2327
|
+
agentCommands: {},
|
|
2179
2328
|
};
|
|
2180
2329
|
let genericProgramNames = new Set(DEFAULT_PROGRAM_DETECTION.genericProgramNames);
|
|
2181
2330
|
let wrapperScriptNames = new Set(DEFAULT_PROGRAM_DETECTION.wrapperScriptNames);
|
|
2182
2331
|
let shellNamesBackend = new Set(DEFAULT_PROGRAM_DETECTION.shellNames);
|
|
2332
|
+
let agentCommandsMap = { ...DEFAULT_PROGRAM_DETECTION.agentCommands };
|
|
2183
2333
|
async function loadProgramDetectionFromDisk() {
|
|
2184
2334
|
try {
|
|
2185
2335
|
const parsed = await readJsonFileIfExists(PROGRAM_DETECTION_FILE);
|
|
@@ -2189,6 +2339,10 @@ async function loadProgramDetectionFromDisk() {
|
|
|
2189
2339
|
genericProgramNames: Array.isArray(parsed.genericProgramNames) ? parsed.genericProgramNames : DEFAULT_PROGRAM_DETECTION.genericProgramNames,
|
|
2190
2340
|
wrapperScriptNames: Array.isArray(parsed.wrapperScriptNames) ? parsed.wrapperScriptNames : DEFAULT_PROGRAM_DETECTION.wrapperScriptNames,
|
|
2191
2341
|
shellNames: Array.isArray(parsed.shellNames) ? parsed.shellNames : DEFAULT_PROGRAM_DETECTION.shellNames,
|
|
2342
|
+
agentCommands: parsed.agentCommands && typeof parsed.agentCommands === 'object' && !Array.isArray(parsed.agentCommands)
|
|
2343
|
+
? Object.fromEntries(Object.entries(parsed.agentCommands)
|
|
2344
|
+
.filter(([k, v]) => typeof k === 'string' && typeof v === 'string'))
|
|
2345
|
+
: {},
|
|
2192
2346
|
};
|
|
2193
2347
|
}
|
|
2194
2348
|
catch { /* file doesn't exist or invalid, use defaults */ }
|
|
@@ -2198,6 +2352,7 @@ function applyProgramDetectionConfig(config) {
|
|
|
2198
2352
|
genericProgramNames = new Set(config.genericProgramNames);
|
|
2199
2353
|
wrapperScriptNames = new Set(config.wrapperScriptNames);
|
|
2200
2354
|
shellNamesBackend = new Set(config.shellNames);
|
|
2355
|
+
agentCommandsMap = { ...(config.agentCommands ?? {}) };
|
|
2201
2356
|
}
|
|
2202
2357
|
async function saveProgramDetectionToDisk(config) {
|
|
2203
2358
|
await writeJsonFile(PROGRAM_DETECTION_FILE, config);
|
|
@@ -2205,6 +2360,27 @@ async function saveProgramDetectionToDisk(config) {
|
|
|
2205
2360
|
}
|
|
2206
2361
|
// Initialize on startup
|
|
2207
2362
|
void loadProgramDetectionFromDisk().then(applyProgramDetectionConfig);
|
|
2363
|
+
// Startup keeper: rewrite hook integrations that point at a stale termdock
|
|
2364
|
+
// (package moved/updated) so they keep firing into the right emitter.
|
|
2365
|
+
try {
|
|
2366
|
+
refreshStaleHooksAtLaunch();
|
|
2367
|
+
}
|
|
2368
|
+
catch { /* hook refresh must never block startup */ }
|
|
2369
|
+
// Load user-defined agent plugins into the identity registry
|
|
2370
|
+
try {
|
|
2371
|
+
const { plugins, errors } = loadPlugins();
|
|
2372
|
+
const result = registerPluginAgents(plugins);
|
|
2373
|
+
if (result.registered > 0) {
|
|
2374
|
+
console.log(`[agent-plugins] loaded ${result.registered} plugin(s)`);
|
|
2375
|
+
}
|
|
2376
|
+
for (const skip of result.skipped) {
|
|
2377
|
+
console.warn(`[agent-plugins] skipped: ${skip}`);
|
|
2378
|
+
}
|
|
2379
|
+
for (const err of errors) {
|
|
2380
|
+
console.warn(`[agent-plugins] validation error in "${err.slug}": ${err.errors.join('; ')}`);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
catch { /* plugin loading must never block startup */ }
|
|
2208
2384
|
async function resolveTmuxPaneProgram(pane) {
|
|
2209
2385
|
// If pane command is a known shell, try to find a child foreground process
|
|
2210
2386
|
const command = normalizeProgramName(pane.command);
|
|
@@ -2605,6 +2781,18 @@ async function ensureManagedTmuxSessionReady(sessionName) {
|
|
|
2605
2781
|
}
|
|
2606
2782
|
await applyTmuxScrollbackProfile(sessionName);
|
|
2607
2783
|
await stampTmuxMetadata(sessionName);
|
|
2784
|
+
// Agent-hook plumbing for panes in this session: TERMDOCK marks panes
|
|
2785
|
+
// spawned here as ours (the emitter's gate), and allow-passthrough lets
|
|
2786
|
+
// hooks' DCS-wrapped sentinel OSC reach us through tmux. Set here (not in
|
|
2787
|
+
// the shell-integration path, which early-returns) so every managed
|
|
2788
|
+
// session gets them regardless of shell type.
|
|
2789
|
+
try {
|
|
2790
|
+
await runTmux(['set-environment', '-t', sessionName, 'TERMDOCK', '1']);
|
|
2791
|
+
await runTmux(['set-option', '-t', sessionName, 'allow-passthrough', 'on']);
|
|
2792
|
+
}
|
|
2793
|
+
catch (error) {
|
|
2794
|
+
console.warn(`Failed to enable agent-hook plumbing for ${sessionName}: ${getErrorMessage(error)}`);
|
|
2795
|
+
}
|
|
2608
2796
|
}
|
|
2609
2797
|
async function prepareManagedTmuxSession(sessionName, cwd) {
|
|
2610
2798
|
await ensureTmuxSessionExists(sessionName, cwd);
|
|
@@ -2819,12 +3007,10 @@ function cleanupSession(sessionId, options) {
|
|
|
2819
3007
|
if (!session) {
|
|
2820
3008
|
return;
|
|
2821
3009
|
}
|
|
2822
|
-
if (session.agentStatusTimer) {
|
|
2823
|
-
clearTimeout(session.agentStatusTimer);
|
|
2824
|
-
session.agentStatusTimer = null;
|
|
2825
|
-
}
|
|
2826
3010
|
session.dataDisposable?.dispose();
|
|
2827
3011
|
session.exitDisposable?.dispose();
|
|
3012
|
+
lastAgentStatusSnapshots.delete(sessionId);
|
|
3013
|
+
lastGitStatusSnapshots.delete(sessionId);
|
|
2828
3014
|
destroyTmuxControl(session.tmuxControl);
|
|
2829
3015
|
session.tmuxControl = undefined;
|
|
2830
3016
|
for (const timer of session.flowPausedClientTimers.values()) {
|
|
@@ -3077,75 +3263,88 @@ function updateFocusTrackingFromOutput(sessionId, session, data) {
|
|
|
3077
3263
|
emitFocusSequenceIfNeeded(sessionId, session, true, 'focus-mode-enabled');
|
|
3078
3264
|
}
|
|
3079
3265
|
}
|
|
3080
|
-
|
|
3266
|
+
/**
|
|
3267
|
+
* The PTY output pipeline: history append + OSC sniffing + push. Live output
|
|
3268
|
+
* runs with broadcast on; pty-host *replay* (adoption after a server restart)
|
|
3269
|
+
* runs with broadcast off — it rebuilds history and sniffed state (cwd,
|
|
3270
|
+
* prompt-state, agent sessions) without re-sending stale output to clients.
|
|
3271
|
+
*/
|
|
3272
|
+
function processSessionOutput(sessionId, session, data, opts) {
|
|
3081
3273
|
const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
|
|
3082
|
-
|
|
3274
|
+
if (opts.broadcast) {
|
|
3083
3275
|
session.lastActivity = Date.now();
|
|
3084
3276
|
session.lastOutputAt = Date.now();
|
|
3085
3277
|
session.hasWrittenData = true;
|
|
3086
3278
|
scheduleActivityReorder();
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
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
|
-
});
|
|
3279
|
+
}
|
|
3280
|
+
let seq;
|
|
3281
|
+
if (session.mode === 'shell') {
|
|
3282
|
+
seq = addToHistory(sessionId, data);
|
|
3283
|
+
}
|
|
3284
|
+
if (session.mode === 'tmux') {
|
|
3285
|
+
updateFocusTrackingFromOutput(sessionId, session, data);
|
|
3286
|
+
}
|
|
3287
|
+
// Sniff OSC sequences for CWD tracking + prompt state + title
|
|
3288
|
+
try {
|
|
3289
|
+
const buf = session.oscSniffBuf + data;
|
|
3290
|
+
if (buf.length > OSC_SNIFF_CAP) {
|
|
3291
|
+
session.oscSniffBuf = buf.slice(-OSC_SNIFF_CAP / 4); // trim
|
|
3292
|
+
}
|
|
3293
|
+
const result = sniffOsc(buf, home);
|
|
3294
|
+
session.oscSniffBuf = result.remaining;
|
|
3295
|
+
// CWD change
|
|
3296
|
+
if (result.cwd && result.cwd !== session.lastOscCwd) {
|
|
3297
|
+
session.lastOscCwd = result.cwd;
|
|
3298
|
+
session.cwd = result.cwd;
|
|
3299
|
+
// 用户在终端里 cd 到白名单外目录(如 Windows 其它盘符)时,动态放行,
|
|
3300
|
+
// 否则侧边栏目录树/文件 API 会报 "Access denied: path not allowed"。
|
|
3301
|
+
void pathValidator.allowSessionCwd(result.cwd);
|
|
3302
|
+
if (updateGlobalBindingForBackendSession(sessionId, { cwd: result.cwd, lastActivity: session.lastActivity })) {
|
|
3303
|
+
schedulePersistGlobalState();
|
|
3127
3304
|
}
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3305
|
+
broadcastEvent(sessionId, { type: 'cwd', cwd: result.cwd });
|
|
3306
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
3307
|
+
}
|
|
3308
|
+
// Title change — broadcast so the frontend can update tab/sidebar
|
|
3309
|
+
if (result.title !== null && result.title !== session.lastOscTitle) {
|
|
3310
|
+
session.lastOscTitle = result.title;
|
|
3311
|
+
broadcastJsonWs(sessionId, { type: 'shell-title', title: result.title });
|
|
3312
|
+
}
|
|
3313
|
+
// Prompt state change (OSC 133)
|
|
3314
|
+
if (result.promptState !== null && result.promptState !== session.lastPromptState) {
|
|
3315
|
+
const wasRunning = session.lastPromptState === 'running';
|
|
3316
|
+
session.lastPromptState = result.promptState;
|
|
3317
|
+
broadcastJsonWs(sessionId, {
|
|
3318
|
+
type: 'prompt-state',
|
|
3319
|
+
state: result.promptState,
|
|
3320
|
+
exitCode: result.exitCode,
|
|
3321
|
+
});
|
|
3322
|
+
// A command just finished — the working tree may have changed.
|
|
3323
|
+
if (wasRunning && result.promptState === 'idle') {
|
|
3324
|
+
refreshGitStatus(sessionId, session);
|
|
3134
3325
|
}
|
|
3135
3326
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
//
|
|
3144
|
-
//
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3327
|
+
if (result.tuiProgress !== null) {
|
|
3328
|
+
session.tuiProgress = result.tuiProgress.state === 'remove' ? null : result.tuiProgress;
|
|
3329
|
+
broadcastJsonWs(sessionId, {
|
|
3330
|
+
type: 'tui-progress',
|
|
3331
|
+
tuiProgress: session.tuiProgress,
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
// Rich agent events (OSC 777 sentinel from installed hooks) + the
|
|
3335
|
+
// opaque notification fallback drive the per-pane agent session state.
|
|
3336
|
+
if (result.agentEvents.length > 0 || result.notification) {
|
|
3337
|
+
applyAgentSignals(sessionId, session, result.agentEvents, result.notification);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
catch { /* sniff failure should never block data */ }
|
|
3341
|
+
if (opts.broadcast) {
|
|
3148
3342
|
broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
function setupPtyHandlers(sessionId, session) {
|
|
3346
|
+
session.dataDisposable = session.ptyProcess.onData((data) => {
|
|
3347
|
+
processSessionOutput(sessionId, session, data, { broadcast: true });
|
|
3149
3348
|
});
|
|
3150
3349
|
session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
3151
3350
|
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
@@ -3167,6 +3366,17 @@ function wrapPtySpawnError(error) {
|
|
|
3167
3366
|
}
|
|
3168
3367
|
return error;
|
|
3169
3368
|
}
|
|
3369
|
+
/**
|
|
3370
|
+
* Shell-mode persistence via the pty-host daemon: shells live in a detached
|
|
3371
|
+
* host process and survive server restarts. Set TERMDOCK_PTY_HOST=off to
|
|
3372
|
+
* fall back to in-process PTYs (Windows always uses the in-process path).
|
|
3373
|
+
*/
|
|
3374
|
+
function ptyHostShellModeEnabled() {
|
|
3375
|
+
// The host daemon always uses node-pty; under a Bun runtime that's the
|
|
3376
|
+
// backend with known issues, so keep the in-process path there.
|
|
3377
|
+
const isBun = typeof globalThis.Bun !== 'undefined';
|
|
3378
|
+
return process.platform !== 'win32' && !isBun && process.env.TERMDOCK_PTY_HOST !== 'off';
|
|
3379
|
+
}
|
|
3170
3380
|
async function spawnTerminalSession(req, input) {
|
|
3171
3381
|
const cwd = await resolveWorkingDirectory(req, input.cwd);
|
|
3172
3382
|
const cols = input.cols || 80;
|
|
@@ -3194,12 +3404,20 @@ async function spawnTerminalSession(req, input) {
|
|
|
3194
3404
|
: (process.platform === 'win32' ? buildPowerShellCwdHookArgs() : []);
|
|
3195
3405
|
const envPath = buildAugmentedPath();
|
|
3196
3406
|
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
3407
|
+
// The server itself may run under tmux; spawned shells are NOT tmux panes.
|
|
3408
|
+
// Inheriting TMUX/TMUX_PANE makes tmux-aware programs (incl. our own
|
|
3409
|
+
// agent-hook emitter's passthrough decision) misdetect their context.
|
|
3410
|
+
delete resolvedEnv.TMUX;
|
|
3411
|
+
delete resolvedEnv.TMUX_PANE;
|
|
3197
3412
|
const pty = await getPtyProvider();
|
|
3198
3413
|
const termValue = resolveTerminalTermType(input.termType);
|
|
3199
3414
|
const baseEnv = {
|
|
3200
3415
|
...resolvedEnv,
|
|
3201
3416
|
TERM: termValue,
|
|
3202
3417
|
COLORTERM: 'truecolor',
|
|
3418
|
+
// Gates the agent-hook emitter: hooks installed globally stay silent
|
|
3419
|
+
// unless the agent runs inside a termdock-spawned shell.
|
|
3420
|
+
TERMDOCK: '1',
|
|
3203
3421
|
};
|
|
3204
3422
|
let ptyProcess = null;
|
|
3205
3423
|
if (mode === 'shell' && process.platform !== 'win32') {
|
|
@@ -3208,13 +3426,28 @@ async function spawnTerminalSession(req, input) {
|
|
|
3208
3426
|
for (const shellCandidate of shellCandidates) {
|
|
3209
3427
|
try {
|
|
3210
3428
|
const env = await injectShellIntegration(shellCandidate, baseEnv);
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3429
|
+
if (ptyHostShellModeEnabled()) {
|
|
3430
|
+
// The PTY lives in the detached pty-host daemon: this session
|
|
3431
|
+
// survives server restarts and is re-adopted on launch.
|
|
3432
|
+
ptyProcess = await getPtyHostManager().spawnChannel(sessionId, {
|
|
3433
|
+
shell: shellCandidate,
|
|
3434
|
+
args: [],
|
|
3435
|
+
cwd,
|
|
3436
|
+
cols,
|
|
3437
|
+
rows,
|
|
3438
|
+
env,
|
|
3439
|
+
termName: termValue,
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
else {
|
|
3443
|
+
ptyProcess = pty.spawn(shellCandidate, [], {
|
|
3444
|
+
name: termValue,
|
|
3445
|
+
cols,
|
|
3446
|
+
rows,
|
|
3447
|
+
cwd,
|
|
3448
|
+
env,
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3218
3451
|
break;
|
|
3219
3452
|
}
|
|
3220
3453
|
catch (error) {
|
|
@@ -3251,7 +3484,7 @@ async function spawnTerminalSession(req, input) {
|
|
|
3251
3484
|
}
|
|
3252
3485
|
const session = {
|
|
3253
3486
|
ptyProcess,
|
|
3254
|
-
ptyBackend: pty.backend,
|
|
3487
|
+
ptyBackend: mode === 'shell' && ptyHostShellModeEnabled() ? 'pty-host' : pty.backend,
|
|
3255
3488
|
cwd,
|
|
3256
3489
|
mode,
|
|
3257
3490
|
tmuxSessionName,
|
|
@@ -3263,17 +3496,16 @@ async function spawnTerminalSession(req, input) {
|
|
|
3263
3496
|
createdAt: Date.now(),
|
|
3264
3497
|
hasWrittenData: false,
|
|
3265
3498
|
activeProgram: null,
|
|
3499
|
+
agent: null,
|
|
3500
|
+
agentSession: null,
|
|
3501
|
+
gitStatus: null,
|
|
3502
|
+
gitStatusKey: null,
|
|
3503
|
+
gitAgentActivitySeen: 0,
|
|
3266
3504
|
oscSniffBuf: '',
|
|
3267
3505
|
lastOscCwd: null,
|
|
3268
3506
|
lastOscTitle: null,
|
|
3269
3507
|
lastPromptState: null,
|
|
3270
3508
|
tuiProgress: null,
|
|
3271
|
-
agentStatus: null,
|
|
3272
|
-
agentColor: null,
|
|
3273
|
-
agentIndicator: null,
|
|
3274
|
-
agentStatusBuf: '',
|
|
3275
|
-
agentStatusTimer: null,
|
|
3276
|
-
agentStatusClearDelayMs: DEFAULT_AGENT_CLEAR_DELAY_MS,
|
|
3277
3509
|
focusTrackingRequested: false,
|
|
3278
3510
|
focusModeSniffBuf: '',
|
|
3279
3511
|
focusAggregation: {
|
|
@@ -3285,6 +3517,8 @@ async function spawnTerminalSession(req, input) {
|
|
|
3285
3517
|
ptyPausedForFlowControl: false,
|
|
3286
3518
|
};
|
|
3287
3519
|
terminalSessions.set(sessionId, session);
|
|
3520
|
+
// First git snapshot for the pane's spawn cwd (throttle-free).
|
|
3521
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
3288
3522
|
setupPtyHandlers(sessionId, session);
|
|
3289
3523
|
if (mode === 'tmux' && tmuxSessionName) {
|
|
3290
3524
|
// Session was prepared before attach so the first tmux client sees the
|
|
@@ -3309,6 +3543,88 @@ async function spawnTerminalSession(req, input) {
|
|
|
3309
3543
|
}
|
|
3310
3544
|
return { sessionId, session, cols, rows };
|
|
3311
3545
|
}
|
|
3546
|
+
// ── pty-host adoption (shell-mode persistence across server restarts) ──
|
|
3547
|
+
/**
|
|
3548
|
+
* Adopt every shell session that survived in the pty-host daemon. Rebuilds
|
|
3549
|
+
* the TerminalSession around a PtyHostClient, feeds the host's replay ring
|
|
3550
|
+
* through the output pipeline (history + OSC/agent state, no broadcast), and
|
|
3551
|
+
* re-links — or creates — the persisted client record so the session shows
|
|
3552
|
+
* up as a tab again.
|
|
3553
|
+
*/
|
|
3554
|
+
async function adoptPtyHostSessions() {
|
|
3555
|
+
if (!ptyHostShellModeEnabled())
|
|
3556
|
+
return;
|
|
3557
|
+
const adopted = await getPtyHostManager().adoptChannels();
|
|
3558
|
+
if (adopted.length === 0)
|
|
3559
|
+
return;
|
|
3560
|
+
let createdRecords = 0;
|
|
3561
|
+
for (const { meta, client } of adopted) {
|
|
3562
|
+
if (terminalSessions.has(meta.id))
|
|
3563
|
+
continue;
|
|
3564
|
+
const session = {
|
|
3565
|
+
ptyProcess: client,
|
|
3566
|
+
ptyBackend: 'pty-host',
|
|
3567
|
+
cwd: meta.cwd,
|
|
3568
|
+
mode: 'shell',
|
|
3569
|
+
tmuxSessionName: null,
|
|
3570
|
+
cols: meta.cols,
|
|
3571
|
+
rows: meta.rows,
|
|
3572
|
+
lastActivity: Date.now(),
|
|
3573
|
+
lastOutputAt: Date.now(),
|
|
3574
|
+
clients: new Map(),
|
|
3575
|
+
createdAt: meta.startedAt,
|
|
3576
|
+
hasWrittenData: true,
|
|
3577
|
+
activeProgram: null,
|
|
3578
|
+
agent: null,
|
|
3579
|
+
agentSession: null,
|
|
3580
|
+
gitStatus: null,
|
|
3581
|
+
gitStatusKey: null,
|
|
3582
|
+
gitAgentActivitySeen: 0,
|
|
3583
|
+
oscSniffBuf: '',
|
|
3584
|
+
lastOscCwd: null,
|
|
3585
|
+
lastOscTitle: null,
|
|
3586
|
+
lastPromptState: null,
|
|
3587
|
+
tuiProgress: null,
|
|
3588
|
+
focusTrackingRequested: false,
|
|
3589
|
+
focusModeSniffBuf: '',
|
|
3590
|
+
focusAggregation: {
|
|
3591
|
+
focusedClients: new Map(),
|
|
3592
|
+
effectiveFocused: false,
|
|
3593
|
+
},
|
|
3594
|
+
flowPausedClients: new Set(),
|
|
3595
|
+
flowPausedClientTimers: new Map(),
|
|
3596
|
+
ptyPausedForFlowControl: false,
|
|
3597
|
+
};
|
|
3598
|
+
terminalSessions.set(meta.id, session);
|
|
3599
|
+
// The host's replay ring rebuilds this server's history + sniffed state
|
|
3600
|
+
// (cwd / prompt-state / agent sessions) without re-broadcasting.
|
|
3601
|
+
client.onReplay((data) => {
|
|
3602
|
+
processSessionOutput(meta.id, session, data, { broadcast: false });
|
|
3603
|
+
});
|
|
3604
|
+
setupPtyHandlers(meta.id, session);
|
|
3605
|
+
refreshGitStatus(meta.id, session, { minIntervalMs: 0 });
|
|
3606
|
+
// Re-link or create the persisted record so the session appears as a tab.
|
|
3607
|
+
const existing = globalSessionState.sessions.find((s) => s.backendSessionId === meta.id);
|
|
3608
|
+
if (!existing) {
|
|
3609
|
+
const now = Date.now();
|
|
3610
|
+
upsertGlobalSessionRecord({
|
|
3611
|
+
sessionId: randomUUID(),
|
|
3612
|
+
name: `terminal-${now.toString(36)}`,
|
|
3613
|
+
backendSessionId: meta.id,
|
|
3614
|
+
mode: 'shell',
|
|
3615
|
+
tmuxSessionName: null,
|
|
3616
|
+
createdAt: meta.startedAt,
|
|
3617
|
+
lastActivity: now,
|
|
3618
|
+
cwd: meta.cwd,
|
|
3619
|
+
});
|
|
3620
|
+
createdRecords++;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
console.log(`[pty-host] adopted ${adopted.length} surviving shell session(s)${createdRecords > 0 ? `, created ${createdRecords} record(s)` : ''}`);
|
|
3624
|
+
if (createdRecords > 0) {
|
|
3625
|
+
persistAndBroadcastGlobalState();
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3312
3628
|
function detectShellType(shellPath) {
|
|
3313
3629
|
const base = shellPath.split('/').pop()?.toLowerCase() || '';
|
|
3314
3630
|
if (base.includes('bash'))
|
|
@@ -3855,69 +4171,91 @@ router.put('/settings', async (req, res) => {
|
|
|
3855
4171
|
}
|
|
3856
4172
|
res.json(await getSettingsPayload());
|
|
3857
4173
|
});
|
|
3858
|
-
// ── Agent
|
|
3859
|
-
router.get('/agent-
|
|
3860
|
-
res.json(
|
|
4174
|
+
// ── Agent hooks API (rich status channel installers, built-in + plugin) ──
|
|
4175
|
+
router.get('/agent-hooks', (_req, res) => {
|
|
4176
|
+
res.json({ agents: listAllHookAgents() });
|
|
3861
4177
|
});
|
|
3862
|
-
router.
|
|
3863
|
-
const
|
|
3864
|
-
|
|
3865
|
-
|
|
4178
|
+
router.post('/agent-hooks/:agent/install', async (req, res) => {
|
|
4179
|
+
const agent = req.params.agent;
|
|
4180
|
+
try {
|
|
4181
|
+
const result = await installHooksForSlug(agent);
|
|
4182
|
+
res.json({ agent, ...result, state: 'installed' });
|
|
4183
|
+
}
|
|
4184
|
+
catch (error) {
|
|
4185
|
+
res.status(400).json({ error: error.message });
|
|
4186
|
+
}
|
|
4187
|
+
});
|
|
4188
|
+
router.post('/agent-hooks/:agent/uninstall', async (req, res) => {
|
|
4189
|
+
const agent = req.params.agent;
|
|
4190
|
+
try {
|
|
4191
|
+
const summary = await uninstallHooksForSlug(agent);
|
|
4192
|
+
res.json({ agent, summary, state: 'not-installed' });
|
|
4193
|
+
}
|
|
4194
|
+
catch (error) {
|
|
4195
|
+
res.status(400).json({ error: error.message });
|
|
4196
|
+
}
|
|
4197
|
+
});
|
|
4198
|
+
// ── end Agent hooks API ──
|
|
4199
|
+
// ── Agent plugins API (user-defined agent plugins) ──
|
|
4200
|
+
router.get('/agent-plugins', (_req, res) => {
|
|
4201
|
+
const { plugins, errors } = loadPlugins();
|
|
4202
|
+
res.json({
|
|
4203
|
+
plugins: plugins.map((p) => ({
|
|
4204
|
+
slug: p.manifest.slug,
|
|
4205
|
+
displayName: p.manifest.displayName,
|
|
4206
|
+
aliases: p.manifest.aliases,
|
|
4207
|
+
accentColor: p.manifest.accentColor,
|
|
4208
|
+
hasHooks: p.manifest.hooks !== undefined,
|
|
4209
|
+
hasResume: p.manifest.resume !== undefined,
|
|
4210
|
+
hasIcon: p.iconPath !== null,
|
|
4211
|
+
})),
|
|
4212
|
+
errors: errors.map((e) => ({ slug: e.slug, errors: e.errors })),
|
|
4213
|
+
});
|
|
4214
|
+
});
|
|
4215
|
+
router.post('/agent-plugins', async (req, res) => {
|
|
4216
|
+
const manifest = req.body;
|
|
4217
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
4218
|
+
res.status(400).json({ error: 'Request body must be a plugin manifest JSON object' });
|
|
3866
4219
|
return;
|
|
3867
4220
|
}
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
const
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
4221
|
+
try {
|
|
4222
|
+
// validate through the loader
|
|
4223
|
+
const { plugins } = loadPlugins();
|
|
4224
|
+
// Check for duplicate slug
|
|
4225
|
+
const existing = plugins.find((p) => p.manifest.slug === manifest.slug);
|
|
4226
|
+
if (existing) {
|
|
4227
|
+
res.status(409).json({ error: `Plugin "${existing.manifest.slug}" already exists` });
|
|
3874
4228
|
return;
|
|
3875
4229
|
}
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
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
|
-
}
|
|
4230
|
+
const dir = savePlugin(manifest);
|
|
4231
|
+
res.json({ slug: manifest.slug, dir, state: 'created' });
|
|
4232
|
+
}
|
|
4233
|
+
catch (error) {
|
|
4234
|
+
res.status(400).json({ error: error.message });
|
|
3901
4235
|
}
|
|
3902
|
-
await saveAgentRulesToDisk(rules);
|
|
3903
|
-
broadcastControlEvent({
|
|
3904
|
-
type: 'config-updated',
|
|
3905
|
-
key: 'agent-rules',
|
|
3906
|
-
updatedAt: Date.now(),
|
|
3907
|
-
});
|
|
3908
|
-
res.json(rules);
|
|
3909
4236
|
});
|
|
3910
|
-
router.delete('/agent-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
}
|
|
3919
|
-
res.json(BUILTIN_AGENT_RULES);
|
|
4237
|
+
router.delete('/agent-plugins/:slug', async (req, res) => {
|
|
4238
|
+
const { slug } = req.params;
|
|
4239
|
+
try {
|
|
4240
|
+
removePlugin(slug);
|
|
4241
|
+
res.json({ slug, state: 'removed' });
|
|
4242
|
+
}
|
|
4243
|
+
catch (error) {
|
|
4244
|
+
res.status(404).json({ error: error.message });
|
|
4245
|
+
}
|
|
3920
4246
|
});
|
|
4247
|
+
router.get('/agent-plugin-icon/:slug', (req, res) => {
|
|
4248
|
+
const { slug } = req.params;
|
|
4249
|
+
const svg = readPluginIcon(slug);
|
|
4250
|
+
if (svg === null) {
|
|
4251
|
+
res.status(404).json({ error: 'No icon found' });
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
4254
|
+
res.setHeader('Content-Type', 'image/svg+xml');
|
|
4255
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
4256
|
+
res.send(svg);
|
|
4257
|
+
});
|
|
4258
|
+
// ── end Agent plugins API ──
|
|
3921
4259
|
// ── Program detection config API ──
|
|
3922
4260
|
router.get('/program-detection', async (_req, res) => {
|
|
3923
4261
|
res.json(await loadProgramDetectionFromDisk());
|
|
@@ -4056,14 +4394,23 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4056
4394
|
mode: session.mode,
|
|
4057
4395
|
tmuxSessionName: session.tmuxSessionName,
|
|
4058
4396
|
cwd: session.cwd ?? null,
|
|
4059
|
-
activeProgram: session.activeProgram?.command ??
|
|
4397
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
|
|
4060
4398
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
4061
4399
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
4062
|
-
agentStatus: session.agentStatus,
|
|
4063
|
-
agentColor: session.agentColor,
|
|
4064
|
-
agentIndicator: session.agentIndicator,
|
|
4065
4400
|
tuiProgress: session.tuiProgress,
|
|
4401
|
+
// 标题/prompt 状态只在「变化」时广播,且去重基准在 server 端——刷新后的
|
|
4402
|
+
// 新客户端不会收到重发。连接即推当前值,否则 tab 名塌回默认直到下次变化。
|
|
4403
|
+
shellTitle: session.lastOscTitle,
|
|
4404
|
+
promptState: session.lastPromptState,
|
|
4066
4405
|
});
|
|
4406
|
+
// Rich agent state rides its own message so the connected payload stays
|
|
4407
|
+
// backward compatible; force past the dedupe snapshot.
|
|
4408
|
+
if (session.agent || session.agentSession) {
|
|
4409
|
+
writeSse(res, buildAgentStatusPayload(sessionId, session));
|
|
4410
|
+
}
|
|
4411
|
+
if (session.gitStatus) {
|
|
4412
|
+
writeSse(res, { type: 'git-status', gitStatus: session.gitStatus });
|
|
4413
|
+
}
|
|
4067
4414
|
let tmuxInterval = null;
|
|
4068
4415
|
let activeProgramInterval = null;
|
|
4069
4416
|
let lastTmuxLayoutSnapshot = '';
|
|
@@ -4077,8 +4424,9 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4077
4424
|
}
|
|
4078
4425
|
lastActiveProgramSnapshot = snapshot;
|
|
4079
4426
|
session.activeProgram = activeProgram;
|
|
4427
|
+
persistActiveProgramBinding(sessionId, activeProgram?.command);
|
|
4080
4428
|
// Agent status: react to AI tool start/exit
|
|
4081
|
-
|
|
4429
|
+
syncAgentIdentity(sessionId, session);
|
|
4082
4430
|
console.log(`[active-program][shell-sse] session=${sessionId} client=${clientId} cmd=${activeProgram?.command ?? null} source=${activeProgram?.source ?? null}`);
|
|
4083
4431
|
writeSse(res, {
|
|
4084
4432
|
type: 'active-program',
|
|
@@ -4120,6 +4468,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4120
4468
|
schedulePersistGlobalState();
|
|
4121
4469
|
}
|
|
4122
4470
|
writeSse(res, { type: 'cwd', cwd: newCwd });
|
|
4471
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
4123
4472
|
}
|
|
4124
4473
|
// tmux 消费了 inner shell 发的 OSC 2/133,不透传到外层 PTY。
|
|
4125
4474
|
// 从 tmux layout 提取 active pane 的 title 和 command 来推导。
|
|
@@ -4132,8 +4481,12 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4132
4481
|
const paneCmd = activePane.command || '';
|
|
4133
4482
|
const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
|
|
4134
4483
|
if (inferredState !== session.lastPromptState) {
|
|
4484
|
+
const wasRunning = session.lastPromptState === 'running';
|
|
4135
4485
|
session.lastPromptState = inferredState;
|
|
4136
4486
|
writeSse(res, { type: 'prompt-state', state: inferredState });
|
|
4487
|
+
if (wasRunning && inferredState === 'idle') {
|
|
4488
|
+
refreshGitStatus(sessionId, session);
|
|
4489
|
+
}
|
|
4137
4490
|
}
|
|
4138
4491
|
}
|
|
4139
4492
|
// Mirror dynamic metadata onto tmux user options (cheap when nothing
|
|
@@ -4280,6 +4633,48 @@ router.post('/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|
|
4280
4633
|
res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
|
|
4281
4634
|
}
|
|
4282
4635
|
});
|
|
4636
|
+
/**
|
|
4637
|
+
* Resume the pane's last agent conversation: rebuild the agent's native
|
|
4638
|
+
* resume command (carrying the original launch flags) and deliver it via
|
|
4639
|
+
* bracketed paste. Refused while an agent turn is in flight — pasting a
|
|
4640
|
+
* resume into a busy TUI would corrupt its input.
|
|
4641
|
+
*/
|
|
4642
|
+
router.post('/:sessionId/agent-resume', (req, res) => {
|
|
4643
|
+
const { sessionId } = req.params;
|
|
4644
|
+
const session = terminalSessions.get(sessionId);
|
|
4645
|
+
if (!session) {
|
|
4646
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
4647
|
+
}
|
|
4648
|
+
const live = session.agentSession;
|
|
4649
|
+
if (session.agent && live && (live.status === 'working' || live.status === 'waiting')) {
|
|
4650
|
+
return res.status(409).json({ error: 'agent is busy; wait for the turn to finish' });
|
|
4651
|
+
}
|
|
4652
|
+
const command = resolveAgentResumeCommand(sessionId, session);
|
|
4653
|
+
if (!command) {
|
|
4654
|
+
return res.status(404).json({ error: 'no resumable agent session for this pane' });
|
|
4655
|
+
}
|
|
4656
|
+
try {
|
|
4657
|
+
session.ptyProcess.write(buildBracketedSubmitBytes(command));
|
|
4658
|
+
session.lastActivity = Date.now();
|
|
4659
|
+
res.json({ success: true, command });
|
|
4660
|
+
}
|
|
4661
|
+
catch (error) {
|
|
4662
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4663
|
+
res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
|
|
4664
|
+
}
|
|
4665
|
+
});
|
|
4666
|
+
/** Whether this pane has a resumable agent conversation (for the UI affordance). */
|
|
4667
|
+
router.get('/:sessionId/agent-resume', (req, res) => {
|
|
4668
|
+
const { sessionId } = req.params;
|
|
4669
|
+
const session = terminalSessions.get(sessionId);
|
|
4670
|
+
if (!session) {
|
|
4671
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
4672
|
+
}
|
|
4673
|
+
const command = resolveAgentResumeCommand(sessionId, session);
|
|
4674
|
+
const busy = !!(session.agent && session.agentSession
|
|
4675
|
+
&& (session.agentSession.status === 'working' || session.agentSession.status === 'waiting'));
|
|
4676
|
+
res.json({ available: command !== null, command, busy });
|
|
4677
|
+
});
|
|
4283
4678
|
router.post('/:sessionId/resize', (req, res) => {
|
|
4284
4679
|
const { sessionId } = req.params;
|
|
4285
4680
|
const session = terminalSessions.get(sessionId);
|
|
@@ -4669,13 +5064,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4669
5064
|
mode: session.mode,
|
|
4670
5065
|
tmuxSessionName: session.tmuxSessionName,
|
|
4671
5066
|
cwd: session.cwd ?? null,
|
|
4672
|
-
activeProgram: session.activeProgram?.command ??
|
|
5067
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
|
|
4673
5068
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
4674
5069
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
4675
|
-
agentStatus: session.agentStatus,
|
|
4676
|
-
agentColor: session.agentColor,
|
|
4677
|
-
agentIndicator: session.agentIndicator,
|
|
4678
5070
|
tuiProgress: session.tuiProgress,
|
|
5071
|
+
shellTitle: session.lastOscTitle,
|
|
5072
|
+
promptState: session.lastPromptState,
|
|
4679
5073
|
focusTrackingRequested: session.focusTrackingRequested,
|
|
4680
5074
|
// 短线重连补帧:
|
|
4681
5075
|
// replayChunks 为补发数据;replayLastSeq 是客户端应记录的新基线;
|
|
@@ -4684,6 +5078,13 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4684
5078
|
replayLastSeq,
|
|
4685
5079
|
replayOutOfWindow,
|
|
4686
5080
|
}));
|
|
5081
|
+
// Rich agent state rides its own message (see the SSE path above).
|
|
5082
|
+
if (session.agent || session.agentSession) {
|
|
5083
|
+
ws.send(JSON.stringify(buildAgentStatusPayload(sessionId, session)));
|
|
5084
|
+
}
|
|
5085
|
+
if (session.gitStatus) {
|
|
5086
|
+
ws.send(JSON.stringify({ type: 'git-status', gitStatus: session.gitStatus }));
|
|
5087
|
+
}
|
|
4687
5088
|
})();
|
|
4688
5089
|
// Tmux layout polling (per-client, like the SSE stream does)
|
|
4689
5090
|
let tmuxInterval = null;
|
|
@@ -4717,8 +5118,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4717
5118
|
if (apSnapshot !== lastActiveProgramSnapshot) {
|
|
4718
5119
|
lastActiveProgramSnapshot = apSnapshot;
|
|
4719
5120
|
session.activeProgram = ap;
|
|
5121
|
+
persistActiveProgramBinding(sessionId, ap?.command);
|
|
4720
5122
|
// Agent status: react to AI tool start/exit
|
|
4721
|
-
|
|
5123
|
+
syncAgentIdentity(sessionId, session);
|
|
4722
5124
|
console.log(`[active-program][ws] session=${sessionId} cmd=${ap?.command ?? null} source=${ap?.source ?? null}`);
|
|
4723
5125
|
ws.send(JSON.stringify({
|
|
4724
5126
|
type: 'active-program',
|
|
@@ -4736,6 +5138,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4736
5138
|
schedulePersistGlobalState();
|
|
4737
5139
|
}
|
|
4738
5140
|
ws.send(JSON.stringify({ type: 'cwd', cwd: newCwd }));
|
|
5141
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
4739
5142
|
}
|
|
4740
5143
|
// tmux 消费了 inner shell 发的 OSC 2(存入 pane_title)和 OSC 133,
|
|
4741
5144
|
// 不透传到外层 PTY。因此从 tmux layout 提取 active pane 的 title
|
|
@@ -4750,8 +5153,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4750
5153
|
const paneCmd = activePane.command || '';
|
|
4751
5154
|
const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
|
|
4752
5155
|
if (inferredState !== session.lastPromptState) {
|
|
5156
|
+
const wasRunning = session.lastPromptState === 'running';
|
|
4753
5157
|
session.lastPromptState = inferredState;
|
|
4754
5158
|
ws.send(JSON.stringify({ type: 'prompt-state', state: inferredState }));
|
|
5159
|
+
if (wasRunning && inferredState === 'idle') {
|
|
5160
|
+
refreshGitStatus(sessionId, session);
|
|
5161
|
+
}
|
|
4755
5162
|
}
|
|
4756
5163
|
}
|
|
4757
5164
|
// Mirror dynamic metadata onto tmux user options.
|
|
@@ -4787,8 +5194,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4787
5194
|
if (snapshot !== lastApSnapshot) {
|
|
4788
5195
|
lastApSnapshot = snapshot;
|
|
4789
5196
|
session.activeProgram = ap;
|
|
5197
|
+
persistActiveProgramBinding(sessionId, ap?.command);
|
|
4790
5198
|
// Agent status: react to AI tool start/exit
|
|
4791
|
-
|
|
5199
|
+
syncAgentIdentity(sessionId, session);
|
|
4792
5200
|
ws.send(JSON.stringify({
|
|
4793
5201
|
type: 'active-program',
|
|
4794
5202
|
activeProgram: ap?.command ?? null,
|