dsh-any-agent 0.3.4 → 0.3.6
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 +665 -0
- package/DESIGN.md +2 -2
- package/README.md +66 -22
- package/README.zh.md +37 -25
- package/USAGE-MIGRATION.md +9 -9
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +7 -7
- package/bin/dsh-any-agent-workspaces.mjs +22 -3
- package/bin/dsh-any-agent.mjs +3 -2
- package/bin/restore.mjs +7 -3
- package/package.json +2 -1
- package/src/acp.mjs +24 -3
- package/src/bootstrap.mjs +73 -64
- package/src/clients.mjs +35 -17
- package/src/config.mjs +59 -8
- package/src/hub.mjs +154 -19
- package/src/locks.mjs +39 -1
- package/src/mcp.mjs +6 -0
- package/src/tools.mjs +43 -14
- package/src/workspace.mjs +148 -43
- package/src/yaml-lite.mjs +424 -0
package/src/hub.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
+
copyFileSync,
|
|
18
|
+
cpSync,
|
|
17
19
|
mkdirSync,
|
|
18
20
|
readFileSync,
|
|
19
21
|
writeFileSync,
|
|
@@ -41,13 +43,17 @@ import {
|
|
|
41
43
|
SENTINEL_INCLUDE_REASONING,
|
|
42
44
|
SENTINEL_TTL_MS,
|
|
43
45
|
STATE_FILE,
|
|
46
|
+
LEGACY_STATE_FILE,
|
|
47
|
+
USING_DEFAULT_STATE,
|
|
44
48
|
DSH_HOME,
|
|
49
|
+
assertWritableDir,
|
|
45
50
|
defaultApprovalPolicy,
|
|
46
51
|
} from './config.mjs';
|
|
47
52
|
import { AcpProcess } from './acp.mjs';
|
|
48
53
|
import { readConversationMeta } from './titles.mjs';
|
|
49
54
|
import { registerWorkspace, findSessionHeader } from './workspace.mjs';
|
|
50
55
|
import {
|
|
56
|
+
LOCKS_DIR,
|
|
51
57
|
classifyHolder,
|
|
52
58
|
describeLeaseConflict,
|
|
53
59
|
heartbeat as lockHeartbeat,
|
|
@@ -149,6 +155,8 @@ function pruneStaleTombstones() {
|
|
|
149
155
|
|
|
150
156
|
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
151
157
|
const MAX_OUT_ENTRIES = 500;
|
|
158
|
+
/** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
|
|
159
|
+
const MAX_OUT_TEXT = 200_000;
|
|
152
160
|
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
153
161
|
const MAX_PROMPT_ECHO = 4000;
|
|
154
162
|
/** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
|
|
@@ -240,13 +248,18 @@ export function sanitizeResultForSentinel(result) {
|
|
|
240
248
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
241
249
|
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
242
250
|
const { thinking, ...rest } = result;
|
|
251
|
+
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
252
|
+
for (const key of ['answer', 'error', 'title']) {
|
|
253
|
+
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
254
|
+
}
|
|
243
255
|
// 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
|
|
244
256
|
// 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
|
|
245
257
|
if (Array.isArray(rest.tools_used)) {
|
|
246
258
|
rest.tools_used = rest.tools_used.map((t) => {
|
|
247
259
|
if (!t || typeof t !== 'object') return t;
|
|
248
260
|
const { input, ...meta } = t;
|
|
249
|
-
|
|
261
|
+
const safeMeta = sanitizeApprovalContext(meta);
|
|
262
|
+
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
250
263
|
});
|
|
251
264
|
}
|
|
252
265
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
@@ -271,6 +284,8 @@ export class Conversation {
|
|
|
271
284
|
model: state.model ?? null,
|
|
272
285
|
};
|
|
273
286
|
this.lastConfigApplied = null;
|
|
287
|
+
/** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
|
|
288
|
+
this.permissionVerified = null;
|
|
274
289
|
/** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
|
|
275
290
|
this.workspace = state.workspace ?? null;
|
|
276
291
|
this.#hub = hub;
|
|
@@ -337,10 +352,15 @@ export class Conversation {
|
|
|
337
352
|
snapshot() {
|
|
338
353
|
const meta = readConversationMeta(this.id);
|
|
339
354
|
const nativeTitle = meta?.title ?? null;
|
|
355
|
+
const holder = this.proc?.alive
|
|
356
|
+
? { kind: 'self', reason: '本进程持有该会话的写锁' }
|
|
357
|
+
: classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
340
358
|
return {
|
|
341
359
|
conversation_id: this.id,
|
|
342
360
|
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
343
361
|
title: nativeTitle ?? this.titleHint ?? null,
|
|
362
|
+
/** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
|
|
363
|
+
title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
|
|
344
364
|
/**
|
|
345
365
|
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
346
366
|
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
@@ -350,6 +370,8 @@ export class Conversation {
|
|
|
350
370
|
title_hint: this.titleHint ?? null,
|
|
351
371
|
cwd: this.cwd,
|
|
352
372
|
permission: this.permission,
|
|
373
|
+
/** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
|
|
374
|
+
permission_verified: this.permissionVerified,
|
|
353
375
|
on_approval: this.onApproval,
|
|
354
376
|
alive: !!this.proc?.alive,
|
|
355
377
|
busy: this.busy,
|
|
@@ -375,8 +397,13 @@ export class Conversation {
|
|
|
375
397
|
approval_id: approvalId,
|
|
376
398
|
...pending.params.details,
|
|
377
399
|
})),
|
|
378
|
-
/**
|
|
379
|
-
|
|
400
|
+
/**
|
|
401
|
+
* 不在本进程手里时,写锁在谁手上:
|
|
402
|
+
* self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
|
|
403
|
+
* 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
|
|
404
|
+
*/
|
|
405
|
+
lock_holder: holder.kind,
|
|
406
|
+
lock_holder_reason: holder.reason ?? null,
|
|
380
407
|
/** ★ 最近一次"撞写锁"的持久记录。
|
|
381
408
|
* 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
|
|
382
409
|
* dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
|
|
@@ -418,6 +445,8 @@ export class Conversation {
|
|
|
418
445
|
// 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
|
|
419
446
|
this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
|
|
420
447
|
for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
|
|
448
|
+
// ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
|
|
449
|
+
await this.verifyPermissionRecorded();
|
|
421
450
|
// 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
|
|
422
451
|
writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
|
|
423
452
|
this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
|
|
@@ -465,6 +494,39 @@ export class Conversation {
|
|
|
465
494
|
}
|
|
466
495
|
}
|
|
467
496
|
|
|
497
|
+
/**
|
|
498
|
+
* 核对**会话自己记录**的权限档是否与请求一致。
|
|
499
|
+
*
|
|
500
|
+
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
501
|
+
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
502
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘,所以短轮询;读不到时
|
|
503
|
+
* 不阻断(read-only 仍有审批层的硬拒绝兜底),但读到不一致就立刻失败。
|
|
504
|
+
*/
|
|
505
|
+
async verifyPermissionRecorded({ attempts = 6, delayMs = 250 } = {}) {
|
|
506
|
+
for (let i = 0; i < attempts; i++) {
|
|
507
|
+
const meta = readConversationMeta(this.id);
|
|
508
|
+
const recorded = meta?.permissions?.preset;
|
|
509
|
+
if (recorded) {
|
|
510
|
+
if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
|
|
513
|
+
' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
|
|
514
|
+
` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
|
|
515
|
+
'删掉 permission.defaultPreset 后重试。',
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
this.permissionVerified = true;
|
|
519
|
+
return recorded;
|
|
520
|
+
}
|
|
521
|
+
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
522
|
+
}
|
|
523
|
+
this.permissionVerified = false;
|
|
524
|
+
this.#hub.log.info(
|
|
525
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(read-only 仍由审批层强制拒绝)`,
|
|
526
|
+
);
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
468
530
|
/**
|
|
469
531
|
* 抢占写锁。
|
|
470
532
|
*
|
|
@@ -519,7 +581,7 @@ export class Conversation {
|
|
|
519
581
|
}
|
|
520
582
|
|
|
521
583
|
// 3) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
522
|
-
const kill = killHolderChild(killTarget);
|
|
584
|
+
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
523
585
|
report.killed_pid = killTarget;
|
|
524
586
|
report.kill = kill;
|
|
525
587
|
if (!kill.killed) {
|
|
@@ -806,7 +868,9 @@ export class Conversation {
|
|
|
806
868
|
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
807
869
|
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
808
870
|
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
809
|
-
this.#hub.writeRunSentinel(this.id, runRecord);
|
|
871
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
872
|
+
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
873
|
+
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
810
874
|
return result;
|
|
811
875
|
} catch (e) {
|
|
812
876
|
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
@@ -830,7 +894,9 @@ export class Conversation {
|
|
|
830
894
|
runRecord.status = 'error';
|
|
831
895
|
runRecord.error = fallback.error;
|
|
832
896
|
runRecord.result = fallback;
|
|
833
|
-
this.#hub.writeRunSentinel(this.id, runRecord);
|
|
897
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
898
|
+
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
899
|
+
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
834
900
|
return fallback;
|
|
835
901
|
}
|
|
836
902
|
};
|
|
@@ -895,14 +961,15 @@ export class Conversation {
|
|
|
895
961
|
/** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
|
|
896
962
|
pushOut(kind, text, extra = {}) {
|
|
897
963
|
const last = this.out[this.out.length - 1];
|
|
964
|
+
const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
|
|
898
965
|
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
899
|
-
last.text
|
|
966
|
+
last.text = cap(last.text + text);
|
|
900
967
|
last.at = now();
|
|
901
968
|
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
902
969
|
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
903
970
|
last.rev = ++this.outRev;
|
|
904
971
|
} else {
|
|
905
|
-
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
|
|
972
|
+
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
|
|
906
973
|
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
907
974
|
}
|
|
908
975
|
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
@@ -1051,8 +1118,17 @@ export class Conversation {
|
|
|
1051
1118
|
|
|
1052
1119
|
/** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
|
|
1053
1120
|
export function gitStatus(cwd) {
|
|
1121
|
+
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
1054
1122
|
try {
|
|
1055
|
-
|
|
1123
|
+
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
1124
|
+
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
1125
|
+
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
1126
|
+
const r = spawnSync('git', ['-c', 'core.fsmonitor=false', 'status', '--porcelain', '--no-optional-locks'], {
|
|
1127
|
+
cwd,
|
|
1128
|
+
encoding: 'utf8',
|
|
1129
|
+
timeout: 8000,
|
|
1130
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
1131
|
+
});
|
|
1056
1132
|
if (r.status !== 0 || !r.stdout) return null;
|
|
1057
1133
|
const lines = r.stdout.split('\n').filter(Boolean);
|
|
1058
1134
|
if (!lines.length) return null;
|
|
@@ -1120,7 +1196,12 @@ export class Hub {
|
|
|
1120
1196
|
constructor({ log } = {}) {
|
|
1121
1197
|
this.conversations = new Map();
|
|
1122
1198
|
this.log = log ?? NOOP_LOG;
|
|
1123
|
-
|
|
1199
|
+
/** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
|
|
1200
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
|
|
1201
|
+
const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
|
|
1202
|
+
// 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
|
|
1203
|
+
this.approvalTimeoutMs =
|
|
1204
|
+
Number.isFinite(rawApprovalTimeout) && rawApprovalTimeout > 0 ? rawApprovalTimeout : 5 * 60 * 1000;
|
|
1124
1205
|
this.#reaper = null;
|
|
1125
1206
|
}
|
|
1126
1207
|
|
|
@@ -1129,6 +1210,30 @@ export class Hub {
|
|
|
1129
1210
|
#probeCache = null;
|
|
1130
1211
|
|
|
1131
1212
|
load() {
|
|
1213
|
+
// ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
|
|
1214
|
+
// 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
|
|
1215
|
+
assertWritableDir(dirname(STATE_FILE), '状态目录');
|
|
1216
|
+
assertWritableDir(RUNS_DIR, '哨兵目录');
|
|
1217
|
+
assertWritableDir(LOCKS_DIR, '锁登记目录');
|
|
1218
|
+
|
|
1219
|
+
// 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
|
|
1220
|
+
// 只在没有显式设置 DSH_MCP_STATE 时做,避免覆盖用户指定的路径。
|
|
1221
|
+
if (USING_DEFAULT_STATE && !existsSync(STATE_FILE) && existsSync(LEGACY_STATE_FILE)) {
|
|
1222
|
+
try {
|
|
1223
|
+
copyFileSync(LEGACY_STATE_FILE, STATE_FILE);
|
|
1224
|
+
this.log.info(`[hub] 已迁移旧状态文件: ${LEGACY_STATE_FILE} → ${STATE_FILE}`);
|
|
1225
|
+
// 侧车元数据(审批策略/思考深度/标题/轮数)也一并搬过来;
|
|
1226
|
+
// 否则旧会话按 id 重建时会悄悄回落到默认值。
|
|
1227
|
+
const legacyMeta = join(dirname(LEGACY_STATE_FILE), 'meta');
|
|
1228
|
+
if (existsSync(legacyMeta) && !existsSync(META_DIR)) {
|
|
1229
|
+
cpSync(legacyMeta, META_DIR, { recursive: true });
|
|
1230
|
+
this.log.info(`[hub] 已迁移旧元数据侧车: ${legacyMeta} → ${META_DIR}`);
|
|
1231
|
+
}
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
this.log.info(`[hub] 旧状态文件迁移失败(继续用新路径): ${e.message}`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1132
1237
|
const stale = pruneStaleTombstones();
|
|
1133
1238
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1134
1239
|
if (!existsSync(STATE_FILE)) return;
|
|
@@ -1186,24 +1291,34 @@ export class Hub {
|
|
|
1186
1291
|
return join(RUNS_DIR, conversationId, `${runId}.json`);
|
|
1187
1292
|
}
|
|
1188
1293
|
|
|
1189
|
-
/**
|
|
1294
|
+
/**
|
|
1295
|
+
* 写完成哨兵(原子)。
|
|
1296
|
+
* @returns {string|null} 成功返回文件路径;失败返回 null,并把原因记进 run.sentinel_error。
|
|
1297
|
+
*
|
|
1298
|
+
* 失败不阻断回合收尾,但**必须让调用方看得见**:以前只记 silent 日志,
|
|
1299
|
+
* 调用方会拿着一个永远不会出现的路径一直等。
|
|
1300
|
+
*/
|
|
1190
1301
|
writeRunSentinel(conversationId, run) {
|
|
1302
|
+
const file = this.runSentinelPath(conversationId, run.run_id);
|
|
1191
1303
|
try {
|
|
1192
|
-
const file = this.runSentinelPath(conversationId, run.run_id);
|
|
1193
1304
|
mkdirSync(dirname(file), { recursive: true });
|
|
1194
1305
|
const payload = {
|
|
1195
1306
|
conversation_id: conversationId,
|
|
1196
1307
|
run_id: run.run_id,
|
|
1197
1308
|
status: run.status ?? 'unknown',
|
|
1198
1309
|
ended_at: run.ended_at ?? Date.now(),
|
|
1199
|
-
error: run.error ?? run.result?.error ?? null,
|
|
1310
|
+
error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
|
|
1200
1311
|
result: sanitizeResultForSentinel(run.result ?? null),
|
|
1201
1312
|
};
|
|
1202
1313
|
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
1203
1314
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
1204
1315
|
renameSync(tmp, file);
|
|
1316
|
+
return file;
|
|
1205
1317
|
} catch (e) {
|
|
1206
|
-
|
|
1318
|
+
const msg = `写完成哨兵失败(${file}): ${e.message}`;
|
|
1319
|
+
run.sentinel_error = msg;
|
|
1320
|
+
this.log.info(`[hub] ${msg}`);
|
|
1321
|
+
return null;
|
|
1207
1322
|
}
|
|
1208
1323
|
}
|
|
1209
1324
|
|
|
@@ -1287,12 +1402,15 @@ export class Hub {
|
|
|
1287
1402
|
const recordedPreset = meta?.permissions?.preset;
|
|
1288
1403
|
const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
|
|
1289
1404
|
// 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
|
|
1405
|
+
// ★ 档位未知(没有投影缓存、也没有本服务侧车)时 **fail-closed 到 read-only**。
|
|
1406
|
+
// 以前回落到 DEFAULT_PERMISSION(danger-full-access),等于把一个可能是
|
|
1407
|
+
// 只读的外部会话悄悄提升为完全访问。宁可让用户显式新建会话,也不要无声提权。
|
|
1290
1408
|
const permission =
|
|
1291
1409
|
recordedPermission === 'read-only'
|
|
1292
1410
|
? 'read-only'
|
|
1293
1411
|
: PERMISSION_TIERS.includes(persisted?.permission)
|
|
1294
1412
|
? persisted.permission
|
|
1295
|
-
: (recordedPermission ??
|
|
1413
|
+
: (recordedPermission ?? 'read-only');
|
|
1296
1414
|
const recordedApproval = meta?.permissions?.approval;
|
|
1297
1415
|
let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
|
|
1298
1416
|
? persisted.onApproval
|
|
@@ -1452,6 +1570,8 @@ export class Hub {
|
|
|
1452
1570
|
` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
|
|
1453
1571
|
);
|
|
1454
1572
|
}
|
|
1573
|
+
// ★ 回读会话自己的权限记录;不一致会在这里失败并走 catch(停进程、删登记)。
|
|
1574
|
+
await conv.verifyPermissionRecorded();
|
|
1455
1575
|
// 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
|
|
1456
1576
|
// ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
|
|
1457
1577
|
// 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
|
|
@@ -1486,7 +1606,10 @@ export class Hub {
|
|
|
1486
1606
|
if (isForgotten(c.id)) continue;
|
|
1487
1607
|
out.set(c.id, c.snapshot());
|
|
1488
1608
|
}
|
|
1489
|
-
if (!includeClosed)
|
|
1609
|
+
if (!includeClosed) {
|
|
1610
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: now() };
|
|
1611
|
+
return [...out.values()];
|
|
1612
|
+
}
|
|
1490
1613
|
|
|
1491
1614
|
// 磁盘探测要 spawn 一个 DSH 进程(实测约 1 秒),所以同 cwd 的结果缓存一小段时间,
|
|
1492
1615
|
// 避免"连着列几次会话"就反复拉进程。
|
|
@@ -1498,7 +1621,9 @@ export class Hub {
|
|
|
1498
1621
|
|
|
1499
1622
|
const probeCwd = cwd ?? [...this.conversations.values()][0]?.cwd ?? process.cwd();
|
|
1500
1623
|
let sessions = cached;
|
|
1501
|
-
if (
|
|
1624
|
+
if (cached) {
|
|
1625
|
+
this.lastProbe = { ok: true, error: null, cached: true, at: this.#probeCache.at };
|
|
1626
|
+
} else if (!sessions) {
|
|
1502
1627
|
let proc = null;
|
|
1503
1628
|
try {
|
|
1504
1629
|
proc = new AcpProcess({ cwd: probeCwd, permission: 'read-only' });
|
|
@@ -1507,9 +1632,12 @@ export class Hub {
|
|
|
1507
1632
|
const res = await proc.request('session/list', cwd ? { cwd } : {});
|
|
1508
1633
|
sessions = res?.sessions ?? [];
|
|
1509
1634
|
this.#probeCache = { key: cacheKey, at: now(), sessions };
|
|
1635
|
+
this.lastProbe = { ok: true, error: null, cached: false, at: this.#probeCache.at };
|
|
1510
1636
|
} catch (e) {
|
|
1511
1637
|
this.log.info(`[hub] session/list 探测失败: ${e.message}`);
|
|
1512
1638
|
sessions = []; // 失败不缓存,下次重试
|
|
1639
|
+
// ★ 调用方必须能区分"没有别的会话"和"探测失败",否则会以为会话丢了。
|
|
1640
|
+
this.lastProbe = { ok: false, error: e.message, cached: false, at: now() };
|
|
1513
1641
|
} finally {
|
|
1514
1642
|
await proc?.stop().catch(() => {});
|
|
1515
1643
|
}
|
|
@@ -1619,10 +1747,17 @@ export class Hub {
|
|
|
1619
1747
|
* 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约 300-480MB ✗)。
|
|
1620
1748
|
* 一批活干完了就调它,内存立刻归零 ✓;下次派活按需 resume ✓。
|
|
1621
1749
|
*/
|
|
1622
|
-
async releaseAll() {
|
|
1750
|
+
async releaseAll({ force = false } = {}) {
|
|
1623
1751
|
const released = [];
|
|
1752
|
+
const skippedBusy = [];
|
|
1624
1753
|
for (const c of this.conversations.values()) {
|
|
1625
1754
|
if (!c.proc?.alive) continue;
|
|
1755
|
+
// ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
|
|
1756
|
+
// 确实要连跑着的也停掉时传 force=true。
|
|
1757
|
+
if (c.busy && !force) {
|
|
1758
|
+
skippedBusy.push(c.id);
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1626
1761
|
const proc = c.proc;
|
|
1627
1762
|
c.proc = null;
|
|
1628
1763
|
await proc.stop().catch(() => {});
|
|
@@ -1630,7 +1765,7 @@ export class Hub {
|
|
|
1630
1765
|
released.push(c.id);
|
|
1631
1766
|
}
|
|
1632
1767
|
this.save();
|
|
1633
|
-
return { released: released.length, conversations: released };
|
|
1768
|
+
return { released: released.length, conversations: released, skipped_busy: skippedBusy };
|
|
1634
1769
|
}
|
|
1635
1770
|
|
|
1636
1771
|
async shutdown() {
|
package/src/locks.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, renameSync } from 'node:fs';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
30
31
|
import { join, dirname } from 'node:path';
|
|
31
32
|
import { randomUUID } from 'node:crypto';
|
|
32
33
|
import { STATE_FILE, REAP_INTERVAL_MS, numEnv } from './config.mjs';
|
|
@@ -220,8 +221,21 @@ export function classifyHolder(conversationId, { knownChildPid = null } = {}) {
|
|
|
220
221
|
* 杀掉一个 DSH 子进程。**只允许对 classification 判定为 `stale-mcp` / `live-mcp(force)`
|
|
221
222
|
* 的、由本 MCP 登记过的进程调用** —— 调用方负责先做分类。
|
|
222
223
|
*/
|
|
223
|
-
export function killHolderChild(pid) {
|
|
224
|
+
export function killHolderChild(pid, { expectedSpawnedAt } = {}) {
|
|
224
225
|
if (!pid || !isPidAlive(pid)) return { killed: false, reason: '进程已经不在了' };
|
|
226
|
+
// ★ PID 会被复用。抢占是"杀进程"这种不可逆动作,所以先核对启动时间:
|
|
227
|
+
// 登记时间与 OS 记录的启动时间差太多 → 这已经不是当初登记的那个进程,拒绝杀。
|
|
228
|
+
if (Number.isFinite(expectedSpawnedAt)) {
|
|
229
|
+
const actual = processStartTime(pid);
|
|
230
|
+
if (Number.isFinite(actual) && Math.abs(actual - expectedSpawnedAt) > 10 * 60 * 1000) {
|
|
231
|
+
return {
|
|
232
|
+
killed: false,
|
|
233
|
+
reason:
|
|
234
|
+
`PID ${pid} 的启动时间与登记不符(登记 ${new Date(expectedSpawnedAt).toISOString()},` +
|
|
235
|
+
`实际 ${new Date(actual).toISOString()}),疑似 PID 已被复用,拒绝杀。`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
225
239
|
try {
|
|
226
240
|
process.kill(pid, 'SIGKILL');
|
|
227
241
|
} catch (e) {
|
|
@@ -230,6 +244,30 @@ export function killHolderChild(pid) {
|
|
|
230
244
|
return { killed: true, reason: `已向 PID ${pid} 发送 SIGKILL` };
|
|
231
245
|
}
|
|
232
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 读取进程启动时间(毫秒时间戳)。拿不到时返回 null(调用方按旧行为处理)。
|
|
249
|
+
* Windows 用 PowerShell 的 Get-Process.StartTime;POSIX 用 ps -o lstart=。
|
|
250
|
+
* 只在 takeover 这种低频、不可逆动作前调用,不在热路径上。
|
|
251
|
+
*/
|
|
252
|
+
export function processStartTime(pid) {
|
|
253
|
+
if (!pid) return null;
|
|
254
|
+
try {
|
|
255
|
+
const args =
|
|
256
|
+
process.platform === 'win32'
|
|
257
|
+
? ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToString('o')`]
|
|
258
|
+
: ['-o', 'lstart=', '-p', String(pid)];
|
|
259
|
+
const r = spawnSync(process.platform === 'win32' ? 'powershell' : 'ps', args, {
|
|
260
|
+
encoding: 'utf8',
|
|
261
|
+
timeout: 3000,
|
|
262
|
+
windowsHide: true,
|
|
263
|
+
});
|
|
264
|
+
const t = Date.parse(String(r.stdout || '').trim());
|
|
265
|
+
return Number.isFinite(t) ? t : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
233
271
|
/**
|
|
234
272
|
* 启动清理:删掉"MCP 已死 + 子进程也死了"的登记文件。
|
|
235
273
|
* 注意**不删**那些子进程还活着的登记 —— 那正是后面抢占要用的线索。
|
package/src/mcp.mjs
CHANGED
|
@@ -183,6 +183,12 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
|
|
|
183
183
|
// 通知(无 id):不需要回包
|
|
184
184
|
if (id === undefined) {
|
|
185
185
|
if (method === 'notifications/initialized') initialized = true;
|
|
186
|
+
// 客户端换了打开的项目:丢掉缓存并重新问一次,下一次 dsh_start 才会用新目录。
|
|
187
|
+
if (method === 'notifications/roots/list_changed') {
|
|
188
|
+
clientRoots = null;
|
|
189
|
+
rootsRequested = false;
|
|
190
|
+
requestRoots().catch(() => {});
|
|
191
|
+
}
|
|
186
192
|
return;
|
|
187
193
|
}
|
|
188
194
|
|
package/src/tools.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export const TOOL_DEFS = [
|
|
|
45
45
|
type: 'string',
|
|
46
46
|
enum: PERMISSION_TIERS,
|
|
47
47
|
description:
|
|
48
|
-
`权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write
|
|
48
|
+
`权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 由 DSH 沙箱限制可写范围,越界请求默认 auto-allow,想在调用方挂起审批请传 on_approval=ask。` +
|
|
49
49
|
'**档位在创建时定下**:DSH 设计上"resume 的会话保留它自己记录的权限",所以事后改这个参数不会改变已有会话,要换档需新建。' +
|
|
50
50
|
'(本档位是**真实生效**的 —— 由启动时传入的 env 决定,并有 test/permission.mjs 对着会话自己的记录逐档核验。)',
|
|
51
51
|
},
|
|
@@ -188,7 +188,7 @@ export const TOOL_DEFS = [
|
|
|
188
188
|
enum: REASONING_MODES,
|
|
189
189
|
description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
|
|
190
190
|
},
|
|
191
|
-
timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue
|
|
191
|
+
timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
|
|
192
192
|
},
|
|
193
193
|
['conversation_id', 'message'],
|
|
194
194
|
),
|
|
@@ -229,14 +229,19 @@ export const TOOL_DEFS = [
|
|
|
229
229
|
'会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
|
|
230
230
|
'(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
|
|
231
231
|
'★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
|
|
232
|
-
'all=true
|
|
232
|
+
'all=true 会释放**空闲**会话,正在跑回合的默认跳过;确实要连跑着的也停掉请传 force=true)。' +
|
|
233
|
+
'对单个会话 release 会停掉其 DSH 进程;若它正在跑回合,该回合会被中断。',
|
|
233
234
|
inputSchema: S(
|
|
234
235
|
{
|
|
235
236
|
conversation_id: CONV_ID,
|
|
236
237
|
forget: { type: 'boolean', description: '同时从本服务的注册表里移除,默认 false。' },
|
|
237
238
|
all: {
|
|
238
239
|
type: 'boolean',
|
|
239
|
-
description: '忽略 conversation_id
|
|
240
|
+
description: '忽略 conversation_id,释放所有**空闲**会话进程(腾内存用)。正在跑回合的默认跳过。默认 false。',
|
|
241
|
+
},
|
|
242
|
+
force: {
|
|
243
|
+
type: 'boolean',
|
|
244
|
+
description: '仅配合 all=true:连正在跑回合的会话也停掉(会中断那些回合)。默认 false。',
|
|
240
245
|
},
|
|
241
246
|
},
|
|
242
247
|
[],
|
|
@@ -454,9 +459,13 @@ export function createHandlers(hub, opts = {}) {
|
|
|
454
459
|
if (c.turns != null) bits.push(`轮数=${c.turns}`);
|
|
455
460
|
return `- ${bits.join(' | ')}`;
|
|
456
461
|
};
|
|
462
|
+
const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
|
|
463
|
+
const probeNote = probe.ok === false
|
|
464
|
+
? `\n\n⚠️ 磁盘会话探测失败:${probe.error}\n 上面只列出本服务已打开的会话;磁盘上未打开的会话可能没显示。`
|
|
465
|
+
: '';
|
|
457
466
|
const text = items.length
|
|
458
467
|
? `${items.length} 个会话(其中 ${running} 个正在跑):\n${items.map(describe).join('\n')}`
|
|
459
|
-
: '(没有会话)';
|
|
468
|
+
: probe.ok === false ? '(本服务没有已打开的会话;磁盘探测失败)' : '(没有会话)';
|
|
460
469
|
// 孤儿锁持有者:上一代 MCP 崩了但它的 DSH 子进程还活着握着写锁 —— 不主动报出来,
|
|
461
470
|
// 用户只会在下一次撞上"被占用"时才发现。这里顺手提示,并给出可执行的动作。
|
|
462
471
|
const orphans = listOrphans();
|
|
@@ -466,8 +475,16 @@ export function createHandlers(hub, opts = {}) {
|
|
|
466
475
|
'\n→ 用 dsh_takeover 抢占即可(这些是本服务自己的残留,抢占是安全的)。'
|
|
467
476
|
: '';
|
|
468
477
|
return {
|
|
469
|
-
structured: {
|
|
470
|
-
|
|
478
|
+
structured: {
|
|
479
|
+
count: items.length,
|
|
480
|
+
running,
|
|
481
|
+
conversations: items,
|
|
482
|
+
orphan_holders: orphans,
|
|
483
|
+
probe_ok: probe.ok,
|
|
484
|
+
probe_error: probe.error ?? null,
|
|
485
|
+
probe_cached: probe.cached === true,
|
|
486
|
+
},
|
|
487
|
+
text: text + probeNote + orphanNote,
|
|
471
488
|
};
|
|
472
489
|
},
|
|
473
490
|
|
|
@@ -503,7 +520,12 @@ export function createHandlers(hub, opts = {}) {
|
|
|
503
520
|
let run = null;
|
|
504
521
|
const meta = [];
|
|
505
522
|
// DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
|
|
506
|
-
|
|
523
|
+
const titleKind = snap.title_source === 'native'
|
|
524
|
+
? 'DSH 自动生成'
|
|
525
|
+
: snap.title_source === 'title_hint'
|
|
526
|
+
? '来自 title_hint(DSH 尚未生成标题)'
|
|
527
|
+
: '尚未生成';
|
|
528
|
+
meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(${titleKind})` +
|
|
507
529
|
(snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
|
|
508
530
|
meta.push(`工作区: ${snap.cwd}`);
|
|
509
531
|
meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
|
|
@@ -587,12 +609,15 @@ export function createHandlers(hub, opts = {}) {
|
|
|
587
609
|
// ★ all=true:一次性放掉所有活着的会话进程(腾内存)。
|
|
588
610
|
// 无损:会话日志都在磁盘上,之后照样 resume。
|
|
589
611
|
if (args.all === true) {
|
|
590
|
-
const r = await hub.releaseAll();
|
|
612
|
+
const r = await hub.releaseAll({ force: args.force === true });
|
|
591
613
|
return {
|
|
592
|
-
structured: { released: r.released, conversations: r.conversations },
|
|
614
|
+
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
|
|
593
615
|
text:
|
|
594
|
-
`已释放 ${r.released}
|
|
595
|
-
|
|
616
|
+
`已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
|
|
617
|
+
(r.skipped_busy.length
|
|
618
|
+
? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
|
|
619
|
+
: '') +
|
|
620
|
+
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
596
621
|
};
|
|
597
622
|
}
|
|
598
623
|
const conv = hub.get(args.conversation_id);
|
|
@@ -633,11 +658,14 @@ export function createHandlers(hub, opts = {}) {
|
|
|
633
658
|
if (snap.lock_holder && snap.lock_holder !== 'self') {
|
|
634
659
|
const hint =
|
|
635
660
|
snap.lock_holder === 'stale-mcp'
|
|
636
|
-
? '
|
|
661
|
+
? '(本服务残留/卡死的持有者,可 dsh_takeover 抢占)'
|
|
637
662
|
: snap.lock_holder === 'live-mcp'
|
|
638
663
|
? '(另一个活着的 dsh-any-agent 实例在用;要夺需 dsh_takeover(force=true))'
|
|
639
|
-
:
|
|
664
|
+
: snap.lock_conflict
|
|
665
|
+
? '(最近撞过锁:多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)'
|
|
666
|
+
: '(没有本服务的持锁登记;空闲/已释放时也会是 none,只有在撞锁时才可能是 GUI)';
|
|
640
667
|
bits.push(`写锁=${snap.lock_holder}${hint}`);
|
|
668
|
+
if (snap.lock_holder_reason) bits.push(` 判定:${snap.lock_holder_reason}`);
|
|
641
669
|
}
|
|
642
670
|
// ★ 撞过锁就把"谁占用 + 怎么解"讲清楚。实测:GUI 点开一次就会长期持有写锁
|
|
643
671
|
// (切走、等十几分钟都不释放),所以这条信息必须持久可见,而不是一闪而过的报错。
|
|
@@ -661,6 +689,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
661
689
|
pending_approval_details: snap.pending_approval_details,
|
|
662
690
|
turns: snap.turns,
|
|
663
691
|
lock_holder: snap.lock_holder,
|
|
692
|
+
lock_holder_reason: snap.lock_holder_reason,
|
|
664
693
|
lock_conflict: snap.lock_conflict,
|
|
665
694
|
run: run
|
|
666
695
|
? {
|