bothouse-dsh 0.1.52 → 0.1.54

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.
@@ -53,6 +53,8 @@ export function cleanEnv(env = process.env) {
53
53
  return Object.fromEntries(Object.entries(env).filter(([k]) => KEEP_ENV.has(k) || (!k.startsWith('CLAUDECODE') && !k.startsWith('CLAUDE_CODE_'))))
54
54
  }
55
55
 
56
+ function flattenOptions(options = []) { return options.flatMap(x => Array.isArray(x.options) ? flattenOptions(x.options) : [x]) }
57
+
56
58
  export class CliMember {
57
59
  constructor({ cli, cwd, persona = '', env } = {}) {
58
60
  if (!ADAPTERS[cli]) throw new Error(`unknown cli: ${cli}`)
@@ -81,6 +83,10 @@ export class CliMember {
81
83
  },
82
84
  sessionUpdate: async (n) => {
83
85
  const u = n.update
86
+ if (u?.sessionUpdate === 'config_option_update' && Array.isArray(u.configOptions)) {
87
+ const session = [...this.sessions.values()].find(x => x.sessionId === n.sessionId)
88
+ if (session) session.configOptions = u.configOptions
89
+ }
84
90
  if (u?.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text' && this.buffers.has(n.sessionId)) {
85
91
  // 工具调用隔开的两段话是两条消息:另起一段,[report]/[ask] 这类首行标记才落在行首(team-exit.mjs exitMark)。
86
92
  const prev = this.buffers.get(n.sessionId)
@@ -161,8 +167,8 @@ export class CliMember {
161
167
  this.saved.delete(room)
162
168
  if (prevId && this.canLoad) {
163
169
  try {
164
- await Promise.race([this.conn.loadSession({ sessionId: prevId, cwd: this.cwd, mcpServers: [] }), this.exited])
165
- s = { sessionId: prevId }
170
+ const loaded = await Promise.race([this.conn.loadSession({ sessionId: prevId, cwd: this.cwd, mcpServers: [] }), this.exited])
171
+ s = { sessionId: prevId, configOptions: loaded?.configOptions }
166
172
  this.sessions.set(room, s)
167
173
  return { ...s, resumed: true, lost: false }
168
174
  } catch (err) {
@@ -170,11 +176,73 @@ export class CliMember {
170
176
  }
171
177
  }
172
178
  const r = await Promise.race([this.conn.newSession({ cwd: this.cwd, mcpServers: [] }), this.exited])
173
- s = { sessionId: r.sessionId }
179
+ s = { sessionId: r.sessionId, configOptions: r.configOptions }
174
180
  this.sessions.set(room, s)
175
181
  return { ...s, resumed: false, lost: Boolean(prevId) }
176
182
  }
177
183
 
184
+ // A fresh probe supplies current adapter defaults, including the selected model's effort default.
185
+ // It never prompts and does not touch the existing conversation.
186
+ async _options(model) {
187
+ await this._start()
188
+ const fresh = await Promise.race([this.conn.newSession({ cwd: this.cwd, mcpServers: [] }), this.exited])
189
+ let options = fresh.configOptions || []
190
+ const selector = (id) => options.find(o => o.id === id && o.type === 'select')
191
+ if (model !== undefined) {
192
+ const option = selector('model')
193
+ if (!option) return []
194
+ if (!flattenOptions(option.options).some(o => o.value === model)) { const error = new Error('Codex model is unavailable'); error.code = 'COLLAB_CONFIG_INVALID'; throw error }
195
+ options = (await Promise.race([this.conn.setSessionConfigOption({ sessionId: fresh.sessionId, configId: 'model', value: model }), this.exited])).configOptions || []
196
+ if (selector('model')?.currentValue !== model) throw new Error('Codex did not apply the model')
197
+ }
198
+ return options
199
+ }
200
+
201
+ async probeOptions(model) {
202
+ const options = await this._options(model)
203
+ const select = id => {
204
+ const o = options.find(x => x.id === id && x.type === 'select')
205
+ return o ? { current: o.currentValue ?? null, options: flattenOptions(o.options).map(x => ({ value: x.value, name: x.name })) } : null
206
+ }
207
+ const modelOption = select('model')
208
+ return modelOption ? { supported: true, model: modelOption, reasoningEffort: select('reasoning_effort') } : { supported: false, model: null, reasoningEffort: null }
209
+ }
210
+
211
+ // Keep probe sessions in a short-lived process, not in the conversation adapter.
212
+ async _defaultOptions(model) {
213
+ const probe = new CliMember({ cli: this.cli, cwd: this.cwd, env: this.extraEnv })
214
+ try { return await probe._options(model) } finally { probe.close() }
215
+ }
216
+
217
+ async _configure(s, desired) {
218
+ const get = (opts, id) => opts?.find(o => o.id === id && o.type === 'select')
219
+ const signature = JSON.stringify([desired?.model ?? null, desired?.reasoningEffort ?? null])
220
+ const snapshot = s.appliedConfig
221
+ if (snapshot?.signature === signature && snapshot.values.every(([id, value]) => get(s.configOptions, id)?.currentValue === value)) return
222
+ const stored = [...this.sessions.values()].find(x => x.sessionId === s.sessionId)
223
+ if (stored) stored.appliedConfig = undefined
224
+ const defaults = await this._defaultOptions(desired?.model)
225
+ const model = desired?.model ?? get(defaults, 'model')?.currentValue
226
+ const effort = desired?.reasoningEffort ?? get(defaults, 'reasoning_effort')?.currentValue
227
+ if (!model) throw new Error('Codex model selection is unsupported')
228
+ let options = s.configOptions || []
229
+ for (const [id, value] of [['model', model], ['reasoning_effort', effort]]) {
230
+ if (value === undefined || value === null) continue
231
+ const option = get(options, id)
232
+ if (!option || !flattenOptions(option.options).some(o => o.value === value)) throw new Error(`Codex ${id} selection is unavailable`)
233
+ if (option.currentValue !== value) {
234
+ options = (await Promise.race([this.conn.setSessionConfigOption({ sessionId: s.sessionId, configId: id, value }), this.exited])).configOptions || []
235
+ if (stored) stored.configOptions = options
236
+ }
237
+ if (get(options, id)?.currentValue !== value) throw new Error(`Codex ${id} did not apply`)
238
+ }
239
+ s.configOptions = options
240
+ if (stored) {
241
+ stored.configOptions = options
242
+ stored.appliedConfig = { signature, values: [['model', model], ['reasoning_effort', effort]].filter(([, value]) => value != null) }
243
+ }
244
+ }
245
+
178
246
  /**
179
247
  * onText(textSoFar) 在每个公开文字分片到达时调用(#121);onTool(update) 在每个工具调用开始/更新和每个思考分片到达时调用。
180
248
  * opts.resumeId:要续上的 sessionId;opts.onSession({ sessionId, resumed, lost }):会话就绪后、发提示词前调用。
@@ -188,9 +256,10 @@ export class CliMember {
188
256
  return run
189
257
  }
190
258
 
191
- async _ask(text, room, onText, onTool, { resumeId, onSession, attachments = [], onPermission } = {}) {
259
+ async _ask(text, room, onText, onTool, { resumeId, onSession, attachments = [], onPermission, cliConfig, cliConfigManaged } = {}) {
192
260
  const s = await this._session(room, resumeId)
193
- await onSession?.(s)
261
+ if (this.cli === 'codex' && cliConfigManaged) await this._configure(s, cliConfig)
262
+ await onSession?.({ sessionId: s.sessionId, resumed: s.resumed, lost: s.lost })
194
263
  // persona 每轮都拼:续上的或新开的会话都不会丢人设。
195
264
  const body = this.persona ? `${this.persona}\n\n${text}` : text
196
265
  this.buffers.set(s.sessionId, '')
@@ -385,7 +385,7 @@ export class DshMembers {
385
385
  this.openTurns = new Map();
386
386
  /** Agents whose request hook was installed after they were loaded by someone else. */
387
387
  this.hooked = new WeakSet();
388
- /** memberId -> { cli, agent } live local CLI agents (members with a `cli` field). */
388
+ /** JSON([memberId, cwd, roomId]) -> { memberId, cli, agent } live local CLI agents (members with a `cli` field). */
389
389
  this.cliAgents = new Map();
390
390
  /** #557: permission cards of channel-started turns, shared with cloud-web bots. */
391
391
  this.approvals = ChannelApprovals.of(hub);
@@ -669,7 +669,10 @@ export class DshMembers {
669
669
  if (!this.store.markSent(job.id, sessionId, 0, undefined)) return;
670
670
  const cwd = this.store.bindingWorkspacePath(member.id, room.id, this.store.workspacePath(room.id, this.rt.workspacePath));
671
671
  this.store.setBindingWorkspacePath(member.id, room.id, cwd);
672
- const cliKey = JSON.stringify([member.id, cwd]);
672
+ // The room is part of the key: a CLI process gets its env once, at spawn (cli-member.mjs _start), and
673
+ // CC_SESSION_KEY names the room memvote.py records a citation under (memory-sources.mjs keyRoom). Keyed
674
+ // by (member, cwd) alone, the turn in room 2 kept the process of room 1 and voted/recalled room 1.
675
+ const cliKey = JSON.stringify([member.id, cwd, room.id]);
673
676
  let entry = this.cliAgents.get(cliKey);
674
677
  if (entry === undefined) {
675
678
  // Persona goes through promptText every turn, so an edit applies without restarting the CLI.
@@ -737,8 +740,9 @@ export class DshMembers {
737
740
  };
738
741
  let done = false;
739
742
  try {
740
- this.store.advanceCursor(member.id, job.room_id, job.trigger_id);
741
743
  const onSession = ({ sessionId: acpId, lost }) => {
744
+ this.store.advanceCursor(member.id, job.room_id, job.trigger_id);
745
+ wiki.commit();
742
746
  if (this.store.binding(member.id, room.id)?.session_id !== acpId) this.store.rebind(member.id, room.id, acpId);
743
747
  if (lost) {
744
748
  // the CLI's new session has none of what this conversation was given: the next turn gives it again
@@ -751,8 +755,7 @@ export class DshMembers {
751
755
  }
752
756
  };
753
757
  const prompt = promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id);
754
- wiki.commit();
755
- const text = (await entry.agent.ask(prompt, String(room.id), onText, onTool, { resumeId, onSession, onPermission, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
758
+ const text = (await entry.agent.ask(prompt, String(room.id), onText, onTool, { resumeId, onSession, onPermission, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
756
759
  if (this.closed) return;
757
760
  this.hub.quota?.observe(member.id, room.id, { error: text });
758
761
  if (!text) { this.fail(job.id, `${member.name} 这一轮没有给出回复(${member.cli} 没有输出文字)`); return; }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "_doc": [
3
- "交接校验规则,只此一份:ws-handoff(_handoff_rules.py)和 DSH collab(handoff-rules.mjs:建工作区交接、/tasks 派活)都读它。",
3
+ "交接校验规则(产品默认,只查通用必填项):ws-handoff(_handoff_rules.py)和 DSH collab(handoff-rules.mjs:建工作区交接、/tasks 派活)都读它。",
4
+ "必填只有「用户原话」「总目标」「范围」(至少一行「改:…」)和「参照实现」(文件:行,或「无(新建)」);另外不许引用 /tmp、浏览器活要写标签页 id、多人改要写底分支。",
5
+ "严格规则在 handoff-rules.strict.json,默认不加载;在数据目录的 collaboration/handoff-rules.local.json 写 {\"profile\": \"strict\"} 才叠加。local 文件的 checks 按 id 覆盖或追加,flags 打开 ws-handoff 的额外关卡(require_short、cli_gate)。DSH collab 只读本文件。",
4
6
  "标题行 = 去掉前导空白后以 # 开头的行;一节 = 从标题(或 inline 时以节名开头的行)到下一个标题。lead 是行首可去掉的字符。",
5
7
  "正则只写 ASCII 字符类(JS 的 \\w、\\d 只认 ASCII,Python 认 Unicode),按 search 用,要锚定就写 ^。",
6
- "checks 按顺序跑,报第一条不合格的。dispatch 列出只带字段的派活(/tasks:title、ref、base)要跑的检查:字段拼成一份交接再查。",
7
- "改规则后跑 contrib/lead/test_handoff_rules.py(Python 与 Node 跑同一组 fixture 比对)。",
8
- "art_ref:出图类活(总目标/范围提到画、出图、小人、角色图、封面、立绘、Q版等,且范围的「改」行没写代码文件)要有「参考图」节(或「参考图:」行),列至少 min 张参考图路径或链接;写「原创:无现成图」的创意活照样要 min 张风格/构图参考,另写先出小样给用户挑。交回 [report] 要带对照图(team-exit-rules.json 的 art)。起因:用户 11-01「每次都给我画一坨屎出来」,规矩写在文字里没人执行,改成检查。",
9
- "reuse:参照实现写「无(新建)」时必须有「复用盘点:」行。起因:用户 10-30 两次、11-02 一次骂「又自己写一套、不复用记忆论坛」,同类错第 3 次改成检查。"
8
+ "checks 按顺序跑;ws-handoff 一次列出所有不合格项,DSH collab 报第一条。dispatch 列出只带字段的派活(/tasks:title、ref、base)要跑的检查:字段拼成一份交接再查。",
9
+ "改规则后跑 contrib/lead/test_handoff_rules.py(Python 与 Node 跑同一组 fixture 比对)。"
10
10
  ],
11
11
  "lead": "#-*> \t",
12
12
  "checks": [
@@ -16,11 +16,7 @@
16
16
  "names": [
17
17
  "用户原话",
18
18
  "总目标",
19
- "范围",
20
- "已查过什么",
21
- "进度",
22
- "相关改动",
23
- "规矩"
19
+ "范围"
24
20
  ],
25
21
  "msg": "交接文件缺小节:{missing}",
26
22
  "sep": "、"
@@ -36,49 +32,17 @@
36
32
  "msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:\n## 参照实现\n- bridge/contrib/lead/ws-up:120(和它的差异:…)",
37
33
  "dispatch_msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:ref: \"dsh/collab/tasks.mjs:226\",或 ref: \"无(新建)\""
38
34
  },
39
- {
40
- "id": "reuse",
41
- "type": "needs_if",
42
- "trigger_re": "无\\s*[((]\\s*新建\\s*[))]",
43
- "need_re": "复用盘点\\s*[::]\\s*\\S.{5,}",
44
- "msg": "参照实现写了「无(新建)」,就要加一行「复用盘点:」,写清查过哪些现成能力(记忆论坛推送/打分、夜间反思、dream 手册、ws-handoff 续做、已有脚本或 skill 等,带 文件:行 或名字)、为什么都不能用。起因:用户 10-30、11-02 三次骂「又自己写一套」。示例:\n复用盘点:查过 ~/.bothouse/gate-exp/ctxsvc.py:355 推送、~/wiki/bin/manuals.py 夜里手册,都管不到 X,所以新建"
45
- },
46
- {
47
- "id": "checked",
48
- "type": "ref",
49
- "section": "已查过什么",
50
- "none_re": "无\\s*[((]\\s*没查过",
51
- "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*(:[0-9]+|\\.[A-Za-z]+)",
52
- "path_re": "[/.]",
53
- "msg": "「已查过什么」节要写已查过的 文件:行 或文档路径,下一棒不用重查;什么都没查过写「无(没查过)」。示例:\n## 已查过什么\n- dsh/collab/tasks.mjs:467 assign 只校验 ref/base\n- ~/work/codemaps/bothouse-bridge.md「编译与测试」节"
54
- },
55
35
  {
56
36
  "id": "scope",
57
37
  "type": "kinds",
58
38
  "section": "范围",
59
39
  "line_re": "^(不?改)\\s*[::]\\s*\\S",
60
40
  "need": [
61
- "改",
62
- "不改"
41
+ "改"
63
42
  ],
64
- "max": {
65
- "kind": "改",
66
- "n": 3,
67
- "msg": "「范围」里「改」有 {n} 项,超过 3 项:拆成几件分别交,每件只管 1-3 项。"
68
- },
69
43
  "item": "{}:…",
70
44
  "sep": "」「",
71
- "msg": "「范围」节缺「{missing}」行:用户原话里的名词逐个写改或不改,没有不改的写「不改:无」。示例:\n## 范围\n- 改:任务卡(只在工作区显示)\n- 不改:进度表、聊天频道的每轮回复"
72
- },
73
- {
74
- "id": "conflict",
75
- "type": "values",
76
- "section": "相关改动",
77
- "line_re": "^冲突\\s*[::]\\s*(.*)",
78
- "ok_re": "^无\\s*([((].*[))])?\\s*[。.;;]?$",
79
- "or_re": "已问用户\\s*[::]\\s*\\S",
80
- "msg": "「相关改动」节缺一行「冲突:无」或「冲突:…(已问用户:<答复>)」。示例:\n## 相关改动\n- #612 已合,改了 core/handoff.go,和这件不重叠\n冲突:无",
81
- "bad_msg": "相关改动里写了冲突、没写已问用户:先在聊天频道问用户,把答复写进相关改动(「冲突:…(已问用户:<答复>)」)"
45
+ "msg": "「范围」节缺「{missing}」行:写清这件要改什么,一行一项。示例:\n## 范围\n- 改:任务卡(只在工作区显示)\n- 不改:聊天频道的每轮回复"
82
46
  },
83
47
  {
84
48
  "id": "base",
@@ -91,63 +55,6 @@
91
55
  "msg": "交接文件写了多人改同组文件(「共改文件」/「多人改」节),缺「底分支:<分支名>」一行。示例:\n底分支:feat/approvals-card",
92
56
  "dispatch_msg": "多人改同组文件要写底分支:base 填分支名,示例:base: \"feat/approvals-card\""
93
57
  },
94
- {
95
- "id": "art_ref",
96
- "type": "art_ref",
97
- "trigger_sections": [
98
- "总目标",
99
- "范围"
100
- ],
101
- "trigger_re": "出图|生图|小人|角色图|立绘|[Qq] ?版|插画|海报|宣传图|配图|封面图|做封面|画(一|个|张|幅|出)?[张个幅]?(角色|人物|头像|封面|画像|插图)",
102
- "code_section": "范围",
103
- "code_line_re": "^改\\s*[::].*([A-Za-z0-9_-][.](py|go|mjs|js|ts|tsx|json|sh|yml|yaml|toml|html|css)([^A-Za-z0-9]|$)|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
104
- "section": "参考图",
105
- "inline": true,
106
- "image_re": "https?://[^ \t<>()()「」\"'`,,、;;]+|[^ \t<>()()「」\"'`,,、;;::]+[.](png|jpg|jpeg|webp|gif|avif|PNG|JPG|JPEG|WEBP)",
107
- "min": 2,
108
- "original_re": "原创\\s*[::]\\s*无现成图",
109
- "sample_re": "小样",
110
- "msg": "这是出图类活,缺参考图:先找官方图或现成高分图当参考,写一节「参考图」列至少 {min} 张图的路径或链接(现在 {n} 张)。真是原创没有现成图,写「原创:无现成图」,照样列 {min} 张风格/构图参考(同类作品、画风、配色都行),并写明先出 3 张小样给用户挑,挑定再出正稿。示例:\n## 参考图\n- ~/art/ref/官方立绘.png\n- https://example.com/高分同人.jpg",
111
- "original_msg": "写了「原创:无现成图」,还要写明先出 3 张小样给用户挑、挑定再出正稿(正文里要有「小样」)。"
112
- },
113
- {
114
- "id": "exemplar",
115
- "type": "section_needs_table",
116
- "trigger_section": "用户原话",
117
- "trigger_re": "「(?!\\[replying)[^」]*(照着|照抄|抄|学习|学一下|参考|对标|模仿|仿照)",
118
- "object_re": "https?://|《[^》]+》",
119
- "section": "照抄对象",
120
- "row_re": "^\\S.*(→|->)\\s*\\S",
121
- "rows": 3,
122
- "need_res": [
123
- "https?://|《[^》]+》",
124
- "并排截图"
125
- ],
126
- "msg": "用户原话给了范本(链接或《…》+ 照着/抄/学习/参考/对标),交接要有「照抄对象」节:范本链接、「范本第 N 部分 → 我们第 N 部分」对照至少 3 行、验收写「并排截图」。示例:\n## 照抄对象\n- 范本:https://github.com/BotHarness/DeepSeekBot 的 README\n- 范本第 1 部分 顶部插画横幅 → 我们第 1 部分 顶部横幅\n- 范本第 2 部分 明暗两版截图 → 我们第 2 部分 功能截图\n- 范本第 3 部分 三行安装 → 我们第 3 部分 安装\n- 验收:并排截图(范本和我们同一部分左右放)"
127
- },
128
- {
129
- "id": "hq_kinds",
130
- "type": "kinds",
131
- "section": "总部判断",
132
- "line_re": "^(要的效果|好的样子|教训和定案)\\s*[::]\\s*\\S.{9,}",
133
- "need": [
134
- "要的效果",
135
- "好的样子",
136
- "教训和定案"
137
- ],
138
- "item": "{}:…",
139
- "sep": "」「",
140
- "msg": "交接缺「总部判断」或写得太空:要有「{missing}」行,每行写满一句(10 字以上),写清要的效果、好的样子、教训和定案。"
141
- },
142
- {
143
- "id": "hq_ref",
144
- "type": "ref",
145
- "section": "总部判断",
146
- "none_re": "(?!)",
147
- "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*:[0-9]+|[0-9]{1,2}-[0-9]{2}",
148
- "path_re": "[/.]|^[0-9]{1,2}-[0-9]{2}$",
149
- "msg": "「总部判断」没写出处:至少带一处文档 文件:行,或原话的日期(如 11-01)。"
150
- },
151
58
  {
152
59
  "id": "no_tmp",
153
60
  "type": "forbid",
@@ -0,0 +1,46 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+
5
+ export const LOCK_FILE = 'collab.lock';
6
+
7
+ /**
8
+ * #1019: one DSH process per collab data directory. Two hosts on the same collab.sqlite3 both run
9
+ * reminders, recovery and bot routing, so every job and message is handled twice.
10
+ *
11
+ * The lock is an exclusive SQLite transaction on a separate file: SQLite takes a POSIX lock, the
12
+ * kernel drops it when the process dies however it dies, so a crash never leaves a stale lock and
13
+ * no pid file has to be guessed at. busy_timeout 0 makes the second taker fail at once.
14
+ * Nothing is written to collab.sqlite3 itself.
15
+ */
16
+ export class InstanceLock {
17
+ /** @returns InstanceLock, or undefined when another process already holds `path`. */
18
+ static acquire(path) {
19
+ mkdirSync(dirname(path), { recursive: true });
20
+ const db = new DatabaseSync(path);
21
+ try {
22
+ db.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE');
23
+ } catch (err) {
24
+ db.close();
25
+ if (/locked|busy/i.test(String(err?.message))) return undefined;
26
+ throw err;
27
+ }
28
+ return new InstanceLock(db);
29
+ }
30
+
31
+ constructor(db) { this.db = db; }
32
+
33
+ release() {
34
+ if (this.db === undefined) return;
35
+ const db = this.db;
36
+ this.db = undefined;
37
+ try { db.exec('ROLLBACK'); } catch { /* closing drops the lock anyway */ }
38
+ db.close();
39
+ }
40
+ }
41
+
42
+ /** Plain-language reason shown in the DSH log when the plugin refuses to start. */
43
+ export function lockBusyMessage(path) {
44
+ return `conest-collab: 频道插件没有启动——这台电脑上已经有另一个 DSH 在用同一份频道数据(${path})。`
45
+ + '两个同时跑会让提醒、派活和消息各处理两遍。请用已经打开的那个 DSH;要换就先关掉它再打开。';
46
+ }
@@ -0,0 +1,75 @@
1
+ import { CollabError } from './store.mjs';
2
+ import { rowToMember } from './roster.mjs';
3
+ import { createCliMember } from './cli-member.mjs';
4
+
5
+ const fields = new Set(['name', 'persona', 'cli', 'model', 'cliConfig']);
6
+ function check(body, create = false) {
7
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !fields.has(k))) throw new CollabError('invalid', 'invalid member fields');
8
+ if (create && (typeof body.name !== 'string' || !body.name.trim())) throw new CollabError('invalid', 'name is required');
9
+ if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) throw new CollabError('invalid', 'name is required');
10
+ if (body.persona !== undefined && typeof body.persona !== 'string') throw new CollabError('invalid', 'persona must be a string');
11
+ }
12
+ function view(m, source = m.source) {
13
+ return { id: m.id, name: m.name, source, can_edit: source === 'ui', can_delete: source === 'ui', persona: m.persona ?? '',
14
+ cli: m.cli ?? null, model: m.model ?? null, cliConfig: m.cliConfig ?? null,
15
+ apply: { timing: 'next_turn', status: m.cliConfigManaged ? 'pending' : 'not_configured' } };
16
+ }
17
+ export function managementMembers(roster, hub) {
18
+ const rows = roster.store.members().map(rowToMember);
19
+ const seen = new Set(rows.map(m => m.id));
20
+ return [...rows.map(m => view(m)), ...hub.memberList().filter(m => m.kind !== 'human' && !seen.has(m.id)).map(m => view(m, 'external'))];
21
+ }
22
+ async function validateChoices(roster, member, probe, id) {
23
+ const config = member.cliConfig;
24
+ if (!config || !Object.keys(config).length) return;
25
+ const result = await probe({ cli: 'codex', ...(id ? { memberId: id } : {}), ...(config.model ? { model: config.model } : {}) });
26
+ if (!result.supported) throw new CollabError('invalid', 'Codex configuration is unsupported');
27
+ if (config.model && !result.model?.options.some(x => x.value === config.model)) throw new CollabError('invalid', 'Codex model is unavailable');
28
+ if (config.reasoningEffort && !result.reasoningEffort?.options.some(x => x.value === config.reasoningEffort)) throw new CollabError('invalid', 'Codex reasoning effort is unavailable');
29
+ }
30
+ export async function managementCreate(roster, body, probe) {
31
+ check(body, true);
32
+ if (body.cli === null) body = { ...body, cli: undefined };
33
+ if (probe && body.cliConfig != null) await validateChoices(roster, await roster.validate(body), probe);
34
+ return view(await roster.create(body));
35
+ }
36
+ export async function managementUpdate(roster, id, body, probe) {
37
+ check(body);
38
+ const row = roster.store.member(id);
39
+ if (!row || row.removed) throw new CollabError('member-missing', 'member not found');
40
+ if (row.source !== 'ui') throw new CollabError('read-only', 'member is read-only');
41
+ const old = rowToMember(row);
42
+ if (Object.hasOwn(body, 'cli') && body.cli !== (old.cli ?? null) && roster.store.openJobs().some(j => j.member_id === id && j.state === 'sent')) throw new CollabError('member-busy', 'member is busy');
43
+ const merged = { ...old, ...body };
44
+ if (Object.hasOwn(body, 'cliConfig') && body.cliConfig != null && merged.cli !== 'codex') throw new CollabError('invalid', 'cliConfig requires a Codex member');
45
+ if (Object.hasOwn(body, 'cli') && body.cli !== old.cli) {
46
+ if (!Object.hasOwn(body, 'model')) merged.model = null;
47
+ if (!Object.hasOwn(body, 'cliConfig')) merged.cliConfig = null;
48
+ }
49
+ if (body.cliConfig && typeof body.cliConfig === 'object' && !Array.isArray(body.cliConfig)) {
50
+ merged.cliConfig = { ...(old.cliConfig || {}), ...body.cliConfig };
51
+ for (const k of Object.keys(merged.cliConfig)) if (merged.cliConfig[k] === null) delete merged.cliConfig[k];
52
+ }
53
+ if (merged.cli == null) { merged.cli = undefined; merged.cliConfig = undefined; }
54
+ if (probe && Object.hasOwn(body, 'cliConfig') && merged.cliConfig != null) await validateChoices(roster, await roster.validate(merged, id), probe, old.cli === 'codex' ? id : undefined);
55
+ return view(await roster.update(id, merged));
56
+ }
57
+ export function managementDelete(roster, id) {
58
+ const row = roster.store.member(id);
59
+ if (!row || row.removed) throw new CollabError('member-missing', 'member not found');
60
+ if (row.source !== 'ui') throw new CollabError('read-only', 'member is read-only');
61
+ if (roster.store.openJobs().some(j => j.member_id === id && j.state === 'sent')) throw new CollabError('member-busy', 'member is busy');
62
+ roster.remove(id);
63
+ }
64
+ export async function managementOptions(roster, body, cwd) {
65
+ if (!body || body.cli !== 'codex' || Object.keys(body).some(k => !['cli','memberId','model'].includes(k)) || (body.model !== undefined && (typeof body.model !== 'string' || !body.model))) throw new CollabError('invalid', 'invalid capability request');
66
+ if (body.memberId !== undefined) {
67
+ const row = roster.store.member(body.memberId);
68
+ if (!row || row.removed) throw new CollabError('member-missing', 'member not found');
69
+ if (row.source !== 'ui' || row.cli !== 'codex') throw new CollabError('read-only', 'member cannot be probed');
70
+ }
71
+ const probe = createCliMember({ cli: 'codex', cwd });
72
+ try { return await probe.probeOptions(body.model); }
73
+ catch (error) { throw new CollabError(error.code === 'COLLAB_CONFIG_INVALID' ? 'invalid' : 'offline', error.code === 'COLLAB_CONFIG_INVALID' ? 'Codex model is unavailable' : 'Codex capability probe failed'); }
74
+ finally { probe.close(); }
75
+ }
package/collab/plugin.mjs CHANGED
@@ -10,12 +10,14 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
10
10
  import { isAbsolute, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { Store, CollabError, publicMessage } from './store.mjs';
13
+ import { InstanceLock, LOCK_FILE, lockBusyMessage } from './instance-lock.mjs';
13
14
  import { startBotPort } from './bot-port.mjs';
14
15
  import { Hub } from './hub.mjs';
15
16
  import { TeamExit } from './team-exit.mjs';
16
17
  import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
17
18
  import { DshMembers } from './dsh-member.mjs';
18
19
  import { Roster, rowToMember } from './roster.mjs';
20
+ import { managementMembers, managementCreate, managementUpdate, managementDelete, managementOptions } from './member-management.mjs';
19
21
  import { Tasks, WORKING } from './tasks.mjs';
20
22
  import { createCliMember } from './cli-member.mjs';
21
23
  import { MemorySources } from './memory-sources.mjs';
@@ -134,10 +136,37 @@ export async function apply(ctx, rawConfig) {
134
136
  const { WebSocketServer } = createRequire(import.meta.url)('ws');
135
137
  if (typeof createUserMessage !== 'function' || typeof WebSocketServer !== 'function') throw Error('conest-collab: unsupported DSH runtime');
136
138
 
139
+ // #1019: a second DSH on the same data directory refuses to start the plugin instead of
140
+ // sharing collab.sqlite3 (the panel's 「打开 DSH」 used to start one beside a running host).
141
+ const lockPath = join(config.dataDir, LOCK_FILE);
142
+ const lock = InstanceLock.acquire(lockPath);
143
+ if (lock === undefined) throw Error(lockBusyMessage(lockPath));
144
+ // Teardown is registered before anything is opened, so a startup that fails half-way still
145
+ // stops whatever it started; the lock is released last, after the store and the background
146
+ // writers are closed, so no second host gets in while this one can still write.
147
+ const r = {};
148
+ ctx.effect(() => async () => {
149
+ r.teamStatus?.stop();
150
+ r.desk?.stop();
151
+ r.quota?.close();
152
+ r.later?.close();
153
+ r.tasks?.close();
154
+ r.gateway?.closeAll();
155
+ clearInterval(r.pairSweep);
156
+ clearInterval(r.aliveSweep);
157
+ await r.pairListener?.close();
158
+ for (const end of r.streams ?? []) end();
159
+ r.wss?.close();
160
+ // Drain DSH jobs first: a job still awaiting its Agent must not touch a closed store.
161
+ await r.dsh?.close();
162
+ r.store?.close();
163
+ lock.release();
164
+ }, 'conest-collab: teardown');
165
+
137
166
  // #1044: an install from before had the desk, panel and other-computers pages; checked before Store creates the file.
138
167
  const collabExisted = existsSync(join(config.dataDir, 'collab.sqlite3'));
139
168
  // #445: the instance maxRelay stays the default for channels that never set their own cap.
140
- const store = new Store(join(config.dataDir, 'collab.sqlite3'), { maxRelay: config.maxRelay });
169
+ const store = r.store = new Store(join(config.dataDir, 'collab.sqlite3'), { maxRelay: config.maxRelay });
141
170
  for (const room of config.rooms) {
142
171
  store.ensureRoom(room.id, store.room(room.id)?.name ?? room.name.trim(), room.workspacePath);
143
172
  store.ensureRoomSettings(room.id);
@@ -150,18 +179,18 @@ export async function apply(ctx, rawConfig) {
150
179
  hub.teamExit = new TeamExit(hub);
151
180
  hub.setMember({ id: OWNER, name: config.ownerName, kind: 'human', online: true });
152
181
  const files = new Attachments(join(config.dataDir, 'attachments'));
153
- const gateway = new CloudWebGateway(hub, { token: config.botToken, logger: ctx.logger, files });
154
- const later = new Later(hub, join(config.dataDir, 'collab.sqlite3'), (id, msg) => dsh.nudge(id, msg) || gateway.nudge(id, msg), { start: false });
182
+ const gateway = r.gateway = new CloudWebGateway(hub, { token: config.botToken, logger: ctx.logger, files });
183
+ const later = r.later = new Later(hub, join(config.dataDir, 'collab.sqlite3'), (id, msg) => dsh.nudge(id, msg) || gateway.nudge(id, msg), { start: false });
155
184
  gateway.later = later;
156
- const dsh = new DshMembers(hub, { files, later, ctx, createUserMessage, createCliMember, workspacePath: config.workspacePath, logger: ctx.logger }, [], { maxRelay: config.maxRelay });
185
+ const dsh = r.dsh = new DshMembers(hub, { files, later, ctx, createUserMessage, createCliMember, workspacePath: config.workspacePath, logger: ctx.logger }, [], { maxRelay: config.maxRelay });
157
186
  const avatarUploads = new AvatarUploads(join(config.dataDir, 'avatars'));
158
187
  const roster = new Roster(hub, dsh, ctx.llm, avatarUploads);
159
188
  // #139 B: dispatched tasks; a reminder wakes its owner whichever kind of member it is.
160
189
  // Started after dsh.recover(): a reminder due during the downtime must not look like a leftover job.
161
- const tasks = new Tasks(hub, { start: false });
190
+ const tasks = r.tasks = new Tasks(hub, { start: false });
162
191
  hub.tasks = tasks;
163
192
  gateway.tasks = tasks;
164
- const quota = new QuotaCooling(hub, tasks, (id, msg) => dsh.nudge(id,msg) || gateway.nudge(id,msg), quotaDirectory(config.leadFile));
193
+ const quota = r.quota = new QuotaCooling(hub, tasks, (id, msg) => dsh.nudge(id,msg) || gateway.nudge(id,msg), quotaDirectory(config.leadFile));
165
194
  hub.quota = quota;
166
195
  tasks.onWake((memberId, message) => dsh.nudge(memberId, message) || gateway.nudge(memberId, message));
167
196
  // A workspace released after its last task shows up free in the room list. Its sessions are
@@ -186,10 +215,10 @@ export async function apply(ctx, rawConfig) {
186
215
  later.start();
187
216
  quota.start();
188
217
  // #876: only the plugin's own tail touches board-events.jsonl; board.py writes it.
189
- const desk = new DeskEvents(hub, { boardDir: config.boardDir, logger: ctx.logger });
218
+ const desk = r.desk = new DeskEvents(hub, { boardDir: config.boardDir, logger: ctx.logger });
190
219
  desk.start();
191
220
  // #876 K: read-only busy/idle from the shared team directory; no bridge change.
192
- const teamStatus = config.teamStatusDir === undefined ? undefined : new TeamStatus(hub, config.teamStatusDir, { logger: ctx.logger });
221
+ const teamStatus = r.teamStatus = config.teamStatusDir === undefined ? undefined : new TeamStatus(hub, config.teamStatusDir, { logger: ctx.logger });
193
222
  teamStatus?.start();
194
223
  if (!config.botToken) ctx.logger.warn(`conest-collab: ${config.botTokenEnv} is unset; cc-connect bots cannot connect`);
195
224
 
@@ -201,20 +230,20 @@ export async function apply(ctx, rawConfig) {
201
230
  const pairPort = Number(process.env.CONEST_COLLAB_PAIR_PORT) || PAIR_PORT;
202
231
  const pairHost = new PairHost({ file: join(config.dataDir, 'pair-devices.json') });
203
232
  const pairClient = new PairClient({ file: join(config.dataDir, 'pair-links.json'), port: pairPort });
204
- const pairListener = new PairListener({
233
+ const pairListener = r.pairListener = new PairListener({
205
234
  host: pairHost, port: pairPort, logger: ctx.logger,
206
235
  address: pairAddr === '' ? () => tailnetAddress() : async () => pairAddr,
207
236
  handler: hostHandler({ host: pairHost, name: hostname(), route: (req, res) => browserRoute(req, res), allowLoopback: isLoopback(pairAddr) }),
208
237
  });
209
238
  if (pairHost.list().length > 0) pairListener.ensure().catch(err => ctx.logger.warn('conest-collab: pairing listener: %s', err.message));
210
239
  // 来源:码有效期按分钟计(pairing.mjs CODE_TTL_MS),每分钟收一次没人用的配对监听。
211
- const pairSweep = setInterval(() => { pairListener.settle().catch(() => {}); }, 60_000);
240
+ const pairSweep = r.pairSweep = setInterval(() => { pairListener.settle().catch(() => {}); }, 60_000);
212
241
  pairSweep.unref?.();
213
242
  // 「直到我断开」: drop links whose DSH window stopped beating (same cadence as the beat).
214
- const aliveSweep = setInterval(() => { pairClient.sweep().catch(() => {}); }, ALIVE_EVERY_MS);
243
+ const aliveSweep = r.aliveSweep = setInterval(() => { pairClient.sweep().catch(() => {}); }, ALIVE_EVERY_MS);
215
244
  aliveSweep.unref?.();
216
- const wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
217
- const streams = new Set();
245
+ const wss = r.wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
246
+ const streams = r.streams = new Set();
218
247
 
219
248
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
220
249
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/rooms', handler: (req, res) => historyRoute(req, res) }), 'conest-collab: /api/rooms');
@@ -241,22 +270,6 @@ export async function apply(ctx, rawConfig) {
241
270
  err => { ctx.logger.warn('conest-collab: bot port %d not opened: %s', config.botPort, err.message); return undefined; });
242
271
  ctx.effect(() => async () => { (await listening)?.close(); }, 'conest-collab: bot port');
243
272
  }
244
- ctx.effect(() => async () => {
245
- teamStatus?.stop();
246
- desk.stop();
247
- quota.close();
248
- later.close();
249
- tasks.close();
250
- gateway.closeAll();
251
- clearInterval(pairSweep);
252
- clearInterval(aliveSweep);
253
- await pairListener.close();
254
- for (const end of streams) end();
255
- wss.close();
256
- // Drain DSH jobs first: a job still awaiting its Agent must not touch a closed store.
257
- await dsh.close();
258
- store.close();
259
- }, 'conest-collab: teardown');
260
273
 
261
274
  ctx.logger.info('conest-collab: %d room(s), %d DSH member(s), cloud-web %s', config.rooms.length, config.dshMembers.length, config.botToken ? 'on' : 'off');
262
275
 
@@ -456,6 +469,12 @@ export async function apply(ctx, rawConfig) {
456
469
  }, false);
457
470
  }
458
471
  if (path === '/collab/api/clis') return clisHandler(req, res);
472
+ if (path === '/collab/api/panel/members' && req.method === 'GET') return json(res, 200, { members: managementMembers(roster, hub) });
473
+ if (path === '/collab/api/panel/members' && req.method === 'POST') return withBody(req, res, async body => ({ member: await managementCreate(roster, body, b => managementOptions(roster, b, config.workspacePath)) }));
474
+ const panelMember = /^\/collab\/api\/panel\/members\/([^/]+)$/.exec(path);
475
+ if (panelMember && req.method === 'PATCH') return withBody(req, res, async body => ({ member: await managementUpdate(roster, decodeURIComponent(panelMember[1]), body, b => managementOptions(roster, b, config.workspacePath)) }));
476
+ if (panelMember && req.method === 'DELETE') return withBody(req, res, () => { const id = decodeURIComponent(panelMember[1]); managementDelete(roster, id); hub.emit({ type: 'rooms', rooms: store.roomsView() }); return { id }; }, false);
477
+ if (path === '/collab/api/panel/cli-options' && req.method === 'POST') return withBody(req, res, body => managementOptions(roster, body, config.workspacePath));
459
478
  if (path === '/collab/api/models' && req.method === 'GET') return json(res, 200, { routes: await roster.models() });
460
479
  if (path === '/collab/api/rooms' && req.method === 'POST') {
461
480
  return withBody(req, res, (body) => {
@@ -477,6 +496,15 @@ export async function apply(ctx, rawConfig) {
477
496
  return { room };
478
497
  });
479
498
  }
499
+ // #155: close a private chat (hidden from the list, history kept; opening it again restores it).
500
+ const dmm = /^\/collab\/api\/dms\/(\d+)$/.exec(path);
501
+ if (dmm !== null && req.method === 'DELETE') {
502
+ return withBody(req, res, () => {
503
+ store.closeDm(Number(dmm[1]));
504
+ hub.emit({ type: 'rooms', rooms: store.roomsView() });
505
+ return { ok: true };
506
+ }, false);
507
+ }
480
508
  if (path === '/collab/api/members' && req.method === 'POST') {
481
509
  return withBody(req, res, async body => ({ member: await roster.create(body) }));
482
510
  }
@@ -487,7 +515,11 @@ export async function apply(ctx, rawConfig) {
487
515
  return withBody(req, res, async body => ({ member: await roster.update(decodeURIComponent(mm[1]), body) }));
488
516
  }
489
517
  if (mm !== null && req.method === 'DELETE') {
490
- return withBody(req, res, () => { roster.remove(decodeURIComponent(mm[1])); return { ok: true }; }, false);
518
+ return withBody(req, res, () => {
519
+ roster.remove(decodeURIComponent(mm[1]));
520
+ hub.emit({ type: 'rooms', rooms: store.roomsView() }); // #155: its private chat leaves the list too.
521
+ return { ok: true };
522
+ }, false);
491
523
  }
492
524
  return json(res, 404, { error: 'not found' });
493
525
  }
@@ -560,7 +592,9 @@ export function renameRoomRoute(req, res, path, { store, hub, dsh }) {
560
592
  const roomId = Number(match[1]);
561
593
  if (store.room(roomId) === undefined) return json(res, 404, { error: 'room not found' });
562
594
  return withBody(req, res, async body => {
563
- const room = store.renameRoom(roomId, body?.name);
595
+ // #176: create strips a leading '#' (the channel list shows names with one), so rename must too,
596
+ // or '#general' gets past checkRoomName as a second "general".
597
+ const room = store.renameRoom(roomId, typeof body?.name === 'string' ? body.name.trim().replace(/^#/, '') : body?.name);
564
598
  hub.emit({ type: 'rooms', rooms: store.roomsView() });
565
599
  await dsh.refreshRoomTitles(roomId);
566
600
  return { room };