bothouse-dsh 0.1.57 → 0.1.59
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/README.en.md +3 -2
- package/README.md +3 -2
- package/collab/attachments.mjs +115 -1
- package/collab/cli-member.mjs +69 -11
- package/collab/cloudweb.mjs +4 -1
- package/collab/devices.html +94 -12
- package/collab/dsh-member.mjs +114 -12
- package/collab/instance-lock.mjs +135 -2
- package/collab/local-host.mjs +106 -0
- package/collab/mentions.mjs +626 -12
- package/collab/plugin.mjs +74 -20
- package/collab/roster.mjs +6 -1
- package/collab/store.mjs +9 -1
- package/collab/team-mode-rules.md +1 -1
- package/node_modules/bothouse-dsh-channels/client.js +1213 -169
- package/node_modules/bothouse-dsh-devices/client.js +70 -5
- package/package.json +4 -7
package/collab/plugin.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { existsSync, 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 {
|
|
13
|
+
import { LOCK_FILE, acquireLockWithOwner, clearLockOwner } from './instance-lock.mjs';
|
|
14
|
+
import { localConnection, isLocalOwner, mountSharedHost, refuse } from './local-host.mjs';
|
|
14
15
|
import { startBotPort } from './bot-port.mjs';
|
|
15
16
|
import { Hub } from './hub.mjs';
|
|
16
17
|
import { TeamExit } from './team-exit.mjs';
|
|
@@ -25,7 +26,7 @@ import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
|
25
26
|
import { AvatarUploads } from './avatar-uploads.mjs';
|
|
26
27
|
import { handler as clisHandler } from './clis.mjs';
|
|
27
28
|
import { LeadState } from './lead-state.mjs';
|
|
28
|
-
import { Attachments, INLINE_IMAGE } from './attachments.mjs';
|
|
29
|
+
import { Attachments, DEFAULT_MAX_UPLOAD_BYTES, INLINE_IMAGE, MAX_BOT_ATTACHMENT_BYTES, formatBytes } from './attachments.mjs';
|
|
29
30
|
import { Later, registerLater } from './later.mjs';
|
|
30
31
|
import { QuotaCooling, quotaDirectory } from './quota-cooling.mjs';
|
|
31
32
|
import { DeskEvents, DESK_NAMES_FILE, deskRoute } from './desk.mjs';
|
|
@@ -51,7 +52,6 @@ const MAX_BODY = 256 * 1024;
|
|
|
51
52
|
// that forwards it to a bot.
|
|
52
53
|
const MAX_MESSAGE_BODY = 8 * 1024 * 1024;
|
|
53
54
|
const OWNER = 'owner';
|
|
54
|
-
|
|
55
55
|
/** Validate the overlay config; misconfiguration fails at load. */
|
|
56
56
|
export function resolveConfig(config, env = process.env) {
|
|
57
57
|
const c = config ?? {};
|
|
@@ -86,6 +86,11 @@ export function resolveConfig(config, env = process.env) {
|
|
|
86
86
|
}
|
|
87
87
|
const maxRelay = c.maxRelay ?? 6;
|
|
88
88
|
if (!Number.isSafeInteger(maxRelay) || maxRelay < 0) throw Error('conest-collab: maxRelay must be a non-negative integer');
|
|
89
|
+
// #197: the cap of one streamed channel upload, in bytes. The overlay's maxUploadBytes
|
|
90
|
+
// (README) wins so a slower machine or a small disk can lower it; otherwise the
|
|
91
|
+
// measured default in attachments.mjs applies (#228's 8 MiB only fit a ~6 MB file).
|
|
92
|
+
const maxUploadBytes = c.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
93
|
+
if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes <= 0) throw Error('conest-collab: maxUploadBytes must be a positive integer');
|
|
89
94
|
const botTokenEnv = c.botTokenEnv ?? 'CONEST_COLLAB_BOT_TOKEN';
|
|
90
95
|
const botToken = env[botTokenEnv] ?? '';
|
|
91
96
|
// #142 桌面版宿主端口每次随机:给一个固定端口另开 bot 接入(bot-port.mjs)。配置或 CONEST_COLLAB_BOT_PORT,都没给就不开。
|
|
@@ -110,7 +115,7 @@ export function resolveConfig(config, env = process.env) {
|
|
|
110
115
|
// #105 侧栏「bothouse」:读这份主桥配置的 [management] 段,在服务端转发管理面板(令牌不进浏览器)。
|
|
111
116
|
const bridgeConfig = String(c.bridgeConfig ?? '').trim() || defaultBridgeConfig(env);
|
|
112
117
|
if (!isAbsolute(bridgeConfig)) throw Error('conest-collab: bridgeConfig must be an absolute path');
|
|
113
|
-
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, botPort, ownerName: c.ownerName ?? '我' };
|
|
118
|
+
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, maxUploadBytes, botTokenEnv, botToken, botPort, ownerName: c.ownerName ?? '我' };
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/** Resolve a package from the running DSH installation, never a second copy. */
|
|
@@ -139,8 +144,23 @@ export async function apply(ctx, rawConfig) {
|
|
|
139
144
|
// #1019: a second DSH on the same data directory refuses to start the plugin instead of
|
|
140
145
|
// sharing collab.sqlite3 (the panel's 「打开 DSH」 used to start one beside a running host).
|
|
141
146
|
const lockPath = join(config.dataDir, LOCK_FILE);
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
// #216: the holder records who it is, so a second DSH can repeat the truth instead of
|
|
148
|
+
// telling the user to go and configure a service that is in fact running.
|
|
149
|
+
// #227: taking the lock and writing that record are one step: if the record cannot be
|
|
150
|
+
// written the lock is released before the error travels on. This DSH stays alive with the
|
|
151
|
+
// plugin stopped, and a lock nobody releases would shut the data directory to every later
|
|
152
|
+
// DSH while its page says "another DSH is using it".
|
|
153
|
+
const local = localConnection(ctx.webServer);
|
|
154
|
+
const lock = acquireLockWithOwner(lockPath, { pid: process.pid, startedAt: Date.now(), dataDir: config.dataDir, host: hostname(), local });
|
|
155
|
+
if (lock === undefined) {
|
|
156
|
+
// #216: this instance is a client of the holder, not a second data writer. Keep scoped
|
|
157
|
+
// routes alive rather than throwing out of apply; unloading the plugin removes them.
|
|
158
|
+
// #227: the two routes live in local-host.mjs so this branch can be tested without the
|
|
159
|
+
// DSH runtime (审稿 F2: nothing ever called it in a test).
|
|
160
|
+
mountSharedHost(ctx, lockPath);
|
|
161
|
+
ctx.logger.info('conest-collab: using the existing channel host for %s', config.dataDir);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
144
164
|
// Teardown is registered before anything is opened, so a startup that fails half-way still
|
|
145
165
|
// stops whatever it started; the lock is released last, after the store and the background
|
|
146
166
|
// writers are closed, so no second host gets in while this one can still write.
|
|
@@ -161,6 +181,9 @@ export async function apply(ctx, rawConfig) {
|
|
|
161
181
|
await r.dsh?.close();
|
|
162
182
|
r.store?.close();
|
|
163
183
|
lock.release();
|
|
184
|
+
// #216: the record goes with the lock, so a DSH that opens later is not told about a
|
|
185
|
+
// holder that already quit.
|
|
186
|
+
clearLockOwner(lockPath);
|
|
164
187
|
}, 'conest-collab: teardown');
|
|
165
188
|
|
|
166
189
|
// #1044: an install from before had the desk, panel and other-computers pages; checked before Store creates the file.
|
|
@@ -224,7 +247,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
224
247
|
|
|
225
248
|
// #131: a request the tailnet pairing listener authenticated with a device credential acts as
|
|
226
249
|
// the owner; everything else still needs this DSH's own sign-in.
|
|
227
|
-
const owner = req => req[REMOTE_OWNER] === true || ctx.connection.requestRejection(req) === undefined;
|
|
250
|
+
const owner = req => req[REMOTE_OWNER] === true || isLocalOwner(req, local) || ctx.connection.requestRejection(req) === undefined;
|
|
228
251
|
// Test-only overrides (same-machine two-instance check): address and port of the pairing listener.
|
|
229
252
|
const pairAddr = String(process.env.CONEST_COLLAB_PAIR_ADDR ?? '').trim();
|
|
230
253
|
const pairPort = Number(process.env.CONEST_COLLAB_PAIR_PORT) || PAIR_PORT;
|
|
@@ -290,7 +313,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
290
313
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
291
314
|
const path = url.pathname.replace(/\/+$/, '');
|
|
292
315
|
// A paired device reaches us on the tailnet listener; ticket URLs stay on this machine's own origin.
|
|
293
|
-
if (!req[REMOTE_OWNER]) later.origin ??= localOrigin(req);
|
|
316
|
+
if (!req[REMOTE_OWNER] && !isLocalOwner(req, local)) later.origin ??= localOrigin(req);
|
|
294
317
|
const scheduled = laterRoute(req, res, url, { hub, gateway, owner, later });
|
|
295
318
|
if (scheduled !== undefined) return scheduled;
|
|
296
319
|
const routed = tasksRoute(req, res, url, { tasks, gateway, owner });
|
|
@@ -326,7 +349,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
326
349
|
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
|
|
327
350
|
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
328
351
|
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
|
|
329
|
-
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)]))
|
|
352
|
+
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])),
|
|
353
|
+
max_upload_bytes: config.maxUploadBytes });
|
|
330
354
|
}
|
|
331
355
|
if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
|
|
332
356
|
if (path === '/collab/api/lead' && req.method === 'GET') {
|
|
@@ -343,6 +367,13 @@ export async function apply(ctx, rawConfig) {
|
|
|
343
367
|
return { lead_id: id, effective_lead_id: hub.primaryMember()?.id ?? null };
|
|
344
368
|
});
|
|
345
369
|
}
|
|
370
|
+
// #197: one attachment streams straight to disk instead of riding base64 in the
|
|
371
|
+
// message JSON, so its cap no longer comes from MAX_MESSAGE_BODY. The bytes are
|
|
372
|
+
// the raw request body; the name and type travel in the query string because a
|
|
373
|
+
// header can not carry 中文. The browser posts the message with the returned
|
|
374
|
+
// metadata once every file of that message has arrived.
|
|
375
|
+
const streamed = streamUploadRoute(req, res, url, { files, maxBytes: config.maxUploadBytes });
|
|
376
|
+
if (streamed !== undefined) return streamed;
|
|
346
377
|
const att = /^\/collab\/api\/attachments\/([0-9a-f-]{36})$/.exec(path);
|
|
347
378
|
if (att !== null && req.method === 'GET') {
|
|
348
379
|
const row = store.attachmentMeta(att[1]);
|
|
@@ -398,7 +429,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
398
429
|
const clientKey = typeof body?.client_key === 'string' && body.client_key ? `web:${body.client_key.slice(0, 64)}` : undefined;
|
|
399
430
|
const roomId = Number(m[1]);
|
|
400
431
|
if (store.room(roomId) === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
401
|
-
|
|
432
|
+
// #197: ids from the streamed upload endpoint, or base64 from an older client/bot.
|
|
433
|
+
const attachments = files.receive(body?.attachments);
|
|
402
434
|
const { message } = hub.post(roomId, { authorKind: 'user', author: OWNER, name: config.ownerName, text: String(body?.text ?? ''), clientKey,
|
|
403
435
|
replyTo: Number.isInteger(body?.reply_to) ? body.reply_to : undefined, attachments });
|
|
404
436
|
return { message: publicMessage(message, store) };
|
|
@@ -585,6 +617,31 @@ export function memberAvatarRoute(req, res, path, { roster, owner }) {
|
|
|
585
617
|
}, req.method === 'POST', 512 * 1024);
|
|
586
618
|
}
|
|
587
619
|
|
|
620
|
+
/**
|
|
621
|
+
* #197: one attachment streams straight to disk instead of riding base64 in the message
|
|
622
|
+
* JSON, so its cap no longer comes from MAX_MESSAGE_BODY. The bytes are the raw request
|
|
623
|
+
* body; the name and type travel in the query string because a header can not carry 中文.
|
|
624
|
+
* The browser posts the message with the returned metadata once every file of that message
|
|
625
|
+
* has arrived.
|
|
626
|
+
* POST /collab/api/attachments?name=…&mime=… -> 201 { attachment } | 413/400 { error, code, max_upload_bytes }
|
|
627
|
+
* Owner authentication is checked by browserRoute before this handler, like ownMessageRoute.
|
|
628
|
+
*/
|
|
629
|
+
export function streamUploadRoute(req, res, url, { files, maxBytes }) {
|
|
630
|
+
// browserRoute normalizes trailing slashes before routing; do the same here so a stray one still lands.
|
|
631
|
+
if (url.pathname.replace(/\/+$/, '') !== '/collab/api/attachments' || req.method !== 'POST') return undefined;
|
|
632
|
+
const name = url.searchParams.get('name') ?? '';
|
|
633
|
+
const mime = url.searchParams.get('mime') ?? String(req.headers?.['content-type'] ?? '');
|
|
634
|
+
return files.saveStream(req, { name, mime, maxBytes }).then(
|
|
635
|
+
attachment => json(res, 201, { attachment }),
|
|
636
|
+
(error) => {
|
|
637
|
+
if (!(error instanceof CollabError)) throw error;
|
|
638
|
+
// Past the cap the rest of the body is drained, not cut, so the browser sees this reply
|
|
639
|
+
// rather than a reset connection (same trade as readBody).
|
|
640
|
+
return json(res, error.code === 'too-large' ? 413 : 400,
|
|
641
|
+
{ error: error.message, code: error.code, max_upload_bytes: maxBytes });
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
588
645
|
/** Owner authentication is checked by browserRoute before this handler, like ownMessageRoute. */
|
|
589
646
|
export function renameRoomRoute(req, res, path, { store, hub, dsh }) {
|
|
590
647
|
const match = /^\/collab\/api\/rooms\/(\d+)$/.exec(path);
|
|
@@ -639,12 +696,10 @@ export function ownMessageRoute(req, res, path, { store, hub, gateway, dsh }) {
|
|
|
639
696
|
/**
|
|
640
697
|
* #597 G26: a later ticket only opens workspaces (and later). Sent anywhere else, say where to go
|
|
641
698
|
* instead of a bare 401, so a member does not give up; the route's own auth is unchanged.
|
|
699
|
+
* #227: the split lives in local-host.mjs beside the routes that also use it; re-exported here
|
|
700
|
+
* because the task API has always published the sentence.
|
|
642
701
|
*/
|
|
643
|
-
export
|
|
644
|
-
function refuse(req, res) {
|
|
645
|
-
if (req.headers?.['x-later-ticket'] !== undefined) return json(res, 403, { error: TICKET_WRONG_ROUTE });
|
|
646
|
-
return json(res, 401, { error: 'unauthorized' });
|
|
647
|
-
}
|
|
702
|
+
export { TICKET_WRONG_ROUTE } from './local-host.mjs';
|
|
648
703
|
|
|
649
704
|
/**
|
|
650
705
|
* #139 B task API, for the owner's browser and for cloud-web bots. The browser
|
|
@@ -815,11 +870,10 @@ const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing
|
|
|
815
870
|
/** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
|
|
816
871
|
class BodyTooLarge extends Error {}
|
|
817
872
|
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
const MAX_BOT_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
873
|
+
// A bot frame carries attachments base64-encoded (x4/3) plus JSON fields; 1 MiB of
|
|
874
|
+
// headroom covers those. A smaller cap makes ws close the bot with 1009 after the
|
|
875
|
+
// sender already reported success. MAX_BOT_ATTACHMENT_BYTES lives in attachments.mjs
|
|
876
|
+
// because #197 also uses it to decide which files are too big to send at all.
|
|
823
877
|
export const BOT_WS_MAX_PAYLOAD = Math.ceil(MAX_BOT_ATTACHMENT_BYTES / 3) * 4 + 1024 * 1024;
|
|
824
878
|
|
|
825
879
|
/** Parses a single `bytes=` range against size: undefined = serve whole body, null = unsatisfiable. */
|
package/collab/roster.mjs
CHANGED
|
@@ -96,7 +96,12 @@ export class Roster {
|
|
|
96
96
|
}
|
|
97
97
|
try {
|
|
98
98
|
const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
|
|
99
|
-
const
|
|
99
|
+
const base = { name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) };
|
|
100
|
+
// The wizard pins a DSH-model role to the route its key belongs to (#1173); a host without that model
|
|
101
|
+
// still gets the role, on its default model.
|
|
102
|
+
const model = !m?.cli && m?.model?.provider && m?.model?.model ? { provider: String(m.model.provider), model: String(m.model.model) } : undefined;
|
|
103
|
+
const member = model === undefined ? await this.create(base)
|
|
104
|
+
: await this.create({ ...base, model }).catch(() => this.create(base));
|
|
100
105
|
claimed.add(member.id);
|
|
101
106
|
imported.push({ ...m, name: member.name, id: member.id });
|
|
102
107
|
added.push(member.name);
|
package/collab/store.mjs
CHANGED
|
@@ -92,6 +92,8 @@ export class Store {
|
|
|
92
92
|
this.closed = false;
|
|
93
93
|
/** fn(roomId) when a workspace's sessions are dropped for a new claim (resetSessions). */
|
|
94
94
|
this.sessionResets = [];
|
|
95
|
+
/** fn(roomId) when a workspace returns to the pool; bindings remain resumable. */
|
|
96
|
+
this.workspaceReleases = [];
|
|
95
97
|
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
|
|
96
98
|
let version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
97
99
|
if (version > SCHEMA_VERSION) throw Error(`collab: store schema ${version} is newer than ${SCHEMA_VERSION}`);
|
|
@@ -550,7 +552,7 @@ export class Store {
|
|
|
550
552
|
* workspace was claimed again, so a late release never frees the new claim. null = any claim.
|
|
551
553
|
*/
|
|
552
554
|
releaseWorkspace(roomId, claim = null) {
|
|
553
|
-
|
|
555
|
+
const released = this.transaction(() => {
|
|
554
556
|
const ws = this.q.room.get(roomId);
|
|
555
557
|
if (ws === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
556
558
|
if (ws.kind !== 'workspace' || this.workspaceOwner(ws.id) == null) return false;
|
|
@@ -562,8 +564,14 @@ export class Store {
|
|
|
562
564
|
this.db.prepare('UPDATE rooms SET free = 1, claim_bridge = 0 WHERE id = ?').run(ws.id);
|
|
563
565
|
return true;
|
|
564
566
|
});
|
|
567
|
+
if (released) {
|
|
568
|
+
for (const fn of this.workspaceReleases) fn(roomId);
|
|
569
|
+
}
|
|
570
|
+
return released;
|
|
565
571
|
}
|
|
566
572
|
|
|
573
|
+
onWorkspaceRelease(fn) { this.workspaceReleases.push(fn); }
|
|
574
|
+
|
|
567
575
|
/** Register fn(roomId), called when a workspace's sessions are dropped (resetSessions). */
|
|
568
576
|
onSessionsReset(fn) { this.sessionResets.push(fn); }
|
|
569
577
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
- 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
|
|
5
5
|
- 聊天频道里对真人说人话:讲场景、短段落,默认不用表格、不贴代码,细节进子话题。
|
|
6
6
|
- 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
|
|
7
|
-
- 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加
|
|
7
|
+
- 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。截图用 ws-up --image 推;文字进度不推。
|
|
8
8
|
- 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。
|
|
9
9
|
- 一个工作区同一时间只归一个聊天频道;挑空着的,都占着就新建。所有聊天频道共用同一组工作区1..n。
|
|
10
10
|
- 收口(每小时合结果、发汇报)时,没收完、这一小时也没在动的活由收口的人当场暂停(`board pause <编号>`):它让出工作区,那里没别的活就放回池子、清空会话;恢复时重新交活,先用空着的工作区。
|