dsh-any-agent 0.6.1 → 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.
@@ -40,7 +40,7 @@ import { rssMb } from './procstat.mjs';
40
40
  import { readConversationMeta } from './titles.mjs';
41
41
  import { gitStatus, sameWorkspaceSnapshot } from './git-snapshot.mjs';
42
42
  import { writeTombstone } from './hub-store.mjs';
43
- import { emitLive, livePreview, removeLive, summarizeToolInput } from './live.mjs';
43
+ import { emitLive, livePreview, liveSettings, removeLive, summarizeToolInput } from './live.mjs';
44
44
  import {
45
45
  classifyHolder,
46
46
  describeLeaseConflict,
@@ -158,6 +158,61 @@ export class Conversation {
158
158
  emitLive(this.id, type, { run: this.#liveRunId(), turn: this.currentTurn, ...fields }, detail);
159
159
  }
160
160
 
161
+ /**
162
+ * live 事件流的 token 跟踪:`{runId, base, at, last}`。
163
+ * DSH 在投影缓存里维护**会话累计**的 tokenUsage.totals,并在回合中每个模型步之后更新
164
+ * (真机实测);回合开始时记下基线,之后发的是"本回合增量"。
165
+ */
166
+ #liveUsage = null;
167
+
168
+ /** 读 DSH 投影缓存里的 token 累计与上下文压力;读不到返回 null。 */
169
+ #readTokenUsage() {
170
+ try {
171
+ const meta = readConversationMeta(this.id);
172
+ const t = meta?.usage?.totals;
173
+ const p = meta?.pressure;
174
+ if (!t && !p) return null;
175
+ const n = (v) => (Number.isFinite(v) && v > 0 ? v : 0);
176
+ return {
177
+ totals: t
178
+ ? { input: n(t.uncachedInputTokens), output: n(t.outputTokens), cache_read: n(t.cacheReadTokens), cache_write: n(t.cacheWriteTokens) }
179
+ : null,
180
+ context: p && Number.isFinite(p.contextWindow) ? { used: n(p.pressureTokens ?? p.surfaceTokens), window: p.contextWindow } : null,
181
+ };
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 发一条 `usage` 事件(本回合 token 增量 + 会话累计 + 上下文占用)。
189
+ * 回合中节流到 2 秒一次、且只在数字变化时发;回合结束后 force 再发一次最终值。
190
+ */
191
+ #liveUsageTick({ force = false } = {}) {
192
+ const u = this.#liveUsage;
193
+ if (!u) return;
194
+ const t = now();
195
+ if (!force && t - u.at < 2000) return;
196
+ u.at = t;
197
+ const cur = this.#readTokenUsage();
198
+ if (!cur?.totals && !cur?.context) return;
199
+ const base = u.base ?? { input: 0, output: 0, cache_read: 0, cache_write: 0 };
200
+ const tokens = cur.totals
201
+ ? Object.fromEntries(Object.keys(base).map((k) => [k, Math.max(0, cur.totals[k] - base[k])]))
202
+ : null;
203
+ const sig = JSON.stringify([tokens, cur.context]);
204
+ if (!force && sig === u.last) return;
205
+ u.last = sig;
206
+ emitLive(this.id, 'usage', {
207
+ run: u.runId,
208
+ turn: u.turn,
209
+ tokens,
210
+ session_tokens: cur.totals,
211
+ context: cur.context,
212
+ final: force || undefined,
213
+ });
214
+ }
215
+
161
216
  /** 审批裁决事件(decide / 超时 / 回合结束 / 关闭 统一走这里)。 */
162
217
  #liveApprovalDecided(approvalId, by, decision = null, effective = null) {
163
218
  this.#live('approval.decided', { approval_id: approvalId, by, decision, effective });
@@ -598,6 +653,7 @@ export class Conversation {
598
653
  }
599
654
  case 'usage_update':
600
655
  c.usage = u;
656
+ this.#liveUsageTick();
601
657
  break;
602
658
  case 'plan':
603
659
  case 'plan_update':
@@ -635,6 +691,7 @@ export class Conversation {
635
691
  },
636
692
  { title: livePreview(merged.title, 200), input_preview: summarizeToolInput(merged.input) },
637
693
  );
694
+ this.#liveUsageTick();
638
695
  }
639
696
 
640
697
  async onPermissionRequest(params) {
@@ -789,6 +846,7 @@ export class Conversation {
789
846
  runRecord.status = 'error';
790
847
  runRecord.error = error?.message ?? String(error);
791
848
  runRecord.elapsed_ms = 0;
849
+ if (this.#liveUsage?.runId === runRecord.run_id) this.#liveUsage = null;
792
850
  emitLive(
793
851
  this.id,
794
852
  'run.end',
@@ -912,6 +970,10 @@ export class Conversation {
912
970
  this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
913
971
  this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
914
972
  this.#liveTools.clear();
973
+ // live 关闭时不读投影缓存(emitLive 会直接丢弃,读了也白读)。
974
+ this.#liveUsage = liveSettings().level === 'off'
975
+ ? null
976
+ : { runId: runRecord.run_id, turn: this.currentTurn, base: this.#readTokenUsage()?.totals ?? null, at: 0, last: '' };
915
977
  this.#live(
916
978
  'run.start',
917
979
  { via, permission: this.permission, on_approval: this.onApproval, cwd: this.cwd },
@@ -1019,6 +1081,12 @@ export class Conversation {
1019
1081
  this.#scheduleDrain();
1020
1082
  // 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
1021
1083
  const after = await afterPromise;
1084
+ // 回合结束后 DSH 的 token 累计已经落盘(真机实测在哨兵之前):发一次最终值。
1085
+ // 只发给本回合自己的跟踪器——下一回合可能已经在 git 快照期间开始了。
1086
+ if (this.#liveUsage?.runId === runRecord.run_id) {
1087
+ this.#liveUsageTick({ force: true });
1088
+ this.#liveUsage = null;
1089
+ }
1022
1090
  const result = {
1023
1091
  conversation_id: this.id,
1024
1092
  run_id: runRecord.run_id,
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, 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) {
@@ -59,19 +63,42 @@ export async function runWatch(args, { out = process.stdout } = {}) {
59
63
  return 0;
60
64
  }
61
65
  const interval = intFlag(flags.interval, 500, 100, 10_000);
62
- const draw = () => {
63
- // 清屏 + 光标回到左上角,再整屏重画(树会变长变短,增量刷新不值得)。
64
- out.write('\x1b[2J\x1b[H' + renderText(tail.view(), opts) + `\n\n${'\x1b[2m'}live 目录: ${tail.dir} · Ctrl+C 退出${'\x1b[0m'}\n`);
66
+ const tty = Boolean(out.isTTY);
67
+ const clock = () => new Date().toTimeString().slice(0, 8);
68
+ const frameText = () => renderText(tail.view(), opts) + `\n\n\x1b[2m${TEXT[lang].footer(clock(), tail.dir)}\x1b[0m`;
69
+
70
+ // ★ 在 TTY 上用"备用屏幕"原地刷新(同 top / htop):每帧只重写变化的行。
71
+ // 旧实现每次 `ESC[2J` 清屏,Windows Terminal 会把被清掉的整屏推进回滚区,
72
+ // 往上一翻就是一摞重复的帧。备用屏幕没有回滚区,退出时原样恢复用户的终端。
73
+ let prev = null;
74
+ let lastText = '';
75
+ const draw = (force = false) => {
76
+ const text = frameText();
77
+ if (!tty) {
78
+ // 管道 / 重定向:没有光标控制,内容变了才追加一帧(去掉时间行再比较,免得每次都算变化)。
79
+ const body = text.slice(0, text.lastIndexOf('\n\n'));
80
+ if (body !== lastText) out.write(text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') + '\n\n');
81
+ lastText = body;
82
+ return;
83
+ }
84
+ const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40, lang });
85
+ const seq = diffFrame(force ? null : prev, next);
86
+ if (seq) out.write(seq);
87
+ prev = next;
88
+ };
89
+ const restore = () => {
90
+ if (tty) out.write('\x1b[0m\x1b[?25h\x1b[?1049l');
65
91
  };
66
- draw();
67
- let lastDraw = Date.now();
92
+ if (tty) out.write('\x1b[?1049h\x1b[?25l');
93
+ process.once('exit', restore);
94
+ draw(true);
95
+ const onResize = () => draw(true);
96
+ if (tty) out.on?.('resize', onResize);
68
97
  await new Promise((resolve) => {
69
98
  const timer = setInterval(() => {
70
- // 有新事件就重画;没有也每 5 秒重画一次(刷新"写者是否还活着")。
71
- if (tail.poll() || Date.now() - lastDraw > 5000) {
72
- draw();
73
- lastDraw = Date.now();
74
- }
99
+ // 有新事件才重算;时间行每秒在变,所以每次都会写,但 diffFrame 只重写真正变了的那几行。
100
+ tail.poll();
101
+ draw();
75
102
  }, interval);
76
103
  const stop = () => {
77
104
  clearInterval(timer);
@@ -80,16 +107,55 @@ export async function runWatch(args, { out = process.stdout } = {}) {
80
107
  process.once('SIGINT', stop);
81
108
  process.once('SIGTERM', stop);
82
109
  });
110
+ out.off?.('resize', onResize);
111
+ restore();
112
+ process.off('exit', restore);
83
113
  return 0;
84
114
  }
85
115
 
86
- 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
+ }
87
152
 
88
153
  /** dashboard:起一个只读本地页面(127.0.0.1 + 一次性 token),Ctrl+C 退出。 */
89
154
  export async function runDashboard(args, { out = process.stdout } = {}) {
90
155
  const { flags } = parseFlags(args);
156
+ const lang = detectLang(typeof flags.lang === 'string' ? flags.lang : null);
91
157
  if (flags.help || flags.h) {
92
- out.write(DASHBOARD_USAGE);
158
+ out.write(DASHBOARD_USAGE[lang]);
93
159
  return 0;
94
160
  }
95
161
  const { startDashboard } = await import('./live-dashboard.mjs');
@@ -99,12 +165,10 @@ export async function runDashboard(args, { out = process.stdout } = {}) {
99
165
  port: intFlag(flags.port, 0, 0, 65535),
100
166
  pollMs: intFlag(flags.interval, 500, 100, 10_000),
101
167
  });
102
- out.write(
103
- `DSH live dashboard(只读)\n` +
104
- ` 打开: ${dash.url}\n` +
105
- ` live 目录: ${tail.dir}\n` +
106
- ` 链接里带一次性 token,只在本机 127.0.0.1 可访问;Ctrl+C 退出。\n`,
107
- );
168
+ // 页面自己也会按浏览器语言选;这里把命令行的语言带过去,两边一致(页面上还能再切换)。
169
+ const url = `${dash.url}&lang=${lang}`;
170
+ out.write(DASHBOARD_BANNER[lang](url, tail.dir));
171
+ if (flags.open) await openInBrowser(url);
108
172
  await new Promise((resolve) => {
109
173
  const stop = () => resolve();
110
174
  process.once('SIGINT', stop);