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.
package/collab/panel.mjs CHANGED
@@ -89,7 +89,16 @@ function sendNotice(res, status, reason) {
89
89
  * @returns {string}
90
90
  */
91
91
  export function rewritePanelHTML(html, prefix = PANEL_PREFIX) {
92
- const head = `<base href="${prefix}/" />\n <meta name="bothouse-auth" content="proxy" />`;
92
+ // The page may itself sit under another proxy's prefix: the bridge management server mounts
93
+ // the whole DSH site at /dsh/ (core/management_dsh_proxy.go) and strips it before DSH sees the
94
+ // request, so this route cannot know it. A fixed <base href="/collab/panel/"> then sends the
95
+ // panel's scripts to the management server's own /collab/panel/..., which answers with its
96
+ // index.html, and the frame stays blank. The inline script moves <base> to wherever the page
97
+ // was actually loaded from, before any asset URL is resolved (it runs ahead of the panel's
98
+ // module scripts, and basePath.ts reads document.baseURI only when they run).
99
+ const mount = JSON.stringify(`${prefix}/`).replace(/</g, '\\u003c');
100
+ const fix = `<script>(()=>{const m=${mount},p=location.pathname,i=p.indexOf(m);if(i>0)document.querySelector("base").setAttribute("href",p.slice(0,i)+m)})()</script>`;
101
+ const head = `<base href="${prefix}/" />${fix}\n <meta name="bothouse-auth" content="proxy" />`;
93
102
  if (/<base\s[^>]*>/i.test(html)) return html.replace(/<base\s[^>]*>/i, head);
94
103
  return html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${head}`);
95
104
  }
package/collab/plugin.mjs CHANGED
@@ -4,19 +4,21 @@ import { personaLibrary } from './personas.mjs';
4
4
  // - insert:
5
5
  // - id: conest-collab
6
6
  // name: /abs/path/dsh/collab/plugin.mjs
7
- // config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
7
+ // config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, botPort?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
8
8
  import { createRequire } from 'node:module';
9
9
  import { readFileSync, realpathSync, statSync } from 'node:fs';
10
10
  import { isAbsolute, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { Store, CollabError, publicMessage } from './store.mjs';
13
+ import { startBotPort } from './bot-port.mjs';
13
14
  import { Hub } from './hub.mjs';
14
15
  import { TeamExit } from './team-exit.mjs';
15
16
  import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
16
17
  import { DshMembers } from './dsh-member.mjs';
17
- import { Roster } from './roster.mjs';
18
+ import { Roster, rowToMember } from './roster.mjs';
18
19
  import { Tasks, WORKING } from './tasks.mjs';
19
20
  import { createCliMember } from './cli-member.mjs';
21
+ import { MemorySources } from './memory-sources.mjs';
20
22
  import { avatarImage, checkAvatar } from './avatars.mjs';
21
23
  import { AvatarUploads } from './avatar-uploads.mjs';
22
24
  import { handler as clisHandler } from './clis.mjs';
@@ -28,6 +30,8 @@ import { DeskEvents, DESK_NAMES_FILE, deskRoute } from './desk.mjs';
28
30
  import { wikiGalaxyRoute } from './wiki-galaxy.mjs';
29
31
  import { defaultBridgeConfig, panelRoute } from './panel.mjs';
30
32
  import { TeamStatus } from './team-status.mjs';
33
+ import { ALIVE_EVERY_MS, PairClient, PairHost, PairListener, PAIR_PORT, REMOTE_OWNER, hostHandler, isLoopback, pairApiRoute, remoteProxyRoute, tailnetAddress } from './pairing.mjs';
34
+ import { homedir, hostname } from 'node:os';
31
35
 
32
36
  export const name = 'conest-collab';
33
37
  export const inject = [
@@ -36,6 +40,8 @@ export const inject = [
36
40
  ];
37
41
 
38
42
  const PAGE = readFileSync(new URL('./page.html', import.meta.url), 'utf8');
43
+ // #131: 「其他电脑」page — pair this DSH with another one by a one-time number.
44
+ const DEVICES_PAGE = readFileSync(new URL('./devices.html', import.meta.url), 'utf8');
39
45
  const MAX_BODY = 256 * 1024;
40
46
  // #144: a message POST may carry base64 attachments. Same cap as the bot WebSocket
41
47
  // maxPayload below (8 MiB), so a message that is accepted here still fits in the frame
@@ -79,7 +85,11 @@ export function resolveConfig(config, env = process.env) {
79
85
  if (!Number.isSafeInteger(maxRelay) || maxRelay < 0) throw Error('conest-collab: maxRelay must be a non-negative integer');
80
86
  const botTokenEnv = c.botTokenEnv ?? 'CONEST_COLLAB_BOT_TOKEN';
81
87
  const botToken = env[botTokenEnv] ?? '';
82
- const leadFile = c.leadFile ?? join(env.BOTHOUSE_DATA_DIR ?? join(env.HOME ?? '.', '.bothouse'), 'lead.json');
88
+ // #142 桌面版宿主端口每次随机:给一个固定端口另开 bot 接入(bot-port.mjs)。配置或 CONEST_COLLAB_BOT_PORT,都没给就不开。
89
+ const botPortRaw = c.botPort ?? (String(env.CONEST_COLLAB_BOT_PORT ?? '').trim() || undefined);
90
+ const botPort = botPortRaw === undefined ? undefined : Number(botPortRaw);
91
+ if (botPort !== undefined && !(Number.isSafeInteger(botPort) && botPort > 0 && botPort < 65536)) throw Error('conest-collab: botPort must be a TCP port number');
92
+ const leadFile = c.leadFile ?? join(env.BOTHOUSE_DATA_DIR ?? join(env.HOME || homedir(), '.bothouse'), 'lead.json');
83
93
  if (!isAbsolute(leadFile)) throw Error('conest-collab: leadFile must be an absolute path');
84
94
  const fallbackLead = String(c.fallbackLead ?? env.CONEST_FALLBACK_LEAD ?? '').trim();
85
95
  // #876: board events sit next to board.jsonl; BOARD_DIR is the same switch gate/board.py reads,
@@ -97,7 +107,7 @@ export function resolveConfig(config, env = process.env) {
97
107
  // #105 侧栏「bothouse」:读这份主桥配置的 [management] 段,在服务端转发管理面板(令牌不进浏览器)。
98
108
  const bridgeConfig = String(c.bridgeConfig ?? '').trim() || defaultBridgeConfig(env);
99
109
  if (!isAbsolute(bridgeConfig)) throw Error('conest-collab: bridgeConfig must be an absolute path');
100
- return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, ownerName: c.ownerName ?? '我' };
110
+ return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, botPort, ownerName: c.ownerName ?? '我' };
101
111
  }
102
112
 
103
113
  /** Resolve a package from the running DSH installation, never a second copy. */
@@ -132,6 +142,7 @@ export async function apply(ctx, rawConfig) {
132
142
  store.migrateMemberRooms();
133
143
  const leadState = new LeadState(config.leadFile, ctx.logger);
134
144
  const hub = new Hub(store, ctx.logger, leadState, config.fallbackLead);
145
+ hub.memorySources = new MemorySources({ logger: ctx.logger }); // #1020 回复的记忆来源
135
146
  hub.workspacePath = config.workspacePath;
136
147
  hub.teamExit = new TeamExit(hub);
137
148
  hub.setMember({ id: OWNER, name: config.ownerName, kind: 'human', online: true });
@@ -179,24 +190,47 @@ export async function apply(ctx, rawConfig) {
179
190
  teamStatus?.start();
180
191
  if (!config.botToken) ctx.logger.warn(`conest-collab: ${config.botTokenEnv} is unset; cc-connect bots cannot connect`);
181
192
 
182
- const owner = req => ctx.connection.requestRejection(req) === undefined;
193
+ // #131: a request the tailnet pairing listener authenticated with a device credential acts as
194
+ // the owner; everything else still needs this DSH's own sign-in.
195
+ const owner = req => req[REMOTE_OWNER] === true || ctx.connection.requestRejection(req) === undefined;
196
+ // Test-only overrides (same-machine two-instance check): address and port of the pairing listener.
197
+ const pairAddr = String(process.env.CONEST_COLLAB_PAIR_ADDR ?? '').trim();
198
+ const pairPort = Number(process.env.CONEST_COLLAB_PAIR_PORT) || PAIR_PORT;
199
+ const pairHost = new PairHost({ file: join(config.dataDir, 'pair-devices.json') });
200
+ const pairClient = new PairClient({ file: join(config.dataDir, 'pair-links.json'), port: pairPort });
201
+ const pairListener = new PairListener({
202
+ host: pairHost, port: pairPort, logger: ctx.logger,
203
+ address: pairAddr === '' ? () => tailnetAddress() : async () => pairAddr,
204
+ handler: hostHandler({ host: pairHost, name: hostname(), route: (req, res) => browserRoute(req, res), allowLoopback: isLoopback(pairAddr) }),
205
+ });
206
+ if (pairHost.list().length > 0) pairListener.ensure().catch(err => ctx.logger.warn('conest-collab: pairing listener: %s', err.message));
207
+ // 来源:码有效期按分钟计(pairing.mjs CODE_TTL_MS),每分钟收一次没人用的配对监听。
208
+ const pairSweep = setInterval(() => { pairListener.settle().catch(() => {}); }, 60_000);
209
+ pairSweep.unref?.();
210
+ // 「直到我断开」: drop links whose DSH window stopped beating (same cadence as the beat).
211
+ const aliveSweep = setInterval(() => { pairClient.sweep().catch(() => {}); }, ALIVE_EVERY_MS);
212
+ aliveSweep.unref?.();
183
213
  const wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
184
214
  const streams = new Set();
185
215
 
186
216
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
187
217
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/rooms', handler: (req, res) => historyRoute(req, res) }), 'conest-collab: /api/rooms');
188
- ctx.effect(() => ctx.webServer.registerUpgrade({
189
- path: WS_PATH,
190
- handler: (req, socket, head) => {
191
- const url = new URL(req.url ?? '/', 'http://collab.invalid');
192
- later.origin ??= localOrigin(req);
193
- if (!gateway.authorized(req, url)) {
194
- socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
195
- return;
196
- }
197
- wss.handleUpgrade(req, socket, head, ws => gateway.accept(ws, url));
198
- },
199
- }), 'conest-collab: cloud-web upgrade');
218
+ const botUpgrade = (req, socket, head) => {
219
+ const url = new URL(req.url ?? '/', 'http://collab.invalid');
220
+ later.origin ??= localOrigin(req);
221
+ if (!gateway.authorized(req, url)) {
222
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
223
+ return;
224
+ }
225
+ wss.handleUpgrade(req, socket, head, ws => gateway.accept(ws, url));
226
+ };
227
+ ctx.effect(() => ctx.webServer.registerUpgrade({ path: WS_PATH, handler: botUpgrade }), 'conest-collab: cloud-web upgrade');
228
+ if (config.botPort !== undefined && config.botToken) {
229
+ const listening = startBotPort({ port: config.botPort, path: WS_PATH, upgrade: botUpgrade, history: (req, res) => historyRoute(req, res), logger: ctx.logger })
230
+ .then(l => { ctx.logger.info('conest-collab: bot port 127.0.0.1:%d', l.port); return l; },
231
+ err => { ctx.logger.warn('conest-collab: bot port %d not opened: %s', config.botPort, err.message); return undefined; });
232
+ ctx.effect(() => async () => { (await listening)?.close(); }, 'conest-collab: bot port');
233
+ }
200
234
  ctx.effect(() => async () => {
201
235
  teamStatus?.stop();
202
236
  desk.stop();
@@ -204,6 +238,9 @@ export async function apply(ctx, rawConfig) {
204
238
  later.close();
205
239
  tasks.close();
206
240
  gateway.closeAll();
241
+ clearInterval(pairSweep);
242
+ clearInterval(aliveSweep);
243
+ await pairListener.close();
207
244
  for (const end of streams) end();
208
245
  wss.close();
209
246
  // Drain DSH jobs first: a job still awaiting its Agent must not touch a closed store.
@@ -229,13 +266,16 @@ export async function apply(ctx, rawConfig) {
229
266
  async function browserRoute(req, res) {
230
267
  const url = new URL(req.url ?? '/', 'http://collab.invalid');
231
268
  const path = url.pathname.replace(/\/+$/, '');
232
- later.origin ??= localOrigin(req);
269
+ // A paired device reaches us on the tailnet listener; ticket URLs stay on this machine's own origin.
270
+ if (!req[REMOTE_OWNER]) later.origin ??= localOrigin(req);
233
271
  const scheduled = laterRoute(req, res, url, { hub, gateway, owner, later });
234
272
  if (scheduled !== undefined) return scheduled;
235
273
  const routed = tasksRoute(req, res, url, { tasks, gateway, owner });
236
274
  if (routed !== undefined) return routed;
237
275
  const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
238
276
  if (opened !== undefined) return opened;
277
+ const team = teamRoute(req, res, url, { store, hub, later, roster });
278
+ if (team !== undefined) return team;
239
279
  if (!owner(req)) {
240
280
  if ((path === '/collab' || path === '/collab/desk' || path === '/collab/wiki-galaxy') && req.method === 'GET') return text(res, 401, 'text/plain; charset=utf-8', '请先在同一个浏览器打开 DSH 网页登录,再回到 /collab。');
241
281
  return refuse(req, res);
@@ -247,6 +287,14 @@ export async function apply(ctx, rawConfig) {
247
287
  if (galaxyHandled !== undefined) return galaxyHandled;
248
288
  const panelHandled = panelRoute(req, res, url, { bridgeConfig: config.bridgeConfig });
249
289
  if (panelHandled !== undefined) return panelHandled;
290
+ // #131: pairing (this machine lets others in) and remote links (this machine drives others).
291
+ if (!req[REMOTE_OWNER]) {
292
+ if (path === '/collab/devices' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', DEVICES_PAGE);
293
+ const paired = pairApiRoute(req, res, url, { host: pairHost, client: pairClient, listener: pairListener });
294
+ if (paired !== undefined) return paired;
295
+ const remote = remoteProxyRoute(req, res, url, { client: pairClient });
296
+ if (remote !== undefined) return remote;
297
+ }
250
298
  if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
251
299
  if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
252
300
  if (path === '/collab/api/state' && req.method === 'GET') {
@@ -254,7 +302,8 @@ export async function apply(ctx, rawConfig) {
254
302
  effective_lead_id: hub.primaryMember()?.id ?? null,
255
303
  rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
256
304
  workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
257
- thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])) });
305
+ thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
306
+ queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])) });
258
307
  }
259
308
  if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
260
309
  if (path === '/collab/api/lead' && req.method === 'GET') {
@@ -407,6 +456,17 @@ export async function apply(ctx, rawConfig) {
407
456
  return { room };
408
457
  });
409
458
  }
459
+ // #1023: open (or reopen) the private chat with one agent member.
460
+ if (path === '/collab/api/dms' && req.method === 'POST') {
461
+ return withBody(req, res, (body) => {
462
+ const id = String(body?.member ?? '');
463
+ const member = hub.members.get(id);
464
+ if (member === undefined || member.kind === 'human') throw new CollabError('invalid', 'member must be an agent in this channel list');
465
+ const room = store.openDm(id, member.name);
466
+ hub.emit({ type: 'rooms', rooms: store.roomsView() });
467
+ return { room };
468
+ });
469
+ }
410
470
  if (path === '/collab/api/members' && req.method === 'POST') {
411
471
  return withBody(req, res, async body => ({ member: await roster.create(body) }));
412
472
  }
@@ -448,7 +508,10 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
448
508
  if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
449
509
  if (req.method !== 'PATCH') return undefined;
450
510
  return withBody(req, res, body => {
451
- if (body?.lead_id !== undefined && body.lead_id !== null && !hub.routableIn(roomId).some(m => m.id === body.lead_id)) {
511
+ // The settings dialog's「未选择(按原配置)」option sends the select's empty value: clear the
512
+ // channel's own choice so the primary falls back to the instance-wide one, exactly like null.
513
+ const leadId = body?.lead_id === '' ? null : body?.lead_id;
514
+ if (leadId !== undefined && leadId !== null && !hub.routableIn(roomId).some(m => m.id === leadId)) {
452
515
  throw new CollabError('invalid', '请选择本频道的主角色');
453
516
  }
454
517
  if (body?.workspace_path !== undefined) {
@@ -459,7 +522,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
459
522
  if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
460
523
  }
461
524
  store.transaction(() => {
462
- if (body?.lead_id !== undefined) store.setLead(roomId, body.lead_id);
525
+ if (leadId !== undefined) store.setLead(roomId, leadId);
463
526
  if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
464
527
  // #445: 6–20 only; an invalid value rolls the whole PATCH back.
465
528
  if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
@@ -635,6 +698,74 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
635
698
  });
636
699
  }
637
700
 
701
+ /**
702
+ * 一句话拉团队 (user 11-04: DSH 用户一句话拉单模型多角色): the primary role of a channel, with its
703
+ * (member, room) later ticket, creates a new chat channel and its roles in one call.
704
+ * POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, model? }], model? }
705
+ * model is a model id ("deepseek-v4.1-flash") or "provider/model"; absent = DSH's default model.
706
+ * The new roles join only the new channel; the caller becomes its primary role and joins it too.
707
+ */
708
+ export function teamRoute(req, res, url, { store, hub, later, roster }) {
709
+ const m = /^\/collab\/api\/rooms\/(\d+)\/team\/?$/.exec(url.pathname);
710
+ if (m === null || req.method !== 'POST') return undefined;
711
+ const claims = req.headers['x-later-ticket'] === undefined ? undefined : later?.verify(req.headers['x-later-ticket']);
712
+ if (!claims) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
713
+ const roomId = Number(m[1]);
714
+ if (claims.room !== roomId) return Promise.resolve(json(res, 403, { error: 'ticket is for another room' }));
715
+ if (hub.primaryIn(roomId)?.id !== claims.member) return Promise.resolve(json(res, 403, { error: 'only the primary role builds teams' }));
716
+ return withBody(req, res, async body => buildTeam(body, claims.member, { store, hub, roster }));
717
+ }
718
+
719
+ const TEAM_AVATARS = ['orange', 'dragon', 'rainbow', 'lanxi', 'whale'];
720
+ const MAX_TEAM = 8;
721
+
722
+ /** Find a model by id or provider/id among DSH's routes; undefined input = DSH default (no model on the member). */
723
+ export async function resolveTeamModel(roster, wanted) {
724
+ const want = String(wanted ?? '').trim();
725
+ if (!want) return undefined;
726
+ const routes = await roster.models();
727
+ const slash = want.indexOf('/');
728
+ for (const route of routes) {
729
+ for (const model of route.models) {
730
+ const exact = slash > 0 && route.provider === want.slice(0, slash) && model.id === want.slice(slash + 1);
731
+ if (exact || model.id.toLowerCase() === want.toLowerCase()) return { provider: route.provider, model: model.id };
732
+ }
733
+ }
734
+ const known = routes.flatMap(r => r.models.map(x => x.id)).slice(0, 20).join(', ') || '(DSH 没报出可用模型)';
735
+ throw new CollabError('invalid', `模型 ${want} 在 DSH 里找不到;可用:${known}`);
736
+ }
737
+
738
+ export async function buildTeam(body, leadId, { store, hub, roster }) {
739
+ const channel = String(body?.channel ?? '').trim().replace(/^#/, '');
740
+ if (!channel || channel.length > 32 || /\s/.test(channel)) throw new CollabError('invalid', 'channel must be 1-32 characters without spaces');
741
+ const wanted = Array.isArray(body?.members) ? body.members : [];
742
+ if (wanted.length === 0 || wanted.length > MAX_TEAM) throw new CollabError('invalid', `members must list 1-${MAX_TEAM} roles`);
743
+ const names = wanted.map(x => String(x?.name ?? '').trim());
744
+ if (names.some(n => !n) || new Set(names.map(n => n.toLowerCase())).size !== names.length) throw new CollabError('invalid', 'every role needs a distinct name');
745
+ const shared = await resolveTeamModel(roster, body?.model);
746
+ const models = [];
747
+ for (const x of wanted) models.push(x?.model === undefined || x?.model === '' ? shared : await resolveTeamModel(roster, x.model));
748
+ // Check every role before anything is written, so a bad name leaves no half-built channel behind.
749
+ for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
750
+ const room = store.createRoom(channel);
751
+ const created = [];
752
+ for (const [i, x] of wanted.entries()) {
753
+ const model = models[i];
754
+ created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
755
+ }
756
+ const lead = store.member(leadId);
757
+ if (lead?.source === 'ui' && lead.rooms && lead.rooms !== '[]') {
758
+ await roster.update(leadId, { ...rowToMember(lead), rooms: [...JSON.parse(lead.rooms), room.id] });
759
+ }
760
+ store.setLead(room.id, leadId);
761
+ hub.emit({ type: 'rooms', rooms: store.roomsView() });
762
+ const label = model => (model ? model.model : 'DSH 默认模型');
763
+ const leadName = hub.members.get(leadId)?.name ?? leadId;
764
+ hub.post(room.id, { authorKind: 'system', author: 'system', name: '建团队',
765
+ text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i])})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
766
+ return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i]) })) };
767
+ }
768
+
638
769
  const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
639
770
 
640
771
  /** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
@@ -665,9 +796,14 @@ export function parseRange(header, size) {
665
796
 
666
797
  /** Serves a stored attachment; honours single byte ranges so iOS Safari can play audio. */
667
798
  export function sendAttachment(req, res, row, bytes) {
668
- const headers = { 'content-type': row.mime, 'accept-ranges': 'bytes',
669
- 'content-disposition': `${INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
670
- 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400' };
799
+ // #1023: a delivered web page (`?view=page`) opens inline for the delivery card's frame. The CSP
800
+ // sandbox gives it an opaque origin even when opened in its own tab, so its scripts never act
801
+ // with the owner's sign-in on this origin.
802
+ const page = /[?&]view=page(?:&|$)/.test(req.url ?? '') && (/^text\/html\b/i.test(row.mime) || /\.html?$/i.test(row.name));
803
+ const headers = { 'content-type': page ? 'text/html; charset=utf-8' : row.mime, 'accept-ranges': 'bytes',
804
+ 'content-disposition': `${page || INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
805
+ 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400',
806
+ ...(page ? { 'content-security-policy': 'sandbox allow-scripts allow-forms allow-popups allow-modals' } : {}) };
671
807
  const range = parseRange(req.headers?.range, bytes.length);
672
808
  if (range === null) {
673
809
  res.writeHead(416, { ...headers, 'content-range': `bytes */${bytes.length}`, 'content-length': 0 });
package/collab/roster.mjs CHANGED
@@ -64,6 +64,11 @@ export class Roster {
64
64
  * as UI members, once: names already in the channel are skipped, then the file becomes *.imported.json with
65
65
  * each role's member id (the new one, or the one already holding the name), so bothouse names the role's wiki
66
66
  * after the id the member really has (bridge core/setup_dsh_wiki.go). Returns the names added.
67
+ * A default-named role (name_customized false) reuses a DSH member a former run made for it — same CLI,
68
+ * still automatic, named the role's name or name-N — so re-running the wizard does not add Codex-2, Codex-3…
69
+ * (11-05 Windows report); the one named exactly the role's name goes first, then the oldest. Each member
70
+ * serves one role per run, so two default Codex roles still get two members. The imported entry carries the
71
+ * member's real name, which the bridge writes into gate-team.json and lead.json.
67
72
  */
68
73
  async importWizard(path, logger) {
69
74
  if (!existsSync(path)) return [];
@@ -76,19 +81,22 @@ export class Roster {
76
81
  }
77
82
  const added = [];
78
83
  const imported = [];
84
+ const claimed = new Set();
79
85
  for (const m of Array.isArray(members) ? members : []) {
80
86
  const name = String(m?.name ?? '').trim();
81
- const same = this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
82
- // A default-named role may become Codex-2 when Codex is already there; custom and legacy
83
- // names stay idempotent (a re-import must not duplicate them).
84
- if (same !== undefined && m?.name_customized !== false) {
85
- imported.push({ ...m, id: same.id });
87
+ // Custom and legacy names stay idempotent by name (a re-import must not duplicate them).
88
+ const same = m?.name_customized === false ? this.reusableWizardMember(name, m?.cli, claimed)
89
+ : this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
90
+ if (same !== undefined) {
91
+ claimed.add(same.id);
92
+ imported.push({ ...m, name: same.name, id: same.id });
86
93
  continue;
87
94
  }
88
95
  try {
89
96
  const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
90
97
  const member = await this.create({ name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) });
91
- imported.push({ ...m, id: member.id });
98
+ claimed.add(member.id);
99
+ imported.push({ ...m, name: member.name, id: member.id });
92
100
  added.push(member.name);
93
101
  } catch (error) {
94
102
  imported.push(m);
@@ -100,6 +108,19 @@ export class Roster {
100
108
  return added;
101
109
  }
102
110
 
111
+ /** A UI member an earlier wizard run made for the default-named role `name` (see importWizard), or undefined. */
112
+ reusableWizardMember(name, cli, claimed) {
113
+ const stem = name.toLowerCase();
114
+ // The exact name is the same role whatever its flag says: members seeded on the plugin's first start
115
+ // (seedDefaults) and those made before name_customized was stored carry none, and skipping them
116
+ // made every re-run add 鲸鱼娘-2 / DSH-2. A suffixed name (Codex-2) is reused only when it is marked automatic.
117
+ const fits = this.store.members().filter(r => r.source === 'ui' && !claimed.has(r.id) && (r.cli ?? '') === (cli ?? '')
118
+ && (r.name.toLowerCase() === stem
119
+ || (r.name_customized != null && !Boolean(r.name_customized) && r.name.toLowerCase().replace(/-\d+$/, '') === stem)));
120
+ const pick = fits.find(r => r.name.toLowerCase() === stem) ?? fits[0];
121
+ return pick === undefined ? undefined : rowToMember(pick);
122
+ }
123
+
103
124
  /**
104
125
  * Plugin-only installs (`dsh plugin add bothouse-dsh`) have no setup wizard: on the very first start,
105
126
  * with no member ever stored, add one preset role (鲸鱼娘) as a DSH member on DSH's own default model, so
package/collab/store.mjs CHANGED
@@ -56,6 +56,10 @@ const COLUMNS = [
56
56
  ['rooms', 'claim_seq', 'INTEGER NOT NULL DEFAULT 0'],
57
57
  ['rooms', 'workspace_path', 'TEXT'], // Optional chat directory; workspace rooms inherit their current parent.
58
58
  ['messages', 'edited_at', 'REAL'], // v11 (#148): when the owner last edited their own message; NULL if never.
59
+ // #1020 (no version bump, so an older build still opens the file): JSON list of the memory entries a reply used ({id, ref, page, title}); NULL when none.
60
+ ['messages', 'memories', 'TEXT'],
61
+ // #1023 (no version bump): a private chat is a chat room with exactly one agent member; NULL for every other room.
62
+ ['rooms', 'dm_member', 'TEXT'],
59
63
  // #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
60
64
  // edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
61
65
  ['messages', 'changed_at', 'REAL'],
@@ -90,6 +94,7 @@ export class Store {
90
94
  this.q = {
91
95
  room: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms WHERE id = ?'),
92
96
  rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
97
+ dms: this.db.prepare('SELECT id, dm_member FROM rooms WHERE dm_member IS NOT NULL'),
93
98
  children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
94
99
  // A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
95
100
  createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
@@ -104,6 +109,7 @@ export class Store {
104
109
  byId: this.db.prepare('SELECT * FROM messages WHERE id = ? AND deleted = 0'),
105
110
  edit: this.db.prepare('UPDATE messages SET text = ?, card = ?, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
106
111
  commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, attachments = ?, draft = 0, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
112
+ setMemories: this.db.prepare('UPDATE messages SET memories = ? WHERE id = ? RETURNING *'),
107
113
  editUser: this.db.prepare(`UPDATE messages SET text = ?, edited_at = ? WHERE id = ? AND author_kind = 'user' AND author = ?
108
114
  AND deleted = 0 AND draft = 0 AND card IS NULL RETURNING *`),
109
115
  del: this.db.prepare('UPDATE messages SET deleted = 1 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
@@ -121,6 +127,10 @@ export class Store {
121
127
  AND draft = 1 AND deleted = 0 AND card IS NULL`),
122
128
  authorDrafts: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
123
129
  AND draft = 1 AND deleted = 0 AND card IS NULL ORDER BY id`),
130
+ // #913: every live card row one author posted, in every room. A restarted bridge
131
+ // holds no handle on them, so they must be found without a message window.
132
+ authorCards: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
133
+ AND card IS NOT NULL AND deleted = 0 ORDER BY id`),
124
134
  addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
125
135
  VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
126
136
  job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
@@ -382,6 +392,24 @@ export class Store {
382
392
  this.ensureRoomSettings(room.id);
383
393
  return room;
384
394
  }
395
+ /**
396
+ * #1023: the private chat with one agent member — its existing room, or a new chat room whose
397
+ * only agent is that member (Hub.membersInRoom). Same room every time, so the session carries on.
398
+ */
399
+ openDm(memberId, name) {
400
+ if (typeof memberId !== 'string' || memberId === '') throw new CollabError('invalid', 'member id required');
401
+ const room = this.transaction(() => {
402
+ const found = this.db.prepare('SELECT id FROM rooms WHERE dm_member = ? ORDER BY id LIMIT 1').get(memberId);
403
+ if (found !== undefined) return { ...this.q.room.get(found.id), dm_member: memberId };
404
+ const created = this.q.createRoom.get(String(name || memberId).replace(/\s+/g, '').slice(0, 32) || memberId);
405
+ this.db.prepare('UPDATE rooms SET dm_member = ? WHERE id = ?').run(memberId, created.id);
406
+ return { ...created, dm_member: memberId };
407
+ });
408
+ this.ensureRoomSettings(room.id);
409
+ return room;
410
+ }
411
+ /** #1023: the agent member a private chat room is with; undefined for every other room. */
412
+ dmMember(roomId) { return this.db.prepare('SELECT dm_member FROM rooms WHERE id = ?').get(roomId)?.dm_member ?? undefined; }
385
413
  /**
386
414
  * Give a chat channel its next workspace: the lowest-numbered free 工作区<k> of the shared
387
415
  * pool (released by any chat channel after its last task) if there is one, else a new
@@ -623,7 +651,11 @@ export class Store {
623
651
  return saved || this.q.workspacePath.get(roomId)?.path || fallback;
624
652
  }
625
653
  room(id) { return this.q.room.get(id); }
626
- rooms() { return this.q.rooms.all(); }
654
+ rooms() {
655
+ // #1023: only a private chat carries dm_member, so every other room keeps its old shape.
656
+ const dms = new Map(this.q.dms.all().map(r => [r.id, r.dm_member]));
657
+ return this.q.rooms.all().map(r => (dms.has(r.id) ? { ...r, dm_member: dms.get(r.id) } : r));
658
+ }
627
659
  /** Return stale, empty claims to the pool without taking bridge or paused work. */
628
660
  releaseIdleWorkspaces() {
629
661
  const idle = this.db.prepare(`SELECT id FROM rooms WHERE kind = 'workspace' AND free = 0
@@ -672,6 +704,9 @@ export class Store {
672
704
 
673
705
  message(id) { return this.q.byId.get(id); }
674
706
 
707
+ /** #1020: record the memory entries a reply used; returns the updated row. */
708
+ setMemories(id, items) { return this.q.setMemories.get(items?.length ? JSON.stringify(items) : null, id); }
709
+
675
710
  /**
676
711
  * What a reply quotes: undefined for a plain message; `{ id, deleted: true }`
677
712
  * when the original is gone (no author or text leaks); else id, author, and
@@ -721,6 +756,10 @@ export class Store {
721
756
  // WebSocket close events may arrive after plugin teardown has closed SQLite.
722
757
  return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
723
758
  }
759
+ /** Every live progress-card row one author posted (#913), oldest first. */
760
+ authorCards(authorKind, author) {
761
+ return this.closed ? [] : this.q.authorCards.all(authorKind, author);
762
+ }
724
763
 
725
764
  /** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
726
765
  page(roomId, beforeId, limit) {
@@ -794,9 +833,17 @@ export function publicMessage(row, store) {
794
833
  if (quote !== undefined) out.reply_to = quote;
795
834
  const attachments = messageAttachments(row);
796
835
  if (attachments.length) out.attachments = attachments;
836
+ const memories = messageMemories(row);
837
+ if (memories.length) out.memories = memories;
797
838
  return out;
798
839
  }
799
840
 
841
+ /** #1020: the memory entries a reply used ([] when none or unreadable). */
842
+ export function messageMemories(row) {
843
+ if (!row?.memories) return [];
844
+ try { const list = JSON.parse(row.memories); return Array.isArray(list) ? list : []; } catch { return []; }
845
+ }
846
+
800
847
  /** #144: the uploaded files a message carries ([] when none or unreadable). */
801
848
  export function messageAttachments(row) {
802
849
  if (!row?.attachments) return [];