bothouse-dsh 0.1.53 → 0.1.55
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/collab/cli-member.mjs +74 -5
- package/collab/dsh-member.mjs +8 -5
- package/collab/handoff-rules.json +8 -101
- package/collab/instance-lock.mjs +46 -0
- package/collab/member-management.mjs +75 -0
- package/collab/plugin.mjs +65 -31
- package/collab/roster.mjs +21 -1
- package/collab/store.mjs +50 -8
- package/collab/team-exit-rules.json +1 -1
- package/collab/team-mode-rules.md +2 -1
- package/node_modules/bothouse-dsh-channels/client.js +473 -119
- package/node_modules/bothouse-dsh-desk/client.js +3 -2
- package/node_modules/bothouse-dsh-devices/client.js +3 -2
- package/node_modules/bothouse-dsh-panel/client.js +3 -2
- package/package.json +1 -1
package/collab/cli-member.mjs
CHANGED
|
@@ -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
|
|
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, '')
|
package/collab/dsh-member.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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}
|
|
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, () => {
|
|
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
|
-
|
|
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 };
|