dsh-any-agent 0.6.2 → 0.6.3

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/src/doctor.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * dsh_doctor:只读体检(DSH 可执行文件与版本、profile、状态文件、runs/locks 目录、
3
- * 孤儿锁、会话数与 RSS、生效配置)。只报告,不修改任何东西。
3
+ * live 事件流、孤儿锁、会话数与 RSS、生效配置)。只报告,不修改任何东西
4
+ * (目录可写性用一个临时探测文件,写完立刻删掉)。
4
5
  *
5
6
  * 从 tools.mjs 拆出来:它是处理器表里最长的一项,且只依赖 `hub` 的两个只读字段
6
7
  * (conversations / hosts)与一批探测函数。返回值形状与原处理器一致:
7
8
  * `{ structured: { ok, verdict, checks }, text }`。
8
9
  */
9
10
 
10
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
12
  import { join } from 'node:path';
12
13
  import {
13
14
  DEFAULT_PERMISSION,
@@ -34,6 +35,58 @@ import {
34
35
  import { inspectProfile } from './bootstrap.mjs';
35
36
  import { listOrphans, LOCKS_DIR } from './locks.mjs';
36
37
  import { rssMb } from './procstat.mjs';
38
+ import { liveSettings } from './live.mjs';
39
+
40
+ /** live 目录占用超过这个值就提醒(TTL 正常时远到不了)。 */
41
+ const LIVE_WARN_BYTES = 500 * 1024 * 1024;
42
+
43
+ /**
44
+ * 体检 live 事件流:级别、目录可写、会话数、占用、最近一条事件的时间。
45
+ * 返回 `[status, detail, fix]`,供 add('live', …) 使用。
46
+ */
47
+ export function inspectLive({ dir = liveSettings().dir, level = liveSettings().level, now = Date.now() } = {}) {
48
+ if (level === 'off') {
49
+ return ['ok', `已关闭(DSH_MCP_LIVE=off):watch / dashboard 看不到新的派活;目录 ${dir}`, null];
50
+ }
51
+ try {
52
+ mkdirSync(dir, { recursive: true });
53
+ const probe = join(dir, `.doctor-${process.pid}`);
54
+ writeFileSync(probe, '');
55
+ rmSync(probe, { force: true });
56
+ } catch (e) {
57
+ return [
58
+ 'warn',
59
+ `级别=${level},但目录不可写:${dir}(${e.message})—— 派活不受影响,只是 watch / dashboard 看不到事件`,
60
+ '检查权限,或用 DSH_MCP_LIVE_DIR 指向可写目录',
61
+ ];
62
+ }
63
+ let convs = 0;
64
+ let bytes = 0;
65
+ let newest = 0;
66
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
67
+ if (!ent.isDirectory()) continue;
68
+ convs++;
69
+ for (const f of readdirSync(join(dir, ent.name), { withFileTypes: true })) {
70
+ if (!f.isFile()) continue;
71
+ try {
72
+ const st = statSync(join(dir, ent.name, f.name));
73
+ bytes += st.size;
74
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
75
+ } catch {
76
+ /* 正在滚动 / 被清理:跳过 */
77
+ }
78
+ }
79
+ }
80
+ const mb = (bytes / 1024 / 1024).toFixed(1);
81
+ const age = newest ? Math.round((now - newest) / 60000) : null;
82
+ const last = newest ? (age < 1 ? '最近一条事件:刚刚' : `最近一条事件:${age} 分钟前`) : '还没有任何事件';
83
+ const big = bytes > LIVE_WARN_BYTES;
84
+ return [
85
+ big ? 'warn' : 'ok',
86
+ `级别=${level},${dir},${convs} 个会话,${mb}MB;${last}`,
87
+ big ? '占用偏大:检查 DSH_MCP_SENTINEL_TTL_MS 是否被设成 0(关闭清理),或改用 DSH_MCP_LIVE=meta' : null,
88
+ ];
89
+ }
37
90
 
38
91
  export async function runDoctor(hub) {
39
92
  const checks = [];
@@ -148,6 +201,14 @@ export async function runDoctor(hub) {
148
201
  }
149
202
  }
150
203
 
204
+ // live 事件流(watch / dashboard 的数据源)。可选功能、写失败不影响派活,
205
+ // 所以有问题只报 warn,不报 fail;但"为什么 watch 里什么都看不到"要能从这里查出来。
206
+ try {
207
+ add('live', ...inspectLive());
208
+ } catch (e) {
209
+ add('live', 'warn', e.message);
210
+ }
211
+
151
212
  try {
152
213
  const orphans = listOrphans();
153
214
  add(
package/src/live-cli.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { LIVE_DIR, STATE_FILE } from './config.mjs';
9
- import { LiveTail, diffFrame, layoutFrame, renderText } from './live-view.mjs';
9
+ import { LiveTail, TEXT, detectLang, diffFrame, layoutFrame, renderText } from './live-view.mjs';
10
10
  import { readConversationMeta } from './titles.mjs';
11
11
 
12
12
  function parseFlags(args) {
@@ -36,14 +36,17 @@ export function makeTail(dir = LIVE_DIR) {
36
36
  });
37
37
  }
38
38
 
39
- export const WATCH_USAGE =
40
- '用法: dsh-any-agent watch [会话id前缀] [--once] [--interval 毫秒] [--runs N] [--items N] [--no-color]\n';
39
+ export const WATCH_USAGE = {
40
+ zh: '用法: dsh-any-agent watch [会话id前缀] [--once] [--interval 毫秒] [--runs N] [--items N] [--no-color] [--lang zh|en]\n',
41
+ en: 'usage: dsh-any-agent watch [conversation id prefix] [--once] [--interval ms] [--runs N] [--items N] [--no-color] [--lang zh|en]\n',
42
+ };
41
43
 
42
44
  /** watch:终端里实时显示调用链(Ctrl+C 退出)。`--once` 打印一次就退出。 */
43
45
  export async function runWatch(args, { out = process.stdout } = {}) {
44
46
  const { flags, positional } = parseFlags(args);
47
+ const lang = detectLang(typeof flags.lang === 'string' ? flags.lang : null);
45
48
  if (flags.help || flags.h) {
46
- out.write(WATCH_USAGE);
49
+ out.write(WATCH_USAGE[lang]);
47
50
  return 0;
48
51
  }
49
52
  const tail = makeTail(typeof flags.dir === 'string' ? flags.dir : LIVE_DIR);
@@ -52,6 +55,7 @@ export async function runWatch(args, { out = process.stdout } = {}) {
52
55
  runs: intFlag(flags.runs, 3, 1, 30),
53
56
  items: intFlag(flags.items, 12, 1, 300),
54
57
  color: !flags['no-color'] && Boolean(out.isTTY),
58
+ lang,
55
59
  };
56
60
  tail.poll();
57
61
  if (flags.once) {
@@ -61,8 +65,7 @@ export async function runWatch(args, { out = process.stdout } = {}) {
61
65
  const interval = intFlag(flags.interval, 500, 100, 10_000);
62
66
  const tty = Boolean(out.isTTY);
63
67
  const clock = () => new Date().toTimeString().slice(0, 8);
64
- const frameText = () =>
65
- renderText(tail.view(), opts) + `\n\n\x1b[2m更新于 ${clock()} · live 目录: ${tail.dir} · Ctrl+C 退出\x1b[0m`;
68
+ const frameText = () => renderText(tail.view(), opts) + `\n\n\x1b[2m${TEXT[lang].footer(clock(), tail.dir)}\x1b[0m`;
66
69
 
67
70
  // ★ 在 TTY 上用"备用屏幕"原地刷新(同 top / htop):每帧只重写变化的行。
68
71
  // 旧实现每次 `ESC[2J` 清屏,Windows Terminal 会把被清掉的整屏推进回滚区,
@@ -78,7 +81,7 @@ export async function runWatch(args, { out = process.stdout } = {}) {
78
81
  lastText = body;
79
82
  return;
80
83
  }
81
- const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40 });
84
+ const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40, lang });
82
85
  const seq = diffFrame(force ? null : prev, next);
83
86
  if (seq) out.write(seq);
84
87
  prev = next;
@@ -110,13 +113,49 @@ export async function runWatch(args, { out = process.stdout } = {}) {
110
113
  return 0;
111
114
  }
112
115
 
113
- export const DASHBOARD_USAGE = '用法: dsh-any-agent dashboard [--port 端口] [--interval 毫秒]\n';
116
+ export const DASHBOARD_USAGE = {
117
+ zh: '用法: dsh-any-agent dashboard [--port 端口] [--interval 毫秒] [--open] [--lang zh|en]\n',
118
+ en: 'usage: dsh-any-agent dashboard [--port port] [--interval ms] [--open] [--lang zh|en]\n',
119
+ };
120
+
121
+ const DASHBOARD_BANNER = {
122
+ zh: (url, dir) =>
123
+ `DSH live dashboard(只读)\n 打开: ${url}\n live 目录: ${dir}\n 链接里带一次性 token,只在本机 127.0.0.1 可访问;Ctrl+C 退出。\n`,
124
+ en: (url, dir) =>
125
+ `DSH live dashboard (read-only)\n open: ${url}\n live dir: ${dir}\n The link carries a one-time token and only works on 127.0.0.1; Ctrl+C to quit.\n`,
126
+ };
127
+
128
+ /**
129
+ * 用系统默认浏览器打开地址。失败不抛错(打印的地址照样能手动打开)。
130
+ * 地址里的 token 只含 base64url 字符,不会被 cmd 的 `start` 当成元字符。
131
+ *
132
+ * ★ Windows:`start` 的第一个带引号参数是窗口标题,所以要先给一个空标题。
133
+ * 必须传空字符串 ''(Node 会把它转成 `""`);传字面量 '""' 会被转义成 `"\"\""`,
134
+ * cmd 收到的就不是空标题了(实测)。
135
+ */
136
+ export function openInBrowser(url, { platform = process.platform, spawnFn } = {}) {
137
+ const [cmd, args] =
138
+ platform === 'win32'
139
+ ? ['cmd', ['/c', 'start', '', url]]
140
+ : platform === 'darwin'
141
+ ? ['open', [url]]
142
+ : ['xdg-open', [url]];
143
+ return import('node:child_process')
144
+ .then(({ spawn }) => {
145
+ const child = (spawnFn ?? spawn)(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
146
+ child.on?.('error', () => {});
147
+ child.unref?.();
148
+ return true;
149
+ })
150
+ .catch(() => false);
151
+ }
114
152
 
115
153
  /** dashboard:起一个只读本地页面(127.0.0.1 + 一次性 token),Ctrl+C 退出。 */
116
154
  export async function runDashboard(args, { out = process.stdout } = {}) {
117
155
  const { flags } = parseFlags(args);
156
+ const lang = detectLang(typeof flags.lang === 'string' ? flags.lang : null);
118
157
  if (flags.help || flags.h) {
119
- out.write(DASHBOARD_USAGE);
158
+ out.write(DASHBOARD_USAGE[lang]);
120
159
  return 0;
121
160
  }
122
161
  const { startDashboard } = await import('./live-dashboard.mjs');
@@ -126,12 +165,10 @@ export async function runDashboard(args, { out = process.stdout } = {}) {
126
165
  port: intFlag(flags.port, 0, 0, 65535),
127
166
  pollMs: intFlag(flags.interval, 500, 100, 10_000),
128
167
  });
129
- out.write(
130
- `DSH live dashboard(只读)\n` +
131
- ` 打开: ${dash.url}\n` +
132
- ` live 目录: ${tail.dir}\n` +
133
- ` 链接里带一次性 token,只在本机 127.0.0.1 可访问;Ctrl+C 退出。\n`,
134
- );
168
+ // 页面自己也会按浏览器语言选;这里把命令行的语言带过去,两边一致(页面上还能再切换)。
169
+ const url = `${dash.url}&lang=${lang}`;
170
+ out.write(DASHBOARD_BANNER[lang](url, tail.dir));
171
+ if (flags.open) await openInBrowser(url);
135
172
  await new Promise((resolve) => {
136
173
  const stop = () => resolve();
137
174
  process.once('SIGINT', stop);