dsh-any-agent 0.6.0 → 0.6.2

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.
@@ -0,0 +1,762 @@
1
+ /**
2
+ * live 事件流的读取与归并:`dsh-any-agent watch` 与 `dashboard` 共用。
3
+ *
4
+ * 只读 `<LIVE_DIR>/<会话>/events.jsonl`(以及注册表 / DSH 投影缓存里的标题),
5
+ * 从不写任何东西、从不碰写锁。把事件归并成一棵"调用链":
6
+ *
7
+ * 客户端(谁派的) → 会话 → 回合(send / queue / interject) → 工具调用 / 审批
8
+ *
9
+ * 读取是按字节偏移的增量 tail:只消费到最后一个换行,末尾写到一半的行留到下一次。
10
+ */
11
+
12
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { LIVE_FILE } from './live.mjs';
15
+
16
+ /** 每个会话保留的回合数、每个回合保留的工具 / 审批数(只影响查看器内存)。 */
17
+ const MAX_RUNS_PER_CONV = 30;
18
+ const MAX_ITEMS_PER_RUN = 300;
19
+ /** 标题读 DSH 投影缓存,别每次轮询都读。 */
20
+ const TITLE_TTL_MS = 10_000;
21
+ const NEWLINE = 0x0a;
22
+
23
+ export function pidAlive(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (e) {
29
+ return e?.code === 'EPERM';
30
+ }
31
+ }
32
+
33
+ export function writerPid(w) {
34
+ const n = Number.parseInt(String(w ?? '').split(':')[0], 10);
35
+ return Number.isInteger(n) && n > 0 ? n : null;
36
+ }
37
+
38
+ /**
39
+ * 从 buffer 里切出完整的行;返回 {lines, rest}。rest 是末尾没有换行的半行(原样留着)。
40
+ * 按字节找换行,不会把多字节 UTF-8 字符切坏。
41
+ */
42
+ export function splitLines(buf) {
43
+ const lines = [];
44
+ let start = 0;
45
+ for (let i = 0; i < buf.length; i++) {
46
+ if (buf[i] === NEWLINE) {
47
+ if (i > start) lines.push(buf.subarray(start, i).toString('utf8'));
48
+ start = i + 1;
49
+ }
50
+ }
51
+ return { lines, rest: buf.subarray(start) };
52
+ }
53
+
54
+ /** 解析一行;坏行 / 未知结构返回 null(查看器绝不因为一行坏数据崩掉)。 */
55
+ export function parseEvent(line) {
56
+ try {
57
+ const ev = JSON.parse(line);
58
+ if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string' || typeof ev.conv !== 'string') return null;
59
+ return ev;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function newConv(id) {
66
+ return {
67
+ id,
68
+ cwd: null,
69
+ title: null,
70
+ title_hint: null,
71
+ client: null,
72
+ permission: null,
73
+ on_approval: null,
74
+ model: null,
75
+ writer: null,
76
+ opened_at: null,
77
+ last_ts: null,
78
+ closed: false,
79
+ /** DSH 会话累计 token(含不在 live 里的更早回合)。 */
80
+ session_tokens: null,
81
+ context: null,
82
+ runs: [],
83
+ };
84
+ }
85
+
86
+ function newRun(id) {
87
+ return {
88
+ run: id,
89
+ turn: null,
90
+ via: null,
91
+ client: null,
92
+ status: 'running',
93
+ queued_at: null,
94
+ started_at: null,
95
+ ended_at: null,
96
+ duration_ms: null,
97
+ prompt_preview: null,
98
+ stop_reason: null,
99
+ error: null,
100
+ tool_count: null,
101
+ auto_approvals: null,
102
+ auto_escalations: null,
103
+ writer: null,
104
+ /** 本回合 token 增量 {input, output, cache_read, cache_write}(来自 usage 事件)。 */
105
+ tokens: null,
106
+ /** 上下文窗口占用 {used, window}。 */
107
+ context: null,
108
+ items: [],
109
+ };
110
+ }
111
+
112
+ const TOKEN_KEYS = ['input', 'output', 'cache_read', 'cache_write'];
113
+
114
+ /** 规整一组 token 计数;不是对象时返回 null。 */
115
+ function cleanTokens(t) {
116
+ if (!t || typeof t !== 'object') return null;
117
+ const out = {};
118
+ for (const k of TOKEN_KEYS) out[k] = Number.isFinite(t[k]) && t[k] > 0 ? t[k] : 0;
119
+ return out;
120
+ }
121
+
122
+ /** 输入总量(未命中缓存 + 缓存读 + 缓存写)。 */
123
+ export function tokenInputTotal(t) {
124
+ return t ? t.input + t.cache_read + t.cache_write : 0;
125
+ }
126
+
127
+ /** 缓存命中率(缓存读 / 输入总量),没有输入时为 null。 */
128
+ export function cacheHitRate(t) {
129
+ const all = tokenInputTotal(t);
130
+ return all > 0 ? t.cache_read / all : null;
131
+ }
132
+
133
+ /** 1234 → 1.2k,1500000 → 1.5M。 */
134
+ export function fmtTokens(n) {
135
+ if (!Number.isFinite(n)) return '';
136
+ if (n < 1000) return String(Math.round(n));
137
+ if (n < 1e6) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
138
+ return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`;
139
+ }
140
+
141
+ /**
142
+ * 事件归并器。纯内存、与文件无关,方便单测。
143
+ * `apply(ev)` 吃一条事件;`view()` 给出可序列化的调用链快照。
144
+ */
145
+ export class LiveState {
146
+ constructor() {
147
+ /** @type {Map<string, ReturnType<typeof newConv>>} */
148
+ this.convs = new Map();
149
+ }
150
+
151
+ #conv(id) {
152
+ let c = this.convs.get(id);
153
+ if (!c) {
154
+ c = newConv(id);
155
+ this.convs.set(id, c);
156
+ }
157
+ return c;
158
+ }
159
+
160
+ #run(conv, runId, { create = true } = {}) {
161
+ if (!runId) return null;
162
+ let r = conv.runs.find((x) => x.run === runId);
163
+ if (!r && create) {
164
+ r = newRun(runId);
165
+ conv.runs.push(r);
166
+ if (conv.runs.length > MAX_RUNS_PER_CONV) conv.runs.shift();
167
+ }
168
+ return r;
169
+ }
170
+
171
+ #push(run, item) {
172
+ run.items.push(item);
173
+ if (run.items.length > MAX_ITEMS_PER_RUN) run.items.shift();
174
+ }
175
+
176
+ apply(ev) {
177
+ const c = this.#conv(ev.conv);
178
+ c.last_ts = ev.ts ?? c.last_ts;
179
+ if (ev.w) c.writer = ev.w;
180
+ if (ev.client) c.client = ev.client;
181
+ if (ev.cwd) c.cwd = ev.cwd;
182
+ switch (ev.type) {
183
+ case 'conv.open':
184
+ c.opened_at = ev.ts;
185
+ c.permission = ev.permission ?? c.permission;
186
+ c.on_approval = ev.on_approval ?? c.on_approval;
187
+ c.model = ev.model ?? c.model;
188
+ if (ev.title_hint) c.title_hint = ev.title_hint;
189
+ c.closed = false;
190
+ break;
191
+ case 'conv.close':
192
+ c.closed = true;
193
+ break;
194
+ case 'run.queued': {
195
+ const r = this.#run(c, ev.run);
196
+ r.status = 'queued';
197
+ r.queued_at = ev.ts;
198
+ r.via = 'queue';
199
+ r.client = ev.client ?? r.client;
200
+ if (ev.prompt_preview) r.prompt_preview = ev.prompt_preview;
201
+ r.writer = ev.w ?? r.writer;
202
+ break;
203
+ }
204
+ case 'run.start': {
205
+ const r = this.#run(c, ev.run);
206
+ r.status = 'running';
207
+ r.started_at = ev.ts;
208
+ r.turn = ev.turn ?? r.turn;
209
+ r.via = ev.via ?? r.via;
210
+ r.client = ev.client ?? r.client;
211
+ r.writer = ev.w ?? r.writer;
212
+ if (ev.prompt_preview) r.prompt_preview = ev.prompt_preview;
213
+ c.permission = ev.permission ?? c.permission;
214
+ c.on_approval = ev.on_approval ?? c.on_approval;
215
+ c.closed = false;
216
+ break;
217
+ }
218
+ case 'run.end': {
219
+ const r = this.#run(c, ev.run);
220
+ r.status = ev.status ?? 'done';
221
+ r.ended_at = ev.ts;
222
+ r.duration_ms = ev.duration_ms ?? r.duration_ms;
223
+ r.stop_reason = ev.stop_reason ?? r.stop_reason;
224
+ r.error = ev.error ?? r.error;
225
+ r.tool_count = ev.tool_count ?? r.tool_count;
226
+ r.auto_approvals = ev.auto_approvals ?? r.auto_approvals;
227
+ r.auto_escalations = ev.auto_escalations ?? r.auto_escalations;
228
+ // 回合结束时还没收尾的工具:标记成未完成,别一直显示"进行中"。
229
+ for (const it of r.items) if (it.kind === 'tool' && !isTerminalTool(it.status)) it.status = 'unfinished';
230
+ break;
231
+ }
232
+ case 'tool': {
233
+ const r = this.#run(c, ev.run);
234
+ if (!r) break;
235
+ let it = r.items.find((x) => x.kind === 'tool' && x.id === ev.tool_call_id);
236
+ if (!it) {
237
+ it = { kind: 'tool', id: ev.tool_call_id, at: ev.ts, name: null, tool_kind: null, status: null, title: null, input_preview: null, elapsed_ms: null };
238
+ this.#push(r, it);
239
+ }
240
+ it.name = ev.name ?? it.name;
241
+ it.tool_kind = ev.kind ?? it.tool_kind;
242
+ it.status = ev.status ?? it.status;
243
+ it.title = ev.title ?? it.title;
244
+ it.input_preview = ev.input_preview ?? it.input_preview;
245
+ if (ev.elapsed_ms !== undefined) it.elapsed_ms = ev.elapsed_ms;
246
+ it.last_at = ev.ts;
247
+ break;
248
+ }
249
+ case 'approval': {
250
+ const r = this.#run(c, ev.run);
251
+ if (!r) break;
252
+ this.#push(r, {
253
+ kind: 'approval',
254
+ at: ev.ts,
255
+ approval_id: ev.approval_id ?? null,
256
+ tool_call_id: ev.tool_call_id ?? null,
257
+ tool: ev.tool ?? null,
258
+ mode: ev.mode ?? null,
259
+ escalation: ev.escalation ?? null,
260
+ title: ev.title ?? null,
261
+ command_preview: ev.command_preview ?? null,
262
+ decided: null,
263
+ });
264
+ break;
265
+ }
266
+ case 'approval.decided': {
267
+ if (!ev.approval_id) break;
268
+ for (const r of c.runs) {
269
+ const it = r.items.find((x) => x.kind === 'approval' && x.approval_id === ev.approval_id);
270
+ if (it) {
271
+ it.decided = { by: ev.by ?? null, decision: ev.decision ?? null, effective: ev.effective ?? null, at: ev.ts };
272
+ break;
273
+ }
274
+ }
275
+ break;
276
+ }
277
+ case 'usage': {
278
+ const r = this.#run(c, ev.run);
279
+ if (r) {
280
+ const t = cleanTokens(ev.tokens);
281
+ if (t) r.tokens = t;
282
+ if (ev.context && Number.isFinite(ev.context.window)) r.context = { used: ev.context.used ?? 0, window: ev.context.window };
283
+ }
284
+ const s = cleanTokens(ev.session_tokens);
285
+ if (s) c.session_tokens = s;
286
+ if (ev.context && Number.isFinite(ev.context.window)) c.context = { used: ev.context.used ?? 0, window: ev.context.window };
287
+ break;
288
+ }
289
+ case 'interject': {
290
+ const r = this.#run(c, ev.run, { create: false });
291
+ if (r) this.#push(r, { kind: 'interject', at: ev.ts, mode: ev.mode ?? null, text_preview: ev.text_preview ?? null, pending_run: ev.pending_run ?? null });
292
+ break;
293
+ }
294
+ case 'interrupt': {
295
+ const r = this.#run(c, ev.run, { create: false });
296
+ if (r) this.#push(r, { kind: 'interrupt', at: ev.ts });
297
+ break;
298
+ }
299
+ default:
300
+ // 未知类型(更新的 schema):只更新公共字段,不报错。
301
+ break;
302
+ }
303
+ }
304
+
305
+ removeConv(id) {
306
+ this.convs.delete(id);
307
+ }
308
+
309
+ /**
310
+ * 调用链快照(可直接 JSON 序列化)。
311
+ * `alive(pid)` 可注入,便于测试;写者已退出而回合仍"running"的,标成 interrupted。
312
+ */
313
+ view({ alive = pidAlive, titles = new Map() } = {}) {
314
+ const aliveCache = new Map();
315
+ const isAlive = (w) => {
316
+ const pid = writerPid(w);
317
+ if (pid === null) return false;
318
+ if (!aliveCache.has(pid)) aliveCache.set(pid, alive(pid));
319
+ return aliveCache.get(pid);
320
+ };
321
+ const convs = [...this.convs.values()].map((c) => {
322
+ const runs = c.runs.map((r) => {
323
+ let status = r.status;
324
+ if ((status === 'running' || status === 'queued') && !isAlive(r.writer ?? c.writer)) status = 'interrupted';
325
+ const current = status === 'running' ? [...r.items].reverse().find((x) => x.kind === 'tool' && !isTerminalTool(x.status)) : null;
326
+ const pending = r.items.filter((x) => x.kind === 'approval' && x.mode === 'ask' && !x.decided);
327
+ return {
328
+ ...r,
329
+ status,
330
+ items: r.items.map((x) => ({ ...x })),
331
+ current_tool: current ? current.name ?? current.title ?? current.id : null,
332
+ pending_approvals: status === 'running' ? pending.length : 0,
333
+ escalations: r.items.filter((x) => x.kind === 'approval' && x.escalation).length,
334
+ };
335
+ });
336
+ const latest = runs[runs.length - 1] ?? null;
337
+ // 运行中的回合优先于排队的(排队的回合总是排在运行中的后面)。
338
+ const state = runs.some((r) => r.status === 'running')
339
+ ? 'running'
340
+ : runs.some((r) => r.status === 'queued')
341
+ ? 'queued'
342
+ : c.closed
343
+ ? 'closed'
344
+ : 'idle';
345
+ return {
346
+ id: c.id,
347
+ title: titles.get(c.id) ?? c.title ?? c.title_hint ?? null,
348
+ cwd: c.cwd,
349
+ client: c.client ?? latest?.client ?? null,
350
+ writer: c.writer,
351
+ writer_alive: isAlive(c.writer),
352
+ permission: c.permission,
353
+ on_approval: c.on_approval,
354
+ model: c.model,
355
+ opened_at: c.opened_at,
356
+ last_ts: c.last_ts,
357
+ state,
358
+ /** 会话累计(DSH 自己记的,含更早的回合);没有就用 live 里各回合之和。 */
359
+ tokens: c.session_tokens ?? sumTokens(runs.map((r) => r.tokens)),
360
+ context: c.context,
361
+ runs,
362
+ };
363
+ });
364
+ convs.sort((a, b) => rank(a.state) - rank(b.state) || String(b.last_ts ?? '').localeCompare(String(a.last_ts ?? '')));
365
+
366
+ // 调用链的第一层:按"哪个客户端、哪个服务实例"分组。
367
+ const groups = new Map();
368
+ for (const c of convs) {
369
+ const pid = writerPid(c.writer);
370
+ const key = `${c.client ?? 'unknown'}|${pid ?? '-'}`;
371
+ if (!groups.has(key)) groups.set(key, { client: c.client ?? null, pid, alive: c.writer_alive, conversations: [] });
372
+ groups.get(key).conversations.push(c);
373
+ }
374
+ const running = convs.filter((c) => c.state === 'running').length;
375
+ return {
376
+ generated_at: new Date().toISOString(),
377
+ totals: {
378
+ conversations: convs.length,
379
+ running,
380
+ queued: convs.reduce((n, c) => n + c.runs.filter((r) => r.status === 'queued').length, 0),
381
+ pending_approvals: convs.reduce((n, c) => n + c.runs.reduce((m, r) => m + r.pending_approvals, 0), 0),
382
+ escalations: convs.reduce((n, c) => n + c.runs.reduce((m, r) => m + r.escalations, 0), 0),
383
+ tokens: sumTokens(convs.map((c) => c.tokens)),
384
+ },
385
+ groups: [...groups.values()],
386
+ };
387
+ }
388
+ }
389
+
390
+ /** 把若干组 token 计数相加;全是空时返回 null。 */
391
+ export function sumTokens(list) {
392
+ let any = false;
393
+ const out = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
394
+ for (const t of list) {
395
+ if (!t) continue;
396
+ any = true;
397
+ for (const k of TOKEN_KEYS) out[k] += t[k] ?? 0;
398
+ }
399
+ return any ? out : null;
400
+ }
401
+
402
+ function rank(state) {
403
+ return state === 'running' ? 0 : state === 'queued' ? 1 : state === 'idle' ? 2 : 3;
404
+ }
405
+
406
+ export function isTerminalTool(status) {
407
+ return status === 'completed' || status === 'failed' || status === 'unfinished';
408
+ }
409
+
410
+ /**
411
+ * 增量 tail 一个 live 目录。`poll()` 返回本次是否有变化。
412
+ * 只读文件;目录不存在时安静地什么也不做(服务可能还没写过任何事件)。
413
+ */
414
+ export class LiveTail {
415
+ constructor(dir, { state = new LiveState(), stateFile = null, readTitle = null } = {}) {
416
+ this.dir = dir;
417
+ this.state = state;
418
+ this.stateFile = stateFile;
419
+ this.readTitle = readTitle;
420
+ /** file → {offset, rest: Buffer, ino} */
421
+ this.files = new Map();
422
+ this.titles = new Map();
423
+ this.titlesAt = 0;
424
+ }
425
+
426
+ #readFrom(file, track) {
427
+ let st;
428
+ try {
429
+ st = statSync(file);
430
+ } catch {
431
+ return false;
432
+ }
433
+ if (st.size < track.offset) {
434
+ // 被滚动(rename 成 .1 后重新开始):从头读新文件。
435
+ track.offset = 0;
436
+ track.rest = Buffer.alloc(0);
437
+ }
438
+ if (st.size === track.offset) return false;
439
+ const len = st.size - track.offset;
440
+ const buf = Buffer.alloc(len);
441
+ let fd;
442
+ try {
443
+ fd = openSync(file, 'r');
444
+ const n = readSync(fd, buf, 0, len, track.offset);
445
+ track.offset += n;
446
+ } catch {
447
+ return false;
448
+ } finally {
449
+ if (fd !== undefined) {
450
+ try {
451
+ closeSync(fd);
452
+ } catch {}
453
+ }
454
+ }
455
+ const { lines, rest } = splitLines(Buffer.concat([track.rest, buf]));
456
+ track.rest = Buffer.from(rest);
457
+ let changed = false;
458
+ for (const line of lines) {
459
+ const ev = parseEvent(line);
460
+ if (ev) {
461
+ this.state.apply(ev);
462
+ changed = true;
463
+ }
464
+ }
465
+ return changed;
466
+ }
467
+
468
+ poll() {
469
+ let changed = false;
470
+ let names = [];
471
+ try {
472
+ names = existsSync(this.dir) ? readdirSync(this.dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : [];
473
+ } catch {
474
+ names = [];
475
+ }
476
+ const present = new Set(names);
477
+ // 会话目录被删(forget):从视图里移除。
478
+ for (const id of [...this.state.convs.keys()]) {
479
+ if (!present.has(id)) {
480
+ this.state.removeConv(id);
481
+ for (const k of [...this.files.keys()]) if (k.startsWith(join(this.dir, id))) this.files.delete(k);
482
+ changed = true;
483
+ }
484
+ }
485
+ for (const id of names) {
486
+ const file = join(this.dir, id, LIVE_FILE);
487
+ let track = this.files.get(file);
488
+ if (!track) {
489
+ track = { offset: 0, rest: Buffer.alloc(0) };
490
+ this.files.set(file, track);
491
+ // 第一次见到这个会话:先读滚动出去的旧档,时间线才完整。
492
+ const old = `${file}.1`;
493
+ if (existsSync(old)) {
494
+ const oldTrack = { offset: 0, rest: Buffer.alloc(0) };
495
+ if (this.#readFrom(old, oldTrack)) changed = true;
496
+ }
497
+ }
498
+ if (this.#readFrom(file, track)) changed = true;
499
+ }
500
+ if (this.#refreshTitles()) changed = true;
501
+ return changed;
502
+ }
503
+
504
+ /**
505
+ * 标题:调用方给的 title_hint(注册表 > conv.open 事件)> DSH 原生标题(投影缓存)。
506
+ * DSH 原生标题只是首条 prompt 的截断前缀("在当前目录做一个简易的、可"),
507
+ * 有显式 title_hint 时它更可读。
508
+ */
509
+ #refreshTitles() {
510
+ const t = Date.now();
511
+ if (t - this.titlesAt < TITLE_TTL_MS) return false;
512
+ this.titlesAt = t;
513
+ const hints = new Map();
514
+ if (this.stateFile) {
515
+ try {
516
+ const doc = JSON.parse(readFileSync(this.stateFile, 'utf8'));
517
+ for (const c of doc?.conversations ?? []) if (c?.id && typeof c.titleHint === 'string') hints.set(c.id, c.titleHint);
518
+ } catch {
519
+ /* 注册表读不了就算了 */
520
+ }
521
+ }
522
+ let changed = false;
523
+ for (const [id, conv] of this.state.convs) {
524
+ let title = hints.get(id) ?? conv.title_hint ?? null;
525
+ if (!title && this.readTitle) {
526
+ try {
527
+ title = this.readTitle(id) ?? null;
528
+ } catch {
529
+ title = null;
530
+ }
531
+ }
532
+ if (title && this.titles.get(id) !== title) {
533
+ this.titles.set(id, title);
534
+ changed = true;
535
+ }
536
+ }
537
+ return changed;
538
+ }
539
+
540
+ view(opts = {}) {
541
+ return this.state.view({ titles: this.titles, ...opts });
542
+ }
543
+ }
544
+
545
+ // ── 终端原地刷新(watch) ───────────────────────────────────────
546
+
547
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/y;
548
+
549
+ /** 单个码点在终端里占几列:CJK / 全角 / emoji 记 2,其余 1(框线字符在主流终端里是 1)。 */
550
+ export function charWidth(cp) {
551
+ if (cp < 0x1100) return 1;
552
+ if (
553
+ (cp >= 0x1100 && cp <= 0x115f) ||
554
+ (cp >= 0x2e80 && cp <= 0x303e) ||
555
+ (cp >= 0x3041 && cp <= 0x33ff) ||
556
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
557
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
558
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
559
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
560
+ (cp >= 0xf900 && cp <= 0xfaff) ||
561
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
562
+ (cp >= 0xff00 && cp <= 0xff60) ||
563
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
564
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
565
+ (cp >= 0x20000 && cp <= 0x3fffd)
566
+ ) {
567
+ return 2;
568
+ }
569
+ return 1;
570
+ }
571
+
572
+ /** 可见宽度(忽略 ANSI 颜色序列)。 */
573
+ export function displayWidth(s) {
574
+ let w = 0;
575
+ for (let i = 0; i < s.length; ) {
576
+ ANSI_RE.lastIndex = i;
577
+ const m = ANSI_RE.exec(s);
578
+ if (m) {
579
+ i += m[0].length;
580
+ continue;
581
+ }
582
+ const cp = s.codePointAt(i);
583
+ w += charWidth(cp);
584
+ i += cp > 0xffff ? 2 : 1;
585
+ }
586
+ return w;
587
+ }
588
+
589
+ /**
590
+ * 把一行截到 `cols` 列以内(保留颜色序列,超出时末尾放 `…`)。
591
+ * 不截的话长行会折到下一行,原地刷新时整棵树就错位了。
592
+ */
593
+ export function clipLine(s, cols) {
594
+ if (!(cols > 1) || displayWidth(s) <= cols) return s;
595
+ let w = 0;
596
+ let out = '';
597
+ for (let i = 0; i < s.length; ) {
598
+ ANSI_RE.lastIndex = i;
599
+ const m = ANSI_RE.exec(s);
600
+ if (m) {
601
+ out += m[0];
602
+ i += m[0].length;
603
+ continue;
604
+ }
605
+ const cp = s.codePointAt(i);
606
+ const cw = charWidth(cp);
607
+ if (w + cw > cols - 1) break;
608
+ out += String.fromCodePoint(cp);
609
+ w += cw;
610
+ i += cp > 0xffff ? 2 : 1;
611
+ }
612
+ return out + '…\x1b[0m';
613
+ }
614
+
615
+ /**
616
+ * 把一帧文本排成"适合这个终端"的行:截宽、截高(超出的行数在最后一行说明)。
617
+ * 纯函数,方便测试。
618
+ */
619
+ export function layoutFrame(text, { cols = 120, rows = 40 } = {}) {
620
+ let lines = String(text).split('\n').map((l) => clipLine(l, cols));
621
+ if (rows > 2 && lines.length > rows) {
622
+ const hidden = lines.length - (rows - 1);
623
+ lines = lines.slice(0, rows - 1);
624
+ lines.push(clipLine(`\x1b[2m…还有 ${hidden} 行没显示(给会话 id 前缀、或用 --runs / --items 收窄)\x1b[0m`, cols));
625
+ }
626
+ return lines;
627
+ }
628
+
629
+ /**
630
+ * 计算从上一帧到这一帧要写的终端序列:只重写变化的行,多出来的旧行清掉。
631
+ * 第一帧(prev 为 null)整屏重画。返回要写出的字符串(无变化时为空串)。
632
+ */
633
+ export function diffFrame(prev, next) {
634
+ let out = '';
635
+ if (!prev) out += '\x1b[H\x1b[2J';
636
+ for (let i = 0; i < next.length; i++) {
637
+ if (prev && prev[i] === next[i]) continue;
638
+ out += `\x1b[${i + 1};1H${next[i]}\x1b[0m\x1b[K`;
639
+ }
640
+ if (prev && prev.length > next.length) out += `\x1b[${next.length + 1};1H\x1b[J`;
641
+ return out;
642
+ }
643
+
644
+ // ── 终端渲染(watch) ───────────────────────────────────────────
645
+
646
+ const STATUS_MARK = {
647
+ running: '▶',
648
+ queued: '…',
649
+ done: '✓',
650
+ error: '✗',
651
+ cancelled: '■',
652
+ interrupted: '!',
653
+ };
654
+
655
+ function shortId(id) {
656
+ return String(id ?? '').slice(0, 8);
657
+ }
658
+
659
+ function fmtMs(ms) {
660
+ if (!Number.isFinite(ms)) return '';
661
+ if (ms < 1000) return `${ms}ms`;
662
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
663
+ return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
664
+ }
665
+
666
+ function clip(s, n) {
667
+ if (!s) return '';
668
+ const t = String(s).replace(/\s+/g, ' ');
669
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
670
+ }
671
+
672
+ /**
673
+ * 把调用链快照渲染成纯文本树(watch 用)。
674
+ * @param {object} view LiveState.view() 的返回
675
+ * @param {object} [opts]
676
+ * @param {string} [opts.conv] 只看某个会话(id 前缀即可)
677
+ * @param {number} [opts.runs] 每个会话显示最近几轮(默认 3)
678
+ * @param {number} [opts.items] 每轮显示最后几条工具/审批(默认 12)
679
+ * @param {boolean} [opts.color] ANSI 颜色
680
+ */
681
+ export function renderText(view, { conv = null, runs = 3, items = 12, color = false } = {}) {
682
+ const c = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
683
+ const dim = (s) => c('2', s);
684
+ const warn = (s) => c('33', s);
685
+ const bad = (s) => c('31', s);
686
+ const good = (s) => c('32', s);
687
+ const out = [];
688
+ const t = view.totals;
689
+ out.push(
690
+ `DSH live · ${t.conversations} 会话 · ${t.running} 运行中` +
691
+ (t.queued ? ` · ${t.queued} 排队` : '') +
692
+ (t.pending_approvals ? ` · ${warn(`${t.pending_approvals} 待审批`)}` : '') +
693
+ (t.escalations ? ` · ${warn(`⚠ ${t.escalations} 次提权`)}` : '') +
694
+ (t.tokens ? ` · token 入 ${fmtTokens(tokenInputTotal(t.tokens))} / 出 ${fmtTokens(t.tokens.output)}` : ''),
695
+ );
696
+ let shown = 0;
697
+ for (const g of view.groups) {
698
+ const convs = conv ? g.conversations.filter((x) => x.id.startsWith(conv)) : g.conversations;
699
+ if (!convs.length) continue;
700
+ out.push('');
701
+ out.push(`${g.client ?? '未知客户端'} ${dim(`pid ${g.pid ?? '?'}${g.alive ? '' : ' (已退出)'}`)}`);
702
+ convs.forEach((cv, ci) => {
703
+ shown++;
704
+ const lastConv = ci === convs.length - 1;
705
+ const p1 = lastConv ? '└─ ' : '├─ ';
706
+ const p1c = lastConv ? ' ' : '│ ';
707
+ const stateTxt = cv.state === 'running' ? good('运行中') : cv.state === 'queued' ? warn('排队') : dim(cv.state === 'closed' ? '已释放' : '空闲');
708
+ const ctx = cv.context && cv.context.window ? ` · 上下文 ${Math.round((cv.context.used / cv.context.window) * 100)}%` : '';
709
+ const tok = cv.tokens ? ` · 累计 ${fmtTokens(tokenInputTotal(cv.tokens))}/${fmtTokens(cv.tokens.output)}` : '';
710
+ out.push(`${p1}${shortId(cv.id)} ${clip(cv.title ?? '(无标题)', 40)} ${stateTxt}${dim(tok + ctx)} ${dim(`${cv.permission ?? ''} · ${clip(cv.cwd ?? '', 50)}`)}`);
711
+ const rs = cv.runs.slice(-runs);
712
+ rs.forEach((r, ri) => {
713
+ const lastRun = ri === rs.length - 1;
714
+ const p2 = p1c + (lastRun ? '└─ ' : '├─ ');
715
+ const p2c = p1c + (lastRun ? ' ' : '│ ');
716
+ const mark = STATUS_MARK[r.status] ?? '?';
717
+ const markC = r.status === 'error' || r.status === 'interrupted' ? bad(mark) : r.status === 'running' ? good(mark) : mark;
718
+ const meta = [
719
+ r.via ? `[${r.via}]` : null,
720
+ r.status,
721
+ r.duration_ms != null ? fmtMs(r.duration_ms) : null,
722
+ r.tool_count != null ? `${r.tool_count} 工具` : null,
723
+ r.tokens ? `${fmtTokens(tokenInputTotal(r.tokens))}↑ ${fmtTokens(r.tokens.output)}↓ tok` : null,
724
+ r.escalations ? warn(`⚠${r.escalations} 提权`) : null,
725
+ r.pending_approvals ? warn(`${r.pending_approvals} 待审批`) : null,
726
+ ].filter(Boolean);
727
+ out.push(`${p2}${markC} ${r.run} ${meta.join(' · ')}${r.prompt_preview ? ` ${dim(`“${clip(r.prompt_preview, 60)}”`)}` : ''}`);
728
+ if (r.error) out.push(`${p2c} ${bad(clip(r.error, 100))}`);
729
+ const its = r.items.slice(-items);
730
+ if (r.items.length > its.length) out.push(`${p2c} ${dim(`…前面还有 ${r.items.length - its.length} 条`)}`);
731
+ for (const it of its) out.push(p2c + ' ' + renderItem(it, { warn, bad, good, dim }, cv.cwd));
732
+ });
733
+ });
734
+ }
735
+ if (!shown) out.push('', dim(conv ? `没有匹配 ${conv} 的会话。` : '还没有任何事件。派一次活(dsh_send)之后这里就会出现。'));
736
+ return out.join('\n');
737
+ }
738
+
739
+ /** 去掉会话目录前缀,把路径显示成相对路径。 */
740
+ export function relToCwd(text, cwd) {
741
+ if (!text || !cwd) return text ?? '';
742
+ const root = String(cwd).replace(/[\\/]+$/, '');
743
+ let out = String(text);
744
+ for (const sep of ['\\', '/']) out = out.split(root + sep).join('');
745
+ return out.split(root).join('.');
746
+ }
747
+
748
+ function renderItem(it, { warn, bad, good, dim }, cwd) {
749
+ if (it.kind === 'tool') {
750
+ const st = it.status === 'completed' ? good('✓') : it.status === 'failed' ? bad('✗') : it.status === 'unfinished' ? dim('·') : warn('▶');
751
+ const what = relToCwd(it.input_preview ?? (it.title !== it.name ? it.title : '') ?? '', cwd);
752
+ return `${st} ${it.name ?? it.title ?? it.id ?? 'tool'}${what ? ` ${dim(clip(what, 70))}` : ''}${it.elapsed_ms != null ? ` ${dim(fmtMs(it.elapsed_ms))}` : ''}`;
753
+ }
754
+ if (it.kind === 'approval') {
755
+ const decided = it.decided ? ` → ${it.decided.decision ?? it.decided.effective ?? ''}${it.decided.by ? `(${it.decided.by})` : ''}` : it.mode === 'ask' ? warn(' → 等待裁决') : '';
756
+ const esc = it.escalation ? warn(` ⚠ ${it.escalation}`) : '';
757
+ return `${warn('◆')} 审批 ${it.mode ?? ''}${esc}${decided}${it.command_preview ? ` ${dim(clip(relToCwd(it.command_preview, cwd), 60))}` : ''}`;
758
+ }
759
+ if (it.kind === 'interject') return `${warn('↪')} 插话(${it.mode ?? ''})${it.text_preview ? ` ${dim(clip(it.text_preview, 60))}` : ''}`;
760
+ if (it.kind === 'interrupt') return bad('■ 中断');
761
+ return dim(it.kind);
762
+ }