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.
- package/CHANGELOG.md +17 -0
- package/bin/dsh-any-agent.mjs +23 -1
- package/docs/en/reference.md +2 -0
- package/docs/en/security.md +1 -0
- package/docs/en/tests.md +4 -2
- package/docs/en/usage.md +22 -0
- package/docs/zh/live-view-design.md +192 -0
- package/docs/zh/reference.md +2 -0
- package/docs/zh/security.md +1 -0
- package/docs/zh/tests.md +4 -2
- package/docs/zh/usage.md +22 -0
- package/package.json +1 -1
- package/src/config.mjs +25 -0
- package/src/conversation.mjs +121 -7
- package/src/hub.mjs +7 -0
- package/src/live-cli.mjs +115 -0
- package/src/live-dashboard.html +535 -0
- package/src/live-dashboard.mjs +176 -0
- package/src/live-view.mjs +592 -0
- package/src/live.mjs +186 -0
- package/src/mcp.mjs +3 -0
- package/src/residue.mjs +7 -3
package/src/conversation.mjs
CHANGED
|
@@ -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
|
-
|
|
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}`})`,
|
package/src/live-cli.mjs
ADDED
|
@@ -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
|
+
}
|