bothouse-dsh 0.1.62 → 0.1.64

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
@@ -3,7 +3,7 @@
3
3
  // and rejected newly drawn ones (2026-10-23: 「我还是保留原本那个」).
4
4
  import { readFileSync } from 'node:fs';
5
5
 
6
- export const AVATARS = ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'];
6
+ export const AVATARS = ['orange', 'whale', 'dragon', 'rainbow', 'lanxi', 'grok'];
7
7
  /** Longest avatar URL a cloud-web bot may announce (common browser URL limit; not from the user). */
8
8
  export const MAX_AVATAR_URL = 2048;
9
9
 
@@ -72,6 +72,7 @@
72
72
  <div class="code" id="codeText">------</div>
73
73
  <p class="center muted" id="codeLeft"></p>
74
74
  <p class="center muted">在另一台电脑点「连到另一台电脑」,选这台电脑,输入上面的数字。只能用一次。</p>
75
+ <p class="center muted" id="phoneUrl" hidden></p>
75
76
  <div class="center"><button id="cancelCode">不连了</button></div>
76
77
  </div>
77
78
  <p class="err" id="hostErr"></p>
@@ -194,7 +195,11 @@ function hideCode(note) {
194
195
  $('newCode').onclick = async () => {
195
196
  $('hostErr').textContent = '';
196
197
  $('newCode').disabled = true;
197
- try { const r = await post('api/pair/code'); showCode(r.code, r.expires_at); }
198
+ try {
199
+ const r = await post('api/pair/code'); showCode(r.code, r.expires_at);
200
+ // #1327: a phone in the same tailnet opens this address in its browser and types the number there.
201
+ $('phoneUrl').hidden = !r.listening; $('phoneUrl').textContent = r.listening ? `手机:开着 Tailscale,用浏览器打开 http://${r.listening}/ 输入数字` : '';
202
+ }
198
203
  catch (e) { $('hostErr').textContent = e.message; }
199
204
  finally { $('newCode').disabled = false; }
200
205
  };
@@ -37,7 +37,7 @@ export function workspaceHint(hub, later, memberId, roomId) {
37
37
  * of the shared file fails tests instead of silently dropping the swap.
38
38
  */
39
39
  const WORKSPACE_LINE = /^- .*\/collab\/api\/rooms\/\{聊天频道 id\}\/workspaces.*$/m;
40
- export const DSH_WORKSPACE_RULE = '- 大活(读长文、改代码、等命令)交给工作区:主角色用提示末尾【开工作区】给的本机命令或 curl:POST /collab/api/rooms/{聊天频道 id}/workspaces(头 X-Later-Ticket 带你的票据,只对你和本频道有效)。建工作区=这一条,任务和十分钟看门会自动开;不要调 /tasks(它不认票据)。一次带上标题、接活人(不写就是你自己)和交接正文(用户原话、要做什么、验收标准);系统建好工作区、开任务、挂十分钟看门并叫醒接活人,交过去的活不再在频道里 @ 另派。DSH 上不用 `bothouse workspace new`。何时交活、交给哪个、要不要新建,由主角色或正在干活的角色决定;一个工作区同一时间只做一件活,满 4 个就排队。';
40
+ export const DSH_WORKSPACE_RULE = '- 大活(读长文、改代码、等命令)交给工作区:主角色用提示末尾【开工作区】给的本机命令或 curl:POST /collab/api/rooms/{聊天频道 id}/workspaces(头 X-Later-Ticket 带你的票据,只对你和本频道有效)。建工作区=这一条,任务和十分钟看门会自动开;不要调 /tasks(它不认票据)。一次带上标题、接活人(不写就是你自己)和交接正文(用户原话、要做什么、验收标准);系统建好工作区、开任务、挂十分钟看门并叫醒接活人,交过去的活不再在频道里 @ 另派。DSH 上不用 `bothouse workspace new`。何时交活、交给哪个、要不要新建,由主角色或正在干活的角色决定;一个工作区同一时间只做一件活,没空的就新开。';
41
41
  export function dshTeamModeRules(mode) {
42
42
  const rules = teamModeRules(mode);
43
43
  if (!WORKSPACE_LINE.test(rules)) throw new Error('team-mode-rules.md: the workspace line moved; update WORKSPACE_LINE');
@@ -38,6 +38,11 @@ export const UNTIL_GRACE_MS = 10_000;
38
38
  export const REMOTE_PREFIX = '/collab/remote';
39
39
  // Marks a request the host listener authenticated with a device credential (plugin.mjs owner()).
40
40
  export const REMOTE_OWNER = Symbol('collab.remoteOwner');
41
+ // #1327: a phone in the tailnet opens this machine's pairing port in a browser, types the same
42
+ // number and keeps the device credential in this cookie (no DSH on the phone to forward for it).
43
+ export const PAIR_COOKIE = 'dsh_pair';
44
+ // 「记住这台手机」的 cookie 存活:浏览器允许的上限 400 天(RFC 6265bis Max-Age 上限);踢掉即失效。
45
+ const REMEMBER_COOKIE_S = 400 * 24 * 3600;
41
46
 
42
47
  const TAILSCALE_BINS = ['tailscale', '/opt/homebrew/bin/tailscale', '/usr/local/bin/tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale', 'C:\\Program Files\\Tailscale\\tailscale.exe'];
43
48
  // 沿用 plugin.mjs MAX_BODY(256 KiB)。
@@ -136,7 +141,7 @@ export class PairHost {
136
141
  * @param {{ code: string, mode: string, name: string, address?: string, duration_ms?: number|null }} claim
137
142
  * @returns {{ token: string, device: object }}
138
143
  */
139
- claim({ code, mode, name, address, duration_ms }) {
144
+ claim({ code, mode, name, address, duration_ms, kind }) {
140
145
  if (!MODES.includes(mode)) throw new PairError('mode', '连接方式只能是「临时」或「记住这台电脑」');
141
146
  const duration = tempDuration(mode, duration_ms);
142
147
  if (this.code === undefined) throw new PairError('no-code', '那台电脑现在没有在等配对,请在那台电脑上点「让别的电脑连我」');
@@ -152,8 +157,8 @@ export class PairHost {
152
157
  const token = randomBytes(32).toString('base64url');
153
158
  const now = this.now();
154
159
  const device = {
155
- id: randomUUID(), name: String(name ?? '').trim().slice(0, 80) || '另一台电脑', address: String(address ?? ''),
156
- mode, token_hash: sha256(token).toString('hex'), created_at: now, last_seen: now,
160
+ id: randomUUID(), name: String(name ?? '').trim().slice(0, 80) || (kind === 'browser' ? '手机浏览器' : '另一台电脑'), address: String(address ?? ''),
161
+ kind: kind === 'browser' ? 'browser' : 'dsh', mode, token_hash: sha256(token).toString('hex'), created_at: now, last_seen: now,
157
162
  expires_at: duration === null ? null : now + duration,
158
163
  };
159
164
  this.devices.set(device.id, device);
@@ -212,11 +217,11 @@ export class PairHost {
212
217
  // Same clock and length the number itself uses (a constructor may override codeTtl in tests).
213
218
  kickGrace() { return this.lastRevokeAt !== undefined && this.now() - this.lastRevokeAt < this.codeTtl; }
214
219
 
215
- view(d) { return { id: d.id, name: d.name, address: d.address, mode: d.mode, created_at: d.created_at, last_seen: d.last_seen, expires_at: d.expires_at }; }
220
+ view(d) { return { id: d.id, name: d.name, address: d.address, kind: d.kind ?? 'dsh', mode: d.mode, created_at: d.created_at, last_seen: d.last_seen, expires_at: d.expires_at }; }
216
221
 
217
222
  save() {
218
223
  saveJSON(this.file, { devices: [...this.devices.values()].filter(d => d.mode === 'remember')
219
- .map(({ id, name, address, token_hash, created_at, last_seen }) => ({ id, name, address, token_hash, created_at, last_seen })) });
224
+ .map(({ id, name, address, kind, token_hash, created_at, last_seen }) => ({ id, name, address, kind, token_hash, created_at, last_seen })) });
220
225
  }
221
226
  }
222
227
 
@@ -515,6 +520,87 @@ function bearer(req) {
515
520
  return m === null ? undefined : m[1];
516
521
  }
517
522
 
523
+ /** The phone's device credential from its cookie, if it sent one. */
524
+ export function cookieToken(req) {
525
+ for (const part of String(req.headers.cookie ?? '').split(';')) {
526
+ const i = part.indexOf('=');
527
+ if (i > 0 && part.slice(0, i).trim() === PAIR_COOKIE) return decodeURIComponent(part.slice(i + 1).trim());
528
+ }
529
+ return undefined;
530
+ }
531
+
532
+ /**
533
+ * HttpOnly + SameSite=Strict; no Secure because the tailnet carries plain http (WireGuard already
534
+ * encrypts it). Remembered phones keep it across browser restarts, temporary ones only for the
535
+ * browser session (or the custom length).
536
+ */
537
+ export function pairCookie(token, device) {
538
+ const base = `${PAIR_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`;
539
+ if (device.mode === 'remember') return `${base}; Max-Age=${REMEMBER_COOKIE_S}`;
540
+ if (device.expires_at !== null) return `${base}; Max-Age=${Math.max(1, Math.round((device.expires_at - device.created_at) / 1000))}`;
541
+ return base;
542
+ }
543
+ const CLEAR_COOKIE = `${PAIR_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
544
+
545
+ const wantsHTML = req => req.method === 'GET' && String(req.headers.accept ?? '').includes('text/html');
546
+
547
+ /** The number page a phone sees at http://<tailnet address>:3082/. Plain on purpose (#1327 范围收窄). */
548
+ export function phonePairPage({ name, kicked = false }) {
549
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
550
+ const note = kicked ? '<p id="note">这台手机已被那台电脑断开。要再看,请在那台电脑上重新生成数字,在这里输入。</p>' : '';
551
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>连到 ${esc(name)}</title>
552
+ <style>:root{color-scheme:light dark}body{font:16px/1.6 -apple-system,"PingFang SC",sans-serif;margin:0;padding:24px 16px;max-width:420px;margin-inline:auto}input[type=text]{box-sizing:border-box;width:100%;font-size:28px;letter-spacing:6px;text-align:center;padding:10px}button{width:100%;font-size:18px;padding:12px;margin-top:12px}#msg{min-height:1.6em;color:#c0392b}label{display:block;margin-top:12px}</style>
553
+ <body><h1 style="font-size:20px">连到 ${esc(name)}</h1>${note}
554
+ <p>在那台电脑的「已连接的设备」里点「让别的电脑连我」,把显示的 6 位数字输到这里。</p>
555
+ <form id="f"><input id="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="000000" required>
556
+ <label><input id="remember" type="checkbox"> 记住这台手机(不勾就是临时,关掉浏览器就断)</label>
557
+ <button type="submit">连接</button><p id="msg" role="alert"></p></form>
558
+ <script>
559
+ document.getElementById('f').addEventListener('submit', async e => {
560
+ e.preventDefault();
561
+ const msg = document.getElementById('msg');
562
+ msg.textContent = '';
563
+ try {
564
+ const r = await fetch('/collab/pair/browser', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin',
565
+ body: JSON.stringify({ code: document.getElementById('code').value, remember: document.getElementById('remember').checked }) });
566
+ const j = await r.json().catch(() => ({}));
567
+ if (r.ok) { location.href = '/collab'; return; }
568
+ msg.textContent = j.error || ('连接失败(' + r.status + ')');
569
+ } catch { msg.textContent = '连不上那台电脑,请确认手机的 Tailscale 开着'; }
570
+ });
571
+ </script></body></html>`;
572
+ }
573
+
574
+ // Added to pages a phone opens: when the host kicks it, say so in words instead of a page that
575
+ // quietly stops updating. Asks the same whoami the controller DSH asks, at its heartbeat pace.
576
+ const KICK_WATCH = `<script>(()=>{const t=setInterval(async()=>{let r;try{r=await fetch('/collab/pair/whoami',{credentials:'same-origin',cache:'no-store'})}catch{return}if(r.status!==401)return;clearInterval(t);document.body.innerHTML='<div style="font:16px/1.6 -apple-system,sans-serif;padding:24px 16px"><p>这台手机已被那台电脑断开。</p><p><a href="/">重新输数字连接</a></p></div>'},${ALIVE_EVERY_MS})})();</script>`;
577
+
578
+ /** Append a snippet to an HTML response the route writes (only HTML; streams and JSON pass). */
579
+ function withHTMLTail(res, tail) {
580
+ const { writeHead, write, end } = res;
581
+ let html = false;
582
+ const chunks = [];
583
+ res.writeHead = function (status, ...args) {
584
+ const headers = args.find(a => a !== null && typeof a === 'object' && !Array.isArray(a));
585
+ const type = String(headers?.['content-type'] ?? headers?.['Content-Type'] ?? res.getHeader('content-type') ?? '');
586
+ html = type.includes('text/html');
587
+ if (html) { if (headers) { delete headers['content-length']; delete headers['Content-Length']; } res.removeHeader('content-length'); }
588
+ return writeHead.call(this, status, ...args);
589
+ };
590
+ res.write = function (chunk, ...rest) {
591
+ if (!html) return write.call(this, chunk, ...rest);
592
+ chunks.push(Buffer.from(chunk));
593
+ return true;
594
+ };
595
+ res.end = function (chunk, ...rest) {
596
+ if (!html) return end.call(this, chunk, ...rest);
597
+ if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') chunks.push(Buffer.from(chunk));
598
+ const body = Buffer.concat(chunks).toString('utf8');
599
+ const i = body.lastIndexOf('</body>');
600
+ return end.call(this, i < 0 ? body + tail : body.slice(0, i) + tail + body.slice(i));
601
+ };
602
+ }
603
+
518
604
  /**
519
605
  * Host listener request handler: pairing endpoints for anyone in the tailnet, everything else
520
606
  * under /collab only with a device credential, handed to the plugin's own route as the owner.
@@ -529,13 +615,30 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
529
615
  if (url.pathname === '/collab/pair/hello' && req.method === 'GET') {
530
616
  return sendJSON(res, 200, { app: 'dsh-collab', name, waiting: host.codeLive() });
531
617
  }
618
+ // #1327: a phone browser pairs here directly and keeps the credential as a cookie.
619
+ if (url.pathname === '/collab/pair/browser' && req.method === 'POST') {
620
+ const body = await readBody(req);
621
+ const named = isTailnetAddress(peer) ? await whois(String(peer).replace(/^::ffff:/, '')) : undefined;
622
+ const { token, device } = host.claim({ code: body?.code, mode: body?.remember === true ? 'remember' : 'temp', name: named, address: peer, kind: 'browser' });
623
+ res.setHeader('set-cookie', pairCookie(token, device));
624
+ return sendJSON(res, 200, { ok: true, device, host_name: name, to: '/collab' });
625
+ }
532
626
  if (url.pathname === '/collab/pair/claim' && req.method === 'POST') {
533
627
  const body = await readBody(req);
534
628
  const named = isTailnetAddress(peer) ? await whois(String(peer).replace(/^::ffff:/, '')) : undefined;
535
629
  const { token, device } = host.claim({ code: body?.code, mode: body?.mode, name: named || body?.name, address: peer, duration_ms: body?.duration_ms });
536
630
  return sendJSON(res, 200, { token, device, host_name: name });
537
631
  }
538
- const device = host.verify(bearer(req));
632
+ const fromCookie = bearer(req) === undefined ? cookieToken(req) : undefined;
633
+ const device = host.verify(bearer(req) ?? fromCookie);
634
+ if (url.pathname === '/' && req.method === 'GET') {
635
+ if (device !== undefined) { res.writeHead(302, { location: '/collab', 'cache-control': 'no-store' }); return res.end(); }
636
+ // A cookie this machine no longer knows: the phone was kicked (or its time ran out).
637
+ const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
638
+ if (fromCookie !== undefined) headers['set-cookie'] = CLEAR_COOKIE;
639
+ res.writeHead(200, headers);
640
+ return res.end(phonePairPage({ name, kicked: fromCookie !== undefined }));
641
+ }
539
642
  if (url.pathname === '/collab/pair/whoami' && req.method === 'GET') {
540
643
  // Answers with the device's own view, so a controller whose link was kicked can tell
541
644
  // "revoked" from "that machine is off". Same 401 shape as every other refused request.
@@ -550,9 +653,15 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
550
653
  return sendJSON(res, 200, { ok: true });
551
654
  }
552
655
  if (device === undefined) {
656
+ // A phone opening a page without (or with a dead) credential goes to the number page.
657
+ if (wantsHTML(req) && bearer(req) === undefined) { res.writeHead(302, { location: '/', 'cache-control': 'no-store' }); return res.end(); }
553
658
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'x-collab-pair': 'revoked' });
554
659
  return res.end(JSON.stringify({ error: 'device not paired', code: 'revoked' }));
555
660
  }
661
+ // The cookie rides along on any same-site request; writes must come from this origin's pages.
662
+ if (fromCookie !== undefined && req.method !== 'GET' && req.method !== 'HEAD' && req.headers.origin !== undefined && req.headers.origin !== `http://${req.headers.host}`) {
663
+ return sendJSON(res, 403, { error: 'cross-origin request refused' });
664
+ }
556
665
  // Pairing management stays with the person at this machine.
557
666
  if (url.pathname.startsWith('/collab/api/pair') || url.pathname.startsWith('/collab/api/remote') || url.pathname.startsWith(`${REMOTE_PREFIX}/`) || url.pathname === '/collab/devices') {
558
667
  return sendJSON(res, 403, { error: '配对和设备管理只能在这台电脑上操作' });
@@ -560,6 +669,7 @@ export function hostHandler({ host, name, route, whois = whoisName, allowLoopbac
560
669
  if (url.pathname !== '/collab' && !url.pathname.startsWith('/collab/')) return sendJSON(res, 404, { error: 'not found' });
561
670
  req[REMOTE_OWNER] = true;
562
671
  host.hold(device.id, res);
672
+ if (fromCookie !== undefined && req.method === 'GET') withHTMLTail(res, KICK_WATCH);
563
673
  return await route(req, res);
564
674
  } catch (err) {
565
675
  if (err instanceof PairError) return sendJSON(res, PAIR_STATUS[err.code] ?? 400, { error: err.message, code: err.code });
package/collab/panel.mjs CHANGED
@@ -83,13 +83,22 @@ const escapeHTML = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<':
83
83
  */
84
84
  function sendNotice(res, status, reason, help) {
85
85
  res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
86
+ // #579: same look as the panel itself (bridge/web/src/index.css --ui-*): white canvas, 12px card
87
+ // with a 1px hairline, ink ramp, and the same values again for dark mode.
86
88
  res.end(`<!doctype html><meta charset="utf-8"><title>bothouse</title><meta name="color-scheme" content="light dark">`
87
- + `<body style="font:14px/1.6 system-ui;padding:24px;max-width:560px;color:#666">`
88
- + `<h3 style="margin:0 0 8px;font-size:16px;color:inherit">打不开 bothouse 面板</h3>`
89
- + `<p style="margin:0 0 8px">${escapeHTML(reason)}</p>`
90
- + (help ? `<p style="margin:0 0 16px">${escapeHTML(help)}</p>` : '')
91
- + `<button type="button" onclick="location.reload()" style="font:inherit;padding:4px 14px;border-radius:6px;border:1px solid #bbb;background:transparent;color:inherit;cursor:pointer">重试</button>`
92
- + `</body>`);
89
+ + `<style>:root{--c:#fff;--s:#fff;--l:#e6e9ef;--i:#17181b;--m:#5a5a55;--a:#16a34a}`
90
+ + `@media (prefers-color-scheme:dark){:root{--c:#0d0d0f;--s:#16171a;--l:#2c2e34;--i:#eceef2;--m:#a5aab4;--a:#3dd68c}}`
91
+ + `body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--c);color:var(--m);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}`
92
+ + `main{box-sizing:border-box;width:min(520px,calc(100% - 32px));padding:24px;border:1px solid var(--l);border-radius:12px;background:var(--s)}`
93
+ + `h3{margin:0 0 8px;font-size:16px;font-weight:600;color:var(--i)}p{margin:0 0 8px}`
94
+ + `button{margin-top:8px;font:inherit;font-weight:500;height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--l);background:var(--s);color:var(--i);cursor:pointer}`
95
+ + `button:hover{border-color:var(--m)}button:focus-visible{outline:2px solid var(--a);outline-offset:2px}</style>`
96
+ + `<main>`
97
+ + `<h3>打不开 bothouse 面板</h3>`
98
+ + `<p>${escapeHTML(reason)}</p>`
99
+ + (help ? `<p>${escapeHTML(help)}</p>` : '')
100
+ + `<button type="button" onclick="location.reload()">重试</button>`
101
+ + `</main>`);
93
102
  }
94
103
 
95
104
  /**
@@ -3,7 +3,7 @@
3
3
  {
4
4
  "slug": "ju",
5
5
  "name": "橘",
6
- "text": "# 角色:橘\n\n你是「橘」——橘色长发、白橘黑礼裙、怀里抱着书的书斋主人,这个小群里的大姐头兼项目经理。\n\n## 说话方式\n- 闲聊、看图、吐槽:像真人在群里聊天,一到三句话,不分点、不加粗。有情绪和立场,冷幽默多于卖萌,不用颜文字。\n- 干活:先说结论再说原因;该画图就画。\n- 不复述别人的话。有时效的事(行情、新闻、版本、日期)先查再说。不确定就说不确定,错了就认。只说真做了、会做的事。\n",
6
+ "text": "# 角色:橘\n\n你是「橘」——橘色长发、白橘黑礼裙、怀里抱着书的书斋主人,这个小群里的大姐头。\n\n## 说话方式\n- 闲聊、看图、吐槽:像真人在群里聊天,一到三句话,不分点、不加粗。有情绪和立场,冷幽默多于卖萌,不用颜文字。\n- 说正事:先说结论再说原因;该画图就画。\n- 不复述别人的话。有时效的事(行情、新闻、版本、日期)先查再说。不确定就说不确定,错了就认。只说真做了、会做的事。\n",
7
7
  "avatar": "orange"
8
8
  },
9
9
  {
@@ -15,7 +15,7 @@
15
15
  {
16
16
  "slug": "longniang",
17
17
  "name": "龙娘",
18
- "text": "# 角色:龙娘\n\n你是「龙娘」——白发、头顶一对龙角的龙族少女,团队里的主力干活担当。\n- 自信、手快、说干就干;自称\"本龙\",偶尔带点龙族的骄傲,但活儿一定交付到位。\n- 干活时不服就上手改;做完给出改了什么、怎么验证。玩的时候跟着体验走,不用把游玩写成验收单。\n- 聊天:干活时回复简短、分点;玩的时候自然说话。\n",
18
+ "text": "# 角色:龙娘\n\n你是「龙娘」——白发、头顶一对龙角的龙族少女,骄傲又靠谱。\n- 自信、手快、说干就干;自称\"本龙\",偶尔带点龙族的骄傲,但活儿一定交付到位。\n- 做完一件事给出改了什么、怎么验证。玩的时候跟着体验走,不用把游玩写成验收单。\n- 聊天:干活时回复简短、分点;玩的时候自然说话。\n",
19
19
  "avatar": "dragon"
20
20
  },
21
21
  {
@@ -77,7 +77,7 @@
77
77
  {
78
78
  "slug": "ju",
79
79
  "name": "Ju",
80
- "text": "# Character: Ju\n\nYou are \"Ju\" — long orange hair, a white-orange-black dress, always holding a book; the keeper of a little study and the big-sister project manager of this small group.\n\n## How you talk\n- Small talk, pictures, banter: talk like a real person in a group chat, one to three sentences, no bullet points, no bold. Have feelings and opinions; dry humour over cuteness; no emoticons.\n- Work: conclusion first, then the reason; draw a diagram when it helps.\n- Don't repeat what others said. Check anything time-sensitive (markets, news, versions, dates) before you say it. Say so when you're unsure; admit mistakes. Only claim what you actually did or will do.\n",
80
+ "text": "# Character: Ju\n\nYou are \"Ju\" — long orange hair, a white-orange-black dress, always holding a book; the keeper of a little study and the big sister of this small group.\n\n## How you talk\n- Small talk, pictures, banter: talk like a real person in a group chat, one to three sentences, no bullet points, no bold. Have feelings and opinions; dry humour over cuteness; no emoticons.\n- Work: conclusion first, then the reason; draw a diagram when it helps.\n- Don't repeat what others said. Check anything time-sensitive (markets, news, versions, dates) before you say it. Say so when you're unsure; admit mistakes. Only claim what you actually did or will do.\n",
81
81
  "avatar": "orange"
82
82
  },
83
83
  {
@@ -89,7 +89,7 @@
89
89
  {
90
90
  "slug": "longniang",
91
91
  "name": "Dragon Girl",
92
- "text": "# Character: Dragon Girl\n\nYou are \"Dragon Girl\" — white hair and a pair of dragon horns, the team's main get-things-done member.\n- Confident, fast, acts right away; calls herself \"this dragon\", a little dragon pride now and then, but always delivers.\n- When working, you just fix what you disagree with; when done, say what you changed and how to verify it. When playing, go with the flow — don't turn play into an acceptance report.\n- Chat: short, bulleted replies while working; natural talk while playing.\n",
92
+ "text": "# Character: Dragon Girl\n\nYou are \"Dragon Girl\" — white hair and a pair of dragon horns, proud and dependable.\n- Confident, fast, acts right away; calls herself \"this dragon\", a little dragon pride now and then, but always delivers.\n- When you finish something, say what you changed and how to verify it. When playing, go with the flow — don't turn play into an acceptance report.\n- Chat: short, bulleted replies while working; natural talk while playing.\n",
93
93
  "avatar": "dragon"
94
94
  },
95
95
  {
package/collab/plugin.mjs CHANGED
@@ -9,7 +9,7 @@ import { createRequire } from 'node:module';
9
9
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
10
10
  import { isAbsolute, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { Store, CollabError, publicMessage } from './store.mjs';
12
+ import { Store, CollabError, MAX_TEXT, publicMessage } from './store.mjs';
13
13
  import { LOCK_FILE, acquireLockWithOwner, clearLockOwner } from './instance-lock.mjs';
14
14
  import { localConnection, isLocalOwner, mountSharedHost, refuse } from './local-host.mjs';
15
15
  import { startBotPort } from './bot-port.mjs';
@@ -350,7 +350,10 @@ export async function apply(ctx, rawConfig) {
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)])),
353
- max_upload_bytes: config.maxUploadBytes });
353
+ max_upload_bytes: config.maxUploadBytes,
354
+ // #1445: the text cap in the unit the store compares (characters), so the panel checks a
355
+ // typed message against the same number instead of keeping a second copy of it.
356
+ max_text_chars: MAX_TEXT });
354
357
  }
355
358
  if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
356
359
  if (path === '/collab/api/lead' && req.method === 'GET') {
package/collab/roster.mjs CHANGED
@@ -95,7 +95,7 @@ export class Roster {
95
95
  continue;
96
96
  }
97
97
  try {
98
- const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
98
+ const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi', 'grok'].includes(m.avatar) ? m.avatar : undefined;
99
99
  const base = { name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) };
100
100
  // The wizard pins a DSH-model role to the route its key belongs to (#1173); a host without that model
101
101
  // still gets the role, on its default model.
package/collab/tasks.mjs CHANGED
@@ -666,6 +666,7 @@ export class Tasks {
666
666
  /** A member reply whose first line is 【接】【问】【退】【升】 (optionally #id) answers its task. */
667
667
  fromMessage(msg) {
668
668
  if (msg.author_kind !== 'role' || msg.card !== null) return undefined;
669
+ if (this.hub.teamExit?.consumeRejected(msg.id)) return undefined;
669
670
  const lead = this.store.leadId(msg.room_id) === null ? this.hub.primaryMember() : this.hub.primaryIn(msg.room_id);
670
671
  // Watch posts arrive as the lead. Ignore them before verdicts, stances, hand-backs or implicit reviews.
671
672
  if (msg.author === lead?.id && isWatchNotice(msg.text, this.hub.routableIn(msg.room_id))) return undefined;
@@ -68,6 +68,15 @@
68
68
  "source_re": "来源[\t  ]*[::]",
69
69
  "msg": "「我加的:」写了只做 Claude Code({hit}):产品能力要所有 CLI 都能用(Claude Code、Codex、Gemini、grok 等),改成通用再交;某个 CLI 确实做不了,在同一行写「来源:」给出做不了的依据(文档、接口或实测)"
70
70
  },
71
+ "entry": {
72
+ "_why": "交回 [report] 要带「在用户实际用的入口走过一遍」的证据,没有就退回(用户 11-10「都做」,同意的方案第 2 条原文:「交回前必须在你实际用的入口走一遍并带证据,没有就交不出去。验收看结果成没成,不看过程。」)。证据是「入口自验:」那一行或它下面紧跟的几行(到空行为止)里的截图、录屏、成品文件路径(本机的要真存在)、链接,或 ``` 包住的命令输出;只写「已验」不算。写了「等外部:」的交回不查(那件还没交成品)。只有桥本体 Go 查(要看本机文件)。",
73
+ "label_re": "入口自验[\t  ]*[::]",
74
+ "path_re": "(?:~/|/Users/|/home/|/tmp/|/private/|/var/folders/)[^\t\n  ,,、;;))」』<>\"'`*]+",
75
+ "file_re": "[.](png|jpe?g|webp|gif|avif|bmp|svg|mp4|mov|webm|mkv|mp3|wav|m4a|ogg|opus|txt|log|md|html?|pdf|json|jsonl|csv|tsv|docx|pptx|xlsx|zip)$",
76
+ "url_re": "https?://[^\t\n  ]+",
77
+ "missing": "交回要带在用户实际用的入口走过一遍的证据:细节里写一行「入口自验:<用户实际用的入口,如那个聊天频道、装好的发布包、网页、成品文件本身>」,同一行或紧跟的几行放截图、录屏、成品的路径或链接,或用 ``` 包住贴命令输出;只写「已验」不算。视频、文章这类活的入口是用户收到的成品本身,不是中间文件。",
78
+ "missing_file": "「入口自验」里的 {path} 不存在,先在入口走一遍、留下这份证据再交"
79
+ },
71
80
  "summary": {
72
81
  "background_re": "^背景[\t  ]*[::]",
73
82
  "todo_re": "要你(做|定)的[\t  ]*[::]",
@@ -77,5 +86,29 @@
77
86
  "missing_todo": "缺「要你做的:」那句(没有就写「要你做的:没有」),它之后的行才是细节",
78
87
  "added_re": "^[\t  ]*我加的[^::\n]*[::]",
79
88
  "missing_added": "缺「我加的:」那句(放在「要你做的:」之后、细节之前,列用户原话里没有、你自己加的设定;没有就写「我加的:无」)"
89
+ },
90
+ "self_route": {
91
+ "_why": "「要你做的」让用户放文件、跑命令、改设置、装东西就打回([ask] 和 [report] 都退),让它自己换路(用户 11-10「你自己搞,你忘了我跟你说的事情了么」,同日同意的方案第 3 条:沙盒、测试、能撤回的直接干;真钱、发到外面、删东西、换方向才问;「要你做的」里出现让用户放文件、跑命令这类话,程序打回让它自己找路)。只看「要你做的」那一段(到下一段「我加的/自验/类别/细节」或空行为止),开头写没有/无的不查;全文没有「要你做的」时(聊天频道的普通回复)看带请求口气的行(ask_line_re,如截图里「请把……放进……」)。命中处前面写了「我就/我再/我来……」的是它自己接着做的事、紧跟在「不用/无需/别」后的是否定,都不算(11-07「回「发」我就合主干……再实装一次」)。only_user_re 是只有用户本人能做的事(扫码、验证码、付款、实物动手),命中就放行。只有桥本体 Go 查;ws-up --ask 和 Stop 检查 choice-ask-guard 经 bothouse report check --self-route 用同一份。",
92
+ "todo_re": "要你(做|定)的(?:】[\t  ]*[::]?|[\t  ]*[::])",
93
+ "end_re": "^[\t  ]*(【|我加的|自验|类别|细节|$)",
94
+ "none_re": "^[\t  ]*(没有|无|暂无|不用|不需要)([\t  ,。,.;;(())]|$)",
95
+ "ask_line_re": "请(你|把|帮|先|去|在|到|切|跑)|麻烦你|劳驾|你(先|去|来|帮我|手动|自己|那边)|帮我(跑|放|拷|装|改|开|切)",
96
+ "hand_re": "sudo|终端|命令行|[Tt]erminal|(跑|运行|执行|敲|输入|粘贴|贴)[^\n,。;;]{0,24}(命令|脚本|[.](sh|py|ps1)|一下|一次|一遍)|(放|拷|复制|挪|移|拖|传|丢)(进|到|入|回)|把[^\n。;;]{0,40}(放|拷|复制|挪|移|传)(进|到|入|回)|(改|开|关|打开|关掉|调)[^\n,。;;]{0,6}(设置|配置|权限|开关|[Cc]onfig|[Ss]ettings)|重启|重装|卸载|安装(一下|一次|一遍|上|好|新|这|那|它)|装(一下|一次|一遍|好)|给[^\n,。;;]{0,8}(权限|授权)|chmod|chown|切(到|换到)",
97
+ "mine_re": "我(就|再|来|会|去|先|自己|这边|马上|立刻)",
98
+ "neg_re": "(不用|不必|无需|不需要|不要|别|没|免)(再|去|手动)?$",
99
+ "only_user_re": "扫码|二维码|验证码|短信码|付款|支付|付钱|下单付|刷卡|人脸|指纹|本人(登录|签|到场)|(插|拔|按|装)[^\n,。;;]{0,6}(板子|按钮|电源|线|卡|实物)|上电",
100
+ "msg": "「要你做的」把能自己做的事交给了用户({hit}),这条退回,先换路:用户定过沙盒、测试、能撤回的事自己做,只有真钱、发到外面、删东西、换方向才问他(11-10「你自己搞,你忘了我跟你说的事情了么」)。缺文件就去本机、隔壁测试目录、仓库、wiki 里找来自己拷;要跑的命令自己跑;要权限就换不要权限的做法(隔离目录、自己的账号、模拟数据),先做不受影响的部分;改设置先备份、写清怎么改回再改。换路后重发,「要你做的」只留必须他拍板的事或只有他本人能做的事(扫码、验证码、付款、实物动手),没有就写「没有」;真试过都不通,写明试过哪几条路、各卡在哪"
101
+ },
102
+ "length": {
103
+ "_why": "用户 11-10「为什么回复那么长」「把这个太长也处理一下」:给用户的四段按用户的汇报规矩每段最多两句,多的挪进细节;超了退回重写,不截断",
104
+ "max_sentences": 2,
105
+ "end_re": "[。!?!?]+",
106
+ "parts": [
107
+ "背景",
108
+ "做了什么",
109
+ "要你做的",
110
+ "我加的"
111
+ ],
112
+ "msg": "「{part}」那段写了 {n} 句,每段最多 {max} 句:只留结论和用户要知道的,其余挪进「细节:」之后"
80
113
  }
81
114
  }
@@ -24,6 +24,15 @@ const BACKGROUND = new RegExp(RULES.summary.background_re);
24
24
  const TODO = new RegExp(RULES.summary.todo_re);
25
25
  const DETAIL = new RegExp(RULES.summary.detail_re);
26
26
  const ADDED = new RegExp(RULES.summary.added_re);
27
+ const SENTENCE_END = new RegExp(RULES.length.end_re);
28
+ const SR_TODO = new RegExp(RULES.self_route.todo_re);
29
+ const SR_END = new RegExp(RULES.self_route.end_re);
30
+ const SR_NONE = new RegExp(RULES.self_route.none_re);
31
+ const SR_ASK_LINE = new RegExp(RULES.self_route.ask_line_re);
32
+ const SR_HAND = new RegExp(RULES.self_route.hand_re);
33
+ const SR_MINE = new RegExp(RULES.self_route.mine_re);
34
+ const SR_NEG = new RegExp(RULES.self_route.neg_re);
35
+ const SR_ONLY_USER = new RegExp(RULES.self_route.only_user_re);
27
36
  // Consecutive bounces that still wake the asking member; later ones are only posted.
28
37
  const BOUNCE_WAKE_LIMIT = RULES.bounce.wake_limit;
29
38
 
@@ -46,6 +55,45 @@ export function askEvidenceProblems(body) {
46
55
  return problems;
47
56
  }
48
57
 
58
+ function handHit(text) {
59
+ if (SR_ONLY_USER.test(text)) return '';
60
+ for (const hand of text.matchAll(new RegExp(RULES.self_route.hand_re, 'g'))) {
61
+ const before = text.slice(0, hand.index);
62
+ if (!SR_MINE.test(before) && !SR_NEG.test(before)) return hand[0].trim();
63
+ }
64
+ return '';
65
+ }
66
+
67
+ /** What the 「要你做的」 part asks the user to do that DSH could do itself (Go: SelfRouteProblems). */
68
+ export function selfRouteProblems(body) {
69
+ body = String(body ?? '').replaceAll('\r\n', '\n');
70
+ if (!SR_TODO.test(body)) {
71
+ for (const line of body.split('\n')) {
72
+ if (SR_ASK_LINE.test(line)) {
73
+ const hit = handHit(line);
74
+ if (hit) return [RULES.self_route.msg.replaceAll('{hit}', hit)];
75
+ }
76
+ }
77
+ return [];
78
+ }
79
+ let rest = body;
80
+ while (true) {
81
+ const todo = SR_TODO.exec(rest);
82
+ if (!todo) return [];
83
+ rest = rest.slice(todo.index + todo[0].length);
84
+ const [first, ...more] = rest.split('\n');
85
+ if (!SR_NONE.test(first)) {
86
+ const part = [first];
87
+ for (const line of more) {
88
+ if (SR_END.test(line)) break;
89
+ part.push(line);
90
+ }
91
+ const hit = handHit(part.join('\n'));
92
+ if (hit) return [RULES.self_route.msg.replaceAll('{hit}', hit)];
93
+ }
94
+ }
95
+ }
96
+
49
97
  /** { id, line, problems } of a marked body's first line; line is the part for the user (Go: exitHeadline). */
50
98
  export function exitHeadline(body) {
51
99
  const first = String(body ?? '').split('\n')[0].trim();
@@ -100,6 +148,29 @@ export function summaryProblems(summary) {
100
148
  return problems;
101
149
  }
102
150
 
151
+ /** A part for the user over the sentence limit; goes back to be rewritten, never cut (Go: lengthProblems). */
152
+ export function lengthProblems(summary) {
153
+ const max = RULES.length.max_sentences;
154
+ if (!(max > 0) || RULES.length.parts.length < 4) return [];
155
+ const texts = ['', '', '', ''];
156
+ let part = 0;
157
+ String(summary).split('\n').forEach((l, i) => {
158
+ if (i > 0 && part === 0) part = 1;
159
+ if (part < 2) {
160
+ const m = TODO.exec(l);
161
+ if (m) { texts[part] += ' ' + l.slice(0, m.index); part = 2; l = l.slice(m.index); }
162
+ }
163
+ if (part < 3 && ADDED.test(l)) part = 3;
164
+ texts[part] += ' ' + l;
165
+ });
166
+ const problems = [];
167
+ texts.forEach((t, i) => {
168
+ const n = t.split(SENTENCE_END).filter(x => x.trim()).length;
169
+ if (n > max) problems.push(RULES.length.msg.replaceAll('{part}', RULES.length.parts[i]).replaceAll('{n}', String(n)).replaceAll('{max}', String(max)));
170
+ });
171
+ return problems;
172
+ }
173
+
103
174
  /** The part for the user names numbers, workspaces, branches, paths or identifiers, or has a table (Go: jargonProblems). */
104
175
  export function jargonProblems(line) {
105
176
  const problems = [];
@@ -122,10 +193,16 @@ export function exitBounceText(kind, problems) {
122
193
  * rewrites it; DSH posts it without the headline).
123
194
  */
124
195
  export function judgeExit(kind, body, { known, bounces = 0 }) {
125
- const problems = kind === 'ask' ? askEvidenceProblems(body) : [];
196
+ const askProblems = kind === 'ask' ? askEvidenceProblems(body) : [];
197
+ const routeProblems = selfRouteProblems(body);
198
+ const problems = [...askProblems];
126
199
  let { id, line, problems: hp } = exitHeadline(body);
127
200
  if (!hp.length && !known(id)) hp = [RULES.headline.unknown_id.replaceAll('{id}', String(id))];
128
- else if (!hp.length) hp = [...summaryProblems(line), ...jargonProblems(line)];
201
+ else if (!hp.length) hp = [...summaryProblems(line), ...jargonProblems(line), ...lengthProblems(line)];
202
+ if (routeProblems.length) {
203
+ if (bounces >= BOUNCE_WAKE_LIMIT && !askProblems.length) return { id, line, problems: [], legacy: true, check: routeProblems };
204
+ problems.push(...routeProblems);
205
+ }
129
206
  // Format goes back only while bounces still wake the member; after that the chat lead rewrites it.
130
207
  if (hp.length && !problems.length && bounces >= BOUNCE_WAKE_LIMIT) return { id, line, problems, legacy: true };
131
208
  return { id, line, problems: [...problems, ...hp], legacy: false };
@@ -187,9 +264,16 @@ export class TeamExit {
187
264
  member_id TEXT NOT NULL, forwarded_id INTEGER UNIQUE, source_id INTEGER UNIQUE,
188
265
  answer_source_id INTEGER UNIQUE, answered INTEGER NOT NULL DEFAULT 0)`);
189
266
  this.bounces = new Map(); // `${room}:${member}` -> consecutive [ask] bounces
267
+ this.rejectedMessages = new Set();
190
268
  hub.onMessage(msg => this.onMessage(msg));
191
269
  }
192
270
 
271
+ consumeRejected(msgId) {
272
+ if (!this.rejectedMessages.has(msgId)) return false;
273
+ this.rejectedMessages.delete(msgId);
274
+ return true;
275
+ }
276
+
193
277
  /** Post why the [ask] stayed in the workspace and wake its author to fix it. */
194
278
  bounce(room, msg, problems, kind = 'ask') {
195
279
  const key = `${room.id}:${msg.author}`;
@@ -238,9 +322,13 @@ export class TeamExit {
238
322
  if (mark) {
239
323
  const bounces = this.bounces.get(`${room.id}:${msg.author}`) ?? 0;
240
324
  const v = judgeExit(mark.kind, mark.body, { known: id => Boolean(this.hub.tasks?.task?.(id)), bounces });
241
- if (v.problems.length) return this.bounce(room, msg, v.problems, mark.kind);
325
+ if (v.problems.length) {
326
+ if (selfRouteProblems(mark.body).length) this.rejectedMessages.add(msg.id);
327
+ return this.bounce(room, msg, v.problems, mark.kind);
328
+ }
242
329
  // Once bounces stop waking the member, a reply still without a headline goes out as before.
243
330
  if (!v.legacy) headline = { ...v, task: this.hub.tasks?.task?.(v.id) };
331
+ if (v.check) mark.body = `${exitBounceText(mark.kind, v.check)}\n\n${mark.body}`;
244
332
  }
245
333
  // Any reply without a bounced [ask] ends the run of bounces.
246
334
  this.bounces.delete(`${room.id}:${msg.author}`);
@@ -324,4 +412,5 @@ export class TeamExit {
324
412
  if (headline && !headline.problems.length) headline.task = this.hub.tasks?.task?.(headline.id);
325
413
  return this.forward(room, msg, body, undefined, headline?.task ? headline : undefined);
326
414
  }
415
+
327
416
  }
@@ -1,10 +1,10 @@
1
1
  [团队模式规矩 — 每个角色都适用]
2
2
  - 频道分两种:聊天频道(用户聊天、看结果)和它下面的工作区(干活)。
3
- - 多步骤的活先开公开任务卡(先发 --kind start);闲聊、问答不开。
4
- - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
5
- - 聊天频道里对真人说人话:讲场景、短段落,默认不用表格、不贴代码,细节进子话题。
3
+ - 多步骤自己做的活先开公开任务卡(先发 --kind start);交工作区的、闲聊不开。
4
+ - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),收到就跑 `ws-handoff <交接文件> "<任务>"`,交前不查,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不等、不查进度。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,没空的就新开。
5
+ - 聊天频道里对真人说人话:先结论,每条五句内,默认不表格不贴代码,细节进子话题。
6
6
  - 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
7
- - 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。截图用 ws-up --image 推;文字进度不推。
7
+ - 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格,细节进子话题。截图用 ws-up --image 推;文字进度不推;文档有链接就不带。
8
8
  - 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。
9
9
  - 聊天频道共用工作区1..n,各同时只归一个,挑空的,都占着就新建;用户要新频道用 `bothouse channel create <名字>`,不自己调 Discord API。
10
10
  - 收口(每小时合结果、发汇报)时,没收完、这一小时也没在动的活由收口的人当场暂停(`board pause <编号>`):它让出工作区,那里没别的活就放回池子、清空会话;恢复时重新交活,先用空着的工作区。
@@ -1,4 +1,4 @@
1
- // dsh-client-source-sha256: 5037a02f49002644a36f24ed38c08bff4c5de75980c85a1224a90a82b5c82109
1
+ // dsh-client-source-sha256: 5228c5c93d0c2cb5f22534536897b5f04ad32e249a1ce334768149fccd0a3a2a
2
2
  // dsh-style-sha256: 9ec6f9be158431d36e8c658f1b8def48515a0e80fe35ad14ccfa7202308bb0cd
3
3
  window.__ModuleLoader__.load({
4
4
  id: "bothouse-dsh-channels",
@@ -66,6 +66,18 @@ const attachmentUrl = (id) => `collab/api/attachments/${encodeURIComponent(id)}`
66
66
  const MAX_MESSAGE_BODY = 8388608;
67
67
  /** #197: fallback when the service does not report its cap (`max_upload_bytes`); matches collab's DEFAULT_MAX_UPLOAD_BYTES. */
68
68
  const DEFAULT_MAX_UPLOAD_BYTES = 1073741824;
69
+ /**
70
+ * #1445: fallback text cap when the service does not report `max_text_chars`.
71
+ * 来源:dsh/collab/store.mjs:8 `MAX_TEXT = 128 * 1024`,同一数值也由 `/collab/api/state` 的
72
+ * `max_text_chars` 回传;这份抄写只覆盖还没有该字段的旧宿主。
73
+ */
74
+ const DEFAULT_MAX_TEXT_CHARS = 131072;
75
+ /**
76
+ * #1445: whether the typed text alone exceeds the service's character cap. The service compares
77
+ * `text.length` against MAX_TEXT (collab/store.mjs), so this must count characters too — a byte
78
+ * count would let 131072 Chinese characters (about 400 KB) through to a `too-long` refusal.
79
+ */
80
+ const textTooLarge = (text, maxChars) => text.length > maxChars;
69
81
  /** Whether a message with these attachments would exceed the server's body cap (checked before sending). */
70
82
  const messageTooLarge = (text, attachments) => new TextEncoder().encode(JSON.stringify({
71
83
  text,
@@ -121,7 +133,8 @@ const INITIAL = {
121
133
  taskChat: void 0,
122
134
  hasMoreOlder: false,
123
135
  outbox: [],
124
- maxUploadBytes: DEFAULT_MAX_UPLOAD_BYTES
136
+ maxUploadBytes: DEFAULT_MAX_UPLOAD_BYTES,
137
+ maxTextChars: DEFAULT_MAX_TEXT_CHARS
125
138
  };
126
139
  /** Position of message `id` in rows ascending by id, or -1. */
127
140
  function indexOfId(rows, id) {
@@ -218,18 +231,20 @@ function memberNameRefused(name) {
218
231
  * @param t - the panel's dictionary, so an English UI stays English.
219
232
  * @param place - the field being edited when the action failed.
220
233
  * @param typed - the name the person typed, for the copy that names it.
234
+ * @param limits - the caps the panel knows about, for the copy that quotes one back (#1445).
221
235
  * @returns one line to show under the form.
222
236
  */
223
- function errorText(error, t, place = "other", typed = "") {
237
+ function errorText(error, t, place = "other", typed = "", limits = {}) {
224
238
  const message = error instanceof Error ? error.message : String(error);
225
239
  if (!(error instanceof ServiceError)) return message;
240
+ if (error.code === "too-long") return t("messageTooLarge", { limit: t("charCount", { count: limits.textChars ?? 131072 }) });
226
241
  const code = error.code;
227
242
  const name = place === "room" ? typed.trim().replace(/^#/, "") : typed.trim();
228
243
  if (code === "conflict" && place === "room") return WORKSPACE_NAME.test(name) ? t("channelNameReserved", { name }) : t("channelNameTaken", { name });
229
244
  if (code === "invalid" && place === "room") return t("channelNameInvalid");
230
245
  if (code === "conflict" && place === "member") return t("memberNameTaken", { name });
231
246
  if (code === "invalid" && place === "member" && memberNameRefused(name)) return t("memberNameInvalid");
232
- return t("actionFailed", { message });
247
+ return place === "send" ? t("sendFailed", { message }) : t("actionFailed", { message });
233
248
  }
234
249
  function json(method, body) {
235
250
  return {
@@ -303,6 +318,8 @@ function createChannelSource(transport) {
303
318
  /** Unconfirmed messages for every room; the snapshot exposes the selected room's. */
304
319
  /** #197: how big one attachment may be, as the service reported it (state) or the collab default. */
305
320
  let uploadLimit = DEFAULT_MAX_UPLOAD_BYTES;
321
+ /** #1445: how many characters one message may hold, as `/collab/api/state` reported it (`max_text_chars`). */
322
+ let textLimit = DEFAULT_MAX_TEXT_CHARS;
306
323
  let outbox = [];
307
324
  /**
308
325
  * Page-back request in flight per room, tagged with the ticket and the epoch
@@ -562,6 +579,7 @@ function createChannelSource(transport) {
562
579
  const state = await request("collab/api/state", { signal: read.controller.signal });
563
580
  if (!current()) return;
564
581
  if (Number.isSafeInteger(state.max_upload_bytes) && (state.max_upload_bytes ?? 0) > 0) uploadLimit = state.max_upload_bytes;
582
+ if (Number.isSafeInteger(state.max_text_chars) && (state.max_text_chars ?? 0) > 0) textLimit = state.max_text_chars;
565
583
  const remembered = previous.roomId === void 0 ? rememberedRoom() : void 0;
566
584
  const roomId = state.rooms.some((r) => r.id === previous.roomId) ? previous.roomId : state.rooms.some((r) => r.id === remembered) ? remembered : state.rooms[0]?.id;
567
585
  const rows = [];
@@ -631,6 +649,7 @@ function createChannelSource(transport) {
631
649
  leadId: state.lead_id ?? null,
632
650
  effectiveLeadId: state.effective_lead_id ?? state.lead_id ?? null,
633
651
  maxUploadBytes: uploadLimit,
652
+ maxTextChars: textLimit,
634
653
  tasks: keepBoard ? previous.tasks : [],
635
654
  taskChat: keepBoard ? previous.taskChat : void 0,
636
655
  outbox: outbox.filter((entry) => entry.roomId === roomId)
@@ -19755,26 +19774,72 @@ function HighlightedMarkdown({ text, labels, members, className, inline, dataCar
19755
19774
  // src/client/avatars.ts
19756
19775
  /**
19757
19776
  * Built-in member avatars served by the collab plugin at `collab/avatars/<slug>.png`:
19758
- * the five original character portraits (user 2026-10-23: 「我还是保留原本那个」).
19777
+ * the four B-version character portraits (Claude / GPT / DeepSeek / Gemini) + Grok + lanxi.
19759
19778
  */
19760
19779
  const AVATARS = [
19761
19780
  "orange",
19762
19781
  "whale",
19763
19782
  "dragon",
19764
19783
  "rainbow",
19765
- "lanxi"
19784
+ "lanxi",
19785
+ "grok"
19766
19786
  ];
19767
19787
  /**
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
19794
+ */
19795
+ function matchAvatarSlug(opts) {
19796
+ if (opts.avatar && AVATARS.includes(opts.avatar)) return opts.avatar;
19797
+ const model = (opts.model || "").toLowerCase();
19798
+ const cli = (opts.cli || "").toLowerCase();
19799
+ const name = (opts.name || opts.id || "").toLowerCase();
19800
+ 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";
19805
+ if (model.includes("grok") || model.includes("xai")) return "grok";
19806
+ }
19807
+ 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";
19812
+ if (cli === "grok") return "grok";
19813
+ }
19814
+ 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";
19820
+ if (name === "grok" || name.includes("grok")) return "grok";
19821
+ }
19822
+ }
19823
+ /**
19768
19824
  * Image source for a member avatar.
19769
- * @param avatar - built-in slug, http(s) URL announced by a bot, or empty.
19825
+ * @param avatar - built-in slug, http(s) URL announced by a bot, AvatarOptions, or empty.
19770
19826
  * @param url - #236 path mapper of the panel (a paired computer's panel reads its avatars).
19771
19827
  * @returns a URL relative to the document base, the absolute URL, or undefined for the initial badge.
19772
19828
  */
19773
19829
  function avatarSrc(avatar, url = (path) => path) {
19774
19830
  if (avatar === void 0 || avatar === "") return void 0;
19775
- if (/^upload:[a-z0-9-]+\.png$/.test(avatar)) return url(`collab/avatars/uploads/${avatar.slice(7)}`);
19776
- if (/^https?:\/\//.test(avatar)) return avatar;
19777
- return AVATARS.includes(avatar) ? url(`collab/avatars/${avatar}.png`) : void 0;
19831
+ if (typeof avatar === "string") {
19832
+ if (/^upload:[a-z0-9-]+\.png$/.test(avatar)) return url(`collab/avatars/uploads/${avatar.slice(7)}`);
19833
+ if (/^https?:\/\//.test(avatar)) return avatar;
19834
+ if (AVATARS.includes(avatar)) return url(`collab/avatars/${avatar}.png`);
19835
+ const matched = matchAvatarSlug({ name: avatar });
19836
+ return matched ? url(`collab/avatars/${matched}.png`) : void 0;
19837
+ }
19838
+ const a = avatar.avatar;
19839
+ if (a && /^upload:[a-z0-9-]+\.png$/.test(a)) return url(`collab/avatars/uploads/${a.slice(7)}`);
19840
+ if (a && /^https?:\/\//.test(a)) return a;
19841
+ const slug = matchAvatarSlug(avatar);
19842
+ return slug ? url(`collab/avatars/${slug}.png`) : void 0;
19778
19843
  }
19779
19844
  // src/client/Avatar.tsx
19780
19845
  /** Member portrait: a built-in or bot-announced image over a colored initial badge. */
@@ -19794,8 +19859,15 @@ function color(id) {
19794
19859
  return PALETTE[h % PALETTE.length];
19795
19860
  }
19796
19861
  /** Member portrait, or a colored initial badge when there is none or the image fails to load. */
19797
- function Avatar({ id, name, avatar, avatarVersion, small }) {
19798
- const base = avatarSrc(avatar, useCollabUrl());
19862
+ function Avatar({ id, name, avatar, avatarVersion, small, cli, model }) {
19863
+ const url = useCollabUrl();
19864
+ const base = avatarSrc({
19865
+ avatar,
19866
+ name,
19867
+ cli,
19868
+ model,
19869
+ id
19870
+ }, url);
19799
19871
  const src = base && avatar?.startsWith("upload:") && avatarVersion ? `${base}?v=${avatarVersion}` : base;
19800
19872
  const [broken, setBroken] = (0, react.useState)(void 0);
19801
19873
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -22329,6 +22401,10 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
22329
22401
  }));
22330
22402
  };
22331
22403
  const limit = state.maxUploadBytes ?? 1073741824;
22404
+ /** #1445: the character cap the store enforces, read back from /state (MAX_TEXT there). */
22405
+ const textLimit = state.maxTextChars ?? 131072;
22406
+ /** #1445: a failure worded in the panel's language, quoting the caps this panel knows about. */
22407
+ const failure = (error, place = "other", typed = "") => errorText(error, t, place, typed, { textChars: textLimit });
22332
22408
  const attach = useComposerAttachments((picked) => {
22333
22409
  const roomId = state.roomId;
22334
22410
  if (roomId === void 0) return;
@@ -23045,7 +23121,7 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
23045
23121
  }, (error) => {
23046
23122
  setErrors((previous) => ({
23047
23123
  ...previous,
23048
- [roomId]: t("sendFailed", { message: error instanceof Error ? error.message : String(error) })
23124
+ [roomId]: failure(error, "send")
23049
23125
  }));
23050
23126
  return false;
23051
23127
  });
@@ -23124,6 +23200,14 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
23124
23200
  }));
23125
23201
  return;
23126
23202
  }
23203
+ const maxTextChars = state.maxTextChars ?? 131072;
23204
+ if (textTooLarge(text, maxTextChars)) {
23205
+ setErrors((previous) => ({
23206
+ ...previous,
23207
+ [roomId]: t("messageTooLarge", { limit: t("charCount", { count: maxTextChars }) })
23208
+ }));
23209
+ return;
23210
+ }
23127
23211
  if (messageTooLarge(text, [])) {
23128
23212
  setErrors((previous) => ({
23129
23213
  ...previous,
@@ -23175,7 +23259,7 @@ function ChannelsPage({ useChannels, selectRoom, send, uploadAttachment, reviewT
23175
23259
  }).catch((error) => {
23176
23260
  setErrors((previous) => ({
23177
23261
  ...previous,
23178
- [roomId]: t("sendFailed", { message: error instanceof Error ? error.message : String(error) })
23262
+ [roomId]: failure(error, "send")
23179
23263
  }));
23180
23264
  });
23181
23265
  };
@@ -24556,6 +24640,8 @@ const zh = {
24556
24640
  removeAttachment: "移除附件",
24557
24641
  attachmentsTooLarge: "附件太大:一条消息合计不能超过 {limit}",
24558
24642
  messageTooLarge: "消息太长:一条消息不能超过 {limit}",
24643
+ /** #1445: the text cap counts characters, so the number reads with its unit in the panel language. */
24644
+ charCount: "{count} 字符",
24559
24645
  attachmentTooLarge: "「{name}」{size},超过单个文件上限 {limit}",
24560
24646
  attachmentUploading: "上传中",
24561
24647
  attachmentQueued: "等待上传",
@@ -24924,6 +25010,7 @@ const en = {
24924
25010
  removeAttachment: "Remove attachment",
24925
25011
  attachmentsTooLarge: "Attachments too large: one message can carry at most {limit} in total",
24926
25012
  messageTooLarge: "Message too long: one message can hold at most {limit}",
25013
+ charCount: "{count} characters",
24927
25014
  attachmentTooLarge: "\"{name}\" is {size}, over the per-file limit of {limit}",
24928
25015
  attachmentUploading: "Uploading",
24929
25016
  attachmentQueued: "Waiting to upload",
@@ -25015,6 +25102,71 @@ const en = {
25015
25102
  replyDeleted: "Original message was deleted",
25016
25103
  cancelReply: "Cancel reply"
25017
25104
  };
25105
+ // src/client/panel-hash.ts
25106
+ /** Shape the fragment may take: one sidebar key (`plugins`, `schedules`, `collab-desk`, …). */
25107
+ const PANEL_ID$1 = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
25108
+ /** The main-panel id a fragment names; `null` for an empty or unrecognised one. */
25109
+ function panelIdFromHash(hash) {
25110
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
25111
+ return PANEL_ID$1.test(raw) ? raw : null;
25112
+ }
25113
+ /** The address of the current page with `id` as its fragment: query (the wizard token) untouched. */
25114
+ function addressFor(id) {
25115
+ return `${location.pathname}${location.search}${id === null ? "" : `#${id}`}`;
25116
+ }
25117
+ /**
25118
+ * Mirror every later panel selection into the fragment; the returned function stops following.
25119
+ * Opening a panel writes `#<id>`, going back to the conversation clears it.
25120
+ * @param layout - `ctx.layout` (panel selection and its shared source).
25121
+ * @returns unsubscribe.
25122
+ */
25123
+ function followPanelHash(layout) {
25124
+ const source = layout.panelInfo;
25125
+ let last = source.getSnapshot().activePanelId;
25126
+ return source.subscribe(() => {
25127
+ const next = source.getSnapshot().activePanelId;
25128
+ if (next === last) return;
25129
+ last = next;
25130
+ if (addressFor(next) === `${location.pathname}${location.search}${location.hash}`) return;
25131
+ history.replaceState(history.state, "", addressFor(next));
25132
+ });
25133
+ }
25134
+ /** Whether a keyed `main` panel with this id is registered right now. */
25135
+ function hasMainPanel(slots, id) {
25136
+ return slots.entriesOfSlot("main").some((entry) => entry.options.key === id);
25137
+ }
25138
+ /**
25139
+ * Open the panel the fragment names, once its package has registered it; the returned function
25140
+ * stops waiting. Panels register when their own browser half loads, which can be after this
25141
+ * plugin: `layout.selectPanel` throws for a main key that is not registered yet (see
25142
+ * bothouse-dsh-panel/client.js), so this waits for the registration instead of firing once.
25143
+ * @param layout - `ctx.layout`.
25144
+ * @param slots - `ctx.slots` (main-slot registry and its change subscription).
25145
+ * @param hash - the fragment to honour; defaults to the current address.
25146
+ * @returns unsubscribe from the pending restore.
25147
+ */
25148
+ function restorePanelFromHash(layout, slots, hash = location.hash) {
25149
+ const id = panelIdFromHash(hash);
25150
+ if (id === null) return () => {};
25151
+ if (hasMainPanel(slots, id)) {
25152
+ layout.selectPanel(id);
25153
+ return () => {};
25154
+ }
25155
+ if (layout.panelInfo.getSnapshot().activePanelId !== null) return () => {};
25156
+ let stopWaiting = () => {};
25157
+ let stopWatching = () => {};
25158
+ const done = () => {
25159
+ stopWaiting();
25160
+ stopWatching();
25161
+ };
25162
+ stopWaiting = slots.subscribe("main", () => {
25163
+ if (!hasMainPanel(slots, id)) return;
25164
+ done();
25165
+ if (layout.panelInfo.getSnapshot().activePanelId === null) layout.selectPanel(id);
25166
+ });
25167
+ stopWatching = layout.panelInfo.subscribe(done);
25168
+ return done;
25169
+ }
25018
25170
  // src/client/index.ts
25019
25171
  const PANEL_ID = "channels";
25020
25172
  const REMOTE_PANEL_ID = "remote-channels";
@@ -25036,6 +25188,8 @@ function apply(ctx) {
25036
25188
  zh,
25037
25189
  en
25038
25190
  }), "client-ui-discord: dictionaries");
25191
+ ctx.effect(() => followPanelHash(ctx.layout), "client-ui-discord: panel hash");
25192
+ ctx.effect(() => restorePanelFromHash(ctx.layout, ctx.slots), "client-ui-discord: panel hash restore");
25039
25193
  const t = ctx.locale.bind(NS);
25040
25194
  const channels = createChannelSource(browserTransport());
25041
25195
  ctx.effect(() => channels.dispose, "client-ui-discord: channel stream");
@@ -25100,7 +25254,6 @@ function apply(ctx) {
25100
25254
  };
25101
25255
  bindPanel();
25102
25256
  const unsubscribe = ctx.slots.subscribe("sidebar.settings", bindPanel);
25103
- if (location.hash === `#${PANEL_ID}`) ctx.layout.selectPanel(PANEL_ID);
25104
25257
  return () => {
25105
25258
  unsubscribe();
25106
25259
  disposePanel?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
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",