dsh-any-agent 0.3.7 → 0.3.9
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 +89 -2
- package/DESIGN.md +13 -5
- package/README.md +36 -24
- package/README.zh.md +26 -22
- package/USAGE-MIGRATION.en.md +162 -0
- package/USAGE-MIGRATION.md +3 -3
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +9 -9
- package/bin/restore.mjs +6 -2
- package/bin/setup.mjs +6 -0
- package/package.json +3 -1
- package/src/acp.mjs +8 -10
- package/src/clients.mjs +13 -2
- package/src/hub.mjs +99 -33
- package/src/line-decoder.mjs +41 -0
- package/src/locks.mjs +6 -1
- package/src/mcp.mjs +3 -6
- package/src/tools.mjs +5 -2
- package/src/workspace.mjs +24 -8
package/src/hub.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
} from './config.mjs';
|
|
52
52
|
import { AcpProcess } from './acp.mjs';
|
|
53
53
|
import { readConversationMeta } from './titles.mjs';
|
|
54
|
-
import { registerWorkspace,
|
|
54
|
+
import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
|
|
55
55
|
import {
|
|
56
56
|
LOCKS_DIR,
|
|
57
57
|
classifyHolder,
|
|
@@ -134,7 +134,7 @@ function isForgotten(id) {
|
|
|
134
134
|
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
135
135
|
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
136
136
|
*/
|
|
137
|
-
async function pruneStaleTombstones() {
|
|
137
|
+
async function pruneStaleTombstones(log = NOOP_LOG) {
|
|
138
138
|
let removed = 0;
|
|
139
139
|
try {
|
|
140
140
|
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
@@ -142,7 +142,13 @@ async function pruneStaleTombstones() {
|
|
|
142
142
|
if (!f.endsWith('.json')) continue;
|
|
143
143
|
const id = f.slice(0, -5);
|
|
144
144
|
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
145
|
-
|
|
145
|
+
// ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
|
|
146
|
+
// 否则一次慢盘/挂载异常就会让 forget 失效。
|
|
147
|
+
const found = await findSessionHeaderStatus(id);
|
|
148
|
+
if (found.status === 'found' || found.status === 'error') {
|
|
149
|
+
if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
146
152
|
try {
|
|
147
153
|
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
148
154
|
removed++;
|
|
@@ -247,8 +253,9 @@ async function applySessionConfig(proc, sessionId, cfg) {
|
|
|
247
253
|
*/
|
|
248
254
|
export function sanitizeResultForSentinel(result) {
|
|
249
255
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
250
|
-
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
251
256
|
const { thinking, ...rest } = result;
|
|
257
|
+
// ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
|
|
258
|
+
// answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
|
|
252
259
|
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
253
260
|
for (const key of ['answer', 'error', 'title']) {
|
|
254
261
|
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
@@ -264,7 +271,9 @@ export function sanitizeResultForSentinel(result) {
|
|
|
264
271
|
});
|
|
265
272
|
}
|
|
266
273
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
267
|
-
|
|
274
|
+
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
275
|
+
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
276
|
+
return out;
|
|
268
277
|
}
|
|
269
278
|
|
|
270
279
|
export class Conversation {
|
|
@@ -462,9 +471,10 @@ export class Conversation {
|
|
|
462
471
|
// 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
|
|
463
472
|
// 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
|
|
464
473
|
// 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
|
|
465
|
-
await proc.stop().catch(() =>
|
|
466
|
-
// 只删**自己的**登记(removeMarker 会校验 token
|
|
467
|
-
|
|
474
|
+
const stopped = await proc.stop().catch(() => false);
|
|
475
|
+
// 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
|
|
476
|
+
// 让 classifyHolder/takeover 仍能归因。
|
|
477
|
+
if (stopped) removeMarker(this.id);
|
|
468
478
|
// 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
|
|
469
479
|
// ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
|
|
470
480
|
if (isLeaseError(e.message)) {
|
|
@@ -500,11 +510,14 @@ export class Conversation {
|
|
|
500
510
|
*
|
|
501
511
|
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
502
512
|
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
503
|
-
* read-only
|
|
504
|
-
*
|
|
513
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
|
|
514
|
+
* 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
|
|
515
|
+
* (danger sandbox 下根本不会产生审批请求)。
|
|
505
516
|
*/
|
|
506
|
-
async verifyPermissionRecorded({ attempts
|
|
507
|
-
|
|
517
|
+
async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
|
|
518
|
+
// read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
|
|
519
|
+
const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
|
|
520
|
+
for (let i = 0; i < tries; i++) {
|
|
508
521
|
const meta = readConversationMeta(this.id);
|
|
509
522
|
const recorded = meta?.permissions?.preset;
|
|
510
523
|
if (recorded) {
|
|
@@ -519,11 +532,18 @@ export class Conversation {
|
|
|
519
532
|
this.permissionVerified = true;
|
|
520
533
|
return recorded;
|
|
521
534
|
}
|
|
522
|
-
if (i <
|
|
535
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
523
536
|
}
|
|
524
537
|
this.permissionVerified = false;
|
|
538
|
+
if (this.permission === 'read-only') {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
|
|
541
|
+
' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
|
|
542
|
+
' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
|
|
543
|
+
);
|
|
544
|
+
}
|
|
525
545
|
this.#hub.log.info(
|
|
526
|
-
`[conv ${this.id}]
|
|
546
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
|
|
527
547
|
);
|
|
528
548
|
return null;
|
|
529
549
|
}
|
|
@@ -565,7 +585,8 @@ export class Conversation {
|
|
|
565
585
|
report.holder_kind = holder.kind;
|
|
566
586
|
report.holder = holder.marker;
|
|
567
587
|
// 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
|
|
568
|
-
const
|
|
588
|
+
const markerChild = holder.marker?.child_pid ?? null;
|
|
589
|
+
const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
|
|
569
590
|
|
|
570
591
|
if (holder.kind === 'none' || !holder.childAlive) {
|
|
571
592
|
report.message =
|
|
@@ -857,7 +878,7 @@ export class Conversation {
|
|
|
857
878
|
},
|
|
858
879
|
tools_used: [...c.tools.values()],
|
|
859
880
|
usage: c.usage,
|
|
860
|
-
workspace_changed: before
|
|
881
|
+
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
861
882
|
diff_stat: after,
|
|
862
883
|
elapsed_ms: now() - t0,
|
|
863
884
|
};
|
|
@@ -1092,11 +1113,12 @@ export class Conversation {
|
|
|
1092
1113
|
async close({ forget = false } = {}) {
|
|
1093
1114
|
const proc = this.proc;
|
|
1094
1115
|
this.proc = null;
|
|
1116
|
+
let stopped = true;
|
|
1095
1117
|
if (proc?.alive) {
|
|
1096
1118
|
try {
|
|
1097
1119
|
await proc.request('session/close', { sessionId: this.id }, 10_000);
|
|
1098
1120
|
} catch {}
|
|
1099
|
-
await proc.stop();
|
|
1121
|
+
stopped = await proc.stop().catch(() => false);
|
|
1100
1122
|
}
|
|
1101
1123
|
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1102
1124
|
for (const [, p] of this.pendingApprovals) {
|
|
@@ -1105,18 +1127,32 @@ export class Conversation {
|
|
|
1105
1127
|
} catch {}
|
|
1106
1128
|
}
|
|
1107
1129
|
this.pendingApprovals.clear();
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1130
|
+
// 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
|
|
1131
|
+
// 还能把这个仍握着写锁的孤儿归因出来。
|
|
1132
|
+
if (stopped) removeMarker(this.id);
|
|
1133
|
+
else {
|
|
1134
|
+
this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
|
|
1135
|
+
// 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
|
|
1136
|
+
// 仍握着写锁,之后连 takeover 都无法归因/接管。
|
|
1137
|
+
if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
|
|
1138
|
+
}
|
|
1110
1139
|
if (forget) {
|
|
1111
1140
|
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1112
1141
|
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1113
1142
|
writeTombstone(this.id);
|
|
1114
1143
|
this.#hub.remove(this.id);
|
|
1115
1144
|
} else this.#hub.save();
|
|
1116
|
-
return { closed:
|
|
1145
|
+
return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
|
|
1117
1146
|
}
|
|
1118
1147
|
}
|
|
1119
1148
|
|
|
1149
|
+
/** 两次 git status 快照是否表示同一组工作区变更。 */
|
|
1150
|
+
export function sameWorkspaceSnapshot(a, b) {
|
|
1151
|
+
if (!a && !b) return true;
|
|
1152
|
+
if (!a || !b) return false;
|
|
1153
|
+
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1120
1156
|
/** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
|
|
1121
1157
|
export function gitStatus(cwd) {
|
|
1122
1158
|
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
@@ -1207,6 +1243,8 @@ export class Hub {
|
|
|
1207
1243
|
}
|
|
1208
1244
|
|
|
1209
1245
|
#reaper;
|
|
1246
|
+
/** 同一 id 的磁盘接管 in-flight Promise,防止并发请求各自构造一个 Conversation。 */
|
|
1247
|
+
#adopting = new Map();
|
|
1210
1248
|
/** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
|
|
1211
1249
|
#probeCache = null;
|
|
1212
1250
|
|
|
@@ -1235,7 +1273,7 @@ export class Hub {
|
|
|
1235
1273
|
}
|
|
1236
1274
|
}
|
|
1237
1275
|
|
|
1238
|
-
const stale = await pruneStaleTombstones();
|
|
1276
|
+
const stale = await pruneStaleTombstones(this.log);
|
|
1239
1277
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1240
1278
|
if (!existsSync(STATE_FILE)) return;
|
|
1241
1279
|
try {
|
|
@@ -1390,10 +1428,26 @@ export class Hub {
|
|
|
1390
1428
|
this.adoptNewFromDisk();
|
|
1391
1429
|
c = this.conversations.get(id);
|
|
1392
1430
|
}
|
|
1431
|
+
if (!c) {
|
|
1432
|
+
// 同一个磁盘态会话的并发查询共用一次 adopt,避免各自构造 Conversation。
|
|
1433
|
+
const inflight = this.#adopting.get(id);
|
|
1434
|
+
if (inflight) {
|
|
1435
|
+
await inflight;
|
|
1436
|
+
c = this.conversations.get(id);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1393
1439
|
if (!c) {
|
|
1394
1440
|
// 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
|
|
1395
1441
|
// 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
|
|
1396
|
-
|
|
1442
|
+
let release;
|
|
1443
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
1444
|
+
this.#adopting.set(id, gate);
|
|
1445
|
+
try {
|
|
1446
|
+
const found = await findSessionHeaderStatus(id);
|
|
1447
|
+
if (found.status === 'error') {
|
|
1448
|
+
throw new Error(`无法读取会话 ${id} 的会话头:${found.reason}`);
|
|
1449
|
+
}
|
|
1450
|
+
const header = found.header;
|
|
1397
1451
|
if (header?.cwd) {
|
|
1398
1452
|
// ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
|
|
1399
1453
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
@@ -1442,6 +1496,10 @@ export class Hub {
|
|
|
1442
1496
|
this.save();
|
|
1443
1497
|
this.log.info(`[hub] 从会话存储吸收了 ${id}(注册表里没有,但磁盘上有;权限档沿用 ${permission})`);
|
|
1444
1498
|
}
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.#adopting.delete(id);
|
|
1501
|
+
release();
|
|
1502
|
+
}
|
|
1445
1503
|
}
|
|
1446
1504
|
if (!c) {
|
|
1447
1505
|
throw new Error(
|
|
@@ -1593,8 +1651,8 @@ export class Hub {
|
|
|
1593
1651
|
// 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
|
|
1594
1652
|
// 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
|
|
1595
1653
|
// 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
|
|
1596
|
-
|
|
1597
|
-
|
|
1654
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1655
|
+
if (createdSessionId && stopped) removeMarker(createdSessionId);
|
|
1598
1656
|
throw e;
|
|
1599
1657
|
}
|
|
1600
1658
|
}
|
|
@@ -1732,8 +1790,8 @@ export class Hub {
|
|
|
1732
1790
|
// ★ 顺序要紧:**先确认子进程真的退出(写锁真的释放),再删登记**。
|
|
1733
1791
|
// 反过来的话,一旦它没死透,锁的持有者就变成"无登记的未知进程" ——
|
|
1734
1792
|
// 既报不清是谁,连 dsh_takeover 都会拒绝(判成 none)。
|
|
1735
|
-
await proc.stop().catch(() =>
|
|
1736
|
-
removeMarker(c.id);
|
|
1793
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1794
|
+
if (stopped) removeMarker(c.id);
|
|
1737
1795
|
}
|
|
1738
1796
|
} catch (e) {
|
|
1739
1797
|
this.log.info(`[hub] 回收周期异常(已忽略,下个周期继续): ${e.message}`);
|
|
@@ -1751,6 +1809,7 @@ export class Hub {
|
|
|
1751
1809
|
async releaseAll({ force = false } = {}) {
|
|
1752
1810
|
const released = [];
|
|
1753
1811
|
const skippedBusy = [];
|
|
1812
|
+
const failed = [];
|
|
1754
1813
|
for (const c of this.conversations.values()) {
|
|
1755
1814
|
if (!c.proc?.alive) continue;
|
|
1756
1815
|
// ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
|
|
@@ -1761,12 +1820,18 @@ export class Hub {
|
|
|
1761
1820
|
}
|
|
1762
1821
|
const proc = c.proc;
|
|
1763
1822
|
c.proc = null;
|
|
1764
|
-
await proc.stop().catch(() =>
|
|
1765
|
-
|
|
1766
|
-
|
|
1823
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1824
|
+
if (stopped) {
|
|
1825
|
+
removeMarker(c.id);
|
|
1826
|
+
released.push(c.id);
|
|
1827
|
+
} else {
|
|
1828
|
+
// 进程没确认退出:保留登记,让 takeover 还能归因这个孤儿。
|
|
1829
|
+
failed.push(c.id);
|
|
1830
|
+
this.log.info(`[hub] 释放 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
1831
|
+
}
|
|
1767
1832
|
}
|
|
1768
1833
|
this.save();
|
|
1769
|
-
return { released: released.length, conversations: released, skipped_busy: skippedBusy };
|
|
1834
|
+
return { released: released.length, conversations: released, skipped_busy: skippedBusy, failed };
|
|
1770
1835
|
}
|
|
1771
1836
|
|
|
1772
1837
|
async shutdown() {
|
|
@@ -1775,9 +1840,10 @@ export class Hub {
|
|
|
1775
1840
|
for (const c of this.conversations.values()) {
|
|
1776
1841
|
const proc = c.proc;
|
|
1777
1842
|
c.proc = null;
|
|
1778
|
-
await proc?.stop().catch(() =>
|
|
1779
|
-
//
|
|
1780
|
-
removeMarker(c.id);
|
|
1843
|
+
const stopped = await proc?.stop().catch(() => false);
|
|
1844
|
+
// 只有确认子进程退出、锁真的释放后才删登记;否则留给下个实例归因。
|
|
1845
|
+
if (stopped) removeMarker(c.id);
|
|
1846
|
+
else this.log.info(`[hub] 关闭 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
1781
1847
|
}
|
|
1782
1848
|
this.save();
|
|
1783
1849
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行式 UTF-8 解码器。
|
|
3
|
+
*
|
|
4
|
+
* Node 的 stream 回调给的是 Buffer;直接对每个 chunk 调 `toString('utf8')`
|
|
5
|
+
* 会在 chunk 边界把一个多字节字符(中文/emoji)截成 U+FFFD,JSON 仍然合法,
|
|
6
|
+
* 但内容被静默改写。StringDecoder 会跨 chunk 保留未完成的多字节序列。
|
|
7
|
+
*
|
|
8
|
+
* 同时负责按 `\n` 切行;行内保留 `\r`,由调用方按需 trim。
|
|
9
|
+
*/
|
|
10
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
11
|
+
|
|
12
|
+
export class LineDecoder {
|
|
13
|
+
#decoder = new StringDecoder('utf8');
|
|
14
|
+
#buf = '';
|
|
15
|
+
|
|
16
|
+
/** 喂一个 Buffer;返回本轮切出的完整行(不含换行符)。 */
|
|
17
|
+
push(chunk) {
|
|
18
|
+
this.#buf += this.#decoder.write(chunk);
|
|
19
|
+
return this.#drain(false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** EOF:把 decoder 里残留的半个字符和最后一行一起吐出来。 */
|
|
23
|
+
end() {
|
|
24
|
+
this.#buf += this.#decoder.end();
|
|
25
|
+
return this.#drain(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#drain(flush) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
let i;
|
|
31
|
+
while ((i = this.#buf.indexOf('\n')) !== -1) {
|
|
32
|
+
lines.push(this.#buf.slice(0, i));
|
|
33
|
+
this.#buf = this.#buf.slice(i + 1);
|
|
34
|
+
}
|
|
35
|
+
if (flush && this.#buf) {
|
|
36
|
+
lines.push(this.#buf);
|
|
37
|
+
this.#buf = '';
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/locks.mjs
CHANGED
|
@@ -106,7 +106,12 @@ export function writeMarker(conversationId, { childPid, cwd }) {
|
|
|
106
106
|
mcp_token: MCP_TOKEN,
|
|
107
107
|
child_pid: childPid ?? null,
|
|
108
108
|
cwd: cwd ?? null,
|
|
109
|
-
|
|
109
|
+
// 只有同一个子进程才沿用旧启动时间;子进程换了(PID 变化)必须重置,
|
|
110
|
+
// 否则 PID 复用保护会把新进程误判成“复用旧 PID”而拒绝 takeover。
|
|
111
|
+
spawned_at:
|
|
112
|
+
prev && prev.child_pid === childPid && Number.isFinite(prev.spawned_at)
|
|
113
|
+
? prev.spawned_at
|
|
114
|
+
: now,
|
|
110
115
|
heartbeat_at: now,
|
|
111
116
|
});
|
|
112
117
|
return true;
|
package/src/mcp.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { createHandlers, TOOL_DEFS } from './tools.mjs';
|
|
12
12
|
import { DEFAULT_PERMISSION } from './config.mjs';
|
|
13
|
+
import { LineDecoder } from './line-decoder.mjs';
|
|
13
14
|
|
|
14
15
|
const SERVER_NAME = 'dsh-any-agent';
|
|
15
16
|
/**
|
|
@@ -117,7 +118,7 @@ function fileURLToPathSafe(uri) {
|
|
|
117
118
|
|
|
118
119
|
export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHandlers(hub), log }) {
|
|
119
120
|
const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
120
|
-
|
|
121
|
+
const lineDecoder = new LineDecoder();
|
|
121
122
|
let initialized = false;
|
|
122
123
|
let clientCapabilities = {};
|
|
123
124
|
let rootsSeq = 0;
|
|
@@ -251,11 +252,7 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
function onData(chunk) {
|
|
254
|
-
|
|
255
|
-
let idx;
|
|
256
|
-
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
257
|
-
const line = buffer.slice(0, idx);
|
|
258
|
-
buffer = buffer.slice(idx + 1);
|
|
255
|
+
for (const line of lineDecoder.push(chunk)) {
|
|
259
256
|
if (!line.trim()) continue;
|
|
260
257
|
let msg;
|
|
261
258
|
try {
|
package/src/tools.mjs
CHANGED
|
@@ -292,7 +292,7 @@ function renderSendResult(r) {
|
|
|
292
292
|
}
|
|
293
293
|
const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
|
|
294
294
|
if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
|
|
295
|
-
if (r.workspace_changed) meta.push(
|
|
295
|
+
if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
|
|
296
296
|
lines.push('');
|
|
297
297
|
lines.push(`[${meta.join(' · ')}]`);
|
|
298
298
|
|
|
@@ -611,12 +611,15 @@ export function createHandlers(hub, opts = {}) {
|
|
|
611
611
|
if (args.all === true) {
|
|
612
612
|
const r = await hub.releaseAll({ force: args.force === true });
|
|
613
613
|
return {
|
|
614
|
-
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
|
|
614
|
+
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy, failed: r.failed },
|
|
615
615
|
text:
|
|
616
616
|
`已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
|
|
617
617
|
(r.skipped_busy.length
|
|
618
618
|
? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
|
|
619
619
|
: '') +
|
|
620
|
+
(r.failed.length
|
|
621
|
+
? `\n⚠️ ${r.failed.length} 个会话的进程未确认退出,写锁登记已保留;可稍后用 dsh_status/dsh_takeover 处理。`
|
|
622
|
+
: '') +
|
|
620
623
|
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
621
624
|
};
|
|
622
625
|
}
|
package/src/workspace.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { copyFileSync, createReadStream, readFileSync, writeFileSync, existsSync
|
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
20
|
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
22
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
22
23
|
import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
|
|
23
24
|
import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
|
|
24
25
|
|
|
@@ -245,6 +246,7 @@ export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes =
|
|
|
245
246
|
let settled = false;
|
|
246
247
|
let buf = '';
|
|
247
248
|
let timer = null;
|
|
249
|
+
const decoder = new StringDecoder('utf8');
|
|
248
250
|
const rs = createReadStream(file);
|
|
249
251
|
const zs = createZstdDecompress();
|
|
250
252
|
const finish = (value) => {
|
|
@@ -268,12 +270,15 @@ export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes =
|
|
|
268
270
|
rs.on('error', () => finish(null));
|
|
269
271
|
zs.on('error', () => finish(null));
|
|
270
272
|
zs.on('data', (chunk) => {
|
|
271
|
-
|
|
273
|
+
// 用 StringDecoder 处理跨 chunk 的多字节 UTF-8(中文/emoji 路径),
|
|
274
|
+
// 避免 chunk.toString() 把字符截成乱码。
|
|
275
|
+
buf += decoder.write(chunk);
|
|
272
276
|
const nl = buf.search(/\r?\n/);
|
|
273
277
|
if (nl >= 0) parseLine(buf.slice(0, nl));
|
|
274
278
|
else if (buf.length > maxHeaderBytes) finish(null);
|
|
275
279
|
});
|
|
276
280
|
zs.on('end', () => {
|
|
281
|
+
buf += decoder.end();
|
|
277
282
|
const line = buf.split(/\r?\n/).find(Boolean);
|
|
278
283
|
if (line) parseLine(line);
|
|
279
284
|
else finish(null);
|
|
@@ -324,22 +329,33 @@ export function scanSessions() {
|
|
|
324
329
|
*
|
|
325
330
|
* 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
|
|
326
331
|
*/
|
|
327
|
-
export async function
|
|
328
|
-
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId))
|
|
329
|
-
|
|
332
|
+
export async function findSessionHeaderStatus(sessionId) {
|
|
333
|
+
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) {
|
|
334
|
+
return { status: 'missing', header: null, reason: 'id 形状非法' };
|
|
335
|
+
}
|
|
336
|
+
if (!existsSync(SESSIONS_ROOT)) return { status: 'missing', header: null, reason: '会话目录不存在' };
|
|
330
337
|
let buckets;
|
|
331
338
|
try {
|
|
332
339
|
buckets = readdirSync(SESSIONS_ROOT);
|
|
333
|
-
} catch {
|
|
334
|
-
return null;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return { status: 'error', header: null, reason: `会话目录不可读: ${e.message}` };
|
|
335
342
|
}
|
|
336
343
|
for (const bucket of buckets) {
|
|
337
344
|
const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
|
|
338
345
|
if (!existsSync(f)) continue;
|
|
339
346
|
const header = await readHeaderAsync(f);
|
|
340
|
-
if (header?.id === sessionId) return header;
|
|
347
|
+
if (header?.id === sessionId) return { status: 'found', header, file: f };
|
|
348
|
+
if (header === null) {
|
|
349
|
+
// 文件存在但头读不出来(超时/损坏/IO):不能当成“会话不存在”,
|
|
350
|
+
// 否则 forget 墓碑会被误删、按 id 查询也会误报未知会话。
|
|
351
|
+
return { status: 'error', header: null, reason: `无法读取会话头: ${f}`, file: f };
|
|
352
|
+
}
|
|
341
353
|
}
|
|
342
|
-
return null;
|
|
354
|
+
return { status: 'missing', header: null, reason: '会话文件不存在' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function findSessionHeader(sessionId) {
|
|
358
|
+
return (await findSessionHeaderStatus(sessionId)).header;
|
|
343
359
|
}
|
|
344
360
|
|
|
345
361
|
/**
|