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.
@@ -40,6 +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, liveSettings, removeLive, summarizeToolInput } from './live.mjs';
43
44
  import {
44
45
  classifyHolder,
45
46
  describeLeaseConflict,
@@ -141,6 +142,82 @@ export class Conversation {
141
142
 
142
143
  #hub;
143
144
 
145
+ /**
146
+ * live 事件流用的工具跟踪:tool_call_id → {at, status, hasInput}。
147
+ * 单独放在这里而不是回合收集器里:收集器的 tools 会原样进 tools_used,不能混入新字段。
148
+ */
149
+ #liveTools = new Map();
150
+
151
+ /** 当前 run_id(live 事件用;回合外为 null)。 */
152
+ #liveRunId() {
153
+ return this.currentRun?.run_id ?? null;
154
+ }
155
+
156
+ /** 写一条 live 事件(永不抛错,见 live.mjs)。 */
157
+ #live(type, fields = {}, detail = null) {
158
+ emitLive(this.id, type, { run: this.#liveRunId(), turn: this.currentTurn, ...fields }, detail);
159
+ }
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
+
216
+ /** 审批裁决事件(decide / 超时 / 回合结束 / 关闭 统一走这里)。 */
217
+ #liveApprovalDecided(approvalId, by, decision = null, effective = null) {
218
+ this.#live('approval.decided', { approval_id: approvalId, by, decision, effective });
219
+ }
220
+
144
221
  /** 序列化进注册表的最小状态——**只有元数据,没有对话内容**。 */
145
222
  toState() {
146
223
  return {
@@ -571,10 +648,12 @@ export class Conversation {
571
648
  if (u.title !== undefined || u.status !== undefined || u.name !== undefined) {
572
649
  this.pushOut('tool', `${label}${tag}`, { tool_call_id: id });
573
650
  }
651
+ this.#liveTool(id, merged);
574
652
  break;
575
653
  }
576
654
  case 'usage_update':
577
655
  c.usage = u;
656
+ this.#liveUsageTick();
578
657
  break;
579
658
  case 'plan':
580
659
  case 'plan_update':
@@ -585,6 +664,36 @@ export class Conversation {
585
664
  }
586
665
  }
587
666
 
667
+ /**
668
+ * 工具状态 → live 事件。DSH 的工具更新是快照、会重复推同一状态,所以只在
669
+ * "第一次出现 / 状态变化 / 第一次拿到输入"时写,避免刷屏。
670
+ */
671
+ #liveTool(id, merged) {
672
+ const seen = this.#liveTools.get(id);
673
+ const hasInput = merged.input !== undefined && merged.input !== null;
674
+ const status = merged.status ?? null;
675
+ if (seen && seen.status === status && (seen.hasInput || !hasInput)) return;
676
+ const at = seen?.at ?? now();
677
+ this.#liveTools.set(id, { at, status, hasInput });
678
+ const terminal = status === 'completed' || status === 'failed';
679
+ // 真实 DSH 的 tool_call 不带 name,工具名在 title 里("read" / "pwsh")。title 是纯标识符时
680
+ // 当作名字写进 meta 级字段;带空格等可能含命令原文的 title 只进 detail。
681
+ const titleAsName =
682
+ typeof merged.title === 'string' && /^[\w.:-]{1,40}$/.test(merged.title) ? merged.title : null;
683
+ this.#live(
684
+ 'tool',
685
+ {
686
+ tool_call_id: id,
687
+ name: merged.name ?? titleAsName,
688
+ kind: merged.kind ?? null,
689
+ status,
690
+ elapsed_ms: terminal ? now() - at : undefined,
691
+ },
692
+ { title: livePreview(merged.title, 200), input_preview: summarizeToolInput(merged.input) },
693
+ );
694
+ this.#liveUsageTick();
695
+ }
696
+
588
697
  async onPermissionRequest(params) {
589
698
  const options = params?.options ?? [];
590
699
  const rawToolCall = params?.toolCall ?? {};
@@ -605,11 +714,24 @@ export class Conversation {
605
714
  const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
606
715
  const allowId = pickOption(options, 'allow');
607
716
  const rejectId = pickOption(options, 'reject');
717
+ const liveApproval = (mode, extra = {}) => {
718
+ const input = toolCall.rawInput ?? toolCall.input ?? null;
719
+ const escalation =
720
+ input && typeof input === 'object' && typeof input.sandbox_permissions === 'string'
721
+ ? input.sandbox_permissions
722
+ : null;
723
+ this.#live(
724
+ 'approval',
725
+ { tool_call_id: toolCall.toolCallId ?? null, tool: toolCall.name ?? null, mode, escalation, ...extra },
726
+ { title: livePreview(desc, 200), command_preview: summarizeToolInput(input) },
727
+ );
728
+ };
608
729
 
609
730
  // ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
610
731
  // 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
611
732
  if (this.permission === 'read-only') {
612
733
  this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
734
+ liveApproval('read-only-deny');
613
735
  return rejectId
614
736
  ? { outcome: { outcome: 'selected', optionId: rejectId } }
615
737
  : { outcome: { outcome: 'cancelled' } };
@@ -619,22 +741,26 @@ export class Conversation {
619
741
  // ★ 自动放行必须对调用方可见:DSH 会借审批把沙箱提升到 danger-full-access,
620
742
  // 不记下来的话 workspace-write 会话事后看起来像从未越界(实测一轮 6 次提权无人知晓)。
621
743
  if (allowId) this.#recordAutoApproval(params, toolCall, desc, options);
744
+ liveApproval('auto-allow');
622
745
  return allowId
623
746
  ? { outcome: { outcome: 'selected', optionId: allowId } }
624
747
  : { outcome: { outcome: 'cancelled' } };
625
748
  }
626
749
  if (this.onApproval === 'auto-deny') {
627
750
  this.#hub.log.info(`[conv ${this.id}] 审批拒绝: ${desc}`);
751
+ liveApproval('auto-deny');
628
752
  return rejectId
629
753
  ? { outcome: { outcome: 'selected', optionId: rejectId } }
630
754
  : { outcome: { outcome: 'cancelled' } };
631
755
  }
632
756
  const approvalId = `ap-${Math.random().toString(36).slice(2, 10)}`;
633
757
  this.#hub.log.info(`[conv ${this.id}] 审批待裁决: ${approvalId}`);
758
+ liveApproval('ask', { approval_id: approvalId });
634
759
  return await new Promise((resolve) => {
635
760
  const timer = setTimeout(() => {
636
761
  if (this.pendingApprovals.delete(approvalId)) {
637
762
  this.#hub.log.info(`[conv ${this.id}] 审批 ${approvalId} 超时,按拒绝处理`);
763
+ this.#liveApprovalDecided(approvalId, 'timeout', 'reject', 'cancelled');
638
764
  resolve({ outcome: { outcome: 'cancelled' } });
639
765
  }
640
766
  }, this.#hub.approvalTimeoutMs);
@@ -690,6 +816,7 @@ export class Conversation {
690
816
  // 否则 dsh_approval_decide 会渲染成"已裁决为 allow",而 DSH 收到的是取消(假成功)。
691
817
  const effective = chosen ? 'selected' : 'cancelled';
692
818
  p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
819
+ this.#liveApprovalDecided(approvalId, 'caller', decision, effective);
693
820
  return {
694
821
  approval_id: approvalId,
695
822
  decision,
@@ -719,6 +846,13 @@ export class Conversation {
719
846
  runRecord.status = 'error';
720
847
  runRecord.error = error?.message ?? String(error);
721
848
  runRecord.elapsed_ms = 0;
849
+ if (this.#liveUsage?.runId === runRecord.run_id) this.#liveUsage = null;
850
+ emitLive(
851
+ this.id,
852
+ 'run.end',
853
+ { run: runRecord.run_id, turn: this.currentTurn, status: 'error', stop_reason: 'error' },
854
+ { error: livePreview(runRecord.error, 300) },
855
+ );
722
856
  runRecord.result = {
723
857
  conversation_id: this.id,
724
858
  run_id: runRecord.run_id,
@@ -789,7 +923,7 @@ export class Conversation {
789
923
  *
790
924
  * @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
791
925
  */
792
- async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
926
+ async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review, via = 'send' } = {}) {
793
927
  if (this.busy) {
794
928
  throw new Error(
795
929
  `会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
@@ -835,6 +969,16 @@ export class Conversation {
835
969
  this.currentTool = null;
836
970
  this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
837
971
  this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
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: '' };
977
+ this.#live(
978
+ 'run.start',
979
+ { via, permission: this.permission, on_approval: this.onApproval, cwd: this.cwd },
980
+ { prompt_preview: livePreview(text, 200) },
981
+ );
838
982
  const c = {
839
983
  answer: '',
840
984
  /** 上一段正文之后出现过工具调用:下一段正文前要补段落分隔(见 onUpdate)。 */
@@ -900,10 +1044,11 @@ export class Conversation {
900
1044
  this.currentTool = null;
901
1045
  // 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
902
1046
  // 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
903
- for (const [, p] of this.pendingApprovals) {
1047
+ for (const [apId, p] of this.pendingApprovals) {
904
1048
  try {
905
1049
  p.resolve({ outcome: { outcome: 'cancelled' } });
906
1050
  } catch {}
1051
+ this.#liveApprovalDecided(apId, 'turn_end', null, 'cancelled');
907
1052
  }
908
1053
  this.pendingApprovals.clear();
909
1054
  this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
@@ -911,6 +1056,21 @@ export class Conversation {
911
1056
  stop_reason: stopReason,
912
1057
  error: failure ?? undefined,
913
1058
  });
1059
+ emitLive(
1060
+ this.id,
1061
+ 'run.end',
1062
+ {
1063
+ run: runRecord.run_id,
1064
+ turn: this.currentTurn,
1065
+ status: failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done',
1066
+ stop_reason: stopReason ?? null,
1067
+ duration_ms: now() - t0,
1068
+ tool_count: c.tools.size,
1069
+ auto_approvals: c.autoApprovalTotal,
1070
+ auto_escalations: c.autoEscalationTotal,
1071
+ },
1072
+ { error: livePreview(failure, 300) },
1073
+ );
914
1074
  this.turnCount += 1;
915
1075
  this.lastUsedAt = now();
916
1076
  this.#hub.save();
@@ -921,6 +1081,12 @@ export class Conversation {
921
1081
  this.#scheduleDrain();
922
1082
  // 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
923
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
+ }
924
1090
  const result = {
925
1091
  conversation_id: this.id,
926
1092
  run_id: runRecord.run_id,
@@ -1063,6 +1229,7 @@ export class Conversation {
1063
1229
  async interrupt() {
1064
1230
  if (!this.proc?.alive) return { interrupted: false, reason: 'DSH 进程未运行' };
1065
1231
  this.proc.notify('session/cancel', { sessionId: this.id });
1232
+ this.#live('interrupt', { busy: this.busy });
1066
1233
  if (!this.busy) {
1067
1234
  return { interrupted: false, reason: '当前没有进行中的回合(已发送 cancel,用于清空排队输入)' };
1068
1235
  }
@@ -1206,6 +1373,12 @@ export class Conversation {
1206
1373
  const run = this.createRun(preview ?? text, { clientRunId, review });
1207
1374
  run.status = 'queued';
1208
1375
  this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
1376
+ emitLive(
1377
+ this.id,
1378
+ 'run.queued',
1379
+ { run: run.run_id, turn: this.currentTurn, position: this.queue.length, cwd: this.cwd },
1380
+ { prompt_preview: livePreview(run.prompt_preview, 200) },
1381
+ );
1209
1382
  return { run, position: this.queue.length, deduped: false };
1210
1383
  }
1211
1384
 
@@ -1250,6 +1423,7 @@ export class Conversation {
1250
1423
  permission: this.permission,
1251
1424
  });
1252
1425
  }
1426
+ emitLive(this.id, 'run.end', { run: run.run_id, turn: this.currentTurn, status: 'cancelled', stop_reason: 'cancelled' });
1253
1427
  // 取消也是终态 → 写哨兵,避免等待方永远空等。
1254
1428
  const file = this.#hub.writeRunSentinel(this.id, run);
1255
1429
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
@@ -1284,7 +1458,7 @@ export class Conversation {
1284
1458
  const item = this.queue.shift();
1285
1459
  const run = item.run;
1286
1460
  run.status = 'running';
1287
- this.prompt(item.text, { reasoning: item.reasoning, run, wait: false })
1461
+ this.prompt(item.text, { reasoning: item.reasoning, run, wait: false, via: 'queue' })
1288
1462
  .catch(() => {})
1289
1463
  .finally(() => this.#scheduleDrain());
1290
1464
  }, 0);
@@ -1481,13 +1655,14 @@ export class Conversation {
1481
1655
  // 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
1482
1656
  const run = this.createRun(message, { clientRunId, review });
1483
1657
  if (!this.busy) {
1484
- const r = await this.prompt(message, { reasoning, timeoutMs, run });
1658
+ const r = await this.prompt(message, { reasoning, timeoutMs, run, via: 'interject' });
1485
1659
  return { ...r, interjected: 'idle', waited_ms: 0 };
1486
1660
  }
1487
1661
  const t0 = now();
1488
1662
  if (mode === 'interject') {
1489
1663
  this.proc?.notify('session/cancel', { sessionId: this.id });
1490
1664
  }
1665
+ this.#live('interject', { mode, pending_run: run.run_id }, { text_preview: livePreview(message, 200) });
1491
1666
  // 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
1492
1667
  const idle = await this.waitIdle(timeoutMs);
1493
1668
  const waited = now() - t0;
@@ -1519,11 +1694,12 @@ export class Conversation {
1519
1694
  permission: this.permission,
1520
1695
  });
1521
1696
  }
1697
+ emitLive(this.id, 'run.end', { run: run.run_id, turn: this.currentTurn, status: 'cancelled', stop_reason: 'cancelled' }, { error: livePreview(err.message, 300) });
1522
1698
  const file = this.#hub.writeRunSentinel?.(this.id, run);
1523
1699
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
1524
1700
  throw err;
1525
1701
  }
1526
- const r = await this.prompt(message, { reasoning, timeoutMs, run });
1702
+ const r = await this.prompt(message, { reasoning, timeoutMs, run, via: mode === 'queue' ? 'queue' : 'interject' });
1527
1703
  return { ...r, interjected: mode, waited_ms: waited };
1528
1704
  }
1529
1705
 
@@ -1559,10 +1735,11 @@ export class Conversation {
1559
1735
  this.proc = null;
1560
1736
  }
1561
1737
  // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1562
- for (const [, p] of this.pendingApprovals) {
1738
+ for (const [apId, p] of this.pendingApprovals) {
1563
1739
  try {
1564
1740
  p.resolve({ outcome: { outcome: 'cancelled' } });
1565
1741
  } catch {}
1742
+ this.#liveApprovalDecided(apId, 'close', null, 'cancelled');
1566
1743
  }
1567
1744
  this.pendingApprovals.clear();
1568
1745
  // 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
@@ -1579,7 +1756,12 @@ export class Conversation {
1579
1756
  // 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
1580
1757
  writeTombstone(this.id);
1581
1758
  this.#hub.remove(this.id);
1582
- } else this.#hub.save();
1759
+ // forget 是"永久移除":事件流也一并删掉,查看器里不再出现这个会话。
1760
+ removeLive(this.id);
1761
+ } else {
1762
+ this.#hub.save();
1763
+ this.#live('conv.close', { stopped });
1764
+ }
1583
1765
  return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
1584
1766
  }
1585
1767
  }
package/src/hub.mjs CHANGED
@@ -63,6 +63,7 @@ import { rssMb } from './procstat.mjs';
63
63
  import { readConversationMeta } from './titles.mjs';
64
64
  import { Conversation } from './conversation.mjs';
65
65
  import { pruneDirTree, sanitizeResultForSentinel } from './sentinel.mjs';
66
+ import { emitLive, livePreview } from './live.mjs';
66
67
  import { homeWorkspaceProblem } from './workspace-guard.mjs';
67
68
  import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
68
69
  import {
@@ -579,6 +580,12 @@ export class Hub {
579
580
  }
580
581
  this.conversations.set(conv.id, conv);
581
582
  this.save();
583
+ emitLive(
584
+ conv.id,
585
+ 'conv.open',
586
+ { cwd, permission, on_approval: conv.onApproval, model: conv.cfg?.model ?? null },
587
+ { title_hint: livePreview(conv.titleHint, 120) },
588
+ );
582
589
  this.log.info(
583
590
  `[hub] 新建会话 ${conv.id}(cwd=${cwd}, 权限=${permission}, 思考深度=${effort}` +
584
591
  `${conv.workspace?.registered ? ', 工作区已登记' : `, 工作区登记失败: ${conv.workspace?.error}`})`,
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `dsh-any-agent watch` / `dsh-any-agent dashboard` 两个子命令的入口。
3
+ *
4
+ * 两者都是**独立进程**、只读 live 事件流,与 MCP 服务进程无关:不碰写锁、不连 DSH、
5
+ * 不需要服务在跑(没有事件时就显示空)。
6
+ */
7
+
8
+ import { LIVE_DIR, STATE_FILE } from './config.mjs';
9
+ import { LiveTail, diffFrame, layoutFrame, renderText } from './live-view.mjs';
10
+ import { readConversationMeta } from './titles.mjs';
11
+
12
+ function parseFlags(args) {
13
+ const flags = {};
14
+ const positional = [];
15
+ for (let i = 0; i < args.length; i++) {
16
+ const a = args[i];
17
+ if (a.startsWith('--')) {
18
+ const [k, v] = a.slice(2).split('=', 2);
19
+ if (v !== undefined) flags[k] = v;
20
+ else if (args[i + 1] !== undefined && !args[i + 1].startsWith('--')) flags[k] = args[++i];
21
+ else flags[k] = true;
22
+ } else positional.push(a);
23
+ }
24
+ return { flags, positional };
25
+ }
26
+
27
+ const intFlag = (v, d, min, max) => {
28
+ const n = Number.parseInt(v, 10);
29
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d;
30
+ };
31
+
32
+ export function makeTail(dir = LIVE_DIR) {
33
+ return new LiveTail(dir, {
34
+ stateFile: STATE_FILE,
35
+ readTitle: (id) => readConversationMeta(id)?.title ?? null,
36
+ });
37
+ }
38
+
39
+ export const WATCH_USAGE =
40
+ '用法: dsh-any-agent watch [会话id前缀] [--once] [--interval 毫秒] [--runs N] [--items N] [--no-color]\n';
41
+
42
+ /** watch:终端里实时显示调用链(Ctrl+C 退出)。`--once` 打印一次就退出。 */
43
+ export async function runWatch(args, { out = process.stdout } = {}) {
44
+ const { flags, positional } = parseFlags(args);
45
+ if (flags.help || flags.h) {
46
+ out.write(WATCH_USAGE);
47
+ return 0;
48
+ }
49
+ const tail = makeTail(typeof flags.dir === 'string' ? flags.dir : LIVE_DIR);
50
+ const opts = {
51
+ conv: positional[0] ?? null,
52
+ runs: intFlag(flags.runs, 3, 1, 30),
53
+ items: intFlag(flags.items, 12, 1, 300),
54
+ color: !flags['no-color'] && Boolean(out.isTTY),
55
+ };
56
+ tail.poll();
57
+ if (flags.once) {
58
+ out.write(renderText(tail.view(), opts) + '\n');
59
+ return 0;
60
+ }
61
+ const interval = intFlag(flags.interval, 500, 100, 10_000);
62
+ const tty = Boolean(out.isTTY);
63
+ 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`;
66
+
67
+ // ★ 在 TTY 上用"备用屏幕"原地刷新(同 top / htop):每帧只重写变化的行。
68
+ // 旧实现每次 `ESC[2J` 清屏,Windows Terminal 会把被清掉的整屏推进回滚区,
69
+ // 往上一翻就是一摞重复的帧。备用屏幕没有回滚区,退出时原样恢复用户的终端。
70
+ let prev = null;
71
+ let lastText = '';
72
+ const draw = (force = false) => {
73
+ const text = frameText();
74
+ if (!tty) {
75
+ // 管道 / 重定向:没有光标控制,内容变了才追加一帧(去掉时间行再比较,免得每次都算变化)。
76
+ const body = text.slice(0, text.lastIndexOf('\n\n'));
77
+ if (body !== lastText) out.write(text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') + '\n\n');
78
+ lastText = body;
79
+ return;
80
+ }
81
+ const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40 });
82
+ const seq = diffFrame(force ? null : prev, next);
83
+ if (seq) out.write(seq);
84
+ prev = next;
85
+ };
86
+ const restore = () => {
87
+ if (tty) out.write('\x1b[0m\x1b[?25h\x1b[?1049l');
88
+ };
89
+ if (tty) out.write('\x1b[?1049h\x1b[?25l');
90
+ process.once('exit', restore);
91
+ draw(true);
92
+ const onResize = () => draw(true);
93
+ if (tty) out.on?.('resize', onResize);
94
+ await new Promise((resolve) => {
95
+ const timer = setInterval(() => {
96
+ // 有新事件才重算;时间行每秒在变,所以每次都会写,但 diffFrame 只重写真正变了的那几行。
97
+ tail.poll();
98
+ draw();
99
+ }, interval);
100
+ const stop = () => {
101
+ clearInterval(timer);
102
+ resolve();
103
+ };
104
+ process.once('SIGINT', stop);
105
+ process.once('SIGTERM', stop);
106
+ });
107
+ out.off?.('resize', onResize);
108
+ restore();
109
+ process.off('exit', restore);
110
+ return 0;
111
+ }
112
+
113
+ export const DASHBOARD_USAGE = '用法: dsh-any-agent dashboard [--port 端口] [--interval 毫秒]\n';
114
+
115
+ /** dashboard:起一个只读本地页面(127.0.0.1 + 一次性 token),Ctrl+C 退出。 */
116
+ export async function runDashboard(args, { out = process.stdout } = {}) {
117
+ const { flags } = parseFlags(args);
118
+ if (flags.help || flags.h) {
119
+ out.write(DASHBOARD_USAGE);
120
+ return 0;
121
+ }
122
+ const { startDashboard } = await import('./live-dashboard.mjs');
123
+ const tail = makeTail(typeof flags.dir === 'string' ? flags.dir : LIVE_DIR);
124
+ const dash = await startDashboard({
125
+ tail,
126
+ port: intFlag(flags.port, 0, 0, 65535),
127
+ pollMs: intFlag(flags.interval, 500, 100, 10_000),
128
+ });
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
+ );
135
+ await new Promise((resolve) => {
136
+ const stop = () => resolve();
137
+ process.once('SIGINT', stop);
138
+ process.once('SIGTERM', stop);
139
+ });
140
+ await dash.close();
141
+ return 0;
142
+ }