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.
- package/CHANGELOG.md +53 -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 +23 -0
- package/docs/zh/live-view-design.md +193 -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 +23 -0
- package/package.json +1 -1
- package/src/config.mjs +25 -0
- package/src/conversation.mjs +189 -7
- package/src/hub.mjs +7 -0
- package/src/live-cli.mjs +142 -0
- package/src/live-dashboard.html +847 -0
- package/src/live-dashboard.mjs +176 -0
- package/src/live-view.mjs +762 -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, 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
|
-
|
|
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}`})`,
|
package/src/live-cli.mjs
ADDED
|
@@ -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
|
+
}
|