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
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import webpush from 'web-push';
5
+ import { getViewingPushClientIds } from './pushViewers.js';
5
6
  const storeDirectory = path.join(os.homedir(), '.termdock');
6
7
  const storePath = path.join(storeDirectory, 'push-notifications.json');
7
8
  let store = null;
@@ -20,7 +21,14 @@ function readStore() {
20
21
  && typeof parsed.vapid?.publicKey === 'string'
21
22
  && typeof parsed.vapid?.privateKey === 'string'
22
23
  && Array.isArray(parsed.subscriptions)) {
23
- store = parsed;
24
+ // Subscriptions written before exitEnabled existed default to opt-out.
25
+ store = {
26
+ ...parsed,
27
+ subscriptions: parsed.subscriptions.map((entry) => ({
28
+ ...entry,
29
+ exitEnabled: entry.exitEnabled === true,
30
+ })),
31
+ };
24
32
  }
25
33
  }
26
34
  catch {
@@ -34,7 +42,11 @@ function readStore() {
34
42
  };
35
43
  persistStore(store);
36
44
  }
37
- webpush.setVapidDetails('mailto:push@termdock.local', store.vapid.publicKey, store.vapid.privateKey);
45
+ // VAPID subject must be a mailto: or https: contact URI. Configurable for
46
+ // self-hosters; defaults to the project repository instead of a fake mailbox.
47
+ const vapidSubject = process.env.TERMDOCK_VAPID_SUBJECT?.trim()
48
+ || 'https://github.com/Jovines/termdock';
49
+ webpush.setVapidDetails(vapidSubject, store.vapid.publicKey, store.vapid.privateKey);
38
50
  return store;
39
51
  }
40
52
  export function getVapidPublicKey() {
@@ -67,10 +79,43 @@ export function removePushSubscription(clientId, endpoint) {
67
79
  if (current.subscriptions.length !== previousLength)
68
80
  persistStore(current);
69
81
  }
70
- async function sendPayload(payload) {
82
+ function isZhLocale(locale) {
83
+ return locale.toLowerCase().startsWith('zh');
84
+ }
85
+ /**
86
+ * Localized push copy, mirroring the client-side getAgentNotificationText in
87
+ * useTerminalStore.ts so both delivery paths read the same for one locale.
88
+ */
89
+ export function agentNotificationText(locale, kind, agentName) {
90
+ if (isZhLocale(locale)) {
91
+ if (kind === 'waiting')
92
+ return { title: `${agentName} 需要你的处理`, body: '点按返回对应会话' };
93
+ if (kind === 'done')
94
+ return { title: `${agentName} 已完成`, body: '点按查看结果' };
95
+ return { title: `${agentName} 已退出`, body: '点按查看结果' };
96
+ }
97
+ if (kind === 'waiting')
98
+ return { title: `${agentName} needs your input`, body: 'Tap to return to the session' };
99
+ if (kind === 'done')
100
+ return { title: `${agentName} finished`, body: 'Tap to see the result' };
101
+ return { title: `${agentName} exited`, body: 'Tap to see the result' };
102
+ }
103
+ export function terminalExitText(locale, exitCode) {
104
+ if (isZhLocale(locale)) {
105
+ return {
106
+ title: '终端会话已退出',
107
+ body: exitCode !== null ? `进程已退出(代码 ${exitCode}),点按查看` : '进程已退出,点按查看',
108
+ };
109
+ }
110
+ return {
111
+ title: 'Terminal session ended',
112
+ body: exitCode !== null ? `Process exited (code ${exitCode}). Tap to view.` : 'Process exited. Tap to view.',
113
+ };
114
+ }
115
+ async function sendPayload(options) {
71
116
  const current = readStore();
72
117
  const invalidEndpoints = new Set();
73
- await Promise.all(current.subscriptions.filter((entry) => entry.aiEnabled).map(async (entry) => {
118
+ await Promise.all(current.subscriptions.filter((entry) => (options.enabled(entry) && !options.skipClientIds?.has(entry.clientId))).map(async (entry) => {
74
119
  try {
75
120
  const subscription = {
76
121
  endpoint: entry.endpoint,
@@ -78,12 +123,12 @@ async function sendPayload(payload) {
78
123
  keys: entry.keys,
79
124
  };
80
125
  await webpush.sendNotification(subscription, JSON.stringify({
81
- ...payload,
126
+ ...options.build(entry.locale),
82
127
  alertStyle: entry.alertStyle,
83
128
  locale: entry.locale,
84
129
  }), {
85
- TTL: 60 * 60,
86
- urgency: payload.kind === 'waiting' ? 'high' : 'normal',
130
+ TTL: options.ttlSeconds ?? 60 * 60,
131
+ urgency: options.urgency ?? 'normal',
87
132
  });
88
133
  }
89
134
  catch (error) {
@@ -104,39 +149,80 @@ async function sendPayload(payload) {
104
149
  export function notifyAgentTransition(sessionId, previous, current) {
105
150
  if (!shouldNotifyAgentTransition(previous, current))
106
151
  return;
107
- const agentExited = current.agentStatus === null && previous?.agentStatus === 'working';
108
- const kind = agentExited ? 'exited' : current.agentStatus;
152
+ const agentExited = (current.agentStatus === null || current.agentStatus === 'idle')
153
+ && (previous?.agentStatus === 'working' || previous?.agentStatus === 'waiting');
154
+ const kind = (agentExited ? 'exited' : current.agentStatus);
109
155
  const agentName = current.agent?.displayName ?? previous?.agent?.displayName ?? 'Agent';
110
- const title = kind === 'waiting'
111
- ? `${agentName} 需要你的处理`
112
- : kind === 'done'
113
- ? `${agentName} 已完成`
114
- : `${agentName} 已退出`;
115
156
  void sendPayload({
116
- kind,
117
- title,
118
- body: current.agentMessage ?? (kind === 'waiting' ? '点按返回对应会话' : '点按查看结果'),
119
- // Same tag for all transitions of one session new notification
120
- // replaces the old one (key behaviour on iOS where getNotifications()
121
- // is unavailable for programmatic cleanup).
122
- tag: `agent:${sessionId}`,
123
- // Per-transition key so the 5 s dedup window only suppresses
124
- // duplicate deliveries of the same transition, not later ones.
125
- dedupKey: `agent:${sessionId}:${kind}:${current.agentActivity}`,
126
- sessionId,
127
- url: `/?session=${encodeURIComponent(sessionId)}`,
157
+ enabled: (entry) => entry.aiEnabled,
158
+ skipClientIds: getViewingPushClientIds(sessionId),
159
+ urgency: kind === 'waiting' ? 'high' : 'normal',
160
+ ttlSeconds: kind === 'waiting' ? 24 * 60 * 60 : 60 * 60,
161
+ build: (locale) => {
162
+ const text = agentNotificationText(locale, kind, agentName);
163
+ return {
164
+ kind,
165
+ title: text.title,
166
+ body: current.agentMessage ?? text.body,
167
+ // Same tag for all transitions of one session → new notification
168
+ // replaces the old one (key behaviour on iOS where getNotifications()
169
+ // is unavailable for programmatic cleanup).
170
+ tag: `agent:${sessionId}`,
171
+ // Per-transition key so the 5 s dedup window only suppresses
172
+ // duplicate deliveries of the same transition, not later ones.
173
+ dedupKey: `agent:${sessionId}:${kind}:${current.agentActivity}`,
174
+ sessionId,
175
+ url: `/?session=${encodeURIComponent(sessionId)}`,
176
+ };
177
+ },
178
+ });
179
+ }
180
+ /**
181
+ * Push for a terminal process that exited on its own (not a user-initiated
182
+ * close — those dispose the exit listener before killing the pty). Sessions
183
+ * with a live agent are covered by notifyAgentTransition instead, so the
184
+ * caller skips those to avoid double notifications for one event.
185
+ */
186
+ export function notifyTerminalExit(sessionId, exitCode) {
187
+ void sendPayload({
188
+ enabled: (entry) => entry.exitEnabled,
189
+ skipClientIds: getViewingPushClientIds(sessionId),
190
+ build: (locale) => {
191
+ const text = terminalExitText(locale, exitCode);
192
+ return {
193
+ kind: 'exit',
194
+ title: text.title,
195
+ body: text.body,
196
+ tag: `exit:${sessionId}`,
197
+ dedupKey: `exit:${sessionId}:${exitCode ?? 'signal'}`,
198
+ sessionId,
199
+ url: `/?session=${encodeURIComponent(sessionId)}`,
200
+ };
201
+ },
128
202
  });
129
203
  }
130
204
  export function shouldNotifyAgentTransition(previous, current) {
131
205
  if (!previous)
132
206
  return false;
133
- const needsAttention = current.reviewed !== true
134
- && (current.agentStatus === 'waiting' || current.agentStatus === 'done');
135
- const attentionChanged = previous.agentStatus !== current.agentStatus
136
- || previous.agentActivity !== current.agentActivity
137
- || previous.reviewed === true;
138
- const agentExited = current.agentStatus === null
139
- && previous.agentStatus === 'working'
140
- && current.reviewed !== true;
141
- return (needsAttention && attentionChanged) || agentExited;
207
+ // Only status or activity changes carry new information. A `reviewed` flip
208
+ // is the user's own acknowledgement (or a stop stamping an unread turn) and
209
+ // must not re-notify by itself — the previous reviewed-based gate here
210
+ // silently suppressed every mid-turn waiting event, because reviewed stays
211
+ // true while an agent is working or waiting and only `stop` clears it.
212
+ const stateChanged = previous.agentStatus !== current.agentStatus
213
+ || previous.agentActivity !== current.agentActivity;
214
+ if (!stateChanged)
215
+ return false;
216
+ if (current.agentStatus === 'waiting' || current.agentStatus === 'done') {
217
+ // waiting: any new wait event (entering waiting, or a new activity round
218
+ // while waiting) deserves a push. done: only entering done — a second
219
+ // stop for the same turn, or an ack-only broadcast, must stay silent.
220
+ return current.agentStatus === 'waiting' || previous.agentStatus !== 'done';
221
+ }
222
+ // Agent exited: from working or waiting (including a hook-emitted
223
+ // session-end that lands on 'idle' before the process poll clears the
224
+ // session). done → exit is deliberately silent — the completion was
225
+ // already notified.
226
+ return (current.agentStatus === null || current.agentStatus === 'idle')
227
+ && (previous.agentStatus === 'working' || previous.agentStatus === 'waiting');
142
228
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tracks which push-subscribed clients are actively viewing which terminal
3
+ * session, so agent-transition pushes can skip the very client that is
4
+ * already looking at the session (its notification would be redundant).
5
+ *
6
+ * The per-terminal WebSocket reports viewing state (session active + document
7
+ * visible + window focused + stream ready — deliberately NOT textarea focus,
8
+ * since mobile users routinely watch output with the keyboard dismissed) on
9
+ * every change, including a viewing=false when the user switches sessions,
10
+ * backgrounds the app, or the window blurs — and the WS close handler clears
11
+ * the entry when the connection dies (e.g. the iOS PWA was killed without a
12
+ * clean viewing=false).
13
+ */
14
+ const viewingSessionsByClient = new Map();
15
+ export function setClientViewingSession(pushClientId, sessionId, viewing) {
16
+ if (!pushClientId || !sessionId)
17
+ return;
18
+ if (!viewing) {
19
+ const sessions = viewingSessionsByClient.get(pushClientId);
20
+ if (!sessions)
21
+ return;
22
+ sessions.delete(sessionId);
23
+ if (sessions.size === 0)
24
+ viewingSessionsByClient.delete(pushClientId);
25
+ return;
26
+ }
27
+ let sessions = viewingSessionsByClient.get(pushClientId);
28
+ if (!sessions) {
29
+ sessions = new Set();
30
+ viewingSessionsByClient.set(pushClientId, sessions);
31
+ }
32
+ sessions.add(sessionId);
33
+ }
34
+ /** Push subscription clientIds currently looking at the given session. */
35
+ export function getViewingPushClientIds(sessionId) {
36
+ const result = new Set();
37
+ if (!sessionId)
38
+ return result;
39
+ for (const [pushClientId, sessions] of viewingSessionsByClient) {
40
+ if (sessions.has(sessionId))
41
+ result.add(pushClientId);
42
+ }
43
+ return result;
44
+ }
@@ -148,6 +148,10 @@ function handleControl(msg) {
148
148
  case 'attach':
149
149
  attachChannel(msg.id, msg.since);
150
150
  return;
151
+ case 'ping':
152
+ // 服务端心跳:host 事件循环活着就立即回 pong;连续不回 = wedge。
153
+ send({ op: 'pong', t: msg.t });
154
+ return;
151
155
  case 'resize': {
152
156
  const ch = channels.get(msg.id);
153
157
  if (ch && !ch.exited) {
@@ -235,8 +239,15 @@ function shutdown() {
235
239
  fs.unlinkSync(SOCKET_PATH);
236
240
  }
237
241
  catch { /* already gone */ }
242
+ try {
243
+ fs.unlinkSync(pidFilePath());
244
+ }
245
+ catch { /* already gone */ }
238
246
  process.exit(0);
239
247
  }
248
+ function pidFilePath() {
249
+ return `${SOCKET_PATH}.pid`;
250
+ }
240
251
  function main() {
241
252
  fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o700 });
242
253
  // Stale socket from a crashed host: only our own pid could own it, and we
@@ -251,6 +262,12 @@ function main() {
251
262
  fs.chmodSync(SOCKET_PATH, 0o600);
252
263
  }
253
264
  catch { /* best effort */ }
265
+ // 写 pid 文件:服务端检测到这个 host wedge(心跳超时)时需要知道
266
+ // 杀谁——host 是 detached 的,pid 没法靠父子关系找回。
267
+ try {
268
+ fs.writeFileSync(pidFilePath(), String(process.pid), { mode: 0o600 });
269
+ }
270
+ catch { /* best effort */ }
254
271
  console.log(`[pty-host] listening on ${SOCKET_PATH} (pid ${process.pid})`);
255
272
  });
256
273
  // The host is meant to run forever; a dropped server connection is normal
@@ -17,6 +17,16 @@ import { FRAME_CONTROL, FRAME_DATA, FRAME_INPUT, FRAME_REPLAY, PTY_HOST_PROTOCOL
17
17
  const HOST_STARTUP_TIMEOUT_MS = 5_000;
18
18
  const HOST_STARTUP_POLL_MS = 50;
19
19
  const RECONNECT_DELAY_MS = 500;
20
+ /** connect + helloAck 的总等待上限。host 活着但 wedge(内核 backlog 收下
21
+ * 连接、事件循环不回包)时,没有这个上限 promise 会永不 settle——且
22
+ * ensureConnected 的 connecting 锁存器会让之后所有调用挂在同一个
23
+ * promise 上,表现为"一次卡住、之后全部卡住"。 */
24
+ const CONNECT_HANDSHAKE_TIMEOUT_MS = 4_000;
25
+ /** spawn 发出后等 host 回 spawned 的上限。 */
26
+ const SPAWN_RESPONSE_TIMEOUT_MS = 10_000;
27
+ /** 心跳:连续 HEARTBEAT_MISS_LIMIT 个间隔没有 pong 判定 host wedge。 */
28
+ const HEARTBEAT_INTERVAL_MS = 15_000;
29
+ const HEARTBEAT_MISS_LIMIT = 2;
20
30
  function hostSocketDir() {
21
31
  return path.join(os.homedir(), '.termdock', 'pty-hosts');
22
32
  }
@@ -143,6 +153,9 @@ export class PtyHostManager {
143
153
  helloResolve = null;
144
154
  pendingSpawns = new Map();
145
155
  reconnectTimer = null;
156
+ heartbeatTimer = null;
157
+ lastPongAt = 0;
158
+ killingWedgedHost = null;
146
159
  stopped = false;
147
160
  /** Resolve when the host is connected + handshaken; returns live channels. */
148
161
  ensureConnected() {
@@ -157,12 +170,20 @@ export class PtyHostManager {
157
170
  return await this.connectOnce();
158
171
  }
159
172
  catch (error) {
160
- if (error.code !== 'ENOENT'
161
- && error.code !== 'ECONNREFUSED') {
173
+ const code = error.code;
174
+ if (code === 'ENOENT' || code === 'ECONNREFUSED') {
175
+ this.spawnHostDaemon();
176
+ }
177
+ else if (code === 'PTY_HOST_HANDSHAKE_TIMEOUT') {
178
+ // host 活着但 wedge(accept 连接不回 helloAck):杀掉再重生。
179
+ console.warn('[pty-host] handshake timed out — replacing wedged host:', ptyHostSocketPath());
180
+ await this.killWedgedHost();
181
+ this.spawnHostDaemon();
182
+ }
183
+ else {
162
184
  throw error;
163
185
  }
164
186
  }
165
- this.spawnHostDaemon();
166
187
  const deadline = Date.now() + HOST_STARTUP_TIMEOUT_MS;
167
188
  let lastError = null;
168
189
  while (Date.now() < deadline) {
@@ -176,6 +197,66 @@ export class PtyHostManager {
176
197
  }
177
198
  throw new Error(`pty-host did not start listening in time: ${lastError?.message ?? lastError}`);
178
199
  }
200
+ /** 杀掉 wedge 的 host(SIGTERM → 1s 后 SIGKILL)并清理 socket/pid 文件。
201
+ * 等旧 host 确实死了才返回——否则旧 host 的 SIGTERM shutdown 可能删掉
202
+ * 新 host 刚 bind 的 socket 文件。并发调用共享同一次 kill。 */
203
+ killWedgedHost() {
204
+ if (!this.killingWedgedHost) {
205
+ this.killingWedgedHost = this.doKillWedgedHost().finally(() => {
206
+ this.killingWedgedHost = null;
207
+ });
208
+ }
209
+ return this.killingWedgedHost;
210
+ }
211
+ async doKillWedgedHost() {
212
+ const sockPath = ptyHostSocketPath();
213
+ const pidPath = `${sockPath}.pid`;
214
+ let pid = null;
215
+ try {
216
+ const parsed = Number(fs.readFileSync(pidPath, 'utf8').trim());
217
+ if (Number.isInteger(parsed) && parsed > 1)
218
+ pid = parsed;
219
+ }
220
+ catch { /* 无 pid 文件(host 早于该特性)——只能放弃精准击杀 */ }
221
+ if (pid) {
222
+ const alive = () => {
223
+ try {
224
+ process.kill(pid, 0);
225
+ return true;
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ };
231
+ console.warn(`[pty-host] killing wedged host (pid ${pid})`);
232
+ try {
233
+ process.kill(pid, 'SIGTERM');
234
+ }
235
+ catch { /* already gone */ }
236
+ let deadline = Date.now() + 1_000;
237
+ while (alive() && Date.now() < deadline) {
238
+ await new Promise((r) => setTimeout(r, 50));
239
+ }
240
+ if (alive()) {
241
+ try {
242
+ process.kill(pid, 'SIGKILL');
243
+ }
244
+ catch { /* already gone */ }
245
+ deadline = Date.now() + 1_000;
246
+ while (alive() && Date.now() < deadline) {
247
+ await new Promise((r) => setTimeout(r, 50));
248
+ }
249
+ }
250
+ }
251
+ try {
252
+ fs.unlinkSync(sockPath);
253
+ }
254
+ catch { /* gone */ }
255
+ try {
256
+ fs.unlinkSync(pidPath);
257
+ }
258
+ catch { /* gone */ }
259
+ }
179
260
  spawnHostDaemon() {
180
261
  const { command, args } = resolveHostEntry();
181
262
  fs.mkdirSync(hostSocketDir(), { recursive: true, mode: 0o700 });
@@ -192,7 +273,14 @@ export class PtyHostManager {
192
273
  connectOnce() {
193
274
  return new Promise((resolvePromise, rejectPromise) => {
194
275
  const conn = net.createConnection(ptyHostSocketPath());
276
+ const timer = setTimeout(() => {
277
+ const error = new Error('pty-host handshake timed out');
278
+ error.code = 'PTY_HOST_HANDSHAKE_TIMEOUT';
279
+ conn.destroy();
280
+ rejectPromise(error);
281
+ }, CONNECT_HANDSHAKE_TIMEOUT_MS);
195
282
  const onError = (error) => {
283
+ clearTimeout(timer);
196
284
  conn.destroy();
197
285
  rejectPromise(error);
198
286
  };
@@ -200,11 +288,42 @@ export class PtyHostManager {
200
288
  conn.once('connect', () => {
201
289
  conn.removeListener('error', onError);
202
290
  this.attachConnection(conn);
203
- this.helloResolve = resolvePromise;
291
+ this.helloResolve = (channels) => {
292
+ clearTimeout(timer);
293
+ // handleDisconnect 会用 [] 兜底调用这里——连接已死时不开心跳。
294
+ if (this.conn === conn && !conn.destroyed)
295
+ this.startHeartbeat();
296
+ resolvePromise(channels);
297
+ };
204
298
  conn.write(encodeControl({ op: 'hello', v: PTY_HOST_PROTOCOL_VERSION }));
205
299
  });
206
300
  });
207
301
  }
302
+ startHeartbeat() {
303
+ this.stopHeartbeat();
304
+ this.lastPongAt = Date.now();
305
+ this.heartbeatTimer = setInterval(() => {
306
+ if (!this.conn || this.conn.destroyed) {
307
+ this.stopHeartbeat();
308
+ return;
309
+ }
310
+ if (Date.now() - this.lastPongAt > HEARTBEAT_INTERVAL_MS * HEARTBEAT_MISS_LIMIT) {
311
+ // host 事件循环 wedge:杀旧 host(新 host 由重连路径 respawn)。
312
+ console.warn('[pty-host] heartbeat missed — host appears wedged, replacing it:', ptyHostSocketPath());
313
+ this.stopHeartbeat();
314
+ this.conn.destroy();
315
+ void this.killWedgedHost();
316
+ return;
317
+ }
318
+ this.send({ op: 'ping', t: Date.now() });
319
+ }, HEARTBEAT_INTERVAL_MS);
320
+ }
321
+ stopHeartbeat() {
322
+ if (this.heartbeatTimer) {
323
+ clearInterval(this.heartbeatTimer);
324
+ this.heartbeatTimer = null;
325
+ }
326
+ }
208
327
  attachConnection(conn) {
209
328
  if (this.conn && !this.conn.destroyed)
210
329
  this.conn.destroy();
@@ -264,6 +383,9 @@ export class PtyHostManager {
264
383
  case 'replayEnd':
265
384
  this.clients.get(control.id)?.handleReplayEnd(control.offset);
266
385
  return;
386
+ case 'pong':
387
+ this.lastPongAt = Date.now();
388
+ return;
267
389
  case 'exit': {
268
390
  const client = this.clients.get(control.id);
269
391
  if (client) {
@@ -276,6 +398,7 @@ export class PtyHostManager {
276
398
  }
277
399
  }
278
400
  handleDisconnect() {
401
+ this.stopHeartbeat();
279
402
  this.helloResolve?.([]);
280
403
  this.helloResolve = null;
281
404
  for (const pending of this.pendingSpawns.values()) {
@@ -336,8 +459,26 @@ export class PtyHostManager {
336
459
  await this.ensureConnected();
337
460
  const client = new PtyHostClient(this, id);
338
461
  this.clients.set(id, client);
462
+ let spawnTimer = null;
339
463
  const spawned = new Promise((resolvePromise, rejectPromise) => {
340
- this.pendingSpawns.set(id, { resolve: resolvePromise, reject: rejectPromise });
464
+ // host wedge spawned 永远不会来——必须有超时,否则这里永挂。
465
+ spawnTimer = setTimeout(() => {
466
+ const error = new Error('pty-host spawn timed out');
467
+ error.code = 'PTY_HOST_SPAWN_TIMEOUT';
468
+ rejectPromise(error);
469
+ }, SPAWN_RESPONSE_TIMEOUT_MS);
470
+ this.pendingSpawns.set(id, {
471
+ resolve: (result) => {
472
+ if (spawnTimer)
473
+ clearTimeout(spawnTimer);
474
+ resolvePromise(result);
475
+ },
476
+ reject: (error) => {
477
+ if (spawnTimer)
478
+ clearTimeout(spawnTimer);
479
+ rejectPromise(error);
480
+ },
481
+ });
341
482
  });
342
483
  this.send({ op: 'spawn', id, spec });
343
484
  try {
@@ -351,6 +492,12 @@ export class PtyHostManager {
351
492
  }
352
493
  catch (error) {
353
494
  this.clients.delete(id);
495
+ this.pendingSpawns.delete(id);
496
+ // spawn 超时 ≈ host wedge:断连触发 handleDisconnect 的自愈重连。
497
+ if (error.code === 'PTY_HOST_SPAWN_TIMEOUT') {
498
+ console.warn('[pty-host] spawn timed out — host may be wedged, dropping connection:', ptyHostSocketPath());
499
+ this.conn?.destroy();
500
+ }
354
501
  throw error;
355
502
  }
356
503
  }
@@ -142,8 +142,33 @@ export function discoverCodexTokens() {
142
142
  return discoverCodexFromEnv();
143
143
  }
144
144
  // ── Kimi ─────────────────────────────────────────────────────────────
145
+ /** Kimi Code CLI stores OAuth device-code credentials here (`kimi login`). */
146
+ function discoverKimiFromCredentials() {
147
+ const credPath = path.join(HOME, '.kimi-code', 'credentials', 'kimi-code.json');
148
+ try {
149
+ const raw = fs.readFileSync(credPath, 'utf-8');
150
+ const cred = JSON.parse(raw);
151
+ if (typeof cred.access_token !== 'string' || !cred.access_token.trim()) {
152
+ return [];
153
+ }
154
+ // expires_at: epoch seconds (or ms on some versions); expired → skip so
155
+ // the env/opencode fallbacks still get a chance.
156
+ const exp = Number(cred.expires_at);
157
+ if (Number.isFinite(exp) && exp > 0) {
158
+ const expMs = exp > 1e12 ? exp : exp * 1000;
159
+ if (Date.now() >= expMs)
160
+ return [];
161
+ }
162
+ return [{ token: cred.access_token.trim(), source: 'kimi-code credentials' }];
163
+ }
164
+ catch {
165
+ return [];
166
+ }
167
+ }
145
168
  export function discoverKimiTokens() {
146
169
  const results = [];
170
+ // Priority: kimi-code OAuth credentials > env > legacy config > OpenCode auth
171
+ results.push(...discoverKimiFromCredentials());
147
172
  // Check KIMI_API_KEY env
148
173
  const envKey = process.env.KIMI_API_KEY;
149
174
  if (envKey?.trim()) {
@@ -2455,8 +2455,10 @@ router.get('/read', async (req, res) => {
2455
2455
  throwIfAborted(controller.signal, 'fs.read');
2456
2456
  const stat = await fs.promises.stat(resolvedPath);
2457
2457
  throwIfAborted(controller.signal, 'fs.read');
2458
- if (stat.isDirectory()) {
2459
- throw new Error('Path is a directory, not a file');
2458
+ // 只允许常规文件:FIFO / unix socket / 设备文件的 open() 会永久占住
2459
+ // 一个 libuv 线程且不可取消(线程池耗尽 = 整个服务 IO 假死)。
2460
+ if (!stat.isFile()) {
2461
+ throw new Error('Path is not a regular file');
2460
2462
  }
2461
2463
  const bytesToRead = Math.min(stat.size, MAX_FILE_SIZE);
2462
2464
  const truncated = stat.size > bytesToRead;
@@ -17,6 +17,7 @@ router.get('/status', (req, res) => {
17
17
  endpoint: subscription.endpoint,
18
18
  expirationTime: subscription.expirationTime,
19
19
  aiEnabled: subscription.aiEnabled,
20
+ exitEnabled: subscription.exitEnabled,
20
21
  alertStyle: subscription.alertStyle,
21
22
  locale: subscription.locale,
22
23
  updatedAt: subscription.updatedAt,
@@ -26,7 +27,7 @@ router.get('/status', (req, res) => {
26
27
  });
27
28
  router.post('/subscribe', (req, res) => {
28
29
  const clientId = clientIdFromRequest(req);
29
- const { subscription, aiEnabled, alertStyle, locale } = req.body ?? {};
30
+ const { subscription, aiEnabled, exitEnabled, alertStyle, locale } = req.body ?? {};
30
31
  if (!clientId
31
32
  || typeof subscription?.endpoint !== 'string'
32
33
  || subscription.endpoint.length > 4096
@@ -49,19 +50,24 @@ router.post('/subscribe', (req, res) => {
49
50
  auth: subscription.keys.auth,
50
51
  },
51
52
  aiEnabled: aiEnabled !== false,
53
+ exitEnabled: exitEnabled === true,
52
54
  alertStyle,
53
55
  locale: typeof locale === 'string' ? locale.slice(0, 32) : 'zh-CN',
54
56
  });
55
57
  res.json({ ok: true, updatedAt: saved.updatedAt });
56
58
  });
57
59
  router.post('/preferences', (req, res) => {
58
- const { aiEnabled, alertStyle, locale } = req.body ?? {};
59
- if (typeof aiEnabled !== 'boolean' || !validAlertStyle(alertStyle)) {
60
+ const { aiEnabled, exitEnabled, alertStyle, locale } = req.body ?? {};
61
+ if (typeof aiEnabled !== 'boolean'
62
+ || (exitEnabled !== undefined && typeof exitEnabled !== 'boolean')
63
+ || !validAlertStyle(alertStyle)) {
60
64
  res.status(400).json({ error: 'Invalid notification preferences' });
61
65
  return;
62
66
  }
63
67
  const updated = updatePushPreferences(clientIdFromRequest(req), {
64
68
  aiEnabled,
69
+ // Optional so stale cached clients don't wipe the newer toggle.
70
+ ...(typeof exitEnabled === 'boolean' ? { exitEnabled } : {}),
65
71
  alertStyle,
66
72
  locale: typeof locale === 'string' ? locale.slice(0, 32) : 'zh-CN',
67
73
  });