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.
- package/.desktop-dist/main.js +95 -27
- package/dist/client/assets/{abnfDiagram-VRR7QNED-B3X7vTL7.js → abnfDiagram-VRR7QNED-In9dJ8xk.js} +1 -1
- package/dist/client/assets/{arc-OmYbmu75.js → arc-OgDXNUfC.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CNNIlew_.js → architectureDiagram-ZJ3FMSHR-CUHWXG-6.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-eoDvsGst.js → blockDiagram-677ZJIJ3-D6iMCZrE.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BYElS7nn.js → c4Diagram-LMCZKHZV-BQIYSHIO.js} +1 -1
- package/dist/client/assets/channel-DR9ZrrsH.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-DgBhC1KN.js → chunk-2Q5K7J3B-VrJ2oEjG.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-B4LjEHPP.js → chunk-32BRIVSS-DgE4tlwU.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-BrBK8Xy3.js → chunk-5VM5RSS4-DPL1VUbd.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-QndtZFQ6.js → chunk-EX3LRPZG-Cnzcl0zo.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-B8bZHm4g.js → chunk-JWPE2WC7-BwjoHnSm.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-B5Kzsd5Z.js → chunk-MOJQB5TN-Q1VspcHf.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-BdNa9gwg.js → chunk-RYQCIY6F-C3B0hROO.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-DpQA4l-k.js → chunk-V7JOEXUC-CT1WyL44.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-CtGdy3K8.js → chunk-VR4S4FIN-RHg32W0I.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-CZBBj4P_.js → chunk-XXDRQBXY-B8nloyVH.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CSyi0dnm.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CSyi0dnm.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-gtzwCMyn.js → cose-bilkent-JH36ORCC-DM9b-HC2.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-B7X5gKj-.js → cynefin-VYW2F7L2-DyfTbIta.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BoKfaePi.js → cynefinDiagram-TSTJHNR4-B0TfoSKo.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-Bjh8y-RO.js → dagre-VKFMJZFB-DoYxgTE_.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-Cx8vWSw0.js → diagram-FQU43EPY-BhdYoO5s.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-DyKuyGsn.js → diagram-G47NLZAW-DU-PG20h.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-DwnG0pD9.js → diagram-NH7WQ7WH-WcnJXUD6.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-wn7Eux1Q.js → diagram-OA4YK3LP-N1S0AWUf.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-DNy0FovQ.js → diagram-WEI45ONY-DBhEABdQ.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-TFnZlH4k.js → ebnfDiagram-CCIWWBDH-CPhAEOjW.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-Cx3_8T9Q.js → erDiagram-Q63AITRT-416GnX06.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-CZ7KiBe4.js → flowDiagram-23GEKE2U-CQY7Be4e.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-BRTRytgo.js → ganttDiagram-NO4QXBWP-CCdUVGY1.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DyNUwP6-.js → gitGraphDiagram-IHSO6WYX-DihTneNH.js} +1 -1
- package/dist/client/assets/index-DyTRm1PJ.css +1 -0
- package/dist/client/assets/index-SiGQTF6y.js +436 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-ClLiuIL8.js → infoDiagram-FWYZ7A6U-DxVtT8XS.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CbO_ecRd.js → ishikawaDiagram-FXEZZL3T-CI04eDRq.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-YKjq3svJ.js → journeyDiagram-5HDEW3XC-Dku9Ke-J.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CcnV3jvx.js → kanban-definition-HUTT4EX6-X-7cHaWp.js} +1 -1
- package/dist/client/assets/{linear-3N9evV-e.js → linear-BfhGmqE2.js} +1 -1
- package/dist/client/assets/{mermaid.core-C8HeTU54.js → mermaid.core-lwiCvpBd.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CgkCCxMO.js → mindmap-definition-LN4V7U3C-DI3swXAM.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-CQRK4LBF.js → pegDiagram-2B236MQR-ByBkCa7g.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-BHrDXKeR.js → pieDiagram-ENE6RG2P-CAbocCk6.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CuJ9UyOO.js → quadrantDiagram-ABIIQ3AL-lE4NNIct.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-D0lUBYT1.js → railroadDiagram-RFXS5EU6-DVG7K2hA.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-s5kYB_Ya.js → requirementDiagram-TGXJPOKE-BhjZccsb.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-Bhw1mAOH.js → sankeyDiagram-HTMAVEWB-BROBV7xf.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-qnNrmb6g.js → sequenceDiagram-DBY2YBRQ-DCiUXSQK.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DufN5SxK.js → sizeCapture-X5ZJPWSS-wCRp1dLA.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Y5rMnVoS.js → stateDiagram-2N3HPSRC-GnItAgu1.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DodZ2p7d.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-Cmrmdfpk.js → swimlanes-5IMT3BWC-DbXlO1IP.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-Dh3DdtaE.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-CqHH1NSC.js → timeline-definition-FHXFAJF6-CQjgTVIW.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-C2l_9LuC.js → vennDiagram-L72KCM5P-Bz_Lzf5a.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-CNf23qHc.js → wardleyDiagram-EHGQE667-Cgw3S7By.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-RIp6RoVE.js → xychartDiagram-FW5EYKEG-IyZJ_VfA.js} +1 -1
- package/dist/client/icons/agents/kimi.svg +5 -0
- package/dist/client/index.html +3 -3
- package/dist/client/sw-notifications.js +163 -36
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/installers.js +145 -1
- package/dist/server/agent/registry.js +3 -0
- package/dist/server/cli.js +25 -0
- package/dist/server/entry.js +53 -13
- package/dist/server/notifications/pushService.js +121 -35
- package/dist/server/notifications/pushViewers.js +44 -0
- package/dist/server/ptyHost.js +17 -0
- package/dist/server/ptyhost/manager.js +152 -5
- package/dist/server/quota/tokenDiscovery.js +25 -0
- package/dist/server/routes/filesystem.js +4 -2
- package/dist/server/routes/notifications.js +9 -3
- package/dist/server/routes/terminal.js +82 -106
- package/dist/server/utils/contextDraft.js +36 -0
- package/dist/server/utils/requestDeadline.js +104 -0
- package/dist/server/utils/runtimeClient.js +41 -0
- package/dist/server/utils/settings.js +18 -9
- package/package.json +5 -2
- package/runtime-manifest.json +12 -0
- package/scripts/classify-release.mjs +88 -0
- package/scripts/generate-runtime-manifest.mjs +77 -0
- package/scripts/prepare-desktop-runtime.mjs +27 -3
- package/dist/client/assets/channel-DBZQbHSi.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BsrNzS08.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BsrNzS08.js +0 -1
- package/dist/client/assets/index-CbYQuue6.css +0 -1
- package/dist/client/assets/index-DB-0aiWT.js +0 -433
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BSqYeylv.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
...
|
|
126
|
+
...options.build(entry.locale),
|
|
82
127
|
alertStyle: entry.alertStyle,
|
|
83
128
|
locale: entry.locale,
|
|
84
129
|
}), {
|
|
85
|
-
TTL: 60 * 60,
|
|
86
|
-
urgency:
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
package/dist/server/ptyHost.js
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2459
|
-
|
|
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'
|
|
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
|
});
|