bothouse-dsh 0.1.59 → 0.1.61

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/README.en.md CHANGED
@@ -6,14 +6,16 @@ BotHouse channels inside DeepSeek Harness (DSH): a Discord-style channels page w
6
6
 
7
7
  ## Install (pick one)
8
8
 
9
- 1. **Easiest**: tell DSH "install the plugin bothouse-dsh".
10
- 2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh`, click "Install"; then switch bothouse on under "Installed plugins" — "Channels" appears in the sidebar right away, no restart.
9
+ 1. **Easiest**: tell DSH "install the plugin bothouse-dsh, pinned to the newest version on npm".
10
+ 2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh@<newest version on npm>`, click "Install"; then switch bothouse on under "Installed plugins" — "Channels" appears in the sidebar right away, no restart.
11
11
  3. **From a terminal**:
12
12
 
13
13
  ~~~sh
14
- dsh plugin --profile web add bothouse-dsh
14
+ dsh plugin --profile web add "bothouse-dsh@$(npm view bothouse-dsh version)"
15
15
  ~~~
16
16
 
17
+ Pin the version: DSH installs plugins with pnpm, and pnpm's supply-chain policy skips very fresh releases, so plain `bothouse-dsh` or `bothouse-dsh@latest` can install a version from days ago. An exact version goes through at once (pnpm records it as an exclusion). Measured 2026-11-08 with pnpm 12.16.1: `bothouse-dsh` resolved to 0.1.57, then the newest release older than a day — 0.1.59 was 19 hours old and 0.1.60 about 30 minutes; `bothouse-dsh@0.1.60` resolved to 0.1.60.
18
+
17
19
  Asked through DSH, "Channels" appears right after the install; installed from the command line while DSH is running, restart DSH once. Then click "Channels" in the sidebar. Team desk, bothouse panel and other computers start off; switch the one you want on under "Plugins → bothouse" and it appears in the sidebar right away. #general already has Whale Girl, running on the model you configured in DSH; nothing else to fill in. @ her to get a reply; tell her "hand this to a workspace: …" and she opens a workspace, does the work and reports back. For more characters or models, use "Add member".
18
20
 
19
21
  If the terminal install says `pnpm was not found`, run `npm i -g pnpm` first.
package/README.md CHANGED
@@ -6,14 +6,16 @@
6
6
 
7
7
  ## 安装(三选一)
8
8
 
9
- 1. **最省事**:在 DSH 里对它说「帮我装插件 bothouse-dsh」。
10
- 2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh`,点「安装」;装完在「已安装插件」里把 bothouse 的开关打开,左侧马上出现「频道」,不用重启。
9
+ 1. **最省事**:在 DSH 里对它说「帮我装插件 bothouse-dsh,钉到 npm 上最新的版本」。
10
+ 2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh@<npm 上最新的版本号>`,点「安装」;装完在「已安装插件」里把 bothouse 的开关打开,左侧马上出现「频道」,不用重启。
11
11
  3. **命令行**:
12
12
 
13
13
  ~~~sh
14
- dsh plugin --profile web add bothouse-dsh
14
+ dsh plugin --profile web add "bothouse-dsh@$(npm view bothouse-dsh version)"
15
15
  ~~~
16
16
 
17
+ 钉版本:DSH 用 pnpm 装插件,pnpm 的供应链策略会跳过刚发不久的版本,所以写 `bothouse-dsh` 或 `bothouse-dsh@latest` 可能装到几天前的版本;写准版本号会当场放行(pnpm 记一条例外)。2026-11-08 用 pnpm 12.16.1 实测:`bothouse-dsh` 解析到 0.1.57——当时一天前发的那个,0.1.59 才 19 小时、0.1.60 才半小时;写 `bothouse-dsh@0.1.60` 就解析到 0.1.60。
18
+
17
19
  对 DSH 说的装法装完左侧直接出现「频道」;命令行装时 DSH 若正开着,重启一次。然后点左侧「频道」。团队工作台、bothouse 面板、其他电脑默认不在左侧,要用就到「插件 → bothouse」里打开对应那一项,左侧马上出现。#general 里已经有鲸鱼娘,用的是你在 DSH 里配好的那个模型,不用再填任何东西。@鲸鱼娘 就回话;对她说「交给工作区做:……」,她会开工作区干完再交回。想要多角色、多模型,再点「添加成员」。
18
20
 
19
21
  命令行装时提示 `pnpm was not found`:先 `npm i -g pnpm` 再装。
@@ -55,7 +55,7 @@ const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息
55
55
  export const PROGRESS_PREFIX = '__cc_connect_progress_card_v1__:';
56
56
  // cancelled (#913): a card whose turn was cut without an outcome — a restarted bridge, a
57
57
  // cancelled DSH job. page.html styles it like any other card state.
58
- const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消', aborted: '已打断' };
58
+ const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消', aborted: '已打断', stopped: '已停止' };
59
59
  const RESERVED_IDS = new Set(['user', 'system']);
60
60
  const MAX_NAME = 64;
61
61
 
@@ -1360,7 +1360,7 @@ function dispatchBeamOnce(id, from, to) {
1360
1360
  beam(from, bodyRef(to.id, id), 'assign');
1361
1361
  }
1362
1362
 
1363
- function applyTask(ev, animate = true) {
1363
+ function applyTask(ev, animate = true, quiet = false) {
1364
1364
  // 实时流上 tasks.mjs 也发 `{type:'task', task:{…}, chat_id, active}`(给聊天面板用的任务对象),
1365
1365
  // 那不是板事件的形态(id/kind/from/to/title,由 gate 的 board.py 写进 board-events.jsonl)。
1366
1366
  // 两种都照画,任务动作会多出一行「— → —」的空记录,实时就和回放对不上了。
@@ -1419,7 +1419,7 @@ function applyTask(ev, animate = true) {
1419
1419
  }
1420
1420
  forgetDispatch(id, S.dispatched);
1421
1421
  }
1422
- addLog(kind, ts, fromName, toName, header, note);
1422
+ if (!quiet) addLog(kind, ts, fromName, toName, header, note);
1423
1423
  paintAll(animate);
1424
1424
  }
1425
1425
 
@@ -1502,6 +1502,12 @@ async function loadState() {
1502
1502
  renderSeats();
1503
1503
  }
1504
1504
 
1505
+ // 刷新/第一次打开时进行中的任务要从板上补回来,否则工位和「进行中」要等下一个事件才对。
1506
+ async function loadDeskState() {
1507
+ const data = await api(ROOT + '/collab/api/desk/state');
1508
+ for (const ev of data.tasks ?? []) applyTask({ ...ev, type: 'task' }, false, true);
1509
+ }
1510
+
1505
1511
  function connectLive() {
1506
1512
  disconnectLive();
1507
1513
  const es = new EventSource(ROOT + '/collab/api/events');
@@ -1554,11 +1560,15 @@ async function goLive() {
1554
1560
  setConn('wait', '连接中');
1555
1561
  try {
1556
1562
  await loadState();
1557
- connectLive();
1558
1563
  } catch (error) {
1559
1564
  setConn('err', '连接失败');
1560
1565
  toast(`加载失败:${error.message}`);
1566
+ return;
1561
1567
  }
1568
+ try {
1569
+ await loadDeskState();
1570
+ } catch { /* 进行中的任务补不回来,不影响实时事件 */ }
1571
+ connectLive();
1562
1572
  }
1563
1573
  const R = (S.replay = { events: [], idx: 0, playing: false, speed: 4, timer: null, loaded: false });
1564
1574
 
package/collab/desk.mjs CHANGED
@@ -125,6 +125,19 @@ export function deskReplay({ store, boardDir, from, to, members = [] }) {
125
125
  return { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString(), events: kept.map(m => m.event) };
126
126
  }
127
127
 
128
+ /**
129
+ * 现在还在进行中的任务:按任务 id 取最后一条板事件,最后一条是 created/assigned 就算在做。
130
+ * 页面刷新或第一次打开时用它把工位上的任务和「进行中」计数补回来,不必等下一个事件。
131
+ */
132
+ export function deskActiveTasks({ boardDir }) {
133
+ const last = new Map();
134
+ for (const entry of readBoardEvents(boardDir)) {
135
+ const id = String(entry.id ?? '').trim();
136
+ if (id && entry.kind) last.set(id, entry);
137
+ }
138
+ return [...last.values()].filter(entry => entry.kind === 'created' || entry.kind === 'assigned');
139
+ }
140
+
128
141
  /** 桌面页:C 路提供的 dsh/collab/desk/index.html;还没落地时给一个能看事件流的占位页。 */
129
142
  export function deskPage() {
130
143
  try { return readFileSync(new URL('./desk/index.html', import.meta.url), 'utf8'); } catch { return PLACEHOLDER_PAGE; }
@@ -177,9 +190,13 @@ async function namesRoute(req, res, namesFile) {
177
190
  }
178
191
  }
179
192
 
180
- /** /collab/desk、/collab/api/desk/replay、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
193
+ /** /collab/desk、/collab/api/desk/replay、/collab/api/desk/state、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
181
194
  export function deskRoute(req, res, url, { store, boardDir, members = [], namesFile } = {}) {
182
195
  if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/names' && (req.method === 'GET' || req.method === 'PUT')) return namesRoute(req, res, namesFile);
196
+ if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/state' && req.method === 'GET') {
197
+ sendJson(res, 200, { tasks: deskActiveTasks({ boardDir }) });
198
+ return true;
199
+ }
183
200
  if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
184
201
  if (req.method !== 'GET') return undefined;
185
202
  if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
@@ -308,6 +308,7 @@ export class TurnProgress {
308
308
  this.id = undefined;
309
309
  this.queued = false;
310
310
  this.ended = false;
311
+ this.interrupted = false;
311
312
  }
312
313
 
313
314
  call(callId, name, args) {
@@ -346,10 +347,15 @@ export class TurnProgress {
346
347
  end(state) {
347
348
  if (this.ended) return;
348
349
  this.ended = true;
350
+ // The owner withdrew the trigger, so this turn was stopped by hand, not broken: its card says 已停止.
351
+ if (this.interrupted) state = 'stopped';
349
352
  // Only a turn that had events has a card to close.
350
353
  if (this.steps > 0 || this.items.length > 0) this.write(state);
351
354
  }
352
355
 
356
+ /** The owner withdrew the trigger message (#148): the card must not read as a failure. */
357
+ interrupt() { this.interrupted = true; }
358
+
353
359
  write(state) {
354
360
  const body = progressCardOf({ agent: this.member.name, state, steps: this.steps, elapsed_sec: Math.floor((this.now() - this.started) / 1000), items: this.items });
355
361
  if (this.id === undefined) {
@@ -517,6 +523,7 @@ export class DshMembers {
517
523
  this.approvals.expireWhere(p => p.meta?.sessionId === job.session_id);
518
524
  if (w.turn === undefined && agent.inbox.remove(job.input_id)) continue;
519
525
  this.watching.delete(job.session_id);
526
+ w.progress?.interrupt?.();
520
527
  w.resolve(INTERRUPTED);
521
528
  agent.cancel({ kind: 'user' }, { keepInbox: true });
522
529
  stopped.push(job.member_id);
@@ -597,7 +604,7 @@ export class DshMembers {
597
604
  const answered = this.approvals.answerByText(msg);
598
605
  const hit = mentions(msg.text, this.hub.routableIn(msg.room_id));
599
606
  const only = this.hub.replyRoute(msg);
600
- const leadID = this.hub.primaryIn(msg.room_id)?.id;
607
+ const leadID = this.hub.defaultReplyIn(msg.room_id)?.id;
601
608
  const leadTarget = msg.author_kind === 'user' && leadID !== undefined && target === undefined && only === null && hit.size === 0;
602
609
  const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
603
610
  const targets = [...this.config.keys()].filter(id => !this.hub.quota?.cooling(id) && visible.has(id) && id !== msg.author && !answered.has(id) && (!target || target === id)
package/collab/hub.mjs CHANGED
@@ -48,9 +48,8 @@ export class Hub {
48
48
  return member !== undefined && member.kind !== 'human' ? member : undefined;
49
49
  }
50
50
 
51
- /** Channel selection, inherited by workspaces; only unset channels use the legacy global choice. */
51
+ /** Channel primary for delegation; unset channels retain the selected global role. */
52
52
  primaryIn(roomId) {
53
- // #1023: in a private chat the one agent answers every owner message, @ or not.
54
53
  const dm = this.store.dmMember?.(roomId);
55
54
  if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
56
55
  const id = this.store.leadId(roomId);
@@ -58,7 +57,18 @@ export class Hub {
58
57
  return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
59
58
  }
60
59
 
61
- /** UI-only fallback; callers that route messages must use selectedPrimaryMember(). */
60
+ /** Default replies are channel-local and do not change delegation authority. */
61
+ defaultReplyIn(roomId) {
62
+ // #1023: in a private chat the one agent answers every owner message, @ or not.
63
+ const dm = this.store.dmMember?.(roomId);
64
+ if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
65
+ const id = this.store.leadId(roomId);
66
+ const members = this.routableIn(roomId);
67
+ if (id === null) return members.length === 1 ? members[0] : undefined;
68
+ return members.find(member => member.id === id);
69
+ }
70
+
71
+ /** UI-only fallback; message routing uses defaultReplyIn(), delegation uses primaryIn(). */
62
72
  primaryMember() {
63
73
  const fallback = this.fallbackLead === '' ? undefined : this.members.get(this.fallbackLead);
64
74
  return this.selectedPrimaryMember() ?? (fallback?.kind !== 'human' ? fallback : undefined);
@@ -68,6 +78,8 @@ export class Hub {
68
78
  if (member.avatar?.startsWith('upload:')) member.avatarVersion = Math.random().toString(36).slice(2);
69
79
  this.members.set(member.id, member);
70
80
  this.emit({ type: 'members', members: this.memberView() });
81
+ // Membership determines an unset channel's effective primary, including its workspaces.
82
+ this.emit({ type: 'rooms', rooms: this.store.roomsView() });
71
83
  }
72
84
 
73
85
  removeMember(id) {
@@ -76,6 +88,7 @@ export class Hub {
76
88
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
77
89
  for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
78
90
  this.emit({ type: 'members', members: this.memberView() });
91
+ this.emit({ type: 'rooms', rooms: this.store.roomsView() });
79
92
  }
80
93
 
81
94
  setOnline(id, online) {
@@ -283,7 +296,7 @@ export class Hub {
283
296
 
284
297
  emit(event) {
285
298
  if (event.type === 'rooms') event = { ...event, rooms: event.rooms.map(room => ({ ...room,
286
- lead_id: this.store.leadId(room.id), effective_lead_id: this.primaryIn(room.id)?.id ?? null,
299
+ lead_id: this.store.leadId(room.id), effective_lead_id: this.defaultReplyIn(room.id)?.id ?? null,
287
300
  workspace_path: this.store.workspacePath(room.id, this.workspacePath),
288
301
  })) };
289
302
  for (const s of this.subscribers) {
package/collab/page.html CHANGED
@@ -247,6 +247,8 @@
247
247
  被 40px 的头像列宽挤成一字一行(#112)。 */
248
248
  .msg.cont .avatar { visibility: hidden; }
249
249
  .msg.cont .head { display: none; }
250
+ /* 正文列固定落在第二列,即使头像被隐藏也不会顶到第一列。 */
251
+ .msg > :not(.avatar) { grid-column: 2; min-width: 0; }
250
252
  .msg .stamp {
251
253
  display: none;
252
254
  position: absolute;
@@ -330,12 +332,14 @@
330
332
  .card[data-state="completed"] { --card-accent: var(--ok); --card-tint: rgba(35, 165, 90, .16); --card-text: #7ee2a8; }
331
333
  .card[data-state="failed"] { --card-accent: var(--danger); --card-tint: rgba(242, 63, 67, .16); --card-text: #ff9a9c; }
332
334
  .card[data-state="cancelled"] { --card-accent: var(--muted); --card-tint: rgba(127, 127, 127, .14); --card-text: var(--muted); }
335
+ .card[data-state="stopped"] { --card-accent: var(--muted); --card-tint: rgba(127, 127, 127, .14); --card-text: var(--muted); }
333
336
  @media (prefers-color-scheme: light) {
334
337
  .card { --card-text: #3b46b8; --card-tint: rgba(74, 85, 214, .12); }
335
338
  .card[data-state="running"] { --card-text: #6d4a00; --card-tint: rgba(240, 178, 50, .18); }
336
339
  .card[data-state="completed"] { --card-text: #146c3a; --card-tint: rgba(26, 127, 67, .12); }
337
340
  .card[data-state="failed"] { --card-text: #a52327; --card-tint: rgba(194, 43, 47, .12); }
338
341
  .card[data-state="cancelled"] { --card-text: #545861; --card-tint: rgba(84, 88, 97, .12); }
342
+ .card[data-state="stopped"] { --card-text: #545861; --card-tint: rgba(84, 88, 97, .12); }
339
343
  }
340
344
  .card-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
341
345
  .card .title { font-weight: 600; font-size: var(--fs-sm); color: var(--text-strong); }
@@ -650,7 +654,7 @@ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls)
650
654
  const palette = ['#4F46E5', '#B45309', '#0F766E']; // taste-skill-ignore 靛蓝为 design-spec 唯一强调色(用户标杆),按 SKILL.md 已有品牌条款保留
651
655
  const color = id => palette[[...id].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % palette.length];
652
656
  const kindLabel = { dsh: 'DSH', bot: 'Agent', human: '' };
653
- const cardStateLabel = { pending: '待确认', running: '进行中', completed: '已完成', failed: '失败', cancelled: '已取消' };
657
+ const cardStateLabel = { pending: '待确认', running: '进行中', completed: '已完成', failed: '失败', cancelled: '已取消', stopped: '已停止', retried: '已重试' };
654
658
  // 5 分钟:同一人连续发言合并头像与昵称的窗口,沿用 Discord 的习惯(交接要求「按同一人连续发言分组」)
655
659
  const GROUP_WINDOW_MS = 5 * 60 * 1000;
656
660
  // 768px 是 U2 交接里定的窄屏阈值
@@ -1026,6 +1030,26 @@ function messageNode(m) {
1026
1030
  }
1027
1031
  card.append(bar);
1028
1032
  }
1033
+ // 与原生频道页共用重试接口;后端把已受理的失败卡更新成 retried。
1034
+ if (meta.state === 'failed') {
1035
+ const bar = el('div', 'actions');
1036
+ const retry = el('button', '', '重试');
1037
+ retry.type = 'button';
1038
+ retry.onclick = async () => {
1039
+ retry.disabled = true;
1040
+ retry.textContent = '重试中…';
1041
+ try {
1042
+ await api(`${ROOT}/collab/api/messages/${m.id}/retry`, { method: 'POST' });
1043
+ retry.textContent = '已重试';
1044
+ } catch (err) {
1045
+ retry.disabled = false;
1046
+ retry.textContent = '重试';
1047
+ alert('重试失败:' + err.message);
1048
+ }
1049
+ };
1050
+ bar.append(retry);
1051
+ card.append(bar);
1052
+ }
1029
1053
  col.append(card);
1030
1054
  } else if (m.text) col.append(renderBody(m.text, 'body'));
1031
1055
  if (Array.isArray(m.attachments) && m.attachments.length) col.append(attachmentList(m.attachments));
@@ -1131,6 +1155,9 @@ function upsert(m) {
1131
1155
  const after = [...log.children].find(n => n.dataset.id != null && Number(n.dataset.id) > m.id);
1132
1156
  log.insertBefore(messageNode(m), after || null);
1133
1157
  }
1158
+ // 「还没有消息」只是打开空频道时的占位:第一条消息(本地发的、SSE 推来的、重连补来的)一到就得收起,
1159
+ // 否则它会一直压在真实消息上面,直到手动切换频道或刷新。
1160
+ log.querySelector('.state.empty')?.remove();
1134
1161
  regroup();
1135
1162
  followBottom(log, stick);
1136
1163
  }
@@ -1151,7 +1178,43 @@ function showState(kind, title, detail, retry) {
1151
1178
  return box;
1152
1179
  }
1153
1180
 
1181
+ // 来源:openRoom 原有每次读取 100 条;向上分页沿用同一页大小。
1182
+ const HISTORY_LIMIT = 100;
1183
+ let historyPage;
1184
+
1185
+ async function loadOlder() {
1186
+ const page = historyPage;
1187
+ if (!page || page.loading || !page.more) return;
1188
+ page.loading = true;
1189
+ const log = $('log');
1190
+ log.querySelector('.history-error')?.remove();
1191
+ try {
1192
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${page.room}/messages?before=${page.before}&limit=${HISTORY_LIMIT}`);
1193
+ if (historyPage !== page) return;
1194
+ const oldHeight = log.scrollHeight;
1195
+ const oldTop = log.scrollTop;
1196
+ messages.forEach(upsert);
1197
+ if (messages.length) page.before = messages[0].id;
1198
+ page.more = messages.length === HISTORY_LIMIT;
1199
+ log.scrollTop = oldTop + log.scrollHeight - oldHeight;
1200
+ } catch (err) {
1201
+ if (historyPage !== page) return;
1202
+ const retry = el('button', 'btn history-error', '历史加载失败,点击重试');
1203
+ retry.type = 'button';
1204
+ retry.title = err.message;
1205
+ retry.onclick = () => loadOlder();
1206
+ log.prepend(retry);
1207
+ } finally {
1208
+ page.loading = false;
1209
+ }
1210
+ }
1211
+
1212
+ $('log').addEventListener('scroll', () => {
1213
+ if ($('log').scrollTop === 0) void loadOlder();
1214
+ }, { passive: true });
1215
+
1154
1216
  async function openRoom(id) {
1217
+ const page = historyPage = { room: id, before: undefined, more: false, loading: true };
1155
1218
  state.room = id; state.synced = 0;
1156
1219
  $('log').replaceChildren(showState('loading', '正在加载消息…'));
1157
1220
  $('log').setAttribute('aria-busy', 'true');
@@ -1161,11 +1224,14 @@ async function openRoom(id) {
1161
1224
  const setting = await api(`${ROOT}/collab/api/rooms/${id}/mode`);
1162
1225
  state.mode = setting.dispatch_mode;
1163
1226
  renderRooms(); renderMembers();
1164
- const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=100`);
1165
- if (state.room !== id) return;
1227
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=${HISTORY_LIMIT}`);
1228
+ if (historyPage !== page) return;
1166
1229
  const log = $('log');
1167
1230
  log.querySelector('.state')?.remove();
1168
1231
  messages.forEach(upsert);
1232
+ page.before = messages[0]?.id;
1233
+ page.more = messages.length === HISTORY_LIMIT;
1234
+ page.loading = false;
1169
1235
  state.synced = messages.length ? messages[messages.length - 1].id : 0;
1170
1236
  if (!log.querySelector('.msg')) log.append(showState('empty', '还没有消息', ''));
1171
1237
  $('log').setAttribute('aria-busy', 'false');
@@ -1173,7 +1239,7 @@ async function openRoom(id) {
1173
1239
  stuck = true;
1174
1240
  $('log').scrollTop = $('log').scrollHeight;
1175
1241
  } catch (err) {
1176
- if (state.room !== id) return;
1242
+ if (historyPage !== page) return;
1177
1243
  $('log').replaceChildren(showState('error', '加载失败', err.message, () => openRoom(id)));
1178
1244
  $('log').setAttribute('aria-busy', 'false');
1179
1245
  }
package/collab/plugin.mjs CHANGED
@@ -346,7 +346,7 @@ export async function apply(ctx, rawConfig) {
346
346
  if (path === '/collab/api/state' && req.method === 'GET') {
347
347
  return json(res, 200, { me: OWNER, lead_id: hub.selectedPrimaryMember()?.id ?? null,
348
348
  effective_lead_id: hub.primaryMember()?.id ?? null,
349
- rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
349
+ rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.defaultReplyIn(r.id)?.id ?? null,
350
350
  workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
351
351
  thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
352
352
  queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])),
@@ -573,7 +573,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
573
573
  if (match === null) return undefined;
574
574
  const roomId = Number(match[1]);
575
575
  if (store.room(roomId) === undefined) return Promise.resolve(json(res, 404, { error: '频道不存在' }));
576
- const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.primaryIn(roomId)?.id ?? null,
576
+ const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.defaultReplyIn(roomId)?.id ?? null,
577
577
  workspace_path: store.workspacePath(roomId, workspacePath), max_relay: store.relaySetting(roomId).max_relay });
578
578
  if (match[2] === 'renew' && req.method === 'POST') {
579
579
  return withBody(req, res, async () => ({ members: await dsh.renewRoom(roomId) }), false);
@@ -108,7 +108,7 @@ export class QuotaCooling {
108
108
  if (!quota.cooling_until && !state.until) continue;
109
109
  if (!this.cooling(member.id)) {
110
110
  for (const [room, lead] of Object.entries(state.leads ?? {})) {
111
- if (this.hub.primaryIn(Number(room))?.id === lead.to) {
111
+ if (this.hub.defaultReplyIn(Number(room))?.id === lead.to) {
112
112
  this.hub.store.setLead(Number(room), member.id);
113
113
  this.notice(Number(room), `${member.id}-${state.until}-restore-${room}`, `${member.name} 额度恢复,主角色已切回。`);
114
114
  }
@@ -124,7 +124,7 @@ export class QuotaCooling {
124
124
  const key = `${member.id}-${state.until}`;
125
125
  const reserved = new Set(Object.values(state.transfers).filter(t => !t.woken).map(t => t.to));
126
126
  for (const room of this.hub.store.rooms().filter(r => r.kind !== 'workspace')) {
127
- if (state.leads[room.id] || this.hub.primaryIn(room.id)?.id !== member.id) continue;
127
+ if (state.leads[room.id] || this.hub.defaultReplyIn(room.id)?.id !== member.id) continue;
128
128
  const target = this.candidate(room.id, '', member.id, reserved);
129
129
  if (!target) continue;
130
130
  state.leads[room.id] = { to: target.id, woken: false };
@@ -135,8 +135,8 @@ export class QuotaCooling {
135
135
  for (const [room, lead] of Object.entries(state.leads)) {
136
136
  if (lead.woken || this.cooling(lead.to)) continue;
137
137
  // Replay a crash between persisting intent and changing the selection.
138
- if (this.hub.primaryIn(Number(room))?.id === member.id) this.hub.store.setLead(Number(room), lead.to);
139
- if (this.hub.primaryIn(Number(room))?.id !== lead.to) continue;
138
+ if (this.hub.defaultReplyIn(Number(room))?.id === member.id) this.hub.store.setLead(Number(room), lead.to);
139
+ if (this.hub.defaultReplyIn(Number(room))?.id !== lead.to) continue;
140
140
  const msg = this.notice(Number(room), `${key}-lead-${room}`, `${member.name} 额度冷却,主角色交给 ${this.tasks.nameOf(lead.to)}。请接着推进未完成任务。`);
141
141
  if (this.wake(lead.to, msg)) { lead.woken = true; this.save(member.id, state, true); }
142
142
  }
package/collab/roster.mjs CHANGED
@@ -208,7 +208,7 @@ export class Roster {
208
208
  this.avatars?.remove(id);
209
209
  this.store.removeMember(id);
210
210
  // #176: a dead id must not linger as a channel's primary role.
211
- 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 });
211
+ 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.defaultReplyIn(roomId)?.id ?? null });
212
212
  this.dsh.dropMember(id);
213
213
  }
214
214
 
@@ -248,11 +248,15 @@ export class Roster {
248
248
  throw new CollabError('invalid', `name must be 1-${MAX_NAME} characters without spaces (except Claude Code), @ or <>`);
249
249
  }
250
250
  const rooms = input?.rooms ?? [];
251
- if (!Array.isArray(rooms) || rooms.some(id => !Number.isSafeInteger(id) || this.store.room(id)?.kind !== 'chat')) {
251
+ // A private chat holds exactly its one member (hub.membersInRoom), so listing it here would
252
+ // store a membership the room never honours: the extra member shows nowhere, cannot be
253
+ // dispatched to, and the add-member dialog is the only thing that creates it.
254
+ if (!Array.isArray(rooms) || rooms.some(id => !Number.isSafeInteger(id) || this.store.room(id)?.kind !== 'chat' || this.store.dmMember(id) !== undefined)) {
252
255
  throw new CollabError('invalid', 'rooms must contain existing chat channel ids');
253
256
  }
254
257
  const clash = this.hub.memberList().find(m => m.id !== selfId && (!rooms.length || !m.rooms?.length || rooms.some(id => m.rooms.includes(id))) && (m.name.toLowerCase() === name.toLowerCase() || m.id.toLowerCase() === name.toLowerCase()));
255
- if (clash !== undefined) throw new CollabError('conflict', `name ${name} is already used by ${clash.name}`);
258
+ // #366: 这条会原样显示在成员对话框的错误条里,和同页的成员名校验一样说中文。
259
+ if (clash !== undefined) throw new CollabError('conflict', `名字 ${name} 已被 ${clash.name} 使用`);
256
260
  const persona = String(input?.persona ?? '');
257
261
  if (persona.length > MAX_PERSONA) throw new CollabError('invalid', `persona exceeds ${MAX_PERSONA} characters`);
258
262
  let avatar;
package/collab/store.mjs CHANGED
@@ -393,19 +393,21 @@ export class Store {
393
393
  * workspace is addressed, so a duplicate or a workspace-shaped name is refused with 'conflict'.
394
394
  */
395
395
  checkRoomName(name, selfId) {
396
- if (new RegExp(`^${WORKSPACE_PREFIX}\\d+$`).test(name)) throw new CollabError('conflict', `channel name ${name} is reserved for workspaces`);
396
+ // #366: 这两句会原样显示在客户端改名/新建对话框的错误条里,因此和同页其它校验一样说中文。
397
+ if (new RegExp(`^${WORKSPACE_PREFIX}\\d+$`).test(name)) throw new CollabError('conflict', `${name} 是工作区保留名,换个名字`);
397
398
  // A private chat is named after its member and listed with its avatar, so it does not clash.
398
399
  const clash = this.rooms().find(r => r.id !== selfId && r.kind === 'chat' && r.dm_member == null && r.name.toLowerCase() === name.toLowerCase());
399
- if (clash !== undefined) throw new CollabError('conflict', `channel name ${name} is already in use`);
400
+ if (clash !== undefined) throw new CollabError('conflict', `频道名 ${name} 已被占用`);
400
401
  }
401
402
  /** Rename the channel without changing any room, message or session identity. */
402
403
  renameRoom(id, name) {
403
404
  if (typeof name !== 'string' || !name || name.length > 32 || /\s/.test(name)) {
404
- throw new CollabError('invalid', 'room name must be 1-32 characters without spaces');
405
+ throw new CollabError('invalid', '频道名 1–32 个字符,且不能有空格');
405
406
  }
406
407
  const room = this.room(id);
407
- if (room === undefined) throw new CollabError('room-missing', `room ${id} does not exist`);
408
- if (room.kind === 'workspace') throw new CollabError('invalid', 'workspace names cannot be changed');
408
+ if (room === undefined) throw new CollabError('room-missing', `频道 ${id} 不存在`);
409
+ // 工作区名是系统分配的(工作区<n>),改名会和一个地址撞名,所以服务端直接拒绝。
410
+ if (room.kind === 'workspace') throw new CollabError('invalid', '工作区名称由系统分配,不能修改');
409
411
  this.checkRoomName(name, id);
410
412
  return this.db.prepare('UPDATE rooms SET name = ? WHERE id = ? RETURNING id, name, kind, parent_id').get(name, id);
411
413
  }
@@ -878,6 +880,11 @@ export function publicMessage(row, store) {
878
880
  id: row.id, room_id: row.room_id, author_kind: row.author_kind, author: row.author, name: row.name,
879
881
  text: row.text, created_at: row.created_at, card: row.card, from_turn: row.from_turn,
880
882
  ...(row.edited_at != null ? { edited_at: row.edited_at } : {}),
883
+ // #1355: one message keeps its id for life; revision moves on every in-place edit or
884
+ // commit (changed_at), so a consumer can tell an update from a new message. `draft`
885
+ // marks a streamed reply that has not settled yet.
886
+ revision: row.changed_at ?? row.created_at,
887
+ ...(row.draft ? { draft: true } : {}),
881
888
  };
882
889
  const quote = store?.quote(row);
883
890
  if (quote !== undefined) out.reply_to = quote;
@@ -1,12 +1,12 @@
1
1
  [团队模式规矩 — 每个角色都适用]
2
- - 频道分两种:聊天频道(用户在这里聊天、被提问、看阶段结果)和它下面的工作区(干活的地方)。
2
+ - 频道分两种:聊天频道(用户聊天、看结果)和它下面的工作区(干活)。
3
3
  - 多步骤的活先开公开任务卡(先发 --kind start);闲聊、问答不开。
4
4
  - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
5
5
  - 聊天频道里对真人说人话:讲场景、短段落,默认不用表格、不贴代码,细节进子话题。
6
6
  - 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
7
7
  - 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。截图用 ws-up --image 推;文字进度不推。
8
8
  - 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。
9
- - 一个工作区同一时间只归一个聊天频道;挑空着的,都占着就新建。所有聊天频道共用同一组工作区1..n。
9
+ - 聊天频道共用工作区1..n,各同时只归一个,挑空的,都占着就新建;用户要新频道用 `bothouse channel create <名字>`,不自己调 Discord API。
10
10
  - 收口(每小时合结果、发汇报)时,没收完、这一小时也没在动的活由收口的人当场暂停(`board pause <编号>`):它让出工作区,那里没别的活就放回池子、清空会话;恢复时重新交活,先用空着的工作区。
11
11
  - 会缩小或改变用户原话范围的决定(砍功能、换方向等),以及用户原话里没有、你拿不准的设定,不要自己定:在工作区用 [ask] 转到聊天频道问用户,等答复再做;等的时候先做不受影响的部分。主角色拿不准也一样,在聊天频道直接问用户。
12
12
  - 转话必须指定哪个工作区,不默认发给最后一个。