dsh-any-agent 0.3.1 → 0.3.3
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/README.md +128 -65
- package/README.zh.md +119 -60
- package/USAGE-NOTES.en.md +5 -3
- package/USAGE-NOTES.md +6 -4
- package/bin/dsh-any-agent.mjs +4 -1
- package/package.json +5 -3
- package/scripts/commit.mjs +122 -0
- package/src/config.mjs +1 -1
- package/src/hub.mjs +308 -30
- package/src/mcp.mjs +91 -3
- package/src/tools.mjs +74 -18
- package/src/workspace.mjs +8 -0
package/src/hub.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
rmSync,
|
|
24
24
|
readdirSync,
|
|
25
25
|
} from 'node:fs';
|
|
26
|
-
import { dirname, isAbsolute, join } from 'node:path';
|
|
26
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
27
|
+
import { homedir, tmpdir } from 'node:os';
|
|
27
28
|
import { spawnSync } from 'node:child_process';
|
|
28
29
|
import {
|
|
29
30
|
APPROVAL_POLICIES,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SENTINEL_INCLUDE_REASONING,
|
|
41
42
|
SENTINEL_TTL_MS,
|
|
42
43
|
STATE_FILE,
|
|
44
|
+
DSH_HOME,
|
|
43
45
|
defaultApprovalPolicy,
|
|
44
46
|
} from './config.mjs';
|
|
45
47
|
import { AcpProcess } from './acp.mjs';
|
|
@@ -59,6 +61,92 @@ import {
|
|
|
59
61
|
const now = () => Date.now();
|
|
60
62
|
const NOOP_LOG = { info: () => {}, debug: () => {}, dshStderr: () => {} };
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 会话元数据侧车(`.state/meta/<id>.json`)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么需要:`forget=true` 只从注册表移除;之后按 id 查询时,`Hub.get()` 会从
|
|
68
|
+
* DSH 会话存储重建会话(这是多实例恢复的刻意设计)。但 DSH 的会话记录里**没有**
|
|
69
|
+
* 本服务选择的 `onApproval` / `reasoning_effort` / `title_hint` / 轮数,
|
|
70
|
+
* 重建就会悄悄回落到默认值(实测 auto-allow→ask、low→max)。这个侧车保存
|
|
71
|
+
* **仅元数据**(不含任何对话/思考正文),让重建后仍与原会话一致。
|
|
72
|
+
*/
|
|
73
|
+
const META_DIR = join(dirname(STATE_FILE), 'meta');
|
|
74
|
+
const metaFilePath = (id) => join(META_DIR, `${id}.json`);
|
|
75
|
+
|
|
76
|
+
function writeConversationMeta(conv) {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(META_DIR, { recursive: true });
|
|
79
|
+
const file = metaFilePath(conv.id);
|
|
80
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(conv.toMeta(), null, 2), 'utf8');
|
|
82
|
+
renameSync(tmp, file);
|
|
83
|
+
} catch {
|
|
84
|
+
/* 元数据侧车只是辅助信息,写失败不影响主流程 */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readConversationMetaFile(id) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(readFileSync(metaFilePath(id), 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* forget 墓碑:`release(forget=true)` 之后,按 id 查询必须**明确失败**,
|
|
98
|
+
* 而不是从 DSH 会话存储里悄悄复活。墓碑只含 id 与时间,不含任何内容;
|
|
99
|
+
* 删除 `.state/forgotten/<id>.json` 即可恢复按 id 找回(手动、可审计)。
|
|
100
|
+
*/
|
|
101
|
+
// 墓碑放在 DSH home 下而不是注册表旁边:不同实例即使注册表路径不同,
|
|
102
|
+
// 也应该对同一个会话的 forget 达成一致(forget 是会话级终态,不是注册表级)。
|
|
103
|
+
const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
|
|
104
|
+
const forgottenPath = (id) => join(FORGOTTEN_DIR, `${id}.json`);
|
|
105
|
+
|
|
106
|
+
function writeTombstone(id) {
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(FORGOTTEN_DIR, { recursive: true });
|
|
109
|
+
const file = forgottenPath(id);
|
|
110
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
111
|
+
writeFileSync(tmp, JSON.stringify({ id, forgottenAt: Date.now() }, null, 2), 'utf8');
|
|
112
|
+
renameSync(tmp, file);
|
|
113
|
+
} catch {
|
|
114
|
+
/* 墓碑写失败时 forget 仍从注册表移除;下次 get 可能仍会复活,但不影响主流程 */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isForgotten(id) {
|
|
119
|
+
try {
|
|
120
|
+
return existsSync(forgottenPath(id));
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 启动时清掉“会话文件已经不存在”的墓碑:它们不再阻止任何 resurrection,
|
|
128
|
+
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
129
|
+
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
130
|
+
*/
|
|
131
|
+
function pruneStaleTombstones() {
|
|
132
|
+
let removed = 0;
|
|
133
|
+
try {
|
|
134
|
+
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
135
|
+
for (const f of readdirSync(FORGOTTEN_DIR)) {
|
|
136
|
+
if (!f.endsWith('.json')) continue;
|
|
137
|
+
const id = f.slice(0, -5);
|
|
138
|
+
if (findSessionHeader(id)) continue;
|
|
139
|
+
try {
|
|
140
|
+
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
141
|
+
removed++;
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
/* 清理失败不影响启动 */
|
|
146
|
+
}
|
|
147
|
+
return removed;
|
|
148
|
+
}
|
|
149
|
+
|
|
62
150
|
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
63
151
|
const MAX_OUT_ENTRIES = 500;
|
|
64
152
|
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
@@ -148,11 +236,21 @@ async function applySessionConfig(proc, sessionId, cfg) {
|
|
|
148
236
|
* 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
|
|
149
237
|
* 免得调用方以为字段是意外丢失的。
|
|
150
238
|
*/
|
|
151
|
-
function sanitizeResultForSentinel(result) {
|
|
239
|
+
export function sanitizeResultForSentinel(result) {
|
|
152
240
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
153
241
|
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
154
242
|
const { thinking, ...rest } = result;
|
|
155
|
-
|
|
243
|
+
// 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
|
|
244
|
+
// 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
|
|
245
|
+
if (Array.isArray(rest.tools_used)) {
|
|
246
|
+
rest.tools_used = rest.tools_used.map((t) => {
|
|
247
|
+
if (!t || typeof t !== 'object') return t;
|
|
248
|
+
const { input, ...meta } = t;
|
|
249
|
+
return input === undefined ? meta : { ...meta, input: sanitizeApprovalContext(input) };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
253
|
+
return { ...rest, thinking_omitted: Boolean(thinking) };
|
|
156
254
|
}
|
|
157
255
|
|
|
158
256
|
export class Conversation {
|
|
@@ -193,6 +291,8 @@ export class Conversation {
|
|
|
193
291
|
*/
|
|
194
292
|
this.out = [];
|
|
195
293
|
this.outSeq = 0;
|
|
294
|
+
/** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
|
|
295
|
+
this.outRev = 0;
|
|
196
296
|
this.turnOutChars = 0;
|
|
197
297
|
this.currentTurn = 0;
|
|
198
298
|
this.turnStartedAt = 0;
|
|
@@ -228,6 +328,11 @@ export class Conversation {
|
|
|
228
328
|
};
|
|
229
329
|
}
|
|
230
330
|
|
|
331
|
+
/** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
|
|
332
|
+
toMeta() {
|
|
333
|
+
return { version: 1, ...this.toState() };
|
|
334
|
+
}
|
|
335
|
+
|
|
231
336
|
/** 合规的公开快照(含磁盘上的元信息)。 */
|
|
232
337
|
snapshot() {
|
|
233
338
|
const meta = readConversationMeta(this.id);
|
|
@@ -236,6 +341,13 @@ export class Conversation {
|
|
|
236
341
|
conversation_id: this.id,
|
|
237
342
|
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
238
343
|
title: nativeTitle ?? this.titleHint ?? null,
|
|
344
|
+
/**
|
|
345
|
+
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
346
|
+
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
347
|
+
* 所以调用方给了 title_hint 时,把两者分开返回,由调用方自己选:
|
|
348
|
+
* 想按 DSH 原生标题认会话就看 title,想要可读标签就看 title_hint。
|
|
349
|
+
*/
|
|
350
|
+
title_hint: this.titleHint ?? null,
|
|
239
351
|
cwd: this.cwd,
|
|
240
352
|
permission: this.permission,
|
|
241
353
|
on_approval: this.onApproval,
|
|
@@ -487,11 +599,33 @@ export class Conversation {
|
|
|
487
599
|
|
|
488
600
|
async onPermissionRequest(params) {
|
|
489
601
|
const options = params?.options ?? [];
|
|
490
|
-
const
|
|
602
|
+
const rawToolCall = params?.toolCall ?? {};
|
|
603
|
+
// 真实 DSH 的 request_permission 只带 toolCallId,不带 rawInput;
|
|
604
|
+
// 同一 toolCallId 的参数通常已经在 tool_call 更新里记录过,按 id 关联回来,
|
|
605
|
+
// 否则审批详情里没有命令/路径,调用方无法裁决。
|
|
606
|
+
const known = this.collector?.tools?.get(rawToolCall.toolCallId) ?? null;
|
|
607
|
+
const toolCall = known
|
|
608
|
+
? {
|
|
609
|
+
...rawToolCall,
|
|
610
|
+
name: rawToolCall.name ?? known.name,
|
|
611
|
+
title: rawToolCall.title ?? known.title,
|
|
612
|
+
kind: rawToolCall.kind ?? known.kind,
|
|
613
|
+
rawInput: rawToolCall.rawInput ?? rawToolCall.input ?? known.input,
|
|
614
|
+
locations: rawToolCall.locations ?? known.locations,
|
|
615
|
+
}
|
|
616
|
+
: rawToolCall;
|
|
491
617
|
const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
|
|
492
618
|
const allowId = pickOption(options, 'allow');
|
|
493
619
|
const rejectId = pickOption(options, 'reject');
|
|
494
620
|
|
|
621
|
+
// ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
|
|
622
|
+
// 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
|
|
623
|
+
if (this.permission === 'read-only') {
|
|
624
|
+
this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
|
|
625
|
+
return rejectId
|
|
626
|
+
? { outcome: { outcome: 'selected', optionId: rejectId } }
|
|
627
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
628
|
+
}
|
|
495
629
|
if (this.onApproval === 'auto-allow') {
|
|
496
630
|
this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
|
|
497
631
|
return allowId
|
|
@@ -624,6 +758,14 @@ export class Conversation {
|
|
|
624
758
|
this.busy = false;
|
|
625
759
|
this.collector = null;
|
|
626
760
|
this.currentTool = null;
|
|
761
|
+
// 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
|
|
762
|
+
// 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
|
|
763
|
+
for (const [, p] of this.pendingApprovals) {
|
|
764
|
+
try {
|
|
765
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
766
|
+
} catch {}
|
|
767
|
+
}
|
|
768
|
+
this.pendingApprovals.clear();
|
|
627
769
|
this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
|
|
628
770
|
phase: 'end',
|
|
629
771
|
stop_reason: stopReason,
|
|
@@ -756,17 +898,24 @@ export class Conversation {
|
|
|
756
898
|
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
757
899
|
last.text += text;
|
|
758
900
|
last.at = now();
|
|
901
|
+
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
902
|
+
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
903
|
+
last.rev = ++this.outRev;
|
|
759
904
|
} else {
|
|
760
|
-
this.out.push({ seq: ++this.outSeq, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
|
|
905
|
+
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
|
|
761
906
|
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
762
907
|
}
|
|
763
908
|
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
764
909
|
}
|
|
765
910
|
|
|
766
|
-
/**
|
|
911
|
+
/** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
|
|
767
912
|
readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
|
|
768
|
-
const
|
|
769
|
-
|
|
913
|
+
const cur = Number.isFinite(cursor) ? cursor : 0;
|
|
914
|
+
// limit 必须清洗:负数会被 slice 解释成“从头到尾少一条”,0 会让调用方原地空转。
|
|
915
|
+
const n = Number.isFinite(limit) ? Math.max(1, Math.min(Math.floor(limit), MAX_OUT_ENTRIES)) : 200;
|
|
916
|
+
const revOf = (e) => (typeof e.rev === 'number' ? e.rev : e.seq);
|
|
917
|
+
const entries = this.out.filter((e) => revOf(e) > cur).slice(0, n);
|
|
918
|
+
const next = entries.length ? revOf(entries[entries.length - 1]) : cur;
|
|
770
919
|
const res = {
|
|
771
920
|
conversation_id: this.id,
|
|
772
921
|
state: this.state(),
|
|
@@ -774,9 +923,9 @@ export class Conversation {
|
|
|
774
923
|
current_tool: this.currentTool,
|
|
775
924
|
entries,
|
|
776
925
|
cursor: next,
|
|
777
|
-
has_more: this.out.some((e) => e
|
|
926
|
+
has_more: this.out.some((e) => revOf(e) > next),
|
|
778
927
|
/** 缓冲被环形覆盖时会为 true,表示你错过了中间一段。 */
|
|
779
|
-
gap: entries.length > 0 && this.out.length > 0 && this.out[0]
|
|
928
|
+
gap: entries.length > 0 && this.out.length > 0 && revOf(this.out[0]) > cur + 1,
|
|
780
929
|
};
|
|
781
930
|
// 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
|
|
782
931
|
if (includeReasoning && this.collector?.reasoning) {
|
|
@@ -816,24 +965,27 @@ export class Conversation {
|
|
|
816
965
|
return this.runs.length ? this.runs[this.runs.length - 1] : null;
|
|
817
966
|
}
|
|
818
967
|
|
|
819
|
-
/** 等到当前回合结束(不忙则立即返回 true
|
|
968
|
+
/** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
|
|
820
969
|
async waitIdle(timeoutMs = 30 * 60 * 1000) {
|
|
821
970
|
if (!this.busy) return true;
|
|
822
971
|
return await new Promise((resolve) => {
|
|
823
972
|
let settled = false;
|
|
973
|
+
let timer = null;
|
|
824
974
|
const done = () => {
|
|
825
975
|
if (settled) return;
|
|
826
976
|
settled = true;
|
|
827
|
-
clearTimeout(timer);
|
|
977
|
+
if (timer) clearTimeout(timer);
|
|
828
978
|
resolve(true);
|
|
829
979
|
};
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
980
|
+
if (timeoutMs > 0) {
|
|
981
|
+
timer = setTimeout(() => {
|
|
982
|
+
if (settled) return;
|
|
983
|
+
settled = true;
|
|
984
|
+
const i = this.idleWaiters.indexOf(done);
|
|
985
|
+
if (i >= 0) this.idleWaiters.splice(i, 1);
|
|
986
|
+
resolve(false);
|
|
987
|
+
}, timeoutMs);
|
|
988
|
+
}
|
|
837
989
|
this.idleWaiters.push(done);
|
|
838
990
|
});
|
|
839
991
|
}
|
|
@@ -860,7 +1012,8 @@ export class Conversation {
|
|
|
860
1012
|
if (mode === 'interject') {
|
|
861
1013
|
this.proc?.notify('session/cancel', { sessionId: this.id });
|
|
862
1014
|
}
|
|
863
|
-
|
|
1015
|
+
// 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
|
|
1016
|
+
const idle = await this.waitIdle(timeoutMs);
|
|
864
1017
|
const waited = now() - t0;
|
|
865
1018
|
if (!idle) throw new Error('等待当前回合结束超时,插话未发送');
|
|
866
1019
|
const r = await this.prompt(message, { reasoning, timeoutMs });
|
|
@@ -877,10 +1030,21 @@ export class Conversation {
|
|
|
877
1030
|
} catch {}
|
|
878
1031
|
await proc.stop();
|
|
879
1032
|
}
|
|
1033
|
+
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1034
|
+
for (const [, p] of this.pendingApprovals) {
|
|
1035
|
+
try {
|
|
1036
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1037
|
+
} catch {}
|
|
1038
|
+
}
|
|
1039
|
+
this.pendingApprovals.clear();
|
|
880
1040
|
// 进程没了 → 写锁也释放了,登记必须跟着删(否则会把"孤儿持有者"误报给抢占逻辑)
|
|
881
1041
|
removeMarker(this.id);
|
|
882
|
-
if (forget)
|
|
883
|
-
|
|
1042
|
+
if (forget) {
|
|
1043
|
+
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1044
|
+
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1045
|
+
writeTombstone(this.id);
|
|
1046
|
+
this.#hub.remove(this.id);
|
|
1047
|
+
} else this.#hub.save();
|
|
884
1048
|
return { closed: true, forgotten: forget };
|
|
885
1049
|
}
|
|
886
1050
|
}
|
|
@@ -915,6 +1079,43 @@ export function formatReasoning(c, mode) {
|
|
|
915
1079
|
|
|
916
1080
|
// ── 注册表 ────────────────────────────────────────────────────────
|
|
917
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* 工作区能不能用在家目录本身?
|
|
1084
|
+
*
|
|
1085
|
+
* DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 %TEMP% 是
|
|
1086
|
+
* C:/Users/<name>/AppData/Local/Temp,正好在用户家目录里面,所以把家目录
|
|
1087
|
+
* 当作工作区时,沙箱初始化会失败:
|
|
1088
|
+
* Windows ACL: temp root must be outside the workspace
|
|
1089
|
+
* 症状出现在第一次起 shell 的时候,看起来像这个服务坏了,其实只是 cwd
|
|
1090
|
+
* 选得太大。read-only 档不写盘、也不起 ACL 沙箱,所以不受影响。
|
|
1091
|
+
*
|
|
1092
|
+
* @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
|
|
1093
|
+
*/
|
|
1094
|
+
export function homeWorkspaceProblem(cwd, permission) {
|
|
1095
|
+
if (permission === 'read-only') return null;
|
|
1096
|
+
const home = homedir();
|
|
1097
|
+
if (!home) return null;
|
|
1098
|
+
const norm = (p) => {
|
|
1099
|
+
const r = resolve(p);
|
|
1100
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
1101
|
+
};
|
|
1102
|
+
if (norm(cwd) !== norm(home)) return null;
|
|
1103
|
+
|
|
1104
|
+
const temp = tmpdir();
|
|
1105
|
+
const tempInside = norm(temp) === norm(home) || norm(temp).startsWith(norm(home) + sep);
|
|
1106
|
+
const lines = ['cwd 不能是用户家目录本身: ' + cwd];
|
|
1107
|
+
if (tempInside) lines.push(' 临时目录 ' + temp + ' 就在它里面,');
|
|
1108
|
+
lines.push(
|
|
1109
|
+
' DSH 的 ACL 沙箱要求临时目录在工作区之外,否则第一次起 shell 就会报:',
|
|
1110
|
+
' Windows ACL: temp root must be outside the workspace',
|
|
1111
|
+
'',
|
|
1112
|
+
' 换个具体项目目录最省事,或者改权限档:',
|
|
1113
|
+
' permission=read-only 不写盘,不需要该沙箱',
|
|
1114
|
+
' permission=danger-full-access 不受该沙箱约束',
|
|
1115
|
+
);
|
|
1116
|
+
return lines.join('\n');
|
|
1117
|
+
}
|
|
1118
|
+
|
|
918
1119
|
export class Hub {
|
|
919
1120
|
constructor({ log } = {}) {
|
|
920
1121
|
this.conversations = new Map();
|
|
@@ -928,11 +1129,14 @@ export class Hub {
|
|
|
928
1129
|
#probeCache = null;
|
|
929
1130
|
|
|
930
1131
|
load() {
|
|
1132
|
+
const stale = pruneStaleTombstones();
|
|
1133
|
+
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
931
1134
|
if (!existsSync(STATE_FILE)) return;
|
|
932
1135
|
try {
|
|
933
1136
|
const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
934
1137
|
for (const s of doc.conversations ?? []) {
|
|
935
1138
|
if (!s?.id || !s?.cwd) continue;
|
|
1139
|
+
if (isForgotten(s.id)) continue;
|
|
936
1140
|
this.conversations.set(s.id, new Conversation(s, this));
|
|
937
1141
|
}
|
|
938
1142
|
this.log.info(`[hub] 载入 ${this.conversations.size} 个会话`);
|
|
@@ -955,6 +1159,9 @@ export class Hub {
|
|
|
955
1159
|
const tmp = `${STATE_FILE}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
956
1160
|
writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
|
|
957
1161
|
renameSync(tmp, STATE_FILE);
|
|
1162
|
+
// 侧车元数据独立于注册表:forget 移除注册表条目后仍然保留,
|
|
1163
|
+
// 让按 id 重建的会话不会丢掉审批策略/思考深度/标题/轮数。
|
|
1164
|
+
for (const c of this.conversations.values()) writeConversationMeta(c);
|
|
958
1165
|
} catch (e) {
|
|
959
1166
|
this.log.info(`[hub] 状态保存失败: ${e.message}`);
|
|
960
1167
|
}
|
|
@@ -1052,6 +1259,14 @@ export class Hub {
|
|
|
1052
1259
|
}
|
|
1053
1260
|
|
|
1054
1261
|
get(id) {
|
|
1262
|
+
// ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
|
|
1263
|
+
// 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
|
|
1264
|
+
if (isForgotten(id)) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`会话已被 forget(永久移除): ${id}\n` +
|
|
1267
|
+
` 它不会再被按 id 自动恢复。确实要恢复的话,删除墓碑文件 ${forgottenPath(id)} 后重试。`,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1055
1270
|
let c = this.conversations.get(id);
|
|
1056
1271
|
if (!c) {
|
|
1057
1272
|
// 多实例常见情形:这个会话是**另一个 dsh-any-agent 实例**刚建的,我们内存里的注册表还没有。
|
|
@@ -1068,18 +1283,39 @@ export class Hub {
|
|
|
1068
1283
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
1069
1284
|
// resume 时 DSH_PERMISSION_MODE 会把档位写进会话 —— 等于**悄悄提权**。
|
|
1070
1285
|
const meta = readConversationMeta(id);
|
|
1286
|
+
const persisted = readConversationMetaFile(id);
|
|
1071
1287
|
const recordedPreset = meta?.permissions?.preset;
|
|
1072
|
-
const
|
|
1288
|
+
const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
|
|
1289
|
+
// 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
|
|
1290
|
+
const permission =
|
|
1291
|
+
recordedPermission === 'read-only'
|
|
1292
|
+
? 'read-only'
|
|
1293
|
+
: PERMISSION_TIERS.includes(persisted?.permission)
|
|
1294
|
+
? persisted.permission
|
|
1295
|
+
: (recordedPermission ?? DEFAULT_PERMISSION);
|
|
1073
1296
|
const recordedApproval = meta?.permissions?.approval;
|
|
1074
|
-
|
|
1075
|
-
?
|
|
1076
|
-
:
|
|
1297
|
+
let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
|
|
1298
|
+
? persisted.onApproval
|
|
1299
|
+
: APPROVAL_POLICIES.includes(recordedApproval)
|
|
1300
|
+
? recordedApproval
|
|
1301
|
+
: defaultApprovalPolicy(permission);
|
|
1302
|
+
if (permission === 'read-only' && onApproval !== 'auto-deny') onApproval = 'auto-deny';
|
|
1077
1303
|
c = new Conversation(
|
|
1078
1304
|
{
|
|
1079
1305
|
id,
|
|
1080
|
-
cwd: header.cwd,
|
|
1306
|
+
cwd: persisted?.cwd ?? header.cwd,
|
|
1081
1307
|
permission,
|
|
1082
1308
|
onApproval,
|
|
1309
|
+
titleHint: persisted?.titleHint ?? null,
|
|
1310
|
+
reasoningEffort: REASONING_EFFORTS.includes(persisted?.reasoningEffort)
|
|
1311
|
+
? persisted.reasoningEffort
|
|
1312
|
+
: DEFAULT_REASONING_EFFORT,
|
|
1313
|
+
provider: persisted?.provider ?? null,
|
|
1314
|
+
model: persisted?.model ?? null,
|
|
1315
|
+
createdAt: persisted?.createdAt,
|
|
1316
|
+
lastUsedAt: persisted?.lastUsedAt,
|
|
1317
|
+
turnCount: persisted?.turnCount,
|
|
1318
|
+
workspace: persisted?.workspace ?? null,
|
|
1083
1319
|
},
|
|
1084
1320
|
this,
|
|
1085
1321
|
);
|
|
@@ -1108,6 +1344,7 @@ export class Hub {
|
|
|
1108
1344
|
const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
1109
1345
|
for (const s of doc.conversations ?? []) {
|
|
1110
1346
|
if (!s?.id || !s?.cwd) continue;
|
|
1347
|
+
if (isForgotten(s.id)) continue;
|
|
1111
1348
|
if (this.conversations.has(s.id)) continue;
|
|
1112
1349
|
this.conversations.set(s.id, new Conversation(s, this));
|
|
1113
1350
|
added++;
|
|
@@ -1121,15 +1358,26 @@ export class Hub {
|
|
|
1121
1358
|
|
|
1122
1359
|
remove(id) {
|
|
1123
1360
|
this.conversations.delete(id);
|
|
1361
|
+
// 注意:**不删侧车元数据**。forget 的语义是“从注册表移除”,
|
|
1362
|
+
// 之后按 id 重建(多实例恢复)时,侧车让审批策略/思考深度/标题/轮数不漂移。
|
|
1124
1363
|
this.save();
|
|
1125
1364
|
}
|
|
1126
1365
|
|
|
1127
1366
|
/** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
|
|
1128
1367
|
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model } = {}) {
|
|
1129
|
-
if (!cwd)
|
|
1368
|
+
if (!cwd) {
|
|
1369
|
+
throw new Error(
|
|
1370
|
+
'没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
|
|
1371
|
+
' 传一个项目目录即可,例如 cwd: "D:/code/my-project"。\n' +
|
|
1372
|
+
' 不要传用户家目录本身 —— 那会让 DSH 的 ACL 沙箱起不来。',
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1130
1375
|
if (!isAbsolute(cwd)) throw new Error(`cwd 必须是绝对路径: ${cwd}`);
|
|
1131
|
-
if (!existsSync(cwd)
|
|
1132
|
-
throw new Error(`工作区不存在或不是目录: ${cwd}
|
|
1376
|
+
if (!existsSync(cwd)) {
|
|
1377
|
+
throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建这个目录)`);
|
|
1378
|
+
}
|
|
1379
|
+
if (!statSync(cwd).isDirectory()) {
|
|
1380
|
+
throw new Error(`工作区不存在或不是目录: ${cwd}(这是一个文件路径,请传目录)`);
|
|
1133
1381
|
}
|
|
1134
1382
|
if (!PERMISSION_TIERS.includes(permission)) {
|
|
1135
1383
|
throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
|
|
@@ -1138,6 +1386,14 @@ export class Hub {
|
|
|
1138
1386
|
if (!APPROVAL_POLICIES.includes(policy)) {
|
|
1139
1387
|
throw new Error(`on_approval 必须是 ${APPROVAL_POLICIES.join(' | ')} 之一`);
|
|
1140
1388
|
}
|
|
1389
|
+
// ★ read-only 是硬边界:显式 auto-allow/ask 会放行 DSH 的 sandbox 提权,
|
|
1390
|
+
// 实测能把文件写进工作区而会话记录仍显示 read-only。直接拒绝这个组合。
|
|
1391
|
+
if (permission === 'read-only' && policy !== 'auto-deny') {
|
|
1392
|
+
throw new Error(
|
|
1393
|
+
'permission=read-only 强制要求 on_approval=auto-deny(或不传)。' +
|
|
1394
|
+
' auto-allow/ask 会放行 DSH 的 sandbox 提权,使只读档失效。',
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1141
1397
|
const effort = reasoningEffort ?? DEFAULT_REASONING_EFFORT;
|
|
1142
1398
|
if (!REASONING_EFFORTS.includes(effort)) {
|
|
1143
1399
|
throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
|
|
@@ -1145,6 +1401,10 @@ export class Hub {
|
|
|
1145
1401
|
if (model && !provider) {
|
|
1146
1402
|
throw new Error('指定 model 时必须同时给 provider(ACP 的模型标识是 [provider, model] 二元组)');
|
|
1147
1403
|
}
|
|
1404
|
+
// 家目录当选工作区 → DSH 的 ACL 沙箱起不来,第一次起 shell 才暴露。
|
|
1405
|
+
// 与其让用户以为服务坏了,不如在这里把原因和出路一起说清楚。
|
|
1406
|
+
const homeProblem = homeWorkspaceProblem(cwd, permission);
|
|
1407
|
+
if (homeProblem) throw new Error(homeProblem);
|
|
1148
1408
|
const proc = new AcpProcess({
|
|
1149
1409
|
cwd,
|
|
1150
1410
|
permission,
|
|
@@ -1153,9 +1413,11 @@ export class Hub {
|
|
|
1153
1413
|
onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
|
|
1154
1414
|
});
|
|
1155
1415
|
proc.start();
|
|
1416
|
+
let createdSessionId = null;
|
|
1156
1417
|
try {
|
|
1157
1418
|
await proc.initialize();
|
|
1158
1419
|
const res = await proc.request('session/new', { cwd, mcpServers: [] });
|
|
1420
|
+
createdSessionId = res.sessionId;
|
|
1159
1421
|
const conv = new Conversation(
|
|
1160
1422
|
{
|
|
1161
1423
|
id: res.sessionId,
|
|
@@ -1180,6 +1442,16 @@ export class Hub {
|
|
|
1180
1442
|
});
|
|
1181
1443
|
// 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
|
|
1182
1444
|
conv.lastConfigApplied = await applySessionConfig(proc, res.sessionId, conv.cfg);
|
|
1445
|
+
// ★ 无效的 provider/model 必须 fail fast:旧行为是建出会话、静默回退
|
|
1446
|
+
// profile 默认模型,调用方以为指定模型已生效。这里在外层 catch 停止进程,
|
|
1447
|
+
// 不注册会话,直接报错(不传 provider/model 才走 profile 默认)。
|
|
1448
|
+
const modelFailure = (conv.lastConfigApplied.failed ?? []).find((f) => String(f).includes('模型='));
|
|
1449
|
+
if (modelFailure) {
|
|
1450
|
+
throw new Error(
|
|
1451
|
+
`指定的 provider/model 无效,会话未创建:${modelFailure}\n` +
|
|
1452
|
+
` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1183
1455
|
// 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
|
|
1184
1456
|
// ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
|
|
1185
1457
|
// 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
|
|
@@ -1197,6 +1469,10 @@ export class Hub {
|
|
|
1197
1469
|
);
|
|
1198
1470
|
return conv;
|
|
1199
1471
|
} catch (e) {
|
|
1472
|
+
// 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
|
|
1473
|
+
// 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
|
|
1474
|
+
// 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
|
|
1475
|
+
if (createdSessionId) removeMarker(createdSessionId);
|
|
1200
1476
|
await proc.stop().catch(() => {});
|
|
1201
1477
|
throw e;
|
|
1202
1478
|
}
|
|
@@ -1207,6 +1483,7 @@ export class Hub {
|
|
|
1207
1483
|
const out = new Map();
|
|
1208
1484
|
for (const c of this.conversations.values()) {
|
|
1209
1485
|
if (cwd && c.cwd !== cwd) continue;
|
|
1486
|
+
if (isForgotten(c.id)) continue;
|
|
1210
1487
|
out.set(c.id, c.snapshot());
|
|
1211
1488
|
}
|
|
1212
1489
|
if (!includeClosed) return [...out.values()];
|
|
@@ -1239,6 +1516,7 @@ export class Hub {
|
|
|
1239
1516
|
}
|
|
1240
1517
|
for (const s of sessions) {
|
|
1241
1518
|
if (out.has(s.sessionId)) continue;
|
|
1519
|
+
if (isForgotten(s.sessionId)) continue;
|
|
1242
1520
|
const meta = readConversationMeta(s.sessionId);
|
|
1243
1521
|
out.set(s.sessionId, {
|
|
1244
1522
|
conversation_id: s.sessionId,
|