@co0ontty/wand 1.22.0 → 1.23.0

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.
@@ -2,11 +2,13 @@ import { createRequire } from "node:module";
2
2
  const require = createRequire(import.meta.url);
3
3
  // neo-blessed 是 CJS,没有匹配 @types/blessed 的 default export,统一当 any 用。
4
4
  const blessed = require("neo-blessed");
5
- const HEADER_HEIGHT = 6;
5
+ const HEADER_HEIGHT = 8;
6
6
  const SESSIONS_HEIGHT = 12;
7
+ const HINT_HEIGHT = 2;
7
8
  const LOG_TOP = HEADER_HEIGHT + SESSIONS_HEIGHT;
8
9
  const LOG_BUFFER_LIMIT = 1000;
9
10
  const RENDER_THROTTLE_MS = 50;
11
+ const TOAST_DEFAULT_TTL = 3500;
10
12
  export function buildLayout() {
11
13
  const screen = blessed.screen({
12
14
  smartCSR: true,
@@ -22,7 +24,7 @@ export function buildLayout() {
22
24
  width: "100%",
23
25
  height: HEADER_HEIGHT,
24
26
  border: { type: "line" },
25
- label: " wand ",
27
+ label: " wand ",
26
28
  tags: true,
27
29
  style: {
28
30
  border: { fg: "cyan" },
@@ -58,7 +60,7 @@ export function buildLayout() {
58
60
  top: LOG_TOP,
59
61
  left: 0,
60
62
  width: "100%",
61
- bottom: 1,
63
+ bottom: HINT_HEIGHT,
62
64
  border: { type: "line" },
63
65
  label: " Logs ",
64
66
  tags: false,
@@ -75,10 +77,82 @@ export function buildLayout() {
75
77
  bottom: 0,
76
78
  left: 0,
77
79
  width: "100%",
80
+ height: HINT_HEIGHT,
81
+ tags: true,
82
+ content: buildHintContent(),
83
+ });
84
+ // —— Toast:右下角 1 行浮层 ——
85
+ const toast = blessed.box({
86
+ parent: screen,
87
+ bottom: HINT_HEIGHT,
88
+ right: 2,
78
89
  height: 1,
90
+ width: "shrink",
91
+ tags: true,
92
+ hidden: true,
93
+ style: { fg: "white" },
94
+ });
95
+ let toastTimer = null;
96
+ // —— Help Overlay ——
97
+ const helpBox = blessed.box({
98
+ parent: screen,
99
+ top: "center",
100
+ left: "center",
101
+ width: 66,
102
+ height: 24,
103
+ border: { type: "line" },
104
+ label: " ✦ Shortcuts ",
105
+ tags: true,
106
+ hidden: true,
107
+ style: {
108
+ border: { fg: "cyan" },
109
+ label: { fg: "cyan", bold: true },
110
+ bg: "black",
111
+ },
112
+ content: buildHelpContent(),
113
+ });
114
+ // —— Detail Overlay(用于显示命令输出详情)。tags 关闭,按纯文本渲染。 ——
115
+ const detailBox = blessed.box({
116
+ parent: screen,
117
+ top: "center",
118
+ left: "center",
119
+ width: "80%",
120
+ height: "70%",
121
+ border: { type: "line" },
122
+ label: " Detail ",
123
+ tags: false,
124
+ hidden: true,
125
+ keys: true,
126
+ scrollable: true,
127
+ alwaysScroll: true,
128
+ scrollbar: { ch: " ", style: { bg: "gray" } },
129
+ style: {
130
+ border: { fg: "magenta" },
131
+ label: { fg: "magenta", bold: true },
132
+ bg: "black",
133
+ },
134
+ });
135
+ // —— Service Control Panel —— 居中浮层,tags: true 用于上色
136
+ const servicePanel = blessed.box({
137
+ parent: screen,
138
+ top: "center",
139
+ left: "center",
140
+ width: 64,
141
+ height: 16,
142
+ border: { type: "line" },
143
+ label: " ✦ Service Control ",
79
144
  tags: true,
80
- content: "{gray-fg}[q]uit [r]efresh [↑↓] navigate{/}",
145
+ hidden: true,
146
+ keys: true,
147
+ style: {
148
+ border: { fg: "yellow" },
149
+ label: { fg: "yellow", bold: true },
150
+ bg: "black",
151
+ },
81
152
  });
153
+ let servicePanelHandlers = null;
154
+ let servicePanelView = null;
155
+ const servicePanelKeys = ["s", "t", "r", "i", "u", "l", "R", "escape", "q"];
82
156
  // 让 sessions 默认获得焦点以接收键盘事件
83
157
  sessions.focus();
84
158
  let renderPending = false;
@@ -98,17 +172,30 @@ export function buildLayout() {
98
172
  renderTimer.unref?.();
99
173
  }
100
174
  function refreshHeader(info) {
175
+ const counts = info.sessionCounts;
176
+ const sess = `{green-fg}${counts.active}{/} active · ` +
177
+ `{gray-fg}${counts.archived}{/} archived · ` +
178
+ `{white-fg}${counts.total}{/} total`;
101
179
  const orphan = info.orphanRecoveredCount > 0
102
180
  ? ` {gray-fg}(${info.orphanRecoveredCount} orphan PTYs cleaned){/}`
103
181
  : "";
104
- const counts = info.sessionCounts;
105
- const sess = `${counts.active} active · ${counts.archived} archived · ${counts.total} total`;
182
+ const schemeColor = info.scheme === "HTTPS" ? "green" : "yellow";
183
+ const serviceBadge = info.serviceInstalled
184
+ ? " {green-fg}[service: on]{/}"
185
+ : " {gray-fg}[service: off]{/}";
186
+ const uptime = formatUptime(Date.now() - info.startedAtMs);
187
+ const mem = formatBytes(info.rssBytes);
106
188
  const lines = [
107
- `{cyan-fg}Version{/} ${info.version}`,
108
- `{cyan-fg}URL{/} ${info.url} {gray-fg}(${info.scheme}, bind ${info.bindAddr}){/}`,
109
- `{cyan-fg}Config{/} ${info.configPath}`,
110
- `{cyan-fg}Database{/} ${info.dbPath}`,
111
- `{cyan-fg}Sessions{/} ${sess}${orphan}`,
189
+ `{cyan-fg}{bold}Version{/} ${info.version}` +
190
+ ` {gray-fg}·{/} {cyan-fg}{bold}Uptime{/} ${uptime}` +
191
+ ` {gray-fg}·{/} {cyan-fg}{bold}Memory{/} ${mem}` +
192
+ serviceBadge,
193
+ `{cyan-fg}{bold}URL{/} {underline}${info.url}{/underline} ` +
194
+ `{${schemeColor}-fg}● ${info.scheme}{/} {gray-fg}(bind ${info.bindAddr}){/}`,
195
+ `{cyan-fg}{bold}Config{/} ${info.configPath}`,
196
+ `{cyan-fg}{bold}Database{/} ${info.dbPath}`,
197
+ `{cyan-fg}{bold}Sessions{/} ${sess}${orphan}`,
198
+ `{gray-fg}按 ? 查看快捷键 · q 退出 · R 重启 · u 检查更新 · s 注册服务 · o 浏览器打开 · c 拷贝 URL{/}`,
112
199
  ];
113
200
  header.setContent(lines.join("\n"));
114
201
  scheduleRender();
@@ -132,11 +219,177 @@ export function buildLayout() {
132
219
  function setSelectionListener(listener) {
133
220
  sessions.on("select item", (_item, index) => listener(index));
134
221
  }
222
+ function showHelp(visible) {
223
+ if (visible) {
224
+ helpBox.setContent(buildHelpContent());
225
+ helpBox.show();
226
+ helpBox.setFront();
227
+ }
228
+ else {
229
+ helpBox.hide();
230
+ sessions.focus();
231
+ }
232
+ scheduleRender();
233
+ }
234
+ function toggleHelp() {
235
+ const next = helpBox.hidden === true;
236
+ showHelp(next);
237
+ return next;
238
+ }
239
+ function showToast(message, level = "info", ttlMs = TOAST_DEFAULT_TTL) {
240
+ const color = level === "error" ? "red" : level === "warn" ? "yellow" : level === "success" ? "green" : "cyan";
241
+ const icon = level === "error" ? "✖" : level === "warn" ? "⚠" : level === "success" ? "✔" : "ℹ";
242
+ toast.setContent(`{${color}-fg}${icon} ${escapeTags(message)}{/}`);
243
+ toast.show();
244
+ toast.setFront();
245
+ if (toastTimer)
246
+ clearTimeout(toastTimer);
247
+ toastTimer = setTimeout(() => {
248
+ toast.hide();
249
+ scheduleRender();
250
+ toastTimer = null;
251
+ }, ttlMs);
252
+ toastTimer.unref?.();
253
+ scheduleRender();
254
+ }
255
+ function confirm(opts) {
256
+ return new Promise((resolve) => {
257
+ const yes = opts.yes || "回车 / y 确认";
258
+ const no = opts.no || "Esc / n 取消";
259
+ const dlg = blessed.box({
260
+ parent: screen,
261
+ top: "center",
262
+ left: "center",
263
+ width: Math.min(78, Math.max(48, opts.body.length + 14)),
264
+ height: 9,
265
+ border: { type: "line" },
266
+ label: ` ${opts.title} `,
267
+ tags: true,
268
+ keys: true,
269
+ style: {
270
+ border: { fg: "yellow" },
271
+ label: { fg: "yellow", bold: true },
272
+ bg: "black",
273
+ },
274
+ content: `\n ${escapeTags(opts.body)}\n\n` +
275
+ ` {green-fg}${yes}{/} {gray-fg}${no}{/}`,
276
+ });
277
+ dlg.setFront();
278
+ dlg.focus();
279
+ const cleanup = (result) => {
280
+ try {
281
+ dlg.destroy();
282
+ }
283
+ catch { /* noop */ }
284
+ sessions.focus();
285
+ scheduleRender();
286
+ resolve(result);
287
+ };
288
+ dlg.key(["enter", "y", "Y"], () => cleanup(true));
289
+ dlg.key(["escape", "n", "N", "q"], () => cleanup(false));
290
+ scheduleRender();
291
+ });
292
+ }
293
+ function showDetail(title, body) {
294
+ detailBox.setLabel(` ${title} (Esc 关闭) `);
295
+ // tags: false 的 box 里 content 直接按字面值渲染。
296
+ detailBox.setContent(body);
297
+ detailBox.show();
298
+ detailBox.setFront();
299
+ detailBox.focus();
300
+ detailBox.key(["escape", "q"], () => hideDetail());
301
+ scheduleRender();
302
+ }
303
+ function hideDetail() {
304
+ detailBox.hide();
305
+ sessions.focus();
306
+ scheduleRender();
307
+ }
308
+ function clearLogs() {
309
+ // neo-blessed.log 没有公开 clear 方法,直接重置 content + scrollback
310
+ try {
311
+ logbox.setContent("");
312
+ logbox._clines && (logbox._clines.length = 0);
313
+ }
314
+ catch { /* noop */ }
315
+ scheduleRender();
316
+ }
317
+ function openServicePanel(handlers, initial) {
318
+ servicePanelHandlers = handlers;
319
+ servicePanelView = initial;
320
+ renderServicePanel();
321
+ servicePanel.show();
322
+ servicePanel.setFront();
323
+ servicePanel.focus();
324
+ // 绑定一次性按键
325
+ for (const k of servicePanelKeys)
326
+ servicePanel.unkey(k, () => { });
327
+ servicePanel.key(["s"], () => { void handlers.onStart(); });
328
+ servicePanel.key(["t"], () => { void handlers.onStop(); });
329
+ servicePanel.key(["r"], () => { void handlers.onRestart(); });
330
+ servicePanel.key(["R"], () => { void handlers.onRefresh(); });
331
+ servicePanel.key(["i"], () => { void handlers.onInstall(); });
332
+ servicePanel.key(["u"], () => { void handlers.onUninstall(); });
333
+ servicePanel.key(["l"], () => { void handlers.onLogs(); });
334
+ servicePanel.key(["escape", "q"], () => { handlers.onClose(); });
335
+ scheduleRender();
336
+ }
337
+ function updateServicePanel(view) {
338
+ servicePanelView = view;
339
+ renderServicePanel();
340
+ scheduleRender();
341
+ }
342
+ function closeServicePanel() {
343
+ servicePanel.hide();
344
+ servicePanelHandlers = null;
345
+ sessions.focus();
346
+ scheduleRender();
347
+ }
348
+ function isServicePanelOpen() {
349
+ return servicePanel.hidden !== true;
350
+ }
351
+ function renderServicePanel() {
352
+ if (!servicePanelView)
353
+ return;
354
+ const v = servicePanelView;
355
+ const stateColor = v.state === "active" ? "green" :
356
+ v.state === "failed" ? "red" :
357
+ v.state === "inactive" ? "yellow" :
358
+ v.state === "unsupported" ? "gray" : "white";
359
+ const platformLabel = v.platform === "linux" ? "systemd (--user)" :
360
+ v.platform === "darwin" ? "launchd (LaunchAgents)" :
361
+ v.platform;
362
+ const installedBadge = v.installed
363
+ ? "{green-fg}[installed]{/}"
364
+ : "{gray-fg}[not installed]{/}";
365
+ const lastAction = v.lastAction
366
+ ? `\n {gray-fg}最近操作:{/} ${escapeTags(v.lastAction)}`
367
+ : "";
368
+ const lines = [
369
+ "",
370
+ ` {cyan-fg}{bold}Backend{/bold}{/} ${platformLabel} ${installedBadge}`,
371
+ ` {cyan-fg}{bold}State{/bold}{/} {${stateColor}-fg}${escapeTags(v.statusLine)}{/}`,
372
+ lastAction ? lastAction.replace(/^\n/, "") : "",
373
+ "",
374
+ " {yellow-fg}{bold}Actions{/bold}{/}",
375
+ " {green-fg}s{/} 启动 (start) {yellow-fg}t{/} 停止 (stop)",
376
+ " {magenta-fg}r{/} 重启 (restart) {cyan-fg}R{/} 刷新状态",
377
+ " {white-fg}i{/} 注册到系统 {gray-fg}u{/} 卸载",
378
+ " {blue-fg}l{/} 查看日志 (journalctl)",
379
+ "",
380
+ " {gray-fg}按 Esc / q 关闭面板{/}",
381
+ ];
382
+ servicePanel.setContent(lines.join("\n"));
383
+ }
135
384
  function destroy() {
136
385
  if (renderTimer) {
137
386
  clearTimeout(renderTimer);
138
387
  renderTimer = null;
139
388
  }
389
+ if (toastTimer) {
390
+ clearTimeout(toastTimer);
391
+ toastTimer = null;
392
+ }
140
393
  try {
141
394
  screen.destroy();
142
395
  }
@@ -148,9 +401,83 @@ export function buildLayout() {
148
401
  refreshSessions,
149
402
  appendLog,
150
403
  setSelectionListener,
404
+ showHelp,
405
+ toggleHelp,
406
+ showToast,
407
+ confirm,
408
+ showDetail,
409
+ hideDetail,
410
+ clearLogs,
411
+ openServicePanel,
412
+ updateServicePanel,
413
+ closeServicePanel,
414
+ isServicePanelOpen,
151
415
  destroy,
152
416
  };
153
417
  }
418
+ function buildHintContent() {
419
+ // 两行底栏,分组展示,避免单行过长。
420
+ const line1 = "{gray-fg}{bold}NAV{/bold}{/} " +
421
+ "{cyan-fg}↑↓{/} 选择 " +
422
+ "{cyan-fg}r{/} 刷新 " +
423
+ "{cyan-fg}l{/} 清日志 " +
424
+ "{cyan-fg}?{/} 帮助 " +
425
+ "{cyan-fg}q{/} 退出";
426
+ const line2 = "{gray-fg}{bold}OPS{/bold}{/} " +
427
+ "{magenta-fg}g{/} 服务面板 " +
428
+ "{magenta-fg}R{/} 重启 " +
429
+ "{magenta-fg}u{/} 更新 " +
430
+ "{magenta-fg}o{/} 浏览器 " +
431
+ "{magenta-fg}c{/} 拷贝 URL";
432
+ return `${line1}\n${line2}`;
433
+ }
434
+ function buildHelpContent() {
435
+ const rows = [
436
+ ["↑ / ↓", "在会话列表中上下移动"],
437
+ ["r", "立即刷新 header 与会话列表"],
438
+ ["l", "清空日志面板"],
439
+ ["? / h", "切换本帮助面板"],
440
+ ["q / Ctrl-C", "退出 wand"],
441
+ ["—", "—"],
442
+ ["g", "打开服务控制面板 (status/start/stop/restart/logs)"],
443
+ ["R", "重启 wand 进程(保留同一组 argv)"],
444
+ ["u", "检查 npm 更新,可选择安装"],
445
+ ["o", "在默认浏览器中打开 URL"],
446
+ ["c", "复制 URL 到剪贴板"],
447
+ ["s", "注册为系统服务 (systemd / launchd)"],
448
+ ["S", "卸载系统服务"],
449
+ ];
450
+ const lines = [
451
+ "",
452
+ " {cyan-fg}{bold}NAV{/bold}{/} 浏览 & 渲染",
453
+ "",
454
+ ];
455
+ let inOps = false;
456
+ for (const [k, desc] of rows) {
457
+ if (k === "—") {
458
+ lines.push("");
459
+ lines.push(" {magenta-fg}{bold}OPS{/bold}{/} 运维操作");
460
+ lines.push("");
461
+ inOps = true;
462
+ continue;
463
+ }
464
+ const color = inOps ? "magenta-fg" : "cyan-fg";
465
+ lines.push(` {${color}}${padRight(k, 14)}{/}${desc}`);
466
+ }
467
+ lines.push("");
468
+ lines.push(" {gray-fg}按 Esc / ? / h 关闭{/}");
469
+ return lines.join("\n");
470
+ }
471
+ function padRight(s, width) {
472
+ if (s.length >= width)
473
+ return s;
474
+ return s + " ".repeat(width - s.length);
475
+ }
476
+ function escapeTags(text) {
477
+ // 把可能被 blessed tag parser 吞掉的 `{...}` 转为视觉相近的全角字符。
478
+ // 这是给"tags: true"box 里嵌入用户文本时用的(confirm body / toast 等)。
479
+ return text.replace(/\{/g, "{").replace(/\}/g, "}");
480
+ }
154
481
  function formatRow(row) {
155
482
  const glyphColor = toneColor(row.tone);
156
483
  const glyph = `{${glyphColor}-fg}${row.glyph}{/}`;
@@ -196,3 +523,30 @@ function formatTs(ms) {
196
523
  const ss = String(d.getSeconds()).padStart(2, "0");
197
524
  return `${hh}:${mm}:${ss}`;
198
525
  }
526
+ function formatUptime(ms) {
527
+ if (!Number.isFinite(ms) || ms < 0)
528
+ ms = 0;
529
+ const sec = Math.floor(ms / 1000);
530
+ if (sec < 60)
531
+ return `${sec}s`;
532
+ const min = Math.floor(sec / 60);
533
+ if (min < 60)
534
+ return `${min}m ${sec % 60}s`;
535
+ const hr = Math.floor(min / 60);
536
+ if (hr < 24)
537
+ return `${hr}h ${min % 60}m`;
538
+ const d = Math.floor(hr / 24);
539
+ return `${d}d ${hr % 24}h`;
540
+ }
541
+ function formatBytes(bytes) {
542
+ if (!Number.isFinite(bytes) || bytes <= 0)
543
+ return "0 B";
544
+ const units = ["B", "KB", "MB", "GB"];
545
+ let value = bytes;
546
+ let i = 0;
547
+ while (value >= 1024 && i < units.length - 1) {
548
+ value /= 1024;
549
+ i++;
550
+ }
551
+ return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
552
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 服务控制面板的业务回调。本地 TUI 和 attach TUI 复用同一份逻辑。
3
+ *
4
+ * 面板按键与处理函数:
5
+ * s — 启动服务
6
+ * t — 停止服务 (有确认)
7
+ * r — 重启服务 (有确认)
8
+ * R — 仅刷新状态行
9
+ * i — 安装到系统
10
+ * u — 卸载
11
+ * l — 查看最近日志
12
+ * Esc / q — 关闭面板
13
+ */
14
+ import { LayoutHandle } from "./layout.js";
15
+ export interface ServicePanelDeps {
16
+ layout: LayoutHandle;
17
+ configPath: string;
18
+ }
19
+ export declare function openServicePanel(deps: ServicePanelDeps): void;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 服务控制面板的业务回调。本地 TUI 和 attach TUI 复用同一份逻辑。
3
+ *
4
+ * 面板按键与处理函数:
5
+ * s — 启动服务
6
+ * t — 停止服务 (有确认)
7
+ * r — 重启服务 (有确认)
8
+ * R — 仅刷新状态行
9
+ * i — 安装到系统
10
+ * u — 卸载
11
+ * l — 查看最近日志
12
+ * Esc / q — 关闭面板
13
+ */
14
+ import { installService, isServiceInstalled, serviceLogs, serviceRestart, serviceStart, serviceStatus, serviceStop, uninstallService, } from "./commands.js";
15
+ export function openServicePanel(deps) {
16
+ const { layout, configPath } = deps;
17
+ let lastAction;
18
+ function computeView() {
19
+ const s = serviceStatus();
20
+ return {
21
+ statusLine: s.description,
22
+ state: s.state,
23
+ installed: s.installed,
24
+ platform: s.platform,
25
+ lastAction,
26
+ };
27
+ }
28
+ function setLastAction(msg) {
29
+ const ts = new Date().toLocaleTimeString();
30
+ lastAction = `${ts} ${msg}`;
31
+ layout.updateServicePanel(computeView());
32
+ }
33
+ function handleResult(label, result) {
34
+ layout.showToast(result.message, result.ok ? "success" : "error", 3500);
35
+ if (result.detail) {
36
+ layout.showDetail(`${label} ${result.ok ? "输出" : "失败"}`, result.detail);
37
+ }
38
+ setLastAction(`${label}: ${result.message}`);
39
+ }
40
+ layout.openServicePanel({
41
+ onStart: () => {
42
+ handleResult("start", serviceStart());
43
+ },
44
+ onStop: async () => {
45
+ const ok = await layout.confirm({
46
+ title: "停止服务",
47
+ body: "将停止 wand.service / launchd 代理;如果你正 attach 到它,连接会断开。",
48
+ });
49
+ if (!ok)
50
+ return;
51
+ handleResult("stop", serviceStop());
52
+ },
53
+ onRestart: async () => {
54
+ const ok = await layout.confirm({
55
+ title: "重启服务",
56
+ body: "将 restart wand.service / 重新 load 代理;attach 连接会短暂断开后自动重连。",
57
+ });
58
+ if (!ok)
59
+ return;
60
+ handleResult("restart", serviceRestart());
61
+ },
62
+ onInstall: async () => {
63
+ if (isServiceInstalled()) {
64
+ layout.showToast("服务已安装,按 u 先卸载再重装", "warn", 2500);
65
+ return;
66
+ }
67
+ const ok = await layout.confirm({
68
+ title: "注册服务",
69
+ body: "将写入 unit / plist 并启用(无需 sudo,使用用户级服务)。",
70
+ });
71
+ if (!ok)
72
+ return;
73
+ handleResult("install", installService({ configPath }));
74
+ },
75
+ onUninstall: async () => {
76
+ if (!isServiceInstalled()) {
77
+ layout.showToast("当前未安装", "warn", 2500);
78
+ return;
79
+ }
80
+ const ok = await layout.confirm({
81
+ title: "卸载服务",
82
+ body: "将禁用并删除服务配置文件。",
83
+ });
84
+ if (!ok)
85
+ return;
86
+ handleResult("uninstall", uninstallService());
87
+ },
88
+ onLogs: () => {
89
+ const r = serviceLogs(80);
90
+ if (r.ok) {
91
+ layout.showDetail("Service Logs (最近 80 行)", r.detail || "(空)");
92
+ setLastAction("logs: 已展开");
93
+ }
94
+ else {
95
+ layout.showToast(r.message, "error", 3000);
96
+ if (r.detail)
97
+ layout.showDetail("Service Logs 失败", r.detail);
98
+ }
99
+ },
100
+ onRefresh: () => {
101
+ layout.updateServicePanel(computeView());
102
+ layout.showToast("已刷新状态", "info", 1000);
103
+ },
104
+ onClose: () => {
105
+ layout.closeServicePanel();
106
+ },
107
+ }, computeView());
108
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 把主进程当前状态打包成 IPC snapshot。
3
+ *
4
+ * 这里同时被 IPC 服务端(远端 attach 客户端拉数据)调用。本地 TUI 仍然直接读
5
+ * processManager / structuredSessions(避免一次额外的对象拷贝)。
6
+ */
7
+ import { SessionSnapshot } from "../types.js";
8
+ import { IpcSnapshotData } from "./ipc-protocol.js";
9
+ export interface SnapshotInputs {
10
+ version: string;
11
+ url: string;
12
+ scheme: "HTTP" | "HTTPS";
13
+ bindAddr: string;
14
+ configPath: string;
15
+ dbPath: string;
16
+ orphanRecoveredCount: number;
17
+ startedAtMs: number;
18
+ pid: number;
19
+ processManager: {
20
+ listSlim(): SessionSnapshot[];
21
+ };
22
+ structuredSessions: {
23
+ listSlim(): SessionSnapshot[];
24
+ };
25
+ }
26
+ export declare function buildSnapshotData(inputs: SnapshotInputs): IpcSnapshotData;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 把主进程当前状态打包成 IPC snapshot。
3
+ *
4
+ * 这里同时被 IPC 服务端(远端 attach 客户端拉数据)调用。本地 TUI 仍然直接读
5
+ * processManager / structuredSessions(避免一次额外的对象拷贝)。
6
+ */
7
+ import { formatSession, sortRows } from "./session-formatter.js";
8
+ export function buildSnapshotData(inputs) {
9
+ const ptyList = inputs.processManager.listSlim();
10
+ let structuredList = [];
11
+ try {
12
+ structuredList = inputs.structuredSessions.listSlim();
13
+ }
14
+ catch { /* noop */ }
15
+ const seen = new Set();
16
+ const merged = [];
17
+ for (const s of ptyList) {
18
+ seen.add(s.id);
19
+ merged.push(s);
20
+ }
21
+ for (const s of structuredList) {
22
+ if (!seen.has(s.id))
23
+ merged.push(s);
24
+ }
25
+ const sorted = sortRows(merged);
26
+ let active = 0, archived = 0;
27
+ for (const s of sorted) {
28
+ if (s.archived)
29
+ archived += 1;
30
+ else if (s.status === "running")
31
+ active += 1;
32
+ }
33
+ const header = {
34
+ version: inputs.version,
35
+ url: inputs.url,
36
+ scheme: inputs.scheme,
37
+ bindAddr: inputs.bindAddr,
38
+ configPath: inputs.configPath,
39
+ dbPath: inputs.dbPath,
40
+ orphanRecoveredCount: inputs.orphanRecoveredCount,
41
+ sessionCounts: { active, archived, total: sorted.length },
42
+ startedAtMs: inputs.startedAtMs,
43
+ rssBytes: safeRss(),
44
+ pid: inputs.pid,
45
+ };
46
+ return {
47
+ header,
48
+ sessions: sorted.map((s) => formatSession(s)),
49
+ };
50
+ }
51
+ function safeRss() {
52
+ try {
53
+ return process.memoryUsage().rss;
54
+ }
55
+ catch {
56
+ return 0;
57
+ }
58
+ }