termdock 1.3.0 → 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.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-DboLYKjC.js → abnfDiagram-VRR7QNED-G_YxqZwv.js} +1 -1
- package/dist/client/assets/{arc-C7cI3SGK.js → arc-BuvKiJCC.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DupwL8qH.js → architectureDiagram-ZJ3FMSHR-BvkwEVZw.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-DNM7fUPm.js → blockDiagram-677ZJIJ3-C3bxox0Z.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-Jaxf-YGr.js → c4Diagram-LMCZKHZV-DLORcr8m.js} +1 -1
- package/dist/client/assets/channel-BnGIwxUi.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-CjqqaH_Z.js → chunk-2Q5K7J3B-Dnqi-8g0.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-BYtt3b30.js → chunk-32BRIVSS-D70B4HxL.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-C68y4tT7.js → chunk-5VM5RSS4-Cl7t39aR.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BpJMVcVA.js → chunk-EX3LRPZG-DKMD4DfE.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-BaYNrMI4.js → chunk-JWPE2WC7-CsN_pMOw.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-y5QTOrLI.js → chunk-MOJQB5TN-D8BHaQpE.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DiHf2o1T.js → chunk-RYQCIY6F-B8BtUiHF.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-9j4f9aIn.js → chunk-V7JOEXUC-CdN7tlvn.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-CdbelzcZ.js → chunk-VR4S4FIN-UPjL_wl4.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-YAjZkdyb.js → chunk-XXDRQBXY--eX5Na_l.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-lTBDAWfs.js → cose-bilkent-JH36ORCC-CEAcZx3N.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Dl7ev6m4.js → cynefin-VYW2F7L2-up8zIMDo.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-CSxz6O5q.js → cynefinDiagram-TSTJHNR4-DZ2SQpCq.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-zWgEHxrl.js → dagre-VKFMJZFB-DOoLtq9O.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-BQeiowzD.js → diagram-FQU43EPY-x8umATxt.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-DJezXuHM.js → diagram-G47NLZAW-Kle7Toxb.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-D1d1WkdQ.js → diagram-NH7WQ7WH-CezkkMqP.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-C2ZpnAsd.js → diagram-OA4YK3LP-CaU0u6UQ.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-DH9kmAm9.js → diagram-WEI45ONY-B4m53TVj.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CjnajZxN.js → ebnfDiagram-CCIWWBDH-Dpwjo8_v.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-CzmBQFbx.js → erDiagram-Q63AITRT-BU4R1QVk.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-ljeoJhTo.js → flowDiagram-23GEKE2U-BFtDsA0k.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DF3m0dot.js → ganttDiagram-NO4QXBWP-DtSEfub5.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BwB7r571.js → gitGraphDiagram-IHSO6WYX-BGukYlT3.js} +1 -1
- package/dist/client/assets/index-CP3_puZd.js +395 -0
- package/dist/client/assets/index-CxBkmASY.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-Cv9S0ijK.js → infoDiagram-FWYZ7A6U-Bx1NQNsL.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DRecz33b.js → ishikawaDiagram-FXEZZL3T-DHlxYJjl.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BLRRylnR.js → journeyDiagram-5HDEW3XC-BOZzlR6d.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-BC1ph5cw.js → kanban-definition-HUTT4EX6-C2nWuUXX.js} +1 -1
- package/dist/client/assets/{linear-C2zeZj0z.js → linear-DtblW0g4.js} +1 -1
- package/dist/client/assets/{mermaid.core-BqDN_zP4.js → mermaid.core-BKSuG721.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BpDX-24D.js → mindmap-definition-LN4V7U3C-Co3LLQGt.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-Sn0Dke6L.js → pegDiagram-2B236MQR-DSUG1nB1.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DzMhYGWJ.js → pieDiagram-ENE6RG2P-DvEr0UPf.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CuQoPglS.js → quadrantDiagram-ABIIQ3AL-C2diLBZ8.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-C4hKErDp.js → railroadDiagram-RFXS5EU6-CqYZHCvf.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-CE5J97_g.js → requirementDiagram-TGXJPOKE-CFAKdPQp.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-BDyhQ6qz.js → sankeyDiagram-HTMAVEWB-DV95NA9H.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-CW-oi8so.js → sequenceDiagram-DBY2YBRQ-cbv0xzG-.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-mMoYKQSz.js → sizeCapture-X5ZJPWSS-DpkzYB1F.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BUd0G8S2.js → stateDiagram-2N3HPSRC-BnAwPP0V.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-B8hw4xPM.js → swimlanes-5IMT3BWC-kObRIKfj.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-yeb4EUGj.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DykkYG1G.js → timeline-definition-FHXFAJF6-BbnAJ8pO.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BBkpVj6b.js → vennDiagram-L72KCM5P-sIu_hWKx.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-ClqSnTks.js → wardleyDiagram-EHGQE667-DpGdJtdb.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-Ba_5tcTW.js → xychartDiagram-FW5EYKEG-C5F9faN2.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 +551 -0
- package/dist/server/agent/registry.js +244 -0
- package/dist/server/agent/session.js +162 -0
- package/dist/server/agentHook.js +174 -0
- 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/terminal.js +829 -395
- package/dist/server/utils/gitStatus.js +114 -0
- package/package.json +1 -1
- package/dist/client/assets/channel-D1achAhM.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BiqbrzEM.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiqbrzEM.js +0 -1
- package/dist/client/assets/index-QtbZAkiJ.css +0 -1
- package/dist/client/assets/index-odB6evg3.js +0 -394
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-XLkUpRKa.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-CA2gJRr9.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`;
|
|
@@ -97,6 +102,81 @@ let lastBroadcastClientStateSignature = null;
|
|
|
97
102
|
const inventoryOpenLocks = new Map();
|
|
98
103
|
const CONTROL_BROADCAST_COALESCE_MS = 50;
|
|
99
104
|
const SESSION_INVENTORY_CACHE_TTL_MS = 1500;
|
|
105
|
+
// 最近活跃优先排序:不是竞速榜,只分「最近活跃」和「非活跃」两组。
|
|
106
|
+
// 活跃组整体在前,组内保持当前相对顺序,避免两个持续输出的 session
|
|
107
|
+
// 按毫秒级 lastActivity 来回抢第一个位置。
|
|
108
|
+
const RECENT_ACTIVITY_WINDOW_MS = 60_000;
|
|
109
|
+
const ACTIVITY_REORDER_COOLDOWN_MS = 5_000;
|
|
110
|
+
const ACTIVITY_REORDER_PERIODIC_MS = 30_000;
|
|
111
|
+
let activityReorderTimer = null;
|
|
112
|
+
let activityReorderPeriodicTimer = null;
|
|
113
|
+
let activityReorderEnabled = true;
|
|
114
|
+
function getEffectiveLastActivity(session) {
|
|
115
|
+
// 只用 PTY 输出时间判断「最近活跃」——resize / input / connect
|
|
116
|
+
// 不参与排序,避免拖窗口尺寸等非输出操作导致 session 跳到前面。
|
|
117
|
+
if (session.backendSessionId) {
|
|
118
|
+
const backend = terminalSessions.get(session.backendSessionId);
|
|
119
|
+
if (backend)
|
|
120
|
+
return backend.lastOutputAt;
|
|
121
|
+
}
|
|
122
|
+
return session.lastActivity;
|
|
123
|
+
}
|
|
124
|
+
function applyActivityBasedReorder() {
|
|
125
|
+
if (!activityReorderEnabled)
|
|
126
|
+
return;
|
|
127
|
+
const sessions = globalSessionState.sessions;
|
|
128
|
+
if (sessions.length <= 1)
|
|
129
|
+
return;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const recentlyActive = [];
|
|
132
|
+
const inactive = [];
|
|
133
|
+
for (const session of sessions) {
|
|
134
|
+
const la = getEffectiveLastActivity(session);
|
|
135
|
+
if (now - la <= RECENT_ACTIVITY_WINDOW_MS) {
|
|
136
|
+
recentlyActive.push(session);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
inactive.push(session);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// 两个 bucket 内各自保持原有相对顺序,只是活跃组整体提前
|
|
143
|
+
const reordered = [...recentlyActive, ...inactive];
|
|
144
|
+
const orderChanged = sessions.map(s => s.sessionId).join('\x00') !== reordered.map(s => s.sessionId).join('\x00');
|
|
145
|
+
if (!orderChanged)
|
|
146
|
+
return;
|
|
147
|
+
globalSessionState = {
|
|
148
|
+
sessions: reordered,
|
|
149
|
+
updatedAt: Date.now(),
|
|
150
|
+
};
|
|
151
|
+
persistAndBroadcastGlobalState();
|
|
152
|
+
}
|
|
153
|
+
function scheduleActivityReorder() {
|
|
154
|
+
if (activityReorderTimer)
|
|
155
|
+
return;
|
|
156
|
+
activityReorderTimer = setTimeout(() => {
|
|
157
|
+
activityReorderTimer = null;
|
|
158
|
+
applyActivityBasedReorder();
|
|
159
|
+
}, ACTIVITY_REORDER_COOLDOWN_MS);
|
|
160
|
+
activityReorderTimer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
function startActivityReorderPeriodic() {
|
|
163
|
+
if (activityReorderPeriodicTimer)
|
|
164
|
+
return;
|
|
165
|
+
activityReorderPeriodicTimer = setInterval(() => {
|
|
166
|
+
applyActivityBasedReorder();
|
|
167
|
+
}, ACTIVITY_REORDER_PERIODIC_MS);
|
|
168
|
+
activityReorderPeriodicTimer.unref?.();
|
|
169
|
+
}
|
|
170
|
+
function stopActivityReorderPeriodic() {
|
|
171
|
+
if (activityReorderPeriodicTimer) {
|
|
172
|
+
clearInterval(activityReorderPeriodicTimer);
|
|
173
|
+
activityReorderPeriodicTimer = null;
|
|
174
|
+
}
|
|
175
|
+
if (activityReorderTimer) {
|
|
176
|
+
clearTimeout(activityReorderTimer);
|
|
177
|
+
activityReorderTimer = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
100
180
|
async function getSessionInventorySnapshot(options = {}) {
|
|
101
181
|
const now = Date.now();
|
|
102
182
|
if (!options.refresh && latestSessionInventory && now - latestSessionInventoryAt < SESSION_INVENTORY_CACHE_TTL_MS) {
|
|
@@ -378,14 +458,22 @@ function flushPersistAndExit() {
|
|
|
378
458
|
}
|
|
379
459
|
catch { /* best effort */ }
|
|
380
460
|
}
|
|
381
|
-
process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
|
|
382
|
-
process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
|
|
461
|
+
process.on('SIGTERM', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
|
|
462
|
+
process.on('SIGINT', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
|
|
383
463
|
// 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
|
|
384
464
|
void (async () => {
|
|
385
465
|
await loadGlobalSessionStateFromDisk();
|
|
386
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
|
+
});
|
|
387
473
|
pruneOrphanSessions();
|
|
388
474
|
await backfillPersistedTmuxMetadata();
|
|
475
|
+
startActivityReorderPeriodic();
|
|
476
|
+
applyActivityBasedReorder();
|
|
389
477
|
})();
|
|
390
478
|
caffeinateManager.startNetworkMonitor();
|
|
391
479
|
// 清理磁盘恢复后后端已不存在的 session 引用。
|
|
@@ -448,15 +536,40 @@ async function backfillPersistedTmuxMetadata() {
|
|
|
448
536
|
'@termdock-version': TERMDOCK_VERSION,
|
|
449
537
|
'@termdock-host': TERMDOCK_HOST,
|
|
450
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',
|
|
451
542
|
};
|
|
452
543
|
const existingCreatedAt = await getTmuxOption(s.tmuxSessionName, '@termdock-created-at');
|
|
453
544
|
if (!existingCreatedAt) {
|
|
454
545
|
baseOptions['@termdock-created-at'] = String(Date.now());
|
|
455
546
|
}
|
|
456
547
|
await setTmuxOptions(s.tmuxSessionName, baseOptions);
|
|
548
|
+
try {
|
|
549
|
+
await runTmux(['set-environment', '-t', s.tmuxSessionName, 'TERMDOCK', '1']);
|
|
550
|
+
}
|
|
551
|
+
catch { /* best effort */ }
|
|
457
552
|
if (s.customName === true && typeof s.name === 'string' && s.name.trim().length > 0) {
|
|
458
553
|
await setTmuxOption(s.tmuxSessionName, '@termdock-friendly-name', s.name);
|
|
459
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
|
+
}
|
|
460
573
|
}
|
|
461
574
|
catch (error) {
|
|
462
575
|
console.warn(`[tmux] failed to backfill metadata on ${s.tmuxSessionName}: ${getErrorMessage(error)}`);
|
|
@@ -617,6 +730,9 @@ function normalizePersistedClientSession(input) {
|
|
|
617
730
|
cwd: typeof candidate.cwd === 'string' && candidate.cwd.trim().length > 0
|
|
618
731
|
? candidate.cwd
|
|
619
732
|
: null,
|
|
733
|
+
activeProgram: typeof candidate.activeProgram === 'string' && candidate.activeProgram.trim().length > 0
|
|
734
|
+
? candidate.activeProgram
|
|
735
|
+
: null,
|
|
620
736
|
};
|
|
621
737
|
}
|
|
622
738
|
function generateTmuxSessionName() {
|
|
@@ -682,7 +798,7 @@ function makeTerminalSessionPayload(backendSessionId, session, cols = 80, rows =
|
|
|
682
798
|
rows,
|
|
683
799
|
mode: session.mode,
|
|
684
800
|
tmuxSessionName: session.tmuxSessionName,
|
|
685
|
-
activeProgram: session.activeProgram?.command ??
|
|
801
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(backendSessionId),
|
|
686
802
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
687
803
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
688
804
|
cwd: session.cwd ?? null,
|
|
@@ -907,6 +1023,24 @@ function updateGlobalBindingForBackendSession(backendSessionId, patch) {
|
|
|
907
1023
|
upsertGlobalSessionRecord(updated);
|
|
908
1024
|
return true;
|
|
909
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
|
+
}
|
|
910
1044
|
async function openInventorySession(req, input) {
|
|
911
1045
|
const normalizedMode = normalizeMode(input.mode);
|
|
912
1046
|
const normalizedTmuxName = normalizedMode === 'tmux'
|
|
@@ -1526,29 +1660,38 @@ async function buildSessionInventory() {
|
|
|
1526
1660
|
schedulePersistGlobalState();
|
|
1527
1661
|
}
|
|
1528
1662
|
const liveTmuxByName = new Map(refreshedTmuxSessions.map((session) => [session.name, session]));
|
|
1529
|
-
let
|
|
1663
|
+
let synchronizedPersistedFields = false;
|
|
1530
1664
|
globalSessionState = {
|
|
1531
1665
|
sessions: globalSessionState.sessions.map((session) => {
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
+
}
|
|
1534
1674
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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;
|
|
1538
1685
|
}
|
|
1539
|
-
if (
|
|
1540
|
-
|
|
1686
|
+
if (liveCwd && liveCwd !== (next.cwd ?? null)) {
|
|
1687
|
+
next = { ...next, cwd: liveCwd };
|
|
1688
|
+
synchronizedPersistedFields = true;
|
|
1541
1689
|
}
|
|
1542
|
-
|
|
1543
|
-
return {
|
|
1544
|
-
...session,
|
|
1545
|
-
name: friendlyName,
|
|
1546
|
-
customName: true,
|
|
1547
|
-
};
|
|
1690
|
+
return next;
|
|
1548
1691
|
}),
|
|
1549
|
-
updatedAt:
|
|
1692
|
+
updatedAt: synchronizedPersistedFields ? Date.now() : globalSessionState.updatedAt,
|
|
1550
1693
|
};
|
|
1551
|
-
if (
|
|
1694
|
+
if (synchronizedPersistedFields) {
|
|
1552
1695
|
schedulePersistGlobalState();
|
|
1553
1696
|
}
|
|
1554
1697
|
const clientSessions = globalSessionState.sessions.map((session) => {
|
|
@@ -1557,11 +1700,12 @@ async function buildSessionInventory() {
|
|
|
1557
1700
|
const live = session.mode === 'tmux' ? tmuxLive : backendLive;
|
|
1558
1701
|
// 展示名提示(activeProgram / cwd):优先取在线 backend session 的实时值,
|
|
1559
1702
|
// 其次回退到 tmux 清单里的 program/cwd(tmux 模式即使 backend 未 attach,
|
|
1560
|
-
// tmux 服务端仍能给出当前 pane
|
|
1703
|
+
// tmux 服务端仍能给出当前 pane 的程序与目录),最后回退到持久化记录里的
|
|
1704
|
+
// last-known 值(server 重启 / tmux 已死也能显示上次的标题)。让前端 hydrate 即可显示。
|
|
1561
1705
|
const backend = backendLive ? terminalSessions.get(session.backendSessionId) : undefined;
|
|
1562
1706
|
const tmuxMeta = session.tmuxSessionName ? liveTmuxByName.get(session.tmuxSessionName) : undefined;
|
|
1563
|
-
const activeProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? null;
|
|
1564
|
-
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;
|
|
1565
1709
|
return {
|
|
1566
1710
|
...session,
|
|
1567
1711
|
frontendSessionId: session.sessionId,
|
|
@@ -1798,6 +1942,8 @@ function sniffOsc(buf, home) {
|
|
|
1798
1942
|
let promptState = null;
|
|
1799
1943
|
let exitCode = null;
|
|
1800
1944
|
let tuiProgress = null;
|
|
1945
|
+
const agentEvents = [];
|
|
1946
|
+
let notification = null;
|
|
1801
1947
|
let lastMatchEnd = 0;
|
|
1802
1948
|
OSC_ANY_PATTERN.lastIndex = 0;
|
|
1803
1949
|
while ((match = OSC_ANY_PATTERN.exec(buf)) !== null) {
|
|
@@ -1838,253 +1984,330 @@ function sniffOsc(buf, home) {
|
|
|
1838
1984
|
}
|
|
1839
1985
|
else if (oscNum === '9') {
|
|
1840
1986
|
const progress = parseConEmuProgressReport(oscData);
|
|
1841
|
-
if (progress)
|
|
1987
|
+
if (progress) {
|
|
1842
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
|
+
}
|
|
1843
2008
|
}
|
|
1844
2009
|
}
|
|
1845
2010
|
const remaining = buf.slice(lastMatchEnd).slice(-128);
|
|
1846
|
-
return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, remaining };
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
],
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
function migrateAgentRules(rules) {
|
|
1892
|
-
let changed = false;
|
|
1893
|
-
const deprecatedCocoConfirmRule = {
|
|
1894
|
-
pattern: 'confirm|approve|permission|continue\\?',
|
|
1895
|
-
status: 'waiting',
|
|
1896
|
-
color: '#facc15',
|
|
1897
|
-
indicator: 'question',
|
|
1898
|
-
clearDelayMs: 10000,
|
|
1899
|
-
};
|
|
1900
|
-
const deprecatedCocoBroadWaitingRule = {
|
|
1901
|
-
pattern: 'Tab/Arrow keys to navigate|Esc to|select ·|Coco 等待态采样|AskUserQuestion|User\'s answers',
|
|
1902
|
-
status: 'waiting',
|
|
1903
|
-
color: '#facc15',
|
|
1904
|
-
indicator: 'question',
|
|
1905
|
-
clearDelayMs: 10000,
|
|
1906
|
-
};
|
|
1907
|
-
const cocoWaitingRule = {
|
|
1908
|
-
pattern: COCO_WAITING_RULE_PATTERN,
|
|
1909
|
-
status: 'waiting',
|
|
1910
|
-
color: '#facc15',
|
|
1911
|
-
indicator: 'question',
|
|
1912
|
-
clearDelayMs: 10000,
|
|
1913
|
-
};
|
|
1914
|
-
const nextRules = rules
|
|
1915
|
-
.filter((config) => {
|
|
1916
|
-
const shouldRemove = isSingleProgramConfig(config, 'aider') &&
|
|
1917
|
-
config.rules.length === 1 &&
|
|
1918
|
-
isSameAgentRule(config.rules[0], {
|
|
1919
|
-
pattern: 'Thinking|Generating|Working',
|
|
1920
|
-
status: 'running',
|
|
1921
|
-
color: '#4ade80',
|
|
1922
|
-
indicator: 'pulse',
|
|
1923
|
-
clearDelayMs: 900,
|
|
1924
|
-
});
|
|
1925
|
-
if (shouldRemove)
|
|
1926
|
-
changed = true;
|
|
1927
|
-
return !shouldRemove;
|
|
1928
|
-
})
|
|
1929
|
-
.map((config) => {
|
|
1930
|
-
if (!isSingleProgramConfig(config, 'coco'))
|
|
1931
|
-
return config;
|
|
1932
|
-
let nextRules = config.rules.filter((rule) => !isSameAgentRule(rule, deprecatedCocoConfirmRule));
|
|
1933
|
-
nextRules = nextRules.map((rule) => isSameAgentRule(rule, deprecatedCocoBroadWaitingRule) ? cocoWaitingRule : rule);
|
|
1934
|
-
if (nextRules.length === config.rules.length && nextRules.every((rule, index) => isSameAgentRule(rule, config.rules[index])))
|
|
1935
|
-
return config;
|
|
1936
|
-
changed = true;
|
|
1937
|
-
return { ...config, rules: nextRules };
|
|
1938
|
-
});
|
|
1939
|
-
return { rules: nextRules, changed };
|
|
1940
|
-
}
|
|
1941
|
-
// Runtime cache: program → compiled rules
|
|
1942
|
-
let agentRulesCache = new Map();
|
|
1943
|
-
let agentRulesVersion = 0;
|
|
1944
|
-
function normalizeAgentIndicator(value) {
|
|
1945
|
-
return typeof value === 'string' && AGENT_INDICATORS.has(value)
|
|
1946
|
-
? value
|
|
1947
|
-
: 'pulse';
|
|
1948
|
-
}
|
|
1949
|
-
function normalizeAgentClearDelay(value) {
|
|
1950
|
-
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_AGENT_CLEAR_DELAY_MS;
|
|
1951
|
-
return Math.min(MAX_AGENT_CLEAR_DELAY_MS, Math.max(MIN_AGENT_CLEAR_DELAY_MS, n));
|
|
1952
|
-
}
|
|
1953
|
-
function compileAgentRules(rules) {
|
|
1954
|
-
const map = new Map();
|
|
1955
|
-
for (const config of rules) {
|
|
1956
|
-
const compiled = config.rules.map(r => ({
|
|
1957
|
-
status: r.status,
|
|
1958
|
-
color: r.color,
|
|
1959
|
-
indicator: normalizeAgentIndicator(r.indicator),
|
|
1960
|
-
clearDelayMs: normalizeAgentClearDelay(r.clearDelayMs),
|
|
1961
|
-
regex: new RegExp(r.pattern, 'i'),
|
|
1962
|
-
}));
|
|
1963
|
-
const programNames = Array.isArray(config.programs)
|
|
1964
|
-
? config.programs
|
|
1965
|
-
: (typeof config.program === 'string' ? [config.program] : []);
|
|
1966
|
-
for (const name of programNames) {
|
|
1967
|
-
const normalized = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
|
1968
|
-
if (!normalized)
|
|
1969
|
-
continue;
|
|
1970
|
-
map.set(normalized, compiled);
|
|
1971
|
-
}
|
|
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);
|
|
1972
2056
|
}
|
|
1973
|
-
|
|
1974
|
-
agentRulesVersion++;
|
|
1975
|
-
return map;
|
|
2057
|
+
return null;
|
|
1976
2058
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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;
|
|
1985
2092
|
}
|
|
1986
|
-
return migrated.rules;
|
|
1987
2093
|
}
|
|
1988
2094
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
+
};
|
|
1998
2108
|
}
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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();
|
|
2005
2138
|
}
|
|
2006
2139
|
/**
|
|
2007
|
-
*
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
2012
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC sequences
|
|
2013
|
-
.replace(/\x1b[()][AB0-2]/g, '') // Charset
|
|
2014
|
-
.replace(/\x1b[^[\]()0-9]/g, '') // Other escapes
|
|
2015
|
-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // Control chars
|
|
2016
|
-
}
|
|
2017
|
-
// Retained for potential future re-enablement of agent status detection.
|
|
2018
|
-
void stripAnsi;
|
|
2019
|
-
/**
|
|
2020
|
-
* 基于可配置规则匹配检测 AI 工具状态
|
|
2021
|
-
* 只看实际输出文本,不受 resize/布局变化影响
|
|
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.
|
|
2022
2144
|
*/
|
|
2023
|
-
function
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
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
|
+
}
|
|
2034
2179
|
}
|
|
2035
2180
|
}
|
|
2036
|
-
return null;
|
|
2037
2181
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
function evaluateAgentStatus(sessionId, session, latestChunk) {
|
|
2045
|
-
const previousStatus = session.agentStatus;
|
|
2046
|
-
const command = session.activeProgram?.command;
|
|
2047
|
-
if (!command || !isAiToolProgram(command)) {
|
|
2048
|
-
if (previousStatus !== null) {
|
|
2049
|
-
clearAgentStatusTimer(session);
|
|
2050
|
-
session.agentStatus = null;
|
|
2051
|
-
session.agentColor = null;
|
|
2052
|
-
session.agentIndicator = null;
|
|
2053
|
-
session.agentStatusClearDelayMs = DEFAULT_AGENT_CLEAR_DELAY_MS;
|
|
2054
|
-
broadcastEvent(sessionId, { type: 'agent-status', agentStatus: null, agentColor: null, agentIndicator: null });
|
|
2055
|
-
}
|
|
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)
|
|
2056
2188
|
return;
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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 });
|
|
2070
2246
|
}
|
|
2071
2247
|
return;
|
|
2072
2248
|
}
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
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(() => { });
|
|
2086
2266
|
}
|
|
2087
|
-
|
|
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`;
|
|
2309
|
+
}
|
|
2310
|
+
// ── end Rich agent session tracking ──
|
|
2088
2311
|
/**
|
|
2089
2312
|
* Resolve the "real" program name for a tmux pane.
|
|
2090
2313
|
*
|
|
@@ -2099,10 +2322,12 @@ const DEFAULT_PROGRAM_DETECTION = {
|
|
|
2099
2322
|
genericProgramNames: ['node', 'python', 'python3', 'ruby', 'perl', 'java'],
|
|
2100
2323
|
wrapperScriptNames: ['aiden', 'ttadk', 'npx', 'yarn', 'dlx'],
|
|
2101
2324
|
shellNames: ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu'],
|
|
2325
|
+
agentCommands: {},
|
|
2102
2326
|
};
|
|
2103
2327
|
let genericProgramNames = new Set(DEFAULT_PROGRAM_DETECTION.genericProgramNames);
|
|
2104
2328
|
let wrapperScriptNames = new Set(DEFAULT_PROGRAM_DETECTION.wrapperScriptNames);
|
|
2105
2329
|
let shellNamesBackend = new Set(DEFAULT_PROGRAM_DETECTION.shellNames);
|
|
2330
|
+
let agentCommandsMap = { ...DEFAULT_PROGRAM_DETECTION.agentCommands };
|
|
2106
2331
|
async function loadProgramDetectionFromDisk() {
|
|
2107
2332
|
try {
|
|
2108
2333
|
const parsed = await readJsonFileIfExists(PROGRAM_DETECTION_FILE);
|
|
@@ -2112,6 +2337,10 @@ async function loadProgramDetectionFromDisk() {
|
|
|
2112
2337
|
genericProgramNames: Array.isArray(parsed.genericProgramNames) ? parsed.genericProgramNames : DEFAULT_PROGRAM_DETECTION.genericProgramNames,
|
|
2113
2338
|
wrapperScriptNames: Array.isArray(parsed.wrapperScriptNames) ? parsed.wrapperScriptNames : DEFAULT_PROGRAM_DETECTION.wrapperScriptNames,
|
|
2114
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
|
+
: {},
|
|
2115
2344
|
};
|
|
2116
2345
|
}
|
|
2117
2346
|
catch { /* file doesn't exist or invalid, use defaults */ }
|
|
@@ -2121,6 +2350,7 @@ function applyProgramDetectionConfig(config) {
|
|
|
2121
2350
|
genericProgramNames = new Set(config.genericProgramNames);
|
|
2122
2351
|
wrapperScriptNames = new Set(config.wrapperScriptNames);
|
|
2123
2352
|
shellNamesBackend = new Set(config.shellNames);
|
|
2353
|
+
agentCommandsMap = { ...(config.agentCommands ?? {}) };
|
|
2124
2354
|
}
|
|
2125
2355
|
async function saveProgramDetectionToDisk(config) {
|
|
2126
2356
|
await writeJsonFile(PROGRAM_DETECTION_FILE, config);
|
|
@@ -2128,6 +2358,12 @@ async function saveProgramDetectionToDisk(config) {
|
|
|
2128
2358
|
}
|
|
2129
2359
|
// Initialize on startup
|
|
2130
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 */ }
|
|
2131
2367
|
async function resolveTmuxPaneProgram(pane) {
|
|
2132
2368
|
// If pane command is a known shell, try to find a child foreground process
|
|
2133
2369
|
const command = normalizeProgramName(pane.command);
|
|
@@ -2528,6 +2764,18 @@ async function ensureManagedTmuxSessionReady(sessionName) {
|
|
|
2528
2764
|
}
|
|
2529
2765
|
await applyTmuxScrollbackProfile(sessionName);
|
|
2530
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
|
+
}
|
|
2531
2779
|
}
|
|
2532
2780
|
async function prepareManagedTmuxSession(sessionName, cwd) {
|
|
2533
2781
|
await ensureTmuxSessionExists(sessionName, cwd);
|
|
@@ -2742,12 +2990,10 @@ function cleanupSession(sessionId, options) {
|
|
|
2742
2990
|
if (!session) {
|
|
2743
2991
|
return;
|
|
2744
2992
|
}
|
|
2745
|
-
if (session.agentStatusTimer) {
|
|
2746
|
-
clearTimeout(session.agentStatusTimer);
|
|
2747
|
-
session.agentStatusTimer = null;
|
|
2748
|
-
}
|
|
2749
2993
|
session.dataDisposable?.dispose();
|
|
2750
2994
|
session.exitDisposable?.dispose();
|
|
2995
|
+
lastAgentStatusSnapshots.delete(sessionId);
|
|
2996
|
+
lastGitStatusSnapshots.delete(sessionId);
|
|
2751
2997
|
destroyTmuxControl(session.tmuxControl);
|
|
2752
2998
|
session.tmuxControl = undefined;
|
|
2753
2999
|
for (const timer of session.flowPausedClientTimers.values()) {
|
|
@@ -3000,73 +3246,88 @@ function updateFocusTrackingFromOutput(sessionId, session, data) {
|
|
|
3000
3246
|
emitFocusSequenceIfNeeded(sessionId, session, true, 'focus-mode-enabled');
|
|
3001
3247
|
}
|
|
3002
3248
|
}
|
|
3003
|
-
|
|
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) {
|
|
3004
3256
|
const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
|
|
3005
|
-
|
|
3257
|
+
if (opts.broadcast) {
|
|
3006
3258
|
session.lastActivity = Date.now();
|
|
3259
|
+
session.lastOutputAt = Date.now();
|
|
3007
3260
|
session.hasWrittenData = true;
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
}
|
|
3035
|
-
// Title change — broadcast so the frontend can update tab/sidebar
|
|
3036
|
-
if (result.title !== null && result.title !== session.lastOscTitle) {
|
|
3037
|
-
session.lastOscTitle = result.title;
|
|
3038
|
-
broadcastJsonWs(sessionId, { type: 'shell-title', title: result.title });
|
|
3039
|
-
}
|
|
3040
|
-
// Prompt state change (OSC 133)
|
|
3041
|
-
if (result.promptState !== null && result.promptState !== session.lastPromptState) {
|
|
3042
|
-
session.lastPromptState = result.promptState;
|
|
3043
|
-
broadcastJsonWs(sessionId, {
|
|
3044
|
-
type: 'prompt-state',
|
|
3045
|
-
state: result.promptState,
|
|
3046
|
-
exitCode: result.exitCode,
|
|
3047
|
-
});
|
|
3261
|
+
scheduleActivityReorder();
|
|
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();
|
|
3048
3287
|
}
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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);
|
|
3055
3308
|
}
|
|
3056
3309
|
}
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
//
|
|
3065
|
-
//
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
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) {
|
|
3069
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 });
|
|
3070
3331
|
});
|
|
3071
3332
|
session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
3072
3333
|
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
@@ -3088,6 +3349,17 @@ function wrapPtySpawnError(error) {
|
|
|
3088
3349
|
}
|
|
3089
3350
|
return error;
|
|
3090
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
|
+
}
|
|
3091
3363
|
async function spawnTerminalSession(req, input) {
|
|
3092
3364
|
const cwd = await resolveWorkingDirectory(req, input.cwd);
|
|
3093
3365
|
const cols = input.cols || 80;
|
|
@@ -3115,12 +3387,20 @@ async function spawnTerminalSession(req, input) {
|
|
|
3115
3387
|
: (process.platform === 'win32' ? buildPowerShellCwdHookArgs() : []);
|
|
3116
3388
|
const envPath = buildAugmentedPath();
|
|
3117
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;
|
|
3118
3395
|
const pty = await getPtyProvider();
|
|
3119
3396
|
const termValue = resolveTerminalTermType(input.termType);
|
|
3120
3397
|
const baseEnv = {
|
|
3121
3398
|
...resolvedEnv,
|
|
3122
3399
|
TERM: termValue,
|
|
3123
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',
|
|
3124
3404
|
};
|
|
3125
3405
|
let ptyProcess = null;
|
|
3126
3406
|
if (mode === 'shell' && process.platform !== 'win32') {
|
|
@@ -3129,13 +3409,28 @@ async function spawnTerminalSession(req, input) {
|
|
|
3129
3409
|
for (const shellCandidate of shellCandidates) {
|
|
3130
3410
|
try {
|
|
3131
3411
|
const env = await injectShellIntegration(shellCandidate, baseEnv);
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
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
|
+
}
|
|
3139
3434
|
break;
|
|
3140
3435
|
}
|
|
3141
3436
|
catch (error) {
|
|
@@ -3172,28 +3467,28 @@ async function spawnTerminalSession(req, input) {
|
|
|
3172
3467
|
}
|
|
3173
3468
|
const session = {
|
|
3174
3469
|
ptyProcess,
|
|
3175
|
-
ptyBackend: pty.backend,
|
|
3470
|
+
ptyBackend: mode === 'shell' && ptyHostShellModeEnabled() ? 'pty-host' : pty.backend,
|
|
3176
3471
|
cwd,
|
|
3177
3472
|
mode,
|
|
3178
3473
|
tmuxSessionName,
|
|
3179
3474
|
cols,
|
|
3180
3475
|
rows,
|
|
3181
3476
|
lastActivity: Date.now(),
|
|
3477
|
+
lastOutputAt: Date.now(),
|
|
3182
3478
|
clients: new Map(),
|
|
3183
3479
|
createdAt: Date.now(),
|
|
3184
3480
|
hasWrittenData: false,
|
|
3185
3481
|
activeProgram: null,
|
|
3482
|
+
agent: null,
|
|
3483
|
+
agentSession: null,
|
|
3484
|
+
gitStatus: null,
|
|
3485
|
+
gitStatusKey: null,
|
|
3486
|
+
gitAgentActivitySeen: 0,
|
|
3186
3487
|
oscSniffBuf: '',
|
|
3187
3488
|
lastOscCwd: null,
|
|
3188
3489
|
lastOscTitle: null,
|
|
3189
3490
|
lastPromptState: null,
|
|
3190
3491
|
tuiProgress: null,
|
|
3191
|
-
agentStatus: null,
|
|
3192
|
-
agentColor: null,
|
|
3193
|
-
agentIndicator: null,
|
|
3194
|
-
agentStatusBuf: '',
|
|
3195
|
-
agentStatusTimer: null,
|
|
3196
|
-
agentStatusClearDelayMs: DEFAULT_AGENT_CLEAR_DELAY_MS,
|
|
3197
3492
|
focusTrackingRequested: false,
|
|
3198
3493
|
focusModeSniffBuf: '',
|
|
3199
3494
|
focusAggregation: {
|
|
@@ -3205,6 +3500,8 @@ async function spawnTerminalSession(req, input) {
|
|
|
3205
3500
|
ptyPausedForFlowControl: false,
|
|
3206
3501
|
};
|
|
3207
3502
|
terminalSessions.set(sessionId, session);
|
|
3503
|
+
// First git snapshot for the pane's spawn cwd (throttle-free).
|
|
3504
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
3208
3505
|
setupPtyHandlers(sessionId, session);
|
|
3209
3506
|
if (mode === 'tmux' && tmuxSessionName) {
|
|
3210
3507
|
// Session was prepared before attach so the first tmux client sees the
|
|
@@ -3229,6 +3526,88 @@ async function spawnTerminalSession(req, input) {
|
|
|
3229
3526
|
}
|
|
3230
3527
|
return { sessionId, session, cols, rows };
|
|
3231
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
|
+
}
|
|
3232
3611
|
function detectShellType(shellPath) {
|
|
3233
3612
|
const base = shellPath.split('/').pop()?.toLowerCase() || '';
|
|
3234
3613
|
if (base.includes('bash'))
|
|
@@ -3547,6 +3926,7 @@ router.get('/client-state', async (_req, res) => {
|
|
|
3547
3926
|
});
|
|
3548
3927
|
router.get('/session-inventory', async (_req, res) => {
|
|
3549
3928
|
try {
|
|
3929
|
+
applyActivityBasedReorder();
|
|
3550
3930
|
const inventory = await getSessionInventorySnapshot();
|
|
3551
3931
|
res.json(inventory);
|
|
3552
3932
|
}
|
|
@@ -3723,6 +4103,28 @@ async function getSettingsPayload() {
|
|
|
3723
4103
|
},
|
|
3724
4104
|
};
|
|
3725
4105
|
}
|
|
4106
|
+
// ── Activity reorder preference ──────────────────────────────────────
|
|
4107
|
+
router.get('/settings/activity-reorder', (_req, res) => {
|
|
4108
|
+
res.json({ enabled: activityReorderEnabled });
|
|
4109
|
+
});
|
|
4110
|
+
router.put('/settings/activity-reorder', (req, res) => {
|
|
4111
|
+
const body = req.body ?? {};
|
|
4112
|
+
if (typeof body.enabled === 'boolean') {
|
|
4113
|
+
activityReorderEnabled = body.enabled;
|
|
4114
|
+
// 关闭后立刻停掉还在队列里的 reorder timer
|
|
4115
|
+
if (!activityReorderEnabled && activityReorderTimer) {
|
|
4116
|
+
clearTimeout(activityReorderTimer);
|
|
4117
|
+
activityReorderTimer = null;
|
|
4118
|
+
}
|
|
4119
|
+
// 如果重新开启,立刻执行一次排序
|
|
4120
|
+
if (activityReorderEnabled) {
|
|
4121
|
+
applyActivityBasedReorder();
|
|
4122
|
+
}
|
|
4123
|
+
res.json({ enabled: activityReorderEnabled });
|
|
4124
|
+
return;
|
|
4125
|
+
}
|
|
4126
|
+
res.status(400).json({ error: '"enabled" must be a boolean' });
|
|
4127
|
+
});
|
|
3726
4128
|
// ── Settings (prevent sleep) ──────────────────────────────────────────
|
|
3727
4129
|
router.get('/settings', async (_req, res) => {
|
|
3728
4130
|
res.json(await getSettingsPayload());
|
|
@@ -3752,69 +4154,31 @@ router.put('/settings', async (req, res) => {
|
|
|
3752
4154
|
}
|
|
3753
4155
|
res.json(await getSettingsPayload());
|
|
3754
4156
|
});
|
|
3755
|
-
// ── Agent
|
|
3756
|
-
router.get('/agent-
|
|
3757
|
-
res.json(
|
|
4157
|
+
// ── Agent hooks API (rich status channel installers) ──
|
|
4158
|
+
router.get('/agent-hooks', (_req, res) => {
|
|
4159
|
+
res.json({ agents: listHookAgents() });
|
|
3758
4160
|
});
|
|
3759
|
-
router.
|
|
3760
|
-
const
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
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' });
|
|
3764
4166
|
}
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
const hasProgram = typeof config.program === 'string' && config.program.trim().length > 0;
|
|
3768
|
-
const hasPrograms = Array.isArray(config.programs) && config.programs.some((name) => typeof name === 'string' && name.trim().length > 0);
|
|
3769
|
-
if ((!hasProgram && !hasPrograms) || !Array.isArray(config.rules)) {
|
|
3770
|
-
res.status(400).json({ error: 'Each config must have program/programs and rules' });
|
|
3771
|
-
return;
|
|
3772
|
-
}
|
|
3773
|
-
for (const rule of config.rules) {
|
|
3774
|
-
if (!rule.pattern || !rule.status) {
|
|
3775
|
-
res.status(400).json({ error: 'Each rule must have pattern and status' });
|
|
3776
|
-
return;
|
|
3777
|
-
}
|
|
3778
|
-
// Validate regex
|
|
3779
|
-
try {
|
|
3780
|
-
new RegExp(rule.pattern, 'i');
|
|
3781
|
-
}
|
|
3782
|
-
catch {
|
|
3783
|
-
res.status(400).json({ error: `Invalid regex: ${rule.pattern}` });
|
|
3784
|
-
return;
|
|
3785
|
-
}
|
|
3786
|
-
if (rule.indicator !== undefined && !AGENT_INDICATORS.has(rule.indicator)) {
|
|
3787
|
-
res.status(400).json({ error: `Invalid indicator: ${rule.indicator}` });
|
|
3788
|
-
return;
|
|
3789
|
-
}
|
|
3790
|
-
if (rule.clearDelayMs !== undefined) {
|
|
3791
|
-
const delay = Number(rule.clearDelayMs);
|
|
3792
|
-
if (!Number.isFinite(delay) || delay < MIN_AGENT_CLEAR_DELAY_MS || delay > MAX_AGENT_CLEAR_DELAY_MS) {
|
|
3793
|
-
res.status(400).json({ error: `clearDelayMs must be ${MIN_AGENT_CLEAR_DELAY_MS}-${MAX_AGENT_CLEAR_DELAY_MS}` });
|
|
3794
|
-
return;
|
|
3795
|
-
}
|
|
3796
|
-
}
|
|
3797
|
-
}
|
|
4167
|
+
catch (error) {
|
|
4168
|
+
res.status(400).json({ error: error.message });
|
|
3798
4169
|
}
|
|
3799
|
-
await saveAgentRulesToDisk(rules);
|
|
3800
|
-
broadcastControlEvent({
|
|
3801
|
-
type: 'config-updated',
|
|
3802
|
-
key: 'agent-rules',
|
|
3803
|
-
updatedAt: Date.now(),
|
|
3804
|
-
});
|
|
3805
|
-
res.json(rules);
|
|
3806
4170
|
});
|
|
3807
|
-
router.
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
}
|
|
3816
|
-
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
|
+
}
|
|
3817
4180
|
});
|
|
4181
|
+
// ── end Agent hooks API ──
|
|
3818
4182
|
// ── Program detection config API ──
|
|
3819
4183
|
router.get('/program-detection', async (_req, res) => {
|
|
3820
4184
|
res.json(await loadProgramDetectionFromDisk());
|
|
@@ -3953,14 +4317,23 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
3953
4317
|
mode: session.mode,
|
|
3954
4318
|
tmuxSessionName: session.tmuxSessionName,
|
|
3955
4319
|
cwd: session.cwd ?? null,
|
|
3956
|
-
activeProgram: session.activeProgram?.command ??
|
|
4320
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
|
|
3957
4321
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
3958
4322
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
3959
|
-
agentStatus: session.agentStatus,
|
|
3960
|
-
agentColor: session.agentColor,
|
|
3961
|
-
agentIndicator: session.agentIndicator,
|
|
3962
4323
|
tuiProgress: session.tuiProgress,
|
|
4324
|
+
// 标题/prompt 状态只在「变化」时广播,且去重基准在 server 端——刷新后的
|
|
4325
|
+
// 新客户端不会收到重发。连接即推当前值,否则 tab 名塌回默认直到下次变化。
|
|
4326
|
+
shellTitle: session.lastOscTitle,
|
|
4327
|
+
promptState: session.lastPromptState,
|
|
3963
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
|
+
}
|
|
3964
4337
|
let tmuxInterval = null;
|
|
3965
4338
|
let activeProgramInterval = null;
|
|
3966
4339
|
let lastTmuxLayoutSnapshot = '';
|
|
@@ -3974,8 +4347,9 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
3974
4347
|
}
|
|
3975
4348
|
lastActiveProgramSnapshot = snapshot;
|
|
3976
4349
|
session.activeProgram = activeProgram;
|
|
4350
|
+
persistActiveProgramBinding(sessionId, activeProgram?.command);
|
|
3977
4351
|
// Agent status: react to AI tool start/exit
|
|
3978
|
-
|
|
4352
|
+
syncAgentIdentity(sessionId, session);
|
|
3979
4353
|
console.log(`[active-program][shell-sse] session=${sessionId} client=${clientId} cmd=${activeProgram?.command ?? null} source=${activeProgram?.source ?? null}`);
|
|
3980
4354
|
writeSse(res, {
|
|
3981
4355
|
type: 'active-program',
|
|
@@ -4017,6 +4391,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4017
4391
|
schedulePersistGlobalState();
|
|
4018
4392
|
}
|
|
4019
4393
|
writeSse(res, { type: 'cwd', cwd: newCwd });
|
|
4394
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
4020
4395
|
}
|
|
4021
4396
|
// tmux 消费了 inner shell 发的 OSC 2/133,不透传到外层 PTY。
|
|
4022
4397
|
// 从 tmux layout 提取 active pane 的 title 和 command 来推导。
|
|
@@ -4029,8 +4404,12 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
4029
4404
|
const paneCmd = activePane.command || '';
|
|
4030
4405
|
const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
|
|
4031
4406
|
if (inferredState !== session.lastPromptState) {
|
|
4407
|
+
const wasRunning = session.lastPromptState === 'running';
|
|
4032
4408
|
session.lastPromptState = inferredState;
|
|
4033
4409
|
writeSse(res, { type: 'prompt-state', state: inferredState });
|
|
4410
|
+
if (wasRunning && inferredState === 'idle') {
|
|
4411
|
+
refreshGitStatus(sessionId, session);
|
|
4412
|
+
}
|
|
4034
4413
|
}
|
|
4035
4414
|
}
|
|
4036
4415
|
// Mirror dynamic metadata onto tmux user options (cheap when nothing
|
|
@@ -4177,6 +4556,48 @@ router.post('/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|
|
4177
4556
|
res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
|
|
4178
4557
|
}
|
|
4179
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
|
+
});
|
|
4180
4601
|
router.post('/:sessionId/resize', (req, res) => {
|
|
4181
4602
|
const { sessionId } = req.params;
|
|
4182
4603
|
const session = terminalSessions.get(sessionId);
|
|
@@ -4566,13 +4987,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4566
4987
|
mode: session.mode,
|
|
4567
4988
|
tmuxSessionName: session.tmuxSessionName,
|
|
4568
4989
|
cwd: session.cwd ?? null,
|
|
4569
|
-
activeProgram: session.activeProgram?.command ??
|
|
4990
|
+
activeProgram: session.activeProgram?.command ?? getPersistedActiveProgramForBackend(sessionId),
|
|
4570
4991
|
activeProgramRaw: session.activeProgram?.rawArgs ?? null,
|
|
4571
4992
|
activeProgramSource: session.activeProgram?.source ?? null,
|
|
4572
|
-
agentStatus: session.agentStatus,
|
|
4573
|
-
agentColor: session.agentColor,
|
|
4574
|
-
agentIndicator: session.agentIndicator,
|
|
4575
4993
|
tuiProgress: session.tuiProgress,
|
|
4994
|
+
shellTitle: session.lastOscTitle,
|
|
4995
|
+
promptState: session.lastPromptState,
|
|
4576
4996
|
focusTrackingRequested: session.focusTrackingRequested,
|
|
4577
4997
|
// 短线重连补帧:
|
|
4578
4998
|
// replayChunks 为补发数据;replayLastSeq 是客户端应记录的新基线;
|
|
@@ -4581,6 +5001,13 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4581
5001
|
replayLastSeq,
|
|
4582
5002
|
replayOutOfWindow,
|
|
4583
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
|
+
}
|
|
4584
5011
|
})();
|
|
4585
5012
|
// Tmux layout polling (per-client, like the SSE stream does)
|
|
4586
5013
|
let tmuxInterval = null;
|
|
@@ -4614,8 +5041,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4614
5041
|
if (apSnapshot !== lastActiveProgramSnapshot) {
|
|
4615
5042
|
lastActiveProgramSnapshot = apSnapshot;
|
|
4616
5043
|
session.activeProgram = ap;
|
|
5044
|
+
persistActiveProgramBinding(sessionId, ap?.command);
|
|
4617
5045
|
// Agent status: react to AI tool start/exit
|
|
4618
|
-
|
|
5046
|
+
syncAgentIdentity(sessionId, session);
|
|
4619
5047
|
console.log(`[active-program][ws] session=${sessionId} cmd=${ap?.command ?? null} source=${ap?.source ?? null}`);
|
|
4620
5048
|
ws.send(JSON.stringify({
|
|
4621
5049
|
type: 'active-program',
|
|
@@ -4633,6 +5061,7 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4633
5061
|
schedulePersistGlobalState();
|
|
4634
5062
|
}
|
|
4635
5063
|
ws.send(JSON.stringify({ type: 'cwd', cwd: newCwd }));
|
|
5064
|
+
refreshGitStatus(sessionId, session, { minIntervalMs: 0 });
|
|
4636
5065
|
}
|
|
4637
5066
|
// tmux 消费了 inner shell 发的 OSC 2(存入 pane_title)和 OSC 133,
|
|
4638
5067
|
// 不透传到外层 PTY。因此从 tmux layout 提取 active pane 的 title
|
|
@@ -4647,8 +5076,12 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4647
5076
|
const paneCmd = activePane.command || '';
|
|
4648
5077
|
const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
|
|
4649
5078
|
if (inferredState !== session.lastPromptState) {
|
|
5079
|
+
const wasRunning = session.lastPromptState === 'running';
|
|
4650
5080
|
session.lastPromptState = inferredState;
|
|
4651
5081
|
ws.send(JSON.stringify({ type: 'prompt-state', state: inferredState }));
|
|
5082
|
+
if (wasRunning && inferredState === 'idle') {
|
|
5083
|
+
refreshGitStatus(sessionId, session);
|
|
5084
|
+
}
|
|
4652
5085
|
}
|
|
4653
5086
|
}
|
|
4654
5087
|
// Mirror dynamic metadata onto tmux user options.
|
|
@@ -4684,8 +5117,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
|
|
|
4684
5117
|
if (snapshot !== lastApSnapshot) {
|
|
4685
5118
|
lastApSnapshot = snapshot;
|
|
4686
5119
|
session.activeProgram = ap;
|
|
5120
|
+
persistActiveProgramBinding(sessionId, ap?.command);
|
|
4687
5121
|
// Agent status: react to AI tool start/exit
|
|
4688
|
-
|
|
5122
|
+
syncAgentIdentity(sessionId, session);
|
|
4689
5123
|
ws.send(JSON.stringify({
|
|
4690
5124
|
type: 'active-program',
|
|
4691
5125
|
activeProgram: ap?.command ?? null,
|