dsh-any-agent 0.3.4 → 0.3.5
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 +628 -0
- package/DESIGN.md +2 -2
- package/README.md +65 -22
- package/README.zh.md +36 -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/package.json +2 -1
- package/src/acp.mjs +24 -3
- package/src/bootstrap.mjs +73 -64
- package/src/clients.mjs +10 -15
- package/src/config.mjs +59 -8
- package/src/hub.mjs +124 -12
- package/src/tools.mjs +16 -8
- package/src/workspace.mjs +137 -39
- 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,
|
|
@@ -240,13 +246,18 @@ export function sanitizeResultForSentinel(result) {
|
|
|
240
246
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
241
247
|
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
242
248
|
const { thinking, ...rest } = result;
|
|
249
|
+
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
250
|
+
for (const key of ['answer', 'error', 'title']) {
|
|
251
|
+
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
252
|
+
}
|
|
243
253
|
// 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
|
|
244
254
|
// 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
|
|
245
255
|
if (Array.isArray(rest.tools_used)) {
|
|
246
256
|
rest.tools_used = rest.tools_used.map((t) => {
|
|
247
257
|
if (!t || typeof t !== 'object') return t;
|
|
248
258
|
const { input, ...meta } = t;
|
|
249
|
-
|
|
259
|
+
const safeMeta = sanitizeApprovalContext(meta);
|
|
260
|
+
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
250
261
|
});
|
|
251
262
|
}
|
|
252
263
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
@@ -271,6 +282,8 @@ export class Conversation {
|
|
|
271
282
|
model: state.model ?? null,
|
|
272
283
|
};
|
|
273
284
|
this.lastConfigApplied = null;
|
|
285
|
+
/** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
|
|
286
|
+
this.permissionVerified = null;
|
|
274
287
|
/** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
|
|
275
288
|
this.workspace = state.workspace ?? null;
|
|
276
289
|
this.#hub = hub;
|
|
@@ -350,6 +363,8 @@ export class Conversation {
|
|
|
350
363
|
title_hint: this.titleHint ?? null,
|
|
351
364
|
cwd: this.cwd,
|
|
352
365
|
permission: this.permission,
|
|
366
|
+
/** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
|
|
367
|
+
permission_verified: this.permissionVerified,
|
|
353
368
|
on_approval: this.onApproval,
|
|
354
369
|
alive: !!this.proc?.alive,
|
|
355
370
|
busy: this.busy,
|
|
@@ -418,6 +433,8 @@ export class Conversation {
|
|
|
418
433
|
// 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
|
|
419
434
|
this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
|
|
420
435
|
for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
|
|
436
|
+
// ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
|
|
437
|
+
await this.verifyPermissionRecorded();
|
|
421
438
|
// 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
|
|
422
439
|
writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
|
|
423
440
|
this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
|
|
@@ -465,6 +482,39 @@ export class Conversation {
|
|
|
465
482
|
}
|
|
466
483
|
}
|
|
467
484
|
|
|
485
|
+
/**
|
|
486
|
+
* 核对**会话自己记录**的权限档是否与请求一致。
|
|
487
|
+
*
|
|
488
|
+
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
489
|
+
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
490
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘,所以短轮询;读不到时
|
|
491
|
+
* 不阻断(read-only 仍有审批层的硬拒绝兜底),但读到不一致就立刻失败。
|
|
492
|
+
*/
|
|
493
|
+
async verifyPermissionRecorded({ attempts = 6, delayMs = 250 } = {}) {
|
|
494
|
+
for (let i = 0; i < attempts; i++) {
|
|
495
|
+
const meta = readConversationMeta(this.id);
|
|
496
|
+
const recorded = meta?.permissions?.preset;
|
|
497
|
+
if (recorded) {
|
|
498
|
+
if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
|
|
501
|
+
' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
|
|
502
|
+
` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
|
|
503
|
+
'删掉 permission.defaultPreset 后重试。',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
this.permissionVerified = true;
|
|
507
|
+
return recorded;
|
|
508
|
+
}
|
|
509
|
+
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
510
|
+
}
|
|
511
|
+
this.permissionVerified = false;
|
|
512
|
+
this.#hub.log.info(
|
|
513
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(read-only 仍由审批层强制拒绝)`,
|
|
514
|
+
);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
468
518
|
/**
|
|
469
519
|
* 抢占写锁。
|
|
470
520
|
*
|
|
@@ -806,7 +856,9 @@ export class Conversation {
|
|
|
806
856
|
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
807
857
|
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
808
858
|
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
809
|
-
this.#hub.writeRunSentinel(this.id, runRecord);
|
|
859
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
860
|
+
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
861
|
+
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
810
862
|
return result;
|
|
811
863
|
} catch (e) {
|
|
812
864
|
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
@@ -830,7 +882,9 @@ export class Conversation {
|
|
|
830
882
|
runRecord.status = 'error';
|
|
831
883
|
runRecord.error = fallback.error;
|
|
832
884
|
runRecord.result = fallback;
|
|
833
|
-
this.#hub.writeRunSentinel(this.id, runRecord);
|
|
885
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
886
|
+
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
887
|
+
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
834
888
|
return fallback;
|
|
835
889
|
}
|
|
836
890
|
};
|
|
@@ -1051,8 +1105,17 @@ export class Conversation {
|
|
|
1051
1105
|
|
|
1052
1106
|
/** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
|
|
1053
1107
|
export function gitStatus(cwd) {
|
|
1108
|
+
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
1054
1109
|
try {
|
|
1055
|
-
|
|
1110
|
+
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
1111
|
+
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
1112
|
+
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
1113
|
+
const r = spawnSync('git', ['-c', 'core.fsmonitor=false', 'status', '--porcelain', '--no-optional-locks'], {
|
|
1114
|
+
cwd,
|
|
1115
|
+
encoding: 'utf8',
|
|
1116
|
+
timeout: 8000,
|
|
1117
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
1118
|
+
});
|
|
1056
1119
|
if (r.status !== 0 || !r.stdout) return null;
|
|
1057
1120
|
const lines = r.stdout.split('\n').filter(Boolean);
|
|
1058
1121
|
if (!lines.length) return null;
|
|
@@ -1120,7 +1183,10 @@ export class Hub {
|
|
|
1120
1183
|
constructor({ log } = {}) {
|
|
1121
1184
|
this.conversations = new Map();
|
|
1122
1185
|
this.log = log ?? NOOP_LOG;
|
|
1123
|
-
|
|
1186
|
+
const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
|
|
1187
|
+
// 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
|
|
1188
|
+
this.approvalTimeoutMs =
|
|
1189
|
+
Number.isFinite(rawApprovalTimeout) && rawApprovalTimeout > 0 ? rawApprovalTimeout : 5 * 60 * 1000;
|
|
1124
1190
|
this.#reaper = null;
|
|
1125
1191
|
}
|
|
1126
1192
|
|
|
@@ -1129,6 +1195,30 @@ export class Hub {
|
|
|
1129
1195
|
#probeCache = null;
|
|
1130
1196
|
|
|
1131
1197
|
load() {
|
|
1198
|
+
// ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
|
|
1199
|
+
// 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
|
|
1200
|
+
assertWritableDir(dirname(STATE_FILE), '状态目录');
|
|
1201
|
+
assertWritableDir(RUNS_DIR, '哨兵目录');
|
|
1202
|
+
assertWritableDir(LOCKS_DIR, '锁登记目录');
|
|
1203
|
+
|
|
1204
|
+
// 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
|
|
1205
|
+
// 只在没有显式设置 DSH_MCP_STATE 时做,避免覆盖用户指定的路径。
|
|
1206
|
+
if (USING_DEFAULT_STATE && !existsSync(STATE_FILE) && existsSync(LEGACY_STATE_FILE)) {
|
|
1207
|
+
try {
|
|
1208
|
+
copyFileSync(LEGACY_STATE_FILE, STATE_FILE);
|
|
1209
|
+
this.log.info(`[hub] 已迁移旧状态文件: ${LEGACY_STATE_FILE} → ${STATE_FILE}`);
|
|
1210
|
+
// 侧车元数据(审批策略/思考深度/标题/轮数)也一并搬过来;
|
|
1211
|
+
// 否则旧会话按 id 重建时会悄悄回落到默认值。
|
|
1212
|
+
const legacyMeta = join(dirname(LEGACY_STATE_FILE), 'meta');
|
|
1213
|
+
if (existsSync(legacyMeta) && !existsSync(META_DIR)) {
|
|
1214
|
+
cpSync(legacyMeta, META_DIR, { recursive: true });
|
|
1215
|
+
this.log.info(`[hub] 已迁移旧元数据侧车: ${legacyMeta} → ${META_DIR}`);
|
|
1216
|
+
}
|
|
1217
|
+
} catch (e) {
|
|
1218
|
+
this.log.info(`[hub] 旧状态文件迁移失败(继续用新路径): ${e.message}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1132
1222
|
const stale = pruneStaleTombstones();
|
|
1133
1223
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1134
1224
|
if (!existsSync(STATE_FILE)) return;
|
|
@@ -1186,24 +1276,34 @@ export class Hub {
|
|
|
1186
1276
|
return join(RUNS_DIR, conversationId, `${runId}.json`);
|
|
1187
1277
|
}
|
|
1188
1278
|
|
|
1189
|
-
/**
|
|
1279
|
+
/**
|
|
1280
|
+
* 写完成哨兵(原子)。
|
|
1281
|
+
* @returns {string|null} 成功返回文件路径;失败返回 null,并把原因记进 run.sentinel_error。
|
|
1282
|
+
*
|
|
1283
|
+
* 失败不阻断回合收尾,但**必须让调用方看得见**:以前只记 silent 日志,
|
|
1284
|
+
* 调用方会拿着一个永远不会出现的路径一直等。
|
|
1285
|
+
*/
|
|
1190
1286
|
writeRunSentinel(conversationId, run) {
|
|
1287
|
+
const file = this.runSentinelPath(conversationId, run.run_id);
|
|
1191
1288
|
try {
|
|
1192
|
-
const file = this.runSentinelPath(conversationId, run.run_id);
|
|
1193
1289
|
mkdirSync(dirname(file), { recursive: true });
|
|
1194
1290
|
const payload = {
|
|
1195
1291
|
conversation_id: conversationId,
|
|
1196
1292
|
run_id: run.run_id,
|
|
1197
1293
|
status: run.status ?? 'unknown',
|
|
1198
1294
|
ended_at: run.ended_at ?? Date.now(),
|
|
1199
|
-
error: run.error ?? run.result?.error ?? null,
|
|
1295
|
+
error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
|
|
1200
1296
|
result: sanitizeResultForSentinel(run.result ?? null),
|
|
1201
1297
|
};
|
|
1202
1298
|
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
1203
1299
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
1204
1300
|
renameSync(tmp, file);
|
|
1301
|
+
return file;
|
|
1205
1302
|
} catch (e) {
|
|
1206
|
-
|
|
1303
|
+
const msg = `写完成哨兵失败(${file}): ${e.message}`;
|
|
1304
|
+
run.sentinel_error = msg;
|
|
1305
|
+
this.log.info(`[hub] ${msg}`);
|
|
1306
|
+
return null;
|
|
1207
1307
|
}
|
|
1208
1308
|
}
|
|
1209
1309
|
|
|
@@ -1287,12 +1387,15 @@ export class Hub {
|
|
|
1287
1387
|
const recordedPreset = meta?.permissions?.preset;
|
|
1288
1388
|
const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
|
|
1289
1389
|
// 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
|
|
1390
|
+
// ★ 档位未知(没有投影缓存、也没有本服务侧车)时 **fail-closed 到 read-only**。
|
|
1391
|
+
// 以前回落到 DEFAULT_PERMISSION(danger-full-access),等于把一个可能是
|
|
1392
|
+
// 只读的外部会话悄悄提升为完全访问。宁可让用户显式新建会话,也不要无声提权。
|
|
1290
1393
|
const permission =
|
|
1291
1394
|
recordedPermission === 'read-only'
|
|
1292
1395
|
? 'read-only'
|
|
1293
1396
|
: PERMISSION_TIERS.includes(persisted?.permission)
|
|
1294
1397
|
? persisted.permission
|
|
1295
|
-
: (recordedPermission ??
|
|
1398
|
+
: (recordedPermission ?? 'read-only');
|
|
1296
1399
|
const recordedApproval = meta?.permissions?.approval;
|
|
1297
1400
|
let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
|
|
1298
1401
|
? persisted.onApproval
|
|
@@ -1452,6 +1555,8 @@ export class Hub {
|
|
|
1452
1555
|
` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
|
|
1453
1556
|
);
|
|
1454
1557
|
}
|
|
1558
|
+
// ★ 回读会话自己的权限记录;不一致会在这里失败并走 catch(停进程、删登记)。
|
|
1559
|
+
await conv.verifyPermissionRecorded();
|
|
1455
1560
|
// 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
|
|
1456
1561
|
// ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
|
|
1457
1562
|
// 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
|
|
@@ -1619,10 +1724,17 @@ export class Hub {
|
|
|
1619
1724
|
* 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约 300-480MB ✗)。
|
|
1620
1725
|
* 一批活干完了就调它,内存立刻归零 ✓;下次派活按需 resume ✓。
|
|
1621
1726
|
*/
|
|
1622
|
-
async releaseAll() {
|
|
1727
|
+
async releaseAll({ force = false } = {}) {
|
|
1623
1728
|
const released = [];
|
|
1729
|
+
const skippedBusy = [];
|
|
1624
1730
|
for (const c of this.conversations.values()) {
|
|
1625
1731
|
if (!c.proc?.alive) continue;
|
|
1732
|
+
// ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
|
|
1733
|
+
// 确实要连跑着的也停掉时传 force=true。
|
|
1734
|
+
if (c.busy && !force) {
|
|
1735
|
+
skippedBusy.push(c.id);
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1626
1738
|
const proc = c.proc;
|
|
1627
1739
|
c.proc = null;
|
|
1628
1740
|
await proc.stop().catch(() => {});
|
|
@@ -1630,7 +1742,7 @@ export class Hub {
|
|
|
1630
1742
|
released.push(c.id);
|
|
1631
1743
|
}
|
|
1632
1744
|
this.save();
|
|
1633
|
-
return { released: released.length, conversations: released };
|
|
1745
|
+
return { released: released.length, conversations: released, skipped_busy: skippedBusy };
|
|
1634
1746
|
}
|
|
1635
1747
|
|
|
1636
1748
|
async shutdown() {
|
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
|
[],
|
|
@@ -587,12 +592,15 @@ export function createHandlers(hub, opts = {}) {
|
|
|
587
592
|
// ★ all=true:一次性放掉所有活着的会话进程(腾内存)。
|
|
588
593
|
// 无损:会话日志都在磁盘上,之后照样 resume。
|
|
589
594
|
if (args.all === true) {
|
|
590
|
-
const r = await hub.releaseAll();
|
|
595
|
+
const r = await hub.releaseAll({ force: args.force === true });
|
|
591
596
|
return {
|
|
592
|
-
structured: { released: r.released, conversations: r.conversations },
|
|
597
|
+
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
|
|
593
598
|
text:
|
|
594
|
-
`已释放 ${r.released}
|
|
595
|
-
|
|
599
|
+
`已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
|
|
600
|
+
(r.skipped_busy.length
|
|
601
|
+
? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
|
|
602
|
+
: '') +
|
|
603
|
+
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
596
604
|
};
|
|
597
605
|
}
|
|
598
606
|
const conv = hub.get(args.conversation_id);
|
package/src/workspace.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
|
-
import { basename, dirname, join } from 'node:path';
|
|
20
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
22
22
|
import { zstdDecompressSync } from 'node:zlib';
|
|
23
23
|
import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
|
|
@@ -42,11 +42,46 @@ export function canonicalPath(p) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/** `child` 是否真的在 `parent` 里面(按路径分段,不能用字符串前缀)。 */
|
|
46
|
+
function isInside(parent, child) {
|
|
47
|
+
try {
|
|
48
|
+
const rel = relative(parent, child);
|
|
49
|
+
if (!rel) return true;
|
|
50
|
+
return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 路径是否位于系统临时目录下(这类"工作区"不该出现在 GUI 里)。
|
|
58
|
+
*
|
|
59
|
+
* ★ 必须按路径分段判断,不能用 `startsWith(tmpdir())`:那样
|
|
60
|
+
* `/tmpfoo`、`%LOCALAPPDATA%\Temporary\proj` 都会被当成临时目录,
|
|
61
|
+
* 而清理逻辑会据此**递归删除真实会话目录**(真实数据丢失级 bug)。
|
|
62
|
+
*/
|
|
46
63
|
export function isTempPath(p) {
|
|
47
64
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
return Boolean(p) && isInside(tmpdir(), p);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 是否是本仓库测试自己造的临时工作区。
|
|
73
|
+
*
|
|
74
|
+
* 只认系统临时目录下、第一段目录名以 `dsh-any-agent-` 开头的路径
|
|
75
|
+
* (测试统一用 `mkdtempSync(join(tmpdir(), 'dsh-any-agent-...'))`)。
|
|
76
|
+
* 这不是"所有临时目录"——用户完全可能把真实项目放在 %TEMP% 的同级目录里。
|
|
77
|
+
*/
|
|
78
|
+
export function isTestTempPath(p) {
|
|
79
|
+
try {
|
|
80
|
+
if (!p || !isTempPath(p)) return false;
|
|
81
|
+
const rel = relative(tmpdir(), p);
|
|
82
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return false;
|
|
83
|
+
const first = rel.split(/[\\/]/).filter(Boolean)[0] ?? '';
|
|
84
|
+
return /^dsh-any-agent-/.test(first);
|
|
50
85
|
} catch {
|
|
51
86
|
return false;
|
|
52
87
|
}
|
|
@@ -87,16 +122,25 @@ function emptyDoc() {
|
|
|
87
122
|
};
|
|
88
123
|
}
|
|
89
124
|
|
|
125
|
+
/**
|
|
126
|
+
* 读取工作区注册表。
|
|
127
|
+
*
|
|
128
|
+
* ★ 解析失败必须 **fail-closed**:以前会静默回落到空结构,下一次 register/prune
|
|
129
|
+
* 就会把 GUI 的整个工作区列表覆盖掉(不可恢复)。现在抛错,并且绝不写盘;
|
|
130
|
+
* 调用方(register/backfill/prune)各自把错误返回给上层。
|
|
131
|
+
*/
|
|
90
132
|
function readDoc() {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/* 坏文件就用空结构重建,避免把 GUI 的注册表彻底写坏 */
|
|
98
|
-
}
|
|
133
|
+
if (!existsSync(WORKSPACE_FILE)) return emptyDoc();
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new Error(`workspace.json 无法解析,已保持原样不写入: ${WORKSPACE_FILE}(${e.message})`);
|
|
99
139
|
}
|
|
140
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
141
|
+
throw new Error(`workspace.json 结构异常(不是对象),已保持原样不写入: ${WORKSPACE_FILE}`);
|
|
142
|
+
}
|
|
143
|
+
const doc = parsed;
|
|
100
144
|
doc.global ??= emptyDoc().global;
|
|
101
145
|
doc.global.workspaceIds ??= [];
|
|
102
146
|
doc.global.archivedSessionIds ??= [];
|
|
@@ -108,9 +152,17 @@ function readDoc() {
|
|
|
108
152
|
|
|
109
153
|
function writeDoc(doc) {
|
|
110
154
|
mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
// 临时文件名必须唯一:固定 `.dshmcp.tmp` 会让两个实例/CLI 并发写互相覆盖。
|
|
156
|
+
const tmp = `${WORKSPACE_FILE}.${process.pid}.${randomUUID()}.tmp`;
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
|
|
159
|
+
renameSync(tmp, WORKSPACE_FILE);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(tmp, { force: true });
|
|
163
|
+
} catch {}
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
114
166
|
}
|
|
115
167
|
|
|
116
168
|
/** 在已读入的 doc 里找到(或新建)某路径对应的工作区条目。 */
|
|
@@ -172,10 +224,17 @@ function readHeader(file) {
|
|
|
172
224
|
}
|
|
173
225
|
}
|
|
174
226
|
|
|
175
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
|
|
229
|
+
*
|
|
230
|
+
* 会话根目录**不可读时抛错**,而不是返回空 Map —— 否则 backfill 的
|
|
231
|
+
* `pruneMissing` 会以为"所有会话都消失了",把工作区注册表里的 sessionIds 清空。
|
|
232
|
+
*/
|
|
176
233
|
export function scanSessions() {
|
|
177
234
|
const byCwd = new Map();
|
|
178
|
-
if (!existsSync(SESSIONS_ROOT))
|
|
235
|
+
if (!existsSync(SESSIONS_ROOT)) {
|
|
236
|
+
throw new Error(`会话目录不存在或不可读: ${SESSIONS_ROOT}`);
|
|
237
|
+
}
|
|
179
238
|
for (const bucket of readdirSync(SESSIONS_ROOT)) {
|
|
180
239
|
const bucketPath = join(SESSIONS_ROOT, bucket);
|
|
181
240
|
let dirs;
|
|
@@ -210,7 +269,13 @@ export function scanSessions() {
|
|
|
210
269
|
export function findSessionHeader(sessionId) {
|
|
211
270
|
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
|
|
212
271
|
if (!existsSync(SESSIONS_ROOT)) return null;
|
|
213
|
-
|
|
272
|
+
let buckets;
|
|
273
|
+
try {
|
|
274
|
+
buckets = readdirSync(SESSIONS_ROOT);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
for (const bucket of buckets) {
|
|
214
279
|
const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
|
|
215
280
|
if (!existsSync(f)) continue;
|
|
216
281
|
const header = readHeader(f);
|
|
@@ -224,9 +289,6 @@ export function findSessionHeader(sessionId) {
|
|
|
224
289
|
* 用于修复"本功能上线前创建的会话在 GUI 里看不到"。
|
|
225
290
|
*/
|
|
226
291
|
export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneMissing = true } = {}) {
|
|
227
|
-
const byCwd = scanSessions();
|
|
228
|
-
const doc = readDoc();
|
|
229
|
-
const nowIso = new Date().toISOString();
|
|
230
292
|
const stats = {
|
|
231
293
|
workspaces_created: 0,
|
|
232
294
|
workspaces_existing: 0,
|
|
@@ -236,7 +298,19 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
|
|
|
236
298
|
temp_skipped_workspaces: 0,
|
|
237
299
|
/** 磁盘上已不存在、被从注册表摘掉的 sessionId 数量。 */
|
|
238
300
|
sessions_pruned: 0,
|
|
301
|
+
/** 读取失败时的说明;非空表示**没有写盘**。 */
|
|
302
|
+
error: null,
|
|
239
303
|
};
|
|
304
|
+
let byCwd;
|
|
305
|
+
let doc;
|
|
306
|
+
try {
|
|
307
|
+
byCwd = scanSessions();
|
|
308
|
+
doc = readDoc();
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// 看不到会话存储 / 注册表坏了时绝不做对账:那会把所有 sessionIds 清空。
|
|
311
|
+
return { ...stats, error: e.message };
|
|
312
|
+
}
|
|
313
|
+
const nowIso = new Date().toISOString();
|
|
240
314
|
|
|
241
315
|
/** 磁盘上真实存在的会话 id 全集。 */
|
|
242
316
|
const live = new Set();
|
|
@@ -286,12 +360,14 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
|
|
|
286
360
|
* 3) ids 里显式指定的
|
|
287
361
|
*
|
|
288
362
|
* @param {object} opts
|
|
289
|
-
* @param {boolean} [opts.
|
|
290
|
-
*
|
|
291
|
-
* @param {boolean} [opts.
|
|
363
|
+
* @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
|
|
364
|
+
* 与桌面 `dsh-any-agent-test-*`。自动清理(测试套件收尾)必须用这条。
|
|
365
|
+
* @param {boolean} [opts.tempOnly] 删除**所有** cwd 在系统临时目录下的会话。
|
|
366
|
+
* 只给显式人工命令用;不要放进自动清理 —— 用户完全可能把真实项目放在临时目录。
|
|
367
|
+
* @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面三条规则。
|
|
292
368
|
* 要精确删某几个会话时用这条(否则一条临时目录规则会把别的也一起带走 —— 真实踩过)。
|
|
293
369
|
*/
|
|
294
|
-
export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false, idsOnly = false } = {}) {
|
|
370
|
+
export function purgeTestSessions({ dryRun = false, ids = [], testOnly = false, tempOnly = false, idsOnly = false } = {}) {
|
|
295
371
|
const explicit = new Set(ids);
|
|
296
372
|
const victims = [];
|
|
297
373
|
if (existsSync(SESSIONS_ROOT)) {
|
|
@@ -304,12 +380,15 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
|
|
|
304
380
|
continue;
|
|
305
381
|
}
|
|
306
382
|
for (const d of dirs) {
|
|
307
|
-
|
|
383
|
+
// 符号链接一律不碰:rmSync 对链接的行为依赖平台,且链接目标可能不在会话目录内。
|
|
384
|
+
if (!d.isDirectory() || d.isSymbolicLink()) continue;
|
|
308
385
|
const dir = join(bucketPath, d.name);
|
|
386
|
+
// 兜底:只允许删 SESSIONS_ROOT 下面的目录(结构上已经如此,防未来改动)。
|
|
387
|
+
if (!isInside(SESSIONS_ROOT, dir)) continue;
|
|
309
388
|
const header = readHeader(join(dir, 'session.v4.jsonl.zstd'));
|
|
310
389
|
if (!header?.id) continue;
|
|
311
390
|
const cwd = header.cwd ?? '';
|
|
312
|
-
const isTemp = isTempPath(cwd);
|
|
391
|
+
const isTemp = testOnly ? isTestTempPath(cwd) : isTempPath(cwd);
|
|
313
392
|
const isTestDesktop = !tempOnly && /[\\/]Desktop[\\/]dsh-any-agent-test-[AB]([\\/]|$)/i.test(cwd);
|
|
314
393
|
const isExplicit = explicit.has(header.id);
|
|
315
394
|
// idsOnly:只认点名,别的规则全部作废
|
|
@@ -318,7 +397,7 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
|
|
|
318
397
|
id: header.id,
|
|
319
398
|
dir,
|
|
320
399
|
cwd,
|
|
321
|
-
reason: isExplicit ? '显式指定' :
|
|
400
|
+
reason: isExplicit ? '显式指定' : isTestDesktop ? '验收测试目录' : testOnly ? '测试临时目录' : '临时目录',
|
|
322
401
|
origin: header.origin ?? null,
|
|
323
402
|
});
|
|
324
403
|
}
|
|
@@ -339,12 +418,14 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
|
|
|
339
418
|
}
|
|
340
419
|
}
|
|
341
420
|
// 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
421
|
+
if (existsSync(SESSIONS_ROOT)) {
|
|
422
|
+
for (const bucket of readdirSync(SESSIONS_ROOT)) {
|
|
423
|
+
const p = join(SESSIONS_ROOT, bucket);
|
|
424
|
+
try {
|
|
425
|
+
if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
|
|
426
|
+
} catch {
|
|
427
|
+
/* 桶被占用或权限问题就跳过 */
|
|
428
|
+
}
|
|
348
429
|
}
|
|
349
430
|
}
|
|
350
431
|
// 删完之后把注册表里对应的 id 也摘掉
|
|
@@ -355,7 +436,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
|
|
|
355
436
|
|
|
356
437
|
/** 清掉"没有会话且路径已不存在"的空工作区条目。 */
|
|
357
438
|
export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
|
|
358
|
-
|
|
439
|
+
let doc;
|
|
440
|
+
try {
|
|
441
|
+
doc = readDoc();
|
|
442
|
+
} catch (e) {
|
|
443
|
+
return { removed: [], error: e.message };
|
|
444
|
+
}
|
|
359
445
|
const removed = [];
|
|
360
446
|
for (const id of Object.keys(doc.tables.workspaces)) {
|
|
361
447
|
const ws = doc.tables.workspaces[id];
|
|
@@ -379,13 +465,20 @@ export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
|
|
|
379
465
|
* 启发式,实测不一定兜住)。这里按**路径模式**确定性清理,绝不碰真实项目目录。
|
|
380
466
|
*/
|
|
381
467
|
export function pruneTestWorkspaces({ dryRun = false } = {}) {
|
|
382
|
-
|
|
468
|
+
let doc;
|
|
469
|
+
try {
|
|
470
|
+
doc = readDoc();
|
|
471
|
+
} catch (e) {
|
|
472
|
+
return { removed: [], error: e.message };
|
|
473
|
+
}
|
|
383
474
|
const removed = [];
|
|
384
475
|
for (const id of Object.keys(doc.tables.workspaces)) {
|
|
385
476
|
const ws = doc.tables.workspaces[id];
|
|
386
477
|
const p = ws?.path ?? '';
|
|
387
478
|
if (!p) continue;
|
|
388
|
-
|
|
479
|
+
// ★ 只删测试自己造的临时目录(dsh-any-agent-*)与桌面测试目录。
|
|
480
|
+
// 以前用 isTempPath(p) 会把用户在 %TEMP% 里的真实项目登记也删掉。
|
|
481
|
+
if (!isTestTempPath(p) && !isTestDesktopPath(p)) continue;
|
|
389
482
|
removed.push({ id, path: p, sessions: ws?.sessionIds?.length ?? 0 });
|
|
390
483
|
delete doc.tables.workspaces[id];
|
|
391
484
|
doc.global.workspaceIds = (doc.global.workspaceIds ?? []).filter((x) => x !== id);
|
|
@@ -397,7 +490,12 @@ export function pruneTestWorkspaces({ dryRun = false } = {}) {
|
|
|
397
490
|
|
|
398
491
|
/** 清理:把系统临时目录下的工作区条目从注册表移除(**不动磁盘上的会话**)。 */
|
|
399
492
|
export function pruneTempWorkspaces({ dryRun = false } = {}) {
|
|
400
|
-
|
|
493
|
+
let doc;
|
|
494
|
+
try {
|
|
495
|
+
doc = readDoc();
|
|
496
|
+
} catch (e) {
|
|
497
|
+
return { removed: [], error: e.message };
|
|
498
|
+
}
|
|
401
499
|
const removed = [];
|
|
402
500
|
for (const id of Object.keys(doc.tables.workspaces)) {
|
|
403
501
|
const ws = doc.tables.workspaces[id];
|
|
@@ -411,7 +509,7 @@ export function pruneTempWorkspaces({ dryRun = false } = {}) {
|
|
|
411
509
|
return { removed };
|
|
412
510
|
}
|
|
413
511
|
|
|
414
|
-
/**
|
|
512
|
+
/** 列出当前注册表里的工作区。解析失败时抛错(由 CLI 显示可操作的错误)。 */
|
|
415
513
|
export function listWorkspaces() {
|
|
416
514
|
const doc = readDoc();
|
|
417
515
|
return Object.entries(doc.tables.workspaces).map(([id, w]) => ({
|