dsh-any-agent 0.6.0 → 0.6.1

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, removeLive, summarizeToolInput } from './live.mjs';
43
44
  import {
44
45
  classifyHolder,
45
46
  describeLeaseConflict,
@@ -141,6 +142,27 @@ 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
+ /** 审批裁决事件(decide / 超时 / 回合结束 / 关闭 统一走这里)。 */
162
+ #liveApprovalDecided(approvalId, by, decision = null, effective = null) {
163
+ this.#live('approval.decided', { approval_id: approvalId, by, decision, effective });
164
+ }
165
+
144
166
  /** 序列化进注册表的最小状态——**只有元数据,没有对话内容**。 */
145
167
  toState() {
146
168
  return {
@@ -571,6 +593,7 @@ export class Conversation {
571
593
  if (u.title !== undefined || u.status !== undefined || u.name !== undefined) {
572
594
  this.pushOut('tool', `${label}${tag}`, { tool_call_id: id });
573
595
  }
596
+ this.#liveTool(id, merged);
574
597
  break;
575
598
  }
576
599
  case 'usage_update':
@@ -585,6 +608,35 @@ export class Conversation {
585
608
  }
586
609
  }
587
610
 
611
+ /**
612
+ * 工具状态 → live 事件。DSH 的工具更新是快照、会重复推同一状态,所以只在
613
+ * "第一次出现 / 状态变化 / 第一次拿到输入"时写,避免刷屏。
614
+ */
615
+ #liveTool(id, merged) {
616
+ const seen = this.#liveTools.get(id);
617
+ const hasInput = merged.input !== undefined && merged.input !== null;
618
+ const status = merged.status ?? null;
619
+ if (seen && seen.status === status && (seen.hasInput || !hasInput)) return;
620
+ const at = seen?.at ?? now();
621
+ this.#liveTools.set(id, { at, status, hasInput });
622
+ const terminal = status === 'completed' || status === 'failed';
623
+ // 真实 DSH 的 tool_call 不带 name,工具名在 title 里("read" / "pwsh")。title 是纯标识符时
624
+ // 当作名字写进 meta 级字段;带空格等可能含命令原文的 title 只进 detail。
625
+ const titleAsName =
626
+ typeof merged.title === 'string' && /^[\w.:-]{1,40}$/.test(merged.title) ? merged.title : null;
627
+ this.#live(
628
+ 'tool',
629
+ {
630
+ tool_call_id: id,
631
+ name: merged.name ?? titleAsName,
632
+ kind: merged.kind ?? null,
633
+ status,
634
+ elapsed_ms: terminal ? now() - at : undefined,
635
+ },
636
+ { title: livePreview(merged.title, 200), input_preview: summarizeToolInput(merged.input) },
637
+ );
638
+ }
639
+
588
640
  async onPermissionRequest(params) {
589
641
  const options = params?.options ?? [];
590
642
  const rawToolCall = params?.toolCall ?? {};
@@ -605,11 +657,24 @@ export class Conversation {
605
657
  const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
606
658
  const allowId = pickOption(options, 'allow');
607
659
  const rejectId = pickOption(options, 'reject');
660
+ const liveApproval = (mode, extra = {}) => {
661
+ const input = toolCall.rawInput ?? toolCall.input ?? null;
662
+ const escalation =
663
+ input && typeof input === 'object' && typeof input.sandbox_permissions === 'string'
664
+ ? input.sandbox_permissions
665
+ : null;
666
+ this.#live(
667
+ 'approval',
668
+ { tool_call_id: toolCall.toolCallId ?? null, tool: toolCall.name ?? null, mode, escalation, ...extra },
669
+ { title: livePreview(desc, 200), command_preview: summarizeToolInput(input) },
670
+ );
671
+ };
608
672
 
609
673
  // ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
610
674
  // 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
611
675
  if (this.permission === 'read-only') {
612
676
  this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
677
+ liveApproval('read-only-deny');
613
678
  return rejectId
614
679
  ? { outcome: { outcome: 'selected', optionId: rejectId } }
615
680
  : { outcome: { outcome: 'cancelled' } };
@@ -619,22 +684,26 @@ export class Conversation {
619
684
  // ★ 自动放行必须对调用方可见:DSH 会借审批把沙箱提升到 danger-full-access,
620
685
  // 不记下来的话 workspace-write 会话事后看起来像从未越界(实测一轮 6 次提权无人知晓)。
621
686
  if (allowId) this.#recordAutoApproval(params, toolCall, desc, options);
687
+ liveApproval('auto-allow');
622
688
  return allowId
623
689
  ? { outcome: { outcome: 'selected', optionId: allowId } }
624
690
  : { outcome: { outcome: 'cancelled' } };
625
691
  }
626
692
  if (this.onApproval === 'auto-deny') {
627
693
  this.#hub.log.info(`[conv ${this.id}] 审批拒绝: ${desc}`);
694
+ liveApproval('auto-deny');
628
695
  return rejectId
629
696
  ? { outcome: { outcome: 'selected', optionId: rejectId } }
630
697
  : { outcome: { outcome: 'cancelled' } };
631
698
  }
632
699
  const approvalId = `ap-${Math.random().toString(36).slice(2, 10)}`;
633
700
  this.#hub.log.info(`[conv ${this.id}] 审批待裁决: ${approvalId}`);
701
+ liveApproval('ask', { approval_id: approvalId });
634
702
  return await new Promise((resolve) => {
635
703
  const timer = setTimeout(() => {
636
704
  if (this.pendingApprovals.delete(approvalId)) {
637
705
  this.#hub.log.info(`[conv ${this.id}] 审批 ${approvalId} 超时,按拒绝处理`);
706
+ this.#liveApprovalDecided(approvalId, 'timeout', 'reject', 'cancelled');
638
707
  resolve({ outcome: { outcome: 'cancelled' } });
639
708
  }
640
709
  }, this.#hub.approvalTimeoutMs);
@@ -690,6 +759,7 @@ export class Conversation {
690
759
  // 否则 dsh_approval_decide 会渲染成"已裁决为 allow",而 DSH 收到的是取消(假成功)。
691
760
  const effective = chosen ? 'selected' : 'cancelled';
692
761
  p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
762
+ this.#liveApprovalDecided(approvalId, 'caller', decision, effective);
693
763
  return {
694
764
  approval_id: approvalId,
695
765
  decision,
@@ -719,6 +789,12 @@ export class Conversation {
719
789
  runRecord.status = 'error';
720
790
  runRecord.error = error?.message ?? String(error);
721
791
  runRecord.elapsed_ms = 0;
792
+ emitLive(
793
+ this.id,
794
+ 'run.end',
795
+ { run: runRecord.run_id, turn: this.currentTurn, status: 'error', stop_reason: 'error' },
796
+ { error: livePreview(runRecord.error, 300) },
797
+ );
722
798
  runRecord.result = {
723
799
  conversation_id: this.id,
724
800
  run_id: runRecord.run_id,
@@ -789,7 +865,7 @@ export class Conversation {
789
865
  *
790
866
  * @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
791
867
  */
792
- async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
868
+ async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review, via = 'send' } = {}) {
793
869
  if (this.busy) {
794
870
  throw new Error(
795
871
  `会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
@@ -835,6 +911,12 @@ export class Conversation {
835
911
  this.currentTool = null;
836
912
  this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
837
913
  this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
914
+ this.#liveTools.clear();
915
+ this.#live(
916
+ 'run.start',
917
+ { via, permission: this.permission, on_approval: this.onApproval, cwd: this.cwd },
918
+ { prompt_preview: livePreview(text, 200) },
919
+ );
838
920
  const c = {
839
921
  answer: '',
840
922
  /** 上一段正文之后出现过工具调用:下一段正文前要补段落分隔(见 onUpdate)。 */
@@ -900,10 +982,11 @@ export class Conversation {
900
982
  this.currentTool = null;
901
983
  // 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
902
984
  // 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
903
- for (const [, p] of this.pendingApprovals) {
985
+ for (const [apId, p] of this.pendingApprovals) {
904
986
  try {
905
987
  p.resolve({ outcome: { outcome: 'cancelled' } });
906
988
  } catch {}
989
+ this.#liveApprovalDecided(apId, 'turn_end', null, 'cancelled');
907
990
  }
908
991
  this.pendingApprovals.clear();
909
992
  this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
@@ -911,6 +994,21 @@ export class Conversation {
911
994
  stop_reason: stopReason,
912
995
  error: failure ?? undefined,
913
996
  });
997
+ emitLive(
998
+ this.id,
999
+ 'run.end',
1000
+ {
1001
+ run: runRecord.run_id,
1002
+ turn: this.currentTurn,
1003
+ status: failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done',
1004
+ stop_reason: stopReason ?? null,
1005
+ duration_ms: now() - t0,
1006
+ tool_count: c.tools.size,
1007
+ auto_approvals: c.autoApprovalTotal,
1008
+ auto_escalations: c.autoEscalationTotal,
1009
+ },
1010
+ { error: livePreview(failure, 300) },
1011
+ );
914
1012
  this.turnCount += 1;
915
1013
  this.lastUsedAt = now();
916
1014
  this.#hub.save();
@@ -1063,6 +1161,7 @@ export class Conversation {
1063
1161
  async interrupt() {
1064
1162
  if (!this.proc?.alive) return { interrupted: false, reason: 'DSH 进程未运行' };
1065
1163
  this.proc.notify('session/cancel', { sessionId: this.id });
1164
+ this.#live('interrupt', { busy: this.busy });
1066
1165
  if (!this.busy) {
1067
1166
  return { interrupted: false, reason: '当前没有进行中的回合(已发送 cancel,用于清空排队输入)' };
1068
1167
  }
@@ -1206,6 +1305,12 @@ export class Conversation {
1206
1305
  const run = this.createRun(preview ?? text, { clientRunId, review });
1207
1306
  run.status = 'queued';
1208
1307
  this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
1308
+ emitLive(
1309
+ this.id,
1310
+ 'run.queued',
1311
+ { run: run.run_id, turn: this.currentTurn, position: this.queue.length, cwd: this.cwd },
1312
+ { prompt_preview: livePreview(run.prompt_preview, 200) },
1313
+ );
1209
1314
  return { run, position: this.queue.length, deduped: false };
1210
1315
  }
1211
1316
 
@@ -1250,6 +1355,7 @@ export class Conversation {
1250
1355
  permission: this.permission,
1251
1356
  });
1252
1357
  }
1358
+ emitLive(this.id, 'run.end', { run: run.run_id, turn: this.currentTurn, status: 'cancelled', stop_reason: 'cancelled' });
1253
1359
  // 取消也是终态 → 写哨兵,避免等待方永远空等。
1254
1360
  const file = this.#hub.writeRunSentinel(this.id, run);
1255
1361
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
@@ -1284,7 +1390,7 @@ export class Conversation {
1284
1390
  const item = this.queue.shift();
1285
1391
  const run = item.run;
1286
1392
  run.status = 'running';
1287
- this.prompt(item.text, { reasoning: item.reasoning, run, wait: false })
1393
+ this.prompt(item.text, { reasoning: item.reasoning, run, wait: false, via: 'queue' })
1288
1394
  .catch(() => {})
1289
1395
  .finally(() => this.#scheduleDrain());
1290
1396
  }, 0);
@@ -1481,13 +1587,14 @@ export class Conversation {
1481
1587
  // 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
1482
1588
  const run = this.createRun(message, { clientRunId, review });
1483
1589
  if (!this.busy) {
1484
- const r = await this.prompt(message, { reasoning, timeoutMs, run });
1590
+ const r = await this.prompt(message, { reasoning, timeoutMs, run, via: 'interject' });
1485
1591
  return { ...r, interjected: 'idle', waited_ms: 0 };
1486
1592
  }
1487
1593
  const t0 = now();
1488
1594
  if (mode === 'interject') {
1489
1595
  this.proc?.notify('session/cancel', { sessionId: this.id });
1490
1596
  }
1597
+ this.#live('interject', { mode, pending_run: run.run_id }, { text_preview: livePreview(message, 200) });
1491
1598
  // 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
1492
1599
  const idle = await this.waitIdle(timeoutMs);
1493
1600
  const waited = now() - t0;
@@ -1519,11 +1626,12 @@ export class Conversation {
1519
1626
  permission: this.permission,
1520
1627
  });
1521
1628
  }
1629
+ emitLive(this.id, 'run.end', { run: run.run_id, turn: this.currentTurn, status: 'cancelled', stop_reason: 'cancelled' }, { error: livePreview(err.message, 300) });
1522
1630
  const file = this.#hub.writeRunSentinel?.(this.id, run);
1523
1631
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
1524
1632
  throw err;
1525
1633
  }
1526
- const r = await this.prompt(message, { reasoning, timeoutMs, run });
1634
+ const r = await this.prompt(message, { reasoning, timeoutMs, run, via: mode === 'queue' ? 'queue' : 'interject' });
1527
1635
  return { ...r, interjected: mode, waited_ms: waited };
1528
1636
  }
1529
1637
 
@@ -1559,10 +1667,11 @@ export class Conversation {
1559
1667
  this.proc = null;
1560
1668
  }
1561
1669
  // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1562
- for (const [, p] of this.pendingApprovals) {
1670
+ for (const [apId, p] of this.pendingApprovals) {
1563
1671
  try {
1564
1672
  p.resolve({ outcome: { outcome: 'cancelled' } });
1565
1673
  } catch {}
1674
+ this.#liveApprovalDecided(apId, 'close', null, 'cancelled');
1566
1675
  }
1567
1676
  this.pendingApprovals.clear();
1568
1677
  // 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
@@ -1579,7 +1688,12 @@ export class Conversation {
1579
1688
  // 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
1580
1689
  writeTombstone(this.id);
1581
1690
  this.#hub.remove(this.id);
1582
- } else this.#hub.save();
1691
+ // forget 是"永久移除":事件流也一并删掉,查看器里不再出现这个会话。
1692
+ removeLive(this.id);
1693
+ } else {
1694
+ this.#hub.save();
1695
+ this.#live('conv.close', { stopped });
1696
+ }
1583
1697
  return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
1584
1698
  }
1585
1699
  }
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,115 @@
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, 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 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`);
65
+ };
66
+ draw();
67
+ let lastDraw = Date.now();
68
+ await new Promise((resolve) => {
69
+ const timer = setInterval(() => {
70
+ // 有新事件就重画;没有也每 5 秒重画一次(刷新"写者是否还活着")。
71
+ if (tail.poll() || Date.now() - lastDraw > 5000) {
72
+ draw();
73
+ lastDraw = Date.now();
74
+ }
75
+ }, interval);
76
+ const stop = () => {
77
+ clearInterval(timer);
78
+ resolve();
79
+ };
80
+ process.once('SIGINT', stop);
81
+ process.once('SIGTERM', stop);
82
+ });
83
+ return 0;
84
+ }
85
+
86
+ export const DASHBOARD_USAGE = '用法: dsh-any-agent dashboard [--port 端口] [--interval 毫秒]\n';
87
+
88
+ /** dashboard:起一个只读本地页面(127.0.0.1 + 一次性 token),Ctrl+C 退出。 */
89
+ export async function runDashboard(args, { out = process.stdout } = {}) {
90
+ const { flags } = parseFlags(args);
91
+ if (flags.help || flags.h) {
92
+ out.write(DASHBOARD_USAGE);
93
+ return 0;
94
+ }
95
+ const { startDashboard } = await import('./live-dashboard.mjs');
96
+ const tail = makeTail(typeof flags.dir === 'string' ? flags.dir : LIVE_DIR);
97
+ const dash = await startDashboard({
98
+ tail,
99
+ port: intFlag(flags.port, 0, 0, 65535),
100
+ pollMs: intFlag(flags.interval, 500, 100, 10_000),
101
+ });
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
+ );
108
+ await new Promise((resolve) => {
109
+ const stop = () => resolve();
110
+ process.once('SIGINT', stop);
111
+ process.once('SIGTERM', stop);
112
+ });
113
+ await dash.close();
114
+ return 0;
115
+ }