bothouse-dsh 0.1.47 → 0.1.49

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.
@@ -0,0 +1,30 @@
1
+ // 固定的 bot 接入端口(#142)。DSH 桌面版启动时把 Web 宿主端口写死成 0(每次随机,见
2
+ // DeepSeek Harness.app dsh-desktop-host main() 的 "--port","0"),主桥的 cloud_web 无法配置固定地址。
3
+ // 配了 botPort 时,插件另开一个只听 127.0.0.1 的小监听,只转两样东西:cloud-web 握手
4
+ // (WS_PATH,与宿主上同一个处理函数、同一口令)和离线补历史 /api/rooms/…;其余一律 404。
5
+ import { createServer } from 'node:http';
6
+
7
+ /**
8
+ * @param {{ port: number, path: string, upgrade: Function, history: Function, logger?: any }} options
9
+ * @returns {Promise<{ port: number, close: () => Promise<void> }>}
10
+ */
11
+ export function startBotPort({ port, path, upgrade, history, logger }) {
12
+ const server = createServer((req, res) => {
13
+ const p = new URL(req.url ?? '/', 'http://collab.invalid').pathname;
14
+ if (p === '/api/rooms' || p.startsWith('/api/rooms/')) return history(req, res);
15
+ res.writeHead(404, { 'content-type': 'application/json' }).end('{"error":"not found"}');
16
+ });
17
+ server.on('upgrade', (req, socket, head) => {
18
+ const p = new URL(req.url ?? '/', 'http://collab.invalid').pathname;
19
+ if (p !== path) { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
20
+ upgrade(req, socket, head);
21
+ });
22
+ return new Promise((resolve, reject) => {
23
+ server.once('error', reject);
24
+ server.listen(port, '127.0.0.1', () => {
25
+ server.off('error', reject);
26
+ server.on('error', err => logger?.warn?.('conest-collab: bot port: %s', err.message));
27
+ resolve({ port: server.address().port, close: () => new Promise(done => { server.closeAllConnections?.(); server.close(() => done()); }) });
28
+ });
29
+ });
30
+ }
@@ -104,6 +104,9 @@ export class CliMember {
104
104
  cwd: this.cwd,
105
105
  stdio: ['pipe', 'pipe', 'inherit'],
106
106
  env: { ...cleanEnv(), ...this.extraEnv },
107
+ // Windows: `gemini` / `npx` are npm .cmd shims, which only start through a shell (spawn without
108
+ // one gives ENOENT, and EINVAL for a .cmd path). Args here are fixed names and flags, no spaces.
109
+ shell: process.platform === 'win32' && cmd !== process.execPath,
107
110
  })
108
111
  this.proc = proc
109
112
  const client = this._client()
package/collab/clis.mjs CHANGED
@@ -3,11 +3,18 @@
3
3
  import { execFile } from 'node:child_process';
4
4
  import { CLIS } from './roster.mjs';
5
5
 
6
- /** Default lookup: `which <name>` → absolute path, or undefined when absent. */
7
- export function which(name) {
6
+ /**
7
+ * Default lookup: `which <name>` → absolute path, or undefined when absent. Windows has no `which`:
8
+ * `where.exe` lists every match (CRLF), including npm's extensionless sh shim next to the .cmd, so the
9
+ * first one Windows can start (PATHEXT) is taken.
10
+ */
11
+ export function which(name, platform = process.platform) {
12
+ const win = platform === 'win32';
8
13
  return new Promise((resolve) => {
9
- execFile('which', [name], (error, stdout) => {
10
- const path = String(stdout ?? '').trim().split('\n')[0];
14
+ execFile(win ? 'where.exe' : 'which', [name], (error, stdout) => {
15
+ const lines = String(stdout ?? '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
16
+ const exts = String(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').toLowerCase().split(';').filter(Boolean);
17
+ const path = win ? (lines.find(l => exts.some(e => l.toLowerCase().endsWith(e))) ?? lines[0]) : lines[0];
11
18
  resolve(error || !path ? undefined : path);
12
19
  });
13
20
  });
@@ -16,7 +16,7 @@ export const WS_PATH = '/cloud-web/ws';
16
16
  // a `buttons` frame (bridge platform/cloud-web SendWithButtons) instead of plain
17
17
  // text (#120); cards are not declared, so cc-connect degrades them to text
18
18
  // (bridge docs/cloud-web.md "Handshake").
19
- export const CAPABILITIES = ['text', 'reconstruct_reply', 'typing', 'preview', 'update_message', 'delete_message', 'reply_in_place', 'buttons', 'tasks', 'dispatch_mode'];
19
+ export const CAPABILITIES = ['text', 'reconstruct_reply', 'typing', 'preview', 'update_message', 'delete_message', 'reply_in_place', 'buttons', 'tasks', 'dispatch_mode', 'queue'];
20
20
  // Permission decisions the Channel page and text replies may send. Values are the
21
21
  // button data cc-connect's sendPermissionPrompt uses (bridge core/engine.go).
22
22
  /**
@@ -52,12 +52,20 @@ export const NOTICE_PREFIXES = ['📬', '✅', '↪', '⏳', '🔄', '⏹', '⏸
52
52
  const FOOTER = /(?:^|\n+)\*[^*\n]*·[^*]*\*\s*$/s;
53
53
  const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息判断要你做什么;看不出来就问)';
54
54
  export const PROGRESS_PREFIX = '__cc_connect_progress_card_v1__:';
55
- const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败' };
55
+ // cancelled (#913): a card whose turn was cut without an outcome — a restarted bridge, a
56
+ // cancelled DSH job. page.html styles it like any other card state.
57
+ const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消' };
56
58
  const RESERVED_IDS = new Set(['user', 'system']);
57
59
  const MAX_NAME = 64;
58
60
 
59
61
  export function roomKey(roomId) { return `room-${roomId}`; }
60
62
 
63
+ /**
64
+ * #1023: chat_type for a frame from this room — "direct" for a private chat (the bridge's
65
+ * documented DM value, bridge/platform/cloud-web/cloudweb.go Direct), else "group".
66
+ */
67
+ export function chatType(store, roomId) { return store?.dmMember?.(roomId) !== undefined ? 'direct' : 'group'; }
68
+
61
69
  /** Room id from a frame's reply_ctx or session_key ("<platform>:room-<id>"), or undefined. */
62
70
  export function roomOf(frame, store) {
63
71
  const sk = frame.session_key;
@@ -96,19 +104,21 @@ export function progressCardOf(p) {
96
104
  lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + ' — ' : ''}${text.slice(0, 120)}`);
97
105
  }
98
106
  const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
99
- const brief = [lines[0], ...briefSteps(items, state)].join('\n');
100
- const card = { text: lines.join('\n'), card: JSON.stringify({ title: `${agent} · ${CARD_STATES[state]}`, state, steps, elapsed_sec: sec, brief }) };
107
+ const run = toolSteps(items);
108
+ const brief = [lines[0], ...briefSteps(run, state)].join('\n');
109
+ const chips = stepChips(run, state);
110
+ const card = { text: lines.join('\n'), card: JSON.stringify({ title: `${agent} · ${CARD_STATES[state]}`, state, steps, elapsed_sec: sec, brief, chips }) };
101
111
  // Bridge task card (cloud-web public_progress.go): its members feed the task board (tasks.mjs fromCard).
102
112
  return Array.isArray(p.task_members) ? { ...card, members: p.task_members } : card;
103
113
  }
104
114
 
105
115
  /**
106
- * The 简略 view of a progress card, one line per tool step: tool name and
107
- * 成功/失败 (进行中 while the turn runs), no command text or output. Same
108
- * rules as the Discord card (bridge/platform/discord/progress.go
109
- * buildDiscordProgressBrief, discordProgressItemFailed).
116
+ * Tool steps of a turn, in order: each tool_use opens a step, its tool_result (same tool, else
117
+ * the last open one) closes it as succeeded/failed, an error fails the last open step. Same
118
+ * pairing as the Discord card (bridge/platform/discord/progress.go buildDiscordProgressBrief,
119
+ * discordProgressItemFailed). Each step keeps its call and output text for the step chips.
110
120
  */
111
- function briefSteps(items, state) {
121
+ function toolSteps(items) {
112
122
  const steps = [];
113
123
  const open = tool => {
114
124
  for (let i = steps.length - 1; i >= 0; i--) if (steps[i].state === 0 && (tool === '' || steps[i].tool === tool)) return i;
@@ -116,19 +126,33 @@ function briefSteps(items, state) {
116
126
  };
117
127
  const failed = it => typeof it.success === 'boolean' ? !it.success
118
128
  : (Number.isInteger(it.exit_code) && it.exit_code !== 0) || ['failed', 'failure', 'error'].includes(String(it.status ?? '').trim().toLowerCase());
129
+ // 沿用 progressCardOf 正文每行截断 120 字。
130
+ const clip = v => {
131
+ const text = String(v ?? '').replace(/\s+/g, ' ').trim();
132
+ return text.length > 120 ? `${text.slice(0, 119)}…` : text;
133
+ };
119
134
  for (const it of items) {
120
135
  if (it === null || typeof it !== 'object') continue;
121
136
  const tool = String(it.tool ?? '').trim();
122
- if (it.kind === 'tool_use') steps.push({ tool, state: 0 });
137
+ if (it.kind === 'tool_use') steps.push({ tool, state: 0, input: clip(it.text), output: '' });
123
138
  else if (it.kind === 'tool_result') {
124
139
  const st = failed(it) ? 2 : 1;
125
140
  const i = open(tool);
126
- if (i >= 0) steps[i].state = st; else steps.push({ tool, state: st });
141
+ if (i >= 0) Object.assign(steps[i], { state: st, output: clip(it.text) });
142
+ else steps.push({ tool, state: st, input: '', output: clip(it.text) });
127
143
  } else if (it.kind === 'error') {
128
144
  const i = open('');
129
- if (i >= 0) steps[i].state = 2;
145
+ if (i >= 0) Object.assign(steps[i], { state: 2, output: clip(it.text) });
130
146
  }
131
147
  }
148
+ return steps;
149
+ }
150
+
151
+ /**
152
+ * The 简略 view of a progress card, one line per tool step: tool name and
153
+ * 成功/失败 (进行中 while the turn runs), no command text or output.
154
+ */
155
+ function briefSteps(steps, state) {
132
156
  return steps.map(s => {
133
157
  const name = (s.tool || '工具').slice(0, 120);
134
158
  if (s.state === 1) return `✅ ${name} · 成功`;
@@ -137,6 +161,16 @@ function briefSteps(items, state) {
137
161
  });
138
162
  }
139
163
 
164
+ /** Step chips for the DSH page (#125, owner's 11:53 sample): tool, outcome, call and output per step. */
165
+ function stepChips(steps, state) {
166
+ return steps.map(s => ({
167
+ tool: (s.tool || '工具').slice(0, 120),
168
+ state: s.state === 1 ? 'ok' : s.state === 2 ? 'failed' : state === 'running' ? 'running' : 'unknown',
169
+ ...(s.input ? { input: s.input } : {}),
170
+ ...(s.output ? { output: s.output } : {}),
171
+ }));
172
+ }
173
+
140
174
  /** reply_to_* frame fields for a reply (store.quote result); {} for a plain message. */
141
175
  function replyToFields(quoted, store) {
142
176
  if (quoted === undefined) return {};
@@ -231,7 +265,8 @@ export class CloudWebGateway {
231
265
  // avatar=<built-in slug> or an http(s) image URL (cc-connect's own library); anything else falls back to the initial badge.
232
266
  let avatar = '';
233
267
  try { avatar = checkAvatar(url.searchParams.get('avatar'), { allowUrl: true }); } catch { avatar = ''; }
234
- const member = { id, name, rooms, kind: 'bot', mentionId: /^\d+$/.test(mentionId) ? mentionId : '', online: true, ...(avatar ? { avatar } : {}) };
268
+ // voter: the bridge project, as which its agent cites memories (memvote.py CC_PROJECT; memory-sources.mjs).
269
+ const member = { id, name, rooms, kind: 'bot', mentionId: /^\d+$/.test(mentionId) ? mentionId : '', online: true, ...(avatar ? { avatar } : {}), voter: project || id };
235
270
  const bot = { ws, member };
236
271
  const old = this.bots.get(id);
237
272
  this.bots.set(id, bot);
@@ -245,9 +280,48 @@ export class CloudWebGateway {
245
280
  this.hub.dropDrafts('role', id);
246
281
  // ...and its pending permission requests are gone with the old process.
247
282
  this.approvals.expireWhere(p => p.meta?.gateway === this && p.meta.bot === id);
283
+ // ...and the progress cards it already posted would stay 「处理中」 forever: the old
284
+ // process held the only handle, and the turn it resumes posts a second card (#913).
285
+ this.closeStaleCards(id);
248
286
  return bot;
249
287
  }
250
288
 
289
+ /**
290
+ * #913: close the progress cards a bot left running. A restarted cc-connect keeps no
291
+ * message ids, so nothing will ever edit those rows again, while its resumed turn posts
292
+ * a fresh card; the room then shows two 「处理中」 and no way to tell which is alive.
293
+ * They are closed as 已取消 (page.html already styles that state) and keep their steps,
294
+ * plus a line saying the turn was cut, so the history stays readable.
295
+ *
296
+ * Every card row this member posted is looked at, not the last page of one room: the
297
+ * room may have carried a hundred messages since (a busy channel, a workshop), and a
298
+ * running card there must still be closed. Only this member's own rows are touched,
299
+ * and only rows still `running`: a card the resumed turn reopens by its own handle goes
300
+ * back to 处理中 on the next frame.
301
+ */
302
+ closeStaleCards(memberId) {
303
+ for (const row of this.hub.store.authorCards('role', memberId)) {
304
+ let card;
305
+ try { card = JSON.parse(row.card); } catch { continue; }
306
+ // A permission card a hub restart left pending: its asker is gone, and next to the
307
+ // reconnected bot's new prompt it reads as the same request asked twice (#1017).
308
+ if (card?.kind === 'permission' && card.state === 'pending' && !this.approvals.pending.has(row.id)) {
309
+ this.approvals.close(row.id, 'expired', row);
310
+ continue;
311
+ }
312
+ if (card?.state !== 'running') continue;
313
+ const note = '⏹ 已中断(桥重启),这一步不会再更新';
314
+ const brief = typeof card.brief === 'string' && card.brief !== '' ? `${card.brief}\n${note}` : note;
315
+ // The stored title is already rendered ("CC · 处理中"): swap only its state label,
316
+ // so the card keeps its agent name.
317
+ const label = ` · ${CARD_STATES[card.state] ?? ''}`;
318
+ const agent = typeof card.title === 'string' && card.title.endsWith(label) ? card.title.slice(0, -label.length) : (card.title ?? 'Agent');
319
+ // A cut turn reports no outcome for its open steps.
320
+ const chips = Array.isArray(card.chips) ? card.chips.map(c => c?.state === 'running' ? { ...c, state: 'unknown' } : c) : card.chips;
321
+ this.hub.edit(row.id, 'role', memberId, `${row.text}\n${note}`, JSON.stringify({ ...card, state: 'cancelled', title: `${agent} · ${CARD_STATES.cancelled}`, brief, chips }));
322
+ }
323
+ }
324
+
251
325
  /** Forward one new channel message to every connected bot except its author. */
252
326
  onNew(msg) {
253
327
  if (msg.author_kind === 'system' || msg.card !== null || this.bots.size === 0 || this.hub.teamExit?.answerFor(msg)) return;
@@ -280,14 +354,18 @@ export class CloudWebGateway {
280
354
  // A reply addressed to other bots is not this bot's to take (reply_routing.go accept=false).
281
355
  if (only !== null && !only.has(member.id)) continue;
282
356
  let content = forMember(text, member, members);
283
- const hit = target === member.id || only !== null || mentioned.has(member.id) || nameCalled(text, member.name);
357
+ // #1023: an owner message in a private chat is addressed to its one agent.
358
+ const hit = target === member.id || only !== null || mentioned.has(member.id) || nameCalled(text, member.name)
359
+ || (!fromAgent && this.hub.store.dmMember?.(msg.room_id) === member.id);
284
360
  if (fromAgent && !content) continue;
285
361
  if (fromAgent) content = `【${msg.name}】${quote}${content}`;
286
362
  else if (hit && !content) content = quote + BARE_MENTION;
287
363
  else content = quote + content;
288
364
  const frame = {
289
- type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: 'group',
290
- user_id: msg.author, user_name: msg.name, content: content + (hit ? (this.later?.instructions(member.id, msg.room_id) ?? '') : ''), reply_ctx: roomKey(msg.room_id),
365
+ type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: chatType(this.hub.store, msg.room_id),
366
+ // A bridge command (`@橘 /model x`) goes through bare: the bridge reads everything after the
367
+ // command word as its arguments (core/engine.go handleCommand), so no later instructions.
368
+ user_id: msg.author, user_name: msg.name, content: content + (hit && !isCommand(content) ? (this.later?.instructions(member.id, msg.room_id) ?? '') : ''), reply_ctx: roomKey(msg.room_id),
291
369
  mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
292
370
  };
293
371
  if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
@@ -307,7 +385,7 @@ export class CloudWebGateway {
307
385
  const members = this.hub.routableIn(msg.room_id);
308
386
  const room = this.hub.store.room(msg.room_id);
309
387
  const frame = {
310
- type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: 'group',
388
+ type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: chatType(this.hub.store, msg.room_id),
311
389
  user_id: msg.author, user_name: msg.name, content: forMember(msg.text, bot.member, members) || msg.text,
312
390
  reply_ctx: roomKey(msg.room_id), mentioned: true, from_bot: false,
313
391
  };
@@ -486,6 +564,14 @@ export class CloudWebGateway {
486
564
  this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text: '', attachments });
487
565
  return;
488
566
  }
567
+ case 'queue': {
568
+ // #1020: the owner's messages ("hub-<id>") waiting behind this bot's running turn here (bridge core/queue_report.go).
569
+ const room = roomOf(d, this.hub.store);
570
+ if (room === undefined || !Array.isArray(d.msg_ids)) return;
571
+ const ids = d.msg_ids.map(x => /^hub-(\d+)$/.exec(String(x))?.[1]).filter(Boolean).map(Number);
572
+ this.hub.setQueue(room, member.id, ids);
573
+ return;
574
+ }
489
575
  case 'typing_start':
490
576
  case 'typing_stop': {
491
577
  const room = roomOf(d, this.hub.store);
@@ -578,6 +664,9 @@ function send(ws, frame) {
578
664
  try { ws.send(JSON.stringify(frame)); } catch { /* socket already closing; its close handler cleans up */ }
579
665
  }
580
666
 
667
+ /** A bridge slash command as forwarded (`/stop`, `/model x`); quoted replies keep their prefix and are not. */
668
+ export function isCommand(content) { return /^\/[a-z]/i.test(content); }
669
+
581
670
  function clean(s) { return String(s ?? '').replace(/[\x00-\x1f\x7f]/g, '').trim(); }
582
671
 
583
672
  function timingSafeEqualString(a, b) {