dsh-any-agent 0.3.2 → 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 +120 -65
- package/README.zh.md +111 -60
- package/USAGE-NOTES.en.md +5 -3
- package/USAGE-NOTES.md +6 -4
- package/package.json +5 -3
- package/scripts/commit.mjs +122 -0
- package/src/config.mjs +1 -1
- package/src/hub.mjs +251 -28
- package/src/mcp.mjs +5 -1
- package/src/tools.mjs +32 -10
- package/src/workspace.mjs +8 -0
package/src/hub.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
SENTINEL_INCLUDE_REASONING,
|
|
42
42
|
SENTINEL_TTL_MS,
|
|
43
43
|
STATE_FILE,
|
|
44
|
+
DSH_HOME,
|
|
44
45
|
defaultApprovalPolicy,
|
|
45
46
|
} from './config.mjs';
|
|
46
47
|
import { AcpProcess } from './acp.mjs';
|
|
@@ -60,6 +61,92 @@ import {
|
|
|
60
61
|
const now = () => Date.now();
|
|
61
62
|
const NOOP_LOG = { info: () => {}, debug: () => {}, dshStderr: () => {} };
|
|
62
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
|
+
|
|
63
150
|
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
64
151
|
const MAX_OUT_ENTRIES = 500;
|
|
65
152
|
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
@@ -149,11 +236,21 @@ async function applySessionConfig(proc, sessionId, cfg) {
|
|
|
149
236
|
* 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
|
|
150
237
|
* 免得调用方以为字段是意外丢失的。
|
|
151
238
|
*/
|
|
152
|
-
function sanitizeResultForSentinel(result) {
|
|
239
|
+
export function sanitizeResultForSentinel(result) {
|
|
153
240
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
154
241
|
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
155
242
|
const { thinking, ...rest } = result;
|
|
156
|
-
|
|
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) };
|
|
157
254
|
}
|
|
158
255
|
|
|
159
256
|
export class Conversation {
|
|
@@ -194,6 +291,8 @@ export class Conversation {
|
|
|
194
291
|
*/
|
|
195
292
|
this.out = [];
|
|
196
293
|
this.outSeq = 0;
|
|
294
|
+
/** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
|
|
295
|
+
this.outRev = 0;
|
|
197
296
|
this.turnOutChars = 0;
|
|
198
297
|
this.currentTurn = 0;
|
|
199
298
|
this.turnStartedAt = 0;
|
|
@@ -229,6 +328,11 @@ export class Conversation {
|
|
|
229
328
|
};
|
|
230
329
|
}
|
|
231
330
|
|
|
331
|
+
/** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
|
|
332
|
+
toMeta() {
|
|
333
|
+
return { version: 1, ...this.toState() };
|
|
334
|
+
}
|
|
335
|
+
|
|
232
336
|
/** 合规的公开快照(含磁盘上的元信息)。 */
|
|
233
337
|
snapshot() {
|
|
234
338
|
const meta = readConversationMeta(this.id);
|
|
@@ -495,11 +599,33 @@ export class Conversation {
|
|
|
495
599
|
|
|
496
600
|
async onPermissionRequest(params) {
|
|
497
601
|
const options = params?.options ?? [];
|
|
498
|
-
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;
|
|
499
617
|
const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
|
|
500
618
|
const allowId = pickOption(options, 'allow');
|
|
501
619
|
const rejectId = pickOption(options, 'reject');
|
|
502
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
|
+
}
|
|
503
629
|
if (this.onApproval === 'auto-allow') {
|
|
504
630
|
this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
|
|
505
631
|
return allowId
|
|
@@ -632,6 +758,14 @@ export class Conversation {
|
|
|
632
758
|
this.busy = false;
|
|
633
759
|
this.collector = null;
|
|
634
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();
|
|
635
769
|
this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
|
|
636
770
|
phase: 'end',
|
|
637
771
|
stop_reason: stopReason,
|
|
@@ -764,17 +898,24 @@ export class Conversation {
|
|
|
764
898
|
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
765
899
|
last.text += text;
|
|
766
900
|
last.at = now();
|
|
901
|
+
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
902
|
+
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
903
|
+
last.rev = ++this.outRev;
|
|
767
904
|
} else {
|
|
768
|
-
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 });
|
|
769
906
|
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
770
907
|
}
|
|
771
908
|
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
772
909
|
}
|
|
773
910
|
|
|
774
|
-
/**
|
|
911
|
+
/** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
|
|
775
912
|
readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
|
|
776
|
-
const
|
|
777
|
-
|
|
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;
|
|
778
919
|
const res = {
|
|
779
920
|
conversation_id: this.id,
|
|
780
921
|
state: this.state(),
|
|
@@ -782,9 +923,9 @@ export class Conversation {
|
|
|
782
923
|
current_tool: this.currentTool,
|
|
783
924
|
entries,
|
|
784
925
|
cursor: next,
|
|
785
|
-
has_more: this.out.some((e) => e
|
|
926
|
+
has_more: this.out.some((e) => revOf(e) > next),
|
|
786
927
|
/** 缓冲被环形覆盖时会为 true,表示你错过了中间一段。 */
|
|
787
|
-
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,
|
|
788
929
|
};
|
|
789
930
|
// 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
|
|
790
931
|
if (includeReasoning && this.collector?.reasoning) {
|
|
@@ -824,24 +965,27 @@ export class Conversation {
|
|
|
824
965
|
return this.runs.length ? this.runs[this.runs.length - 1] : null;
|
|
825
966
|
}
|
|
826
967
|
|
|
827
|
-
/** 等到当前回合结束(不忙则立即返回 true
|
|
968
|
+
/** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
|
|
828
969
|
async waitIdle(timeoutMs = 30 * 60 * 1000) {
|
|
829
970
|
if (!this.busy) return true;
|
|
830
971
|
return await new Promise((resolve) => {
|
|
831
972
|
let settled = false;
|
|
973
|
+
let timer = null;
|
|
832
974
|
const done = () => {
|
|
833
975
|
if (settled) return;
|
|
834
976
|
settled = true;
|
|
835
|
-
clearTimeout(timer);
|
|
977
|
+
if (timer) clearTimeout(timer);
|
|
836
978
|
resolve(true);
|
|
837
979
|
};
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
+
}
|
|
845
989
|
this.idleWaiters.push(done);
|
|
846
990
|
});
|
|
847
991
|
}
|
|
@@ -868,7 +1012,8 @@ export class Conversation {
|
|
|
868
1012
|
if (mode === 'interject') {
|
|
869
1013
|
this.proc?.notify('session/cancel', { sessionId: this.id });
|
|
870
1014
|
}
|
|
871
|
-
|
|
1015
|
+
// 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
|
|
1016
|
+
const idle = await this.waitIdle(timeoutMs);
|
|
872
1017
|
const waited = now() - t0;
|
|
873
1018
|
if (!idle) throw new Error('等待当前回合结束超时,插话未发送');
|
|
874
1019
|
const r = await this.prompt(message, { reasoning, timeoutMs });
|
|
@@ -885,10 +1030,21 @@ export class Conversation {
|
|
|
885
1030
|
} catch {}
|
|
886
1031
|
await proc.stop();
|
|
887
1032
|
}
|
|
1033
|
+
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1034
|
+
for (const [, p] of this.pendingApprovals) {
|
|
1035
|
+
try {
|
|
1036
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1037
|
+
} catch {}
|
|
1038
|
+
}
|
|
1039
|
+
this.pendingApprovals.clear();
|
|
888
1040
|
// 进程没了 → 写锁也释放了,登记必须跟着删(否则会把"孤儿持有者"误报给抢占逻辑)
|
|
889
1041
|
removeMarker(this.id);
|
|
890
|
-
if (forget)
|
|
891
|
-
|
|
1042
|
+
if (forget) {
|
|
1043
|
+
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1044
|
+
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1045
|
+
writeTombstone(this.id);
|
|
1046
|
+
this.#hub.remove(this.id);
|
|
1047
|
+
} else this.#hub.save();
|
|
892
1048
|
return { closed: true, forgotten: forget };
|
|
893
1049
|
}
|
|
894
1050
|
}
|
|
@@ -973,11 +1129,14 @@ export class Hub {
|
|
|
973
1129
|
#probeCache = null;
|
|
974
1130
|
|
|
975
1131
|
load() {
|
|
1132
|
+
const stale = pruneStaleTombstones();
|
|
1133
|
+
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
976
1134
|
if (!existsSync(STATE_FILE)) return;
|
|
977
1135
|
try {
|
|
978
1136
|
const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
979
1137
|
for (const s of doc.conversations ?? []) {
|
|
980
1138
|
if (!s?.id || !s?.cwd) continue;
|
|
1139
|
+
if (isForgotten(s.id)) continue;
|
|
981
1140
|
this.conversations.set(s.id, new Conversation(s, this));
|
|
982
1141
|
}
|
|
983
1142
|
this.log.info(`[hub] 载入 ${this.conversations.size} 个会话`);
|
|
@@ -1000,6 +1159,9 @@ export class Hub {
|
|
|
1000
1159
|
const tmp = `${STATE_FILE}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
1001
1160
|
writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
|
|
1002
1161
|
renameSync(tmp, STATE_FILE);
|
|
1162
|
+
// 侧车元数据独立于注册表:forget 移除注册表条目后仍然保留,
|
|
1163
|
+
// 让按 id 重建的会话不会丢掉审批策略/思考深度/标题/轮数。
|
|
1164
|
+
for (const c of this.conversations.values()) writeConversationMeta(c);
|
|
1003
1165
|
} catch (e) {
|
|
1004
1166
|
this.log.info(`[hub] 状态保存失败: ${e.message}`);
|
|
1005
1167
|
}
|
|
@@ -1097,6 +1259,14 @@ export class Hub {
|
|
|
1097
1259
|
}
|
|
1098
1260
|
|
|
1099
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
|
+
}
|
|
1100
1270
|
let c = this.conversations.get(id);
|
|
1101
1271
|
if (!c) {
|
|
1102
1272
|
// 多实例常见情形:这个会话是**另一个 dsh-any-agent 实例**刚建的,我们内存里的注册表还没有。
|
|
@@ -1113,18 +1283,39 @@ export class Hub {
|
|
|
1113
1283
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
1114
1284
|
// resume 时 DSH_PERMISSION_MODE 会把档位写进会话 —— 等于**悄悄提权**。
|
|
1115
1285
|
const meta = readConversationMeta(id);
|
|
1286
|
+
const persisted = readConversationMetaFile(id);
|
|
1116
1287
|
const recordedPreset = meta?.permissions?.preset;
|
|
1117
|
-
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);
|
|
1118
1296
|
const recordedApproval = meta?.permissions?.approval;
|
|
1119
|
-
|
|
1120
|
-
?
|
|
1121
|
-
:
|
|
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';
|
|
1122
1303
|
c = new Conversation(
|
|
1123
1304
|
{
|
|
1124
1305
|
id,
|
|
1125
|
-
cwd: header.cwd,
|
|
1306
|
+
cwd: persisted?.cwd ?? header.cwd,
|
|
1126
1307
|
permission,
|
|
1127
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,
|
|
1128
1319
|
},
|
|
1129
1320
|
this,
|
|
1130
1321
|
);
|
|
@@ -1153,6 +1344,7 @@ export class Hub {
|
|
|
1153
1344
|
const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
1154
1345
|
for (const s of doc.conversations ?? []) {
|
|
1155
1346
|
if (!s?.id || !s?.cwd) continue;
|
|
1347
|
+
if (isForgotten(s.id)) continue;
|
|
1156
1348
|
if (this.conversations.has(s.id)) continue;
|
|
1157
1349
|
this.conversations.set(s.id, new Conversation(s, this));
|
|
1158
1350
|
added++;
|
|
@@ -1166,6 +1358,8 @@ export class Hub {
|
|
|
1166
1358
|
|
|
1167
1359
|
remove(id) {
|
|
1168
1360
|
this.conversations.delete(id);
|
|
1361
|
+
// 注意:**不删侧车元数据**。forget 的语义是“从注册表移除”,
|
|
1362
|
+
// 之后按 id 重建(多实例恢复)时,侧车让审批策略/思考深度/标题/轮数不漂移。
|
|
1169
1363
|
this.save();
|
|
1170
1364
|
}
|
|
1171
1365
|
|
|
@@ -1179,8 +1373,11 @@ export class Hub {
|
|
|
1179
1373
|
);
|
|
1180
1374
|
}
|
|
1181
1375
|
if (!isAbsolute(cwd)) throw new Error(`cwd 必须是绝对路径: ${cwd}`);
|
|
1182
|
-
if (!existsSync(cwd)
|
|
1183
|
-
throw new Error(`工作区不存在或不是目录: ${cwd}
|
|
1376
|
+
if (!existsSync(cwd)) {
|
|
1377
|
+
throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建这个目录)`);
|
|
1378
|
+
}
|
|
1379
|
+
if (!statSync(cwd).isDirectory()) {
|
|
1380
|
+
throw new Error(`工作区不存在或不是目录: ${cwd}(这是一个文件路径,请传目录)`);
|
|
1184
1381
|
}
|
|
1185
1382
|
if (!PERMISSION_TIERS.includes(permission)) {
|
|
1186
1383
|
throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
|
|
@@ -1189,6 +1386,14 @@ export class Hub {
|
|
|
1189
1386
|
if (!APPROVAL_POLICIES.includes(policy)) {
|
|
1190
1387
|
throw new Error(`on_approval 必须是 ${APPROVAL_POLICIES.join(' | ')} 之一`);
|
|
1191
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
|
+
}
|
|
1192
1397
|
const effort = reasoningEffort ?? DEFAULT_REASONING_EFFORT;
|
|
1193
1398
|
if (!REASONING_EFFORTS.includes(effort)) {
|
|
1194
1399
|
throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
|
|
@@ -1208,9 +1413,11 @@ export class Hub {
|
|
|
1208
1413
|
onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
|
|
1209
1414
|
});
|
|
1210
1415
|
proc.start();
|
|
1416
|
+
let createdSessionId = null;
|
|
1211
1417
|
try {
|
|
1212
1418
|
await proc.initialize();
|
|
1213
1419
|
const res = await proc.request('session/new', { cwd, mcpServers: [] });
|
|
1420
|
+
createdSessionId = res.sessionId;
|
|
1214
1421
|
const conv = new Conversation(
|
|
1215
1422
|
{
|
|
1216
1423
|
id: res.sessionId,
|
|
@@ -1235,6 +1442,16 @@ export class Hub {
|
|
|
1235
1442
|
});
|
|
1236
1443
|
// 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
|
|
1237
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
|
+
}
|
|
1238
1455
|
// 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
|
|
1239
1456
|
// ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
|
|
1240
1457
|
// 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
|
|
@@ -1252,6 +1469,10 @@ export class Hub {
|
|
|
1252
1469
|
);
|
|
1253
1470
|
return conv;
|
|
1254
1471
|
} catch (e) {
|
|
1472
|
+
// 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
|
|
1473
|
+
// 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
|
|
1474
|
+
// 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
|
|
1475
|
+
if (createdSessionId) removeMarker(createdSessionId);
|
|
1255
1476
|
await proc.stop().catch(() => {});
|
|
1256
1477
|
throw e;
|
|
1257
1478
|
}
|
|
@@ -1262,6 +1483,7 @@ export class Hub {
|
|
|
1262
1483
|
const out = new Map();
|
|
1263
1484
|
for (const c of this.conversations.values()) {
|
|
1264
1485
|
if (cwd && c.cwd !== cwd) continue;
|
|
1486
|
+
if (isForgotten(c.id)) continue;
|
|
1265
1487
|
out.set(c.id, c.snapshot());
|
|
1266
1488
|
}
|
|
1267
1489
|
if (!includeClosed) return [...out.values()];
|
|
@@ -1294,6 +1516,7 @@ export class Hub {
|
|
|
1294
1516
|
}
|
|
1295
1517
|
for (const s of sessions) {
|
|
1296
1518
|
if (out.has(s.sessionId)) continue;
|
|
1519
|
+
if (isForgotten(s.sessionId)) continue;
|
|
1297
1520
|
const meta = readConversationMeta(s.sessionId);
|
|
1298
1521
|
out.set(s.sessionId, {
|
|
1299
1522
|
conversation_id: s.sessionId,
|
package/src/mcp.mjs
CHANGED
|
@@ -48,6 +48,7 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
|
|
|
48
48
|
dsh_read(conversation_id, cursor) 中途看它当前吐出的内容(增量)
|
|
49
49
|
dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果
|
|
50
50
|
4. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
|
|
51
|
+
(会话空闲时 interject 等价于 wait=true,会阻塞到回合结束)
|
|
51
52
|
5. dsh_release 交还会话(释放写锁,之后仍可 resume)
|
|
52
53
|
6. dsh_takeover 写锁被别人占着时的**抢占**(见下)
|
|
53
54
|
|
|
@@ -86,9 +87,12 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
|
|
|
86
87
|
- 会话在忙时**不要**再 dsh_send:想改口用 dsh_interject,想停用 dsh_interrupt,
|
|
87
88
|
想把话留到本轮结束后说用 dsh_interject(mode='queue')。
|
|
88
89
|
- 思考内容**默认隐藏**(只返回字数/耗时统计)。确实需要时传 reasoning='full',但很占上下文。
|
|
89
|
-
- 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}
|
|
90
|
+
- 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。**read-only 强制 auto-deny**:传 auto-allow/ask 会被直接拒绝(否则 DSH 的 sandbox 提权会被放行)。分析不信任样本(恶意软件、来路不明 dump)时用
|
|
90
91
|
read-only:沙箱只读 + 审批一律拒绝。
|
|
91
92
|
- 思考深度默认 max;想省钱可在 dsh_start 传 reasoning_effort 调低。
|
|
93
|
+
- 隐私边界:本服务自己的日志、哨兵、注册表都不写思考正文(哨兵只留思考统计);但 **DSH 自己的会话日志
|
|
94
|
+
~/.dsh/sessions/** 会保存 reasoning,reasoning=hide 也照存** —— 分析敏感数据时要把它算进去。
|
|
95
|
+
- 异步哨兵文件保存最终正文与工具输入(常见密钥字面量已脱敏),默认保留 7 天;消费后建议自行删除。
|
|
92
96
|
- 想并行:开多个会话,dsh_list(only_running=true) 看谁在跑,再轮流 dsh_read。
|
|
93
97
|
|
|
94
98
|
【不要做】
|
package/src/tools.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export const TOOL_DEFS = [
|
|
|
39
39
|
description:
|
|
40
40
|
'工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' +
|
|
41
41
|
'**省略时自动用客户端当前打开的项目目录**(MCP roots),这样客户端里看到哪个项目就在这里干活;' +
|
|
42
|
-
'客户端不支持 roots
|
|
42
|
+
'客户端不支持 roots、或 roots 为空时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
|
|
43
43
|
},
|
|
44
44
|
permission: {
|
|
45
45
|
type: 'string',
|
|
@@ -52,7 +52,9 @@ export const TOOL_DEFS = [
|
|
|
52
52
|
on_approval: {
|
|
53
53
|
type: 'string',
|
|
54
54
|
enum: APPROVAL_POLICIES,
|
|
55
|
-
description:
|
|
55
|
+
description:
|
|
56
|
+
`审批处理策略。**read-only 强制 auto-deny**(传 auto-allow/ask 会被直接拒绝,否则会放行 DSH 的 sandbox 提权);` +
|
|
57
|
+
`其余档位默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
|
|
56
58
|
},
|
|
57
59
|
title_hint: {
|
|
58
60
|
type: 'string',
|
|
@@ -103,7 +105,7 @@ export const TOOL_DEFS = [
|
|
|
103
105
|
timeout_ms: {
|
|
104
106
|
type: 'number',
|
|
105
107
|
description:
|
|
106
|
-
'仅 wait=true
|
|
108
|
+
'仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
|
|
107
109
|
},
|
|
108
110
|
},
|
|
109
111
|
['conversation_id', 'prompt'],
|
|
@@ -137,7 +139,8 @@ export const TOOL_DEFS = [
|
|
|
137
139
|
'不必等回合结束。典型用法:dsh_list(only_running) 找到在跑的会话 → 反复 dsh_read 并回传上次的 cursor,' +
|
|
138
140
|
'就能像切换窗口一样轮流观察多个会话。' +
|
|
139
141
|
'输出缓冲**只在内存里且有界**(环形,满则丢最旧),服务/进程退出即消失;' +
|
|
140
|
-
'**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。'
|
|
142
|
+
'**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。' +
|
|
143
|
+
'cursor 是**修订号**:同一条目被追加文本后会再次返回,调用方按 `seq` 用它替换全文(不要重复累加)。',
|
|
141
144
|
inputSchema: S(
|
|
142
145
|
{
|
|
143
146
|
conversation_id: CONV_ID,
|
|
@@ -153,7 +156,8 @@ export const TOOL_DEFS = [
|
|
|
153
156
|
description:
|
|
154
157
|
'查看单个会话的详情;带 run_id 时**取那一次派活的最终结果**(异步模式的验收入口)。' +
|
|
155
158
|
'不传 run_id 则取最近一次回合。返回:标题、工作区、权限档、状态、轮数、token 用量、' +
|
|
156
|
-
'上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。'
|
|
159
|
+
'上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。' +
|
|
160
|
+
'**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后请改读哨兵文件(它落盘且扛重启)。',
|
|
157
161
|
inputSchema: S(
|
|
158
162
|
{
|
|
159
163
|
conversation_id: CONV_ID,
|
|
@@ -167,7 +171,7 @@ export const TOOL_DEFS = [
|
|
|
167
171
|
description:
|
|
168
172
|
'插话:会话正在跑某个回合时往里补一句话。两种模式——' +
|
|
169
173
|
'interject(默认)打断当前回合并立刻改口;queue 不打断,等本轮自然结束后紧接着说。' +
|
|
170
|
-
'
|
|
174
|
+
'会话空闲时按 **wait=true 的普通回合**执行(会阻塞到回合结束并返回完整结果,不是收据)。' +
|
|
171
175
|
'说明:ACP 明确拒绝并发 prompt,所以做不到"让模型在同一个回合里即时改向";' +
|
|
172
176
|
'interject 是它的实用等价物(取消 + 立刻开新回合,历史完整保留)。',
|
|
173
177
|
inputSchema: S(
|
|
@@ -184,7 +188,7 @@ export const TOOL_DEFS = [
|
|
|
184
188
|
enum: REASONING_MODES,
|
|
185
189
|
description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
|
|
186
190
|
},
|
|
187
|
-
timeout_ms: { type: 'number', description: '
|
|
191
|
+
timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。到期转后台,不取消回合。' },
|
|
188
192
|
},
|
|
189
193
|
['conversation_id', 'message'],
|
|
190
194
|
),
|
|
@@ -222,8 +226,10 @@ export const TOOL_DEFS = [
|
|
|
222
226
|
name: 'dsh_release',
|
|
223
227
|
description:
|
|
224
228
|
'释放会话占用的 DSH 进程(释放写锁,让用户能在自己的 DSH GUI 里打开同一个会话)。' +
|
|
225
|
-
'会话本体留在磁盘上,之后仍可 resume
|
|
226
|
-
'
|
|
229
|
+
'会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
|
|
230
|
+
'(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
|
|
231
|
+
'★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
|
|
232
|
+
'all=true 可立即全部释放,之后随时 resume)。',
|
|
227
233
|
inputSchema: S(
|
|
228
234
|
{
|
|
229
235
|
conversation_id: CONV_ID,
|
|
@@ -374,11 +380,24 @@ export function createHandlers(hub, opts = {}) {
|
|
|
374
380
|
(snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
|
|
375
381
|
`工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
|
|
376
382
|
];
|
|
377
|
-
if (snap.config_failed?.length)
|
|
383
|
+
if (snap.config_failed?.length) {
|
|
384
|
+
lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
|
|
385
|
+
if (snap.config_failed.some((f) => String(f).startsWith('模型='))) {
|
|
386
|
+
lines.push(
|
|
387
|
+
'⚠️ 指定的 provider/model 无效:本会话已回退到 profile 默认模型,' +
|
|
388
|
+
'后续回合**不会**使用你指定的模型。',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
378
392
|
return { structured: snap, text: lines.join('\n') };
|
|
379
393
|
},
|
|
380
394
|
|
|
381
395
|
async dsh_send(args) {
|
|
396
|
+
// 空/纯空白 prompt 会被 DSH 以 [-32602] 拒绝;默认 wait=false 下那会变成
|
|
397
|
+
// 一个“accepted 但注定失败”的收据(错误只异步出现在哨兵里)。这里同步拦下。
|
|
398
|
+
if (typeof args.prompt !== 'string' || args.prompt.trim() === '') {
|
|
399
|
+
throw new Error('prompt 不能为空:至少包含一个非空白字符');
|
|
400
|
+
}
|
|
382
401
|
const conv = hub.get(args.conversation_id);
|
|
383
402
|
const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
|
|
384
403
|
if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
|
|
@@ -531,6 +550,9 @@ export function createHandlers(hub, opts = {}) {
|
|
|
531
550
|
},
|
|
532
551
|
|
|
533
552
|
async dsh_interject(args) {
|
|
553
|
+
if (typeof args.message !== 'string' || args.message.trim() === '') {
|
|
554
|
+
throw new Error('message 不能为空:至少包含一个非空白字符');
|
|
555
|
+
}
|
|
534
556
|
const conv = hub.get(args.conversation_id);
|
|
535
557
|
const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
|
|
536
558
|
if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
|
package/src/workspace.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
|
|
|
24
24
|
|
|
25
25
|
const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
|
|
26
26
|
const SESSIONS_ROOT = join(DSH_HOME, 'sessions');
|
|
27
|
+
/** forget 墓碑目录(存 id+时间,不含内容);清掉测试会话时一并清掉其墓碑。 */
|
|
28
|
+
const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
|
|
27
29
|
|
|
28
30
|
/** 路径比较用:Windows 下大小写不敏感,且忽略结尾分隔符。 */
|
|
29
31
|
const normPath = (p) => String(p ?? '').replace(/[\\/]+$/, '').toLowerCase();
|
|
@@ -329,6 +331,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
|
|
|
329
331
|
} catch (e) {
|
|
330
332
|
v.error = e.message;
|
|
331
333
|
}
|
|
334
|
+
// 会话文件都没了,墓碑也就没有意义:一起清掉,避免测试残留。
|
|
335
|
+
try {
|
|
336
|
+
rmSync(join(FORGOTTEN_DIR, `${v.id}.json`), { force: true });
|
|
337
|
+
} catch {
|
|
338
|
+
/* 墓碑不存在或删不掉都不影响会话清理 */
|
|
339
|
+
}
|
|
332
340
|
}
|
|
333
341
|
// 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
|
|
334
342
|
for (const bucket of readdirSync(SESSIONS_ROOT)) {
|