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/roster.mjs CHANGED
@@ -25,6 +25,8 @@ export function rowToMember(row) {
25
25
  ...(row.name_customized == null ? {} : { nameCustomized: Boolean(row.name_customized) }),
26
26
  ...(row.provider && row.model ? { model: { provider: row.provider, model: row.model } } : {}),
27
27
  ...(row.cli ? { cli: row.cli } : {}),
28
+ ...(row.cli_config ? { cliConfig: JSON.parse(row.cli_config) } : {}),
29
+ ...(row.cli_config_managed ? { cliConfigManaged: true } : {}),
28
30
  ...(row.cli && row.permission_preset ? { permissionPreset: row.permission_preset } : {}),
29
31
  persona: row.persona ?? '',
30
32
  ...(row.avatar ? { avatar: row.avatar } : {}),
@@ -179,6 +181,10 @@ export class Roster {
179
181
  if (existing === undefined || existing.removed) throw new CollabError('member-missing', `member ${id} does not exist`);
180
182
  if (existing.source !== 'ui') throw new CollabError('read-only', `member ${id} comes from the config file and cannot be edited here`);
181
183
  const m = await this.validate({ ...input, rooms: input?.rooms === undefined ? JSON.parse(existing.rooms ?? '[]') : input.rooms }, id);
184
+ if (input?.cliConfig === undefined && existing.cli === m.cli) {
185
+ m.cliConfig = existing.cli_config ? JSON.parse(existing.cli_config) : undefined;
186
+ m.cliConfigManaged = Boolean(existing.cli_config_managed);
187
+ }
182
188
  if (input?.nameCustomized === undefined && existing.name_customized != null) m.nameCustomized = Boolean(existing.name_customized);
183
189
  // An edit that leaves out the approval tier keeps the stored one instead of falling back to the default.
184
190
  if (m.cli !== undefined && input?.permissionPreset === undefined && existing.cli && existing.permission_preset) {
@@ -196,6 +202,8 @@ export class Roster {
196
202
  if (existing.source !== 'ui') throw new CollabError('read-only', `member ${id} comes from the config file and cannot be removed here`);
197
203
  this.avatars?.remove(id);
198
204
  this.store.removeMember(id);
205
+ // #176: a dead id must not linger as a channel's primary role.
206
+ for (const roomId of this.store.clearLeadOf(id)) this.hub.emit({ type: 'room-settings', room_id: this.store.settingsRoom(roomId), lead_id: null, effective_lead_id: this.hub.primaryIn(roomId)?.id ?? null });
199
207
  this.dsh.dropMember(id);
200
208
  }
201
209
 
@@ -251,8 +259,20 @@ export class Roster {
251
259
  // Tool approval tier: one of DSH's permission presets; absent = DSH's default (cli-member.mjs).
252
260
  const preset = input.permissionPreset == null || input.permissionPreset === '' ? undefined : String(input.permissionPreset);
253
261
  if (preset !== undefined && !PERMISSION_PRESETS.includes(preset)) throw new CollabError('invalid', `permissionPreset must be one of ${PERMISSION_PRESETS.join(', ')}`);
254
- return { name, persona, avatar, nameCustomized, rooms, cli, ...(preset === undefined ? {} : { permissionPreset: preset }) };
262
+ let cliConfig;
263
+ if (input.cliConfig != null) {
264
+ if (cli !== 'codex' || typeof input.cliConfig !== 'object' || Array.isArray(input.cliConfig)) throw new CollabError('invalid', 'cliConfig requires a Codex member');
265
+ cliConfig = {};
266
+ for (const [key, value] of Object.entries(input.cliConfig)) {
267
+ if (!['model', 'reasoningEffort'].includes(key) || (value !== null && (typeof value !== 'string' || !value.trim()))) throw new CollabError('invalid', 'invalid cliConfig');
268
+ if (value !== null) cliConfig[key] = value;
269
+ }
270
+ if (!Object.keys(cliConfig).length) cliConfig = undefined;
271
+ }
272
+ if (input.model != null) throw new CollabError('invalid', 'CLI members cannot use a DSH model route');
273
+ return { name, persona, avatar, nameCustomized, rooms, cli, cliConfig, cliConfigManaged: input.cliConfig !== undefined, ...(preset === undefined ? {} : { permissionPreset: preset }) };
255
274
  }
275
+ if (input?.cliConfig != null) throw new CollabError('invalid', 'cliConfig requires a Codex member');
256
276
  let model;
257
277
  if (input?.model != null) {
258
278
  const provider = String(input.model.provider ?? '');
package/collab/store.mjs CHANGED
@@ -24,6 +24,8 @@ const SCHEMA_VERSION = 11;
24
24
  /** Columns added after v1, as [table, column, definition]; ensureSchema() adds any missing one. */
25
25
  const COLUMNS = [
26
26
  ['members', 'name_customized', 'INTEGER'],
27
+ ['members', 'cli_config', 'TEXT'],
28
+ ['members', 'cli_config_managed', 'INTEGER NOT NULL DEFAULT 0'],
27
29
  ['bindings', 'workspace_path', 'TEXT'], // Keep a CLI conversation in its original directory until /new. // NULL keeps existing names unchanged; only new UI members opt in.
28
30
 
29
31
  ['jobs', 'input_id', 'TEXT'], // v2: jobs remember the id of the input they admitted, to bind its consuming turn.
@@ -60,6 +62,9 @@ const COLUMNS = [
60
62
  ['messages', 'memories', 'TEXT'],
61
63
  // #1023 (no version bump): a private chat is a chat room with exactly one agent member; NULL for every other room.
62
64
  ['rooms', 'dm_member', 'TEXT'],
65
+ // #155 (no version bump): 1 = the owner closed this private chat; it stays out of the room list
66
+ // (history kept) until openDm() brings it back.
67
+ ['rooms', 'dm_hidden', 'INTEGER NOT NULL DEFAULT 0'],
63
68
  // #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
64
69
  // edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
65
70
  ['messages', 'changed_at', 'REAL'],
@@ -95,6 +100,11 @@ export class Store {
95
100
  room: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms WHERE id = ?'),
96
101
  rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
97
102
  dms: this.db.prepare('SELECT id, dm_member FROM rooms WHERE dm_member IS NOT NULL'),
103
+ // #155: private chats the room list leaves out — closed by the owner, or with a member
104
+ // since removed (a removed member keeps its row). Members that never had a row (bridge
105
+ // roles joining over cloud_web) are not matched, so a reconnect does not hide their chat.
106
+ hiddenDms: this.db.prepare(`SELECT r.id FROM rooms r LEFT JOIN members m ON m.id = r.dm_member
107
+ WHERE r.dm_member IS NOT NULL AND (r.dm_hidden = 1 OR m.removed = 1)`),
98
108
  children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
99
109
  // A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
100
110
  createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
@@ -147,9 +157,9 @@ export class Store {
147
157
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'config', ?, 0)
148
158
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, provider = excluded.provider, model = excluded.model,
149
159
  cli = excluded.cli, persona = excluded.persona, avatar = excluded.avatar, permission_preset = excluded.permission_preset, rooms = excluded.rooms, source = 'config', removed = 0`),
150
- addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, name_customized, rooms, source, created_at, removed)
151
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
152
- updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?, permission_preset = ?, name_customized = ?, rooms = ?
160
+ addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, name_customized, rooms, cli_config, cli_config_managed, source, created_at, removed)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
162
+ updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?, permission_preset = ?, name_customized = ?, rooms = ?, cli_config = ?, cli_config_managed = ?
153
163
  WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *`),
154
164
  removeMember: this.db.prepare("UPDATE members SET removed = 1 WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *"),
155
165
  };
@@ -376,6 +386,16 @@ export class Store {
376
386
  this.q.ensureRoom.run(id, name, saved || workspacePath || null);
377
387
  });
378
388
  }
389
+ /**
390
+ * A chat channel name must be new: the sidebar shows names only (no ids), and 工作区<n> is how a
391
+ * workspace is addressed, so a duplicate or a workspace-shaped name is refused with 'conflict'.
392
+ */
393
+ checkRoomName(name, selfId) {
394
+ if (new RegExp(`^${WORKSPACE_PREFIX}\\d+$`).test(name)) throw new CollabError('conflict', `channel name ${name} is reserved for workspaces`);
395
+ // A private chat is named after its member and listed with its avatar, so it does not clash.
396
+ const clash = this.rooms().find(r => r.id !== selfId && r.kind === 'chat' && r.dm_member == null && r.name.toLowerCase() === name.toLowerCase());
397
+ if (clash !== undefined) throw new CollabError('conflict', `channel name ${name} is already in use`);
398
+ }
379
399
  /** Rename the channel without changing any room, message or session identity. */
380
400
  renameRoom(id, name) {
381
401
  if (typeof name !== 'string' || !name || name.length > 32 || /\s/.test(name)) {
@@ -384,11 +404,15 @@ export class Store {
384
404
  const room = this.room(id);
385
405
  if (room === undefined) throw new CollabError('room-missing', `room ${id} does not exist`);
386
406
  if (room.kind === 'workspace') throw new CollabError('invalid', 'workspace names cannot be changed');
407
+ this.checkRoomName(name, id);
387
408
  return this.db.prepare('UPDATE rooms SET name = ? WHERE id = ? RETURNING id, name, kind, parent_id').get(name, id);
388
409
  }
389
410
  /** Create a chat channel; its first workspace is made on the first createWorkspace() call. */
390
411
  createRoom(name) {
391
- const room = this.transaction(() => this.q.createRoom.get(name));
412
+ const room = this.transaction(() => {
413
+ this.checkRoomName(name);
414
+ return this.q.createRoom.get(name);
415
+ });
392
416
  this.ensureRoomSettings(room.id);
393
417
  return room;
394
418
  }
@@ -400,7 +424,10 @@ export class Store {
400
424
  if (typeof memberId !== 'string' || memberId === '') throw new CollabError('invalid', 'member id required');
401
425
  const room = this.transaction(() => {
402
426
  const found = this.db.prepare('SELECT id FROM rooms WHERE dm_member = ? ORDER BY id LIMIT 1').get(memberId);
403
- if (found !== undefined) return { ...this.q.room.get(found.id), dm_member: memberId };
427
+ if (found !== undefined) {
428
+ this.db.prepare('UPDATE rooms SET dm_hidden = 0 WHERE id = ?').run(found.id); // #155: reopening shows a closed chat again.
429
+ return { ...this.q.room.get(found.id), dm_member: memberId };
430
+ }
404
431
  const created = this.q.createRoom.get(String(name || memberId).replace(/\s+/g, '').slice(0, 32) || memberId);
405
432
  this.db.prepare('UPDATE rooms SET dm_member = ? WHERE id = ?').run(memberId, created.id);
406
433
  return { ...created, dm_member: memberId };
@@ -408,6 +435,11 @@ export class Store {
408
435
  this.ensureRoomSettings(room.id);
409
436
  return room;
410
437
  }
438
+ /** #155: close a private chat: it leaves the room list, its messages stay, openDm() restores it. */
439
+ closeDm(roomId) {
440
+ if (this.dmMember(roomId) === undefined) throw new CollabError('invalid', `room ${roomId} is not a private chat`);
441
+ this.db.prepare('UPDATE rooms SET dm_hidden = 1 WHERE id = ?').run(roomId);
442
+ }
411
443
  /** #1023: the agent member a private chat room is with; undefined for every other room. */
412
444
  dmMember(roomId) { return this.db.prepare('SELECT dm_member FROM rooms WHERE id = ?').get(roomId)?.dm_member ?? undefined; }
413
445
  /**
@@ -616,11 +648,11 @@ export class Store {
616
648
  this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, JSON.stringify(m.rooms ?? []), now);
617
649
  }
618
650
  addMember(m, now = Date.now() / 1000) {
619
- return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), now);
651
+ return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), m.cliConfig ? JSON.stringify(m.cliConfig) : null, Number(Boolean(m.cliConfigManaged)), now);
620
652
  }
621
653
  /** Edit a UI-created member; config members and removed ones are refused (undefined). */
622
654
  updateMember(id, m) {
623
- return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), id);
655
+ return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), m.cliConfig ? JSON.stringify(m.cliConfig) : null, Number(Boolean(m.cliConfigManaged)), id);
624
656
  }
625
657
  removeMember(id) { return this.q.removeMember.get(id); }
626
658
  leadId(roomId) {
@@ -631,6 +663,14 @@ export class Store {
631
663
  this.ensureRoomSettings(roomId);
632
664
  this.db.prepare('UPDATE room_settings SET lead_id = ? WHERE room_id = ?').run(id, roomId);
633
665
  }
666
+ /**
667
+ * #176: forget a member as a channel's chosen primary. A removed member keeps its row (old
668
+ * messages stay attributable) but can never be picked again — hub.primaryIn() stops matching it,
669
+ * so a leftover id is a choice the UI can neither show nor clear.
670
+ */
671
+ clearLeadOf(id) {
672
+ return this.db.prepare('UPDATE room_settings SET lead_id = NULL WHERE lead_id = ? RETURNING room_id').all(id).map(row => row.room_id);
673
+ }
634
674
  bindingWorkspacePath(memberId, roomId, fallback) {
635
675
  return this.db.prepare('SELECT workspace_path FROM bindings WHERE member_id = ? AND room_id = ?').get(memberId, roomId)?.workspace_path || fallback;
636
676
  }
@@ -672,7 +712,9 @@ export class Store {
672
712
  this.releaseIdleWorkspaces();
673
713
  const counts = new Map(this.db.prepare(`SELECT room_id, COUNT(*) AS n FROM tasks
674
714
  WHERE status IN ('assigned', 'accepted', 'asked', 'rework') GROUP BY room_id`).all().map(t => [t.room_id, t.n]));
675
- return this.rooms().map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id), ...this.relaySetting(r.id) }));
715
+ const hidden = new Set(this.q.hiddenDms.all().map(r => r.id));
716
+ return this.rooms().filter(r => !hidden.has(r.id))
717
+ .map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id), ...this.relaySetting(r.id) }));
676
718
  }
677
719
 
678
720
  /**
@@ -30,7 +30,7 @@
30
30
  "no_check_not_exempt": "「自验:无需」只限方向、花钱、需要用户看的成品、权限确认四类,其它类别要附证据"
31
31
  },
32
32
  "bounce": {
33
- "text": "这条 [{kind}] 没有转给用户:{problems}。补上后重发:第一行写 [{kind}] #任务板编号 背景:……,再起一行写做了什么,再一行写「要你做的:……」,这三段系统原样贴到聊天频道;之后的行是细节,贴在那条消息下的子话题{extra}。",
33
+ "text": "这条 [{kind}] 没有转给用户:{problems}。补上后重发:第一行写 [{kind}] #任务板编号 背景:……,再起一行写做了什么,再一行写「要你做的:……」,这三段系统原样贴到聊天频道;之后的行是细节,贴在那条消息下的子话题{extra}。已经做完的活和测试不要重做、不要重跑,只把这条交回改好重发。",
34
34
  "sep": ";",
35
35
  "extra": {
36
36
  "ask": ",正文写「自验:」附证据和「类别:」;方向、花钱、需要用户看的成品、权限确认四类可写「自验:无需」"
@@ -1,7 +1,8 @@
1
1
  [团队模式规矩 — 每个角色都适用]
2
2
  - 频道分两种:聊天频道(用户在这里聊天、被提问、看阶段结果)和它下面的工作区(干活的地方)。
3
3
  - 多步骤的活先开公开任务卡(先发 --kind start);闲聊、问答不开。
4
- - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(「用户原话」「总目标」「范围」「进度」「相关改动」「规矩」「参照实现」七节;范围把用户原话里的名词逐个写成「改:…」或「不改:…」;相关改动写一行「冲突:无」,有冲突先在聊天频道问用户),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
4
+ - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
5
+ - 聊天频道对真人说人话:讲场景,最多三段,不用表格、加粗标题和代码,细节进子话题。
5
6
  - 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
6
7
  - 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。在跑时有截图或阶段结果就 ws-up 推一条。
7
8
  - 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。