bothouse-dsh 0.1.59 → 0.1.60

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.
@@ -597,7 +597,7 @@ export class DshMembers {
597
597
  const answered = this.approvals.answerByText(msg);
598
598
  const hit = mentions(msg.text, this.hub.routableIn(msg.room_id));
599
599
  const only = this.hub.replyRoute(msg);
600
- const leadID = this.hub.primaryIn(msg.room_id)?.id;
600
+ const leadID = this.hub.defaultReplyIn(msg.room_id)?.id;
601
601
  const leadTarget = msg.author_kind === 'user' && leadID !== undefined && target === undefined && only === null && hit.size === 0;
602
602
  const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
603
603
  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
@@ -1026,6 +1026,26 @@ function messageNode(m) {
1026
1026
  }
1027
1027
  card.append(bar);
1028
1028
  }
1029
+ // 与原生频道页共用重试接口;后端把已受理的失败卡更新成 retried。
1030
+ if (meta.state === 'failed') {
1031
+ const bar = el('div', 'actions');
1032
+ const retry = el('button', '', '重试');
1033
+ retry.type = 'button';
1034
+ retry.onclick = async () => {
1035
+ retry.disabled = true;
1036
+ retry.textContent = '重试中…';
1037
+ try {
1038
+ await api(`${ROOT}/collab/api/messages/${m.id}/retry`, { method: 'POST' });
1039
+ retry.textContent = '已重试';
1040
+ } catch (err) {
1041
+ retry.disabled = false;
1042
+ retry.textContent = '重试';
1043
+ alert('重试失败:' + err.message);
1044
+ }
1045
+ };
1046
+ bar.append(retry);
1047
+ card.append(bar);
1048
+ }
1029
1049
  col.append(card);
1030
1050
  } else if (m.text) col.append(renderBody(m.text, 'body'));
1031
1051
  if (Array.isArray(m.attachments) && m.attachments.length) col.append(attachmentList(m.attachments));
@@ -1151,7 +1171,43 @@ function showState(kind, title, detail, retry) {
1151
1171
  return box;
1152
1172
  }
1153
1173
 
1174
+ // 来源:openRoom 原有每次读取 100 条;向上分页沿用同一页大小。
1175
+ const HISTORY_LIMIT = 100;
1176
+ let historyPage;
1177
+
1178
+ async function loadOlder() {
1179
+ const page = historyPage;
1180
+ if (!page || page.loading || !page.more) return;
1181
+ page.loading = true;
1182
+ const log = $('log');
1183
+ log.querySelector('.history-error')?.remove();
1184
+ try {
1185
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${page.room}/messages?before=${page.before}&limit=${HISTORY_LIMIT}`);
1186
+ if (historyPage !== page) return;
1187
+ const oldHeight = log.scrollHeight;
1188
+ const oldTop = log.scrollTop;
1189
+ messages.forEach(upsert);
1190
+ if (messages.length) page.before = messages[0].id;
1191
+ page.more = messages.length === HISTORY_LIMIT;
1192
+ log.scrollTop = oldTop + log.scrollHeight - oldHeight;
1193
+ } catch (err) {
1194
+ if (historyPage !== page) return;
1195
+ const retry = el('button', 'btn history-error', '历史加载失败,点击重试');
1196
+ retry.type = 'button';
1197
+ retry.title = err.message;
1198
+ retry.onclick = () => loadOlder();
1199
+ log.prepend(retry);
1200
+ } finally {
1201
+ page.loading = false;
1202
+ }
1203
+ }
1204
+
1205
+ $('log').addEventListener('scroll', () => {
1206
+ if ($('log').scrollTop === 0) void loadOlder();
1207
+ }, { passive: true });
1208
+
1154
1209
  async function openRoom(id) {
1210
+ const page = historyPage = { room: id, before: undefined, more: false, loading: true };
1155
1211
  state.room = id; state.synced = 0;
1156
1212
  $('log').replaceChildren(showState('loading', '正在加载消息…'));
1157
1213
  $('log').setAttribute('aria-busy', 'true');
@@ -1161,11 +1217,14 @@ async function openRoom(id) {
1161
1217
  const setting = await api(`${ROOT}/collab/api/rooms/${id}/mode`);
1162
1218
  state.mode = setting.dispatch_mode;
1163
1219
  renderRooms(); renderMembers();
1164
- const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=100`);
1165
- if (state.room !== id) return;
1220
+ const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=${HISTORY_LIMIT}`);
1221
+ if (historyPage !== page) return;
1166
1222
  const log = $('log');
1167
1223
  log.querySelector('.state')?.remove();
1168
1224
  messages.forEach(upsert);
1225
+ page.before = messages[0]?.id;
1226
+ page.more = messages.length === HISTORY_LIMIT;
1227
+ page.loading = false;
1169
1228
  state.synced = messages.length ? messages[messages.length - 1].id : 0;
1170
1229
  if (!log.querySelector('.msg')) log.append(showState('empty', '还没有消息', ''));
1171
1230
  $('log').setAttribute('aria-busy', 'false');
@@ -1173,7 +1232,7 @@ async function openRoom(id) {
1173
1232
  stuck = true;
1174
1233
  $('log').scrollTop = $('log').scrollHeight;
1175
1234
  } catch (err) {
1176
- if (state.room !== id) return;
1235
+ if (historyPage !== page) return;
1177
1236
  $('log').replaceChildren(showState('error', '加载失败', err.message, () => openRoom(id)));
1178
1237
  $('log').setAttribute('aria-busy', 'false');
1179
1238
  }
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
 
@@ -252,7 +252,8 @@ export class Roster {
252
252
  throw new CollabError('invalid', 'rooms must contain existing chat channel ids');
253
253
  }
254
254
  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}`);
255
+ // #366: 这条会原样显示在成员对话框的错误条里,和同页的成员名校验一样说中文。
256
+ if (clash !== undefined) throw new CollabError('conflict', `名字 ${name} 已被 ${clash.name} 使用`);
256
257
  const persona = String(input?.persona ?? '');
257
258
  if (persona.length > MAX_PERSONA) throw new CollabError('invalid', `persona exceeds ${MAX_PERSONA} characters`);
258
259
  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
  }
@@ -1,4 +1,4 @@
1
- // dsh-client-source-sha256: bf3c01b19e0ed8f9d15a02ad7d7e9e9684db4fcbf32f43ba251b220ff57aca56
1
+ // dsh-client-source-sha256: def898ca7c3b5cbc9806630df8269a00d441300d83397256c948bcbf8313e7f7
2
2
  // dsh-style-sha256: f6a6de6373a2ead4c3a965e58c158c16626e49a16b91d2d568b35b19fcfdb95a
3
3
  window.__ModuleLoader__.load({
4
4
  id: "bothouse-dsh-channels",
@@ -20541,13 +20541,13 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
20541
20541
  const [name, setName] = (0, react.useState)(room.name);
20542
20542
  const [mode, setMode] = (0, react.useState)(room.dispatch_mode ?? "mixed");
20543
20543
  const [relay, setRelay] = (0, react.useState)(String(room.max_relay ?? RELAY_CAP_MIN));
20544
- const [lead, setLeadDraft] = (0, react.useState)(effectiveLead);
20544
+ const [lead, setLeadDraft] = (0, react.useState)(room.lead_id === void 0 ? effectiveLead : room.lead_id ?? "");
20545
20545
  const [directory, setDirectory] = (0, react.useState)(room.workspace_path ?? "");
20546
20546
  const [saved, setSaved] = (0, react.useState)({
20547
20547
  name: room.name,
20548
20548
  mode: room.dispatch_mode ?? "mixed",
20549
20549
  relay: String(room.max_relay ?? RELAY_CAP_MIN),
20550
- lead: effectiveLead,
20550
+ lead: room.lead_id === void 0 ? effectiveLead : room.lead_id ?? "",
20551
20551
  directory: room.workspace_path ?? ""
20552
20552
  });
20553
20553
  const [saving, setSaving] = (0, react.useState)({});
@@ -20776,7 +20776,6 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
20776
20776
  },
20777
20777
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
20778
20778
  value: "",
20779
- disabled: true,
20780
20779
  children: t("leadNone")
20781
20780
  }), members.filter((member) => member.kind !== "human").map((member) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
20782
20781
  value: member.id,
@@ -24431,7 +24430,7 @@ const zh = {
24431
24430
  dispatchTeam: "完全团队 — 交给团队成员协作",
24432
24431
  dispatchMixed: "混合 — 按任务选择模型与成员",
24433
24432
  lead: "主角色",
24434
- leadNone: "未选择(按原配置)",
24433
+ leadNone: "未选择(单成员自动接话,多成员不接话)",
24435
24434
  durSeconds: "{n} 秒",
24436
24435
  durMinutes: "{n} 分钟",
24437
24436
  durHours: "{h} 小时 {m} 分",
@@ -24715,7 +24714,7 @@ const en = {
24715
24714
  taskReviewReason: "Reason for rework (required)",
24716
24715
  taskReviewSubmit: "Submit rework",
24717
24716
  lead: "Primary role",
24718
- leadNone: "Not selected (use the default)",
24717
+ leadNone: "No selection (one member replies; multiple stay quiet)",
24719
24718
  durSeconds: "{n}s",
24720
24719
  durMinutes: "{n} min",
24721
24720
  durHours: "{h} h {m} min",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
4
4
  "description": "BotHouse channels for DeepSeek Harness: Discord-style rooms where DSH models and local CLIs (Claude Code, Codex, Gemini) are members",
5
5
  "type": "module",
6
6
  "main": "./collab/plugin.mjs",