termdock 1.4.43 → 1.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.desktop-dist/main.js +95 -27
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-B3X7vTL7.js → abnfDiagram-VRR7QNED-In9dJ8xk.js} +1 -1
  3. package/dist/client/assets/{arc-OmYbmu75.js → arc-OgDXNUfC.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CNNIlew_.js → architectureDiagram-ZJ3FMSHR-CUHWXG-6.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-eoDvsGst.js → blockDiagram-677ZJIJ3-D6iMCZrE.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BYElS7nn.js → c4Diagram-LMCZKHZV-BQIYSHIO.js} +1 -1
  7. package/dist/client/assets/channel-DR9ZrrsH.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-DgBhC1KN.js → chunk-2Q5K7J3B-VrJ2oEjG.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-B4LjEHPP.js → chunk-32BRIVSS-DgE4tlwU.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-BrBK8Xy3.js → chunk-5VM5RSS4-DPL1VUbd.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-QndtZFQ6.js → chunk-EX3LRPZG-Cnzcl0zo.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-B8bZHm4g.js → chunk-JWPE2WC7-BwjoHnSm.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-B5Kzsd5Z.js → chunk-MOJQB5TN-Q1VspcHf.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-BdNa9gwg.js → chunk-RYQCIY6F-C3B0hROO.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-DpQA4l-k.js → chunk-V7JOEXUC-CT1WyL44.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-CtGdy3K8.js → chunk-VR4S4FIN-RHg32W0I.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-CZBBj4P_.js → chunk-XXDRQBXY-B8nloyVH.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-CSyi0dnm.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CSyi0dnm.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-gtzwCMyn.js → cose-bilkent-JH36ORCC-DM9b-HC2.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-B7X5gKj-.js → cynefin-VYW2F7L2-DyfTbIta.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BoKfaePi.js → cynefinDiagram-TSTJHNR4-B0TfoSKo.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-Bjh8y-RO.js → dagre-VKFMJZFB-DoYxgTE_.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-Cx8vWSw0.js → diagram-FQU43EPY-BhdYoO5s.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-DyKuyGsn.js → diagram-G47NLZAW-DU-PG20h.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-DwnG0pD9.js → diagram-NH7WQ7WH-WcnJXUD6.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-wn7Eux1Q.js → diagram-OA4YK3LP-N1S0AWUf.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-DNy0FovQ.js → diagram-WEI45ONY-DBhEABdQ.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-TFnZlH4k.js → ebnfDiagram-CCIWWBDH-CPhAEOjW.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-Cx3_8T9Q.js → erDiagram-Q63AITRT-416GnX06.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-CZ7KiBe4.js → flowDiagram-23GEKE2U-CQY7Be4e.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-BRTRytgo.js → ganttDiagram-NO4QXBWP-CCdUVGY1.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DyNUwP6-.js → gitGraphDiagram-IHSO6WYX-DihTneNH.js} +1 -1
  34. package/dist/client/assets/index-DyTRm1PJ.css +1 -0
  35. package/dist/client/assets/index-SiGQTF6y.js +436 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-ClLiuIL8.js → infoDiagram-FWYZ7A6U-DxVtT8XS.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CbO_ecRd.js → ishikawaDiagram-FXEZZL3T-CI04eDRq.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-YKjq3svJ.js → journeyDiagram-5HDEW3XC-Dku9Ke-J.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-CcnV3jvx.js → kanban-definition-HUTT4EX6-X-7cHaWp.js} +1 -1
  40. package/dist/client/assets/{linear-3N9evV-e.js → linear-BfhGmqE2.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-C8HeTU54.js → mermaid.core-lwiCvpBd.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CgkCCxMO.js → mindmap-definition-LN4V7U3C-DI3swXAM.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-CQRK4LBF.js → pegDiagram-2B236MQR-ByBkCa7g.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-BHrDXKeR.js → pieDiagram-ENE6RG2P-CAbocCk6.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CuJ9UyOO.js → quadrantDiagram-ABIIQ3AL-lE4NNIct.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-D0lUBYT1.js → railroadDiagram-RFXS5EU6-DVG7K2hA.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-s5kYB_Ya.js → requirementDiagram-TGXJPOKE-BhjZccsb.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-Bhw1mAOH.js → sankeyDiagram-HTMAVEWB-BROBV7xf.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-qnNrmb6g.js → sequenceDiagram-DBY2YBRQ-DCiUXSQK.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DufN5SxK.js → sizeCapture-X5ZJPWSS-wCRp1dLA.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-Y5rMnVoS.js → stateDiagram-2N3HPSRC-GnItAgu1.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DodZ2p7d.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-Cmrmdfpk.js → swimlanes-5IMT3BWC-DbXlO1IP.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-Dh3DdtaE.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-CqHH1NSC.js → timeline-definition-FHXFAJF6-CQjgTVIW.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-C2l_9LuC.js → vennDiagram-L72KCM5P-Bz_Lzf5a.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-CNf23qHc.js → wardleyDiagram-EHGQE667-Cgw3S7By.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-RIp6RoVE.js → xychartDiagram-FW5EYKEG-IyZJ_VfA.js} +1 -1
  59. package/dist/client/icons/agents/kimi.svg +5 -0
  60. package/dist/client/index.html +3 -3
  61. package/dist/client/sw-notifications.js +163 -36
  62. package/dist/client/sw.js +1 -1
  63. package/dist/server/agent/installers.js +145 -1
  64. package/dist/server/agent/registry.js +3 -0
  65. package/dist/server/cli.js +25 -0
  66. package/dist/server/entry.js +53 -13
  67. package/dist/server/notifications/pushService.js +121 -35
  68. package/dist/server/notifications/pushViewers.js +44 -0
  69. package/dist/server/ptyHost.js +17 -0
  70. package/dist/server/ptyhost/manager.js +152 -5
  71. package/dist/server/quota/tokenDiscovery.js +25 -0
  72. package/dist/server/routes/filesystem.js +4 -2
  73. package/dist/server/routes/notifications.js +9 -3
  74. package/dist/server/routes/terminal.js +82 -106
  75. package/dist/server/utils/contextDraft.js +36 -0
  76. package/dist/server/utils/requestDeadline.js +104 -0
  77. package/dist/server/utils/runtimeClient.js +41 -0
  78. package/dist/server/utils/settings.js +18 -9
  79. package/package.json +5 -2
  80. package/runtime-manifest.json +12 -0
  81. package/scripts/classify-release.mjs +88 -0
  82. package/scripts/generate-runtime-manifest.mjs +77 -0
  83. package/scripts/prepare-desktop-runtime.mjs +27 -3
  84. package/dist/client/assets/channel-DBZQbHSi.js +0 -1
  85. package/dist/client/assets/classDiagram-OUVF2IWQ-BsrNzS08.js +0 -1
  86. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BsrNzS08.js +0 -1
  87. package/dist/client/assets/index-CbYQuue6.css +0 -1
  88. package/dist/client/assets/index-DB-0aiWT.js +0 -433
  89. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BSqYeylv.js +0 -1
  90. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CUwNpVXZ.js +0 -8
@@ -14,7 +14,8 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
14
14
  import { pathValidator } from '../utils/pathValidator.js';
15
15
  import { TERMINAL, TMUX } from '../config.js';
16
16
  import { localAccessManager } from '../utils/localAccess.js';
17
- import { normalizeLocalAccessName, getActivityReorderSetting, getLocaleSetting, setActivityReorderSetting, setLocaleSetting, } from '../utils/settings.js';
17
+ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, } from '../utils/settings.js';
18
+ import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
18
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
19
20
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
20
21
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
@@ -23,7 +24,8 @@ import { applyAgentEvent, defaultAgentSessionState, parseAgentEvent, } from '../
23
24
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
24
25
  import { loadPlugins, savePlugin, removePlugin, readPluginIcon, } from '../agent/plugins.js';
25
26
  import { registerPluginAgents } from '../agent/registry.js';
26
- import { notifyAgentTransition } from '../notifications/pushService.js';
27
+ import { notifyAgentTransition, notifyTerminalExit } from '../notifications/pushService.js';
28
+ import { setClientViewingSession } from '../notifications/pushViewers.js';
27
29
  import { getQuotaStatus, refreshQuota, startQuotaManager, } from '../quota/QuotaManager.js';
28
30
  const router = express.Router();
29
31
  const execFileAsync = promisify(execFile);
@@ -106,81 +108,6 @@ let lastBroadcastClientStateSignature = null;
106
108
  const inventoryOpenLocks = new Map();
107
109
  const CONTROL_BROADCAST_COALESCE_MS = 50;
108
110
  const SESSION_INVENTORY_CACHE_TTL_MS = 1500;
109
- // 最近活跃优先排序:不是竞速榜,只分「最近活跃」和「非活跃」两组。
110
- // 活跃组整体在前,组内保持当前相对顺序,避免两个持续输出的 session
111
- // 按毫秒级 lastActivity 来回抢第一个位置。
112
- const RECENT_ACTIVITY_WINDOW_MS = 60_000;
113
- const ACTIVITY_REORDER_COOLDOWN_MS = 5_000;
114
- const ACTIVITY_REORDER_PERIODIC_MS = 30_000;
115
- let activityReorderTimer = null;
116
- let activityReorderPeriodicTimer = null;
117
- let activityReorderEnabled = getActivityReorderSetting();
118
- function getEffectiveLastActivity(session) {
119
- // 只用 PTY 输出时间判断「最近活跃」——resize / input / connect
120
- // 不参与排序,避免拖窗口尺寸等非输出操作导致 session 跳到前面。
121
- if (session.backendSessionId) {
122
- const backend = terminalSessions.get(session.backendSessionId);
123
- if (backend)
124
- return backend.lastOutputAt;
125
- }
126
- return session.lastActivity;
127
- }
128
- function applyActivityBasedReorder() {
129
- if (!activityReorderEnabled)
130
- return;
131
- const sessions = globalSessionState.sessions;
132
- if (sessions.length <= 1)
133
- return;
134
- const now = Date.now();
135
- const recentlyActive = [];
136
- const inactive = [];
137
- for (const session of sessions) {
138
- const la = getEffectiveLastActivity(session);
139
- if (now - la <= RECENT_ACTIVITY_WINDOW_MS) {
140
- recentlyActive.push(session);
141
- }
142
- else {
143
- inactive.push(session);
144
- }
145
- }
146
- // 两个 bucket 内各自保持原有相对顺序,只是活跃组整体提前
147
- const reordered = [...recentlyActive, ...inactive];
148
- const orderChanged = sessions.map(s => s.sessionId).join('\x00') !== reordered.map(s => s.sessionId).join('\x00');
149
- if (!orderChanged)
150
- return;
151
- globalSessionState = {
152
- sessions: reordered,
153
- updatedAt: Date.now(),
154
- };
155
- persistAndBroadcastGlobalState();
156
- }
157
- function scheduleActivityReorder() {
158
- if (activityReorderTimer)
159
- return;
160
- activityReorderTimer = setTimeout(() => {
161
- activityReorderTimer = null;
162
- applyActivityBasedReorder();
163
- }, ACTIVITY_REORDER_COOLDOWN_MS);
164
- activityReorderTimer.unref?.();
165
- }
166
- function startActivityReorderPeriodic() {
167
- if (activityReorderPeriodicTimer)
168
- return;
169
- activityReorderPeriodicTimer = setInterval(() => {
170
- applyActivityBasedReorder();
171
- }, ACTIVITY_REORDER_PERIODIC_MS);
172
- activityReorderPeriodicTimer.unref?.();
173
- }
174
- function stopActivityReorderPeriodic() {
175
- if (activityReorderPeriodicTimer) {
176
- clearInterval(activityReorderPeriodicTimer);
177
- activityReorderPeriodicTimer = null;
178
- }
179
- if (activityReorderTimer) {
180
- clearTimeout(activityReorderTimer);
181
- activityReorderTimer = null;
182
- }
183
- }
184
111
  async function getSessionInventorySnapshot(options = {}) {
185
112
  const now = Date.now();
186
113
  if (!options.refresh && latestSessionInventory && now - latestSessionInventoryAt < SESSION_INVENTORY_CACHE_TTL_MS) {
@@ -462,8 +389,8 @@ function flushPersistAndExit() {
462
389
  }
463
390
  catch { /* best effort */ }
464
391
  }
465
- process.on('SIGTERM', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
466
- process.on('SIGINT', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
392
+ process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
393
+ process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
467
394
  // 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
468
395
  void (async () => {
469
396
  await loadGlobalSessionStateFromDisk();
@@ -476,8 +403,6 @@ void (async () => {
476
403
  });
477
404
  pruneOrphanSessions();
478
405
  await backfillPersistedTmuxMetadata();
479
- startActivityReorderPeriodic();
480
- applyActivityBasedReorder();
481
406
  })();
482
407
  caffeinateManager.startNetworkMonitor();
483
408
  // 清理磁盘恢复后后端已不存在的 session 引用。
@@ -2777,10 +2702,26 @@ async function enableTmuxFocusEvents() {
2777
2702
  await runTmux(['set-option', '-g', 'focus-events', 'on']);
2778
2703
  console.log('[tmux-focus] enabled global focus-events');
2779
2704
  }
2705
+ // Let tmux forward modified Enter keys (Ctrl/Shift/Alt+Enter) to pane apps.
2706
+ // The option only exists in tmux >= 3.2; on older versions the probe fails
2707
+ // and we silently skip.
2708
+ async function enableTmuxExtendedKeys() {
2709
+ try {
2710
+ const current = (await runTmux(['show-options', '-gqv', 'extended-keys'])).trim();
2711
+ if (current === 'on')
2712
+ return;
2713
+ await runTmux(['set-option', '-g', 'extended-keys', 'on']);
2714
+ console.log('[tmux] enabled global extended-keys');
2715
+ }
2716
+ catch {
2717
+ // extended-keys unsupported on this tmux; nothing to do
2718
+ }
2719
+ }
2780
2720
  async function ensureSharedTmuxServerReady() {
2781
2721
  await ensureTmuxColorEnvironment();
2782
2722
  await applyTmuxScrollbackProfile();
2783
2723
  await enableTmuxFocusEvents();
2724
+ await enableTmuxExtendedKeys();
2784
2725
  await configureTmuxWheelBindings();
2785
2726
  }
2786
2727
  async function stampTmuxMetadata(sessionName) {
@@ -3310,7 +3251,6 @@ function processSessionOutput(sessionId, session, data, opts) {
3310
3251
  session.lastActivity = Date.now();
3311
3252
  session.lastOutputAt = Date.now();
3312
3253
  session.hasWrittenData = true;
3313
- scheduleActivityReorder();
3314
3254
  }
3315
3255
  let seq;
3316
3256
  if (session.mode === 'shell') {
@@ -3384,6 +3324,17 @@ function setupPtyHandlers(sessionId, session) {
3384
3324
  session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
3385
3325
  console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
3386
3326
  broadcastEvent(sessionId, { type: 'exit', exitCode, signal });
3327
+ // User-initiated closes dispose this listener before killing the pty, so
3328
+ // reaching here means the process exited on its own. Sessions with a live
3329
+ // agent already get their exit covered by notifyAgentTransition — skip
3330
+ // those to avoid two notifications for one event.
3331
+ const snapshot = lastAgentStatusSnapshots.get(sessionId);
3332
+ const hasLiveAgent = snapshot
3333
+ ? JSON.parse(snapshot).agentStatus !== null
3334
+ : false;
3335
+ if (!hasLiveAgent) {
3336
+ notifyTerminalExit(sessionId, typeof exitCode === 'number' ? exitCode : null);
3337
+ }
3387
3338
  cleanupSession(sessionId, { killProcess: false });
3388
3339
  });
3389
3340
  }
@@ -3982,7 +3933,6 @@ router.get('/client-state', async (_req, res) => {
3982
3933
  });
3983
3934
  router.get('/session-inventory', async (_req, res) => {
3984
3935
  try {
3985
- applyActivityBasedReorder();
3986
3936
  const inventory = await getSessionInventorySnapshot();
3987
3937
  res.json(inventory);
3988
3938
  }
@@ -4153,6 +4103,7 @@ async function getSettingsPayload() {
4153
4103
  caffeinateActive: caffeinateManager.isActive(),
4154
4104
  networkAvailable: caffeinateManager.isNetworkAvailable(),
4155
4105
  locale: getLocaleSetting(),
4106
+ contextDraftHeight: getContextDraftHeightSetting(),
4156
4107
  localAccess: {
4157
4108
  ...localAccess,
4158
4109
  interfaces,
@@ -4160,29 +4111,6 @@ async function getSettingsPayload() {
4160
4111
  },
4161
4112
  };
4162
4113
  }
4163
- // ── Activity reorder preference ──────────────────────────────────────
4164
- router.get('/settings/activity-reorder', (_req, res) => {
4165
- res.json({ enabled: activityReorderEnabled });
4166
- });
4167
- router.put('/settings/activity-reorder', (req, res) => {
4168
- const body = req.body ?? {};
4169
- if (typeof body.enabled === 'boolean') {
4170
- setActivityReorderSetting(body.enabled);
4171
- activityReorderEnabled = body.enabled;
4172
- // 关闭后立刻停掉还在队列里的 reorder timer
4173
- if (!activityReorderEnabled && activityReorderTimer) {
4174
- clearTimeout(activityReorderTimer);
4175
- activityReorderTimer = null;
4176
- }
4177
- // 如果重新开启,立刻执行一次排序
4178
- if (activityReorderEnabled) {
4179
- applyActivityBasedReorder();
4180
- }
4181
- res.json({ enabled: activityReorderEnabled });
4182
- return;
4183
- }
4184
- res.status(400).json({ error: '"enabled" must be a boolean' });
4185
- });
4186
4114
  // ── Settings (prevent sleep) ──────────────────────────────────────────
4187
4115
  router.get('/settings', async (_req, res) => {
4188
4116
  res.json(await getSettingsPayload());
@@ -4195,6 +4123,18 @@ router.put('/settings', async (req, res) => {
4195
4123
  if (typeof body.locale === 'string' && (body.locale === 'zh' || body.locale === 'en')) {
4196
4124
  setLocaleSetting(body.locale);
4197
4125
  }
4126
+ if (body.contextDraftHeight && typeof body.contextDraftHeight === 'object') {
4127
+ const heightBody = body.contextDraftHeight;
4128
+ for (const device of ['mobile', 'desktop']) {
4129
+ const value = heightBody[device];
4130
+ if (value === null) {
4131
+ setContextDraftHeightSetting(device, null);
4132
+ }
4133
+ else if (typeof value === 'number' && Number.isFinite(value) && value >= 56 && value <= 4000) {
4134
+ setContextDraftHeightSetting(device, Math.round(value));
4135
+ }
4136
+ }
4137
+ }
4198
4138
  if (body.localAccess && typeof body.localAccess === 'object') {
4199
4139
  const localAccessBody = body.localAccess;
4200
4140
  if (localAccessBody.reset === true) {
@@ -4215,6 +4155,27 @@ router.put('/settings', async (req, res) => {
4215
4155
  }
4216
4156
  res.json(await getSettingsPayload());
4217
4157
  });
4158
+ // ── Context draft (cross-device realtime sync) ────────────────────────
4159
+ // 草稿内容存服务端(~/.termdock/context-draft.json),PUT 后通过 control
4160
+ // WebSocket 广播给其它客户端;携带 origin 让发送方忽略自己的回声。
4161
+ router.get('/context-draft', (_req, res) => {
4162
+ res.json(loadContextDraft());
4163
+ });
4164
+ router.put('/context-draft', (req, res) => {
4165
+ const body = req.body ?? {};
4166
+ if (typeof body.text !== 'string') {
4167
+ res.status(400).json({ error: 'Invalid draft text', code: 'INVALID_CONTEXT_DRAFT' });
4168
+ return;
4169
+ }
4170
+ const doc = saveContextDraft(body.text);
4171
+ broadcastControlEvent({
4172
+ type: 'context-draft',
4173
+ text: doc.text,
4174
+ updatedAt: doc.updatedAt,
4175
+ origin: typeof body.origin === 'string' ? body.origin : null,
4176
+ });
4177
+ res.json(doc);
4178
+ });
4218
4179
  // ── Agent hooks API (rich status channel installers, built-in + plugin) ──
4219
4180
  router.get('/agent-hooks', (_req, res) => {
4220
4181
  res.json({ agents: listAllHookAgents() });
@@ -5355,6 +5316,18 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
5355
5316
  updateClientFocusState(sessionId, session, clientId, focused, reason);
5356
5317
  break;
5357
5318
  }
5319
+ case 'viewing': {
5320
+ // Push suppression: while this client is actively viewing the
5321
+ // session, its agent-transition push is redundant — skip it.
5322
+ // Kept separate from 'focus' (which requires textarea focus for
5323
+ // tmux focus tracking): on mobile the keyboard is routinely
5324
+ // dismissed while the user is still watching the session.
5325
+ if (options.pushClientId) {
5326
+ const viewing = msg.viewing === true;
5327
+ setClientViewingSession(options.pushClientId, sessionId, viewing);
5328
+ }
5329
+ break;
5330
+ }
5358
5331
  case 'flow-control': {
5359
5332
  if (typeof msg.paused === 'boolean') {
5360
5333
  const reason = typeof msg.reason === 'string' ? msg.reason : 'client-flow-control';
@@ -5404,6 +5377,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
5404
5377
  }
5405
5378
  }
5406
5379
  removeClientFocus(sessionId, session, clientId);
5380
+ if (options.pushClientId) {
5381
+ setClientViewingSession(options.pushClientId, sessionId, false);
5382
+ }
5407
5383
  removeClientFlowPaused(sessionId, session, clientId, 'client-disconnect');
5408
5384
  syncClientCountToTmux(sessionId);
5409
5385
  broadcastClientState();
@@ -0,0 +1,36 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ const DRAFT_DIR = path.join(os.homedir(), '.termdock');
5
+ const DRAFT_FILE = path.join(DRAFT_DIR, 'context-draft.json');
6
+ // 草稿是纯文本提示词,200k 上限足够宽裕,也避免异常请求写爆磁盘
7
+ export const CONTEXT_DRAFT_MAX_LENGTH = 200_000;
8
+ function normalizeDraft(value) {
9
+ const raw = value && typeof value === 'object'
10
+ ? value
11
+ : {};
12
+ return {
13
+ text: typeof raw.text === 'string' ? raw.text.slice(0, CONTEXT_DRAFT_MAX_LENGTH) : '',
14
+ updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : 0,
15
+ };
16
+ }
17
+ export function loadContextDraft() {
18
+ try {
19
+ const raw = fs.readFileSync(DRAFT_FILE, 'utf-8');
20
+ return normalizeDraft(JSON.parse(raw));
21
+ }
22
+ catch { /* ignore missing/malformed draft */ }
23
+ return { text: '', updatedAt: 0 };
24
+ }
25
+ export function saveContextDraft(text) {
26
+ const doc = {
27
+ text: text.slice(0, CONTEXT_DRAFT_MAX_LENGTH),
28
+ updatedAt: Date.now(),
29
+ };
30
+ try {
31
+ fs.mkdirSync(DRAFT_DIR, { recursive: true });
32
+ fs.writeFileSync(DRAFT_FILE, JSON.stringify(doc), 'utf-8');
33
+ }
34
+ catch { /* best effort */ }
35
+ return doc;
36
+ }
@@ -0,0 +1,104 @@
1
+ import { writeErrorLog } from './serverLogger.js';
2
+ /**
3
+ * 全局请求兜底超时(系统性保险丝)。
4
+ *
5
+ * 背景:浏览器对同源 HTTP/1.1 只有 6 条并发连接。任何一个 handler 挂起
6
+ * (忘了加超时、外部依赖 wedge、客户端半开连接),就永久占住一条;占满后
7
+ * 所有后续请求在浏览器侧排队,用户看到"整个服务 io 全部超时",只能重启。
8
+ *
9
+ * 这个中间件保证:无论 handler 自己是否记得加超时,每个进入 Express 的
10
+ * 请求都有死亡上界。到期后销毁 socket,浏览器立即释放连接槽,其余请求
11
+ * 照常进行。显式的长连接端点(watch NDJSON 流、终端 SSE)按表豁免,
12
+ * 慢但合法的端点(搜索、大文件下载)给更长档位;路由也可以通过
13
+ * `res.locals.requestDeadlineMs` 自行调档。
14
+ */
15
+ const DEFAULT_DEADLINE_MS = 30_000;
16
+ const DEADLINE_RULES = [
17
+ // 无限流:文件监听 NDJSON、终端 SSE。
18
+ { method: 'GET', pattern: /^\/api\/terminal\/fs\/watch$/, deadlineMs: null },
19
+ { method: 'GET', pattern: /^\/api\/terminal\/[^/]+\/stream$/, deadlineMs: null },
20
+ // 有限但可能很久:大目录搜索(有 done 事件的进度流)、大文件下载。
21
+ { method: 'GET', pattern: /^\/api\/terminal\/fs\/search$/, deadlineMs: 120_000 },
22
+ { method: 'GET', pattern: /^\/api\/terminal\/fs\/download$/, deadlineMs: 300_000 },
23
+ // 上传最多 100MB×50 文件,弱网下接收请求体可能要几分钟。注意 Node 内置
24
+ // requestTimeout(默认 300s,本项目未改)管「请求体接收」阶段并在超时后
25
+ // 回 408,所以这里必须低于 300s——让我们的 503+日志先出手,而不是被
26
+ // Node 的裸 408 抢先。
27
+ { method: 'POST', pattern: /^\/api\/terminal\/fs\/upload$/, deadlineMs: 280_000 },
28
+ // 会话打开/创建/重启内部串了多段 tmux 命令(各有 5s 超时),tmux 退化时
29
+ // 合法完成也可能要 20-40s——给 60s,超过才算真挂起。
30
+ { method: 'POST', pattern: /^\/api\/terminal\/session-inventory\/open$/, deadlineMs: 60_000 },
31
+ { method: 'POST', pattern: /^\/api\/terminal\/create$/, deadlineMs: 60_000 },
32
+ { method: 'POST', pattern: /^\/api\/terminal\/[^/]+\/restart$/, deadlineMs: 60_000 },
33
+ ];
34
+ function resolveDeadlineMs(method, pathname) {
35
+ for (const rule of DEADLINE_RULES) {
36
+ if (rule.method && rule.method !== method)
37
+ continue;
38
+ if (rule.pattern.test(pathname))
39
+ return rule.deadlineMs;
40
+ }
41
+ return DEFAULT_DEADLINE_MS;
42
+ }
43
+ export function requestDeadlineMiddleware(options = {}) {
44
+ const defaultDeadlineMs = options.defaultDeadlineMs ?? DEFAULT_DEADLINE_MS;
45
+ return (req, res, next) => {
46
+ let pathname;
47
+ try {
48
+ pathname = new URL(req.originalUrl || req.url, 'http://localhost').pathname;
49
+ }
50
+ catch {
51
+ pathname = req.path || req.url || '';
52
+ }
53
+ const ruleDeadline = resolveDeadlineMs(req.method, pathname);
54
+ const effectiveDeadline = ruleDeadline === DEFAULT_DEADLINE_MS ? defaultDeadlineMs : ruleDeadline;
55
+ if (effectiveDeadline === null) {
56
+ next();
57
+ return;
58
+ }
59
+ const startedAt = Date.now();
60
+ let timer = null;
61
+ const clear = () => {
62
+ if (timer) {
63
+ clearTimeout(timer);
64
+ timer = null;
65
+ }
66
+ };
67
+ const onFire = () => {
68
+ const elapsed = Date.now() - startedAt;
69
+ // 路由在 handler 里调过档:按新档位重新计时(null = 豁免)。
70
+ const override = res.locals.requestDeadlineMs;
71
+ if (override === null) {
72
+ clear();
73
+ return;
74
+ }
75
+ if (typeof override === 'number' && override > elapsed) {
76
+ timer = setTimeout(onFire, override - elapsed);
77
+ return;
78
+ }
79
+ console.warn(`[request-deadline] ${req.method} ${pathname} exceeded ${elapsed}ms — aborting request`);
80
+ writeErrorLog({
81
+ source: 'request-deadline',
82
+ method: req.method,
83
+ path: pathname,
84
+ durationMs: elapsed,
85
+ clientId: req.clientId,
86
+ });
87
+ if (!res.headersSent) {
88
+ // 未发头:socket 是健康的,干净地回 503 即可,连接还能继续复用。
89
+ res.status(503).json({
90
+ error: 'Request took too long and was aborted by the server. Please retry.',
91
+ code: 'REQUEST_DEADLINE',
92
+ });
93
+ return;
94
+ }
95
+ // 已发头但流卡死(含客户端消失的半开连接):无法写出合法响应,
96
+ // 销毁底层 socket,浏览器立即释放该连接槽。
97
+ req.socket.destroy();
98
+ };
99
+ timer = setTimeout(onFire, effectiveDeadline);
100
+ res.on('finish', clear);
101
+ res.on('close', clear);
102
+ next();
103
+ };
104
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function readManifest(filePath) {
5
+ try {
6
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export function resolveRuntimeClientDist(defaultClientDist, homeDir = os.homedir()) {
13
+ try {
14
+ const defaultPackageRoot = path.resolve(defaultClientDist, '..', '..');
15
+ const runningManifest = readManifest(path.join(defaultPackageRoot, 'runtime-manifest.json'));
16
+ if (runningManifest?.schemaVersion !== 1
17
+ || typeof runningManifest.serverBundleHash !== 'string') {
18
+ return defaultClientDist;
19
+ }
20
+ const runtimeRoot = path.join(homeDir, '.termdock', 'desktop-runtime');
21
+ const versionsRoot = fs.realpathSync(path.join(runtimeRoot, 'versions'));
22
+ const selectedVersionRoot = fs.realpathSync(path.join(runtimeRoot, 'client-current'));
23
+ const relative = path.relative(versionsRoot, selectedVersionRoot);
24
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
25
+ return defaultClientDist;
26
+ const selectedPackageRoot = path.join(selectedVersionRoot, 'package');
27
+ const selectedManifest = readManifest(path.join(selectedPackageRoot, 'runtime-manifest.json'));
28
+ if (selectedManifest?.schemaVersion !== 1
29
+ || selectedManifest.serverBundleHash !== runningManifest.serverBundleHash
30
+ || selectedManifest.clientEntrypoint !== 'dist/client/index.html') {
31
+ return defaultClientDist;
32
+ }
33
+ const selectedClientDist = path.join(selectedPackageRoot, 'dist', 'client');
34
+ if (!fs.existsSync(path.join(selectedClientDist, 'index.html')))
35
+ return defaultClientDist;
36
+ return selectedClientDist;
37
+ }
38
+ catch {
39
+ return defaultClientDist;
40
+ }
41
+ }
@@ -44,6 +44,15 @@ function normalizeLocalAccessSettings(value) {
44
44
  source: normalizedName ? source : 'auto',
45
45
  };
46
46
  }
47
+ function normalizeContextDraftHeight(value) {
48
+ const raw = value && typeof value === 'object'
49
+ ? value
50
+ : {};
51
+ const normalizeOne = (input) => typeof input === 'number' && Number.isFinite(input) && input >= 56 && input <= 4000
52
+ ? Math.round(input)
53
+ : null;
54
+ return { mobile: normalizeOne(raw.mobile), desktop: normalizeOne(raw.desktop) };
55
+ }
47
56
  function normalizeSettings(value) {
48
57
  const raw = value && typeof value === 'object'
49
58
  ? value
@@ -51,10 +60,10 @@ function normalizeSettings(value) {
51
60
  return {
52
61
  version: 1,
53
62
  preventSleep: raw.preventSleep === true,
54
- activityReorderEnabled: raw.activityReorderEnabled !== false,
55
63
  localAccess: normalizeLocalAccessSettings(raw.localAccess),
56
64
  firstRunCompleted: raw.firstRunCompleted === true,
57
65
  locale: typeof raw.locale === 'string' && raw.locale === 'zh' ? 'zh' : 'en',
66
+ contextDraftHeight: normalizeContextDraftHeight(raw.contextDraftHeight),
58
67
  updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : Date.now(),
59
68
  };
60
69
  }
@@ -128,14 +137,6 @@ export async function setPreventSleepSettingAsync(enabled) {
128
137
  settings.preventSleep = enabled;
129
138
  });
130
139
  }
131
- export function getActivityReorderSetting() {
132
- return loadSettings().activityReorderEnabled;
133
- }
134
- export function setActivityReorderSetting(enabled) {
135
- return updateSettings((settings) => {
136
- settings.activityReorderEnabled = enabled;
137
- });
138
- }
139
140
  export function getLocalAccessSetting() {
140
141
  return loadSettings().localAccess;
141
142
  }
@@ -173,3 +174,11 @@ export function setLocaleSetting(locale) {
173
174
  settings.locale = locale;
174
175
  });
175
176
  }
177
+ export function getContextDraftHeightSetting() {
178
+ return loadSettings().contextDraftHeight;
179
+ }
180
+ export function setContextDraftHeightSetting(device, height) {
181
+ return updateSettings((settings) => {
182
+ settings.contextDraftHeight[device] = height;
183
+ });
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.43",
3
+ "version": "1.4.46",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "runtime-manifest.json",
16
17
  "README.md",
17
18
  "LICENSE",
18
19
  "scripts"
@@ -22,19 +23,21 @@
22
23
  "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
23
24
  "dev:client": "vite",
24
25
  "dev:server": "cross-env PORT=9835 tsx watch src/server/entry.ts",
25
- "build": "npm run clean && npm run build:client && npm run build:server",
26
+ "build": "npm run clean && npm run build:client && npm run build:server && npm run build:runtime-manifest",
26
27
  "build:desktop": "node scripts/prepare-desktop-tokens.mjs && tsc -p tsconfig.desktop.json",
27
28
  "desktop:dev": "npm run build && npm run build:desktop && electron-forge start",
28
29
  "desktop:prepare": "npm run build && npm run build:desktop && node scripts/prepare-desktop-runtime.mjs",
29
30
  "desktop:make": "npm run desktop:prepare && electron-forge make",
30
31
  "build:client": "vite build",
31
32
  "build:server": "tsc -p tsconfig.server.json && node -e \"require('fs').chmodSync('dist/server/cli.js', 0o755)\"",
33
+ "build:runtime-manifest": "node scripts/generate-runtime-manifest.mjs",
32
34
  "generate:pwa-assets": "pwa-assets-generator --config pwa-assets.config.ts",
33
35
  "preview": "vite preview",
34
36
  "start": "node dist/server/cli.js",
35
37
  "lint": "tsc --noEmit",
36
38
  "trace:diff": "node scripts/inspect-diff-trace.mjs",
37
39
  "trace:fsio": "node scripts/inspect-fs-io.mjs",
40
+ "release:classify": "node scripts/classify-release.mjs",
38
41
  "test": "vitest --passWithNoTests",
39
42
  "test:ui": "vitest --ui",
40
43
  "prepublishOnly": "npm run build",
@@ -0,0 +1,12 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "packageName": "termdock",
4
+ "version": "1.4.46",
5
+ "runtimeProtocolVersion": 1,
6
+ "minimumDesktopVersion": "1.4.46",
7
+ "nodeMajor": 22,
8
+ "dependencyHash": "sha256-aUokhQIdbogiFjsYarTRvcQ40Rva3j5CIot/0H1Lyl4=",
9
+ "serverBundleHash": "sha256-+mlh3Ar6V1IOLEJ5l39G2XsUQ+TOgtvmKInjzinHPsA=",
10
+ "entrypoint": "dist/server/cli.js",
11
+ "clientEntrypoint": "dist/client/index.html"
12
+ }
@@ -0,0 +1,88 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ function git(args) {
9
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
10
+ }
11
+
12
+ function stable(value) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
14
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
15
+ }
16
+
17
+ function latestDesktopTag() {
18
+ return git(['tag', '--merged', 'HEAD', '--list', 'v*', '--sort=-version:refname'])
19
+ .split('\n')
20
+ .find(Boolean);
21
+ }
22
+
23
+ const requestedBase = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
24
+ const base = requestedBase || latestDesktopTag();
25
+ if (!base) {
26
+ throw new Error('No desktop release tag found. Fetch tags or pass a base ref explicitly.');
27
+ }
28
+
29
+ const changedFiles = git(['diff', '--name-only', base, '--'])
30
+ .split('\n')
31
+ .map((file) => file.trim())
32
+ .filter(Boolean);
33
+ const currentPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
34
+ const basePackage = JSON.parse(git(['show', `${base}:package.json`]));
35
+ const dependencyChanged = JSON.stringify(stable({
36
+ dependencies: currentPackage.dependencies ?? {},
37
+ engines: currentPackage.engines ?? {},
38
+ })) !== JSON.stringify(stable({
39
+ dependencies: basePackage.dependencies ?? {},
40
+ engines: basePackage.engines ?? {},
41
+ }));
42
+
43
+ const desktopPatterns = [
44
+ /^desktop\//,
45
+ /^forge\.config\.cjs$/,
46
+ /^tsconfig\.desktop\.json$/,
47
+ /^scripts\/prepare-desktop-runtime\.mjs$/,
48
+ /^scripts\/prepare-desktop-tokens\.mjs$/,
49
+ /^\.github\/workflows\/release-macos\.yml$/,
50
+ ];
51
+ const runtimePatterns = [
52
+ /^src\//,
53
+ /^public\//,
54
+ /^index\.html$/,
55
+ /^vite\.config\.ts$/,
56
+ /^tailwind\.config\.js$/,
57
+ /^postcss\.config\.js$/,
58
+ /^package(?:-lock)?\.json$/,
59
+ /^scripts\/generate-runtime-manifest\.mjs$/,
60
+ ];
61
+ const testFile = /(?:^|\/)\w[^/]*\.test\.[cm]?[jt]sx?$/;
62
+ const noReleaseFile = /^(?:docs\/|README|LICENSE|AGENTS|\.gitignore$|scripts\/classify-release\.mjs$)/;
63
+ const desktopFiles = changedFiles.filter((file) =>
64
+ !testFile.test(file) && desktopPatterns.some((pattern) => pattern.test(file)));
65
+ const runtimeFiles = changedFiles.filter((file) =>
66
+ !testFile.test(file) && runtimePatterns.some((pattern) => pattern.test(file)));
67
+ const codeFiles = changedFiles.filter((file) => !noReleaseFile.test(file) && !testFile.test(file));
68
+ const classified = new Set([...desktopFiles, ...runtimeFiles]);
69
+ const unknownCodeFiles = codeFiles.filter((file) => !classified.has(file));
70
+ const requiresDesktop = dependencyChanged || desktopFiles.length > 0 || unknownCodeFiles.length > 0;
71
+ const requiresRuntime = runtimeFiles.length > 0 || requiresDesktop;
72
+ const result = {
73
+ base,
74
+ requiresRuntime,
75
+ requiresDesktop,
76
+ dependencyChanged,
77
+ desktopFiles,
78
+ runtimeFiles,
79
+ unknownCodeFiles,
80
+ };
81
+
82
+ console.log(JSON.stringify(result, null, 2));
83
+ if (process.argv.includes('--github-output') && process.env.GITHUB_OUTPUT) {
84
+ fs.appendFileSync(
85
+ process.env.GITHUB_OUTPUT,
86
+ `requires_runtime=${requiresRuntime}\nrequires_desktop=${requiresDesktop}\n`,
87
+ );
88
+ }