termdock 1.4.42 → 1.4.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-qNu2vFJo.js → abnfDiagram-VRR7QNED-Bt36T7gE.js} +1 -1
- package/dist/client/assets/{arc-XOLYVcfU.js → arc-DhZtXG4q.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-gEgrl891.js → architectureDiagram-ZJ3FMSHR-C4BLot_l.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-BIsgaqUa.js → blockDiagram-677ZJIJ3-BG8NrPiD.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-chMSg_gz.js → c4Diagram-LMCZKHZV-m3lWYsQI.js} +1 -1
- package/dist/client/assets/channel-Ck9tD_4_.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-CSiy2hKA.js → chunk-2Q5K7J3B-BiBc5Uv0.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-DuZ1_X7h.js → chunk-32BRIVSS-C1g4c11U.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-0fSZFelD.js → chunk-5VM5RSS4-Ctw1x2FG.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-D9fvCCKw.js → chunk-EX3LRPZG-BdI4v3f4.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-DL8-KfNd.js → chunk-JWPE2WC7-K8nPht_5.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-BCbI3rc9.js → chunk-MOJQB5TN-DWsVsFoK.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DYt4CpFP.js → chunk-RYQCIY6F-0MHmqGNs.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-D12qt-EZ.js → chunk-V7JOEXUC-BjQQBzKq.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-BqjiNvma.js → chunk-VR4S4FIN-Bau1q0xN.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-DRSIB4ku.js → chunk-XXDRQBXY-BIzI5y55.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CtEpUyds.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CtEpUyds.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-Cu-QOQlt.js → cose-bilkent-JH36ORCC-C_6nPODG.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-BsyRWhxj.js → cynefin-VYW2F7L2-DZfIXs6F.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Cy_tVdkk.js → cynefinDiagram-TSTJHNR4-Cwtffn_E.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-Bz72Ej-8.js → dagre-VKFMJZFB-n2L4piqF.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-CX4Ns1Cc.js → diagram-FQU43EPY-DYc1Xsf9.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-BbwEVtCg.js → diagram-G47NLZAW-Bty2-g6M.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-CbBWNIjM.js → diagram-NH7WQ7WH-B7NwHUrU.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-Bfrre38v.js → diagram-OA4YK3LP-BB8nlvCx.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-Z3VUSAOB.js → diagram-WEI45ONY-2hoKf8a6.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-D6lySMYl.js → ebnfDiagram-CCIWWBDH-BARVSv3m.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-Bogjh_Ac.js → erDiagram-Q63AITRT-5jgm5Ksi.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-1MjqwNIg.js → flowDiagram-23GEKE2U-BqCA33p5.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-B_nzzngJ.js → ganttDiagram-NO4QXBWP-BL1NGUYm.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DUztfzAM.js → gitGraphDiagram-IHSO6WYX-D9Qw6ov4.js} +1 -1
- package/dist/client/assets/index-BYpFR_L5.js +436 -0
- package/dist/client/assets/index-DyTRm1PJ.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-B8tZi8SS.js → infoDiagram-FWYZ7A6U-ZrovoSsZ.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BzJ_wvF8.js → ishikawaDiagram-FXEZZL3T-uFSMTzKn.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BHxCKx6Q.js → journeyDiagram-5HDEW3XC-DNXNzmef.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-Cr6WZvt0.js → kanban-definition-HUTT4EX6-Db2NpkWT.js} +1 -1
- package/dist/client/assets/{linear-Cg6MlBZf.js → linear-DjF6fII5.js} +1 -1
- package/dist/client/assets/{mermaid.core-C9bkJnmO.js → mermaid.core-BXI9ENYY.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BWAJOQD3.js → mindmap-definition-LN4V7U3C-CjcF3QgK.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-DUxlC5Vz.js → pegDiagram-2B236MQR-BckzTuEj.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-D-yHsCZf.js → pieDiagram-ENE6RG2P-Ch2Ty0Y2.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-D1G2-fGc.js → quadrantDiagram-ABIIQ3AL-DvXeQj4a.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-C_cCBKUg.js → railroadDiagram-RFXS5EU6-BUurE4S_.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-BlbhU39Z.js → requirementDiagram-TGXJPOKE-CfnNu5HR.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-fgbThMOB.js → sankeyDiagram-HTMAVEWB-BfpeK6jA.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BXXXK0iT.js → sequenceDiagram-DBY2YBRQ-CmkcMlBD.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-CziiP2Wx.js → sizeCapture-X5ZJPWSS-BvnnE9Ev.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-LlHMaswp.js → stateDiagram-2N3HPSRC-D-gs0yW-.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-gXYMxyju.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-Cr-G_sZ2.js → swimlanes-5IMT3BWC-BW65nQch.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DmhG9XuP.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-CbU0-g8d.js → timeline-definition-FHXFAJF6-Mj3zCwTc.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-CiQp2lI0.js → vennDiagram-L72KCM5P-gXnoVb66.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DKH93tLA.js → wardleyDiagram-EHGQE667-Cgbh1_F7.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BAkS37cH.js → xychartDiagram-FW5EYKEG-DQW7oknN.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 +22 -1
- 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/settings.js +18 -9
- package/package.json +1 -1
- package/dist/client/assets/channel-B_PO4EuO.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DpZIoBFk.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DpZIoBFk.js +0 -1
- package/dist/client/assets/index-BQkNIHH4.css +0 -1
- package/dist/client/assets/index-Bzbh0v_1.js +0 -433
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DJI3xM_N.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-BRZt8MRc.js +0 -8
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
|
});
|
|
@@ -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,
|
|
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', () => {
|
|
466
|
-
process.on('SIGINT', () => {
|
|
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();
|