@roaming-ai/dsh-group-chat 0.3.0 → 0.3.2

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.
Files changed (80) hide show
  1. package/README.md +93 -28
  2. package/lib/client.js +295 -326
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +234 -221
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/NavPanel.d.ts +9 -11
  7. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  8. package/lib/types/client/components/ToolRow.d.ts +2 -2
  9. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  10. package/lib/types/client/lib/model.d.ts +1 -7
  11. package/lib/types/client/utils/utils.d.ts +4 -4
  12. package/lib/types/core/json.d.ts +6 -0
  13. package/lib/types/core/types.d.ts +59 -64
  14. package/lib/types/host/engine/defaults.d.ts +13 -0
  15. package/lib/types/host/persistence/persistence.d.ts +2 -4
  16. package/lib/types/host/persistence/store.d.ts +5 -1
  17. package/lib/types/host/service.d.ts +2 -2
  18. package/lib/types/host/state.d.ts +1 -0
  19. package/lib/types/host/tools/tools.d.ts +2 -1
  20. package/lib/types/index.d.ts +3 -0
  21. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  22. package/package.json +4 -1
  23. package/src/client/GroupChatPanel.tsx +9 -56
  24. package/src/client/components/AsidePanel.tsx +40 -15
  25. package/src/client/components/ChatPanel.tsx +1 -1
  26. package/src/client/components/Composer.tsx +2 -2
  27. package/src/client/components/ConstraintList.tsx +1 -6
  28. package/src/client/components/MessageFlow.tsx +2 -1
  29. package/src/client/components/NavPanel.tsx +96 -78
  30. package/src/client/components/RoleDrawer.tsx +29 -7
  31. package/src/client/components/ToolRow.tsx +2 -2
  32. package/src/client/hooks/useComposer.ts +14 -53
  33. package/src/client/hooks/useGroupChatState.ts +2 -13
  34. package/src/client/lib/model.ts +1 -14
  35. package/src/client/lib/styles.ts +6 -2
  36. package/src/client/utils/utils.ts +6 -6
  37. package/src/core/constraints.ts +6 -12
  38. package/src/core/errors.ts +2 -12
  39. package/src/core/json.ts +17 -0
  40. package/src/core/types.ts +29 -20
  41. package/src/host/api/actions.ts +14 -13
  42. package/src/host/engine/conversation.ts +50 -51
  43. package/src/host/engine/defaults.ts +28 -0
  44. package/src/host/engine/fold.ts +2 -17
  45. package/src/host/engine/retitle.ts +9 -31
  46. package/src/host/materials/materials.ts +2 -0
  47. package/src/host/persistence/persistence.ts +57 -34
  48. package/src/host/persistence/store.ts +5 -1
  49. package/src/host/service.ts +4 -4
  50. package/src/host/state.ts +5 -2
  51. package/src/host/tools/tools.ts +42 -48
  52. package/src/index.ts +7 -5
  53. package/src/shared/file-mention-grammar.test.ts +1 -38
  54. package/src/shared/file-mention-grammar.ts +7 -33
  55. package/lib/types/client/Bubble.d.ts +0 -10
  56. package/lib/types/client/Glyph.d.ts +0 -12
  57. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  58. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  59. package/lib/types/client/PermissionSelect.d.ts +0 -13
  60. package/lib/types/client/RoleDrawer.d.ts +0 -17
  61. package/lib/types/client/ThinkRow.d.ts +0 -9
  62. package/lib/types/client/ToolRow.d.ts +0 -9
  63. package/lib/types/client/api.d.ts +0 -8
  64. package/lib/types/client/components.d.ts +0 -22
  65. package/lib/types/client/drawer.d.ts +0 -17
  66. package/lib/types/client/glyph.d.ts +0 -12
  67. package/lib/types/client/model.d.ts +0 -78
  68. package/lib/types/client/panel.d.ts +0 -10
  69. package/lib/types/client/settings.d.ts +0 -15
  70. package/lib/types/client/styles.d.ts +0 -10
  71. package/lib/types/client/ui.d.ts +0 -17
  72. package/lib/types/client/utils.d.ts +0 -22
  73. package/lib/types/host/actions.d.ts +0 -34
  74. package/lib/types/host/conversation.d.ts +0 -25
  75. package/lib/types/host/http.d.ts +0 -16
  76. package/lib/types/host/materials.d.ts +0 -32
  77. package/lib/types/host/persistence.d.ts +0 -31
  78. package/lib/types/host/routes.d.ts +0 -11
  79. package/lib/types/host/store.d.ts +0 -65
  80. package/lib/types/host/tools.d.ts +0 -28
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, sep } from "node:path";
6
6
  import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, WorkspaceFileSearch } from "@deepseek-ai/dsh-file-reference-local";
7
- import { spawn } from "node:child_process";
7
+ import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
8
8
  //#region src/mount-once.ts
9
9
  /**
10
10
  * Host single-instance guard shared by the plugin family. The family bundle
@@ -46,6 +46,59 @@ function mountOnce(packageName, fn) {
46
46
  });
47
47
  }
48
48
  //#endregion
49
+ //#region src/core/json.ts
50
+ /** 消息 → 落盘 JSON(可选字段无则省略;兼容保留 error)。 */
51
+ function messageJson(m) {
52
+ if (!m || typeof m.id !== "string") return null;
53
+ const o = {
54
+ id: m.id,
55
+ speaker: m.speaker,
56
+ text: String(m.text || ""),
57
+ ts: typeof m.ts === "number" ? m.ts : Date.now(),
58
+ seq: typeof m.seq === "number" ? m.seq : 0
59
+ };
60
+ if (m.model !== void 0) o.model = m.model;
61
+ if (m.reasoning !== void 0) o.reasoning = m.reasoning;
62
+ if (m.reasoningFull !== void 0) o.reasoningFull = m.reasoningFull;
63
+ if (m.thinkingSummary !== void 0) o.thinkingSummary = m.thinkingSummary;
64
+ if (m.error !== void 0) o.error = m.error;
65
+ if (typeof m.failedRoleId === "string" && m.failedRoleId) o.failedRoleId = m.failedRoleId;
66
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls;
67
+ return o;
68
+ }
69
+ /** 角色 → 落盘 JSON(groupId 由目录归属,不再写入)。 */
70
+ function roleJson(r) {
71
+ const o = {
72
+ id: r.id,
73
+ name: String(r.name || "成员"),
74
+ persona: String(r.persona || ""),
75
+ provider: String(r.provider || ""),
76
+ model: String(r.model || ""),
77
+ enabled: r.enabled !== false,
78
+ thinking: r.thinking === true
79
+ };
80
+ if (r.color) o.color = r.color;
81
+ if (typeof r.temperature === "number" && !Number.isNaN(r.temperature)) o.temperature = r.temperature;
82
+ if (typeof r.reasoningEffort === "string" && r.reasoningEffort && r.reasoningEffort !== "default") o.reasoningEffort = r.reasoningEffort;
83
+ return o;
84
+ }
85
+ /**
86
+ * 宽容 JSON 提取(模型输出/供应商错误原文的统一解析入口):
87
+ * 取首个 { 至末个 } 的片段解析,仅接受普通对象;失败返回 null。
88
+ * 代码围栏剥离由调用方按需先行处理(供应商错误原文不剥)。
89
+ */
90
+ function looseJson(raw) {
91
+ const start = raw.indexOf("{");
92
+ const end = raw.lastIndexOf("}");
93
+ if (start < 0 || end <= start) return null;
94
+ try {
95
+ const parsed = JSON.parse(raw.slice(start, end + 1));
96
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ //#endregion
49
102
  //#region src/core/errors.ts
50
103
  const PREFIX = "模型输出异常终止: ";
51
104
  const LEGACY_ROLE = /^角色「([^」]+)」发言失败[::]\s*/;
@@ -176,6 +229,7 @@ function createHostState(ctx) {
176
229
  ctx,
177
230
  llm: ctx.llm,
178
231
  fs: ctx.fs,
232
+ shell: ctx.shell,
179
233
  groups: /* @__PURE__ */ new Map(),
180
234
  sessions: /* @__PURE__ */ new Map(),
181
235
  roles: /* @__PURE__ */ new Map(),
@@ -190,7 +244,7 @@ function createHostState(ctx) {
190
244
  queue: [],
191
245
  pendingConfirm: null,
192
246
  confirmSignal: null,
193
- childProc: null,
247
+ commandAbort: null,
194
248
  finished: null,
195
249
  replaceMessageId: null
196
250
  },
@@ -226,7 +280,7 @@ function createActions(core, deps) {
226
280
  const { llm, groups, sessions, roles, messages, run } = core;
227
281
  const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps;
228
282
  const mutate = (args) => {
229
- const op = args && args.op;
283
+ const op = args.op;
230
284
  if (op === "createGroup") {
231
285
  const g = {
232
286
  id: core.nid("grp"),
@@ -1117,18 +1171,11 @@ function sanitizeConstraints(raw) {
1117
1171
  * 解析折叠模型输出。null = 解析失败(水位不推);[] = 无新结论(水位推、备忘不动)。
1118
1172
  */
1119
1173
  function parseConstraints(raw) {
1120
- const body = raw.replace(/```(?:json)?/g, "");
1121
- const l = body.indexOf("{");
1122
- const r = body.lastIndexOf("}");
1123
- if (l < 0 || r <= l) return null;
1124
- try {
1125
- const o = JSON.parse(body.slice(l, r + 1));
1126
- if (!Object.prototype.hasOwnProperty.call(o, "constraints")) return null;
1127
- if (!Array.isArray(o.constraints)) return null;
1128
- return sanitizeConstraints(o.constraints);
1129
- } catch {
1130
- return null;
1131
- }
1174
+ const o = looseJson(raw.replace(/```(?:json)?/g, ""));
1175
+ if (o === null) return null;
1176
+ if (!Object.prototype.hasOwnProperty.call(o, "constraints")) return null;
1177
+ if (!Array.isArray(o.constraints)) return null;
1178
+ return sanitizeConstraints(o.constraints);
1132
1179
  }
1133
1180
  /** 本批无用户消息时,新的已定/否决降为未决。 */
1134
1181
  function downgradeWithoutUser(list, hasUser) {
@@ -1144,9 +1191,13 @@ function constraintBlock(list) {
1144
1191
  return "\n# 已确认约束\n" + list.map((c) => "- " + KIND_LABEL[c.kind] + ":" + c.text).join("\n");
1145
1192
  }
1146
1193
  //#endregion
1147
- //#region src/host/engine/fold.ts
1148
- /** DSH 默认模型(与 retitle 同一读取面;缺位返回 null)。 */
1149
- const defaultModel$1 = (core) => {
1194
+ //#region src/host/engine/defaults.ts
1195
+ /**
1196
+ * 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
1197
+ * @module dsh-group-chat/host/engine/defaults
1198
+ */
1199
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
1200
+ const defaultModel = (core) => {
1150
1201
  try {
1151
1202
  const svc = core.ctx.reflect.get("agentDefaultModel");
1152
1203
  const sel = svc ? svc.currentSelection() : null;
@@ -1158,6 +1209,10 @@ const defaultModel$1 = (core) => {
1158
1209
  return null;
1159
1210
  }
1160
1211
  };
1212
+ /** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
1213
+ const speakerNameOf = (roles, speaker) => speakerLabel(speaker, roles.get(speaker)?.name);
1214
+ //#endregion
1215
+ //#region src/host/engine/fold.ts
1161
1216
  /**
1162
1217
  * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
1163
1218
  * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
@@ -1166,12 +1221,11 @@ function createFold(core, deps) {
1166
1221
  const { llm, messages, roles } = core;
1167
1222
  const { touch, schedulePersist } = deps;
1168
1223
  const folding = /* @__PURE__ */ new Set();
1169
- const nameOf = (speaker) => speakerLabel(speaker, (roles.get(speaker) || { name: void 0 }).name);
1170
1224
  return async (sess) => {
1171
1225
  if (folding.has(sess.id)) return;
1172
1226
  const squeezed = squeezedMessages(messages, sess);
1173
1227
  if (!squeezed.length) return;
1174
- const input = takeFoldBatch(squeezed, (m) => nameOf(m.speaker));
1228
+ const input = takeFoldBatch(squeezed, (m) => speakerNameOf(roles, m.speaker));
1175
1229
  const watermark = squeezedMaxSeq(input.consumed);
1176
1230
  if (watermark <= 0) return;
1177
1231
  const commit = (next) => {
@@ -1186,7 +1240,7 @@ function createFold(core, deps) {
1186
1240
  commit(void 0);
1187
1241
  return;
1188
1242
  }
1189
- const dm = defaultModel$1(core);
1243
+ const dm = defaultModel(core);
1190
1244
  if (!dm) return;
1191
1245
  folding.add(sess.id);
1192
1246
  try {
@@ -1240,47 +1294,26 @@ function createFold(core, deps) {
1240
1294
  //#region src/host/engine/retitle.ts
1241
1295
  /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
1242
1296
  const isPlaceholderName = (name) => name === "新会话" || /^会话 \d+$/.test(name);
1243
- /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
1244
- const defaultModel = (core) => {
1245
- try {
1246
- const svc = core.ctx.reflect.get("agentDefaultModel");
1247
- const sel = svc ? svc.currentSelection() : null;
1248
- return sel && typeof sel.provider === "string" && typeof sel.model === "string" && sel.provider && sel.model ? {
1249
- provider: sel.provider,
1250
- model: sel.model
1251
- } : null;
1252
- } catch {
1253
- return null;
1254
- }
1255
- };
1256
1297
  /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
1257
1298
  const titleTranscript = (core, sess) => {
1258
1299
  const out = [];
1259
1300
  for (const mid of sess.messageIds.slice(-40)) {
1260
1301
  const m = core.messages.get(mid);
1261
1302
  if (!m || m.speaker === "system" || m.error || !m.text) continue;
1262
- const name = m.speaker === "user" ? "用户" : (core.roles.get(m.speaker) || { name: void 0 }).name || "成员";
1263
- out.push("【" + name + "】" + m.text.replace(/\s+/g, " ").slice(0, 500));
1303
+ out.push("" + speakerNameOf(core.roles, m.speaker) + "】" + m.text.replace(/\s+/g, " ").slice(0, 500));
1264
1304
  }
1265
1305
  return out.join("\n");
1266
1306
  };
1267
- /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
1307
+ /** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
1268
1308
  const parseRetitle = (raw) => {
1269
- const body = raw.replace(/```(?:json)?/g, "");
1270
- const l = body.indexOf("{");
1271
- const r = body.lastIndexOf("}");
1272
- if (l < 0 || r <= l) return {};
1273
- try {
1274
- const o = JSON.parse(body.slice(l, r + 1));
1275
- const name = typeof o.name === "string" ? o.name.trim() : "";
1276
- const topic = typeof o.topic === "string" ? o.topic.trim() : "";
1277
- return {
1278
- name: name ? name.slice(0, 24) : void 0,
1279
- topic: topic ? topic.slice(0, 120) : void 0
1280
- };
1281
- } catch {
1282
- return {};
1283
- }
1309
+ const o = looseJson(raw.replace(/```(?:json)?/g, ""));
1310
+ if (!o) return {};
1311
+ const name = typeof o.name === "string" ? o.name.trim() : "";
1312
+ const topic = typeof o.topic === "string" ? o.topic.trim() : "";
1313
+ return {
1314
+ name: name ? name.slice(0, 24) : void 0,
1315
+ topic: topic ? topic.slice(0, 120) : void 0
1316
+ };
1284
1317
  };
1285
1318
  /**
1286
1319
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
@@ -1382,7 +1415,6 @@ function createConversation(core, deps) {
1382
1415
  touch,
1383
1416
  schedulePersist
1384
1417
  });
1385
- const nameOf = (speaker) => speakerLabel(speaker, (roles.get(speaker) || { name: void 0 }).name);
1386
1418
  const appendMessage = (sess, speaker, text, extra) => {
1387
1419
  const msg = {
1388
1420
  id: core.nid("msg"),
@@ -1410,60 +1442,56 @@ function createConversation(core, deps) {
1410
1442
  schedulePersist({ session: sess.id });
1411
1443
  return msg;
1412
1444
  };
1445
+ /** 原地覆盖既有消息(重试槽位);目标不存在或不属于本会话时返回 false,由调用方走追加。 */
1446
+ const overwriteMessage = (sess, replaceId, patch) => {
1447
+ if (!replaceId) return false;
1448
+ const existing = messages.get(replaceId);
1449
+ if (!existing || existing.sessionId !== sess.id) return false;
1450
+ Object.assign(existing, patch, { ts: Date.now() });
1451
+ touch();
1452
+ schedulePersist({ session: sess.id });
1453
+ return true;
1454
+ };
1413
1455
  /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
1414
1456
  const writeFailure = (sess, role, err, replaceId) => {
1415
1457
  const raw = unwrapSpeakFailure(String(err && err.message || err));
1458
+ const model = role.provider + " / " + role.model;
1416
1459
  const extra = {
1417
1460
  error: true,
1418
1461
  failedRoleId: role.id,
1419
- model: role.provider + " / " + role.model
1462
+ model
1420
1463
  };
1421
- if (replaceId) {
1422
- const existing = messages.get(replaceId);
1423
- if (existing && existing.sessionId === sess.id) {
1424
- existing.speaker = role.id;
1425
- existing.text = raw;
1426
- existing.error = true;
1427
- existing.failedRoleId = role.id;
1428
- existing.model = extra.model;
1429
- existing.reasoning = void 0;
1430
- existing.reasoningFull = void 0;
1431
- existing.thinkingSummary = void 0;
1432
- existing.toolCalls = void 0;
1433
- existing.ts = Date.now();
1434
- touch();
1435
- schedulePersist({ session: sess.id });
1436
- return;
1437
- }
1438
- }
1464
+ if (overwriteMessage(sess, replaceId, {
1465
+ speaker: role.id,
1466
+ text: raw,
1467
+ ...extra,
1468
+ reasoning: void 0,
1469
+ reasoningFull: void 0,
1470
+ thinkingSummary: void 0,
1471
+ toolCalls: void 0
1472
+ })) return;
1439
1473
  appendMessage(sess, role.id, raw, extra);
1440
1474
  };
1441
1475
  /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
1442
1476
  const writeSuccess = (sess, role, out, replaceId) => {
1443
- const extra = {
1444
- model: role.provider + " / " + role.model,
1477
+ const model = role.provider + " / " + role.model;
1478
+ const toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : void 0;
1479
+ if (overwriteMessage(sess, replaceId, {
1480
+ speaker: role.id,
1481
+ text: out.text,
1482
+ error: void 0,
1483
+ failedRoleId: void 0,
1484
+ model,
1485
+ reasoning: out.reasoning,
1486
+ reasoningFull: void 0,
1487
+ thinkingSummary: void 0,
1488
+ toolCalls
1489
+ })) return;
1490
+ appendMessage(sess, role.id, out.text, {
1491
+ model,
1445
1492
  reasoning: out.reasoning,
1446
1493
  toolCalls: out.toolCalls
1447
- };
1448
- if (replaceId) {
1449
- const existing = messages.get(replaceId);
1450
- if (existing && existing.sessionId === sess.id) {
1451
- existing.speaker = role.id;
1452
- existing.text = out.text;
1453
- existing.error = void 0;
1454
- existing.failedRoleId = void 0;
1455
- existing.model = extra.model;
1456
- existing.reasoning = out.reasoning;
1457
- existing.reasoningFull = void 0;
1458
- existing.thinkingSummary = void 0;
1459
- existing.toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : void 0;
1460
- existing.ts = Date.now();
1461
- touch();
1462
- schedulePersist({ session: sess.id });
1463
- return;
1464
- }
1465
- }
1466
- appendMessage(sess, role.id, out.text, extra);
1494
+ });
1467
1495
  };
1468
1496
  /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
1469
1497
  const transcriptBlock = (sess, skipId) => {
@@ -1471,10 +1499,10 @@ function createConversation(core, deps) {
1471
1499
  for (const mid of prefixIds(sess.messageIds, skipId).slice(-40)) {
1472
1500
  const m = messages.get(mid);
1473
1501
  if (!m || m.id === skipId || m.error) continue;
1474
- out.push(formatTranscriptLine(m, nameOf(m.speaker)));
1502
+ out.push(formatTranscriptLine(m, speakerNameOf(roles, m.speaker)));
1475
1503
  }
1476
1504
  const live = out.join("\n\n");
1477
- const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => nameOf(m.speaker));
1505
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => speakerNameOf(roles, m.speaker));
1478
1506
  if (!temp) return live;
1479
1507
  if (!live) return temp;
1480
1508
  return temp + "\n\n" + live;
@@ -1671,6 +1699,7 @@ function createConversation(core, deps) {
1671
1699
  });
1672
1700
  for (const tc of round.tcs) {
1673
1701
  if (run.stopping) break;
1702
+ if (wsRoot === null) break;
1674
1703
  const res = await tools.executeTool(g, wsRoot, tc);
1675
1704
  const output = String(res.output || "");
1676
1705
  toolCalls.push({
@@ -1718,12 +1747,13 @@ function createConversation(core, deps) {
1718
1747
  };
1719
1748
  };
1720
1749
  const runLoop = async (sess, opts) => {
1721
- const g = groups.get(sess.groupId);
1750
+ const g = groups.get(sess.groupId) ?? null;
1722
1751
  const startCount = sess.messageIds.length;
1723
1752
  let failed = false;
1724
1753
  let replaceId = opts && opts.replaceMessageId;
1725
1754
  run.replaceMessageId = replaceId || null;
1726
1755
  try {
1756
+ if (!g) return;
1727
1757
  while (run.queue.length > 0 && !run.stopping) {
1728
1758
  const roleId = run.queue.shift();
1729
1759
  const role = roles.get(roleId);
@@ -1770,7 +1800,7 @@ function createConversation(core, deps) {
1770
1800
  run.queue = [];
1771
1801
  run.pendingConfirm = null;
1772
1802
  run.confirmSignal = null;
1773
- run.childProc = null;
1803
+ run.commandAbort = null;
1774
1804
  run.stopping = false;
1775
1805
  run.replaceMessageId = null;
1776
1806
  touch();
@@ -2049,43 +2079,6 @@ function createMaterials(core) {
2049
2079
  };
2050
2080
  }
2051
2081
  //#endregion
2052
- //#region src/core/json.ts
2053
- /** 消息 → 落盘 JSON(可选字段无则省略;兼容保留 error)。 */
2054
- function messageJson(m) {
2055
- if (!m || typeof m.id !== "string") return null;
2056
- const o = {
2057
- id: m.id,
2058
- speaker: m.speaker,
2059
- text: String(m.text || ""),
2060
- ts: typeof m.ts === "number" ? m.ts : Date.now(),
2061
- seq: typeof m.seq === "number" ? m.seq : 0
2062
- };
2063
- if (m.model !== void 0) o.model = m.model;
2064
- if (m.reasoning !== void 0) o.reasoning = m.reasoning;
2065
- if (m.reasoningFull !== void 0) o.reasoningFull = m.reasoningFull;
2066
- if (m.thinkingSummary !== void 0) o.thinkingSummary = m.thinkingSummary;
2067
- if (m.error !== void 0) o.error = m.error;
2068
- if (typeof m.failedRoleId === "string" && m.failedRoleId) o.failedRoleId = m.failedRoleId;
2069
- if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls;
2070
- return o;
2071
- }
2072
- /** 角色 → 落盘 JSON(groupId 由目录归属,不再写入)。 */
2073
- function roleJson(r) {
2074
- const o = {
2075
- id: r.id,
2076
- name: String(r.name || "成员"),
2077
- persona: String(r.persona || ""),
2078
- provider: String(r.provider || ""),
2079
- model: String(r.model || ""),
2080
- enabled: r.enabled !== false,
2081
- thinking: r.thinking === true
2082
- };
2083
- if (r.color) o.color = r.color;
2084
- if (typeof r.temperature === "number" && !Number.isNaN(r.temperature)) o.temperature = r.temperature;
2085
- if (typeof r.reasoningEffort === "string" && r.reasoningEffort && r.reasoningEffort !== "default") o.reasoningEffort = r.reasoningEffort;
2086
- return o;
2087
- }
2088
- //#endregion
2089
2082
  //#region src/host/persistence/store.ts
2090
2083
  /**
2091
2084
  * 持久化(PERSISTENCE.md v2.1:会话级文件隔离)。
@@ -2198,7 +2191,11 @@ var Store = class {
2198
2191
  }
2199
2192
  } catch {}
2200
2193
  }
2201
- /** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
2194
+ /**
2195
+ * 同步原子写(tmp+fsync+rename):仅供构造期 v1 迁移(migrateV1)与测试
2196
+ * 直用;运行时 flush 走 persistence.ts 的 writeFileAtomic(异步、wx 独占
2197
+ * 创建 + 随机后缀 + 符号链接安全,无 per-file fsync,对齐 DSH 基座标准)。
2198
+ */
2202
2199
  atomicWrite(file, text) {
2203
2200
  mkdirSync(dirname(file), { recursive: true });
2204
2201
  const tmp = `${file}.tmp-${process.pid}`;
@@ -2441,6 +2438,11 @@ function emptyGroup(id, name) {
2441
2438
  * 锁失败/写失败时降级为内存态运行(console 告警)。
2442
2439
  * @module dsh-group-chat/host/persistence
2443
2440
  */
2441
+ /** writeFileAtomic 权限位:私有数据 0o600、目录 0o700(对齐 Store.atomicWrite)。 */
2442
+ const WRITE_FILE_OPTS = {
2443
+ mode: 384,
2444
+ dirMode: 448
2445
+ };
2444
2446
  /**
2445
2447
  * 创建持久化面:构造即完成 store 初始化 + hydrate(v1 迁移 → 残留清理 →
2446
2448
  * 清单 → 群组数据 → 会话文件;缺文件空重建;补建默认会话/群组立即落盘)。
@@ -2453,11 +2455,15 @@ function createPersistence(core) {
2453
2455
  core.store = null;
2454
2456
  }
2455
2457
  const store = () => core.store;
2456
- const dirtySessions = /* @__PURE__ */ new Set();
2457
- const dirtyRoles = /* @__PURE__ */ new Set();
2458
- const dirtyWorkspace = /* @__PURE__ */ new Set();
2459
- let ledgerDirty = false;
2458
+ const dirtySessions = /* @__PURE__ */ new Map();
2459
+ const dirtyRoles = /* @__PURE__ */ new Map();
2460
+ const dirtyWorkspace = /* @__PURE__ */ new Map();
2461
+ let ledgerDirty = 0;
2460
2462
  let flushScheduled = false;
2463
+ let flushChain = Promise.resolve();
2464
+ const mark = (map, key) => {
2465
+ map.set(key, (map.get(key) ?? 0) + 1);
2466
+ };
2461
2467
  const ledgerDocument = () => ({
2462
2468
  schema: 3,
2463
2469
  savedAt: Date.now(),
@@ -2490,74 +2496,85 @@ function createPersistence(core) {
2490
2496
  savedAt: Date.now(),
2491
2497
  roles: g.roleIds.map((rid) => core.roles.get(rid)).filter((r) => Boolean(r)).map((r) => roleJson(r))
2492
2498
  });
2493
- /** 同步 flush:写全部脏文件;成功才清脏标记;每个实际发生 rename 的目录一次 fsync。 */
2494
- const flushNow = () => {
2495
- const s = store();
2496
- if (s === null) return;
2499
+ /**
2500
+ * 异步 flush(writeFileAtomic:wx 独占创建 + 随机后缀 tmp + rename,无
2501
+ * per-file fsync——崩溃持久性对齐 DSH 基座标准):写全部脏文件;写成功且
2502
+ * 写入期间未被重新标脏(版本未变)才清脏标记;每个实际发生 rename
2503
+ * 目录一次 fsync(调用侧批量执行)。
2504
+ */
2505
+ const flushAll = async (s) => {
2497
2506
  const fsyncDirs = /* @__PURE__ */ new Set();
2498
- for (const sid of [...dirtySessions]) {
2507
+ for (const [sid, v] of [...dirtySessions]) {
2499
2508
  const sess = core.sessions.get(sid);
2500
2509
  if (!sess) {
2501
2510
  dirtySessions.delete(sid);
2502
2511
  continue;
2503
2512
  }
2504
2513
  try {
2505
- s.atomicWrite(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)));
2506
- dirtySessions.delete(sid);
2514
+ await writeFileAtomic(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)), WRITE_FILE_OPTS);
2515
+ if (dirtySessions.get(sid) === v) dirtySessions.delete(sid);
2507
2516
  fsyncDirs.add(s.sessionsDir(sess.groupId));
2508
2517
  } catch (e) {
2509
2518
  console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e);
2510
2519
  }
2511
2520
  }
2512
- for (const gid of [...dirtyRoles]) {
2521
+ for (const [gid, v] of [...dirtyRoles]) {
2513
2522
  const g = core.groups.get(gid);
2514
2523
  if (!g) {
2515
2524
  dirtyRoles.delete(gid);
2516
2525
  continue;
2517
2526
  }
2518
2527
  try {
2519
- s.atomicWrite(s.rolesFile(gid), JSON.stringify(rolesDocument(g)));
2520
- dirtyRoles.delete(gid);
2528
+ await writeFileAtomic(s.rolesFile(gid), JSON.stringify(rolesDocument(g)), WRITE_FILE_OPTS);
2529
+ if (dirtyRoles.get(gid) === v) dirtyRoles.delete(gid);
2521
2530
  fsyncDirs.add(s.groupDir(gid));
2522
2531
  } catch (e) {
2523
2532
  console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e);
2524
2533
  }
2525
2534
  }
2526
- for (const gid of [...dirtyWorkspace]) {
2535
+ for (const [gid, v] of [...dirtyWorkspace]) {
2527
2536
  const g = core.groups.get(gid);
2528
2537
  if (!g) {
2529
2538
  dirtyWorkspace.delete(gid);
2530
2539
  continue;
2531
2540
  }
2532
2541
  try {
2533
- s.atomicWrite(s.workspaceFile(gid), (g.workspaceDir || "") + "\n");
2534
- dirtyWorkspace.delete(gid);
2542
+ await writeFileAtomic(s.workspaceFile(gid), (g.workspaceDir || "") + "\n", WRITE_FILE_OPTS);
2543
+ if (dirtyWorkspace.get(gid) === v) dirtyWorkspace.delete(gid);
2535
2544
  fsyncDirs.add(s.groupDir(gid));
2536
2545
  } catch (e) {
2537
2546
  console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e);
2538
2547
  }
2539
2548
  }
2540
- if (ledgerDirty) try {
2541
- s.atomicWrite(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2));
2542
- ledgerDirty = false;
2543
- fsyncDirs.add(s.dir);
2544
- } catch (e) {
2545
- console.error("[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):", e);
2549
+ if (ledgerDirty > 0) {
2550
+ const v = ledgerDirty;
2551
+ try {
2552
+ await writeFileAtomic(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2), WRITE_FILE_OPTS);
2553
+ if (ledgerDirty === v) ledgerDirty = 0;
2554
+ fsyncDirs.add(s.dir);
2555
+ } catch (e) {
2556
+ console.error("[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):", e);
2557
+ }
2546
2558
  }
2547
2559
  for (const d of fsyncDirs) s.fsyncDir(d);
2548
2560
  };
2549
2561
  /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
2550
2562
  const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null } = {}) => {
2551
2563
  if (store() === null) return;
2552
- if (ledger) ledgerDirty = true;
2553
- if (session) dirtySessions.add(session);
2554
- if (roleGroup) dirtyRoles.add(roleGroup);
2555
- if (workspace) dirtyWorkspace.add(workspace);
2564
+ if (ledger) ledgerDirty++;
2565
+ if (session) mark(dirtySessions, session);
2566
+ if (roleGroup) mark(dirtyRoles, roleGroup);
2567
+ if (workspace) mark(dirtyWorkspace, workspace);
2556
2568
  if (flushScheduled) return;
2557
2569
  flushScheduled = true;
2558
2570
  Promise.resolve().then(() => {
2559
2571
  flushScheduled = false;
2560
- flushNow();
2572
+ flushChain = flushChain.then(async () => {
2573
+ const s = store();
2574
+ if (s !== null) await flushAll(s);
2575
+ }).catch((e) => {
2576
+ console.error("[dsh-group-chat] 落盘 flush 异常(脏标记保留待重试):", e);
2577
+ });
2561
2578
  });
2562
2579
  };
2563
2580
  const dropDirty = ({ session = null, roles: roleGroup = null, workspace = null }) => {
@@ -2717,18 +2734,18 @@ function createPersistence(core) {
2717
2734
  hydrate();
2718
2735
  return {
2719
2736
  schedulePersist,
2720
- flushNow,
2721
2737
  dropDirty,
2722
- release() {
2738
+ async release() {
2723
2739
  const s = store();
2724
2740
  if (s === null) return;
2741
+ core.store = null;
2725
2742
  try {
2726
- flushNow();
2743
+ await flushChain;
2744
+ await flushAll(s);
2727
2745
  } catch {}
2728
2746
  try {
2729
2747
  s.release();
2730
2748
  } catch {}
2731
- core.store = null;
2732
2749
  }
2733
2750
  };
2734
2751
  }
@@ -2737,12 +2754,13 @@ function createPersistence(core) {
2737
2754
  /**
2738
2755
  * 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
2739
2756
  * run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
2740
- * 权限档位走逐条确认或直接执行。
2757
+ * 权限档位走逐条确认或直接执行,经 `shell` 服务(ctx.shell 沙箱执行器)
2758
+ * 以 per-call sandboxPolicy 收紧到群工作区。
2741
2759
  * @module dsh-group-chat/host/tools
2742
2760
  */
2743
2761
  /** 创建工具面。 */
2744
2762
  function createTools(core, touch) {
2745
- const { run } = core;
2763
+ const { run, shell } = core;
2746
2764
  /** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
2747
2765
  const resolveInWorkspace = (root, rawPath) => {
2748
2766
  const p = rawPath === void 0 || rawPath === null || String(rawPath).trim() === "" ? "." : String(rawPath);
@@ -2829,54 +2847,50 @@ function createTools(core, touch) {
2829
2847
  };
2830
2848
  }
2831
2849
  };
2832
- const runCommandTool = (root, command) => new Promise((resolve) => {
2833
- let child;
2850
+ /**
2851
+ * 经 `shell` 服务执行命令(TOOLS.md §2):cwd 与沙箱均收紧到群工作区根
2852
+ * (workspace_write 档经 sandboxPolicy 强制;full_access 档对齐 DSH
2853
+ * danger-full-access 语义免受限)。超时/中止由执行器 kill 进程并按首因
2854
+ * 分类报告;runner 失效(如 SANDBOX_UNAVAILABLE)经 catch 报错不执行。
2855
+ */
2856
+ const runCommandTool = async (g, root, command) => {
2857
+ const abort = new AbortController();
2858
+ run.commandAbort = abort;
2834
2859
  try {
2835
- child = spawn("bash", ["-c", command], { cwd: root });
2860
+ const spec = shell.resolve({
2861
+ command,
2862
+ workdir: root,
2863
+ timeoutMs: RUN_CMD_TIMEOUT_MS,
2864
+ stdoutMaxBytes: CMD_CAPTURE_MAX_BYTES,
2865
+ sandboxPolicy: g.permissionTier === "full_access" ? {
2866
+ mode: "danger-full-access",
2867
+ workspaceRoot: root
2868
+ } : {
2869
+ mode: "workspace-write",
2870
+ workspaceRoot: root
2871
+ },
2872
+ signal: abort.signal
2873
+ });
2874
+ const r = await shell.run(spec);
2875
+ let text = r.stdout.text + r.stderr.text;
2876
+ if (r.stdout.truncated || r.stderr.truncated) text += "\n[输出采集超限,已截断(保留末尾)]";
2877
+ if (r.sandbox?.denied) text += "\n[沙箱拦截了越界的文件操作]";
2878
+ if (r.timedOut) text += "\n[执行超时(" + Math.round(RUN_CMD_TIMEOUT_MS / 1e3) + "s),已强制终止]";
2879
+ if (r.exitCode !== 0 && r.exitCode !== null && !r.timedOut) text += "\n[退出码 " + r.exitCode + "]";
2880
+ if (text.length > 8e3) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + "\n…(输出超长,已截断)";
2881
+ return {
2882
+ status: r.exitCode === 0 ? "ok" : "error",
2883
+ output: text || "(无输出)"
2884
+ };
2836
2885
  } catch (e) {
2837
- resolve({
2886
+ return {
2838
2887
  status: "error",
2839
2888
  output: "无法启动命令:" + String(e && e.message || e)
2840
- });
2841
- return;
2889
+ };
2890
+ } finally {
2891
+ if (run.commandAbort === abort) run.commandAbort = null;
2842
2892
  }
2843
- run.childProc = child;
2844
- let out = "";
2845
- let dropped = 0;
2846
- let timedOut = false;
2847
- const onChunk = (chunk) => {
2848
- if (out.length < 1048576) out += chunk.toString("utf8");
2849
- else dropped += chunk.length;
2850
- };
2851
- child.stdout?.on("data", onChunk);
2852
- child.stderr?.on("data", onChunk);
2853
- const timer = setTimeout(() => {
2854
- timedOut = true;
2855
- try {
2856
- child.kill("SIGKILL");
2857
- } catch {}
2858
- }, RUN_CMD_TIMEOUT_MS);
2859
- const finish = (result) => {
2860
- clearTimeout(timer);
2861
- if (run.childProc === child) run.childProc = null;
2862
- resolve(result);
2863
- };
2864
- child.on("error", (e) => finish({
2865
- status: "error",
2866
- output: "执行失败:" + String(e && e.message || e)
2867
- }));
2868
- child.on("close", (code) => {
2869
- let text = out.length > 1048576 ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out;
2870
- if (dropped > 0) text += "\n[输出采集超限,已丢弃 " + dropped + " 字节]";
2871
- if (timedOut) text += "\n[执行超时(" + Math.round(RUN_CMD_TIMEOUT_MS / 1e3) + "s),已强制终止]";
2872
- if (code !== 0 && code !== null && !timedOut) text += "\n[退出码 " + code + "]";
2873
- if (text.length > 8e3) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + "\n…(输出超长,已截断)";
2874
- finish({
2875
- status: code === 0 ? "ok" : "error",
2876
- output: text || "(无输出)"
2877
- });
2878
- });
2879
- });
2893
+ };
2880
2894
  /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
2881
2895
  const requestConfirmation = (toolCallId, args) => new Promise((resolve) => {
2882
2896
  run.pendingConfirm = {
@@ -2896,8 +2910,8 @@ function createTools(core, touch) {
2896
2910
  } catch {}
2897
2911
  };
2898
2912
  const killChild = () => {
2899
- if (run.childProc) try {
2900
- run.childProc.kill("SIGKILL");
2913
+ try {
2914
+ run.commandAbort?.abort();
2901
2915
  } catch {}
2902
2916
  };
2903
2917
  const executeTool = async (g, root, tc) => {
@@ -2925,8 +2939,8 @@ function createTools(core, touch) {
2925
2939
  status: "denied",
2926
2940
  output: "用户拒绝了这次命令执行"
2927
2941
  };
2928
- else res = await runCommandTool(root, String(args.command || ""));
2929
- } else res = await runCommandTool(root, String(args.command || ""));
2942
+ else res = await runCommandTool(g, root, String(args.command || ""));
2943
+ } else res = await runCommandTool(g, root, String(args.command || ""));
2930
2944
  } else res = {
2931
2945
  status: "error",
2932
2946
  output: "未知工具:" + tc.name
@@ -2996,7 +3010,7 @@ function createGroupChatService(ctx) {
2996
3010
  tools.killChild();
2997
3011
  materials.disposeFileSearch();
2998
3012
  bus.dispose();
2999
- persist.release();
3013
+ return persist.release();
3000
3014
  }
3001
3015
  };
3002
3016
  }
@@ -3006,6 +3020,7 @@ const name = "group-chat";
3006
3020
  const inject = [
3007
3021
  "llm",
3008
3022
  "fs",
3023
+ "shell",
3009
3024
  "webServer",
3010
3025
  "workspaceRegistry"
3011
3026
  ];
@@ -3025,9 +3040,7 @@ const apply = mountOnce("dsh-group-chat", (ctx, config) => {
3025
3040
  for (const dispose of disposers) dispose();
3026
3041
  };
3027
3042
  }, "group-chat: host API routes");
3028
- ctx.effect(() => () => {
3029
- service.dispose();
3030
- });
3043
+ ctx.effect(() => () => service.dispose());
3031
3044
  let current = () => config ?? {};
3032
3045
  const sync = () => {
3033
3046
  if (!(current().enabled ?? true)) service.stopAll();