@chenacc/agentbridge 0.0.0-stage → 0.1.0

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,187 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+
5
+ /**
6
+ * Attachments for phone-sent messages.
7
+ *
8
+ * A phone is the device most likely to hold the thing the agent needs — a
9
+ * screenshot of the error, a photo of the whiteboard, a log file — and before this
10
+ * there was no way to get one across. Files are written under the bridge data
11
+ * directory, described in a small catalog so a preview URL survives a restart, and
12
+ * bounded hard: a phone on a tunnel must not be able to fill the disk.
13
+ */
14
+
15
+ const MAX_BYTES = 8 * 1024 * 1024;
16
+ const MAX_ENTRIES = 400;
17
+ const MAX_FILES_PER_SESSION = 60;
18
+
19
+ const EXTENSIONS = {
20
+ "image/png": ".png",
21
+ "image/jpeg": ".jpg",
22
+ "image/webp": ".webp",
23
+ "image/gif": ".gif",
24
+ "application/pdf": ".pdf",
25
+ "text/plain": ".txt",
26
+ "text/markdown": ".md",
27
+ "application/json": ".json",
28
+ "application/zip": ".zip",
29
+ };
30
+
31
+ export function extensionFor(mimeType, name = "") {
32
+ const byMime = EXTENSIONS[String(mimeType ?? "").toLowerCase()];
33
+ if (byMime) return byMime;
34
+ const byName = path.extname(String(name)).toLowerCase();
35
+ return /^\.[a-z0-9]{1,8}$/.test(byName) ? byName : ".bin";
36
+ }
37
+
38
+ export function isImage(mimeType) {
39
+ return /^image\/(png|jpe?g|webp|gif)$/i.test(String(mimeType ?? ""));
40
+ }
41
+
42
+ function cleanName(value) {
43
+ const name = String(value ?? "").trim().replace(/[\u0000-\u001f\\/:*?"<>|]/g, "_");
44
+ return name.slice(-120) || "attachment";
45
+ }
46
+
47
+ export class AttachmentStore {
48
+ #byId = new Map();
49
+ #catalogPath;
50
+ #directory;
51
+ #writes = Promise.resolve();
52
+ #now;
53
+
54
+ constructor({ dataDir, now = () => Date.now() }) {
55
+ this.#directory = path.join(dataDir, "attachments");
56
+ this.#catalogPath = path.join(this.#directory, "attachments.json");
57
+ this.#now = now;
58
+ }
59
+
60
+ get maxBytes() {
61
+ return MAX_BYTES;
62
+ }
63
+
64
+ async init() {
65
+ await mkdir(this.#directory, { recursive: true });
66
+ try {
67
+ const parsed = JSON.parse(await readFile(this.#catalogPath, "utf8"));
68
+ for (const entry of Array.isArray(parsed?.items) ? parsed.items : []) {
69
+ if (typeof entry?.id === "string" && typeof entry?.file === "string") this.#byId.set(entry.id, entry);
70
+ }
71
+ } catch {
72
+ // No catalog yet: a fresh data directory is not an error.
73
+ }
74
+ }
75
+
76
+ list(sessionId) {
77
+ return [...this.#byId.values()]
78
+ .filter((entry) => !sessionId || entry.sessionId === sessionId)
79
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
80
+ }
81
+
82
+ get(id) {
83
+ return this.#byId.get(id) ?? null;
84
+ }
85
+
86
+ /** Absolute path for an attachment, or null when it is unknown. */
87
+ pathOf(id) {
88
+ const entry = this.#byId.get(id);
89
+ return entry ? path.join(this.#directory, entry.file) : null;
90
+ }
91
+
92
+ /**
93
+ * Store one upload. `data` is a Buffer; anything larger than `maxBytes` is
94
+ * rejected before it is written, and the catalog is trimmed oldest-first so a
95
+ * phone cannot grow the directory without bound.
96
+ */
97
+ async save({ sessionId, name, mimeType, data }) {
98
+ if (!Buffer.isBuffer(data) || data.length === 0) {
99
+ throw Object.assign(new Error("The upload is empty"), { statusCode: 400 });
100
+ }
101
+ if (data.length > MAX_BYTES) {
102
+ throw Object.assign(new Error(`附件不能超过 ${Math.round(MAX_BYTES / 1024 / 1024)} MB`), { statusCode: 413 });
103
+ }
104
+ const sessionFiles = this.list(sessionId);
105
+ if (sessionFiles.length >= MAX_FILES_PER_SESSION) {
106
+ const oldest = sessionFiles.at(-1);
107
+ await this.#remove(oldest.id);
108
+ }
109
+ const id = `att_${randomUUID()}`;
110
+ const fileName = `${createHash("sha256").update(id).digest("hex").slice(0, 24)}${extensionFor(mimeType, name)}`;
111
+ const target = path.join(this.#directory, fileName);
112
+ const handle = await open(target, "w");
113
+ try {
114
+ await handle.writeFile(data);
115
+ await handle.sync();
116
+ } finally {
117
+ await handle.close();
118
+ }
119
+ const entry = {
120
+ id,
121
+ sessionId,
122
+ name: cleanName(name),
123
+ mimeType: String(mimeType ?? "application/octet-stream").toLowerCase().slice(0, 120),
124
+ bytes: data.length,
125
+ file: fileName,
126
+ createdAt: this.#now(),
127
+ };
128
+ this.#byId.set(id, entry);
129
+ await this.#persist();
130
+ return { ...entry };
131
+ }
132
+
133
+ async remove(id) {
134
+ return this.#remove(id);
135
+ }
136
+
137
+ async #remove(id) {
138
+ const entry = this.#byId.get(id);
139
+ if (!entry) return false;
140
+ this.#byId.delete(id);
141
+ await rm(path.join(this.#directory, entry.file), { force: true });
142
+ await this.#persist();
143
+ return true;
144
+ }
145
+
146
+ /** Read the bytes back for a provider that needs them inline (base64). */
147
+ async read(id) {
148
+ const entry = this.#byId.get(id);
149
+ if (!entry) return null;
150
+ const buffer = await readFile(path.join(this.#directory, entry.file));
151
+ return { ...entry, data: buffer };
152
+ }
153
+
154
+ async sizeOf(id) {
155
+ const entry = this.#byId.get(id);
156
+ if (!entry) return null;
157
+ try {
158
+ const info = await stat(path.join(this.#directory, entry.file));
159
+ return info.size;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ #persist() {
166
+ const payload = JSON.stringify({
167
+ version: 1,
168
+ items: [...this.#byId.values()]
169
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
170
+ .slice(0, MAX_ENTRIES),
171
+ });
172
+ // Once over the cap, the entries that fell out are removed from disk too.
173
+ for (const entry of this.#byId.values()) {
174
+ if (!payload.includes(entry.id)) {
175
+ this.#byId.delete(entry.id);
176
+ void rm(path.join(this.#directory, entry.file), { force: true });
177
+ }
178
+ }
179
+ const operation = this.#writes.catch(() => undefined).then(async () => {
180
+ const temporary = `${this.#catalogPath}.tmp`;
181
+ await writeFile(temporary, payload, "utf8");
182
+ await rename(temporary, this.#catalogPath);
183
+ });
184
+ this.#writes = operation;
185
+ return operation;
186
+ }
187
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+
6
+ const KIMI_PROVIDER_ID = "cc-switch:kimi";
7
+ const KIMI_MODELS = [
8
+ { id: "kimi-for-coding", name: "Kimi K2.7 Code", note: "256K 上下文" },
9
+ { id: "k3-256k", name: "Kimi K3 256K", note: "K3,256K 上下文" },
10
+ { id: "k3", name: "Kimi K3", note: "K3;可用上下文取决于会员权益" },
11
+ ];
12
+
13
+ function parseJson(raw) {
14
+ try { return JSON.parse(raw); } catch { return null; }
15
+ }
16
+
17
+ function isKimiCodingEndpoint(value) {
18
+ try {
19
+ const url = new URL(String(value ?? ""));
20
+ return url.hostname === "api.kimi.com" && url.pathname.startsWith("/coding");
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function isLocalProxyEndpoint(value) {
27
+ try {
28
+ const url = new URL(String(value ?? ""));
29
+ return ["127.0.0.1", "localhost", "::1"].includes(url.hostname);
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export class CcSwitchModelCatalog {
36
+ constructor({ homeDir = os.homedir(), readFile: read = readFile, openDatabase = (filePath) => new DatabaseSync(filePath, { readOnly: true }) } = {}) {
37
+ this.homeDir = homeDir;
38
+ this.readFile = read;
39
+ this.openDatabase = openDatabase;
40
+ }
41
+
42
+ async current() {
43
+ const ccSwitchPath = path.join(this.homeDir, ".cc-switch", "settings.json");
44
+ const claudePath = path.join(this.homeDir, ".claude", "settings.json");
45
+ let ccSwitch;
46
+ let claude;
47
+ try {
48
+ [ccSwitch, claude] = await Promise.all([
49
+ this.readFile(ccSwitchPath, "utf8").then(parseJson),
50
+ this.readFile(claudePath, "utf8").then(parseJson),
51
+ ]);
52
+ } catch {
53
+ return null;
54
+ }
55
+ const providerId = String(ccSwitch?.currentProviderClaude ?? "");
56
+ const activeEndpoint = String(claude?.env?.ANTHROPIC_BASE_URL ?? "");
57
+ const selectedEndpoint = this.#providerEndpoint(providerId);
58
+ const usesCcSwitchProxy = ccSwitch?.enableLocalProxy === true && isLocalProxyEndpoint(activeEndpoint);
59
+ if (!providerId || !isKimiCodingEndpoint(selectedEndpoint) || (!isKimiCodingEndpoint(activeEndpoint) && !usesCcSwitchProxy)) return null;
60
+
61
+ const configuredModel = String(claude?.env?.ANTHROPIC_MODEL ?? "");
62
+ const knownModel = KIMI_MODELS.some((entry) => entry.id === configuredModel);
63
+ return {
64
+ source: "cc-switch",
65
+ label: "CC Switch · Kimi 模型",
66
+ description: "仅影响此手机会话后续的 Claude 回合;不会修改 CC Switch 厂商、密钥或代理。",
67
+ routable: true,
68
+ current: knownModel ? { provider: KIMI_PROVIDER_ID, model: configuredModel } : null,
69
+ groups: [{ id: KIMI_PROVIDER_ID, name: "Kimi(CC Switch 当前厂商)", models: KIMI_MODELS }],
70
+ failures: [],
71
+ };
72
+ }
73
+
74
+ #providerEndpoint(providerId) {
75
+ let database;
76
+ try {
77
+ database = this.openDatabase(path.join(this.homeDir, ".cc-switch", "cc-switch.db"));
78
+ const row = database.prepare("SELECT url FROM provider_endpoints WHERE provider_id = ? AND app_type = 'claude' LIMIT 1").get(providerId);
79
+ return row?.url ?? null;
80
+ } catch {
81
+ return null;
82
+ } finally {
83
+ database?.close();
84
+ }
85
+ }
86
+ }
package/src/cli.js ADDED
@@ -0,0 +1,199 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import QRCode from "qrcode";
8
+
9
+ const sourceDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const bridgeRoot = path.resolve(sourceDir, "..");
11
+ const cliEntry = path.join(bridgeRoot, "bin", "agentbridge.js");
12
+
13
+ export function parseCliArguments(argv) {
14
+ const command = argv[0]?.startsWith("-") ? "phone" : argv.shift() ?? "phone";
15
+ const options = {};
16
+ if (command === "setup" && argv[0] && !argv[0].startsWith("-")) {
17
+ options.target = argv.shift().toLowerCase();
18
+ }
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const item = argv[index];
21
+ if (!item.startsWith("--")) throw new Error(`Unexpected argument: ${item}`);
22
+ const key = item.slice(2);
23
+ if (["json", "no-start", "user", "project", "help"].includes(key)) {
24
+ options[key] = true;
25
+ continue;
26
+ }
27
+ const value = argv[index + 1];
28
+ if (value === undefined || value.startsWith("--")) throw new Error(`Missing value for --${key}`);
29
+ options[key] = value;
30
+ index += 1;
31
+ }
32
+ return { command, options };
33
+ }
34
+
35
+ export function renderClaudePhoneSkill(entryPath = cliEntry) {
36
+ const portableEntry = entryPath.replaceAll("\\", "/").replaceAll('"', '\\"');
37
+ return `---
38
+ name: phone
39
+ description: Generate a one-time WeChat QR code bound to this exact Claude Code conversation and project directory.
40
+ disable-model-invocation: true
41
+ allowed-tools: Bash(node "${portableEntry}" phone --agent claude --session * --cwd .)
42
+ ---
43
+
44
+ # Send this Claude conversation to AgentBridge
45
+
46
+ Generate a short-lived QR code for the current conversation. The command output is authoritative; reproduce it without changing the URL or QR layout.
47
+
48
+ !\`node "${portableEntry}" phone --agent claude --session "\${CLAUDE_SESSION_ID}" --cwd .\`
49
+
50
+ Tell the user to scan the QR with WeChat. While the phone controls this session, keep this terminal idle so messages do not interleave.
51
+ `;
52
+ }
53
+
54
+ export function renderCodexPhoneSkill(entryPath = cliEntry) {
55
+ const portableEntry = entryPath.replaceAll("\\", "/").replaceAll('"', '\\"');
56
+ return `---
57
+ name: phone
58
+ description: Generate a one-time WeChat QR code for a Codex project conversation. Use only when the user explicitly invokes /phone.
59
+ disable-model-invocation: true
60
+ ---
61
+
62
+ # Send a Codex project to AgentBridge
63
+
64
+ When the user invokes \`/phone\`, you must immediately execute the following command in the terminal from the current project directory. Do not merely describe the command or ask the user to run it:
65
+
66
+ \`node "${portableEntry}" phone --agent codex --cwd .\`
67
+
68
+ After a successful command, reply with the exact QR image Markdown emitted by the command (for example, \`![微信扫码二维码](<temporary QR image URL>)\`) so Codex Desktop renders a scannable image. The temporary QR image URL and pairing URL must be shown as clickable links; then tell the user to scan the 微信扫码二维码 with WeChat. Do not invent or alter either URL. If the command fails, show its exact safe error and do not claim that a QR code was generated.
69
+
70
+ If the user supplies a known persisted Codex thread ID, append \`--session <thread-id>\` to resume that exact thread. Do not claim that the command attached the current desktop conversation when no thread ID was supplied: Codex does not expose a documented current-thread substitution to this local skill.
71
+ `;
72
+ }
73
+
74
+ function help() {
75
+ return `AgentBridge phone launcher
76
+
77
+ Usage:
78
+ agentbridge phone [--agent claude|codex|echo] [--session ID] [--cwd PATH] [--title TEXT]
79
+ agentbridge setup <codex|claude> [--user|--project] [--cwd PATH]
80
+ agentbridge install-claude-skill [--user|--project] [--cwd PATH]
81
+ agentbridge install-codex-skill [--user|--project] [--cwd PATH]
82
+
83
+ Examples:
84
+ agentbridge phone --agent codex
85
+ agentbridge phone --agent claude --session <claude-session-id>
86
+ agentbridge setup codex
87
+ agentbridge install-claude-skill --user`;
88
+ }
89
+
90
+ async function bridgeIsReady(origin) {
91
+ try {
92
+ const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(700) });
93
+ const body = response.ok ? await response.json() : null;
94
+ return body?.ok === true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async function ensureBridge(origin, autoStart) {
101
+ if (await bridgeIsReady(origin)) return;
102
+ if (!autoStart) throw new Error(`Bridge is not running at ${origin}`);
103
+ const child = spawn(process.execPath, [path.join(bridgeRoot, "src", "server.js")], {
104
+ cwd: bridgeRoot,
105
+ detached: true,
106
+ stdio: "ignore",
107
+ windowsHide: true,
108
+ });
109
+ child.unref();
110
+ for (let attempt = 0; attempt < 30; attempt += 1) {
111
+ await new Promise((resolve) => setTimeout(resolve, 100));
112
+ if (await bridgeIsReady(origin)) return;
113
+ }
114
+ throw new Error(`Could not start Bridge at ${origin}`);
115
+ }
116
+
117
+ async function requestPhone(options) {
118
+ const port = process.env.AGENTBRIDGE_PORT ?? "8787";
119
+ const origin = (process.env.AGENTBRIDGE_LOCAL_ORIGIN ?? `http://127.0.0.1:${port}`).replace(/\/$/, "");
120
+ await ensureBridge(origin, !options["no-start"]);
121
+ const cwd = path.resolve(options.cwd ?? process.cwd());
122
+ const adapterId = options.agent ?? "claude";
123
+ const response = await fetch(`${origin}/api/local/phone`, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify({
127
+ cwd,
128
+ adapterId,
129
+ providerSessionId: options.session || undefined,
130
+ title: options.title || `${path.basename(cwd)} · ${adapterId}`,
131
+ commandId: randomUUID(),
132
+ }),
133
+ });
134
+ const result = await response.json().catch(() => ({}));
135
+ if (!response.ok) throw new Error(result.error ?? `Phone pairing failed (${response.status})`);
136
+
137
+ if (options.json) {
138
+ console.log(JSON.stringify(result));
139
+ return result;
140
+ }
141
+ const qr = await QRCode.toString(result.pairing.url, { type: "terminal", small: true, errorCorrectionLevel: "M" });
142
+ console.log(`\nAgentBridge 已绑定当前对话`);
143
+ console.log(`Agent: ${adapterId}`);
144
+ console.log(`项目: ${cwd}`);
145
+ console.log(`会话: ${result.session.providerSessionId ?? result.session.id}`);
146
+ console.log(`有效期: ${new Date(result.pairing.expiresAt).toLocaleTimeString()}(一次性)\n`);
147
+ console.log(qr);
148
+ if (result.qrImageUrl) {
149
+ console.log(`临时二维码图片地址(仅本机;扫码、配对成功或到期后失效): ${result.qrImageUrl}`);
150
+ console.log(`二维码图片 Markdown: ![微信扫码二维码](${result.qrImageUrl})`);
151
+ }
152
+ console.log(`微信扫码地址: ${result.pairing.url}`);
153
+ console.log(`电脑端会话: ${origin}/?session=${encodeURIComponent(result.session.id)}\n`);
154
+ return result;
155
+ }
156
+
157
+ async function installClaudeSkill(options) {
158
+ if (options.user && options.project) throw new Error("Choose only one of --user or --project");
159
+ const projectCwd = path.resolve(options.cwd ?? process.cwd());
160
+ const root = options.project
161
+ ? path.join(projectCwd, ".claude", "skills")
162
+ : path.join(os.homedir(), ".claude", "skills");
163
+ const target = path.join(root, "phone", "SKILL.md");
164
+ await mkdir(path.dirname(target), { recursive: true });
165
+ await writeFile(target, renderClaudePhoneSkill(), { encoding: "utf8", flag: "w" });
166
+ console.log(`Claude Code /phone 已安装到: ${target}`);
167
+ console.log("重新打开 Claude Code 后,输入 /phone 即可生成当前对话的微信二维码。");
168
+ return target;
169
+ }
170
+
171
+ async function installCodexSkill(options) {
172
+ if (options.user && options.project) throw new Error("Choose only one of --user or --project");
173
+ const projectCwd = path.resolve(options.cwd ?? process.cwd());
174
+ const root = options.project
175
+ ? path.join(projectCwd, ".agents", "skills")
176
+ : path.join(os.homedir(), ".agents", "skills");
177
+ const target = path.join(root, "phone", "SKILL.md");
178
+ await mkdir(path.dirname(target), { recursive: true });
179
+ await writeFile(target, renderCodexPhoneSkill(), { encoding: "utf8", flag: "w" });
180
+ console.log(`Codex /phone 已安装到: ${target}`);
181
+ console.log("重新打开 Codex 桌面项目后,在斜杠命令列表选择 /phone。未提供 thread ID 时,它会创建新的项目会话并清楚标注。");
182
+ return target;
183
+ }
184
+
185
+ async function setup(options) {
186
+ if (options.target === "codex") return installCodexSkill(options);
187
+ if (options.target === "claude") return installClaudeSkill(options);
188
+ throw new Error("Setup target must be one of: codex, claude");
189
+ }
190
+
191
+ export async function main(inputArgs) {
192
+ const { command, options } = parseCliArguments([...inputArgs]);
193
+ if (command === "help" || options.help) return console.log(help());
194
+ if (command === "phone" || command === "qr") return requestPhone(options);
195
+ if (command === "setup") return setup(options);
196
+ if (command === "install-claude-skill") return installClaudeSkill(options);
197
+ if (command === "install-codex-skill") return installCodexSkill(options);
198
+ throw new Error(`Unknown command: ${command}\n\n${help()}`);
199
+ }
package/src/config.js ADDED
@@ -0,0 +1,127 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+
5
+ function firstLanAddress() {
6
+ for (const entries of Object.values(os.networkInterfaces())) {
7
+ for (const entry of entries ?? []) {
8
+ if (entry.family === "IPv4" && !entry.internal) return entry.address;
9
+ }
10
+ }
11
+ return "127.0.0.1";
12
+ }
13
+
14
+ function parseProjects(raw, workspace) {
15
+ if (!raw) return new Map([["workspace", path.resolve(workspace)]]);
16
+
17
+ const projects = new Map();
18
+ for (const part of raw.split(";")) {
19
+ const separator = part.indexOf("=");
20
+ if (separator < 1) throw new Error(`Invalid AGENTBRIDGE_PROJECTS entry: ${part}`);
21
+ const alias = part.slice(0, separator).trim();
22
+ const projectPath = part.slice(separator + 1).trim();
23
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(alias) || !projectPath) {
24
+ throw new Error(`Invalid project alias or path: ${part}`);
25
+ }
26
+ projects.set(alias, path.resolve(projectPath));
27
+ }
28
+ if (projects.size === 0) throw new Error("At least one allowed project is required");
29
+ return projects;
30
+ }
31
+
32
+ /**
33
+ * Notification settings can also live in `.agentbridge/notify.json`.
34
+ *
35
+ * Environment variables are awkward to keep on Windows (a double-clicked launcher
36
+ * inherits no shell environment), and a webhook token is a credential that must not
37
+ * end up in a tracked file. The bridge's own data directory is git-ignored, so a
38
+ * small JSON file there is the honest place for it; environment variables still win
39
+ * when both are present, which keeps `setx` and one-off experiments working.
40
+ */
41
+ function readNotifyFile(dataDir) {
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(path.join(dataDir, "notify.json"), "utf8"));
44
+ return parsed && typeof parsed === "object" ? parsed : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function pick(env, key, fallback) {
51
+ const value = String(env[key] ?? "").trim();
52
+ return value || fallback || null;
53
+ }
54
+
55
+ /** The persisted block, for callers that need to read it directly. */
56
+ export function readNotifyConfig(dataDir) {
57
+ return readNotifyFile(dataDir);
58
+ }
59
+
60
+ /**
61
+ * Patch `.agentbridge/notify.json` without losing the rest of it.
62
+ *
63
+ * Written atomically (tmp + rename) because a half-written file after a power loss
64
+ * would silently disable notifications; the merge is what keeps the token intact when
65
+ * only `muted` changes.
66
+ */
67
+ export function writeNotifyConfig(dataDir, patch) {
68
+ const next = { ...readNotifyFile(dataDir), ...patch };
69
+ mkdirSync(dataDir, { recursive: true });
70
+ const target = path.join(dataDir, "notify.json");
71
+ const temporary = `${target}.tmp`;
72
+ writeFileSync(temporary, JSON.stringify(next, null, 2), "utf8");
73
+ renameSync(temporary, target);
74
+ return next;
75
+ }
76
+
77
+ export function loadConfig(env = process.env, workspace = process.cwd()) {
78
+ const port = Number.parseInt(env.AGENTBRIDGE_PORT ?? "8787", 10);
79
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
80
+ throw new Error("AGENTBRIDGE_PORT must be a valid TCP port");
81
+ }
82
+
83
+ const host = env.AGENTBRIDGE_HOST ?? "0.0.0.0";
84
+ const publicOrigin = (env.AGENTBRIDGE_PUBLIC_ORIGIN ?? "").replace(/\/$/, "");
85
+ const advertisedOrigin = publicOrigin || `http://${firstLanAddress()}:${port}`;
86
+ const accessKey = (env.AGENTBRIDGE_ACCESS_KEY ?? "").trim() || null;
87
+ const dataDir = path.resolve(env.AGENTBRIDGE_DATA_DIR ?? path.join(workspace, ".agentbridge"));
88
+ const notifyFile = readNotifyFile(dataDir);
89
+
90
+ return {
91
+ host,
92
+ port,
93
+ advertisedOrigin,
94
+ accessKey,
95
+ secureCookies: advertisedOrigin.startsWith("https://"),
96
+ dataDir,
97
+ projects: parseProjects(env.AGENTBRIDGE_PROJECTS, workspace),
98
+ pairingTtlMs: Number.parseInt(env.AGENTBRIDGE_PAIRING_TTL_MS ?? "300000", 10),
99
+ sessionTtlMs: Number.parseInt(env.AGENTBRIDGE_SESSION_TTL_MS ?? "259200000", 10),
100
+ machineName: env.AGENTBRIDGE_MACHINE_NAME ?? os.hostname(),
101
+ // Outbound notifications: unset webhook means the feature is simply off.
102
+ notifyWebhook: pick(env, "AGENTBRIDGE_NOTIFY_WEBHOOK", notifyFile.webhook),
103
+ notifyFormat: pick(env, "AGENTBRIDGE_NOTIFY_FORMAT", notifyFile.format ?? "json").toLowerCase(),
104
+ notifyChatId: pick(env, "AGENTBRIDGE_NOTIFY_CHAT_ID", notifyFile.chatId),
105
+ // PushPlus-style services need the token inside the request body.
106
+ notifyToken: pick(env, "AGENTBRIDGE_NOTIFY_TOKEN", notifyFile.token),
107
+ // Some providers (PushPlus) also take a delivery channel, e.g. `clawbot`.
108
+ notifyChannel: pick(env, "AGENTBRIDGE_NOTIFY_CHANNEL", notifyFile.channel),
109
+ // Per-bridge mute, toggled from the phone; persisted so it survives a restart.
110
+ notifyMuted: notifyFile.muted === true,
111
+ // Opt-in: putting the access key into a third-party chat is a real disclosure,
112
+ // so the notification link stays keyless unless the operator asks for it.
113
+ notifyLink: notifyFile.link === true || pick(env, "AGENTBRIDGE_NOTIFY_LINK", null) === "1",
114
+ };
115
+ }
116
+
117
+ /** The URL a notification should offer; never includes the access key by default. */
118
+ export function notificationLink(config) {
119
+ if (!config?.advertisedOrigin) return null;
120
+ const base = config.advertisedOrigin;
121
+ if (!config.notifyLink) return base;
122
+ return config.accessKey ? `${base}/?key=${encodeURIComponent(config.accessKey)}` : base;
123
+ }
124
+
125
+ export function isLoopback(address = "") {
126
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
127
+ }