@roaming-ai/dsh-group-chat 0.3.1 → 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.
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
@@ -229,6 +229,7 @@ function createHostState(ctx) {
229
229
  ctx,
230
230
  llm: ctx.llm,
231
231
  fs: ctx.fs,
232
+ shell: ctx.shell,
232
233
  groups: /* @__PURE__ */ new Map(),
233
234
  sessions: /* @__PURE__ */ new Map(),
234
235
  roles: /* @__PURE__ */ new Map(),
@@ -243,7 +244,7 @@ function createHostState(ctx) {
243
244
  queue: [],
244
245
  pendingConfirm: null,
245
246
  confirmSignal: null,
246
- childProc: null,
247
+ commandAbort: null,
247
248
  finished: null,
248
249
  replaceMessageId: null
249
250
  },
@@ -1799,7 +1800,7 @@ function createConversation(core, deps) {
1799
1800
  run.queue = [];
1800
1801
  run.pendingConfirm = null;
1801
1802
  run.confirmSignal = null;
1802
- run.childProc = null;
1803
+ run.commandAbort = null;
1803
1804
  run.stopping = false;
1804
1805
  run.replaceMessageId = null;
1805
1806
  touch();
@@ -2190,7 +2191,11 @@ var Store = class {
2190
2191
  }
2191
2192
  } catch {}
2192
2193
  }
2193
- /** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
2194
+ /**
2195
+ * 同步原子写(tmp+fsync+rename):仅供构造期 v1 迁移(migrateV1)与测试
2196
+ * 直用;运行时 flush 走 persistence.ts 的 writeFileAtomic(异步、wx 独占
2197
+ * 创建 + 随机后缀 + 符号链接安全,无 per-file fsync,对齐 DSH 基座标准)。
2198
+ */
2194
2199
  atomicWrite(file, text) {
2195
2200
  mkdirSync(dirname(file), { recursive: true });
2196
2201
  const tmp = `${file}.tmp-${process.pid}`;
@@ -2433,6 +2438,11 @@ function emptyGroup(id, name) {
2433
2438
  * 锁失败/写失败时降级为内存态运行(console 告警)。
2434
2439
  * @module dsh-group-chat/host/persistence
2435
2440
  */
2441
+ /** writeFileAtomic 权限位:私有数据 0o600、目录 0o700(对齐 Store.atomicWrite)。 */
2442
+ const WRITE_FILE_OPTS = {
2443
+ mode: 384,
2444
+ dirMode: 448
2445
+ };
2436
2446
  /**
2437
2447
  * 创建持久化面:构造即完成 store 初始化 + hydrate(v1 迁移 → 残留清理 →
2438
2448
  * 清单 → 群组数据 → 会话文件;缺文件空重建;补建默认会话/群组立即落盘)。
@@ -2445,11 +2455,15 @@ function createPersistence(core) {
2445
2455
  core.store = null;
2446
2456
  }
2447
2457
  const store = () => core.store;
2448
- const dirtySessions = /* @__PURE__ */ new Set();
2449
- const dirtyRoles = /* @__PURE__ */ new Set();
2450
- const dirtyWorkspace = /* @__PURE__ */ new Set();
2451
- 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;
2452
2462
  let flushScheduled = false;
2463
+ let flushChain = Promise.resolve();
2464
+ const mark = (map, key) => {
2465
+ map.set(key, (map.get(key) ?? 0) + 1);
2466
+ };
2453
2467
  const ledgerDocument = () => ({
2454
2468
  schema: 3,
2455
2469
  savedAt: Date.now(),
@@ -2482,74 +2496,85 @@ function createPersistence(core) {
2482
2496
  savedAt: Date.now(),
2483
2497
  roles: g.roleIds.map((rid) => core.roles.get(rid)).filter((r) => Boolean(r)).map((r) => roleJson(r))
2484
2498
  });
2485
- /** 同步 flush:写全部脏文件;成功才清脏标记;每个实际发生 rename 的目录一次 fsync。 */
2486
- const flushNow = () => {
2487
- const s = store();
2488
- 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) => {
2489
2506
  const fsyncDirs = /* @__PURE__ */ new Set();
2490
- for (const sid of [...dirtySessions]) {
2507
+ for (const [sid, v] of [...dirtySessions]) {
2491
2508
  const sess = core.sessions.get(sid);
2492
2509
  if (!sess) {
2493
2510
  dirtySessions.delete(sid);
2494
2511
  continue;
2495
2512
  }
2496
2513
  try {
2497
- s.atomicWrite(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)));
2498
- 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);
2499
2516
  fsyncDirs.add(s.sessionsDir(sess.groupId));
2500
2517
  } catch (e) {
2501
2518
  console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e);
2502
2519
  }
2503
2520
  }
2504
- for (const gid of [...dirtyRoles]) {
2521
+ for (const [gid, v] of [...dirtyRoles]) {
2505
2522
  const g = core.groups.get(gid);
2506
2523
  if (!g) {
2507
2524
  dirtyRoles.delete(gid);
2508
2525
  continue;
2509
2526
  }
2510
2527
  try {
2511
- s.atomicWrite(s.rolesFile(gid), JSON.stringify(rolesDocument(g)));
2512
- 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);
2513
2530
  fsyncDirs.add(s.groupDir(gid));
2514
2531
  } catch (e) {
2515
2532
  console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e);
2516
2533
  }
2517
2534
  }
2518
- for (const gid of [...dirtyWorkspace]) {
2535
+ for (const [gid, v] of [...dirtyWorkspace]) {
2519
2536
  const g = core.groups.get(gid);
2520
2537
  if (!g) {
2521
2538
  dirtyWorkspace.delete(gid);
2522
2539
  continue;
2523
2540
  }
2524
2541
  try {
2525
- s.atomicWrite(s.workspaceFile(gid), (g.workspaceDir || "") + "\n");
2526
- dirtyWorkspace.delete(gid);
2542
+ await writeFileAtomic(s.workspaceFile(gid), (g.workspaceDir || "") + "\n", WRITE_FILE_OPTS);
2543
+ if (dirtyWorkspace.get(gid) === v) dirtyWorkspace.delete(gid);
2527
2544
  fsyncDirs.add(s.groupDir(gid));
2528
2545
  } catch (e) {
2529
2546
  console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e);
2530
2547
  }
2531
2548
  }
2532
- if (ledgerDirty) try {
2533
- s.atomicWrite(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2));
2534
- ledgerDirty = false;
2535
- fsyncDirs.add(s.dir);
2536
- } catch (e) {
2537
- 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
+ }
2538
2558
  }
2539
2559
  for (const d of fsyncDirs) s.fsyncDir(d);
2540
2560
  };
2541
2561
  /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
2542
2562
  const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null } = {}) => {
2543
2563
  if (store() === null) return;
2544
- if (ledger) ledgerDirty = true;
2545
- if (session) dirtySessions.add(session);
2546
- if (roleGroup) dirtyRoles.add(roleGroup);
2547
- 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);
2548
2568
  if (flushScheduled) return;
2549
2569
  flushScheduled = true;
2550
2570
  Promise.resolve().then(() => {
2551
2571
  flushScheduled = false;
2552
- 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
+ });
2553
2578
  });
2554
2579
  };
2555
2580
  const dropDirty = ({ session = null, roles: roleGroup = null, workspace = null }) => {
@@ -2709,18 +2734,18 @@ function createPersistence(core) {
2709
2734
  hydrate();
2710
2735
  return {
2711
2736
  schedulePersist,
2712
- flushNow,
2713
2737
  dropDirty,
2714
- release() {
2738
+ async release() {
2715
2739
  const s = store();
2716
2740
  if (s === null) return;
2741
+ core.store = null;
2717
2742
  try {
2718
- flushNow();
2743
+ await flushChain;
2744
+ await flushAll(s);
2719
2745
  } catch {}
2720
2746
  try {
2721
2747
  s.release();
2722
2748
  } catch {}
2723
- core.store = null;
2724
2749
  }
2725
2750
  };
2726
2751
  }
@@ -2729,12 +2754,13 @@ function createPersistence(core) {
2729
2754
  /**
2730
2755
  * 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
2731
2756
  * run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
2732
- * 权限档位走逐条确认或直接执行。
2757
+ * 权限档位走逐条确认或直接执行,经 `shell` 服务(ctx.shell 沙箱执行器)
2758
+ * 以 per-call sandboxPolicy 收紧到群工作区。
2733
2759
  * @module dsh-group-chat/host/tools
2734
2760
  */
2735
2761
  /** 创建工具面。 */
2736
2762
  function createTools(core, touch) {
2737
- const { run } = core;
2763
+ const { run, shell } = core;
2738
2764
  /** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
2739
2765
  const resolveInWorkspace = (root, rawPath) => {
2740
2766
  const p = rawPath === void 0 || rawPath === null || String(rawPath).trim() === "" ? "." : String(rawPath);
@@ -2821,54 +2847,50 @@ function createTools(core, touch) {
2821
2847
  };
2822
2848
  }
2823
2849
  };
2824
- const runCommandTool = (root, command) => new Promise((resolve) => {
2825
- 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;
2826
2859
  try {
2827
- 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
+ };
2828
2885
  } catch (e) {
2829
- resolve({
2886
+ return {
2830
2887
  status: "error",
2831
2888
  output: "无法启动命令:" + String(e && e.message || e)
2832
- });
2833
- return;
2889
+ };
2890
+ } finally {
2891
+ if (run.commandAbort === abort) run.commandAbort = null;
2834
2892
  }
2835
- run.childProc = child;
2836
- let out = "";
2837
- let dropped = 0;
2838
- let timedOut = false;
2839
- const onChunk = (chunk) => {
2840
- if (out.length < 1048576) out += chunk.toString("utf8");
2841
- else dropped += chunk.length;
2842
- };
2843
- child.stdout?.on("data", onChunk);
2844
- child.stderr?.on("data", onChunk);
2845
- const timer = setTimeout(() => {
2846
- timedOut = true;
2847
- try {
2848
- child.kill("SIGKILL");
2849
- } catch {}
2850
- }, RUN_CMD_TIMEOUT_MS);
2851
- const finish = (result) => {
2852
- clearTimeout(timer);
2853
- if (run.childProc === child) run.childProc = null;
2854
- resolve(result);
2855
- };
2856
- child.on("error", (e) => finish({
2857
- status: "error",
2858
- output: "执行失败:" + String(e && e.message || e)
2859
- }));
2860
- child.on("close", (code) => {
2861
- let text = out.length > 1048576 ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out;
2862
- if (dropped > 0) text += "\n[输出采集超限,已丢弃 " + dropped + " 字节]";
2863
- if (timedOut) text += "\n[执行超时(" + Math.round(RUN_CMD_TIMEOUT_MS / 1e3) + "s),已强制终止]";
2864
- if (code !== 0 && code !== null && !timedOut) text += "\n[退出码 " + code + "]";
2865
- if (text.length > 8e3) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + "\n…(输出超长,已截断)";
2866
- finish({
2867
- status: code === 0 ? "ok" : "error",
2868
- output: text || "(无输出)"
2869
- });
2870
- });
2871
- });
2893
+ };
2872
2894
  /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
2873
2895
  const requestConfirmation = (toolCallId, args) => new Promise((resolve) => {
2874
2896
  run.pendingConfirm = {
@@ -2888,8 +2910,8 @@ function createTools(core, touch) {
2888
2910
  } catch {}
2889
2911
  };
2890
2912
  const killChild = () => {
2891
- if (run.childProc) try {
2892
- run.childProc.kill("SIGKILL");
2913
+ try {
2914
+ run.commandAbort?.abort();
2893
2915
  } catch {}
2894
2916
  };
2895
2917
  const executeTool = async (g, root, tc) => {
@@ -2917,8 +2939,8 @@ function createTools(core, touch) {
2917
2939
  status: "denied",
2918
2940
  output: "用户拒绝了这次命令执行"
2919
2941
  };
2920
- else res = await runCommandTool(root, String(args.command || ""));
2921
- } 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 || ""));
2922
2944
  } else res = {
2923
2945
  status: "error",
2924
2946
  output: "未知工具:" + tc.name
@@ -2988,7 +3010,7 @@ function createGroupChatService(ctx) {
2988
3010
  tools.killChild();
2989
3011
  materials.disposeFileSearch();
2990
3012
  bus.dispose();
2991
- persist.release();
3013
+ return persist.release();
2992
3014
  }
2993
3015
  };
2994
3016
  }
@@ -2998,6 +3020,7 @@ const name = "group-chat";
2998
3020
  const inject = [
2999
3021
  "llm",
3000
3022
  "fs",
3023
+ "shell",
3001
3024
  "webServer",
3002
3025
  "workspaceRegistry"
3003
3026
  ];
@@ -3017,9 +3040,7 @@ const apply = mountOnce("dsh-group-chat", (ctx, config) => {
3017
3040
  for (const dispose of disposers) dispose();
3018
3041
  };
3019
3042
  }, "group-chat: host API routes");
3020
- ctx.effect(() => () => {
3021
- service.dispose();
3022
- });
3043
+ ctx.effect(() => () => service.dispose());
3023
3044
  let current = () => config ?? {};
3024
3045
  const sync = () => {
3025
3046
  if (!(current().enabled ?? true)) service.stopAll();
@@ -128,7 +128,8 @@ export interface RunState {
128
128
  confirmSignal: {
129
129
  resolve: (allowed: boolean) => void;
130
130
  } | null;
131
- childProc: import('node:child_process').ChildProcess | null;
131
+ /** 正在执行的 run_command 的中止句柄(stop/dispose 时 abort,执行器 kill 进程)。 */
132
+ commandAbort: AbortController | null;
132
133
  /** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
133
134
  finished: RunFinished | null;
134
135
  /** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
@@ -200,7 +201,7 @@ export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSum
200
201
  /** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
201
202
  export interface Snapshot {
202
203
  revision: number;
203
- run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'childProc'>;
204
+ run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'commandAbort'>;
204
205
  lastCreated: LastCreated | null;
205
206
  groups: GroupRecord[];
206
207
  sessions: SnapshotSession[];
@@ -13,16 +13,14 @@ export interface Persistence {
13
13
  roles?: string | null;
14
14
  workspace?: string | null;
15
15
  }) => void;
16
- /** 同步 flush:写全部脏文件(dispose 最终落盘用)。 */
17
- flushNow: () => void;
18
16
  /** 删除群组/会话后摘除脏标记(对应文件已删/将删,flush 跳过)。 */
19
17
  dropDirty: (targets: {
20
18
  session?: string | null;
21
19
  roles?: string | null;
22
20
  workspace?: string | null;
23
21
  }) => void;
24
- /** dispose:同步最终 flush → 释放锁摘除句柄。 */
25
- release: () => void;
22
+ /** dispose:停止新调度 → 等待挂起 flush → 最终 flush 释放锁(异步)。 */
23
+ release: () => Promise<void>;
26
24
  }
27
25
  /**
28
26
  * 创建持久化面:构造即完成 store 初始化 + hydrate(v1 迁移 → 残留清理 →
@@ -32,7 +32,11 @@ export declare class Store {
32
32
  private acquireLock;
33
33
  release(): void;
34
34
  fsyncDir(dir: string): void;
35
- /** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
35
+ /**
36
+ * 同步原子写(tmp+fsync+rename):仅供构造期 v1 迁移(migrateV1)与测试
37
+ * 直用;运行时 flush 走 persistence.ts 的 writeFileAtomic(异步、wx 独占
38
+ * 创建 + 随机后缀 + 符号链接安全,无 per-file fsync,对齐 DSH 基座标准)。
39
+ */
36
40
  atomicWrite(file: string, text: string): void;
37
41
  quarantine(file: string): void;
38
42
  /** 读 JSON;缺失返回 null;损坏则隔离后返回 null。 */
@@ -19,8 +19,8 @@ export interface GroupChatService {
19
19
  subscribePush(push: () => void): () => void;
20
20
  /** 设置停用时中止正在进行的群聊。 */
21
21
  stopAll(): void;
22
- /** 卸载/热重载:唤醒确认等待 + kill 子进程 → 同步最终 flush → 释放锁。 */
23
- dispose(): void;
22
+ /** 卸载/热重载:唤醒确认等待 + kill 子进程 → 异步最终 flush → 释放锁(cordis 会 await)。 */
23
+ dispose(): Promise<void>;
24
24
  }
25
25
  /**
26
26
  * 创建群聊宿主服务(装配各模块;持久化锁失败时降级内存态运行)。
@@ -27,6 +27,7 @@ export interface HostState {
27
27
  ctx: Context;
28
28
  llm: Context['llm'];
29
29
  fs: Context['fs'];
30
+ shell: Context['shell'];
30
31
  groups: Map<string, GroupRecord>;
31
32
  sessions: Map<string, SessionRecord>;
32
33
  roles: Map<string, RoleRecord>;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
3
3
  * run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
4
- * 权限档位走逐条确认或直接执行。
4
+ * 权限档位走逐条确认或直接执行,经 `shell` 服务(ctx.shell 沙箱执行器)
5
+ * 以 per-call sandboxPolicy 收紧到群工作区。
5
6
  * @module dsh-group-chat/host/tools
6
7
  */
7
8
  import { TOOL_SCHEMAS } from '../../core/tools.ts';
@@ -14,6 +14,9 @@
14
14
  * (engine/fold.ts;立刻 idle、fire-and-forget,不产生消息)
15
15
  * - 群组工作区目录经 `fs` 服务读取(根下一层文本文件,最多 20 个),
16
16
  * 以「共享资料」块注入每个角色的 system 提示词;无独立笔记/文件清单
17
+ * - 工具执行(read_file/list_dir/run_command)见 docs/TOOLS.md:run_command
18
+ * 经 `shell` 服务(ctx.shell 沙箱执行器)执行,per-call sandboxPolicy
19
+ * 收紧到群工作区(workspace_write 档)或免受限(full_access 档)
17
20
  * - 经 `webServer` 暴露 HTTP API:
18
21
  * GET /api/group-chat/state 全量快照
19
22
  * POST /api/group-chat/action { kind: mutate|send|stop|confirmCommand|models|efforts|browse|fileSearch, ... }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@roaming-ai/dsh-group-chat",
3
3
  "description": "DSH 模型群聊:多模型角色群组对话面板。角色绑定不同 provider/model,群内共享对话记录;设置页支持启用/停用。",
4
- "version": "0.3.1",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -30,6 +30,7 @@
30
30
  "README.md"
31
31
  ],
32
32
  "license": "MIT",
33
+ "author": "localSummer (https://github.com/localSummer)",
33
34
  "repository": {
34
35
  "type": "git",
35
36
  "url": "https://github.com/localSummer/dsh-group-chat"
@@ -55,6 +56,7 @@
55
56
  }
56
57
  },
57
58
  "dependencies": {
59
+ "@deepseek-ai/dsh-atomic-write": "0.1.5-rc.2",
58
60
  "@deepseek-ai/dsh-file-reference": "0.1.5-rc.2",
59
61
  "@deepseek-ai/dsh-file-reference-local": "0.1.5-rc.2",
60
62
  "schemastery": "^3.18.0"
@@ -78,6 +80,7 @@
78
80
  "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
79
81
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
80
82
  "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
83
+ "@deepseek-ai/dsh-shell": "^0.1.5-rc.1",
81
84
  "@deepseek-ai/dsh-workspace": "^0.1.5-rc.1",
82
85
  "@types/node": "^22.20.0",
83
86
  "@types/react": "~18.3.1",
@@ -121,6 +121,8 @@ export function GroupChatPanel(): ReactNode {
121
121
 
122
122
  const sendMsg = useCallback(async (): Promise<void> => {
123
123
  if (busyNow || !sess || sendingRef.current) return
124
+ // 空内容不可发送(按钮 disabled 之外,Enter 路径在此静默拦截)
125
+ if (!input.trim()) return
124
126
  sendingRef.current = true
125
127
  try {
126
128
  if (!participants.length && !mentionedRoles.length) {
@@ -171,7 +171,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
171
171
  <div className="dsgc-emptytitle">{sess && sess.topic ? '「' + sess.topic + '」' : '会话已就绪'}</div>
172
172
  <div className="dsgc-hint">
173
173
  {enabledRoles.length
174
- ? '发送消息开始讨论;@成员 点名让其优先回应;留空直接发送可让角色自由讨论——每轮全体参与角色按顺序各发言一次,可用右下角轮数控制(1–10 轮)'
174
+ ? '发送消息开始讨论;@成员 点名让其优先回应——每轮全体参与角色按顺序各发言一次,可用右下角轮数控制(1–10 轮)'
175
175
  : '先在右侧添加角色(每个角色可绑定不同模型),再回到这里发起讨论。'}
176
176
  </div>
177
177
  </div>
@@ -192,7 +192,7 @@ export function Composer(props: ComposerProps): ReactNode {
192
192
  )
193
193
  : null}
194
194
  {!input.trim()
195
- ? <div className="dsgc-ph" aria-hidden="true">发消息给全群,@成员 点名让其回应(留空则让角色自由讨论)…</div>
195
+ ? <div className="dsgc-ph" aria-hidden="true">发消息给全群,@成员 点名让其回应…</div>
196
196
  : null}
197
197
  <div
198
198
  className="dsgc-edit"
@@ -233,7 +233,7 @@ export function Composer(props: ComposerProps): ReactNode {
233
233
  </P.Button>
234
234
  )
235
235
  : (
236
- <P.Button variant="primary" onClick={() => { void sendMsg() }} disabled={(!participants.length && !mentionedRoles.length) || !sess}>
236
+ <P.Button variant="primary" onClick={() => { void sendMsg() }} disabled={!input.trim() || (!participants.length && !mentionedRoles.length) || !sess}>
237
237
  {Icon(P.IconSendOutline16, 16)}发送
238
238
  </P.Button>
239
239
  )}
@@ -201,8 +201,12 @@ export const CSS = [
201
201
  /* @提及芯片(输入区内原子元素):色点 + 角色色淡底胶囊——弹层候选行同语言 */
202
202
  '.dsgc-chipin{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb,var(--role-color,#888) 15%,transparent);border-radius:999px;padding:1px 7px 1px 5px;margin:0 1px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;user-select:all}',
203
203
  '.dsgc-card .dsgc-sendrow{padding:0 12px 10px}',
204
- '.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}',
205
- '.dsgc-stopbtn:hover:not(:disabled){filter:brightness(1.08);color:#fff}',
204
+ /* 停止/清空按钮:实心红(Button primitive outline 变体的 hover 规则
205
+ background:interactive-bg-hover 特异度 (0,3,0) 会盖掉 (0,1,0) 的红底,
206
+ 悬停时按钮变透明;加 button 类型选择器提为 (0,1,1)/(0,3,1),顺序无关
207
+ 地压过变体,hover 补回红底并经 brightness 提亮) */
208
+ 'button.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}',
209
+ 'button.dsgc-stopbtn:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.08);color:#fff}',
206
210
  /* 标签固定在左;芯片在右侧独立换行,后续行与首行芯片左缘对齐(不折到「参与角色」下面)。
207
211
  单行 24px;角色过多时随内容增高,避免固定高度把后续行溢出叠到下方输入卡上 */
208
212
  '.dsgc-parts{display:flex;align-items:flex-start;gap:6px;min-height:24px}',
package/src/core/types.ts CHANGED
@@ -149,7 +149,8 @@ export interface RunState {
149
149
  queue: string[]
150
150
  pendingConfirm: PendingConfirm | null
151
151
  confirmSignal: { resolve: (allowed: boolean) => void } | null
152
- childProc: import('node:child_process').ChildProcess | null
152
+ /** 正在执行的 run_command 的中止句柄(stop/dispose 时 abort,执行器 kill 进程)。 */
153
+ commandAbort: AbortController | null
153
154
  /** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
154
155
  finished: RunFinished | null
155
156
  /** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
@@ -210,7 +211,7 @@ export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSum
210
211
  /** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
211
212
  export interface Snapshot {
212
213
  revision: number
213
- run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'childProc'>
214
+ run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'commandAbort'>
214
215
  lastCreated: LastCreated | null
215
216
  groups: GroupRecord[]
216
217
  sessions: SnapshotSession[]
@@ -379,7 +379,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
379
379
  run.queue = []
380
380
  run.pendingConfirm = null
381
381
  run.confirmSignal = null
382
- run.childProc = null
382
+ run.commandAbort = null
383
383
  run.stopping = false
384
384
  run.replaceMessageId = null
385
385
  touch()