bothouse-dsh 0.1.65 → 0.1.67

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.
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,6 +4,9 @@
4
4
  import { readFileSync } from 'node:fs';
5
5
 
6
6
  export const AVATARS = ['orange', 'whale', 'dragon', 'rainbow', 'lanxi', 'grok'];
7
+ // #602: the vendors' own icons, the default set for members without an avatar (user 2026-11-10
8
+ // 「默认是各家原本的图标,内置另一套娘化图标」); served beside the slugs above, see avatars/README.md.
9
+ export const BRAND_AVATARS = ['brand-claude', 'brand-openai', 'brand-deepseek', 'brand-gemini', 'brand-grok', 'brand-minimax'];
7
10
  /** Longest avatar URL a cloud-web bot may announce (common browser URL limit; not from the user). */
8
11
  export const MAX_AVATAR_URL = 2048;
9
12
 
@@ -17,6 +20,6 @@ export function checkAvatar(value, { allowUrl = false } = {}) {
17
20
 
18
21
  /** PNG bytes of a built-in avatar, or undefined for an unknown slug. */
19
22
  export function avatarImage(slug) {
20
- if (!AVATARS.includes(slug)) return undefined;
23
+ if (!AVATARS.includes(slug) && !BRAND_AVATARS.includes(slug)) return undefined;
21
24
  return readFileSync(new URL(`./avatars/${slug}.png`, import.meta.url));
22
25
  }
@@ -226,7 +226,7 @@ export class CliMember {
226
226
  if (prevId && this.canLoad) {
227
227
  try {
228
228
  const loaded = await Promise.race([this.conn.loadSession({ sessionId: prevId, cwd: this.cwd, mcpServers: [] }), this.exited])
229
- s = { sessionId: prevId, configOptions: loaded?.configOptions }
229
+ s = { sessionId: prevId, configOptions: loaded?.configOptions, defaultModel: loaded?.models?.currentModelId }
230
230
  this.sessions.set(room, s)
231
231
  return { ...s, resumed: true, lost: false }
232
232
  } catch (err) {
@@ -234,7 +234,7 @@ export class CliMember {
234
234
  }
235
235
  }
236
236
  const r = await Promise.race([this.conn.newSession({ cwd: this.cwd, mcpServers: [] }), this.exited])
237
- s = { sessionId: r.sessionId, configOptions: r.configOptions }
237
+ s = { sessionId: r.sessionId, configOptions: r.configOptions, defaultModel: r.models?.currentModelId }
238
238
  this.sessions.set(room, s)
239
239
  return { ...s, resumed: false, lost: Boolean(prevId) }
240
240
  }
@@ -301,6 +301,18 @@ export class CliMember {
301
301
  }
302
302
  }
303
303
 
304
+ async _configureClaude(s, model) {
305
+ // Claude's ACP adapter exposes session/set_model, not Codex's config options.
306
+ // Remember its initial selection so clearing an override also affects a live session.
307
+ if (model == null && s.appliedModel === undefined) return
308
+ const target = model ?? s.defaultModel
309
+ if (!target) throw new Error('Claude default model is unavailable')
310
+ if (s.appliedModel === target) return
311
+ await Promise.race([this.conn.request('session/set_model', { sessionId: s.sessionId, modelId: target }), this.exited])
312
+ const stored = [...this.sessions.values()].find(x => x.sessionId === s.sessionId)
313
+ if (stored) stored.appliedModel = target
314
+ }
315
+
304
316
  /**
305
317
  * onText(textSoFar) 在每个公开文字分片到达时调用(#121);onTool(update) 在每个工具调用开始/更新和每个思考分片到达时调用。
306
318
  * opts.resumeId:要续上的 sessionId;opts.onSession({ sessionId, resumed, lost }):会话就绪后、发提示词前调用。
@@ -314,9 +326,10 @@ export class CliMember {
314
326
  return run
315
327
  }
316
328
 
317
- async _ask(text, room, onText, onTool, { resumeId, onSession, attachments = [], onPermission, cliConfig, cliConfigManaged } = {}) {
329
+ async _ask(text, room, onText, onTool, { resumeId, onSession, attachments = [], onPermission, model, cliConfig, cliConfigManaged } = {}) {
318
330
  const s = await this._session(room, resumeId)
319
331
  if (this.cli === 'codex' && cliConfigManaged) await this._configure(s, cliConfig)
332
+ if (this.cli === 'claude') await this._configureClaude(s, model)
320
333
  await onSession?.({ sessionId: s.sessionId, resumed: s.resumed, lost: s.lost })
321
334
  // persona 每轮都拼:续上的或新开的会话都不会丢人设。
322
335
  const body = this.persona ? `${this.persona}\n\n${text}` : text
@@ -171,7 +171,7 @@ export function promptText(room, member, messages, status = '', mode = DEFAULT_D
171
171
  mode = DEFAULT_DISPATCH_MODE;
172
172
  }
173
173
  const lines = messages.map(m => `【${m.name}】${quotePrefix(store?.quote?.(m))}${m.text}`);
174
- const model = member.model === undefined ? '' : `你底层用的模型是 ${member.model.provider}/${member.model.model}。`;
174
+ const model = member.model === undefined ? '' : `你底层用的模型是 ${typeof member.model === 'string' ? member.model : `${member.model.provider}/${member.model.model}`}。`;
175
175
  const persona = member.persona?.trim() ? ['你的人设:', member.persona.trim(), ''] : [];
176
176
  return [
177
177
  `你是频道 #${room.name} 的成员「${member.name}」。${model}频道里其他人各有自己的身份,他们的自我介绍说的是他们自己,不是你。`,
@@ -851,7 +851,7 @@ export class DshMembers {
851
851
  // 这两步失败时提前标记会让重试跳过 MEMORY.md。记忆目录是否真的送达由 onSession 里的调用决定。
852
852
  // #197: a file over the bot connection cap is not inlined; the CLI reads it from disk.
853
853
  const bigNote = oversizedNote(this.rt.files?.oversized(unseen.flatMap(messageAttachments)));
854
- const text = (await entry.agent.ask(`${prompt}${bigNote ? `\n${bigNote}` : ''}`, String(room.id), onText, onTool, { resumeId, onSession, onPermission, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
854
+ const text = (await entry.agent.ask(`${prompt}${bigNote ? `\n${bigNote}` : ''}`, String(room.id), onText, onTool, { resumeId, onSession, onPermission, model: member.model, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
855
855
  if (this.closed) return;
856
856
  this.hub.quota?.observe(member.id, room.id, { error: text });
857
857
  if (!text) { this.fail(job.id, `${member.name} 这一轮没有给出回复(${member.cli} 没有输出文字)`); return; }
@@ -12,7 +12,7 @@ function check(body, create = false) {
12
12
  function view(m, source = m.source) {
13
13
  return { id: m.id, name: m.name, source, can_edit: source === 'ui', can_delete: source === 'ui', persona: m.persona ?? '',
14
14
  cli: m.cli ?? null, model: m.model ?? null, cliConfig: m.cliConfig ?? null,
15
- apply: { timing: 'next_turn', status: m.cliConfigManaged ? 'pending' : 'not_configured' } };
15
+ apply: { timing: 'next_turn', status: m.cliConfigManaged || (m.cli === 'claude' && m.model) ? 'pending' : 'not_configured' } };
16
16
  }
17
17
  export function managementMembers(roster, hub) {
18
18
  const rows = roster.store.members().map(rowToMember);
package/collab/plugin.mjs CHANGED
@@ -74,8 +74,9 @@ export function resolveConfig(config, env = process.env) {
74
74
  throw Error(`conest-collab: dshMembers id ${JSON.stringify(m?.id)} must be unique lowercase [a-z0-9_-]`);
75
75
  }
76
76
  if (typeof m.name !== 'string' || !m.name.trim()) throw Error(`conest-collab: dshMembers ${m.id} needs a name`);
77
- if (m.model !== undefined && (typeof m.model?.provider !== 'string' || typeof m.model?.model !== 'string')) {
78
- throw Error(`conest-collab: dshMembers ${m.id} model must be { provider, model }`);
77
+ if (m.model !== undefined && (m.cli === 'claude' ? typeof m.model !== 'string' || !m.model.trim()
78
+ : typeof m.model?.provider !== 'string' || typeof m.model?.model !== 'string')) {
79
+ throw Error(`conest-collab: dshMembers ${m.id} model must be ${m.cli === 'claude' ? 'a non-empty model id' : '{ provider, model }'}`);
79
80
  }
80
81
  if (m.rooms !== undefined && (!Array.isArray(m.rooms) || m.rooms.some(id => !rooms.some(r => r.id === id)))) {
81
82
  throw Error(`conest-collab: dshMembers ${m.id} rooms must contain configured chat channel ids`);
@@ -803,8 +804,8 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
803
804
  /**
804
805
  * 一句话拉团队 (user 11-04: DSH 用户一句话拉单模型多角色): the primary role of a channel, with its
805
806
  * (member, room) later ticket, creates a new chat channel and its roles in one call.
806
- * POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, model? }], model? }
807
- * model is a model id ("deepseek-v4.1-flash") or "provider/model"; absent = DSH's default model.
807
+ * POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, cli?, model? }], model? }
808
+ * DSH model is an id or "provider/model"; Claude model is a CLI model id. Absent = backend default.
808
809
  * The new roles join only the new channel; the caller becomes its primary role and joins it too.
809
810
  */
810
811
  export function teamRoute(req, res, url, { store, hub, later, roster }) {
@@ -844,16 +845,18 @@ export async function buildTeam(body, leadId, { store, hub, roster }) {
844
845
  if (wanted.length === 0 || wanted.length > MAX_TEAM) throw new CollabError('invalid', `members must list 1-${MAX_TEAM} roles`);
845
846
  const names = wanted.map(x => String(x?.name ?? '').trim());
846
847
  if (names.some(n => !n) || new Set(names.map(n => n.toLowerCase())).size !== names.length) throw new CollabError('invalid', 'every role needs a distinct name');
847
- const shared = await resolveTeamModel(roster, body?.model);
848
848
  const models = [];
849
- for (const x of wanted) models.push(x?.model === undefined || x?.model === '' ? shared : await resolveTeamModel(roster, x.model));
849
+ for (const x of wanted) {
850
+ const wantedModel = x?.model === undefined || x?.model === '' ? body?.model : x.model;
851
+ models.push(x?.cli ? wantedModel : await resolveTeamModel(roster, wantedModel));
852
+ }
850
853
  // Check every role before anything is written, so a bad name leaves no half-built channel behind.
851
- for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
854
+ for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), cli: x?.cli, model: models[i], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
852
855
  const room = store.createRoom(channel);
853
856
  const created = [];
854
857
  for (const [i, x] of wanted.entries()) {
855
858
  const model = models[i];
856
- created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
859
+ created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), cli: x?.cli, rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
857
860
  }
858
861
  const lead = store.member(leadId);
859
862
  if (lead?.source === 'ui' && lead.rooms && lead.rooms !== '[]') {
@@ -861,11 +864,11 @@ export async function buildTeam(body, leadId, { store, hub, roster }) {
861
864
  }
862
865
  store.setLead(room.id, leadId);
863
866
  hub.emit({ type: 'rooms', rooms: store.roomsView() });
864
- const label = model => (model ? model.model : 'DSH 默认模型');
867
+ const label = (model, i) => (model ? typeof model === 'string' ? model : model.model : `${wanted[i]?.cli ?? 'DSH'} 默认模型`);
865
868
  const leadName = hub.members.get(leadId)?.name ?? leadId;
866
869
  hub.post(room.id, { authorKind: 'system', author: 'system', name: '建团队',
867
- text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i])})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
868
- return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i]) })) };
870
+ text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i], i)})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
871
+ return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i], i) })) };
869
872
  }
870
873
 
871
874
  const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
package/collab/roster.mjs CHANGED
@@ -23,7 +23,8 @@ export function rowToMember(row) {
23
23
  id: row.id,
24
24
  name: row.name,
25
25
  ...(row.name_customized == null ? {} : { nameCustomized: Boolean(row.name_customized) }),
26
- ...(row.provider && row.model ? { model: { provider: row.provider, model: row.model } } : {}),
26
+ ...(row.cli === 'claude' && row.model ? { model: row.model }
27
+ : row.provider && row.model ? { model: { provider: row.provider, model: row.model } } : {}),
27
28
  ...(row.cli ? { cli: row.cli } : {}),
28
29
  ...(row.cli_config ? { cliConfig: JSON.parse(row.cli_config) } : {}),
29
30
  ...(row.cli_config_managed ? { cliConfigManaged: true } : {}),
@@ -185,6 +186,10 @@ export class Roster {
185
186
  const existing = this.store.member(id);
186
187
  if (existing === undefined || existing.removed) throw new CollabError('member-missing', `member ${id} does not exist`);
187
188
  if (existing.source !== 'ui') throw new CollabError('read-only', `member ${id} comes from the config file and cannot be edited here`);
189
+ // Both PATCH endpoints preserve omitted fields. A backend change drops the old backend's model/config.
190
+ const old = rowToMember(existing);
191
+ const changedCli = Object.hasOwn(input, 'cli') && (input.cli ?? undefined) !== old.cli;
192
+ input = { ...old, ...(changedCli ? { model: undefined, cliConfig: undefined, permissionPreset: undefined } : {}), ...input };
188
193
  const m = await this.validate({ ...input, rooms: input?.rooms === undefined ? JSON.parse(existing.rooms ?? '[]') : input.rooms }, id);
189
194
  if (input?.cliConfig === undefined && existing.cli === m.cli) {
190
195
  m.cliConfig = existing.cli_config ? JSON.parse(existing.cli_config) : undefined;
@@ -278,8 +283,13 @@ export class Roster {
278
283
  }
279
284
  if (!Object.keys(cliConfig).length) cliConfig = undefined;
280
285
  }
281
- if (input.model != null) throw new CollabError('invalid', 'CLI members cannot use a DSH model route');
282
- return { name, persona, avatar, nameCustomized, rooms, cli, cliConfig, cliConfigManaged: input.cliConfig !== undefined, ...(preset === undefined ? {} : { permissionPreset: preset }) };
286
+ let model;
287
+ if (input.model != null) {
288
+ if (cli !== 'claude') throw new CollabError('invalid', 'CLI members cannot use a DSH model route');
289
+ if (typeof input.model !== 'string' || !input.model.trim()) throw new CollabError('invalid', 'Claude model must be a non-empty model id');
290
+ model = input.model.trim();
291
+ }
292
+ return { name, persona, avatar, nameCustomized, rooms, cli, model, cliConfig, cliConfigManaged: input.cliConfig !== undefined, ...(preset === undefined ? {} : { permissionPreset: preset }) };
283
293
  }
284
294
  if (input?.cliConfig != null) throw new CollabError('invalid', 'cliConfig requires a Codex member');
285
295
  let model;
package/collab/store.mjs CHANGED
@@ -655,14 +655,14 @@ export class Store {
655
655
  member(id) { return this.q.member.get(id); }
656
656
  /** Upsert a config member; config always wins for its own ids. */
657
657
  seedMember(m, now = Date.now() / 1000) {
658
- 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);
658
+ this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, typeof m.model === 'string' ? m.model : m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, JSON.stringify(m.rooms ?? []), now);
659
659
  }
660
660
  addMember(m, now = Date.now() / 1000) {
661
- 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);
661
+ return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, typeof m.model === 'string' ? m.model : 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);
662
662
  }
663
663
  /** Edit a UI-created member; config members and removed ones are refused (undefined). */
664
664
  updateMember(id, m) {
665
- 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);
665
+ return this.q.updateMember.get(m.name, m.model?.provider ?? null, typeof m.model === 'string' ? m.model : 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);
666
666
  }
667
667
  removeMember(id) { return this.q.removeMember.get(id); }
668
668
  leadId(roomId) {
@@ -1,4 +1,4 @@
1
- // dsh-client-source-sha256: 5228c5c93d0c2cb5f22534536897b5f04ad32e249a1ce334768149fccd0a3a2a
1
+ // dsh-client-source-sha256: 0dae018f21980115a8987a880c994fe82307bbb1aaf1d67b3742cbf6548371e1
2
2
  // dsh-style-sha256: 9ec6f9be158431d36e8c658f1b8def48515a0e80fe35ad14ccfa7202308bb0cd
3
3
  window.__ModuleLoader__.load({
4
4
  id: "bothouse-dsh-channels",
@@ -19775,6 +19775,7 @@ function HighlightedMarkdown({ text, labels, members, className, inline, dataCar
19775
19775
  /**
19776
19776
  * Built-in member avatars served by the collab plugin at `collab/avatars/<slug>.png`:
19777
19777
  * the four B-version character portraits (Claude / GPT / DeepSeek / Gemini) + Grok + lanxi.
19778
+ * These are the slugs a member can pick for itself (MemberDialog).
19778
19779
  */
19779
19780
  const AVATARS = [
19780
19781
  "orange",
@@ -19784,39 +19785,63 @@ const AVATARS = [
19784
19785
  "lanxi",
19785
19786
  "grok"
19786
19787
  ];
19788
+ const DEFAULT_AVATAR_STYLE = "brand";
19789
+ const AVATAR_SETS = {
19790
+ brand: {
19791
+ claude: "brand-claude",
19792
+ gpt: "brand-openai",
19793
+ deepseek: "brand-deepseek",
19794
+ gemini: "brand-gemini",
19795
+ grok: "brand-grok",
19796
+ minimax: "brand-minimax"
19797
+ },
19798
+ chibi: {
19799
+ claude: "orange",
19800
+ gpt: "dragon",
19801
+ deepseek: "whale",
19802
+ gemini: "rainbow",
19803
+ grok: "grok",
19804
+ minimax: "lanxi"
19805
+ }
19806
+ };
19807
+ /** Every image the plugin serves under collab/avatars/ (both sets). */
19808
+ const SERVED = [...AVATARS, ...Object.values(AVATAR_SETS.brand)];
19809
+ function normalizeAvatarStyle(v) {
19810
+ return v === "chibi" ? "chibi" : DEFAULT_AVATAR_STYLE;
19811
+ }
19787
19812
  /**
19788
- * Maps member role/model/cli/name to a built-in avatar slug:
19789
- * - Claude -> orange
19790
- * - GPT / Codex -> dragon
19791
- * - DeepSeek -> whale
19792
- * - Gemini -> rainbow
19793
- * - Grok -> grok
19813
+ * Maps member role/model/cli/name to a built-in avatar slug in the given set (default brand):
19814
+ * Claude, GPT / Codex, DeepSeek, Gemini, Grok, MiniMax. A member's own built-in pick wins.
19794
19815
  */
19795
- function matchAvatarSlug(opts) {
19796
- if (opts.avatar && AVATARS.includes(opts.avatar)) return opts.avatar;
19816
+ function matchAvatarSlug(opts, style = DEFAULT_AVATAR_STYLE) {
19817
+ if (opts.avatar && SERVED.includes(opts.avatar)) return opts.avatar;
19818
+ const family = matchFamily(opts);
19819
+ return family === void 0 ? void 0 : AVATAR_SETS[style][family];
19820
+ }
19821
+ function matchFamily(opts) {
19797
19822
  const model = (opts.model || "").toLowerCase();
19798
19823
  const cli = (opts.cli || "").toLowerCase();
19799
19824
  const name = (opts.name || opts.id || "").toLowerCase();
19800
19825
  if (model) {
19801
- if (model.includes("claude") || model.includes("opus") || model.includes("sonnet") || model.includes("haiku")) return "orange";
19802
- if (model.includes("gpt") || model.includes("codex") || model.includes("o1") || model.includes("o3") || model.includes("o4") || model.includes("chatgpt")) return "dragon";
19803
- if (model.includes("deepseek") || model.includes("r1") || model.includes("v3")) return "whale";
19804
- if (model.includes("gemini")) return "rainbow";
19826
+ if (model.includes("claude") || model.includes("opus") || model.includes("sonnet") || model.includes("haiku")) return "claude";
19827
+ if (model.includes("gpt") || model.includes("codex") || model.includes("o1") || model.includes("o3") || model.includes("o4") || model.includes("chatgpt")) return "gpt";
19828
+ if (model.includes("deepseek") || model.includes("r1") || model.includes("v3")) return "deepseek";
19829
+ if (model.includes("gemini")) return "gemini";
19805
19830
  if (model.includes("grok") || model.includes("xai")) return "grok";
19806
19831
  }
19807
19832
  if (cli) {
19808
- if (cli === "claude" || cli === "claudecode") return "orange";
19809
- if (cli === "codex") return "dragon";
19810
- if (cli === "deepseek" || cli === "dsh") return "whale";
19811
- if (cli === "gemini" || cli === "antigravity") return "rainbow";
19833
+ if (cli === "claude" || cli === "claudecode") return "claude";
19834
+ if (cli === "codex") return "gpt";
19835
+ if (cli === "deepseek" || cli === "dsh") return "deepseek";
19836
+ if (cli === "gemini" || cli === "antigravity") return "gemini";
19812
19837
  if (cli === "grok") return "grok";
19813
19838
  }
19814
19839
  if (name) {
19815
- if (name === "橘" || name === "c酱" || name === "cjiang" || name === "claude") return "orange";
19816
- if (name === "龙娘" || name === "g哥" || name === "gge" || name === "codex" || name === "gpt") return "dragon";
19817
- if (name === "鲸鱼娘" || name === "肥鱼" || name === "deep" || name === "deepseek" || name === "dsh") return "whale";
19818
- if (name === "彩虹猫" || name === "大豆包" || name === "gjiang" || name === "gemini") return "rainbow";
19819
- if (name === "岚汐" || name === "lanxi" || name === "minimax") return "lanxi";
19840
+ if (name === "橘" || name === "c酱" || name === "cjiang" || name === "claude") return "claude";
19841
+ if (name === "龙娘" || name === "g哥" || name === "gge" || name === "codex" || name === "gpt") return "gpt";
19842
+ if (name === "鲸鱼娘" || name === "肥鱼" || name === "deep" || name === "deepseek" || name === "dsh") return "deepseek";
19843
+ if (name === "彩虹猫" || name === "大豆包" || name === "gjiang" || name === "gemini") return "gemini";
19844
+ if (name === "岚汐" || name === "lanxi" || name === "minimax") return "minimax";
19820
19845
  if (name === "grok" || name.includes("grok")) return "grok";
19821
19846
  }
19822
19847
  }
@@ -19826,21 +19851,84 @@ function matchAvatarSlug(opts) {
19826
19851
  * @param url - #236 path mapper of the panel (a paired computer's panel reads its avatars).
19827
19852
  * @returns a URL relative to the document base, the absolute URL, or undefined for the initial badge.
19828
19853
  */
19829
- function avatarSrc(avatar, url = (path) => path) {
19854
+ function avatarSrc(avatar, url = (path) => path, style = DEFAULT_AVATAR_STYLE) {
19830
19855
  if (avatar === void 0 || avatar === "") return void 0;
19831
19856
  if (typeof avatar === "string") {
19832
19857
  if (/^upload:[a-z0-9-]+\.png$/.test(avatar)) return url(`collab/avatars/uploads/${avatar.slice(7)}`);
19833
19858
  if (/^https?:\/\//.test(avatar)) return avatar;
19834
- if (AVATARS.includes(avatar)) return url(`collab/avatars/${avatar}.png`);
19835
- const matched = matchAvatarSlug({ name: avatar });
19859
+ if (SERVED.includes(avatar)) return url(`collab/avatars/${avatar}.png`);
19860
+ const matched = matchAvatarSlug({ name: avatar }, style);
19836
19861
  return matched ? url(`collab/avatars/${matched}.png`) : void 0;
19837
19862
  }
19838
19863
  const a = avatar.avatar;
19839
19864
  if (a && /^upload:[a-z0-9-]+\.png$/.test(a)) return url(`collab/avatars/uploads/${a.slice(7)}`);
19840
19865
  if (a && /^https?:\/\//.test(a)) return a;
19841
- const slug = matchAvatarSlug(avatar);
19866
+ const slug = matchAvatarSlug(avatar, style);
19842
19867
  return slug ? url(`collab/avatars/${slug}.png`) : void 0;
19843
19868
  }
19869
+ // src/client/avatar-style.ts
19870
+ /**
19871
+ * Which built-in avatar set members without their own avatar show (#602). The value lives in the
19872
+ * bothouse config ([display] avatar_style), read through this DSH's panel forwarder
19873
+ * (dsh/collab/panel.mjs, `collab/panel/api/v1/settings`), so the channel page and the web panel
19874
+ * show the same set. No bothouse, not the owner, or any error: the default brand set.
19875
+ * The embedded panel announces a switch on the `bothouse-avatar-style` BroadcastChannel
19876
+ * (bridge/web/src/lib/avatarMap.ts), so the page follows without a reload.
19877
+ */
19878
+ const AVATAR_STYLE_CHANNEL = "bothouse-avatar-style";
19879
+ const SETTINGS_PATH = "collab/panel/api/v1/settings";
19880
+ const targets = /* @__PURE__ */ new Map();
19881
+ function stateFor(url) {
19882
+ const path = url(SETTINGS_PATH);
19883
+ let state = targets.get(path);
19884
+ if (!state) {
19885
+ state = {
19886
+ current: DEFAULT_AVATAR_STYLE,
19887
+ started: false,
19888
+ listeners: /* @__PURE__ */ new Set()
19889
+ };
19890
+ targets.set(path, state);
19891
+ }
19892
+ return state;
19893
+ }
19894
+ function setAvatarStyle(style, url = localCollabUrl) {
19895
+ const state = stateFor(url);
19896
+ if (style === state.current) return;
19897
+ state.current = style;
19898
+ for (const fn of state.listeners) fn();
19899
+ }
19900
+ /** Reads the stored set once and listens for switches from the panel. */
19901
+ function startAvatarStyle(fetchImpl = globalThis.fetch, url = localCollabUrl) {
19902
+ const state = stateFor(url);
19903
+ if (state.started) return;
19904
+ state.started = true;
19905
+ const path = url(SETTINGS_PATH);
19906
+ const link = path.match(/(?:^|\/)collab\/remote\/([^/]+)\/panel\//)?.[1];
19907
+ try {
19908
+ const ch = new BroadcastChannel(link ? `${AVATAR_STYLE_CHANNEL}:${link}` : AVATAR_STYLE_CHANNEL);
19909
+ state.channel = ch;
19910
+ ch.onmessage = (e) => {
19911
+ setAvatarStyle(normalizeAvatarStyle(e.data), url);
19912
+ };
19913
+ } catch (error) {}
19914
+ if (typeof fetchImpl !== "function") return;
19915
+ fetchImpl(path, { credentials: "same-origin" }).then((r) => r.ok ? r.json() : void 0).then((s) => {
19916
+ if (s?.data !== void 0) setAvatarStyle(normalizeAvatarStyle(s.data.avatar_style), url);
19917
+ }).catch((error) => {});
19918
+ }
19919
+ function useAvatarStyle() {
19920
+ const url = useCollabUrl();
19921
+ const state = stateFor(url);
19922
+ (0, react.useEffect)(() => {
19923
+ startAvatarStyle(globalThis.fetch, url);
19924
+ }, [url]);
19925
+ return (0, react.useSyncExternalStore)((fn) => {
19926
+ state.listeners.add(fn);
19927
+ return () => {
19928
+ state.listeners.delete(fn);
19929
+ };
19930
+ }, () => state.current, () => state.current);
19931
+ }
19844
19932
  // src/client/Avatar.tsx
19845
19933
  /** Member portrait: a built-in or bot-announced image over a colored initial badge. */
19846
19934
  const PALETTE = [
@@ -19861,13 +19949,14 @@ function color(id) {
19861
19949
  /** Member portrait, or a colored initial badge when there is none or the image fails to load. */
19862
19950
  function Avatar({ id, name, avatar, avatarVersion, small, cli, model }) {
19863
19951
  const url = useCollabUrl();
19952
+ const style = useAvatarStyle();
19864
19953
  const base = avatarSrc({
19865
19954
  avatar,
19866
19955
  name,
19867
19956
  cli,
19868
19957
  model,
19869
19958
  id
19870
- }, url);
19959
+ }, url, style);
19871
19960
  const src = base && avatar?.startsWith("upload:") && avatarVersion ? `${base}?v=${avatarVersion}` : base;
19872
19961
  const [broken, setBroken] = (0, react.useState)(void 0);
19873
19962
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
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",