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.
Files changed (84) hide show
  1. package/dist/client/assets/{abnfDiagram-VRR7QNED-qNu2vFJo.js → abnfDiagram-VRR7QNED-Bt36T7gE.js} +1 -1
  2. package/dist/client/assets/{arc-XOLYVcfU.js → arc-DhZtXG4q.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-gEgrl891.js → architectureDiagram-ZJ3FMSHR-C4BLot_l.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-BIsgaqUa.js → blockDiagram-677ZJIJ3-BG8NrPiD.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-chMSg_gz.js → c4Diagram-LMCZKHZV-m3lWYsQI.js} +1 -1
  6. package/dist/client/assets/channel-Ck9tD_4_.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-CSiy2hKA.js → chunk-2Q5K7J3B-BiBc5Uv0.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-DuZ1_X7h.js → chunk-32BRIVSS-C1g4c11U.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-0fSZFelD.js → chunk-5VM5RSS4-Ctw1x2FG.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-D9fvCCKw.js → chunk-EX3LRPZG-BdI4v3f4.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-DL8-KfNd.js → chunk-JWPE2WC7-K8nPht_5.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-BCbI3rc9.js → chunk-MOJQB5TN-DWsVsFoK.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-DYt4CpFP.js → chunk-RYQCIY6F-0MHmqGNs.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-D12qt-EZ.js → chunk-V7JOEXUC-BjQQBzKq.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-BqjiNvma.js → chunk-VR4S4FIN-Bau1q0xN.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY-DRSIB4ku.js → chunk-XXDRQBXY-BIzI5y55.js} +1 -1
  17. package/dist/client/assets/classDiagram-OUVF2IWQ-CtEpUyds.js +1 -0
  18. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CtEpUyds.js +1 -0
  19. package/dist/client/assets/{cose-bilkent-JH36ORCC-Cu-QOQlt.js → cose-bilkent-JH36ORCC-C_6nPODG.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-BsyRWhxj.js → cynefin-VYW2F7L2-DZfIXs6F.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Cy_tVdkk.js → cynefinDiagram-TSTJHNR4-Cwtffn_E.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-Bz72Ej-8.js → dagre-VKFMJZFB-n2L4piqF.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-CX4Ns1Cc.js → diagram-FQU43EPY-DYc1Xsf9.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-BbwEVtCg.js → diagram-G47NLZAW-Bty2-g6M.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-CbBWNIjM.js → diagram-NH7WQ7WH-B7NwHUrU.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-Bfrre38v.js → diagram-OA4YK3LP-BB8nlvCx.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-Z3VUSAOB.js → diagram-WEI45ONY-2hoKf8a6.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-D6lySMYl.js → ebnfDiagram-CCIWWBDH-BARVSv3m.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-Bogjh_Ac.js → erDiagram-Q63AITRT-5jgm5Ksi.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-1MjqwNIg.js → flowDiagram-23GEKE2U-BqCA33p5.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-B_nzzngJ.js → ganttDiagram-NO4QXBWP-BL1NGUYm.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DUztfzAM.js → gitGraphDiagram-IHSO6WYX-D9Qw6ov4.js} +1 -1
  33. package/dist/client/assets/index-BYpFR_L5.js +436 -0
  34. package/dist/client/assets/index-DyTRm1PJ.css +1 -0
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-B8tZi8SS.js → infoDiagram-FWYZ7A6U-ZrovoSsZ.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BzJ_wvF8.js → ishikawaDiagram-FXEZZL3T-uFSMTzKn.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BHxCKx6Q.js → journeyDiagram-5HDEW3XC-DNXNzmef.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-Cr6WZvt0.js → kanban-definition-HUTT4EX6-Db2NpkWT.js} +1 -1
  39. package/dist/client/assets/{linear-Cg6MlBZf.js → linear-DjF6fII5.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-C9bkJnmO.js → mermaid.core-BXI9ENYY.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BWAJOQD3.js → mindmap-definition-LN4V7U3C-CjcF3QgK.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-DUxlC5Vz.js → pegDiagram-2B236MQR-BckzTuEj.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-D-yHsCZf.js → pieDiagram-ENE6RG2P-Ch2Ty0Y2.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-D1G2-fGc.js → quadrantDiagram-ABIIQ3AL-DvXeQj4a.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-C_cCBKUg.js → railroadDiagram-RFXS5EU6-BUurE4S_.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-BlbhU39Z.js → requirementDiagram-TGXJPOKE-CfnNu5HR.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-fgbThMOB.js → sankeyDiagram-HTMAVEWB-BfpeK6jA.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BXXXK0iT.js → sequenceDiagram-DBY2YBRQ-CmkcMlBD.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-CziiP2Wx.js → sizeCapture-X5ZJPWSS-BvnnE9Ev.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-LlHMaswp.js → stateDiagram-2N3HPSRC-D-gs0yW-.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-gXYMxyju.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-Cr-G_sZ2.js → swimlanes-5IMT3BWC-BW65nQch.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DmhG9XuP.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-CbU0-g8d.js → timeline-definition-FHXFAJF6-Mj3zCwTc.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-CiQp2lI0.js → vennDiagram-L72KCM5P-gXnoVb66.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-DKH93tLA.js → wardleyDiagram-EHGQE667-Cgbh1_F7.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BAkS37cH.js → xychartDiagram-FW5EYKEG-DQW7oknN.js} +1 -1
  58. package/dist/client/icons/agents/kimi.svg +5 -0
  59. package/dist/client/index.html +3 -3
  60. package/dist/client/sw-notifications.js +163 -36
  61. package/dist/client/sw.js +1 -1
  62. package/dist/server/agent/installers.js +145 -1
  63. package/dist/server/agent/registry.js +3 -0
  64. package/dist/server/cli.js +25 -0
  65. package/dist/server/entry.js +22 -1
  66. package/dist/server/notifications/pushService.js +121 -35
  67. package/dist/server/notifications/pushViewers.js +44 -0
  68. package/dist/server/ptyHost.js +17 -0
  69. package/dist/server/ptyhost/manager.js +152 -5
  70. package/dist/server/quota/tokenDiscovery.js +25 -0
  71. package/dist/server/routes/filesystem.js +4 -2
  72. package/dist/server/routes/notifications.js +9 -3
  73. package/dist/server/routes/terminal.js +82 -106
  74. package/dist/server/utils/contextDraft.js +36 -0
  75. package/dist/server/utils/requestDeadline.js +104 -0
  76. package/dist/server/utils/settings.js +18 -9
  77. package/package.json +1 -1
  78. package/dist/client/assets/channel-B_PO4EuO.js +0 -1
  79. package/dist/client/assets/classDiagram-OUVF2IWQ-DpZIoBFk.js +0 -1
  80. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DpZIoBFk.js +0 -1
  81. package/dist/client/assets/index-BQkNIHH4.css +0 -1
  82. package/dist/client/assets/index-Bzbh0v_1.js +0 -433
  83. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DJI3xM_N.js +0 -1
  84. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BRZt8MRc.js +0 -8
@@ -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
  });
@@ -14,7 +14,8 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
14
14
  import { pathValidator } from '../utils/pathValidator.js';
15
15
  import { TERMINAL, TMUX } from '../config.js';
16
16
  import { localAccessManager } from '../utils/localAccess.js';
17
- import { normalizeLocalAccessName, getActivityReorderSetting, getLocaleSetting, setActivityReorderSetting, setLocaleSetting, } from '../utils/settings.js';
17
+ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, } from '../utils/settings.js';
18
+ import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
18
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
19
20
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
20
21
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
@@ -23,7 +24,8 @@ import { applyAgentEvent, defaultAgentSessionState, parseAgentEvent, } from '../
23
24
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
24
25
  import { loadPlugins, savePlugin, removePlugin, readPluginIcon, } from '../agent/plugins.js';
25
26
  import { registerPluginAgents } from '../agent/registry.js';
26
- import { notifyAgentTransition } from '../notifications/pushService.js';
27
+ import { notifyAgentTransition, notifyTerminalExit } from '../notifications/pushService.js';
28
+ import { setClientViewingSession } from '../notifications/pushViewers.js';
27
29
  import { getQuotaStatus, refreshQuota, startQuotaManager, } from '../quota/QuotaManager.js';
28
30
  const router = express.Router();
29
31
  const execFileAsync = promisify(execFile);
@@ -106,81 +108,6 @@ let lastBroadcastClientStateSignature = null;
106
108
  const inventoryOpenLocks = new Map();
107
109
  const CONTROL_BROADCAST_COALESCE_MS = 50;
108
110
  const SESSION_INVENTORY_CACHE_TTL_MS = 1500;
109
- // 最近活跃优先排序:不是竞速榜,只分「最近活跃」和「非活跃」两组。
110
- // 活跃组整体在前,组内保持当前相对顺序,避免两个持续输出的 session
111
- // 按毫秒级 lastActivity 来回抢第一个位置。
112
- const RECENT_ACTIVITY_WINDOW_MS = 60_000;
113
- const ACTIVITY_REORDER_COOLDOWN_MS = 5_000;
114
- const ACTIVITY_REORDER_PERIODIC_MS = 30_000;
115
- let activityReorderTimer = null;
116
- let activityReorderPeriodicTimer = null;
117
- let activityReorderEnabled = getActivityReorderSetting();
118
- function getEffectiveLastActivity(session) {
119
- // 只用 PTY 输出时间判断「最近活跃」——resize / input / connect
120
- // 不参与排序,避免拖窗口尺寸等非输出操作导致 session 跳到前面。
121
- if (session.backendSessionId) {
122
- const backend = terminalSessions.get(session.backendSessionId);
123
- if (backend)
124
- return backend.lastOutputAt;
125
- }
126
- return session.lastActivity;
127
- }
128
- function applyActivityBasedReorder() {
129
- if (!activityReorderEnabled)
130
- return;
131
- const sessions = globalSessionState.sessions;
132
- if (sessions.length <= 1)
133
- return;
134
- const now = Date.now();
135
- const recentlyActive = [];
136
- const inactive = [];
137
- for (const session of sessions) {
138
- const la = getEffectiveLastActivity(session);
139
- if (now - la <= RECENT_ACTIVITY_WINDOW_MS) {
140
- recentlyActive.push(session);
141
- }
142
- else {
143
- inactive.push(session);
144
- }
145
- }
146
- // 两个 bucket 内各自保持原有相对顺序,只是活跃组整体提前
147
- const reordered = [...recentlyActive, ...inactive];
148
- const orderChanged = sessions.map(s => s.sessionId).join('\x00') !== reordered.map(s => s.sessionId).join('\x00');
149
- if (!orderChanged)
150
- return;
151
- globalSessionState = {
152
- sessions: reordered,
153
- updatedAt: Date.now(),
154
- };
155
- persistAndBroadcastGlobalState();
156
- }
157
- function scheduleActivityReorder() {
158
- if (activityReorderTimer)
159
- return;
160
- activityReorderTimer = setTimeout(() => {
161
- activityReorderTimer = null;
162
- applyActivityBasedReorder();
163
- }, ACTIVITY_REORDER_COOLDOWN_MS);
164
- activityReorderTimer.unref?.();
165
- }
166
- function startActivityReorderPeriodic() {
167
- if (activityReorderPeriodicTimer)
168
- return;
169
- activityReorderPeriodicTimer = setInterval(() => {
170
- applyActivityBasedReorder();
171
- }, ACTIVITY_REORDER_PERIODIC_MS);
172
- activityReorderPeriodicTimer.unref?.();
173
- }
174
- function stopActivityReorderPeriodic() {
175
- if (activityReorderPeriodicTimer) {
176
- clearInterval(activityReorderPeriodicTimer);
177
- activityReorderPeriodicTimer = null;
178
- }
179
- if (activityReorderTimer) {
180
- clearTimeout(activityReorderTimer);
181
- activityReorderTimer = null;
182
- }
183
- }
184
111
  async function getSessionInventorySnapshot(options = {}) {
185
112
  const now = Date.now();
186
113
  if (!options.refresh && latestSessionInventory && now - latestSessionInventoryAt < SESSION_INVENTORY_CACHE_TTL_MS) {
@@ -462,8 +389,8 @@ function flushPersistAndExit() {
462
389
  }
463
390
  catch { /* best effort */ }
464
391
  }
465
- process.on('SIGTERM', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
466
- process.on('SIGINT', () => { stopActivityReorderPeriodic(); flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
392
+ process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
393
+ process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
467
394
  // 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
468
395
  void (async () => {
469
396
  await loadGlobalSessionStateFromDisk();
@@ -476,8 +403,6 @@ void (async () => {
476
403
  });
477
404
  pruneOrphanSessions();
478
405
  await backfillPersistedTmuxMetadata();
479
- startActivityReorderPeriodic();
480
- applyActivityBasedReorder();
481
406
  })();
482
407
  caffeinateManager.startNetworkMonitor();
483
408
  // 清理磁盘恢复后后端已不存在的 session 引用。
@@ -2777,10 +2702,26 @@ async function enableTmuxFocusEvents() {
2777
2702
  await runTmux(['set-option', '-g', 'focus-events', 'on']);
2778
2703
  console.log('[tmux-focus] enabled global focus-events');
2779
2704
  }
2705
+ // Let tmux forward modified Enter keys (Ctrl/Shift/Alt+Enter) to pane apps.
2706
+ // The option only exists in tmux >= 3.2; on older versions the probe fails
2707
+ // and we silently skip.
2708
+ async function enableTmuxExtendedKeys() {
2709
+ try {
2710
+ const current = (await runTmux(['show-options', '-gqv', 'extended-keys'])).trim();
2711
+ if (current === 'on')
2712
+ return;
2713
+ await runTmux(['set-option', '-g', 'extended-keys', 'on']);
2714
+ console.log('[tmux] enabled global extended-keys');
2715
+ }
2716
+ catch {
2717
+ // extended-keys unsupported on this tmux; nothing to do
2718
+ }
2719
+ }
2780
2720
  async function ensureSharedTmuxServerReady() {
2781
2721
  await ensureTmuxColorEnvironment();
2782
2722
  await applyTmuxScrollbackProfile();
2783
2723
  await enableTmuxFocusEvents();
2724
+ await enableTmuxExtendedKeys();
2784
2725
  await configureTmuxWheelBindings();
2785
2726
  }
2786
2727
  async function stampTmuxMetadata(sessionName) {
@@ -3310,7 +3251,6 @@ function processSessionOutput(sessionId, session, data, opts) {
3310
3251
  session.lastActivity = Date.now();
3311
3252
  session.lastOutputAt = Date.now();
3312
3253
  session.hasWrittenData = true;
3313
- scheduleActivityReorder();
3314
3254
  }
3315
3255
  let seq;
3316
3256
  if (session.mode === 'shell') {
@@ -3384,6 +3324,17 @@ function setupPtyHandlers(sessionId, session) {
3384
3324
  session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
3385
3325
  console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
3386
3326
  broadcastEvent(sessionId, { type: 'exit', exitCode, signal });
3327
+ // User-initiated closes dispose this listener before killing the pty, so
3328
+ // reaching here means the process exited on its own. Sessions with a live
3329
+ // agent already get their exit covered by notifyAgentTransition — skip
3330
+ // those to avoid two notifications for one event.
3331
+ const snapshot = lastAgentStatusSnapshots.get(sessionId);
3332
+ const hasLiveAgent = snapshot
3333
+ ? JSON.parse(snapshot).agentStatus !== null
3334
+ : false;
3335
+ if (!hasLiveAgent) {
3336
+ notifyTerminalExit(sessionId, typeof exitCode === 'number' ? exitCode : null);
3337
+ }
3387
3338
  cleanupSession(sessionId, { killProcess: false });
3388
3339
  });
3389
3340
  }
@@ -3982,7 +3933,6 @@ router.get('/client-state', async (_req, res) => {
3982
3933
  });
3983
3934
  router.get('/session-inventory', async (_req, res) => {
3984
3935
  try {
3985
- applyActivityBasedReorder();
3986
3936
  const inventory = await getSessionInventorySnapshot();
3987
3937
  res.json(inventory);
3988
3938
  }
@@ -4153,6 +4103,7 @@ async function getSettingsPayload() {
4153
4103
  caffeinateActive: caffeinateManager.isActive(),
4154
4104
  networkAvailable: caffeinateManager.isNetworkAvailable(),
4155
4105
  locale: getLocaleSetting(),
4106
+ contextDraftHeight: getContextDraftHeightSetting(),
4156
4107
  localAccess: {
4157
4108
  ...localAccess,
4158
4109
  interfaces,
@@ -4160,29 +4111,6 @@ async function getSettingsPayload() {
4160
4111
  },
4161
4112
  };
4162
4113
  }
4163
- // ── Activity reorder preference ──────────────────────────────────────
4164
- router.get('/settings/activity-reorder', (_req, res) => {
4165
- res.json({ enabled: activityReorderEnabled });
4166
- });
4167
- router.put('/settings/activity-reorder', (req, res) => {
4168
- const body = req.body ?? {};
4169
- if (typeof body.enabled === 'boolean') {
4170
- setActivityReorderSetting(body.enabled);
4171
- activityReorderEnabled = body.enabled;
4172
- // 关闭后立刻停掉还在队列里的 reorder timer
4173
- if (!activityReorderEnabled && activityReorderTimer) {
4174
- clearTimeout(activityReorderTimer);
4175
- activityReorderTimer = null;
4176
- }
4177
- // 如果重新开启,立刻执行一次排序
4178
- if (activityReorderEnabled) {
4179
- applyActivityBasedReorder();
4180
- }
4181
- res.json({ enabled: activityReorderEnabled });
4182
- return;
4183
- }
4184
- res.status(400).json({ error: '"enabled" must be a boolean' });
4185
- });
4186
4114
  // ── Settings (prevent sleep) ──────────────────────────────────────────
4187
4115
  router.get('/settings', async (_req, res) => {
4188
4116
  res.json(await getSettingsPayload());
@@ -4195,6 +4123,18 @@ router.put('/settings', async (req, res) => {
4195
4123
  if (typeof body.locale === 'string' && (body.locale === 'zh' || body.locale === 'en')) {
4196
4124
  setLocaleSetting(body.locale);
4197
4125
  }
4126
+ if (body.contextDraftHeight && typeof body.contextDraftHeight === 'object') {
4127
+ const heightBody = body.contextDraftHeight;
4128
+ for (const device of ['mobile', 'desktop']) {
4129
+ const value = heightBody[device];
4130
+ if (value === null) {
4131
+ setContextDraftHeightSetting(device, null);
4132
+ }
4133
+ else if (typeof value === 'number' && Number.isFinite(value) && value >= 56 && value <= 4000) {
4134
+ setContextDraftHeightSetting(device, Math.round(value));
4135
+ }
4136
+ }
4137
+ }
4198
4138
  if (body.localAccess && typeof body.localAccess === 'object') {
4199
4139
  const localAccessBody = body.localAccess;
4200
4140
  if (localAccessBody.reset === true) {
@@ -4215,6 +4155,27 @@ router.put('/settings', async (req, res) => {
4215
4155
  }
4216
4156
  res.json(await getSettingsPayload());
4217
4157
  });
4158
+ // ── Context draft (cross-device realtime sync) ────────────────────────
4159
+ // 草稿内容存服务端(~/.termdock/context-draft.json),PUT 后通过 control
4160
+ // WebSocket 广播给其它客户端;携带 origin 让发送方忽略自己的回声。
4161
+ router.get('/context-draft', (_req, res) => {
4162
+ res.json(loadContextDraft());
4163
+ });
4164
+ router.put('/context-draft', (req, res) => {
4165
+ const body = req.body ?? {};
4166
+ if (typeof body.text !== 'string') {
4167
+ res.status(400).json({ error: 'Invalid draft text', code: 'INVALID_CONTEXT_DRAFT' });
4168
+ return;
4169
+ }
4170
+ const doc = saveContextDraft(body.text);
4171
+ broadcastControlEvent({
4172
+ type: 'context-draft',
4173
+ text: doc.text,
4174
+ updatedAt: doc.updatedAt,
4175
+ origin: typeof body.origin === 'string' ? body.origin : null,
4176
+ });
4177
+ res.json(doc);
4178
+ });
4218
4179
  // ── Agent hooks API (rich status channel installers, built-in + plugin) ──
4219
4180
  router.get('/agent-hooks', (_req, res) => {
4220
4181
  res.json({ agents: listAllHookAgents() });
@@ -5355,6 +5316,18 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
5355
5316
  updateClientFocusState(sessionId, session, clientId, focused, reason);
5356
5317
  break;
5357
5318
  }
5319
+ case 'viewing': {
5320
+ // Push suppression: while this client is actively viewing the
5321
+ // session, its agent-transition push is redundant — skip it.
5322
+ // Kept separate from 'focus' (which requires textarea focus for
5323
+ // tmux focus tracking): on mobile the keyboard is routinely
5324
+ // dismissed while the user is still watching the session.
5325
+ if (options.pushClientId) {
5326
+ const viewing = msg.viewing === true;
5327
+ setClientViewingSession(options.pushClientId, sessionId, viewing);
5328
+ }
5329
+ break;
5330
+ }
5358
5331
  case 'flow-control': {
5359
5332
  if (typeof msg.paused === 'boolean') {
5360
5333
  const reason = typeof msg.reason === 'string' ? msg.reason : 'client-flow-control';
@@ -5404,6 +5377,9 @@ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
5404
5377
  }
5405
5378
  }
5406
5379
  removeClientFocus(sessionId, session, clientId);
5380
+ if (options.pushClientId) {
5381
+ setClientViewingSession(options.pushClientId, sessionId, false);
5382
+ }
5407
5383
  removeClientFlowPaused(sessionId, session, clientId, 'client-disconnect');
5408
5384
  syncClientCountToTmux(sessionId);
5409
5385
  broadcastClientState();