bothouse-dsh 0.1.55 → 0.1.57

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.
@@ -54,7 +54,7 @@ const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息
54
54
  export const PROGRESS_PREFIX = '__cc_connect_progress_card_v1__:';
55
55
  // cancelled (#913): a card whose turn was cut without an outcome — a restarted bridge, a
56
56
  // cancelled DSH job. page.html styles it like any other card state.
57
- const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消' };
57
+ const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消', aborted: '已打断' };
58
58
  const RESERVED_IDS = new Set(['user', 'system']);
59
59
  const MAX_NAME = 64;
60
60
 
@@ -99,9 +99,9 @@ export function progressCardOf(p) {
99
99
  const items = Array.isArray(p.items) ? p.items : (p.entries ?? []).filter(e => typeof e === 'string').map(text => ({ text }));
100
100
  for (const it of items) {
101
101
  const text = String(it?.text ?? '').replace(/\s+/g, ' ').trim();
102
- if (!text) continue;
102
+ if (!text && it?.kind !== 'tool_use' && it?.kind !== 'tool_result') continue;
103
103
  const tool = String(it.tool ?? '').trim();
104
- lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + ' — ' : ''}${text.slice(0, 120)}`);
104
+ lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + (text ? ' — ' : '') : ''}${text.slice(0, 120)}`);
105
105
  }
106
106
  const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
107
107
  const run = toolSteps(items);
@@ -137,7 +137,9 @@ function toolSteps(items) {
137
137
  if (it.kind === 'tool_use') steps.push({ tool, state: 0, input: clip(it.text), output: '' });
138
138
  else if (it.kind === 'tool_result') {
139
139
  const st = failed(it) ? 2 : 1;
140
- const i = open(tool);
140
+ // A repeated result updates its closed step, not another execution.
141
+ let i = open(tool);
142
+ if (i < 0) i = steps.findLastIndex(s => tool === '' || s.tool === tool);
141
143
  if (i >= 0) Object.assign(steps[i], { state: st, output: clip(it.text) });
142
144
  else steps.push({ tool, state: st, input: '', output: clip(it.text) });
143
145
  } else if (it.kind === 'error') {
@@ -234,7 +234,11 @@ async function loadPeers() {
234
234
  ul.append(li);
235
235
  }
236
236
  $('peersNote').textContent = '';
237
- } catch (e) { $('peersNote').textContent = ''; $('ctlErr').textContent = e.message; }
237
+ } catch (e) {
238
+ $('peersNote').textContent = '';
239
+ $('peers').replaceChildren(el('li', { class: 'empty' }, '没列出电脑,看下面的原因'));
240
+ $('ctlErr').textContent = e.message;
241
+ }
238
242
  }
239
243
  function select(p, li) {
240
244
  selected = p;
@@ -312,6 +312,8 @@ export class TurnProgress {
312
312
 
313
313
  call(callId, name, args) {
314
314
  if (this.ended) return;
315
+ // ACP updates for an existing call do not start another execution.
316
+ if (callId !== undefined && this.names.has(callId)) return;
315
317
  this.steps++;
316
318
  if (callId !== undefined) this.names.set(callId, name);
317
319
  this.items.push({ kind: 'tool_use', tool: name, text: toolArgText(args) });
@@ -6,13 +6,15 @@
6
6
  "标题行 = 去掉前导空白后以 # 开头的行;一节 = 从标题(或 inline 时以节名开头的行)到下一个标题。lead 是行首可去掉的字符。",
7
7
  "正则只写 ASCII 字符类(JS 的 \\w、\\d 只认 ASCII,Python 认 Unicode),按 search 用,要锚定就写 ^。",
8
8
  "checks 按顺序跑;ws-handoff 一次列出所有不合格项,DSH collab 报第一条。dispatch 列出只带字段的派活(/tasks:title、ref、base)要跑的检查:字段拼成一份交接再查。",
9
- "改规则后跑 contrib/lead/test_handoff_rules.py(Python 与 Node 跑同一组 fixture 比对)。"
9
+ "改规则后跑 contrib/lead/test_handoff_rules.py(Python 与 Node 跑同一组 fixture 比对)。",
10
+ "tier:fact = 程序能拿外面的东西核对的检查(路径存在、原话搜得到、PR 查得到、不引用 /tmp),所有主角色都过;format = 只看有没有写、写多长,只在主角色后端是 DS 类(dsh、deepseek 模型)时开(ws-handoff 按 config.toml 里本项目的 agent 判)。带 verify 的 fact 检查再真去核对写的内容,一次最多核五条,问题一次报完。起因:用户 11-06 认可「核事实全开、核格式只给 DS」,强模型被格式检查打回六次没变聪明。DSH collab 只读本文件、跑全部检查(DSH 本身就是 DS 类)。"
10
11
  ],
11
12
  "lead": "#-*> \t",
12
13
  "checks": [
13
14
  {
14
15
  "id": "sections",
15
16
  "type": "headings",
17
+ "tier": "format",
16
18
  "names": [
17
19
  "用户原话",
18
20
  "总目标",
@@ -24,17 +26,27 @@
24
26
  {
25
27
  "id": "ref",
26
28
  "type": "ref",
29
+ "tier": "fact",
27
30
  "section": "参照实现",
28
31
  "inline": true,
29
32
  "none_re": "无\\s*[((]\\s*新建\\s*[))]",
30
33
  "ref_re": "[~A-Za-z0-9_./-]*[A-Za-z_][~A-Za-z0-9_./-]*:[0-9]+",
31
34
  "path_re": "[/.]",
32
35
  "msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:\n## 参照实现\n- bridge/contrib/lead/ws-up:120(和它的差异:…)",
33
- "dispatch_msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:ref: \"dsh/collab/tasks.mjs:226\",或 ref: \"无(新建)\""
36
+ "dispatch_msg": "缺参照实现:写 文件:行,非代码活写「无(新建)」。示例:ref: \"dsh/collab/tasks.mjs:226\",或 ref: \"无(新建)\"",
37
+ "verify": {
38
+ "kind": "paths",
39
+ "msg": "「参照实现」写的路径找不到:{items}。写真实存在的 文件:行(完整路径或 ~/ 开头),或改成「无(新建)」。",
40
+ "remote_re": "容器|服务器|远程|远端|隔壁|对面|另一台|别的机器|[Vv][Pp][Ss]|ssh|[Cc]ontainer|[Rr]emote|[Ss]erver",
41
+ "remote_sections": [
42
+ "总目标"
43
+ ]
44
+ }
34
45
  },
35
46
  {
36
47
  "id": "scope",
37
48
  "type": "kinds",
49
+ "tier": "format",
38
50
  "section": "范围",
39
51
  "line_re": "^(不?改)\\s*[::]\\s*\\S",
40
52
  "need": [
@@ -47,6 +59,7 @@
47
59
  {
48
60
  "id": "base",
49
61
  "type": "heading_needs_field",
62
+ "tier": "format",
50
63
  "headings": [
51
64
  "共改文件",
52
65
  "多人改"
@@ -58,12 +71,14 @@
58
71
  {
59
72
  "id": "no_tmp",
60
73
  "type": "forbid",
74
+ "tier": "fact",
61
75
  "re": "(^|[\\s`'\"((「:=])/(private/)?tmp/",
62
76
  "msg": "交接里引用了 /tmp 下的文件:下一棒(换了会话、重启过)常找不到。脚本和状态文件挪到角色目录(如 notes/、~/work/),交接里写完整路径。示例:\n- 上一棒的脚本:~/work/tools/render_cover.py(不写 /tmp/render.py)"
63
77
  },
64
78
  {
65
79
  "id": "browser_tab",
66
80
  "type": "needs_if",
81
+ "tier": "format",
67
82
  "trigger_re": "(开|用|在|进|上)个?浏览器|浏览器(里|上|中|配置)|[Cc]hrome ?(里|上)|登录态下(操作|下单|点)",
68
83
  "need_re": "(标签页|tab)\\s*(id|ID|Id)?\\s*[::=]\\s*([A-Za-z0-9_-]+|无)",
69
84
  "msg": "浏览器活要写用哪个标签页:一行「标签页 id:<id>」,下一棒直接接着用,不用重新找页面、重新登录;还没开过页面写「标签页 id:无」。"
package/collab/page.html CHANGED
@@ -1219,12 +1219,22 @@ function connect() {
1219
1219
  $('input').addEventListener('keydown', (e) => {
1220
1220
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); $('form').requestSubmit(); }
1221
1221
  });
1222
- $('input').addEventListener('input', (e) => { e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; });
1222
+ // #205: fit the box to its text, or to the placeholder while empty — on a narrow window (phone,
1223
+ // Windows at 125%/150%) the hint wraps to two lines and a one-row box cut the second line off.
1224
+ // scrollHeight leaves the border out; add it back so border-box doesn't leave a sliver to scroll.
1225
+ function fitInput() {
1226
+ const t = $('input');
1227
+ t.style.height = 'auto';
1228
+ t.style.height = (t.scrollHeight + t.offsetHeight - t.clientHeight) + 'px';
1229
+ }
1230
+ $('input').addEventListener('input', fitInput);
1231
+ window.addEventListener('resize', fitInput, { passive: true });
1232
+ fitInput();
1223
1233
  $('form').addEventListener('submit', async (e) => {
1224
1234
  e.preventDefault();
1225
1235
  const text = $('input').value.trim();
1226
1236
  if (!text || state.room == null) return;
1227
- $('input').value = ''; $('input').style.height = 'auto';
1237
+ $('input').value = ''; fitInput();
1228
1238
  try {
1229
1239
  const { message } = await api(`${ROOT}/collab/api/rooms/${state.room}/messages`, {
1230
1240
  method: 'POST', headers: { 'content-type': 'application/json' },
@@ -250,9 +250,20 @@ export async function whoisName(ip, run = tailscale) {
250
250
  } catch { return undefined; }
251
251
  }
252
252
 
253
+ /**
254
+ * Parse tailscale's --json output. The macOS app's CLI prints a plain sentence instead
255
+ * ("The Tailscale GUI failed to start: …") and still exits 0 when the app is not running,
256
+ * so a bare JSON.parse would put "Unexpected token 'T'…" on the devices page.
257
+ */
258
+ export function tailscaleJSON(out) {
259
+ try { return JSON.parse(out); } catch {
260
+ throw new PairError('tailnet', 'Tailscale 没在运行或没登录,请先打开 Tailscale');
261
+ }
262
+ }
263
+
253
264
  /** Other machines in the tailnet: [{ name, ip, online, os }]. */
254
265
  export async function tailnetPeers(run = tailscale) {
255
- const status = JSON.parse(await run(['status', '--json']));
266
+ const status = tailscaleJSON(await run(['status', '--json']));
256
267
  return Object.values(status?.Peer ?? {}).map(p => ({
257
268
  name: String(p.HostName || p.DNSName || '').trim() || '未命名电脑',
258
269
  ip: (p.TailscaleIPs ?? []).find(isTailnetAddress) ?? p.TailscaleIPs?.[0],
package/collab/panel.mjs CHANGED
@@ -75,11 +75,21 @@ function sendJSON(res, status, body) {
75
75
  res.end(JSON.stringify(body));
76
76
  }
77
77
 
78
- /** Text shown inside the frame when the panel cannot be reached. */
79
- function sendNotice(res, status, reason) {
78
+ const escapeHTML = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
79
+
80
+ /**
81
+ * Page shown inside the frame when the panel cannot be reached: what is wrong, what to do,
82
+ * and a retry button, so a DSH-only user who switched the panel on is not left at a dead end.
83
+ */
84
+ function sendNotice(res, status, reason, help) {
80
85
  res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
81
- res.end(`<!doctype html><meta charset="utf-8"><title>bothouse</title><body style="font:14px system-ui;padding:24px;color:#888">打不开 bothouse 管理面板:${reason
82
- .replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])}</body>`);
86
+ 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>`);
83
93
  }
84
94
 
85
95
  /**
@@ -117,14 +127,16 @@ export function panelRoute(req, res, url, { bridgeConfig, fetchImpl = fetch }) {
117
127
  return (async () => {
118
128
  const rest = url.pathname.slice(PANEL_PREFIX.length) || '/';
119
129
  const isAPI = rest.startsWith('/api/');
120
- const fail = (status, reason) => (isAPI ? sendJSON(res, status, { ok: false, error: reason }) : sendNotice(res, status, reason), true);
130
+ const fail = (status, reason, help) => (isAPI ? sendJSON(res, status, { ok: false, error: reason }) : sendNotice(res, status, reason, help), true);
121
131
  let settings;
122
132
  try {
123
133
  settings = managementSettings(readFileSync(bridgeConfig, 'utf8'));
124
134
  } catch {
125
- return fail(503, '读不到主桥配置');
135
+ return fail(503, `这台电脑没找到 bothouse:读不到 ${bridgeConfig}`,
136
+ '这个面板管理完整版 bothouse 的角色、用量和设置。装好 bothouse 并启动一次后点「重试」;只用频道的话,可以在「插件 → bothouse」里关掉「bothouse 面板」这一项。');
126
137
  }
127
- if (!settings.enabled) return fail(503, '主桥配置里管理接口没打开');
138
+ if (!settings.enabled) return fail(503, `bothouse 的管理接口没打开:${bridgeConfig} 里 [management] 没有 enabled = true`,
139
+ '在那一节写上 enabled = true,重启 bothouse 后点「重试」。');
128
140
  const upstream = `http://${dialHost(settings.host)}:${settings.port}${rest}${url.search}`;
129
141
  const headers = {};
130
142
  for (const name of PASS_REQUEST) {
@@ -143,7 +155,7 @@ export function panelRoute(req, res, url, { bridgeConfig, fetchImpl = fetch }) {
143
155
  redirect: 'manual',
144
156
  });
145
157
  } catch {
146
- return fail(502, '连不上主桥管理接口');
158
+ return fail(502, `bothouse 没在运行:连不上 ${dialHost(settings.host)}:${settings.port}`, '启动 bothouse 后点「重试」。');
147
159
  }
148
160
  const out = {};
149
161
  for (const name of PASS_RESPONSE) {
package/collab/tasks.mjs CHANGED
@@ -41,6 +41,26 @@ export const CHECK_AFTER_S = 10 * 60;
41
41
  export const MAX_RENEWALS = 0;
42
42
  // setTimeout's own maximum delay (Node clamps anything larger to 1 ms).
43
43
  const MAX_TIMER_MS = 2 ** 31 - 1;
44
+ /**
45
+ * The bridge watch posts its wake (「接着做 X,做完再结束本轮…」, i18n MsgWatchResume) and its notices
46
+ * (「看门:…」, MsgWatchIdleEscalate / MsgWatchFailEscalate …) in the lead's name. They chase work that
47
+ * already exists; read as a lead dispatch they opened a new task per wake (#221, 11-06: 「#6 接着做 -」).
48
+ * The text follows bridge/core/i18n.go; keep both in step.
49
+ */
50
+ const WATCH_NOTICE_PREFIXES = ['看门:', '看門:', 'Watch: ', '見守り:'];
51
+ const WATCH_RESUME_LINES = [
52
+ /^接着做 .+,做完再结束本轮,交回时第一行写 \[report\]$/,
53
+ /^接著做 .+,做完再結束本輪,交回時第一行寫 \[report\]$/,
54
+ /^Continue .+ and finish it; when done, start the reply with \[report\]$/,
55
+ /^.+ を続けて完了させてください。完了したら返信の1行目を \[report\] にしてください$/,
56
+ /^Continúa .+ hasta terminarlo; al terminar, empieza la respuesta con \[report\]$/,
57
+ ];
58
+ export function isWatchNotice(text, team = []) {
59
+ // A wake may have a handoff-file line after it. A report instruction elsewhere in a dispatch is not a wake.
60
+ const first = withoutLeadingMentions(String(text), team).split('\n', 1)[0].trim();
61
+ return WATCH_NOTICE_PREFIXES.some(p => first.startsWith(p)) || WATCH_RESUME_LINES.some(p => p.test(first));
62
+ }
63
+
44
64
  const STANCE_LINE = /^\s*【(接|问|退|升)】\s*(?:#(\d+))?\s*(.*)$/;
45
65
  // The dispatcher's explicit review of a workspace task, a line of its chat-channel reply:
46
66
  // 【验收】#id closes it, 【返工】#id 原因 sends it back (Tasks.verdictsOf).
@@ -646,6 +666,9 @@ export class Tasks {
646
666
  /** A member reply whose first line is 【接】【问】【退】【升】 (optionally #id) answers its task. */
647
667
  fromMessage(msg) {
648
668
  if (msg.author_kind !== 'role' || msg.card !== null) return undefined;
669
+ const lead = this.store.leadId(msg.room_id) === null ? this.hub.primaryMember() : this.hub.primaryIn(msg.room_id);
670
+ // Watch posts arrive as the lead. Ignore them before verdicts, stances, hand-backs or implicit reviews.
671
+ if (msg.author === lead?.id && isWatchNotice(msg.text, this.hub.routableIn(msg.room_id))) return undefined;
649
672
  if (this.verdictsOf(msg)) return undefined;
650
673
  this.settleCall(msg);
651
674
  const stance = this.stanceOf(msg);
@@ -30,7 +30,7 @@
30
30
  "no_check_not_exempt": "「自验:无需」只限方向、花钱、需要用户看的成品、权限确认四类,其它类别要附证据"
31
31
  },
32
32
  "bounce": {
33
- "text": "这条 [{kind}] 没有转给用户:{problems}。补上后重发:第一行写 [{kind}] #任务板编号 背景:……,再起一行写做了什么,再一行写「要你做的:……」,这三段系统原样贴到聊天频道;之后的行是细节,贴在那条消息下的子话题{extra}。已经做完的活和测试不要重做、不要重跑,只把这条交回改好重发。",
33
+ "text": "这条 [{kind}] 没有转给用户:{problems}。补上后重发:第一行写 [{kind}] #任务板编号 背景:……,再起一行写做了什么,再一行写「要你做的:……」,这三段系统原样贴到聊天频道;之后的行是细节,贴在那条消息下的子话题{extra}。",
34
34
  "sep": ";",
35
35
  "extra": {
36
36
  "ask": ",正文写「自验:」附证据和「类别:」;方向、花钱、需要用户看的成品、权限确认四类可写「自验:无需」"
@@ -2,7 +2,7 @@
2
2
  - 频道分两种:聊天频道(用户在这里聊天、被提问、看阶段结果)和它下面的工作区(干活的地方)。
3
3
  - 多步骤的活先开公开任务卡(先发 --kind start);闲聊、问答不开。
4
4
  - 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
5
- - 聊天频道对真人说人话:讲场景,最多三段,不用表格、加粗标题和代码,细节进子话题。
5
+ - 聊天频道里对真人说人话:讲场景、短段落,默认不用表格、不贴代码,细节进子话题。
6
6
  - 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
7
7
  - 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。在跑时有截图或阶段结果就 ws-up 推一条。
8
8
  - 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。