bothouse-dsh 0.1.48 → 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
  /**
@@ -60,6 +60,12 @@ const MAX_NAME = 64;
60
60
 
61
61
  export function roomKey(roomId) { return `room-${roomId}`; }
62
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
+
63
69
  /** Room id from a frame's reply_ctx or session_key ("<platform>:room-<id>"), or undefined. */
64
70
  export function roomOf(frame, store) {
65
71
  const sk = frame.session_key;
@@ -98,19 +104,21 @@ export function progressCardOf(p) {
98
104
  lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + ' — ' : ''}${text.slice(0, 120)}`);
99
105
  }
100
106
  const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
101
- const brief = [lines[0], ...briefSteps(items, state)].join('\n');
102
- 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 }) };
103
111
  // Bridge task card (cloud-web public_progress.go): its members feed the task board (tasks.mjs fromCard).
104
112
  return Array.isArray(p.task_members) ? { ...card, members: p.task_members } : card;
105
113
  }
106
114
 
107
115
  /**
108
- * The 简略 view of a progress card, one line per tool step: tool name and
109
- * 成功/失败 (进行中 while the turn runs), no command text or output. Same
110
- * rules as the Discord card (bridge/platform/discord/progress.go
111
- * 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.
112
120
  */
113
- function briefSteps(items, state) {
121
+ function toolSteps(items) {
114
122
  const steps = [];
115
123
  const open = tool => {
116
124
  for (let i = steps.length - 1; i >= 0; i--) if (steps[i].state === 0 && (tool === '' || steps[i].tool === tool)) return i;
@@ -118,19 +126,33 @@ function briefSteps(items, state) {
118
126
  };
119
127
  const failed = it => typeof it.success === 'boolean' ? !it.success
120
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
+ };
121
134
  for (const it of items) {
122
135
  if (it === null || typeof it !== 'object') continue;
123
136
  const tool = String(it.tool ?? '').trim();
124
- 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: '' });
125
138
  else if (it.kind === 'tool_result') {
126
139
  const st = failed(it) ? 2 : 1;
127
140
  const i = open(tool);
128
- 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) });
129
143
  } else if (it.kind === 'error') {
130
144
  const i = open('');
131
- if (i >= 0) steps[i].state = 2;
145
+ if (i >= 0) Object.assign(steps[i], { state: 2, output: clip(it.text) });
132
146
  }
133
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) {
134
156
  return steps.map(s => {
135
157
  const name = (s.tool || '工具').slice(0, 120);
136
158
  if (s.state === 1) return `✅ ${name} · 成功`;
@@ -139,6 +161,16 @@ function briefSteps(items, state) {
139
161
  });
140
162
  }
141
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
+
142
174
  /** reply_to_* frame fields for a reply (store.quote result); {} for a plain message. */
143
175
  function replyToFields(quoted, store) {
144
176
  if (quoted === undefined) return {};
@@ -233,7 +265,8 @@ export class CloudWebGateway {
233
265
  // avatar=<built-in slug> or an http(s) image URL (cc-connect's own library); anything else falls back to the initial badge.
234
266
  let avatar = '';
235
267
  try { avatar = checkAvatar(url.searchParams.get('avatar'), { allowUrl: true }); } catch { avatar = ''; }
236
- 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 };
237
270
  const bot = { ws, member };
238
271
  const old = this.bots.get(id);
239
272
  this.bots.set(id, bot);
@@ -270,6 +303,12 @@ export class CloudWebGateway {
270
303
  for (const row of this.hub.store.authorCards('role', memberId)) {
271
304
  let card;
272
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
+ }
273
312
  if (card?.state !== 'running') continue;
274
313
  const note = '⏹ 已中断(桥重启),这一步不会再更新';
275
314
  const brief = typeof card.brief === 'string' && card.brief !== '' ? `${card.brief}\n${note}` : note;
@@ -277,7 +316,9 @@ export class CloudWebGateway {
277
316
  // so the card keeps its agent name.
278
317
  const label = ` · ${CARD_STATES[card.state] ?? ''}`;
279
318
  const agent = typeof card.title === 'string' && card.title.endsWith(label) ? card.title.slice(0, -label.length) : (card.title ?? 'Agent');
280
- this.hub.edit(row.id, 'role', memberId, `${row.text}\n${note}`, JSON.stringify({ ...card, state: 'cancelled', title: `${agent} · ${CARD_STATES.cancelled}`, brief }));
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 }));
281
322
  }
282
323
  }
283
324
 
@@ -313,14 +354,18 @@ export class CloudWebGateway {
313
354
  // A reply addressed to other bots is not this bot's to take (reply_routing.go accept=false).
314
355
  if (only !== null && !only.has(member.id)) continue;
315
356
  let content = forMember(text, member, members);
316
- 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);
317
360
  if (fromAgent && !content) continue;
318
361
  if (fromAgent) content = `【${msg.name}】${quote}${content}`;
319
362
  else if (hit && !content) content = quote + BARE_MENTION;
320
363
  else content = quote + content;
321
364
  const frame = {
322
- type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: 'group',
323
- 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),
324
369
  mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
325
370
  };
326
371
  if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
@@ -340,7 +385,7 @@ export class CloudWebGateway {
340
385
  const members = this.hub.routableIn(msg.room_id);
341
386
  const room = this.hub.store.room(msg.room_id);
342
387
  const frame = {
343
- 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),
344
389
  user_id: msg.author, user_name: msg.name, content: forMember(msg.text, bot.member, members) || msg.text,
345
390
  reply_ctx: roomKey(msg.room_id), mentioned: true, from_bot: false,
346
391
  };
@@ -519,6 +564,14 @@ export class CloudWebGateway {
519
564
  this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text: '', attachments });
520
565
  return;
521
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
+ }
522
575
  case 'typing_start':
523
576
  case 'typing_stop': {
524
577
  const room = roomOf(d, this.hub.store);
@@ -611,6 +664,9 @@ function send(ws, frame) {
611
664
  try { ws.send(JSON.stringify(frame)); } catch { /* socket already closing; its close handler cleans up */ }
612
665
  }
613
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
+
614
670
  function clean(s) { return String(s ?? '').replace(/[\x00-\x1f\x7f]/g, '').trim(); }
615
671
 
616
672
  function timingSafeEqualString(a, b) {
@@ -17,7 +17,9 @@ import { GateContext } from './gate-context.mjs';
17
17
  import { MemoryIndex } from './memory-index.mjs';
18
18
  // Shared with the bridge (bridge/core/team_mode.go): a common part, then one
19
19
  // "## <mode>" section per dispatch mode; a member sees only its room's section.
20
- const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8');
20
+ // A Windows checkout (git core.autocrlf) turns the file to CRLF: "## solo\r\n" then misses the
21
+ // `${mode}\n` match below and every member turn failed with "reading 'slice'". Read it as LF.
22
+ const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8').replace(/\r\n?/g, '\n');
21
23
  export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
22
24
  const [common, ...parts] = TEAM_MODE_FILE.split(/^## /m);
23
25
  const section = parts.find(p => p.startsWith(`${mode}\n`)) ?? parts.find(p => p.startsWith(`${DEFAULT_DISPATCH_MODE}\n`));
@@ -199,7 +201,7 @@ const INTERRUPTED = Symbol('interrupted');
199
201
  export function friendlyFailure(raw) {
200
202
  if (raw?.code === 'COLLAB_CLI_START') return raw.message;
201
203
  const text = String(raw);
202
- if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没有配置模型的 API Key,请先去配置模型。';
204
+ if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没填模型密钥,填好就能用。';
203
205
  if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
204
206
  // An unreachable provider is a setting or network problem. Echoing the transport wording
205
207
  // ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
@@ -460,7 +462,7 @@ export class DshMembers {
460
462
  const running = [];
461
463
  for (const job of this.store.openJobs()) {
462
464
  if (job.trigger_id !== triggerId) continue;
463
- if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); continue; }
465
+ if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); this.syncQueue(job.member_id, job.room_id); continue; }
464
466
  const w = this.watching.get(job.session_id);
465
467
  const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
466
468
  if (w?.jobId !== job.id || agent === undefined) { running.push(job.member_id); continue; }
@@ -583,14 +585,29 @@ export class DshMembers {
583
585
  /** Serialize work per (member, room): runs, recoveries, and Session resumes never overlap. */
584
586
  enqueue(job, task) {
585
587
  const key = `${job.member_id}\u0000${job.room_id}`;
586
- const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => (this.closed ? undefined : task())).catch((error) => {
588
+ const sync = () => this.syncQueue(job.member_id, job.room_id);
589
+ const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => { sync(); return this.closed ? undefined : task(); }).catch((error) => {
587
590
  this.rt.logger.warn('collab: DSH job %d failed: %s', job.id, error?.stack ?? error);
588
591
  if (!this.closed) this.fail(job.id, error?.code === 'COLLAB_CLI_START' ? error : `出错了:${error?.message ?? error}`);
589
- });
592
+ }).finally(sync);
590
593
  this.queues.set(key, tail);
594
+ sync();
591
595
  return tail;
592
596
  }
593
597
 
598
+ /**
599
+ * #1020: tell the hub which of the owner's messages wait for this member here — its open
600
+ * jobs after the first (the one running or about to), still pending. Withdrawing one is the
601
+ * existing recall (#148 a queued job is closed).
602
+ */
603
+ syncQueue(memberId, roomId) {
604
+ if (typeof this.hub.setQueue !== 'function') return;
605
+ const open = this.store.openJobs().filter(j => j.member_id === memberId && j.room_id === roomId);
606
+ const ids = open.slice(1).filter(j => j.state === 'pending').map(j => j.trigger_id)
607
+ .filter(id => this.store.message(id)?.author_kind === 'user');
608
+ this.hub.setQueue(roomId, memberId, ids);
609
+ }
610
+
594
611
  async run(jobId) {
595
612
  const job = this.store.job(jobId);
596
613
  if (job === undefined || job.state !== 'pending') return;
@@ -655,7 +672,9 @@ export class DshMembers {
655
672
  let entry = this.cliAgents.get(cliKey);
656
673
  if (entry === undefined) {
657
674
  // Persona goes through promptText every turn, so an edit applies without restarting the CLI.
658
- entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '' }) };
675
+ entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
676
+ // #1020: memvote.py records citations under these (memory-sources.mjs), named as the gate key (gate-context.mjs).
677
+ env: { CC_PROJECT: `dsh:${member.id}`, CC_SESSION_KEY: `dsh:${member.id}:${room.id}` } }) };
659
678
  this.cliAgents.set(cliKey, entry);
660
679
  }
661
680
  this.hub.setError(member.id, undefined);
@@ -1,21 +1,95 @@
1
1
  // Per-turn wiki pages and task board for DSH-native members, from the local gate's
2
2
  // POST /context (bridge/contrib/gate/gate.py -> ctxsvc.context): the same source and the same
3
- // block the bridge puts before a bot's turn (bridge/core/gate.go turnContext). The gate's port
4
- // comes from GATE_PORT, which bothouse sets on the `dsh web` it starts to the port it runs the
5
- // gate on (core/gate_proc.go gatePort); without it, or with the gate down, a turn goes out as before.
3
+ // block the bridge puts before a bot's turn (bridge/core/gate.go turnContext).
4
+ //
5
+ // The gate's port: GATE_PORT, which bothouse sets on the `dsh web` it starts to the port it runs the
6
+ // gate on (core/gate_proc.go gatePort). A `dsh web` started another way (reload-dsh.py reusing an old
7
+ // environment, the desktop app) has no GATE_PORT, so it is read from the same config.toml the bridge
8
+ // itself reads: [gate] port (core/gate_proc.go LocalGatePort), default 9930 when the file has no
9
+ // [gate] table (that is gate.py's own PORT). Without a config file either, no port: a turn goes out
10
+ // as before, and the log says why instead of the badge and the pages vanishing without a word.
11
+ import { readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { dataDirCandidates } from './memory-sources.mjs';
6
15
 
7
16
  // Same as the bridge's wait for /context (core/gate.go turnContext: 2s).
8
17
  export const GATE_CONTEXT_TIMEOUT_MS = 2_000;
18
+ // 来源:contrib/gate/gate.py PORT = 9930,core/setup_wizard_warnings.go defaultGatePort 同值
19
+ // (LocalGatePort 在 config [gate] port 缺省或为 0 时也用它)。
20
+ export const DEFAULT_GATE_PORT = 9930;
21
+
22
+ /** `port\0where` of the log lines already written: one line per process, not one per member. */
23
+ const announced = new Set();
24
+
25
+ /** `[gate] port` of a bridge config's text, as Go reads cfg.Gate.Port; 0 when it has no such table. */
26
+ export function gatePortSetting(text) {
27
+ let inside = false;
28
+ for (const line of String(text).split(/\r?\n/)) {
29
+ const header = /^\s*\[\s*([^\]]+?)\s*\]/.exec(line);
30
+ if (header !== null) { inside = header[1] === 'gate'; continue; }
31
+ if (!inside) continue;
32
+ const kv = /^\s*port\s*=\s*(\d+)\s*(?:#.*)?$/.exec(line);
33
+ if (kv === null) continue;
34
+ const port = Number(kv[1]);
35
+ if (Number.isInteger(port) && port > 0) return port;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ /**
41
+ * The bridge config this process should read: $BOTHOUSE_CONFIG (the bridge's --config), else
42
+ * <its data dir>/config.toml, the data dir being the one the gate itself would pick
43
+ * (bridge/brand.go dataDir, bridge/contrib/gate/datadir.py data_dir). The process's own ./config.toml
44
+ * is deliberately not tried: that is the bridge's rule for the bridge's cwd, not for a plugin's.
45
+ * @param {NodeJS.ProcessEnv} env
46
+ * @param {string} home
47
+ * @returns {string}
48
+ */
49
+ export function bridgeConfigFile(env = process.env, home = homedir()) {
50
+ const explicit = String(env.BOTHOUSE_CONFIG ?? '').trim();
51
+ if (explicit !== '' && explicit !== '~' && !explicit.startsWith('~/')) return explicit;
52
+ if (explicit !== '') return join(home, explicit.slice(2));
53
+ return join(dataDirCandidates(env, home)[0], 'config.toml');
54
+ }
55
+
56
+ /**
57
+ * The gate port to use, and where it came from (for the log line).
58
+ * @param {NodeJS.ProcessEnv} env
59
+ * @param {string} home
60
+ * @param {(file: string, encoding: string) => string} readFile
61
+ * @returns {{ port: string, why: string }}
62
+ */
63
+ export function resolveGatePort(env = process.env, home = homedir(), readFile = readFileSync) {
64
+ const fromEnv = String(env.GATE_PORT ?? '').trim();
65
+ if (fromEnv !== '') return { port: fromEnv, why: 'GATE_PORT' };
66
+ const file = bridgeConfigFile(env, home);
67
+ let text;
68
+ try { text = readFile(file, 'utf8'); } catch { return { port: '', why: `没有配置文件 ${file}` }; }
69
+ const port = gatePortSetting(text);
70
+ if (port !== 0) return { port: String(port), why: `${file} [gate] port` };
71
+ return { port: String(DEFAULT_GATE_PORT), why: `${file} 没有 [gate] port,用默认端口` };
72
+ }
9
73
 
10
74
  export class GateContext {
11
75
  /**
12
- * @param options - { port?: gate port (default process.env.GATE_PORT), timeoutMs?, fetch?, logger? }.
76
+ * @param options - { port?: gate port (default: GATE_PORT, else the bridge config's [gate] port),
77
+ * timeoutMs?, fetch?, logger? }.
13
78
  */
14
- constructor({ port = process.env.GATE_PORT, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
15
- this.port = String(port ?? '').trim();
79
+ constructor({ port, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
80
+ const from = port === undefined ? resolveGatePort() : { port: String(port ?? '').trim(), why: 'port 参数' };
81
+ this.port = String(from.port ?? '').trim();
16
82
  this.timeoutMs = timeoutMs;
17
83
  this.fetch = fetchFn;
18
84
  this.logger = logger;
85
+ // One line per process (a member each builds its own GateContext): which port the context goes
86
+ // to and from where, so a missing one is a line in the log instead of a silently missing block.
87
+ const line = `${this.port}\u0000${from.why}`;
88
+ if (!announced.has(line)) {
89
+ announced.add(line);
90
+ if (this.port !== '') this.logger?.info?.('collab: gate context: port %s(%s)', this.port, from.why);
91
+ else this.logger?.warn?.('collab: gate context: 没有 gate 端口(%s);每轮不带 wiki 页和任务板', from.why);
92
+ }
19
93
  /** `${member}\0${room}` -> { session, epoch }: the name of that conversation's context for the gate. */
20
94
  this.sent = new Map();
21
95
  }
package/collab/hub.mjs CHANGED
@@ -17,6 +17,8 @@ export class Hub {
17
17
  this.members = new Map();
18
18
  /** roomId -> Set<memberId> currently working */
19
19
  this.thinking = new Map();
20
+ /** #1020: room id -> member id -> ids of the owner's messages waiting behind that member's running turn. */
21
+ this.queued = new Map();
20
22
  /** id -> optional busy fields published by the team-status reader (#876). */
21
23
  this.presence = new Map();
22
24
  this.listeners = [];
@@ -27,6 +29,9 @@ export class Hub {
27
29
  memberList() { return [...this.members.values()]; }
28
30
 
29
31
  membersInRoom(roomId) {
32
+ // #1023: a private chat has the owner and its one agent, whatever that agent's channel list says.
33
+ const dm = this.store.dmMember?.(roomId);
34
+ if (dm !== undefined) return this.memberList().filter(m => m.kind === 'human' || m.id === dm);
30
35
  const channel = this.store.settingsRoom(roomId);
31
36
  return this.memberList().filter(m => !m.rooms?.length || m.rooms.includes(channel));
32
37
  }
@@ -45,6 +50,9 @@ export class Hub {
45
50
 
46
51
  /** Channel selection, inherited by workspaces; only unset channels use the legacy global choice. */
47
52
  primaryIn(roomId) {
53
+ // #1023: in a private chat the one agent answers every owner message, @ or not.
54
+ const dm = this.store.dmMember?.(roomId);
55
+ if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
48
56
  const id = this.store.leadId(roomId);
49
57
  const member = id === null ? this.selectedPrimaryMember() : this.members.get(id);
50
58
  return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
@@ -66,6 +74,7 @@ export class Hub {
66
74
  if (!this.members.delete(id)) return;
67
75
  this.presence.delete(id);
68
76
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
77
+ for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
69
78
  this.emit({ type: 'members', members: this.memberView() });
70
79
  }
71
80
 
@@ -76,6 +85,7 @@ export class Hub {
76
85
  if (!online) {
77
86
  this.presence.delete(id);
78
87
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
88
+ for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
79
89
  }
80
90
  this.emit({ type: 'members', members: this.memberView() });
81
91
  }
@@ -114,6 +124,21 @@ export class Hub {
114
124
  this.emit({ type: 'thinking', room_id: roomId, members: [...set] });
115
125
  }
116
126
 
127
+ /** #1020: replace the queue a member has in a room (ids in queue order; [] clears it). */
128
+ setQueue(roomId, id, ids) {
129
+ const room = this.queued.get(roomId) ?? new Map();
130
+ const before = room.get(id) ?? [];
131
+ if (before.length === ids.length && before.every((x, i) => x === ids[i])) return;
132
+ if (ids.length) room.set(id, [...ids]); else room.delete(id);
133
+ if (room.size) this.queued.set(roomId, room); else this.queued.delete(roomId);
134
+ this.emit({ type: 'queue', room_id: roomId, queued: this.queueView(roomId) });
135
+ }
136
+
137
+ /** #1020: { member id: [message ids] } waiting in a room. */
138
+ queueView(roomId) {
139
+ return Object.fromEntries([...(this.queued.get(roomId) ?? new Map())]);
140
+ }
141
+
117
142
  memberView() {
118
143
  return this.memberList().map(({ id, name, kind, online, model, cli, permissionPreset, persona, nameCustomized, avatar, avatarVersion, source, error, rooms }) => ({
119
144
  id, name, kind, ...(rooms ? { rooms } : {}), online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar, ...(avatarVersion ? { avatarVersion } : {}) } : {}),
@@ -161,6 +186,7 @@ export class Hub {
161
186
  return r;
162
187
  });
163
188
  if (!result.created) return result;
189
+ result.message = this.withMemories(result.message);
164
190
  for (const next of after) {
165
191
  Promise.resolve().then(next).catch((error) => {
166
192
  this.logger.warn('collab: post-commit step failed: %s', error?.stack ?? error);
@@ -195,7 +221,7 @@ export class Hub {
195
221
  return c;
196
222
  });
197
223
  if (r === undefined) return undefined;
198
- const row = r.message;
224
+ const row = r.first ? this.withMemories(r.message) : r.message;
199
225
  this.emit({ type: 'message', message: publicMessage(row, this.store) });
200
226
  if (!r.first) return row;
201
227
  for (const next of after) {
@@ -211,6 +237,21 @@ export class Hub {
211
237
  return row;
212
238
  }
213
239
 
240
+ /**
241
+ * #1020: attach to a member's reply the memory entries it cited here since its last reply
242
+ * (memory-sources.mjs). Command answers (from_turn 0), cards and drafts take none.
243
+ */
244
+ withMemories(row) {
245
+ if (!this.memorySources || row?.author_kind !== 'role' || row.card != null || row.draft || row.from_turn === 0) return row;
246
+ const voter = this.members.get(row.author)?.voter ?? `dsh:${row.author}`;
247
+ let items;
248
+ try { items = this.memorySources.take(voter, row.room_id); } catch (error) {
249
+ this.logger.warn('collab: memory sources failed: %s', error?.message ?? error);
250
+ return row;
251
+ }
252
+ return items.length ? (this.store.setMemories(row.id, items) ?? row) : row;
253
+ }
254
+
214
255
  /** Edit/delete a row the same author wrote, and tell browsers. */
215
256
  edit(id, authorKind, author, text, card) {
216
257
  const row = this.store.edit(id, authorKind, author, text, card);