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/README.en.md
CHANGED
|
@@ -7,14 +7,14 @@ BotHouse channels inside DeepSeek Harness (DSH): a Discord-style channels page w
|
|
|
7
7
|
## Install (pick one)
|
|
8
8
|
|
|
9
9
|
1. **Easiest**: tell DSH "install the plugin bothouse-dsh".
|
|
10
|
-
2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh
|
|
10
|
+
2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh`, click "Install"; then switch bothouse on under "Installed plugins" — "Channels" appears in the sidebar right away, no restart.
|
|
11
11
|
3. **From a terminal**:
|
|
12
12
|
|
|
13
13
|
~~~sh
|
|
14
14
|
dsh plugin --profile web add bothouse-dsh
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Asked through DSH, "Channels" appears right after the install; installed from the command line while DSH is running, restart DSH once. Then click "Channels" in the sidebar. Team desk, bothouse panel and other computers start off; switch the one you want on under "Plugins → bothouse" and it appears in the sidebar right away. #general already has Whale Girl, running on the model you configured in DSH; nothing else to fill in. @ her to get a reply; tell her "hand this to a workspace: …" and she opens a workspace, does the work and reports back. For more characters or models, use "Add member".
|
|
18
18
|
|
|
19
19
|
If the terminal install says `pnpm was not found`, run `npm i -g pnpm` first.
|
|
20
20
|
|
|
@@ -23,6 +23,7 @@ If the terminal install says `pnpm was not found`, run `npm i -g pnpm` first.
|
|
|
23
23
|
- DSH 0.2.x (from `0.2.1-rc.2`, see `dsh --version`) and Node.js 22.12 or later. Tested on 0.2.1-rc.2 only; no compatibility promise for the DSH plugin API was found and the page is prebuilt against the DSH 0.2 client packages, so the range stops before 0.3.
|
|
24
24
|
- Model members: configure the model and its key in DSH settings first.
|
|
25
25
|
- CLI members: install and sign in to Claude Code, Codex or the Gemini CLI, with the command on PATH.
|
|
26
|
+
The Claude Code and Codex adapters are not installed with the plugin; they download the first time you add such a member (Claude Code about 125 MB).
|
|
26
27
|
- Channel messages are stored in `~/.conest-collab/collab.sqlite3`; set `CONEST_COLLAB_DATA` before starting DSH to use another directory.
|
|
27
28
|
|
|
28
29
|
## Compared with full BotHouse
|
package/README.md
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
## 安装(三选一)
|
|
8
8
|
|
|
9
9
|
1. **最省事**:在 DSH 里对它说「帮我装插件 bothouse-dsh」。
|
|
10
|
-
2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh
|
|
10
|
+
2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh`,点「安装」;装完在「已安装插件」里把 bothouse 的开关打开,左侧马上出现「频道」,不用重启。
|
|
11
11
|
3. **命令行**:
|
|
12
12
|
|
|
13
13
|
~~~sh
|
|
14
14
|
dsh plugin --profile web add bothouse-dsh
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
对 DSH 说的装法装完左侧直接出现「频道」;命令行装时 DSH 若正开着,重启一次。然后点左侧「频道」。团队工作台、bothouse 面板、其他电脑默认不在左侧,要用就到「插件 → bothouse」里打开对应那一项,左侧马上出现。#general 里已经有鲸鱼娘,用的是你在 DSH 里配好的那个模型,不用再填任何东西。@鲸鱼娘 就回话;对她说「交给工作区做:……」,她会开工作区干完再交回。想要多角色、多模型,再点「添加成员」。
|
|
18
18
|
|
|
19
19
|
命令行装时提示 `pnpm was not found`:先 `npm i -g pnpm` 再装。
|
|
20
20
|
|
|
@@ -23,6 +23,7 @@ dsh plugin --profile web add bothouse-dsh
|
|
|
23
23
|
- DSH 0.2.x(`0.2.1-rc.2` 起,`dsh --version`)和 Node.js 22.12 以上。只在 0.2.1-rc.2 上实测过;DSH 插件接口有没有兼容承诺没查到,页面又是按 DSH 0.2 的前端包预编译的,所以先只放到 0.2.x,0.3 出来要重新验。
|
|
24
24
|
- 用模型当成员:在 DSH 设置里先配好模型和 Key。
|
|
25
25
|
- 用 CLI 当成员:本机装好并登录 Claude Code、Codex 或 Gemini CLI,命令在 PATH 里。
|
|
26
|
+
Claude Code、Codex 的接入适配器不随插件安装,第一次把它们加成成员时自动下载(Claude Code 约 125 MB),之后不再下。
|
|
26
27
|
- 频道消息存在 `~/.conest-collab/collab.sqlite3`;启动 DSH 前设置 `CONEST_COLLAB_DATA` 可换目录。
|
|
27
28
|
|
|
28
29
|
## 和完整版 BotHouse 的区别
|
package/collab/attachments.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// cloud_web wire format they already understand (bridge/platform/cloud-web/protocol.go
|
|
5
5
|
// wireImage / wireFile: base64 data + mime_type + file_name).
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createWriteStream, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { Transform } from 'node:stream';
|
|
10
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
11
|
import { CollabError, messageAttachments } from './store.mjs';
|
|
10
12
|
|
|
11
13
|
const ID = /^[0-9a-f-]{36}$/;
|
|
@@ -18,6 +20,25 @@ export const INLINE_IMAGE = /^image\/(png|jpeg|gif|webp)$/;
|
|
|
18
20
|
// over 5 MB (docs "Vision" / image size limits).
|
|
19
21
|
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
20
22
|
|
|
23
|
+
// #197: cap of one streamed upload when the overlay sets no maxUploadBytes. The
|
|
24
|
+
// old 8 MiB came from #228 aligning the message POST with the bot socket, which
|
|
25
|
+
// capped a file at ~6 MB once base64 was added. The default follows the owner's
|
|
26
|
+
// 「需要大一些 尽可能大」 (2026-11-05 21:01) at the largest size measured on this
|
|
27
|
+
// machine; the measurements are in the PR and in attachments.test.mjs.
|
|
28
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
// cc-connect accepts attachments up to 50 MiB by default (DefaultMaxAttachmentSize in
|
|
31
|
+
// bridge/core/api.go) and a bot frame carries them base64-encoded (x4/3) plus JSON
|
|
32
|
+
// fields; plugin.mjs derives BOT_WS_MAX_PAYLOAD from this. A bigger file is not sent
|
|
33
|
+
// over the socket at all: the bot gets its local path in the prompt instead (#197).
|
|
34
|
+
export const MAX_BOT_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/** Bytes as the UI writes them (MiB/GiB), for limits in messages. */
|
|
37
|
+
export function formatBytes(bytes) {
|
|
38
|
+
if (bytes >= 1024 ** 3) return `${+(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
39
|
+
return `${+(bytes / 1024 ** 2).toFixed(bytes >= 100 * 1024 ** 2 ? 0 : 1)} MB`;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
export class Attachments {
|
|
22
43
|
constructor(dir) { this.dir = dir; }
|
|
23
44
|
|
|
@@ -43,11 +64,95 @@ export class Attachments {
|
|
|
43
64
|
try { return readFileSync(join(this.dir, id)); } catch { return undefined; }
|
|
44
65
|
}
|
|
45
66
|
|
|
67
|
+
/** #197: absolute path of a stored attachment (the bot reads it when it is too big for a frame). */
|
|
68
|
+
path(id) {
|
|
69
|
+
return ID.test(String(id)) ? join(this.dir, id) : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* #197: accept the metadata a streamed upload returned, for a message POST that no
|
|
74
|
+
* longer carries the bytes. The size is read from disk, never from the request.
|
|
75
|
+
*/
|
|
76
|
+
adopt(list) {
|
|
77
|
+
return list.map((item) => {
|
|
78
|
+
const id = String(item?.id ?? '');
|
|
79
|
+
if (!ID.test(id)) throw new CollabError('invalid', 'attachment id is invalid');
|
|
80
|
+
let stats;
|
|
81
|
+
try { stats = statSync(this.path(id)); } catch { throw new CollabError('invalid', 'attachment is missing'); }
|
|
82
|
+
if (!stats?.isFile() || stats.size === 0) throw new CollabError('invalid', 'attachment is empty');
|
|
83
|
+
const name = String(item?.name ?? '').replace(/[\\/]/g, '_').slice(0, 200) || id;
|
|
84
|
+
return { id, name, mime: String(item?.mime ?? '') || 'application/octet-stream', size: stats.size };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** #197: a message POST either carries base64 payloads (old clients, bots) or streamed ids. */
|
|
89
|
+
receive(list) {
|
|
90
|
+
if (list == null) return [];
|
|
91
|
+
if (!Array.isArray(list)) throw new CollabError('invalid', 'attachments must be a list');
|
|
92
|
+
if (list.length === 0) return [];
|
|
93
|
+
const streamed = list.filter(item => item?.id !== undefined).length;
|
|
94
|
+
if (streamed === 0) return this.save(list);
|
|
95
|
+
if (streamed !== list.length) throw new CollabError('invalid', 'attachments must be all streamed uploads or all inline');
|
|
96
|
+
return this.adopt(list);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* #197: receive one upload straight from the request body onto disk. Bytes are
|
|
101
|
+
* never held whole in memory (the old path base64'd them into the message JSON),
|
|
102
|
+
* they stream through a byte counter into a temp file beside the final one and
|
|
103
|
+
* are renamed into place only when the whole body arrived. A body over
|
|
104
|
+
* `maxBytes`, a broken connection or an empty upload leaves no file behind.
|
|
105
|
+
* Returns the metadata to store on the message; throws CollabError.
|
|
106
|
+
*/
|
|
107
|
+
async saveStream(body, { name, mime, maxBytes }) {
|
|
108
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new CollabError('invalid', 'attachment size limit is not configured');
|
|
109
|
+
mkdirSync(this.dir, { recursive: true });
|
|
110
|
+
const id = randomUUID();
|
|
111
|
+
const temporary = join(this.dir, `${id}.part`);
|
|
112
|
+
let size = 0;
|
|
113
|
+
let over = false;
|
|
114
|
+
const counter = new Transform({
|
|
115
|
+
transform(chunk, _encoding, done) {
|
|
116
|
+
size += chunk.length;
|
|
117
|
+
// Past the cap the rest of the body is still read and dropped, so the browser
|
|
118
|
+
// gets the 413 instead of a reset connection (same trade as readBody in plugin.mjs).
|
|
119
|
+
if (size > maxBytes) { over = true; return done(); }
|
|
120
|
+
done(null, chunk);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
await pipeline(body, counter, createWriteStream(temporary, { mode: 0o600, flags: 'wx' }));
|
|
125
|
+
if (over) throw new CollabError('too-large', `附件不能超过 ${formatBytes(maxBytes)}`);
|
|
126
|
+
if (size === 0) throw new CollabError('invalid', 'attachment is empty');
|
|
127
|
+
renameSync(temporary, join(this.dir, id));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
try { unlinkSync(temporary); } catch (cleanup) { if (cleanup.code !== 'ENOENT') throw cleanup; }
|
|
130
|
+
if (error instanceof CollabError) throw error;
|
|
131
|
+
throw new CollabError('invalid', '上传中断了,文件没有存下');
|
|
132
|
+
}
|
|
133
|
+
// The name travels in the query string (a header cannot carry 中文); keep only
|
|
134
|
+
// the last path segment so a generated name never reaches outside the dir.
|
|
135
|
+
const clean = String(name ?? '').replace(/[\\/]/g, '_').slice(0, 200) || id;
|
|
136
|
+
return { id, name: clean, mime: String(mime ?? '') || 'application/octet-stream', size };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* #197: attachments of a message that are too big to travel over a bot
|
|
141
|
+
* connection (cloud_web frame or ACP block). The bot gets their local path in
|
|
142
|
+
* the prompt text instead of the bytes (oversizedNote).
|
|
143
|
+
*/
|
|
144
|
+
oversized(metas) {
|
|
145
|
+
return (metas ?? []).filter(a => (a.size ?? 0) > MAX_BOT_ATTACHMENT_BYTES)
|
|
146
|
+
.map(a => ({ ...a, path: this.path(a.id) }));
|
|
147
|
+
}
|
|
148
|
+
|
|
46
149
|
/** cloud_web inbound frame fields (images / files) for a message's attachments. */
|
|
47
150
|
wire(metas) {
|
|
48
151
|
const images = [];
|
|
49
152
|
const files = [];
|
|
50
153
|
for (const a of metas ?? []) {
|
|
154
|
+
// #197: a file over the frame cap is not encoded at all; the bot is told its path.
|
|
155
|
+
if ((a.size ?? 0) > MAX_BOT_ATTACHMENT_BYTES) continue;
|
|
51
156
|
const bytes = this.read(a.id);
|
|
52
157
|
if (bytes === undefined) continue;
|
|
53
158
|
const item = { mime_type: a.mime, data: bytes.toString('base64'), file_name: a.name };
|
|
@@ -60,6 +165,15 @@ export class Attachments {
|
|
|
60
165
|
}
|
|
61
166
|
}
|
|
62
167
|
|
|
168
|
+
/**
|
|
169
|
+
* #197: one line per attachment that was not sent over the connection, with the
|
|
170
|
+
* path on this machine, so a bot can read or move the file itself.
|
|
171
|
+
*/
|
|
172
|
+
export function oversizedNote(oversized) {
|
|
173
|
+
return (oversized ?? []).map(a =>
|
|
174
|
+
`📎 ${a.name}(${formatBytes(a.size)})太大,没有随消息发送;本机文件:${a.path ?? '(文件已不在)'}`).join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
63
177
|
/** Shared wire admission, projected into the ACP prompt format, each block with its file name. */
|
|
64
178
|
export function namedAttachmentBlocks(files, messages) {
|
|
65
179
|
const wire = files?.wire(messages.flatMap(messageAttachments)) ?? {};
|
package/collab/cli-member.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// CLI 成员:把本机 claude / codex / gemini 通过 ACP 适配器接成群成员。
|
|
2
2
|
// 每个(成员, 房间)一个 ACP session;ask(text) 返回该轮 agent_message_chunk 拼接文本。
|
|
3
|
-
import { spawn } from 'node:child_process'
|
|
4
|
-
import { existsSync } from 'node:fs'
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
5
|
import { Readable, Writable } from 'node:stream'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
7
|
import path from 'node:path'
|
|
@@ -46,6 +46,58 @@ function resolveBin(pkg) {
|
|
|
46
46
|
return { cmd: 'npx', args: ['-y', pkg] }
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** PATH 目录(Windows 的键名可能是 Path);去掉引号。 */
|
|
50
|
+
function pathDirs(env) {
|
|
51
|
+
return String(env.PATH || env.Path || '').split(path.delimiter).map(d => d.replace(/^"|"$/g, '')).filter(Boolean)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 按 PATH + PATHEXT 找可执行文件,返回绝对路径。 */
|
|
55
|
+
function findOnPath(name, env) {
|
|
56
|
+
if (path.isAbsolute(name)) return existsSync(name) ? name : undefined
|
|
57
|
+
const exts = String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
58
|
+
for (const dir of pathDirs(env)) {
|
|
59
|
+
for (const ext of exts) {
|
|
60
|
+
const candidate = path.join(dir, `${name}${ext}`)
|
|
61
|
+
if (existsSync(candidate)) return candidate
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Windows:`gemini` / `npx` 是 npm 写的 .cmd 外壳,外壳里再起 `node <脚本>`。经外壳启动时
|
|
69
|
+
* spawn 出来的是 cmd.exe,close() 只杀外壳,真正的 CLI 和它起的进程全成孤儿(PR #1254 Windows 实测)。
|
|
70
|
+
* 这里把外壳解析成它要跑的 node 脚本,直接 spawn:拿到的 pid 就是 CLI 自己。
|
|
71
|
+
* 解析不出来(不是 node 脚本、脚本不存在)返回 null,调用方照旧经外壳启动,关闭时按进程树杀。
|
|
72
|
+
*/
|
|
73
|
+
export function resolveShim(cmd, env = process.env) {
|
|
74
|
+
const file = findOnPath(cmd, env)
|
|
75
|
+
if (!file) return null
|
|
76
|
+
const lower = file.toLowerCase()
|
|
77
|
+
if (lower.endsWith('.exe') || lower.endsWith('.com')) return { cmd: file, args: [], shell: false }
|
|
78
|
+
if (!lower.endsWith('.cmd') && !lower.endsWith('.bat')) return null
|
|
79
|
+
let body
|
|
80
|
+
try { body = readFileSync(file, 'utf8') } catch { return null }
|
|
81
|
+
// npm / pnpm 的 shim 结尾:「%_prog%」 「%dp0%\<脚本>」 %*(pnpm 用 %~dp0%)。
|
|
82
|
+
const m = body.match(/"%(?:~)?dp0%\\?([^"%]+?)"\s*%\*/i)
|
|
83
|
+
if (!m || !/\bnode(?:\.exe)?\b/i.test(body)) return null
|
|
84
|
+
const script = path.resolve(path.dirname(file), m[1])
|
|
85
|
+
if (!existsSync(script) || !/\.(?:m?js|cjs)$/i.test(script)) return null
|
|
86
|
+
return { cmd: process.execPath, args: [script], shell: false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 关掉一个 CLI 进程。Windows 上按进程树杀:可能还隔着 cmd.exe 外壳或 npx,只杀 pid 会把真 CLI
|
|
91
|
+
* 和它起的孙进程留下(PR #1254 Windows 实测)。非 Windows 保持原来的 proc.kill()。
|
|
92
|
+
*/
|
|
93
|
+
export function stopProcess(proc) {
|
|
94
|
+
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
|
|
95
|
+
if (process.platform === 'win32' && proc.pid !== undefined) {
|
|
96
|
+
try { spawnSync('taskkill', ['/T', '/F', '/PID', String(proc.pid)], { stdio: 'ignore' }) } catch {}
|
|
97
|
+
}
|
|
98
|
+
proc.kill()
|
|
99
|
+
}
|
|
100
|
+
|
|
49
101
|
// 去掉外层 Claude Code 会话的标记(CLAUDECODE、CLAUDE_CODE_ENTRYPOINT…),成员的 claude 才不会当成嵌套会话;
|
|
50
102
|
// 登录用的 CLAUDE_CODE_OAUTH_TOKEN 留着(ANTHROPIC_API_KEY 本来就不在过滤范围),只靠环境变量登录的用户才登得上。
|
|
51
103
|
const KEEP_ENV = new Set(['CLAUDE_CODE_OAUTH_TOKEN'])
|
|
@@ -106,13 +158,19 @@ export class CliMember {
|
|
|
106
158
|
if (this.ready) return this.ready
|
|
107
159
|
const a = ADAPTERS[this.cli]
|
|
108
160
|
const { cmd, args } = a.pkg ? resolveBin(a.pkg) : { cmd: a.cmd, args: [] }
|
|
109
|
-
|
|
161
|
+
// 已直接指到 node + 脚本(claude/codex 的包)就不必再解析;其余在 Windows 上先试着把 .cmd 外壳
|
|
162
|
+
// 拆成 node + 脚本,拆不动才退回经外壳启动。
|
|
163
|
+
const direct = cmd === process.execPath
|
|
164
|
+
const shim = !direct && process.platform === 'win32' ? resolveShim(cmd, { ...cleanEnv(), ...this.extraEnv }) : null
|
|
165
|
+
// shim 只替换命令入口;保留原命令参数(npx 的 -y + 包名),再由 spawn 追加适配器参数。
|
|
166
|
+
const launch = shim ? { ...shim, args: [...shim.args, ...args] } : { cmd, args, shell: !direct && process.platform === 'win32' }
|
|
167
|
+
const proc = spawn(launch.cmd, [...launch.args, ...a.args], {
|
|
110
168
|
cwd: this.cwd,
|
|
111
169
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
112
170
|
env: { ...cleanEnv(), ...this.extraEnv },
|
|
113
|
-
// Windows
|
|
114
|
-
|
|
115
|
-
shell:
|
|
171
|
+
// Windows 上拆不掉外壳时才用 shell:`gemini` / `npx` 是 .cmd,不经外壳 spawn 会 ENOENT
|
|
172
|
+
//(.cmd 路径还会 EINVAL)。参数都是固定名字和开关,没有空格。
|
|
173
|
+
shell: launch.shell,
|
|
116
174
|
})
|
|
117
175
|
this.proc = proc
|
|
118
176
|
const client = this._client()
|
|
@@ -139,12 +197,12 @@ export class CliMember {
|
|
|
139
197
|
})
|
|
140
198
|
exited.catch(() => {})
|
|
141
199
|
this.exited = exited
|
|
142
|
-
// Windows
|
|
143
|
-
//
|
|
144
|
-
//
|
|
200
|
+
// Windows 上退回经外壳启动时,外壳即使找不到 CLI 或目录也会跑起来:ACP 流在进程报 exit/error 之前
|
|
201
|
+
// 就断了,成员只会显示 "ACP connection closed",而不是安装提示(或 COLLAB_CLI_START)。握手失败时
|
|
202
|
+
// 按进程结束方式报错。
|
|
145
203
|
const init = this.conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} })
|
|
146
204
|
.catch(error => {
|
|
147
|
-
|
|
205
|
+
stopProcess(proc)
|
|
148
206
|
return exited.then(() => { throw error })
|
|
149
207
|
})
|
|
150
208
|
this.ready = Promise.race([
|
|
@@ -283,7 +341,7 @@ export class CliMember {
|
|
|
283
341
|
}
|
|
284
342
|
|
|
285
343
|
close() {
|
|
286
|
-
this.proc
|
|
344
|
+
stopProcess(this.proc)
|
|
287
345
|
this.sessions.clear()
|
|
288
346
|
this.saved.clear()
|
|
289
347
|
this.ready = null
|
package/collab/cloudweb.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { exitTarget } from './team-exit.mjs';
|
|
|
7
7
|
import { forDisplay, forMember, mentions, nameCalled } from './mentions.mjs';
|
|
8
8
|
import { checkAvatar } from './avatars.mjs';
|
|
9
9
|
import { CollabError, MAX_TEXT, messageAttachments, publicMessage, quotePrefix } from './store.mjs';
|
|
10
|
+
import { oversizedNote } from './attachments.mjs';
|
|
10
11
|
import { ChannelApprovals, parseCard, textDecision } from './approvals.mjs';
|
|
11
12
|
|
|
12
13
|
export { textDecision };
|
|
@@ -344,6 +345,8 @@ export class CloudWebGateway {
|
|
|
344
345
|
const only = this.hub.replyRoute(msg);
|
|
345
346
|
const metas = messageAttachments(msg);
|
|
346
347
|
const attached = metas.length && this.files ? this.files.wire(metas) : {};
|
|
348
|
+
// #197: a file too big for the frame travels as its path on this machine instead.
|
|
349
|
+
const tooBig = metas.length && this.files ? oversizedNote(this.files.oversized(metas)) : '';
|
|
347
350
|
const quoted = this.hub.store.quote(msg);
|
|
348
351
|
const quote = quotePrefix(quoted);
|
|
349
352
|
// The replied-to row's id (same id space as a task card's preview_handle and a reply_ack),
|
|
@@ -367,7 +370,7 @@ export class CloudWebGateway {
|
|
|
367
370
|
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: chatType(this.hub.store, msg.room_id),
|
|
368
371
|
// A bridge command (`@橘 /model x`) goes through bare: the bridge reads everything after the
|
|
369
372
|
// command word as its arguments (core/engine.go handleCommand), so no later instructions.
|
|
370
|
-
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),
|
|
373
|
+
user_id: msg.author, user_name: msg.name, content: content + (tooBig ? `\n${tooBig}` : '') + (hit && !isCommand(content) ? (this.later?.instructions(member.id, msg.room_id) ?? '') : ''), reply_ctx: roomKey(msg.room_id),
|
|
371
374
|
mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
|
|
372
375
|
};
|
|
373
376
|
if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
|
package/collab/devices.html
CHANGED
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
#viewer header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--card); flex-wrap: wrap; }
|
|
56
56
|
#viewer header .tabs button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
57
57
|
#viewer iframe { flex: 1; border: 0; width: 100%; background: var(--bg); }
|
|
58
|
+
#viewer iframe[hidden], #viewer .react-note[hidden] { display: none; }
|
|
59
|
+
#viewer .react-note { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; text-align: center; color: var(--muted, inherit); }
|
|
58
60
|
</style>
|
|
59
61
|
</head>
|
|
60
62
|
<body>
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
<input class="digits" id="digits" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="输入那台电脑上的数字">
|
|
87
89
|
<label class="mode"><input type="radio" name="mode" value="temp" checked><span><b>临时</b><br><span class="muted">断开后下次要重新输数字。</span></span></label>
|
|
88
90
|
<div class="dur" id="dur">
|
|
89
|
-
<label><input type="radio" name="dur" value="until" checked>直到我断开<span class="muted">(任一台点「断开」,或关掉 DSH
|
|
91
|
+
<label><input type="radio" name="dur" value="until" checked>直到我断开<span class="muted">(任一台点「断开」,或关掉 DSH 窗口;刷新页面、切到别的面板不会断)</span></label>
|
|
90
92
|
<label><input type="radio" name="dur" value="custom">自定义时长:<input type="number" id="durNum" min="1" step="1" value="1" inputmode="numeric"><select id="durUnit"><option value="60000">分钟</option><option value="3600000" selected>小时</option></select><span class="muted">到时自动断</span></label>
|
|
91
93
|
</div>
|
|
92
94
|
<label class="mode"><input type="radio" name="mode" value="remember"><span><b>记住这台电脑</b><br><span class="muted">以后打开就能直接连,不用再输。那台电脑随时可以在「已连接的设备」里踢掉。</span></span></label>
|
|
@@ -109,6 +111,10 @@
|
|
|
109
111
|
</span>
|
|
110
112
|
</header>
|
|
111
113
|
<iframe id="frame" title="另一台电脑"></iframe>
|
|
114
|
+
<div id="reactNote" class="react-note" hidden>
|
|
115
|
+
<p>这台电脑的频道已在 DSH 的频道界面里打开(顶上写着电脑名,能 @ 人、点头像、悬停回复)。</p>
|
|
116
|
+
<button id="reopen" class="primary">再打开频道</button>
|
|
117
|
+
</div>
|
|
112
118
|
</div>
|
|
113
119
|
<script>
|
|
114
120
|
'use strict';
|
|
@@ -254,13 +260,63 @@ $('refresh').onclick = loadPeers;
|
|
|
254
260
|
// Only the custom-length box belongs to 临时.
|
|
255
261
|
for (const r of document.querySelectorAll('input[name=mode]')) r.addEventListener('change', () => { $('dur').hidden = document.querySelector('input[name=mode]:checked').value !== 'temp'; });
|
|
256
262
|
$('durNum').addEventListener('focus', () => { document.querySelector('input[name=dur][value=custom]').checked = true; });
|
|
257
|
-
// 「直到我断开」:
|
|
258
|
-
//
|
|
263
|
+
// 「直到我断开」: only these links lapse when no DSH window beats (pairing.mjs sweep), so only
|
|
264
|
+
// these need a beat. Nothing paired, or only 「记住」 links (those never lapse), stays silent.
|
|
265
|
+
// Switch to another panel and this page is gone, so the answer is published for the DSH window.
|
|
266
|
+
// Every beat rechecks the server list, so a revoked link clears the published answer promptly.
|
|
259
267
|
// 来源:与 pairing.mjs ALIVE_EVERY_MS 同一节拍。
|
|
260
268
|
const ALIVE_EVERY_MS = 3000;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
const UNTIL_KEY = 'bothouse-dsh-devices: until-disconnect';
|
|
270
|
+
const UNTIL_LEASE = 'bothouse-dsh-devices: heartbeat';
|
|
271
|
+
const UNTIL_HOLDER = UNTIL_LEASE + ': holder';
|
|
272
|
+
const UNTIL_OWNER = String(Date.now()) + '-' + Math.random();
|
|
273
|
+
let holding = false;
|
|
274
|
+
const untilDisconnect = () => links.some(l => l.mode === 'temp' && l.expires_at === null);
|
|
275
|
+
const publish = () => {
|
|
276
|
+
try { localStorage.setItem(UNTIL_KEY, untilDisconnect() ? '1' : '0'); } catch (error) { /* no storage: this page still beats while it is open */ }
|
|
277
|
+
};
|
|
278
|
+
const pulse = async () => {
|
|
279
|
+
const result = await api('api/remote/links');
|
|
280
|
+
links = result.links;
|
|
281
|
+
publish();
|
|
282
|
+
if (!untilDisconnect()) return;
|
|
283
|
+
await fetch('api/remote/alive', { method: 'POST', credentials: 'same-origin' });
|
|
284
|
+
};
|
|
285
|
+
let tickNow = () => {};
|
|
286
|
+
let holderTimer;
|
|
287
|
+
if (navigator.locks?.request) {
|
|
288
|
+
const control = new AbortController();
|
|
289
|
+
globalThis.addEventListener?.('pagehide', () => { control.abort(); clearInterval(holderTimer); }, { once: true });
|
|
290
|
+
const acquire = () => { if (localStorage.getItem(UNTIL_KEY) !== '1') return Promise.resolve(); return navigator.locks.request(UNTIL_LEASE, { mode: 'exclusive', ifAvailable: true }, lock => {
|
|
291
|
+
if (!lock) return;
|
|
292
|
+
holderTimer = setInterval(pulse, ALIVE_EVERY_MS);
|
|
293
|
+
pulse();
|
|
294
|
+
return new Promise(resolve => control.signal.addEventListener('abort', resolve, { once: true }));
|
|
295
|
+
}).catch(() => {}); };
|
|
296
|
+
setInterval(acquire, ALIVE_EVERY_MS);
|
|
297
|
+
tickNow = acquire;
|
|
298
|
+
} else {
|
|
299
|
+
const run = async () => {
|
|
300
|
+
if (localStorage.getItem(UNTIL_KEY) !== '1') return;
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
let held;
|
|
303
|
+
try { held = JSON.parse(localStorage.getItem(UNTIL_HOLDER) || 'null'); } catch (error) {}
|
|
304
|
+
if (held && held.owner !== UNTIL_OWNER && held.expires > now) return;
|
|
305
|
+
// 来源: two ALIVE_EVERY_MS ticks, so a closed tab's fallback lease is reclaimed promptly.
|
|
306
|
+
localStorage.setItem(UNTIL_HOLDER, JSON.stringify({ owner: UNTIL_OWNER, expires: now + ALIVE_EVERY_MS * 2 }));
|
|
307
|
+
holding = JSON.parse(localStorage.getItem(UNTIL_HOLDER) || 'null')?.owner === UNTIL_OWNER;
|
|
308
|
+
if (!holding) return;
|
|
309
|
+
if (holderTimer === undefined) holderTimer = setInterval(pulse, ALIVE_EVERY_MS);
|
|
310
|
+
await pulse();
|
|
311
|
+
if (JSON.parse(localStorage.getItem(UNTIL_HOLDER) || 'null')?.owner === UNTIL_OWNER) localStorage.setItem(UNTIL_HOLDER, JSON.stringify({ owner: UNTIL_OWNER, expires: Date.now() + ALIVE_EVERY_MS * 2 }));
|
|
312
|
+
};
|
|
313
|
+
setInterval(() => tickNow(), ALIVE_EVERY_MS);
|
|
314
|
+
tickNow = run;
|
|
315
|
+
globalThis.addEventListener?.('pagehide', () => {
|
|
316
|
+
clearInterval(holderTimer);
|
|
317
|
+
if (JSON.parse(localStorage.getItem(UNTIL_HOLDER) || 'null')?.owner === UNTIL_OWNER) localStorage.removeItem(UNTIL_HOLDER);
|
|
318
|
+
}, { once: true });
|
|
319
|
+
}
|
|
264
320
|
$('digits').addEventListener('keydown', e => { if (e.key === 'Enter') $('doConnect').click(); });
|
|
265
321
|
$('doConnect').onclick = async () => {
|
|
266
322
|
if (!selected) return;
|
|
@@ -285,6 +341,9 @@ $('doConnect').onclick = async () => {
|
|
|
285
341
|
async function loadLinks() {
|
|
286
342
|
try {
|
|
287
343
|
({ links } = await api('api/remote/links'));
|
|
344
|
+
// Read the list before beating: an 「直到我断开」 link already in it must be kept alive at once.
|
|
345
|
+
publish();
|
|
346
|
+
tickNow();
|
|
288
347
|
const ul = $('links');
|
|
289
348
|
ul.replaceChildren();
|
|
290
349
|
if (!links.length) { ul.append(el('li', { class: 'empty' }, '还没有连着的电脑')); return; }
|
|
@@ -301,21 +360,44 @@ async function loadLinks() {
|
|
|
301
360
|
}
|
|
302
361
|
|
|
303
362
|
// ---- 看那台电脑 ----
|
|
304
|
-
function open(link, tab) {
|
|
363
|
+
function open(link, tab, jump = true) {
|
|
305
364
|
viewing = link;
|
|
365
|
+
remember(link.id);
|
|
306
366
|
$('viewName').textContent = link.name;
|
|
307
367
|
$('viewer').classList.add('on');
|
|
308
|
-
show(tab);
|
|
368
|
+
show(tab, jump);
|
|
369
|
+
}
|
|
370
|
+
// #236: 频道 opens in this DSH's own channels page (client-ui-discord RemoteChannelsPage) with the
|
|
371
|
+
// other computer's data, so @ candidates, the avatar menu and hover reply work as they do locally.
|
|
372
|
+
// Without it (an older client-ui-discord, or this page opened outside DSH) the old page stays.
|
|
373
|
+
function openReactChannels(link) {
|
|
374
|
+
try {
|
|
375
|
+
const openRemote = window.parent !== window ? window.parent.__bothouseOpenRemoteChannels : undefined;
|
|
376
|
+
return typeof openRemote === 'function' && openRemote(link.id, link.name) === true;
|
|
377
|
+
} catch { return false; }
|
|
309
378
|
}
|
|
310
|
-
|
|
379
|
+
// The shell remounts this page when 返回其他电脑 brings it back, so the link being viewed is kept for
|
|
380
|
+
// the tab (per-viewer convenience only) and the viewer comes back on it without jumping again.
|
|
381
|
+
const VIEWING_KEY = 'collab-devices:viewing';
|
|
382
|
+
function remember(id) { try { if (id) sessionStorage.setItem(VIEWING_KEY, id); else sessionStorage.removeItem(VIEWING_KEY); } catch { /* storage off */ } }
|
|
383
|
+
function remembered() { try { return sessionStorage.getItem(VIEWING_KEY); } catch { return null; } }
|
|
384
|
+
function show(tab, jump = true) {
|
|
311
385
|
for (const b of document.querySelectorAll('#viewer [data-tab]')) b.classList.toggle('on', b.dataset.tab === tab);
|
|
312
|
-
|
|
386
|
+
const hasReact = tab === '' && (() => { try { return window.parent !== window && typeof window.parent.__bothouseOpenRemoteChannels === 'function'; } catch { return false; } })();
|
|
387
|
+
const react = tab === '' && (jump ? openReactChannels(viewing) : hasReact);
|
|
388
|
+
$('frame').hidden = react;
|
|
389
|
+
$('reactNote').hidden = !react;
|
|
390
|
+
$('frame').src = react ? 'about:blank' : `remote/${viewing.id}/${tab}`;
|
|
313
391
|
}
|
|
392
|
+
$('reopen').onclick = () => { if (viewing) show(''); };
|
|
314
393
|
for (const b of document.querySelectorAll('#viewer [data-tab]')) b.onclick = () => show(b.dataset.tab);
|
|
315
|
-
$('back').onclick = () => { $('viewer').classList.remove('on'); $('frame').src = 'about:blank'; viewing = undefined; loadLinks(); };
|
|
394
|
+
$('back').onclick = () => { $('viewer').classList.remove('on'); $('frame').src = 'about:blank'; viewing = undefined; remember(undefined); loadLinks(); };
|
|
316
395
|
|
|
317
396
|
(async () => {
|
|
318
|
-
loadDevices();
|
|
397
|
+
loadDevices(); loadPeers();
|
|
398
|
+
await loadLinks();
|
|
399
|
+
const back = links?.find(l => l.id === remembered());
|
|
400
|
+
if (back) open(back, '', false); else remember(undefined);
|
|
319
401
|
try { const s = await api('api/pair/code'); if (s.waiting) $('hostErr').textContent = '刚才生成的数字还在等待中;看不到数字的话点「生成数字」换一个。'; } catch { /* ignore */ }
|
|
320
402
|
})();
|
|
321
403
|
</script>
|