@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
@@ -6,55 +6,33 @@
6
6
  */
7
7
 
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import { looseJson } from '../../core/json.ts'
9
10
  import type { SessionRecord } from '../../core/types.ts'
10
11
  import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
12
+ import { defaultModel, speakerNameOf } from './defaults.ts'
11
13
 
12
14
  /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
13
15
  const isPlaceholderName = (name: string): boolean =>
14
16
  name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
15
17
 
16
- /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
17
- const defaultModel = (core: HostState): { provider: string, model: string } | null => {
18
- try {
19
- // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
20
- // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
21
- // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
22
- const svc = core.ctx.reflect.get('agentDefaultModel')
23
- const sel = svc ? svc.currentSelection() : null
24
- return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
25
- ? { provider: sel.provider, model: sel.model }
26
- : null
27
- } catch {
28
- return null
29
- }
30
- }
31
-
32
18
  /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
33
19
  const titleTranscript = (core: HostState, sess: SessionRecord): string => {
34
20
  const out: string[] = []
35
21
  for (const mid of sess.messageIds.slice(-40)) {
36
22
  const m = core.messages.get(mid)
37
23
  if (!m || m.speaker === 'system' || m.error || !m.text) continue
38
- const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
39
- out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
24
+ out.push('' + speakerNameOf(core.roles, m.speaker) + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
40
25
  }
41
26
  return out.join('\n')
42
27
  }
43
28
 
44
- /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
29
+ /** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
45
30
  const parseRetitle = (raw: string): { name?: string, topic?: string } => {
46
- const body = raw.replace(/```(?:json)?/g, '')
47
- const l = body.indexOf('{')
48
- const r = body.lastIndexOf('}')
49
- if (l < 0 || r <= l) return {}
50
- try {
51
- const o = JSON.parse(body.slice(l, r + 1)) as { name?: unknown, topic?: unknown }
52
- const name = typeof o.name === 'string' ? o.name.trim() : ''
53
- const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
54
- return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
55
- } catch {
56
- return {}
57
- }
31
+ const o = looseJson(raw.replace(/```(?:json)?/g, ''))
32
+ if (!o) return {}
33
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
34
+ const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
35
+ return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
58
36
  }
59
37
 
60
38
  /**
@@ -64,6 +64,8 @@ export function createMaterials(core: HostState): Materials {
64
64
 
65
65
  /** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
66
66
  const resolveMaterialTarget = async (raw: unknown) => {
67
+ // candidatePaths 构造上恒返回非空候选数组('' → [HOME],其余分支均单值或
68
+ // 非空 map),first 必在;! 仅为取首候选,无空指针风险
67
69
  const candidates = await candidatePaths(raw)
68
70
  let first: { target: import('@deepseek-ai/dsh-fs').FsTarget, path: string } | undefined
69
71
  for (const c of candidates) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
8
9
  import { repairFailedMessage } from '../../core/errors.ts'
9
10
  import { messageJson, roleJson } from '../../core/json.ts'
10
11
  import { sanitizeConstraints } from '../../core/constraints.ts'
@@ -25,6 +26,9 @@ interface SessionDocument {
25
26
  messages?: Partial<MessageRecord>[]
26
27
  }
27
28
 
29
+ /** writeFileAtomic 权限位:私有数据 0o600、目录 0o700(对齐 Store.atomicWrite)。 */
30
+ const WRITE_FILE_OPTS = { mode: 0o600, dirMode: 0o700 } as const
31
+
28
32
  /** 群组角色文件形态(hydrate 用)。 */
29
33
  interface RolesDocument {
30
34
  roles?: Partial<RoleRecord>[]
@@ -34,12 +38,10 @@ interface RolesDocument {
34
38
  export interface Persistence {
35
39
  /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
36
40
  schedulePersist: (targets?: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null }) => void
37
- /** 同步 flush:写全部脏文件(dispose 最终落盘用)。 */
38
- flushNow: () => void
39
41
  /** 删除群组/会话后摘除脏标记(对应文件已删/将删,flush 跳过)。 */
40
42
  dropDirty: (targets: { session?: string | null, roles?: string | null, workspace?: string | null }) => void
41
- /** dispose:同步最终 flush → 释放锁摘除句柄。 */
42
- release: () => void
43
+ /** dispose:停止新调度 → 等待挂起 flush → 最终 flush 释放锁(异步)。 */
44
+ release: () => Promise<void>
43
45
  }
44
46
 
45
47
  /**
@@ -56,12 +58,20 @@ export function createPersistence(core: HostState): Persistence {
56
58
  }
57
59
  const store = (): Store | null => core.store
58
60
 
59
- // 脏标记按文件记;flush 失败者保留脏标记,下次触发自动重试
60
- const dirtySessions = new Set<string>()
61
- const dirtyRoles = new Set<string>()
62
- const dirtyWorkspace = new Set<string>()
63
- let ledgerDirty = false
61
+ // 脏标记按文件记(值 = 版本号,标记时递增);flush 期间被重新标脏的文件
62
+ // 保留标记待下一轮 flush(写成功后版本未变才清除),失败同理自动重试
63
+ const dirtySessions = new Map<string, number>()
64
+ const dirtyRoles = new Map<string, number>()
65
+ const dirtyWorkspace = new Map<string, number>()
66
+ let ledgerDirty = 0
64
67
  let flushScheduled = false
68
+ // flush 串行链:writeFileAtomic 异步化后,防止并发 flush 对同一文件乱序
69
+ // rename(后写的旧内容盖住先写的新内容);链上任务逐个排队执行
70
+ let flushChain: Promise<void> = Promise.resolve()
71
+
72
+ const mark = (map: Map<string, number>, key: string): void => {
73
+ map.set(key, (map.get(key) ?? 0) + 1)
74
+ }
65
75
 
66
76
  const ledgerDocument = (): LedgerDocument & { savedAt: number } => ({
67
77
  schema: 3,
@@ -85,57 +95,61 @@ export function createPersistence(core: HostState): Persistence {
85
95
  })
86
96
  const rolesDocument = (g: GroupRecord) => ({ schema: 1, savedAt: Date.now(), roles: g.roleIds.map((rid) => core.roles.get(rid)).filter((r): r is RoleRecord => Boolean(r)).map((r) => roleJson(r)) })
87
97
 
88
- /** 同步 flush:写全部脏文件;成功才清脏标记;每个实际发生 rename 的目录一次 fsync。 */
89
- const flushNow = (): void => {
90
- const s = store()
91
- if (s === null) return
98
+ /**
99
+ * 异步 flush(writeFileAtomic:wx 独占创建 + 随机后缀 tmp + rename,无
100
+ * per-file fsync——崩溃持久性对齐 DSH 基座标准):写全部脏文件;写成功且
101
+ * 写入期间未被重新标脏(版本未变)才清脏标记;每个实际发生 rename
102
+ * 目录一次 fsync(调用侧批量执行)。
103
+ */
104
+ const flushAll = async (s: Store): Promise<void> => {
92
105
  const fsyncDirs = new Set<string>()
93
- for (const sid of [...dirtySessions]) {
106
+ for (const [sid, v] of [...dirtySessions]) {
94
107
  const sess = core.sessions.get(sid)
95
108
  if (!sess) {
96
109
  dirtySessions.delete(sid)
97
110
  continue
98
111
  }
99
112
  try {
100
- s.atomicWrite(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)))
101
- dirtySessions.delete(sid)
113
+ await writeFileAtomic(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)), WRITE_FILE_OPTS)
114
+ if (dirtySessions.get(sid) === v) dirtySessions.delete(sid)
102
115
  fsyncDirs.add(s.sessionsDir(sess.groupId))
103
116
  } catch (e) {
104
117
  console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e)
105
118
  }
106
119
  }
107
- for (const gid of [...dirtyRoles]) {
120
+ for (const [gid, v] of [...dirtyRoles]) {
108
121
  const g = core.groups.get(gid)
109
122
  if (!g) {
110
123
  dirtyRoles.delete(gid)
111
124
  continue
112
125
  }
113
126
  try {
114
- s.atomicWrite(s.rolesFile(gid), JSON.stringify(rolesDocument(g)))
115
- dirtyRoles.delete(gid)
127
+ await writeFileAtomic(s.rolesFile(gid), JSON.stringify(rolesDocument(g)), WRITE_FILE_OPTS)
128
+ if (dirtyRoles.get(gid) === v) dirtyRoles.delete(gid)
116
129
  fsyncDirs.add(s.groupDir(gid))
117
130
  } catch (e) {
118
131
  console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e)
119
132
  }
120
133
  }
121
- for (const gid of [...dirtyWorkspace]) {
134
+ for (const [gid, v] of [...dirtyWorkspace]) {
122
135
  const g = core.groups.get(gid)
123
136
  if (!g) {
124
137
  dirtyWorkspace.delete(gid)
125
138
  continue
126
139
  }
127
140
  try {
128
- s.atomicWrite(s.workspaceFile(gid), (g.workspaceDir || '') + '\n')
129
- dirtyWorkspace.delete(gid)
141
+ await writeFileAtomic(s.workspaceFile(gid), (g.workspaceDir || '') + '\n', WRITE_FILE_OPTS)
142
+ if (dirtyWorkspace.get(gid) === v) dirtyWorkspace.delete(gid)
130
143
  fsyncDirs.add(s.groupDir(gid))
131
144
  } catch (e) {
132
145
  console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e)
133
146
  }
134
147
  }
135
- if (ledgerDirty) {
148
+ if (ledgerDirty > 0) {
149
+ const v = ledgerDirty
136
150
  try {
137
- s.atomicWrite(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2))
138
- ledgerDirty = false
151
+ await writeFileAtomic(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2), WRITE_FILE_OPTS)
152
+ if (ledgerDirty === v) ledgerDirty = 0
139
153
  fsyncDirs.add(s.dir)
140
154
  } catch (e) {
141
155
  console.error('[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):', e)
@@ -147,15 +161,22 @@ export function createPersistence(core: HostState): Persistence {
147
161
  /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
148
162
  const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null }: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null } = {}): void => {
149
163
  if (store() === null) return
150
- if (ledger) ledgerDirty = true
151
- if (session) dirtySessions.add(session)
152
- if (roleGroup) dirtyRoles.add(roleGroup)
153
- if (workspace) dirtyWorkspace.add(workspace)
164
+ if (ledger) ledgerDirty++
165
+ if (session) mark(dirtySessions, session)
166
+ if (roleGroup) mark(dirtyRoles, roleGroup)
167
+ if (workspace) mark(dirtyWorkspace, workspace)
154
168
  if (flushScheduled) return
155
169
  flushScheduled = true
156
170
  void Promise.resolve().then(() => {
157
171
  flushScheduled = false
158
- flushNow()
172
+ // 排队进串行链;flushAll 按文件吞错(保留脏标记),此处兜底意外
173
+ // 逃逸的异常并把链复位为健康态,后续 flush 不被跳过
174
+ flushChain = flushChain.then(async () => {
175
+ const s = store()
176
+ if (s !== null) await flushAll(s)
177
+ }).catch((e) => {
178
+ console.error('[dsh-group-chat] 落盘 flush 异常(脏标记保留待重试):', e)
179
+ })
159
180
  })
160
181
  }
161
182
 
@@ -311,18 +332,20 @@ export function createPersistence(core: HostState): Persistence {
311
332
 
312
333
  return {
313
334
  schedulePersist,
314
- flushNow,
315
335
  dropDirty,
316
- release(): void {
336
+ async release(): Promise<void> {
317
337
  const s = store()
318
338
  if (s === null) return
339
+ // 立即停止新调度并摘除句柄(后续 schedulePersist 直接 no-op);
340
+ // 持有的 store 引用继续完成最终落盘
341
+ core.store = null
319
342
  try {
320
- flushNow()
343
+ await flushChain
344
+ await flushAll(s)
321
345
  } catch {}
322
346
  try {
323
347
  s.release()
324
348
  } catch {}
325
- core.store = null
326
349
  },
327
350
  }
328
351
  }
@@ -126,7 +126,11 @@ export class Store {
126
126
  } catch {}
127
127
  }
128
128
 
129
- /** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
129
+ /**
130
+ * 同步原子写(tmp+fsync+rename):仅供构造期 v1 迁移(migrateV1)与测试
131
+ * 直用;运行时 flush 走 persistence.ts 的 writeFileAtomic(异步、wx 独占
132
+ * 创建 + 随机后缀 + 符号链接安全,无 per-file fsync,对齐 DSH 基座标准)。
133
+ */
130
134
  atomicWrite(file: string, text: string): void {
131
135
  mkdirSync(dirname(file), { recursive: true })
132
136
  const tmp = `${file}.tmp-${process.pid}`
@@ -28,8 +28,8 @@ export interface GroupChatService {
28
28
  subscribePush(push: () => void): () => void
29
29
  /** 设置停用时中止正在进行的群聊。 */
30
30
  stopAll(): void
31
- /** 卸载/热重载:唤醒确认等待 + kill 子进程 → 同步最终 flush → 释放锁。 */
32
- dispose(): void
31
+ /** 卸载/热重载:唤醒确认等待 + kill 子进程 → 异步最终 flush → 释放锁(cordis 会 await)。 */
32
+ dispose(): Promise<void>
33
33
  }
34
34
 
35
35
  /**
@@ -66,13 +66,13 @@ export function createGroupChatService(ctx: Context): GroupChatService {
66
66
  bus.touch()
67
67
  }
68
68
  },
69
- dispose(): void {
69
+ dispose(): Promise<void> {
70
70
  core.run.stopping = true
71
71
  if (core.run.pendingConfirm) tools.wakeConfirm()
72
72
  tools.killChild()
73
73
  materials.disposeFileSearch()
74
74
  bus.dispose()
75
- persist.release()
75
+ return persist.release()
76
76
  },
77
77
  }
78
78
  }
package/src/host/state.ts CHANGED
@@ -8,9 +8,10 @@
8
8
 
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
- // 类型合并:ctx.llm / ctx.fs / ctx.workspaceRegistry(宿主面)
11
+ // 类型合并:ctx.llm / ctx.fs / ctx.shell / ctx.workspaceRegistry(宿主面)
12
12
  import type {} from '@deepseek-ai/dsh-llm'
13
13
  import type {} from '@deepseek-ai/dsh-fs'
14
+ import type {} from '@deepseek-ai/dsh-shell'
14
15
  import type {} from '@deepseek-ai/dsh-workspace'
15
16
  import type { Store } from './persistence/store.ts'
16
17
  import type { GroupRecord, LastCreated, MessageRecord, RoleRecord, RunState, SessionRecord } from '../core/types.ts'
@@ -36,6 +37,7 @@ export interface HostState {
36
37
  ctx: Context
37
38
  llm: Context['llm']
38
39
  fs: Context['fs']
40
+ shell: Context['shell']
39
41
  groups: Map<string, GroupRecord>
40
42
  sessions: Map<string, SessionRecord>
41
43
  roles: Map<string, RoleRecord>
@@ -59,11 +61,12 @@ export function createHostState(ctx: Context): HostState {
59
61
  ctx,
60
62
  llm: ctx.llm,
61
63
  fs: ctx.fs,
64
+ shell: ctx.shell,
62
65
  groups: new Map(),
63
66
  sessions: new Map(),
64
67
  roles: new Map(),
65
68
  messages: new Map(),
66
- run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null, replaceMessageId: null },
69
+ run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, commandAbort: null, finished: null, replaceMessageId: null },
67
70
  store: null,
68
71
  revision: 1,
69
72
  idSeq: 1,
@@ -1,15 +1,15 @@
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
 
8
- import { spawn, type ChildProcess } from 'node:child_process'
9
9
  import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'
10
10
  import { isAbsolute, join, sep } from 'node:path'
11
11
  import { CMD_CAPTURE_MAX_BYTES, CMD_OUTPUT_MAX_CHARS, READ_FILE_MAX_BYTES, RUN_CMD_TIMEOUT_MS, TOOL_SCHEMAS } from '../../core/tools.ts'
12
- import type { GroupRecord, RunState, ToolExecution } from '../../core/types.ts'
12
+ import type { GroupRecord, PendingConfirm, ToolExecution } from '../../core/types.ts'
13
13
  import type { HostState } from '../state.ts'
14
14
 
15
15
  /** 工具面。 */
@@ -28,7 +28,7 @@ export interface Tools {
28
28
 
29
29
  /** 创建工具面。 */
30
30
  export function createTools(core: HostState, touch: () => void): Tools {
31
- const { run } = core
31
+ const { run, shell } = core
32
32
 
33
33
  /** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
34
34
  const resolveInWorkspace = (root: string, rawPath: unknown): { ok: true, target: string } | { ok: false, error: string } => {
@@ -92,49 +92,44 @@ export function createTools(core: HostState, touch: () => void): Tools {
92
92
  }
93
93
  }
94
94
 
95
- const runCommandTool = (root: string, command: string): Promise<{ status: 'ok' | 'error', output: string }> => new Promise((resolve) => {
96
- let child: ChildProcess
95
+ /**
96
+ * `shell` 服务执行命令(TOOLS.md §2):cwd 与沙箱均收紧到群工作区根
97
+ * (workspace_write 档经 sandboxPolicy 强制;full_access 档对齐 DSH
98
+ * danger-full-access 语义免受限)。超时/中止由执行器 kill 进程并按首因
99
+ * 分类报告;runner 失效(如 SANDBOX_UNAVAILABLE)经 catch 报错不执行。
100
+ */
101
+ const runCommandTool = async (g: GroupRecord, root: string, command: string): Promise<{ status: 'ok' | 'error', output: string }> => {
102
+ const abort = new AbortController()
103
+ run.commandAbort = abort
97
104
  try {
98
- child = spawn('bash', ['-c', command], { cwd: root })
105
+ const spec = shell.resolve({
106
+ command,
107
+ workdir: root,
108
+ timeoutMs: RUN_CMD_TIMEOUT_MS,
109
+ stdoutMaxBytes: CMD_CAPTURE_MAX_BYTES,
110
+ sandboxPolicy: g.permissionTier === 'full_access'
111
+ ? { mode: 'danger-full-access' as const, workspaceRoot: root }
112
+ : { mode: 'workspace-write' as const, workspaceRoot: root },
113
+ signal: abort.signal,
114
+ })
115
+ const r = await shell.run(spec)
116
+ let text = r.stdout.text + r.stderr.text
117
+ if (r.stdout.truncated || r.stderr.truncated) text += '\n[输出采集超限,已截断(保留末尾)]'
118
+ if (r.sandbox?.denied) text += '\n[沙箱拦截了越界的文件操作]'
119
+ if (r.timedOut) text += '\n[执行超时(' + Math.round(RUN_CMD_TIMEOUT_MS / 1000) + 's),已强制终止]'
120
+ if (r.exitCode !== 0 && r.exitCode !== null && !r.timedOut) text += '\n[退出码 ' + r.exitCode + ']'
121
+ if (text.length > CMD_OUTPUT_MAX_CHARS) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(输出超长,已截断)'
122
+ return { status: r.exitCode === 0 ? 'ok' : 'error', output: text || '(无输出)' }
99
123
  } catch (e) {
100
- resolve({ status: 'error', output: '无法启动命令:' + String((e && (e as Error).message) || e) })
101
- return
102
- }
103
- run.childProc = child
104
- let out = ''
105
- let dropped = 0
106
- let timedOut = false
107
- const onChunk = (chunk: Buffer) => {
108
- if (out.length < CMD_CAPTURE_MAX_BYTES) out += chunk.toString('utf8')
109
- else dropped += chunk.length
110
- }
111
- child.stdout?.on('data', onChunk)
112
- child.stderr?.on('data', onChunk)
113
- const timer = setTimeout(() => {
114
- timedOut = true
115
- try {
116
- child.kill('SIGKILL')
117
- } catch {}
118
- }, RUN_CMD_TIMEOUT_MS)
119
- const finish = (result: { status: 'ok' | 'error', output: string }) => {
120
- clearTimeout(timer)
121
- if (run.childProc === child) run.childProc = null
122
- resolve(result)
124
+ return { status: 'error', output: '无法启动命令:' + String((e && (e as Error).message) || e) }
125
+ } finally {
126
+ if (run.commandAbort === abort) run.commandAbort = null
123
127
  }
124
- child.on('error', (e) => finish({ status: 'error', output: '执行失败:' + String((e && e.message) || e) }))
125
- child.on('close', (code) => {
126
- let text = out.length > CMD_CAPTURE_MAX_BYTES ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out
127
- if (dropped > 0) text += '\n[输出采集超限,已丢弃 ' + dropped + ' 字节]'
128
- if (timedOut) text += '\n[执行超时(' + Math.round(RUN_CMD_TIMEOUT_MS / 1000) + 's),已强制终止]'
129
- if (code !== 0 && code !== null && !timedOut) text += '\n[退出码 ' + code + ']'
130
- if (text.length > CMD_OUTPUT_MAX_CHARS) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(输出超长,已截断)'
131
- finish({ status: code === 0 ? 'ok' : 'error', output: text || '(无输出)' })
132
- })
133
- })
128
+ }
134
129
 
135
130
  /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
136
131
  const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
137
- run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as RunState['pendingConfirm'] extends null ? never : NonNullable<RunState['pendingConfirm']>['args'] }
132
+ run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as PendingConfirm['args'] }
138
133
  run.confirmSignal = { resolve }
139
134
  touch()
140
135
  })
@@ -151,11 +146,10 @@ export function createTools(core: HostState, touch: () => void): Tools {
151
146
  }
152
147
 
153
148
  const killChild = (): void => {
154
- if (run.childProc) {
155
- try {
156
- run.childProc.kill('SIGKILL')
157
- } catch {}
158
- }
149
+ // 中止正在执行的命令:执行器收到 abort 信号后 kill 进程
150
+ try {
151
+ run.commandAbort?.abort()
152
+ } catch {}
159
153
  }
160
154
 
161
155
  const executeTool = async (g: GroupRecord, root: string, tc: { id: string, name: string, args: string }): Promise<ToolExecution> => {
@@ -175,9 +169,9 @@ export function createTools(core: HostState, touch: () => void): Tools {
175
169
  const allowed = await requestConfirmation(tc.id, args)
176
170
  if (run.stopping) res = { status: 'error', output: '对话已被用户停止,命令未执行' }
177
171
  else if (!allowed) res = { status: 'denied', output: '用户拒绝了这次命令执行' }
178
- else res = await runCommandTool(root, String(args.command || ''))
172
+ else res = await runCommandTool(g, root, String(args.command || ''))
179
173
  } else {
180
- res = await runCommandTool(root, String(args.command || ''))
174
+ res = await runCommandTool(g, root, String(args.command || ''))
181
175
  }
182
176
  } else {
183
177
  res = { status: 'error', output: '未知工具:' + tc.name }
package/src/index.ts CHANGED
@@ -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, ... }
@@ -36,7 +39,7 @@ import { createGroupChatService, makeGroupChatRoutes } from './host/index.ts'
36
39
 
37
40
  export const name = 'group-chat'
38
41
 
39
- export const inject = ['llm', 'fs', 'webServer', 'workspaceRegistry']
42
+ export const inject = ['llm', 'fs', 'shell', 'webServer', 'workspaceRegistry']
40
43
 
41
44
  /** 设置命名空间;浏览器半拼写同一值,两边不共享代码。 */
42
45
  export const SETTINGS_NAMESPACE = 'group-chat' as SettingsNamespace
@@ -65,10 +68,9 @@ export const apply = mountOnce('dsh-group-chat', (ctx: Context, config?: Config)
65
68
  }
66
69
  }, 'group-chat: host API routes')
67
70
 
68
- // 插件卸载/热重载:唤醒确认等待 + kill 子进程 → 同步最终 flush → 释放锁
69
- ctx.effect(() => () => {
70
- service.dispose()
71
- })
71
+ // 插件卸载/热重载:唤醒确认等待 + kill 子进程 → 异步最终 flush → 释放锁
72
+ // (返回 Promise,cordis 卸载时 await,保证新实例接管前锁已释放)
73
+ ctx.effect(() => () => service.dispose())
72
74
 
73
75
  // ---------- 设置(enabled 持久化于 settings.yaml) ----------
74
76
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, expect, it } from 'vitest'
7
- import { activeAtToken, formatFileMention } from './file-mention-grammar.ts'
7
+ import { activeAtToken } from './file-mention-grammar.ts'
8
8
 
9
9
  describe('activeAtToken', () => {
10
10
  it('extracts plain @path at cursor', () => {
@@ -38,40 +38,3 @@ describe('activeAtToken', () => {
38
38
  expect(activeAtToken('\t@"test', 7)).toEqual({ prefix: '@"test', query: 'test', quoted: true })
39
39
  })
40
40
  })
41
-
42
- describe('formatFileMention', () => {
43
- it('formats plain file without whitespace', () => {
44
- expect(formatFileMention({ kind: 'file', path: 'README.md' }, false)).toBe('@README.md')
45
- expect(formatFileMention({ kind: 'file', path: 'src/index.ts' }, false)).toBe('@src/index.ts')
46
- })
47
-
48
- it('formats plain directory with trailing slash', () => {
49
- expect(formatFileMention({ kind: 'directory', path: 'src' }, false)).toBe('@src/')
50
- expect(formatFileMention({ kind: 'directory', path: 'lib/utils' }, false)).toBe('@lib/utils/')
51
- })
52
-
53
- it('quotes file with whitespace and closes quote', () => {
54
- expect(formatFileMention({ kind: 'file', path: 'My Document.txt' }, false)).toBe('@"My Document.txt"')
55
- expect(formatFileMention({ kind: 'file', path: 'src/my file.ts' }, false)).toBe('@"src/my file.ts"')
56
- })
57
-
58
- it('quotes directory with whitespace but keeps quote open', () => {
59
- expect(formatFileMention({ kind: 'directory', path: 'My Folder' }, false)).toBe('@"My Folder/')
60
- expect(formatFileMention({ kind: 'directory', path: 'src/my lib' }, false)).toBe('@"src/my lib/')
61
- })
62
-
63
- it('preserves quote even when unnecessary', () => {
64
- expect(formatFileMention({ kind: 'file', path: 'plain.txt' }, true)).toBe('@"plain.txt"')
65
- expect(formatFileMention({ kind: 'directory', path: 'lib' }, true)).toBe('@"lib/')
66
- })
67
-
68
- it('rejects paths with control characters', () => {
69
- expect(formatFileMention({ kind: 'file', path: 'file\x00.txt' }, false)).toBeUndefined()
70
- expect(formatFileMention({ kind: 'file', path: 'file\n.txt' }, false)).toBeUndefined()
71
- })
72
-
73
- it('rejects paths with embedded quotes', () => {
74
- expect(formatFileMention({ kind: 'file', path: 'file"name.txt' }, false)).toBeUndefined()
75
- expect(formatFileMention({ kind: 'directory', path: 'dir"name' }, false)).toBeUndefined()
76
- })
77
- })
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Vendored @file token grammar from dsh-file-reference (browser-safe, zero Node API).
3
- *
3
+ *
4
4
  * Sourced from @deepseek-ai/dsh-file-reference/lib/types/grammar.js
5
5
  * Cannot import directly due to client bundle purity gate (dsh-client-bundle-purity).
6
- *
6
+ *
7
+ * 只保留输入侧的 @token 识别(activeAtToken);候选的文本化(formatFileMention)
8
+ * 未被本插件采用——目录钻入走「插入整颗芯片」语义,见 docs/DESIGN.md Composer 契约。
9
+ *
7
10
  * @module dsh-group-chat/shared
8
11
  */
9
12
 
@@ -16,22 +19,17 @@ export interface AtToken {
16
19
  quoted: boolean
17
20
  }
18
21
 
19
- export interface FileCandidate {
20
- kind: 'file' | 'directory'
21
- path: string
22
- }
23
-
24
22
  /**
25
23
  * Extract active @token at cursor position.
26
24
  * Matches @ at word boundary (line start or after whitespace).
27
- *
25
+ *
28
26
  * @param line - Current line text
29
27
  * @param cursor - Cursor offset in line
30
28
  * @returns Token or undefined if no active @token at cursor
31
29
  */
32
30
  export function activeAtToken(line: string, cursor: number): AtToken | undefined {
33
31
  const before = line.slice(0, cursor)
34
-
32
+
35
33
  // Try quoted @"... first (more specific)
36
34
  const quotedMatch = /(?:^|\s)@"([^"]*)$/.exec(before)
37
35
  if (quotedMatch) {
@@ -56,27 +54,3 @@ export function activeAtToken(line: string, cursor: number): AtToken | undefined
56
54
 
57
55
  return undefined
58
56
  }
59
-
60
- /**
61
- * Format file/directory candidate as @mention text.
62
- *
63
- * @param candidate - File or directory to format
64
- * @param preserveQuote - Keep quotes even when unnecessary (for drill-down continuity)
65
- * @returns Formatted @path or undefined for invalid paths
66
- */
67
- export function formatFileMention(candidate: FileCandidate, preserveQuote: boolean): string | undefined {
68
- const { path } = candidate
69
-
70
- // Reject control characters and embedded quotes
71
- if (/[\x00-\x1F"]/.test(path)) return undefined
72
-
73
- const needsQuote = /\s/.test(path)
74
- const useQuote = needsQuote || preserveQuote
75
-
76
- if (candidate.kind === 'file') {
77
- return useQuote ? `@"${path}"` : `@${path}`
78
- } else {
79
- // Directory: trailing slash, quote stays open for drill-down
80
- return useQuote ? `@"${path}/` : `@${path}/`
81
- }
82
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * 聊天消息气泡(user / system / 角色发言)。
3
- * @module dsh-group-chat/client/Bubble
4
- */
5
- import type { ReactNode } from 'react';
6
- import { type ClientSnapshot } from './model.ts';
7
- export declare function Bubble(props: {
8
- snap: ClientSnapshot;
9
- m: ClientSnapshot['messages'][number];
10
- }): ReactNode;
@@ -1,12 +0,0 @@
1
- /**
2
- * 侧边栏入口图标。
3
- *
4
- * 整行命中层携带 “newSession” 类名:任务看板等 DOM 注入式面板用
5
- * [class*="newSession"] 识别“侧边栏导航点击”并自动收起自己,与点击
6
- * “新建会话”行为一致——类名不可哈希化。
7
- * @module dsh-group-chat/client/glyph
8
- */
9
- import type { ReactNode } from 'react';
10
- export declare function Glyph(props: {
11
- size?: number;
12
- }): ReactNode;