@hasna/bridge 0.1.1 → 0.2.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.
- package/README.md +158 -1
- package/dist/cli/index.js +1602 -92
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1352 -66
- package/dist/lib/agents.d.ts +14 -1
- package/dist/lib/agents.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/daemon.d.ts +123 -0
- package/dist/lib/daemon.d.ts.map +1 -0
- package/dist/lib/doctor.d.ts.map +1 -1
- package/dist/lib/imessage.d.ts +36 -0
- package/dist/lib/imessage.d.ts.map +1 -0
- package/dist/lib/router.d.ts.map +1 -1
- package/dist/lib/sessions.d.ts +48 -0
- package/dist/lib/sessions.d.ts.map +1 -0
- package/dist/lib/state.d.ts +8 -1
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/telegram.d.ts +7 -0
- package/dist/lib/telegram.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +855 -40
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/architecture.md +100 -15
- package/docs/session-bridge-plan.md +204 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2064,7 +2064,7 @@ var require_commander = __commonJS((exports) => {
|
|
|
2064
2064
|
|
|
2065
2065
|
// src/cli/index.ts
|
|
2066
2066
|
import { readFileSync } from "fs";
|
|
2067
|
-
import { dirname as
|
|
2067
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
2068
2068
|
import { fileURLToPath } from "url";
|
|
2069
2069
|
|
|
2070
2070
|
// node_modules/commander/esm.mjs
|
|
@@ -2103,6 +2103,11 @@ function mergeEnv(profile, agent) {
|
|
|
2103
2103
|
env["HOME"] = profile.home;
|
|
2104
2104
|
return Object.keys(env).length ? env : undefined;
|
|
2105
2105
|
}
|
|
2106
|
+
function compatibilityDetail(kind) {
|
|
2107
|
+
if (kind === "shell")
|
|
2108
|
+
return "shell command session; local bridge state is durable";
|
|
2109
|
+
return "compatibility mode: this adapter invokes the current CLI one message at a time until a stable create/send/resume API is wired";
|
|
2110
|
+
}
|
|
2106
2111
|
function resolveAgent(config, agentId) {
|
|
2107
2112
|
const agent = config.agents[agentId];
|
|
2108
2113
|
if (!agent)
|
|
@@ -2121,7 +2126,7 @@ function buildAgentCommand(config, agentId, input) {
|
|
|
2121
2126
|
const kind = agent.kind;
|
|
2122
2127
|
const command = agent.command || profile?.command;
|
|
2123
2128
|
const args = agent.args || profile?.args;
|
|
2124
|
-
const cwd = agent.cwd || profile?.cwd;
|
|
2129
|
+
const cwd = input.session?.cwd || agent.cwd || profile?.cwd;
|
|
2125
2130
|
const env = mergeEnv(profile, agent);
|
|
2126
2131
|
if (command) {
|
|
2127
2132
|
return { command: [command, ...renderCustomArgs(args, prompt)], cwd, env };
|
|
@@ -2143,6 +2148,32 @@ function buildAgentCommand(config, agentId, input) {
|
|
|
2143
2148
|
}
|
|
2144
2149
|
return { command: ["sh", "-lc", prompt], cwd, env };
|
|
2145
2150
|
}
|
|
2151
|
+
function createAgentSessionRef(config, agentId) {
|
|
2152
|
+
const { agent } = resolveAgent(config, agentId);
|
|
2153
|
+
const timestamp = new Date().toISOString();
|
|
2154
|
+
return {
|
|
2155
|
+
kind: agent.kind,
|
|
2156
|
+
mode: "compatibility",
|
|
2157
|
+
createdAt: timestamp,
|
|
2158
|
+
updatedAt: timestamp,
|
|
2159
|
+
detail: compatibilityDetail(agent.kind)
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
function closeAgentSession(session) {
|
|
2163
|
+
return {
|
|
2164
|
+
supported: session.agentSession?.mode === "durable",
|
|
2165
|
+
ref: session.agentSession,
|
|
2166
|
+
detail: session.agentSession?.mode === "durable" ? "durable close is adapter-owned" : "compatibility close only updates bridge session state"
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
async function sendAgentSessionMessage(config, session, message, options = {}) {
|
|
2170
|
+
const run = options.run || runAgent;
|
|
2171
|
+
return run(config, session.agentId, {
|
|
2172
|
+
message,
|
|
2173
|
+
route: { id: `session:${session.id}`, fromChannel: message.channelId, toAgent: session.agentId },
|
|
2174
|
+
session
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2146
2177
|
async function runAgent(config, agentId, input) {
|
|
2147
2178
|
const { agent } = resolveAgent(config, agentId);
|
|
2148
2179
|
const built = buildAgentCommand(config, agentId, input);
|
|
@@ -6200,7 +6231,14 @@ var channelSchema = exports_external.discriminatedUnion("kind", [
|
|
|
6200
6231
|
kind: exports_external.literal("imessage"),
|
|
6201
6232
|
label: exports_external.string().optional(),
|
|
6202
6233
|
enabled: exports_external.boolean().optional(),
|
|
6203
|
-
account: exports_external.string().optional()
|
|
6234
|
+
account: exports_external.string().optional(),
|
|
6235
|
+
serviceName: exports_external.string().optional(),
|
|
6236
|
+
defaultHandle: exports_external.string().optional(),
|
|
6237
|
+
allowedHandles: exports_external.array(exports_external.string()).optional(),
|
|
6238
|
+
allowAllHandles: exports_external.boolean().optional(),
|
|
6239
|
+
receiveMode: exports_external.enum(["disabled", "chat-db"]).optional(),
|
|
6240
|
+
chatDbPath: exports_external.string().optional(),
|
|
6241
|
+
pollLimit: exports_external.number().int().positive().max(500).optional()
|
|
6204
6242
|
})
|
|
6205
6243
|
]);
|
|
6206
6244
|
var envSchema = exports_external.record(exports_external.string(), exports_external.string());
|
|
@@ -6304,25 +6342,44 @@ async function upsertRoute(route, configPath = defaultConfigPath()) {
|
|
|
6304
6342
|
await saveConfig(config, configPath);
|
|
6305
6343
|
return config;
|
|
6306
6344
|
}
|
|
6307
|
-
// src/lib/
|
|
6308
|
-
import {
|
|
6345
|
+
// src/lib/daemon.ts
|
|
6346
|
+
import { spawn } from "child_process";
|
|
6347
|
+
import { closeSync, openSync } from "fs";
|
|
6348
|
+
import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
|
|
6349
|
+
import { dirname as dirname3, join as join3, resolve } from "path";
|
|
6309
6350
|
|
|
6310
6351
|
// src/lib/state.ts
|
|
6311
6352
|
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
6312
6353
|
import { dirname as dirname2, join as join2 } from "path";
|
|
6354
|
+
var STATE_SCHEMA_VERSION = 2;
|
|
6313
6355
|
function defaultStatePath() {
|
|
6314
6356
|
return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
|
|
6315
6357
|
}
|
|
6316
6358
|
function emptyState() {
|
|
6317
|
-
return {
|
|
6359
|
+
return {
|
|
6360
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
6361
|
+
telegramOffsets: {},
|
|
6362
|
+
sessions: {},
|
|
6363
|
+
bindings: {},
|
|
6364
|
+
messageLedger: {},
|
|
6365
|
+
cursors: {}
|
|
6366
|
+
};
|
|
6367
|
+
}
|
|
6368
|
+
function normalizeState(value) {
|
|
6369
|
+
return {
|
|
6370
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
6371
|
+
telegramOffsets: value.telegramOffsets && typeof value.telegramOffsets === "object" ? value.telegramOffsets : {},
|
|
6372
|
+
sessions: value.sessions && typeof value.sessions === "object" ? value.sessions : {},
|
|
6373
|
+
bindings: value.bindings && typeof value.bindings === "object" ? value.bindings : {},
|
|
6374
|
+
messageLedger: value.messageLedger && typeof value.messageLedger === "object" ? value.messageLedger : {},
|
|
6375
|
+
cursors: value.cursors && typeof value.cursors === "object" ? value.cursors : {}
|
|
6376
|
+
};
|
|
6318
6377
|
}
|
|
6319
6378
|
async function loadState(statePath = defaultStatePath()) {
|
|
6320
6379
|
try {
|
|
6321
6380
|
const raw = await readFile2(statePath, "utf-8");
|
|
6322
6381
|
const parsed = JSON.parse(raw);
|
|
6323
|
-
return
|
|
6324
|
-
telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
|
|
6325
|
-
};
|
|
6382
|
+
return normalizeState(parsed);
|
|
6326
6383
|
} catch (err) {
|
|
6327
6384
|
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
6328
6385
|
return emptyState();
|
|
@@ -6331,71 +6388,44 @@ async function loadState(statePath = defaultStatePath()) {
|
|
|
6331
6388
|
}
|
|
6332
6389
|
}
|
|
6333
6390
|
async function saveState(state, statePath = defaultStatePath()) {
|
|
6391
|
+
const normalized = normalizeState(state);
|
|
6334
6392
|
await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
|
|
6335
|
-
await writeFile2(statePath, `${JSON.stringify(
|
|
6393
|
+
await writeFile2(statePath, `${JSON.stringify(normalized, null, 2)}
|
|
6336
6394
|
`, { encoding: "utf-8", mode: 384 });
|
|
6337
6395
|
await chmod2(statePath, 384);
|
|
6338
6396
|
}
|
|
6339
6397
|
|
|
6340
|
-
// src/lib/
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
const mode = info.mode & 511;
|
|
6348
|
-
const ok = (mode & 63) === 0;
|
|
6349
|
-
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
6350
|
-
} catch (err) {
|
|
6351
|
-
if (isNotFound(err))
|
|
6352
|
-
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
6353
|
-
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
6354
|
-
}
|
|
6355
|
-
}
|
|
6356
|
-
async function commandExists(command) {
|
|
6357
|
-
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
6358
|
-
stdout: "ignore",
|
|
6359
|
-
stderr: "ignore"
|
|
6360
|
-
});
|
|
6361
|
-
return await proc.exited === 0;
|
|
6362
|
-
}
|
|
6363
|
-
async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
|
|
6364
|
-
const checks = [];
|
|
6365
|
-
let config = await loadConfig(configPath);
|
|
6366
|
-
checks.push(await privateFileCheck("config", configPath));
|
|
6367
|
-
checks.push(await privateFileCheck("state", statePath));
|
|
6368
|
-
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
6369
|
-
checks.push({
|
|
6370
|
-
name: `command:${command}`,
|
|
6371
|
-
ok: command === "bridge" ? true : await commandExists(command),
|
|
6372
|
-
detail: command === "bridge" ? "current package" : undefined
|
|
6373
|
-
});
|
|
6398
|
+
// src/lib/telegram.ts
|
|
6399
|
+
var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
6400
|
+
function telegramApiBase() {
|
|
6401
|
+
const raw = process.env["BRIDGE_TELEGRAM_API_BASE"] || DEFAULT_TELEGRAM_API_BASE;
|
|
6402
|
+
const parsed = new URL(raw);
|
|
6403
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
6404
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must use http or https");
|
|
6374
6405
|
}
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
6378
|
-
checks.push({
|
|
6379
|
-
name: `telegram-token:${channel.id}`,
|
|
6380
|
-
ok: Boolean(process.env[envName]),
|
|
6381
|
-
detail: envName
|
|
6382
|
-
});
|
|
6383
|
-
checks.push({
|
|
6384
|
-
name: `telegram-allowlist:${channel.id}`,
|
|
6385
|
-
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
6386
|
-
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
6387
|
-
});
|
|
6406
|
+
if (parsed.username || parsed.password) {
|
|
6407
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain credentials");
|
|
6388
6408
|
}
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
name: `route:${route.id}`,
|
|
6392
|
-
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
6393
|
-
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
6394
|
-
});
|
|
6409
|
+
if (parsed.search || parsed.hash) {
|
|
6410
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain query strings or fragments");
|
|
6395
6411
|
}
|
|
6396
|
-
return
|
|
6412
|
+
return parsed;
|
|
6413
|
+
}
|
|
6414
|
+
function telegramApiBaseInfo() {
|
|
6415
|
+
const parsed = telegramApiBase();
|
|
6416
|
+
return {
|
|
6417
|
+
overridden: parsed.href.replace(/\/$/, "") !== DEFAULT_TELEGRAM_API_BASE,
|
|
6418
|
+
origin: parsed.origin,
|
|
6419
|
+
pathname: parsed.pathname
|
|
6420
|
+
};
|
|
6421
|
+
}
|
|
6422
|
+
function telegramMethodUrl(token, method) {
|
|
6423
|
+
const base = telegramApiBase();
|
|
6424
|
+
const prefix = base.pathname.replace(/\/$/, "");
|
|
6425
|
+
base.pathname = `${prefix}/bot${token}/${method}`;
|
|
6426
|
+
base.search = "";
|
|
6427
|
+
return base.toString();
|
|
6397
6428
|
}
|
|
6398
|
-
// src/lib/telegram.ts
|
|
6399
6429
|
function telegramToken(channel) {
|
|
6400
6430
|
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
6401
6431
|
const token = process.env[envName];
|
|
@@ -6411,7 +6441,7 @@ function telegramChatAllowed(channel, chatId) {
|
|
|
6411
6441
|
return Boolean(chatId && channel.allowedChatIds.includes(chatId));
|
|
6412
6442
|
}
|
|
6413
6443
|
async function sendTelegramMessage(token, chatId, text) {
|
|
6414
|
-
const response = await fetch(
|
|
6444
|
+
const response = await fetch(telegramMethodUrl(token, "sendMessage"), {
|
|
6415
6445
|
method: "POST",
|
|
6416
6446
|
headers: { "content-type": "application/json" },
|
|
6417
6447
|
body: JSON.stringify({ chat_id: chatId, text })
|
|
@@ -6428,7 +6458,7 @@ async function getTelegramUpdates(token, options = {}) {
|
|
|
6428
6458
|
if (options.offset !== undefined)
|
|
6429
6459
|
params.set("offset", String(options.offset));
|
|
6430
6460
|
params.set("timeout", String(options.timeoutSeconds ?? 20));
|
|
6431
|
-
const response = await fetch(
|
|
6461
|
+
const response = await fetch(`${telegramMethodUrl(token, "getUpdates")}?${params.toString()}`);
|
|
6432
6462
|
const body = await response.json().catch(() => {
|
|
6433
6463
|
return;
|
|
6434
6464
|
});
|
|
@@ -6447,12 +6477,854 @@ function telegramUpdateToMessage(channelId, update) {
|
|
|
6447
6477
|
channelId,
|
|
6448
6478
|
text,
|
|
6449
6479
|
chatId: String(chatId),
|
|
6480
|
+
threadId: update.message?.message_thread_id !== undefined ? String(update.message.message_thread_id) : undefined,
|
|
6450
6481
|
from: update.message?.from?.username || (update.message?.from?.id !== undefined ? String(update.message.from.id) : undefined),
|
|
6451
6482
|
receivedAt: update.message?.date ? new Date(update.message.date * 1000).toISOString() : new Date().toISOString(),
|
|
6452
6483
|
raw: update
|
|
6453
6484
|
};
|
|
6454
6485
|
}
|
|
6455
6486
|
|
|
6487
|
+
// src/lib/daemon.ts
|
|
6488
|
+
function isNotFound(err) {
|
|
6489
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
6490
|
+
}
|
|
6491
|
+
function currentPlatformSupervisor() {
|
|
6492
|
+
if (process.platform === "darwin")
|
|
6493
|
+
return "launchd";
|
|
6494
|
+
if (process.platform === "linux")
|
|
6495
|
+
return "systemd";
|
|
6496
|
+
return "process";
|
|
6497
|
+
}
|
|
6498
|
+
function resolveSupervisor(supervisor = "process") {
|
|
6499
|
+
return supervisor === "auto" ? currentPlatformSupervisor() : supervisor;
|
|
6500
|
+
}
|
|
6501
|
+
function defaultDaemonDir() {
|
|
6502
|
+
return join3(bridgeHome(), "daemon");
|
|
6503
|
+
}
|
|
6504
|
+
function daemonPaths(daemonDir = defaultDaemonDir()) {
|
|
6505
|
+
const dir = resolve(daemonDir);
|
|
6506
|
+
return {
|
|
6507
|
+
dir,
|
|
6508
|
+
lockDir: join3(dir, "lock"),
|
|
6509
|
+
metadataFile: join3(dir, "bridge-daemon.json"),
|
|
6510
|
+
stdoutLog: join3(dir, "bridge.out.log"),
|
|
6511
|
+
stderrLog: join3(dir, "bridge.err.log"),
|
|
6512
|
+
launchdPlist: join3(process.env["HOME"] || process.cwd(), "Library", "LaunchAgents", "com.hasna.bridge.plist"),
|
|
6513
|
+
systemdUnit: join3(process.env["HOME"] || process.cwd(), ".config", "systemd", "user", "hasna-bridge.service")
|
|
6514
|
+
};
|
|
6515
|
+
}
|
|
6516
|
+
async function ensureDaemonDir(dir = defaultDaemonDir()) {
|
|
6517
|
+
const paths = daemonPaths(dir);
|
|
6518
|
+
await mkdir3(paths.dir, { recursive: true, mode: 448 });
|
|
6519
|
+
await chmod3(paths.dir, 448);
|
|
6520
|
+
return paths;
|
|
6521
|
+
}
|
|
6522
|
+
async function fileExists(path) {
|
|
6523
|
+
try {
|
|
6524
|
+
await stat(path);
|
|
6525
|
+
return true;
|
|
6526
|
+
} catch (err) {
|
|
6527
|
+
if (isNotFound(err))
|
|
6528
|
+
return false;
|
|
6529
|
+
throw err;
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
async function readMetadata(paths) {
|
|
6533
|
+
try {
|
|
6534
|
+
return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
|
|
6535
|
+
} catch (err) {
|
|
6536
|
+
if (isNotFound(err))
|
|
6537
|
+
return;
|
|
6538
|
+
throw err;
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
async function writeMetadata(paths, metadata) {
|
|
6542
|
+
const tmp = `${paths.metadataFile}.${process.pid}.${Date.now()}.tmp`;
|
|
6543
|
+
await writeFile3(tmp, `${JSON.stringify(metadata, null, 2)}
|
|
6544
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
6545
|
+
await chmod3(tmp, 384);
|
|
6546
|
+
await rename(tmp, paths.metadataFile);
|
|
6547
|
+
await chmod3(paths.metadataFile, 384);
|
|
6548
|
+
}
|
|
6549
|
+
async function withDaemonLock(paths, fn) {
|
|
6550
|
+
try {
|
|
6551
|
+
await mkdir3(paths.lockDir, { mode: 448 });
|
|
6552
|
+
} catch (err) {
|
|
6553
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EEXIST") {
|
|
6554
|
+
throw new Error(`Another bridge daemon operation is already running: ${paths.lockDir}`);
|
|
6555
|
+
}
|
|
6556
|
+
throw err;
|
|
6557
|
+
}
|
|
6558
|
+
try {
|
|
6559
|
+
return await fn();
|
|
6560
|
+
} finally {
|
|
6561
|
+
await rmdir(paths.lockDir).catch(() => {
|
|
6562
|
+
return;
|
|
6563
|
+
});
|
|
6564
|
+
}
|
|
6565
|
+
}
|
|
6566
|
+
function pidAlive(pid) {
|
|
6567
|
+
try {
|
|
6568
|
+
process.kill(pid, 0);
|
|
6569
|
+
return true;
|
|
6570
|
+
} catch {
|
|
6571
|
+
return false;
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
async function processCommand(pid) {
|
|
6575
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], {
|
|
6576
|
+
stdout: "pipe",
|
|
6577
|
+
stderr: "ignore"
|
|
6578
|
+
});
|
|
6579
|
+
if (await proc.exited !== 0)
|
|
6580
|
+
return;
|
|
6581
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
6582
|
+
}
|
|
6583
|
+
async function processPgid(pid) {
|
|
6584
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pgid="], {
|
|
6585
|
+
stdout: "pipe",
|
|
6586
|
+
stderr: "ignore"
|
|
6587
|
+
});
|
|
6588
|
+
if (await proc.exited !== 0)
|
|
6589
|
+
return;
|
|
6590
|
+
const parsed = Number.parseInt((await new Response(proc.stdout).text()).trim(), 10);
|
|
6591
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
6592
|
+
}
|
|
6593
|
+
function shellQuote(value) {
|
|
6594
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
6595
|
+
}
|
|
6596
|
+
function commandPattern(command) {
|
|
6597
|
+
return command.map(shellQuote).join(" ");
|
|
6598
|
+
}
|
|
6599
|
+
async function processMatches(metadata) {
|
|
6600
|
+
if (!pidAlive(metadata.pid))
|
|
6601
|
+
return false;
|
|
6602
|
+
const command = await processCommand(metadata.pid);
|
|
6603
|
+
if (!command)
|
|
6604
|
+
return false;
|
|
6605
|
+
if (!metadata.pgid)
|
|
6606
|
+
return false;
|
|
6607
|
+
const pgid = await processPgid(metadata.pid);
|
|
6608
|
+
if (pgid !== metadata.pgid)
|
|
6609
|
+
return false;
|
|
6610
|
+
const requiredArgs = [
|
|
6611
|
+
metadata.command[1],
|
|
6612
|
+
"serve",
|
|
6613
|
+
"--config",
|
|
6614
|
+
metadata.configPath,
|
|
6615
|
+
"--state",
|
|
6616
|
+
metadata.statePath,
|
|
6617
|
+
"--interval",
|
|
6618
|
+
String(metadata.intervalMs)
|
|
6619
|
+
].filter((arg) => Boolean(arg));
|
|
6620
|
+
if (metadata.serveJson)
|
|
6621
|
+
requiredArgs.push("--json");
|
|
6622
|
+
return requiredArgs.every((arg) => command.includes(arg));
|
|
6623
|
+
}
|
|
6624
|
+
async function removeMetadata(paths) {
|
|
6625
|
+
await rm(paths.metadataFile, { force: true });
|
|
6626
|
+
}
|
|
6627
|
+
function safeTelegramApiBaseInfo() {
|
|
6628
|
+
try {
|
|
6629
|
+
return telegramApiBaseInfo();
|
|
6630
|
+
} catch (err) {
|
|
6631
|
+
return {
|
|
6632
|
+
overridden: true,
|
|
6633
|
+
origin: "",
|
|
6634
|
+
pathname: "",
|
|
6635
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6636
|
+
};
|
|
6637
|
+
}
|
|
6638
|
+
}
|
|
6639
|
+
function startCommand(options) {
|
|
6640
|
+
const scriptPath = process.argv[1];
|
|
6641
|
+
const base = scriptPath ? [process.execPath, scriptPath] : ["bridge"];
|
|
6642
|
+
const command = [
|
|
6643
|
+
...base,
|
|
6644
|
+
"serve",
|
|
6645
|
+
"--config",
|
|
6646
|
+
options.configPath,
|
|
6647
|
+
"--state",
|
|
6648
|
+
options.statePath,
|
|
6649
|
+
"--interval",
|
|
6650
|
+
String(options.intervalMs)
|
|
6651
|
+
];
|
|
6652
|
+
if (options.serveJson)
|
|
6653
|
+
command.push("--json");
|
|
6654
|
+
return command;
|
|
6655
|
+
}
|
|
6656
|
+
function telegramChannels(config) {
|
|
6657
|
+
return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
|
|
6658
|
+
}
|
|
6659
|
+
function imessagePollChannels(config) {
|
|
6660
|
+
return Object.values(config.channels).filter((channel) => channel.kind === "imessage" && channel.enabled !== false && channel.receiveMode === "chat-db");
|
|
6661
|
+
}
|
|
6662
|
+
function requiredTelegramEnvVars(config) {
|
|
6663
|
+
return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
|
|
6664
|
+
}
|
|
6665
|
+
async function validateStartConfig(configPath) {
|
|
6666
|
+
const config = await loadConfig(configPath);
|
|
6667
|
+
const channels = [...telegramChannels(config), ...imessagePollChannels(config)];
|
|
6668
|
+
if (!channels.length)
|
|
6669
|
+
throw new Error("No enabled pollable channels configured; add Telegram or iMessage receive before starting the daemon");
|
|
6670
|
+
for (const envName of requiredTelegramEnvVars(config)) {
|
|
6671
|
+
if (!process.env[envName])
|
|
6672
|
+
throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
|
|
6673
|
+
}
|
|
6674
|
+
}
|
|
6675
|
+
function openPrivateLog(path) {
|
|
6676
|
+
const fd = openSync(path, "a", 384);
|
|
6677
|
+
return fd;
|
|
6678
|
+
}
|
|
6679
|
+
async function ensurePrivateLogFiles(paths) {
|
|
6680
|
+
for (const path of [paths.stdoutLog, paths.stderrLog]) {
|
|
6681
|
+
const fd = openPrivateLog(path);
|
|
6682
|
+
closeSync(fd);
|
|
6683
|
+
await chmod3(path, 384);
|
|
6684
|
+
}
|
|
6685
|
+
}
|
|
6686
|
+
async function runCapture(command) {
|
|
6687
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
6688
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
6689
|
+
proc.exited,
|
|
6690
|
+
new Response(proc.stdout).text(),
|
|
6691
|
+
new Response(proc.stderr).text()
|
|
6692
|
+
]);
|
|
6693
|
+
return { exitCode, stdout, stderr };
|
|
6694
|
+
}
|
|
6695
|
+
async function installedSupervisorStatus(supervisor, paths) {
|
|
6696
|
+
if (supervisor === "launchd") {
|
|
6697
|
+
if (!await fileExists(paths.launchdPlist))
|
|
6698
|
+
return { running: false, detail: "launchd plist not installed" };
|
|
6699
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
6700
|
+
if (uid === undefined)
|
|
6701
|
+
return { running: false, detail: "launchd status requires a numeric uid" };
|
|
6702
|
+
const result = await runCapture(["launchctl", "print", `gui/${uid}/com.hasna.bridge`]);
|
|
6703
|
+
if (result.exitCode !== 0)
|
|
6704
|
+
return { running: false, detail: result.stderr.trim() || result.stdout.trim() || "launchd service not loaded" };
|
|
6705
|
+
const running = /state\s*=\s*running/.test(result.stdout);
|
|
6706
|
+
return { running, detail: running ? "launchd running" : "launchd loaded but not running" };
|
|
6707
|
+
}
|
|
6708
|
+
if (supervisor === "systemd") {
|
|
6709
|
+
if (!await fileExists(paths.systemdUnit))
|
|
6710
|
+
return { running: false, detail: "systemd unit not installed" };
|
|
6711
|
+
const result = await runCapture(["systemctl", "--user", "is-active", "hasna-bridge.service"]);
|
|
6712
|
+
const state = result.stdout.trim() || result.stderr.trim() || "unknown";
|
|
6713
|
+
return { running: result.exitCode === 0 && state === "active", detail: `systemd ${state}` };
|
|
6714
|
+
}
|
|
6715
|
+
return { running: false, detail: "process supervisor has no installed status" };
|
|
6716
|
+
}
|
|
6717
|
+
async function daemonStatus(options = {}) {
|
|
6718
|
+
const supervisor = resolveSupervisor(options.supervisor);
|
|
6719
|
+
const paths = daemonPaths(options.daemonDir);
|
|
6720
|
+
const metadata = await readMetadata(paths);
|
|
6721
|
+
const live = metadata ? await processMatches(metadata) : false;
|
|
6722
|
+
const stale = Boolean(metadata && !live);
|
|
6723
|
+
const startedAt = metadata?.startedAt;
|
|
6724
|
+
const uptimeSeconds = live && startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)) : undefined;
|
|
6725
|
+
const installed = {
|
|
6726
|
+
launchd: await fileExists(paths.launchdPlist),
|
|
6727
|
+
systemd: await fileExists(paths.systemdUnit)
|
|
6728
|
+
};
|
|
6729
|
+
const installedRuntime = supervisor === "process" ? undefined : await installedSupervisorStatus(supervisor, paths);
|
|
6730
|
+
return {
|
|
6731
|
+
running: installedRuntime ? installedRuntime.running : live,
|
|
6732
|
+
stale: installedRuntime ? false : stale,
|
|
6733
|
+
supervisor,
|
|
6734
|
+
pid: metadata?.pid,
|
|
6735
|
+
startedAt,
|
|
6736
|
+
uptimeSeconds,
|
|
6737
|
+
detail: installedRuntime?.detail || (stale ? "stale process metadata" : live ? "running" : "not running"),
|
|
6738
|
+
installedDetail: installedRuntime?.detail,
|
|
6739
|
+
metadata,
|
|
6740
|
+
paths,
|
|
6741
|
+
installed,
|
|
6742
|
+
telegramApiBase: safeTelegramApiBaseInfo()
|
|
6743
|
+
};
|
|
6744
|
+
}
|
|
6745
|
+
async function startProcessDaemon(options = {}) {
|
|
6746
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
6747
|
+
return withDaemonLock(paths, async () => {
|
|
6748
|
+
const existing = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
6749
|
+
if (existing.running)
|
|
6750
|
+
return existing;
|
|
6751
|
+
if (existing.stale)
|
|
6752
|
+
await removeMetadata(paths);
|
|
6753
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
6754
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
6755
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
6756
|
+
const serveJson = Boolean(options.serveJson);
|
|
6757
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 0)
|
|
6758
|
+
throw new Error("--interval must be a non-negative integer");
|
|
6759
|
+
await validateStartConfig(configPath);
|
|
6760
|
+
const stdoutFd = openPrivateLog(paths.stdoutLog);
|
|
6761
|
+
const stderrFd = openPrivateLog(paths.stderrLog);
|
|
6762
|
+
try {
|
|
6763
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
6764
|
+
const child = spawn(command[0], command.slice(1), {
|
|
6765
|
+
cwd: process.cwd(),
|
|
6766
|
+
detached: true,
|
|
6767
|
+
env: process.env,
|
|
6768
|
+
stdio: ["ignore", stdoutFd, stderrFd]
|
|
6769
|
+
});
|
|
6770
|
+
child.unref();
|
|
6771
|
+
const metadata = {
|
|
6772
|
+
version: 1,
|
|
6773
|
+
supervisor: "process",
|
|
6774
|
+
pid: child.pid || 0,
|
|
6775
|
+
pgid: child.pid || undefined,
|
|
6776
|
+
startedAt: new Date().toISOString(),
|
|
6777
|
+
identity: {
|
|
6778
|
+
command: commandPattern(command),
|
|
6779
|
+
cwd: process.cwd(),
|
|
6780
|
+
configPath,
|
|
6781
|
+
statePath,
|
|
6782
|
+
daemonDir: paths.dir,
|
|
6783
|
+
bridgeHome: bridgeHome()
|
|
6784
|
+
},
|
|
6785
|
+
command,
|
|
6786
|
+
cwd: process.cwd(),
|
|
6787
|
+
configPath,
|
|
6788
|
+
statePath,
|
|
6789
|
+
intervalMs,
|
|
6790
|
+
serveJson,
|
|
6791
|
+
daemonDir: paths.dir,
|
|
6792
|
+
bridgeHome: bridgeHome(),
|
|
6793
|
+
stdoutLog: paths.stdoutLog,
|
|
6794
|
+
stderrLog: paths.stderrLog
|
|
6795
|
+
};
|
|
6796
|
+
if (!metadata.pid)
|
|
6797
|
+
throw new Error("Failed to start bridge daemon process");
|
|
6798
|
+
await writeMetadata(paths, metadata);
|
|
6799
|
+
await Bun.sleep(200);
|
|
6800
|
+
const status = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
6801
|
+
if (!status.running) {
|
|
6802
|
+
await removeMetadata(paths);
|
|
6803
|
+
throw new Error(`Bridge daemon failed to stay running; inspect ${paths.stderrLog}`);
|
|
6804
|
+
}
|
|
6805
|
+
return status;
|
|
6806
|
+
} finally {
|
|
6807
|
+
closeSync(stdoutFd);
|
|
6808
|
+
closeSync(stderrFd);
|
|
6809
|
+
await chmod3(paths.stdoutLog, 384).catch(() => {
|
|
6810
|
+
return;
|
|
6811
|
+
});
|
|
6812
|
+
await chmod3(paths.stderrLog, 384).catch(() => {
|
|
6813
|
+
return;
|
|
6814
|
+
});
|
|
6815
|
+
}
|
|
6816
|
+
});
|
|
6817
|
+
}
|
|
6818
|
+
async function stopPid(pid, force) {
|
|
6819
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
6820
|
+
}
|
|
6821
|
+
async function waitForExit(pid, timeoutMs) {
|
|
6822
|
+
const started = Date.now();
|
|
6823
|
+
while (Date.now() - started < timeoutMs) {
|
|
6824
|
+
if (!pidAlive(pid))
|
|
6825
|
+
return true;
|
|
6826
|
+
await Bun.sleep(100);
|
|
6827
|
+
}
|
|
6828
|
+
return !pidAlive(pid);
|
|
6829
|
+
}
|
|
6830
|
+
async function stopProcessDaemon(options = {}) {
|
|
6831
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
6832
|
+
return withDaemonLock(paths, async () => {
|
|
6833
|
+
const metadata = await readMetadata(paths);
|
|
6834
|
+
if (!metadata)
|
|
6835
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
6836
|
+
if (!await processMatches(metadata)) {
|
|
6837
|
+
await removeMetadata(paths);
|
|
6838
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
6839
|
+
}
|
|
6840
|
+
await stopPid(metadata.pid, false);
|
|
6841
|
+
let exited = await waitForExit(metadata.pid, options.timeoutMs ?? 5000);
|
|
6842
|
+
if (!exited && options.force) {
|
|
6843
|
+
await stopPid(metadata.pid, true);
|
|
6844
|
+
exited = await waitForExit(metadata.pid, 2000);
|
|
6845
|
+
}
|
|
6846
|
+
if (!exited)
|
|
6847
|
+
throw new Error(`Bridge daemon did not stop within ${options.timeoutMs ?? 5000}ms`);
|
|
6848
|
+
await removeMetadata(paths);
|
|
6849
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
6850
|
+
});
|
|
6851
|
+
}
|
|
6852
|
+
async function restartProcessDaemon(options = {}) {
|
|
6853
|
+
const paths = daemonPaths(options.daemonDir);
|
|
6854
|
+
const metadata = await readMetadata(paths);
|
|
6855
|
+
await stopProcessDaemon(options);
|
|
6856
|
+
return startProcessDaemon({
|
|
6857
|
+
...options,
|
|
6858
|
+
configPath: options.configPath || metadata?.configPath,
|
|
6859
|
+
statePath: options.statePath || metadata?.statePath,
|
|
6860
|
+
intervalMs: options.intervalMs ?? metadata?.intervalMs,
|
|
6861
|
+
serveJson: options.serveJson ?? metadata?.serveJson
|
|
6862
|
+
});
|
|
6863
|
+
}
|
|
6864
|
+
function xmlEscape(value) {
|
|
6865
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6866
|
+
}
|
|
6867
|
+
function plistArray(values) {
|
|
6868
|
+
return values.map((value) => ` <string>${xmlEscape(value)}</string>`).join(`
|
|
6869
|
+
`);
|
|
6870
|
+
}
|
|
6871
|
+
function renderLaunchdPlist(command, paths) {
|
|
6872
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
6873
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
6874
|
+
<plist version="1.0">
|
|
6875
|
+
<dict>
|
|
6876
|
+
<key>Label</key>
|
|
6877
|
+
<string>com.hasna.bridge</string>
|
|
6878
|
+
<key>ProgramArguments</key>
|
|
6879
|
+
<array>
|
|
6880
|
+
${plistArray(command)}
|
|
6881
|
+
</array>
|
|
6882
|
+
<key>RunAtLoad</key>
|
|
6883
|
+
<true/>
|
|
6884
|
+
<key>KeepAlive</key>
|
|
6885
|
+
<true/>
|
|
6886
|
+
<key>StandardOutPath</key>
|
|
6887
|
+
<string>${xmlEscape(paths.stdoutLog)}</string>
|
|
6888
|
+
<key>StandardErrorPath</key>
|
|
6889
|
+
<string>${xmlEscape(paths.stderrLog)}</string>
|
|
6890
|
+
<key>WorkingDirectory</key>
|
|
6891
|
+
<string>${xmlEscape(process.cwd())}</string>
|
|
6892
|
+
</dict>
|
|
6893
|
+
</plist>
|
|
6894
|
+
`;
|
|
6895
|
+
}
|
|
6896
|
+
function systemdEscape(value) {
|
|
6897
|
+
return value.replaceAll("%", "%%").replaceAll(`
|
|
6898
|
+
`, " ");
|
|
6899
|
+
}
|
|
6900
|
+
function systemdQuote(value) {
|
|
6901
|
+
return `"${systemdEscape(value).replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
6902
|
+
}
|
|
6903
|
+
function renderSystemdUnit(command, paths) {
|
|
6904
|
+
return `[Unit]
|
|
6905
|
+
Description=Hasna Bridge daemon
|
|
6906
|
+
After=network-online.target
|
|
6907
|
+
|
|
6908
|
+
[Service]
|
|
6909
|
+
Type=simple
|
|
6910
|
+
ExecStart=${command.map(systemdQuote).join(" ")}
|
|
6911
|
+
Restart=always
|
|
6912
|
+
RestartSec=5
|
|
6913
|
+
WorkingDirectory=${systemdEscape(process.cwd())}
|
|
6914
|
+
StandardOutput=append:${systemdEscape(paths.stdoutLog)}
|
|
6915
|
+
StandardError=append:${systemdEscape(paths.stderrLog)}
|
|
6916
|
+
|
|
6917
|
+
[Install]
|
|
6918
|
+
WantedBy=default.target
|
|
6919
|
+
`;
|
|
6920
|
+
}
|
|
6921
|
+
async function installFile(path, content) {
|
|
6922
|
+
await mkdir3(dirname3(path), { recursive: true, mode: 448 });
|
|
6923
|
+
await writeFile3(path, content, { encoding: "utf-8", mode: 384 });
|
|
6924
|
+
await chmod3(path, 384);
|
|
6925
|
+
}
|
|
6926
|
+
async function installDaemon(options = {}) {
|
|
6927
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
6928
|
+
if (supervisor === "process") {
|
|
6929
|
+
throw new Error("The process supervisor does not need install; use `bridge daemon start`");
|
|
6930
|
+
}
|
|
6931
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
6932
|
+
await ensurePrivateLogFiles(paths);
|
|
6933
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
6934
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
6935
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
6936
|
+
const serveJson = Boolean(options.serveJson);
|
|
6937
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
6938
|
+
const config = await loadConfig(configPath);
|
|
6939
|
+
const requiredEnv = requiredTelegramEnvVars(config);
|
|
6940
|
+
if (supervisor === "launchd") {
|
|
6941
|
+
await installFile(paths.launchdPlist, renderLaunchdPlist(command, paths));
|
|
6942
|
+
return {
|
|
6943
|
+
supervisor,
|
|
6944
|
+
path: paths.launchdPlist,
|
|
6945
|
+
command,
|
|
6946
|
+
requiredEnv,
|
|
6947
|
+
warning: "Telegram token values are not written to launchd files. Set them in the launchd environment before starting."
|
|
6948
|
+
};
|
|
6949
|
+
}
|
|
6950
|
+
await installFile(paths.systemdUnit, renderSystemdUnit(command, paths));
|
|
6951
|
+
return {
|
|
6952
|
+
supervisor,
|
|
6953
|
+
path: paths.systemdUnit,
|
|
6954
|
+
command,
|
|
6955
|
+
requiredEnv,
|
|
6956
|
+
warning: "Telegram token values are not written to systemd files. Import them into the user manager environment before starting."
|
|
6957
|
+
};
|
|
6958
|
+
}
|
|
6959
|
+
async function runCommand(command) {
|
|
6960
|
+
const { exitCode, stdout, stderr } = await runCapture(command);
|
|
6961
|
+
if (exitCode !== 0)
|
|
6962
|
+
throw new Error(`${command.join(" ")} failed (${exitCode}): ${stderr || stdout}`);
|
|
6963
|
+
}
|
|
6964
|
+
async function waitForInstalledRunning(supervisor, paths, timeoutMs = 5000) {
|
|
6965
|
+
const started = Date.now();
|
|
6966
|
+
let last = "";
|
|
6967
|
+
while (Date.now() - started < timeoutMs) {
|
|
6968
|
+
const status = await installedSupervisorStatus(supervisor, paths);
|
|
6969
|
+
last = status.detail;
|
|
6970
|
+
if (status.running)
|
|
6971
|
+
return;
|
|
6972
|
+
await Bun.sleep(250);
|
|
6973
|
+
}
|
|
6974
|
+
throw new Error(`${supervisor} service did not report running: ${last}`);
|
|
6975
|
+
}
|
|
6976
|
+
async function startInstalledDaemon(options = {}) {
|
|
6977
|
+
const result = await installDaemon(options);
|
|
6978
|
+
const paths = daemonPaths(options.daemonDir);
|
|
6979
|
+
if (result.supervisor === "launchd") {
|
|
6980
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
6981
|
+
if (uid === undefined)
|
|
6982
|
+
throw new Error("launchd start requires a numeric uid");
|
|
6983
|
+
await runCommand(["launchctl", "bootstrap", `gui/${uid}`, result.path]).catch(async (err) => {
|
|
6984
|
+
if (!String(err).includes("Input/output error"))
|
|
6985
|
+
throw err;
|
|
6986
|
+
await runCommand(["launchctl", "kickstart", "-k", `gui/${uid}/com.hasna.bridge`]);
|
|
6987
|
+
});
|
|
6988
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
6989
|
+
return result;
|
|
6990
|
+
}
|
|
6991
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]);
|
|
6992
|
+
await runCommand(["systemctl", "--user", "enable", "--now", "hasna-bridge.service"]);
|
|
6993
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
6994
|
+
return result;
|
|
6995
|
+
}
|
|
6996
|
+
async function stopInstalledDaemon(options = {}) {
|
|
6997
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
6998
|
+
const paths = daemonPaths(options.daemonDir);
|
|
6999
|
+
if (supervisor === "launchd") {
|
|
7000
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
7001
|
+
if (uid === undefined)
|
|
7002
|
+
throw new Error("launchd stop requires a numeric uid");
|
|
7003
|
+
await runCommand(["launchctl", "bootout", `gui/${uid}`, paths.launchdPlist]);
|
|
7004
|
+
return;
|
|
7005
|
+
}
|
|
7006
|
+
if (supervisor === "systemd") {
|
|
7007
|
+
await runCommand(["systemctl", "--user", "disable", "--now", "hasna-bridge.service"]);
|
|
7008
|
+
return;
|
|
7009
|
+
}
|
|
7010
|
+
await stopProcessDaemon(options);
|
|
7011
|
+
}
|
|
7012
|
+
async function restartInstalledDaemon(options = {}) {
|
|
7013
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
7014
|
+
if (supervisor === "process")
|
|
7015
|
+
return restartProcessDaemon(options);
|
|
7016
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
7017
|
+
return;
|
|
7018
|
+
});
|
|
7019
|
+
return startInstalledDaemon({ ...options, supervisor });
|
|
7020
|
+
}
|
|
7021
|
+
async function uninstallDaemon(options = {}) {
|
|
7022
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
7023
|
+
const paths = daemonPaths(options.daemonDir);
|
|
7024
|
+
const removed = [];
|
|
7025
|
+
if (supervisor === "launchd") {
|
|
7026
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
7027
|
+
return;
|
|
7028
|
+
});
|
|
7029
|
+
await rm(paths.launchdPlist, { force: true });
|
|
7030
|
+
removed.push(paths.launchdPlist);
|
|
7031
|
+
} else if (supervisor === "systemd") {
|
|
7032
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
7033
|
+
return;
|
|
7034
|
+
});
|
|
7035
|
+
await rm(paths.systemdUnit, { force: true });
|
|
7036
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]).catch(() => {
|
|
7037
|
+
return;
|
|
7038
|
+
});
|
|
7039
|
+
removed.push(paths.systemdUnit);
|
|
7040
|
+
} else {
|
|
7041
|
+
await stopProcessDaemon({ ...options, supervisor }).catch(() => {
|
|
7042
|
+
return;
|
|
7043
|
+
});
|
|
7044
|
+
await removeMetadata(paths);
|
|
7045
|
+
removed.push(paths.metadataFile);
|
|
7046
|
+
}
|
|
7047
|
+
return { supervisor, removed };
|
|
7048
|
+
}
|
|
7049
|
+
async function tailFile(path, lines) {
|
|
7050
|
+
try {
|
|
7051
|
+
const raw = await readFile3(path, "utf-8");
|
|
7052
|
+
return raw.split(/\r?\n/).slice(-Math.max(1, lines)).join(`
|
|
7053
|
+
`);
|
|
7054
|
+
} catch (err) {
|
|
7055
|
+
if (isNotFound(err))
|
|
7056
|
+
return "";
|
|
7057
|
+
throw err;
|
|
7058
|
+
}
|
|
7059
|
+
}
|
|
7060
|
+
async function daemonLogs(options = {}) {
|
|
7061
|
+
const paths = daemonPaths(options.daemonDir);
|
|
7062
|
+
const lines = options.lines ?? 100;
|
|
7063
|
+
return {
|
|
7064
|
+
stdout: await tailFile(paths.stdoutLog, lines),
|
|
7065
|
+
stderr: await tailFile(paths.stderrLog, lines),
|
|
7066
|
+
paths
|
|
7067
|
+
};
|
|
7068
|
+
}
|
|
7069
|
+
// src/lib/doctor.ts
|
|
7070
|
+
import { stat as stat2 } from "fs/promises";
|
|
7071
|
+
|
|
7072
|
+
// src/lib/imessage.ts
|
|
7073
|
+
import { access } from "fs/promises";
|
|
7074
|
+
import { join as join4 } from "path";
|
|
7075
|
+
import { Database } from "bun:sqlite";
|
|
7076
|
+
function defaultMessagesDbPath() {
|
|
7077
|
+
return join4(homeDir(), "Library", "Messages", "chat.db");
|
|
7078
|
+
}
|
|
7079
|
+
function imessageHandleAllowed(channel, handle) {
|
|
7080
|
+
if (channel.allowAllHandles)
|
|
7081
|
+
return true;
|
|
7082
|
+
if (!channel.allowedHandles?.length)
|
|
7083
|
+
return false;
|
|
7084
|
+
return Boolean(handle && channel.allowedHandles.includes(handle));
|
|
7085
|
+
}
|
|
7086
|
+
function appleScriptString(value) {
|
|
7087
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
7088
|
+
}
|
|
7089
|
+
function renderSendIMessageScript(channel, handle, text) {
|
|
7090
|
+
const service = channel.serviceName || "iMessage";
|
|
7091
|
+
const serviceSelector = channel.account ? `1st service whose name = ${appleScriptString(service)} and account = ${appleScriptString(channel.account)}` : `1st service whose name = ${appleScriptString(service)}`;
|
|
7092
|
+
const targetLines = handle.startsWith("chat:") ? [
|
|
7093
|
+
`set targetChat to 1st chat whose id = ${appleScriptString(handle.slice("chat:".length))}`,
|
|
7094
|
+
`send ${appleScriptString(text)} to targetChat`
|
|
7095
|
+
] : [
|
|
7096
|
+
`set targetBuddy to buddy ${appleScriptString(handle)} of targetService`,
|
|
7097
|
+
`send ${appleScriptString(text)} to targetBuddy`
|
|
7098
|
+
];
|
|
7099
|
+
return [
|
|
7100
|
+
'tell application "Messages"',
|
|
7101
|
+
`set targetService to ${serviceSelector}`,
|
|
7102
|
+
...targetLines,
|
|
7103
|
+
"end tell"
|
|
7104
|
+
].join(`
|
|
7105
|
+
`);
|
|
7106
|
+
}
|
|
7107
|
+
async function defaultRun(command) {
|
|
7108
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
7109
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
7110
|
+
proc.exited,
|
|
7111
|
+
new Response(proc.stdout).text(),
|
|
7112
|
+
new Response(proc.stderr).text()
|
|
7113
|
+
]);
|
|
7114
|
+
return { exitCode, stdout, stderr };
|
|
7115
|
+
}
|
|
7116
|
+
async function sendIMessage(channel, handle, text, options = {}) {
|
|
7117
|
+
if (!(options.allowChatTarget && handle.startsWith("chat:")) && !imessageHandleAllowed(channel, handle)) {
|
|
7118
|
+
throw new Error(`iMessage handle is not allowed for channel ${channel.id}: ${handle}`);
|
|
7119
|
+
}
|
|
7120
|
+
const script = renderSendIMessageScript(channel, handle, text);
|
|
7121
|
+
const result = await (options.run || defaultRun)(["osascript", "-e", script]);
|
|
7122
|
+
if (result.exitCode !== 0) {
|
|
7123
|
+
throw new Error(`iMessage send failed: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
|
|
7124
|
+
}
|
|
7125
|
+
return { ok: true };
|
|
7126
|
+
}
|
|
7127
|
+
function imessageDateToIso(value) {
|
|
7128
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
7129
|
+
return new Date().toISOString();
|
|
7130
|
+
const appleEpochMs = Date.UTC(2001, 0, 1);
|
|
7131
|
+
if (value > 1000000000000000)
|
|
7132
|
+
return new Date(appleEpochMs + Math.floor(value / 1e6)).toISOString();
|
|
7133
|
+
if (value > 1e9)
|
|
7134
|
+
return new Date(appleEpochMs + value * 1000).toISOString();
|
|
7135
|
+
return new Date(appleEpochMs + value).toISOString();
|
|
7136
|
+
}
|
|
7137
|
+
function getIMessageDbPath(channel) {
|
|
7138
|
+
return channel.chatDbPath || defaultMessagesDbPath();
|
|
7139
|
+
}
|
|
7140
|
+
function getIMessageMessages(channel, options = {}) {
|
|
7141
|
+
if ((channel.receiveMode || "disabled") !== "chat-db")
|
|
7142
|
+
return [];
|
|
7143
|
+
const db = new Database(getIMessageDbPath(channel), { readonly: true });
|
|
7144
|
+
try {
|
|
7145
|
+
const limit = options.limit || channel.pollLimit || 50;
|
|
7146
|
+
const scanLimit = Math.max(limit * 10, limit);
|
|
7147
|
+
const rows = db.query(`
|
|
7148
|
+
select
|
|
7149
|
+
message.ROWID as rowId,
|
|
7150
|
+
handle.id as handle,
|
|
7151
|
+
chat.guid as chatGuid,
|
|
7152
|
+
chat.display_name as displayName,
|
|
7153
|
+
message.text as text,
|
|
7154
|
+
message.date as date
|
|
7155
|
+
from message
|
|
7156
|
+
left join handle on message.handle_id = handle.ROWID
|
|
7157
|
+
left join chat_message_join on chat_message_join.message_id = message.ROWID
|
|
7158
|
+
left join chat on chat.ROWID = chat_message_join.chat_id
|
|
7159
|
+
where message.ROWID > ?
|
|
7160
|
+
and message.is_from_me = 0
|
|
7161
|
+
and message.text is not null
|
|
7162
|
+
order by message.ROWID asc
|
|
7163
|
+
limit ?
|
|
7164
|
+
`).all(options.afterRowId || 0, scanLimit);
|
|
7165
|
+
return rows.filter((row) => row.handle && row.text && imessageHandleAllowed(channel, row.handle)).slice(0, limit).map((row) => {
|
|
7166
|
+
const item = { rowId: row.rowId, handle: row.handle, text: row.text, date: row.date };
|
|
7167
|
+
if (row.chatGuid)
|
|
7168
|
+
item.chatGuid = row.chatGuid;
|
|
7169
|
+
if (row.displayName)
|
|
7170
|
+
item.displayName = row.displayName;
|
|
7171
|
+
return item;
|
|
7172
|
+
});
|
|
7173
|
+
} finally {
|
|
7174
|
+
db.close();
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
function imessageRowToMessage(channelId, row) {
|
|
7178
|
+
return {
|
|
7179
|
+
id: `imessage:${row.rowId}`,
|
|
7180
|
+
channelId,
|
|
7181
|
+
chatId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
|
|
7182
|
+
responseTargetId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
|
|
7183
|
+
from: row.handle,
|
|
7184
|
+
text: row.text,
|
|
7185
|
+
receivedAt: imessageDateToIso(row.date),
|
|
7186
|
+
raw: row
|
|
7187
|
+
};
|
|
7188
|
+
}
|
|
7189
|
+
async function commandExists(command) {
|
|
7190
|
+
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
7191
|
+
stdout: "ignore",
|
|
7192
|
+
stderr: "ignore"
|
|
7193
|
+
});
|
|
7194
|
+
return await proc.exited === 0;
|
|
7195
|
+
}
|
|
7196
|
+
async function diagnoseIMessage(channel) {
|
|
7197
|
+
const checks = [];
|
|
7198
|
+
checks.push({
|
|
7199
|
+
name: `imessage-platform:${channel.id}`,
|
|
7200
|
+
ok: process.platform === "darwin",
|
|
7201
|
+
detail: process.platform === "darwin" ? "macOS" : `unsupported platform: ${process.platform}`
|
|
7202
|
+
});
|
|
7203
|
+
checks.push({
|
|
7204
|
+
name: `imessage-osascript:${channel.id}`,
|
|
7205
|
+
ok: await commandExists("osascript"),
|
|
7206
|
+
detail: "required for Messages send automation"
|
|
7207
|
+
});
|
|
7208
|
+
checks.push({
|
|
7209
|
+
name: `imessage-allowlist:${channel.id}`,
|
|
7210
|
+
ok: Boolean(channel.allowAllHandles || channel.allowedHandles?.length),
|
|
7211
|
+
detail: channel.allowAllHandles ? "allowAllHandles=true" : `${channel.allowedHandles?.length || 0} handle(s)`
|
|
7212
|
+
});
|
|
7213
|
+
if ((channel.receiveMode || "disabled") === "chat-db") {
|
|
7214
|
+
const path = getIMessageDbPath(channel);
|
|
7215
|
+
try {
|
|
7216
|
+
await access(path);
|
|
7217
|
+
checks.push({ name: `imessage-chat-db:${channel.id}`, ok: true, detail: path });
|
|
7218
|
+
} catch (err) {
|
|
7219
|
+
checks.push({
|
|
7220
|
+
name: `imessage-chat-db:${channel.id}`,
|
|
7221
|
+
ok: false,
|
|
7222
|
+
detail: `${path}: ${err instanceof Error ? err.message : String(err)}. Grant Full Disk Access to the terminal/daemon host or disable receive mode.`
|
|
7223
|
+
});
|
|
7224
|
+
}
|
|
7225
|
+
} else {
|
|
7226
|
+
checks.push({ name: `imessage-receive:${channel.id}`, ok: true, detail: "receiveMode=disabled" });
|
|
7227
|
+
}
|
|
7228
|
+
return checks;
|
|
7229
|
+
}
|
|
7230
|
+
|
|
7231
|
+
// src/lib/doctor.ts
|
|
7232
|
+
function isNotFound2(err) {
|
|
7233
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
7234
|
+
}
|
|
7235
|
+
async function privateFileCheck(name, path) {
|
|
7236
|
+
try {
|
|
7237
|
+
const info = await stat2(path);
|
|
7238
|
+
const mode = info.mode & 511;
|
|
7239
|
+
const ok = (mode & 63) === 0;
|
|
7240
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
7241
|
+
} catch (err) {
|
|
7242
|
+
if (isNotFound2(err))
|
|
7243
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
7244
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
7245
|
+
}
|
|
7246
|
+
}
|
|
7247
|
+
async function privateDirCheck(name, path) {
|
|
7248
|
+
try {
|
|
7249
|
+
const info = await stat2(path);
|
|
7250
|
+
const mode = info.mode & 511;
|
|
7251
|
+
const ok = (mode & 63) === 0;
|
|
7252
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
7253
|
+
} catch (err) {
|
|
7254
|
+
if (isNotFound2(err))
|
|
7255
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
7256
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
7257
|
+
}
|
|
7258
|
+
}
|
|
7259
|
+
async function commandExists2(command) {
|
|
7260
|
+
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
7261
|
+
stdout: "ignore",
|
|
7262
|
+
stderr: "ignore"
|
|
7263
|
+
});
|
|
7264
|
+
return await proc.exited === 0;
|
|
7265
|
+
}
|
|
7266
|
+
async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
|
|
7267
|
+
const checks = [];
|
|
7268
|
+
const config = await loadConfig(configPath);
|
|
7269
|
+
const daemon = await daemonStatus();
|
|
7270
|
+
const paths = daemonPaths();
|
|
7271
|
+
checks.push(await privateFileCheck("config", configPath));
|
|
7272
|
+
checks.push(await privateFileCheck("state", statePath));
|
|
7273
|
+
checks.push(await privateDirCheck("daemon-dir", paths.dir));
|
|
7274
|
+
checks.push(await privateFileCheck("daemon-metadata", paths.metadataFile));
|
|
7275
|
+
checks.push({
|
|
7276
|
+
name: "daemon-status",
|
|
7277
|
+
ok: !daemon.stale,
|
|
7278
|
+
detail: daemon.running ? `running pid=${daemon.pid}` : daemon.stale ? `stale pid=${daemon.pid}` : "not running"
|
|
7279
|
+
});
|
|
7280
|
+
try {
|
|
7281
|
+
const apiBase = telegramApiBaseInfo();
|
|
7282
|
+
checks.push({
|
|
7283
|
+
name: "telegram-api-base",
|
|
7284
|
+
ok: true,
|
|
7285
|
+
detail: apiBase.overridden ? `overridden: ${apiBase.origin}${apiBase.pathname}` : apiBase.origin
|
|
7286
|
+
});
|
|
7287
|
+
} catch (err) {
|
|
7288
|
+
checks.push({
|
|
7289
|
+
name: "telegram-api-base",
|
|
7290
|
+
ok: false,
|
|
7291
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
7292
|
+
});
|
|
7293
|
+
}
|
|
7294
|
+
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
7295
|
+
checks.push({
|
|
7296
|
+
name: `command:${command}`,
|
|
7297
|
+
ok: command === "bridge" ? true : await commandExists2(command),
|
|
7298
|
+
detail: command === "bridge" ? "current package" : undefined
|
|
7299
|
+
});
|
|
7300
|
+
}
|
|
7301
|
+
const telegramChannels2 = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
|
|
7302
|
+
for (const channel of telegramChannels2) {
|
|
7303
|
+
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
7304
|
+
checks.push({
|
|
7305
|
+
name: `telegram-token:${channel.id}`,
|
|
7306
|
+
ok: Boolean(process.env[envName]),
|
|
7307
|
+
detail: envName
|
|
7308
|
+
});
|
|
7309
|
+
checks.push({
|
|
7310
|
+
name: `telegram-allowlist:${channel.id}`,
|
|
7311
|
+
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
7312
|
+
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
7313
|
+
});
|
|
7314
|
+
}
|
|
7315
|
+
for (const route of config.routes) {
|
|
7316
|
+
checks.push({
|
|
7317
|
+
name: `route:${route.id}`,
|
|
7318
|
+
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
7319
|
+
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
7320
|
+
});
|
|
7321
|
+
}
|
|
7322
|
+
const imessageChannels = Object.values(config.channels).filter((channel) => channel.kind === "imessage");
|
|
7323
|
+
for (const channel of imessageChannels) {
|
|
7324
|
+
checks.push(...await diagnoseIMessage(channel));
|
|
7325
|
+
}
|
|
7326
|
+
return { ok: checks.every((check) => check.ok), configPath, checks };
|
|
7327
|
+
}
|
|
6456
7328
|
// src/lib/router.ts
|
|
6457
7329
|
function matchingRoutes(config, message) {
|
|
6458
7330
|
const channel = config.channels[message.channelId];
|
|
@@ -6461,6 +7333,9 @@ function matchingRoutes(config, message) {
|
|
|
6461
7333
|
if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
|
|
6462
7334
|
return [];
|
|
6463
7335
|
}
|
|
7336
|
+
if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
|
|
7337
|
+
return [];
|
|
7338
|
+
}
|
|
6464
7339
|
return config.routes.filter((route) => {
|
|
6465
7340
|
if (route.enabled === false)
|
|
6466
7341
|
return false;
|
|
@@ -6500,15 +7375,359 @@ async function routeMessage(config, message, options = {}) {
|
|
|
6500
7375
|
if (options.writeConsole !== false)
|
|
6501
7376
|
(options.writeConsole || console.log)(responseText);
|
|
6502
7377
|
deliveredResponse = true;
|
|
7378
|
+
} else if (responseText && channel?.kind === "imessage") {
|
|
7379
|
+
const handle = message.responseTargetId || message.chatId || message.from;
|
|
7380
|
+
const allowedIdentity = message.from || (handle?.startsWith("chat:") ? undefined : handle);
|
|
7381
|
+
if (handle && imessageHandleAllowed(channel, allowedIdentity)) {
|
|
7382
|
+
await sendIMessage(channel, handle, responseText, { allowChatTarget: handle.startsWith("chat:") });
|
|
7383
|
+
deliveredResponse = true;
|
|
7384
|
+
}
|
|
6503
7385
|
}
|
|
6504
7386
|
results.push({ route, agent, deliveredResponse });
|
|
6505
7387
|
}
|
|
6506
7388
|
return results;
|
|
6507
7389
|
}
|
|
7390
|
+
// src/lib/sessions.ts
|
|
7391
|
+
import { randomUUID } from "crypto";
|
|
7392
|
+
function nowIso() {
|
|
7393
|
+
return new Date().toISOString();
|
|
7394
|
+
}
|
|
7395
|
+
function newSessionId() {
|
|
7396
|
+
return `ses_${randomUUID()}`;
|
|
7397
|
+
}
|
|
7398
|
+
function normalizeConversationId(channel, conversation) {
|
|
7399
|
+
if (conversation.includes(":") && conversation.startsWith(`${channel.kind}:`))
|
|
7400
|
+
return conversation;
|
|
7401
|
+
if (channel.kind === "telegram")
|
|
7402
|
+
return `telegram:${channel.id}:${conversation}`;
|
|
7403
|
+
if (channel.kind === "imessage")
|
|
7404
|
+
return `imessage:${channel.id}:${conversation}`;
|
|
7405
|
+
return `${channel.kind}:${channel.id}:${conversation || "default"}`;
|
|
7406
|
+
}
|
|
7407
|
+
function messageConversationId(config, message) {
|
|
7408
|
+
const channel = config.channels[message.channelId];
|
|
7409
|
+
if (!channel)
|
|
7410
|
+
return;
|
|
7411
|
+
if (channel.kind === "telegram") {
|
|
7412
|
+
if (!message.chatId)
|
|
7413
|
+
return;
|
|
7414
|
+
return normalizeConversationId(channel, message.threadId ? `${message.chatId}:${message.threadId}` : message.chatId);
|
|
7415
|
+
}
|
|
7416
|
+
if (channel.kind === "imessage") {
|
|
7417
|
+
const conversation = message.chatId || message.from;
|
|
7418
|
+
return conversation ? normalizeConversationId(channel, conversation) : undefined;
|
|
7419
|
+
}
|
|
7420
|
+
return normalizeConversationId(channel, message.chatId || message.from || "default");
|
|
7421
|
+
}
|
|
7422
|
+
function bindingId(channelId, conversationId) {
|
|
7423
|
+
return `${channelId}::${conversationId}`;
|
|
7424
|
+
}
|
|
7425
|
+
function ledgerId(message) {
|
|
7426
|
+
return `${message.channelId}::${message.id}`;
|
|
7427
|
+
}
|
|
7428
|
+
function createBridgeSession(config, state, input) {
|
|
7429
|
+
const { agent, profile } = resolveAgent(config, input.agentId);
|
|
7430
|
+
const timestamp = nowIso();
|
|
7431
|
+
const session = {
|
|
7432
|
+
id: input.id || newSessionId(),
|
|
7433
|
+
agentId: agent.id,
|
|
7434
|
+
profileId: agent.profileId,
|
|
7435
|
+
cwd: input.cwd || agent.cwd || profile?.cwd,
|
|
7436
|
+
title: input.title,
|
|
7437
|
+
status: "active",
|
|
7438
|
+
createdAt: timestamp,
|
|
7439
|
+
updatedAt: timestamp,
|
|
7440
|
+
agentSession: createAgentSessionRef(config, agent.id)
|
|
7441
|
+
};
|
|
7442
|
+
state.sessions[session.id] = session;
|
|
7443
|
+
return session;
|
|
7444
|
+
}
|
|
7445
|
+
function getBridgeSession(state, sessionId) {
|
|
7446
|
+
const session = state.sessions[sessionId];
|
|
7447
|
+
if (!session)
|
|
7448
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
7449
|
+
return session;
|
|
7450
|
+
}
|
|
7451
|
+
function listBridgeSessions(state) {
|
|
7452
|
+
return Object.values(state.sessions).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
7453
|
+
}
|
|
7454
|
+
function updateBridgeSessionStatus(state, sessionId, status) {
|
|
7455
|
+
const session = getBridgeSession(state, sessionId);
|
|
7456
|
+
if (status === "closed")
|
|
7457
|
+
closeAgentSession(session);
|
|
7458
|
+
session.status = status;
|
|
7459
|
+
session.updatedAt = nowIso();
|
|
7460
|
+
return session;
|
|
7461
|
+
}
|
|
7462
|
+
function attachBridgeSession(config, state, input) {
|
|
7463
|
+
const channel = config.channels[input.channelId];
|
|
7464
|
+
if (!channel)
|
|
7465
|
+
throw new Error(`Channel not found: ${input.channelId}`);
|
|
7466
|
+
const session = getBridgeSession(state, input.sessionId);
|
|
7467
|
+
if (session.status === "closed")
|
|
7468
|
+
throw new Error(`Cannot attach closed session: ${session.id}`);
|
|
7469
|
+
const conversationId = normalizeConversationId(channel, input.conversation);
|
|
7470
|
+
const id = bindingId(channel.id, conversationId);
|
|
7471
|
+
const existing = state.bindings[id];
|
|
7472
|
+
const timestamp = nowIso();
|
|
7473
|
+
const binding = {
|
|
7474
|
+
id,
|
|
7475
|
+
channelId: channel.id,
|
|
7476
|
+
conversationId,
|
|
7477
|
+
activeSessionId: session.id,
|
|
7478
|
+
defaultSessionId: input.makeDefault ? session.id : existing?.defaultSessionId,
|
|
7479
|
+
createdAt: existing?.createdAt || timestamp,
|
|
7480
|
+
updatedAt: timestamp,
|
|
7481
|
+
authorization: input.authorization || existing?.authorization || (channel.kind === "telegram" ? { chatId: input.conversation.split(":")[0] } : undefined)
|
|
7482
|
+
};
|
|
7483
|
+
state.bindings[id] = binding;
|
|
7484
|
+
return binding;
|
|
7485
|
+
}
|
|
7486
|
+
function detachBridgeBinding(config, state, channelId, conversation) {
|
|
7487
|
+
const channel = config.channels[channelId];
|
|
7488
|
+
if (!channel)
|
|
7489
|
+
throw new Error(`Channel not found: ${channelId}`);
|
|
7490
|
+
const conversationId = normalizeConversationId(channel, conversation);
|
|
7491
|
+
const id = bindingId(channel.id, conversationId);
|
|
7492
|
+
const existing = state.bindings[id];
|
|
7493
|
+
delete state.bindings[id];
|
|
7494
|
+
return existing;
|
|
7495
|
+
}
|
|
7496
|
+
function noSessionText(channelId, conversationId) {
|
|
7497
|
+
return [
|
|
7498
|
+
"No bridge session is attached to this conversation.",
|
|
7499
|
+
"Create and attach one locally:",
|
|
7500
|
+
"bridge sessions create --agent <agent-id>",
|
|
7501
|
+
`bridge sessions attach <session-id> --channel ${channelId}${conversationId ? ` --conversation ${conversationId}` : " --conversation <conversation-id>"}`
|
|
7502
|
+
].join(`
|
|
7503
|
+
`);
|
|
7504
|
+
}
|
|
7505
|
+
async function deliverResponse(config, message, text, options) {
|
|
7506
|
+
const channel = config.channels[message.channelId];
|
|
7507
|
+
if (!text || !channel || channel.enabled === false)
|
|
7508
|
+
return false;
|
|
7509
|
+
if (channel.kind === "telegram" && message.chatId) {
|
|
7510
|
+
if (!telegramChatAllowed(channel, message.chatId))
|
|
7511
|
+
return false;
|
|
7512
|
+
await (options.sendTelegram || sendTelegramMessage)(telegramToken(channel), message.chatId, text);
|
|
7513
|
+
return true;
|
|
7514
|
+
}
|
|
7515
|
+
if (channel.kind === "console") {
|
|
7516
|
+
if (options.writeConsole !== false)
|
|
7517
|
+
(options.writeConsole || console.log)(text);
|
|
7518
|
+
return true;
|
|
7519
|
+
}
|
|
7520
|
+
if (channel.kind === "imessage" && message.chatId) {
|
|
7521
|
+
const allowedIdentity = message.from || (message.chatId.startsWith("chat:") ? undefined : message.chatId);
|
|
7522
|
+
if (!imessageHandleAllowed(channel, allowedIdentity))
|
|
7523
|
+
return false;
|
|
7524
|
+
await sendIMessage(channel, message.responseTargetId || message.chatId, text, { allowChatTarget: Boolean(message.responseTargetId?.startsWith("chat:") || message.chatId.startsWith("chat:")) });
|
|
7525
|
+
return true;
|
|
7526
|
+
}
|
|
7527
|
+
return false;
|
|
7528
|
+
}
|
|
7529
|
+
async function deliverStoredResponse(config, state, binding, message, entry, options) {
|
|
7530
|
+
const session = getBridgeSession(state, binding.activeSessionId);
|
|
7531
|
+
const responseText = entry.responseText || "";
|
|
7532
|
+
const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
|
|
7533
|
+
completeLedger(entry, "delivered", session.id);
|
|
7534
|
+
entry.deliveredResponse = deliveredResponse;
|
|
7535
|
+
return {
|
|
7536
|
+
kind: "session",
|
|
7537
|
+
session,
|
|
7538
|
+
binding,
|
|
7539
|
+
conversationId: binding.conversationId,
|
|
7540
|
+
deliveredResponse,
|
|
7541
|
+
status: responseText ? "delivered" : "no_output"
|
|
7542
|
+
};
|
|
7543
|
+
}
|
|
7544
|
+
function channelAuthorized(config, message) {
|
|
7545
|
+
const channel = config.channels[message.channelId];
|
|
7546
|
+
if (!channel || channel.enabled === false)
|
|
7547
|
+
return false;
|
|
7548
|
+
if (channel.kind === "telegram")
|
|
7549
|
+
return telegramChatAllowed(channel, message.chatId);
|
|
7550
|
+
if (channel.kind === "imessage")
|
|
7551
|
+
return imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId));
|
|
7552
|
+
return true;
|
|
7553
|
+
}
|
|
7554
|
+
function bindingAuthorized(binding, message) {
|
|
7555
|
+
if (binding.authorization?.chatId && binding.authorization.chatId !== message.chatId)
|
|
7556
|
+
return false;
|
|
7557
|
+
if (binding.authorization?.from && binding.authorization.from !== message.from)
|
|
7558
|
+
return false;
|
|
7559
|
+
return true;
|
|
7560
|
+
}
|
|
7561
|
+
async function sendBridgeSessionMessage(config, state, sessionId, message, options = {}) {
|
|
7562
|
+
const session = getBridgeSession(state, sessionId);
|
|
7563
|
+
if (session.status === "paused")
|
|
7564
|
+
return { kind: "session", session, status: "paused", message: "Session is paused" };
|
|
7565
|
+
if (session.status === "closed")
|
|
7566
|
+
return { kind: "session", session, status: "closed", message: "Session is closed" };
|
|
7567
|
+
const agent = await sendAgentSessionMessage(config, session, message, { run: options.run });
|
|
7568
|
+
const timestamp = nowIso();
|
|
7569
|
+
session.lastMessageAt = timestamp;
|
|
7570
|
+
session.updatedAt = timestamp;
|
|
7571
|
+
if (session.agentSession)
|
|
7572
|
+
session.agentSession.updatedAt = timestamp;
|
|
7573
|
+
if (agent.timedOut || agent.exitCode !== null && agent.exitCode !== 0) {
|
|
7574
|
+
return {
|
|
7575
|
+
kind: "session",
|
|
7576
|
+
session,
|
|
7577
|
+
agent,
|
|
7578
|
+
deliveredResponse: false,
|
|
7579
|
+
status: "failed",
|
|
7580
|
+
message: agent.stderr.trim() || agent.stdout.trim() || (agent.timedOut ? "Agent timed out" : `Agent exited ${agent.exitCode}`)
|
|
7581
|
+
};
|
|
7582
|
+
}
|
|
7583
|
+
const responseText = agent.stdout.trim();
|
|
7584
|
+
await options.beforeDeliver?.(agent, responseText);
|
|
7585
|
+
const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
|
|
7586
|
+
return {
|
|
7587
|
+
kind: "session",
|
|
7588
|
+
session,
|
|
7589
|
+
agent,
|
|
7590
|
+
deliveredResponse,
|
|
7591
|
+
status: responseText ? "delivered" : "no_output"
|
|
7592
|
+
};
|
|
7593
|
+
}
|
|
7594
|
+
async function routeSessionMessage(config, state, message, options = {}) {
|
|
7595
|
+
const channel = config.channels[message.channelId];
|
|
7596
|
+
if (!channel || channel.enabled === false) {
|
|
7597
|
+
return { kind: "session", status: "unauthorized", message: `Channel not enabled: ${message.channelId}` };
|
|
7598
|
+
}
|
|
7599
|
+
if (!channelAuthorized(config, message)) {
|
|
7600
|
+
return { kind: "session", status: "unauthorized", message: "Message is not authorized for this channel" };
|
|
7601
|
+
}
|
|
7602
|
+
const conversationId = messageConversationId(config, message);
|
|
7603
|
+
const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
|
|
7604
|
+
if (!binding) {
|
|
7605
|
+
const text = noSessionText(message.channelId, conversationId);
|
|
7606
|
+
if (options.respondOnNoSession !== false)
|
|
7607
|
+
await deliverResponse(config, message, text, options);
|
|
7608
|
+
return { kind: "session", conversationId, status: "no_session", message: text };
|
|
7609
|
+
}
|
|
7610
|
+
if (!bindingAuthorized(binding, message)) {
|
|
7611
|
+
return { kind: "session", binding, conversationId, status: "unauthorized", message: "Message does not match binding authorization" };
|
|
7612
|
+
}
|
|
7613
|
+
const result = await sendBridgeSessionMessage(config, state, binding.activeSessionId, message, options);
|
|
7614
|
+
return { ...result, binding, conversationId };
|
|
7615
|
+
}
|
|
7616
|
+
function beginLedger(state, message, conversationId) {
|
|
7617
|
+
const id = ledgerId(message);
|
|
7618
|
+
const existing = state.messageLedger[id];
|
|
7619
|
+
if (existing && ["delivered", "skipped", "unauthorized"].includes(existing.status)) {
|
|
7620
|
+
return { entry: existing, shouldProcess: false };
|
|
7621
|
+
}
|
|
7622
|
+
const timestamp = nowIso();
|
|
7623
|
+
const entry = existing || {
|
|
7624
|
+
id,
|
|
7625
|
+
channelId: message.channelId,
|
|
7626
|
+
messageId: message.id,
|
|
7627
|
+
conversationId,
|
|
7628
|
+
status: "processing",
|
|
7629
|
+
attempts: 0,
|
|
7630
|
+
firstSeenAt: timestamp,
|
|
7631
|
+
updatedAt: timestamp
|
|
7632
|
+
};
|
|
7633
|
+
if (entry.status !== "agent_completed")
|
|
7634
|
+
entry.status = "processing";
|
|
7635
|
+
entry.attempts += 1;
|
|
7636
|
+
entry.conversationId = conversationId || entry.conversationId;
|
|
7637
|
+
entry.updatedAt = timestamp;
|
|
7638
|
+
delete entry.error;
|
|
7639
|
+
state.messageLedger[id] = entry;
|
|
7640
|
+
return { entry, shouldProcess: true };
|
|
7641
|
+
}
|
|
7642
|
+
function completeLedger(entry, status, sessionId, error) {
|
|
7643
|
+
const timestamp = nowIso();
|
|
7644
|
+
entry.status = status;
|
|
7645
|
+
entry.sessionId = sessionId || entry.sessionId;
|
|
7646
|
+
entry.updatedAt = timestamp;
|
|
7647
|
+
if (["delivered", "skipped", "unauthorized"].includes(status))
|
|
7648
|
+
entry.terminalAt = timestamp;
|
|
7649
|
+
if (error)
|
|
7650
|
+
entry.error = error;
|
|
7651
|
+
return entry;
|
|
7652
|
+
}
|
|
7653
|
+
function recordAgentCompleted(entry, sessionId, agent, responseText) {
|
|
7654
|
+
const timestamp = nowIso();
|
|
7655
|
+
entry.status = "agent_completed";
|
|
7656
|
+
entry.sessionId = sessionId || entry.sessionId;
|
|
7657
|
+
entry.responseText = responseText;
|
|
7658
|
+
entry.agentExitCode = agent.exitCode;
|
|
7659
|
+
entry.agentTimedOut = agent.timedOut;
|
|
7660
|
+
entry.updatedAt = timestamp;
|
|
7661
|
+
delete entry.error;
|
|
7662
|
+
return entry;
|
|
7663
|
+
}
|
|
7664
|
+
async function dispatchMessageWithSessions(config, state, message, options = {}) {
|
|
7665
|
+
const conversationId = messageConversationId(config, message);
|
|
7666
|
+
const { entry, shouldProcess } = beginLedger(state, message, conversationId);
|
|
7667
|
+
if (!shouldProcess)
|
|
7668
|
+
return { message, ledger: entry };
|
|
7669
|
+
await options.persistState?.(state);
|
|
7670
|
+
try {
|
|
7671
|
+
const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
|
|
7672
|
+
if (binding) {
|
|
7673
|
+
if (!bindingAuthorized(binding, message)) {
|
|
7674
|
+
const session3 = {
|
|
7675
|
+
kind: "session",
|
|
7676
|
+
binding,
|
|
7677
|
+
conversationId,
|
|
7678
|
+
status: "unauthorized",
|
|
7679
|
+
message: "Message does not match binding authorization"
|
|
7680
|
+
};
|
|
7681
|
+
completeLedger(entry, "unauthorized");
|
|
7682
|
+
return { message, session: session3, ledger: entry };
|
|
7683
|
+
}
|
|
7684
|
+
if (entry.status === "agent_completed") {
|
|
7685
|
+
const session3 = await deliverStoredResponse(config, state, binding, message, entry, options);
|
|
7686
|
+
return { message, session: session3, ledger: entry };
|
|
7687
|
+
}
|
|
7688
|
+
const session2 = await routeSessionMessage(config, state, message, {
|
|
7689
|
+
...options,
|
|
7690
|
+
beforeDeliver: async (agent, responseText) => {
|
|
7691
|
+
recordAgentCompleted(entry, binding.activeSessionId, agent, responseText);
|
|
7692
|
+
await options.persistState?.(state);
|
|
7693
|
+
await options.beforeDeliver?.(agent, responseText);
|
|
7694
|
+
}
|
|
7695
|
+
});
|
|
7696
|
+
if (session2.status === "failed") {
|
|
7697
|
+
completeLedger(entry, "failed", session2.session?.id, session2.message);
|
|
7698
|
+
throw new Error(session2.message || "Agent session failed");
|
|
7699
|
+
}
|
|
7700
|
+
const terminal = session2.status === "unauthorized" ? "unauthorized" : session2.status === "delivered" || session2.status === "no_output" ? "delivered" : "skipped";
|
|
7701
|
+
completeLedger(entry, terminal, session2.session?.id);
|
|
7702
|
+
entry.deliveredResponse = session2.deliveredResponse;
|
|
7703
|
+
return { message, session: session2, ledger: entry };
|
|
7704
|
+
}
|
|
7705
|
+
if (options.fallbackToRoutes) {
|
|
7706
|
+
const routes = await routeMessage(config, message, options);
|
|
7707
|
+
if (routes.length) {
|
|
7708
|
+
completeLedger(entry, "delivered");
|
|
7709
|
+
return { message, routes, ledger: entry };
|
|
7710
|
+
}
|
|
7711
|
+
}
|
|
7712
|
+
const session = await routeSessionMessage(config, state, message, options);
|
|
7713
|
+
const status = session.status === "unauthorized" ? "unauthorized" : "skipped";
|
|
7714
|
+
completeLedger(entry, status, session.session?.id);
|
|
7715
|
+
return { message, session, ledger: entry };
|
|
7716
|
+
} catch (err) {
|
|
7717
|
+
const messageText = err instanceof Error ? err.message : String(err);
|
|
7718
|
+
if (entry.status === "agent_completed") {
|
|
7719
|
+
entry.error = messageText;
|
|
7720
|
+
entry.updatedAt = nowIso();
|
|
7721
|
+
} else {
|
|
7722
|
+
completeLedger(entry, "failed", undefined, messageText);
|
|
7723
|
+
}
|
|
7724
|
+
throw err;
|
|
7725
|
+
}
|
|
7726
|
+
}
|
|
6508
7727
|
// src/cli/index.ts
|
|
6509
7728
|
function version() {
|
|
6510
7729
|
try {
|
|
6511
|
-
const pkgPath =
|
|
7730
|
+
const pkgPath = join5(dirname4(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
6512
7731
|
return JSON.parse(readFileSync(pkgPath, "utf-8")).version || "0.0.0";
|
|
6513
7732
|
} catch {
|
|
6514
7733
|
return "0.0.0";
|
|
@@ -6532,6 +7751,12 @@ function parseEnv(values) {
|
|
|
6532
7751
|
function splitCsv(value) {
|
|
6533
7752
|
return value?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
6534
7753
|
}
|
|
7754
|
+
function parseNonNegativeInt(value, name) {
|
|
7755
|
+
const raw = value || "0";
|
|
7756
|
+
if (!/^\d+$/.test(raw))
|
|
7757
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
7758
|
+
return Number.parseInt(raw, 10);
|
|
7759
|
+
}
|
|
6535
7760
|
function printList(items) {
|
|
6536
7761
|
const rows = Array.isArray(items) ? items : Object.values(items);
|
|
6537
7762
|
if (!rows.length) {
|
|
@@ -6543,35 +7768,112 @@ function printList(items) {
|
|
|
6543
7768
|
}
|
|
6544
7769
|
async function runServe(options) {
|
|
6545
7770
|
const config2 = await loadConfig(options.config);
|
|
6546
|
-
const
|
|
6547
|
-
const
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
throw new Error("No enabled Telegram channels configured");
|
|
7771
|
+
const telegramChannels2 = Object.values(config2.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
|
|
7772
|
+
const imessageChannels = Object.values(config2.channels).filter((channel) => channel.kind === "imessage" && channel.enabled !== false && channel.receiveMode === "chat-db");
|
|
7773
|
+
const intervalMs = parseNonNegativeInt(options.interval || "1000", "--interval");
|
|
7774
|
+
if (!telegramChannels2.length && !imessageChannels.length)
|
|
7775
|
+
throw new Error("No enabled pollable channels configured");
|
|
6552
7776
|
const statePath = options.state || defaultStatePath();
|
|
6553
|
-
const
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
const
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
7777
|
+
const errorCounts = new Map;
|
|
7778
|
+
let stopping = false;
|
|
7779
|
+
const stop = () => {
|
|
7780
|
+
stopping = true;
|
|
7781
|
+
};
|
|
7782
|
+
process.once("SIGTERM", stop);
|
|
7783
|
+
process.once("SIGINT", stop);
|
|
7784
|
+
while (!stopping) {
|
|
7785
|
+
for (const channel of telegramChannels2) {
|
|
7786
|
+
try {
|
|
7787
|
+
const pollState = await loadState(statePath);
|
|
7788
|
+
const updates = await getTelegramUpdates(telegramToken(channel), {
|
|
7789
|
+
offset: pollState.telegramOffsets[channel.id],
|
|
7790
|
+
timeoutSeconds: channel.pollTimeoutSeconds || 20
|
|
7791
|
+
});
|
|
7792
|
+
errorCounts.delete(channel.id);
|
|
7793
|
+
for (const update of updates) {
|
|
7794
|
+
const state2 = await loadState(statePath);
|
|
7795
|
+
const message = telegramUpdateToMessage(channel.id, update);
|
|
7796
|
+
if (message) {
|
|
7797
|
+
try {
|
|
7798
|
+
const results = await dispatchMessageWithSessions(config2, state2, message, {
|
|
7799
|
+
writeConsole: options.json ? false : undefined,
|
|
7800
|
+
fallbackToRoutes: true,
|
|
7801
|
+
persistState: async (nextState) => saveState(nextState, statePath)
|
|
7802
|
+
});
|
|
7803
|
+
if (results.ledger?.status === "failed" || results.ledger?.status === "processing" || results.ledger?.status === "agent_completed") {
|
|
7804
|
+
await saveState(state2, statePath);
|
|
7805
|
+
throw new Error(results.ledger.error || `Message ${message.id} did not reach a terminal state`);
|
|
7806
|
+
}
|
|
7807
|
+
state2.telegramOffsets[channel.id] = update.update_id + 1;
|
|
7808
|
+
await saveState(state2, statePath);
|
|
7809
|
+
if (options.json)
|
|
7810
|
+
asJson(results);
|
|
7811
|
+
} catch (err) {
|
|
7812
|
+
await saveState(state2, statePath);
|
|
7813
|
+
throw err;
|
|
7814
|
+
}
|
|
7815
|
+
} else {
|
|
7816
|
+
state2.telegramOffsets[channel.id] = update.update_id + 1;
|
|
7817
|
+
await saveState(state2, statePath);
|
|
7818
|
+
}
|
|
7819
|
+
}
|
|
7820
|
+
} catch (err) {
|
|
7821
|
+
if (options.once)
|
|
7822
|
+
throw err;
|
|
7823
|
+
const count = (errorCounts.get(channel.id) || 0) + 1;
|
|
7824
|
+
errorCounts.set(channel.id, count);
|
|
7825
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7826
|
+
console.error(`[bridge] ${channel.id} poll failed (${count}): ${message}`);
|
|
7827
|
+
await Bun.sleep(Math.min(30000, Math.max(1000, intervalMs * Math.min(count, 30))));
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
for (const channel of imessageChannels) {
|
|
7831
|
+
try {
|
|
7832
|
+
const pollState = await loadState(statePath);
|
|
7833
|
+
const cursorKey = `imessage:${channel.id}`;
|
|
7834
|
+
const rows = getIMessageMessages(channel, {
|
|
7835
|
+
afterRowId: Number(pollState.cursors[cursorKey] || 0),
|
|
7836
|
+
limit: channel.pollLimit || 50
|
|
7837
|
+
});
|
|
7838
|
+
errorCounts.delete(channel.id);
|
|
7839
|
+
for (const row of rows) {
|
|
7840
|
+
const state2 = await loadState(statePath);
|
|
7841
|
+
const message = imessageRowToMessage(channel.id, row);
|
|
7842
|
+
try {
|
|
7843
|
+
const results = await dispatchMessageWithSessions(config2, state2, message, {
|
|
7844
|
+
writeConsole: options.json ? false : undefined,
|
|
7845
|
+
fallbackToRoutes: true,
|
|
7846
|
+
persistState: async (nextState) => saveState(nextState, statePath)
|
|
7847
|
+
});
|
|
7848
|
+
if (results.ledger?.status === "failed" || results.ledger?.status === "processing" || results.ledger?.status === "agent_completed") {
|
|
7849
|
+
await saveState(state2, statePath);
|
|
7850
|
+
throw new Error(results.ledger.error || `Message ${message.id} did not reach a terminal state`);
|
|
7851
|
+
}
|
|
7852
|
+
state2.cursors[cursorKey] = row.rowId;
|
|
7853
|
+
await saveState(state2, statePath);
|
|
7854
|
+
if (options.json)
|
|
7855
|
+
asJson(results);
|
|
7856
|
+
} catch (err) {
|
|
7857
|
+
await saveState(state2, statePath);
|
|
7858
|
+
throw err;
|
|
7859
|
+
}
|
|
7860
|
+
}
|
|
7861
|
+
} catch (err) {
|
|
7862
|
+
if (options.once)
|
|
7863
|
+
throw err;
|
|
7864
|
+
const count = (errorCounts.get(channel.id) || 0) + 1;
|
|
7865
|
+
errorCounts.set(channel.id, count);
|
|
7866
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7867
|
+
console.error(`[bridge] ${channel.id} poll failed (${count}): ${message}`);
|
|
7868
|
+
await Bun.sleep(Math.min(30000, Math.max(1000, intervalMs * Math.min(count, 30))));
|
|
6569
7869
|
}
|
|
6570
7870
|
}
|
|
6571
7871
|
if (options.once)
|
|
6572
7872
|
break;
|
|
6573
7873
|
await Bun.sleep(intervalMs);
|
|
6574
7874
|
}
|
|
7875
|
+
process.removeListener("SIGTERM", stop);
|
|
7876
|
+
process.removeListener("SIGINT", stop);
|
|
6575
7877
|
}
|
|
6576
7878
|
var program2 = new Command;
|
|
6577
7879
|
program2.name("bridge").description("Agent messaging bridge for Telegram and other channels").version(version());
|
|
@@ -6622,6 +7924,27 @@ channels.command("add-console").argument("<id>").description("Add a console chan
|
|
|
6622
7924
|
const config2 = await upsertChannel({ id, kind: "console", enabled: true }, options.config);
|
|
6623
7925
|
options.json ? asJson(config2.channels[id]) : console.log(`Added console channel ${id}`);
|
|
6624
7926
|
});
|
|
7927
|
+
channels.command("add-imessage").argument("<id>").description("Add a local macOS iMessage channel").option("--default-handle <handle>", "default iMessage handle for bridge send").option("--allowed-handles <handles>", "comma-separated allowed handles").option("--allow-all-handles", "explicitly allow every local iMessage handle").option("--account <account>", "Messages account selector for multi-account Macs").option("--service-name <name>", "Messages service name", "iMessage").option("--receive", "enable local Messages chat.db polling").option("--chat-db-path <path>", "override Messages chat.db path").option("--poll-limit <n>", "maximum rows per poll").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (id, options) => {
|
|
7928
|
+
const allowedHandles = splitCsv(options.allowedHandles);
|
|
7929
|
+
if (!allowedHandles?.length && !options.allowAllHandles) {
|
|
7930
|
+
throw new Error("iMessage channels require --allowed-handles or explicit --allow-all-handles");
|
|
7931
|
+
}
|
|
7932
|
+
const pollLimit = options.pollLimit ? Number.parseInt(options.pollLimit, 10) : undefined;
|
|
7933
|
+
const config2 = await upsertChannel({
|
|
7934
|
+
id,
|
|
7935
|
+
kind: "imessage",
|
|
7936
|
+
enabled: true,
|
|
7937
|
+
defaultHandle: options.defaultHandle,
|
|
7938
|
+
allowedHandles,
|
|
7939
|
+
allowAllHandles: Boolean(options.allowAllHandles),
|
|
7940
|
+
account: options.account,
|
|
7941
|
+
serviceName: options.serviceName,
|
|
7942
|
+
receiveMode: options.receive ? "chat-db" : "disabled",
|
|
7943
|
+
chatDbPath: options.chatDbPath,
|
|
7944
|
+
pollLimit
|
|
7945
|
+
}, options.config);
|
|
7946
|
+
options.json ? asJson(config2.channels[id]) : console.log(`Added imessage channel ${id}`);
|
|
7947
|
+
});
|
|
6625
7948
|
var profiles = program2.command("profiles").description("Manage reusable agent profiles");
|
|
6626
7949
|
profiles.command("list").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (options) => {
|
|
6627
7950
|
const config2 = await loadConfig(options.config);
|
|
@@ -6678,14 +8001,103 @@ routes.command("add").argument("<id>").requiredOption("--from <channel>", "sourc
|
|
|
6678
8001
|
}, options.config);
|
|
6679
8002
|
options.json ? asJson(config2.routes.find((route) => route.id === id)) : console.log(`Added route ${id}`);
|
|
6680
8003
|
});
|
|
8004
|
+
var sessions2 = program2.command("sessions").description("Manage durable bridge sessions and channel bindings");
|
|
8005
|
+
sessions2.command("list").description("List bridge sessions").option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (options) => {
|
|
8006
|
+
const state2 = await loadState(options.state);
|
|
8007
|
+
const items = listBridgeSessions(state2);
|
|
8008
|
+
options.json ? asJson(items) : printList(items);
|
|
8009
|
+
});
|
|
8010
|
+
sessions2.command("show").argument("<id>").description("Show one bridge session").option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (id, options) => {
|
|
8011
|
+
const state2 = await loadState(options.state);
|
|
8012
|
+
const session = getBridgeSession(state2, id);
|
|
8013
|
+
options.json ? asJson(session) : console.log(JSON.stringify(session, null, 2));
|
|
8014
|
+
});
|
|
8015
|
+
sessions2.command("create").description("Create a bridge-owned session for an agent").requiredOption("--agent <id>", "agent id").option("--id <id>", "explicit session id").option("--title <text>", "session title").option("--cwd <path>", "session working directory override").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (options) => {
|
|
8016
|
+
const config2 = await loadConfig(options.config);
|
|
8017
|
+
const state2 = await loadState(options.state);
|
|
8018
|
+
const session = createBridgeSession(config2, state2, {
|
|
8019
|
+
id: options.id,
|
|
8020
|
+
agentId: options.agent,
|
|
8021
|
+
title: options.title,
|
|
8022
|
+
cwd: options.cwd
|
|
8023
|
+
});
|
|
8024
|
+
await saveState(state2, options.state);
|
|
8025
|
+
options.json ? asJson(session) : console.log(session.id);
|
|
8026
|
+
});
|
|
8027
|
+
async function attachSessionAction(sessionId, options) {
|
|
8028
|
+
const config2 = await loadConfig(options.config);
|
|
8029
|
+
const state2 = await loadState(options.state);
|
|
8030
|
+
const binding = attachBridgeSession(config2, state2, {
|
|
8031
|
+
sessionId,
|
|
8032
|
+
channelId: options.channel,
|
|
8033
|
+
conversation: options.conversation,
|
|
8034
|
+
makeDefault: Boolean(options.default)
|
|
8035
|
+
});
|
|
8036
|
+
await saveState(state2, options.state);
|
|
8037
|
+
options.json ? asJson(binding) : console.log(binding.id);
|
|
8038
|
+
}
|
|
8039
|
+
sessions2.command("attach").argument("<id>").description("Attach a session to a channel conversation").requiredOption("--channel <id>", "channel id").requiredOption("--conversation <id>", "external conversation id, such as a Telegram chat id").option("--default", "also make this the default session for the conversation").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(attachSessionAction);
|
|
8040
|
+
sessions2.command("use").argument("<id>").description("Set the active session for a channel conversation").requiredOption("--channel <id>", "channel id").requiredOption("--conversation <id>", "external conversation id, such as a Telegram chat id").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (id, options) => attachSessionAction(id, { ...options, default: true }));
|
|
8041
|
+
sessions2.command("detach").description("Detach the active session from a channel conversation").requiredOption("--channel <id>", "channel id").requiredOption("--conversation <id>", "external conversation id, such as a Telegram chat id").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (options) => {
|
|
8042
|
+
const config2 = await loadConfig(options.config);
|
|
8043
|
+
const state2 = await loadState(options.state);
|
|
8044
|
+
const binding = detachBridgeBinding(config2, state2, options.channel, options.conversation);
|
|
8045
|
+
await saveState(state2, options.state);
|
|
8046
|
+
options.json ? asJson(binding || null) : console.log(binding ? `detached ${binding.id}` : "No binding.");
|
|
8047
|
+
});
|
|
8048
|
+
for (const status of ["pause", "resume", "close"]) {
|
|
8049
|
+
const nextStatus = status === "pause" ? "paused" : status === "resume" ? "active" : "closed";
|
|
8050
|
+
sessions2.command(status).argument("<id>").description(`${status} a bridge session`).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (id, options) => {
|
|
8051
|
+
const state2 = await loadState(options.state);
|
|
8052
|
+
const session = updateBridgeSessionStatus(state2, id, nextStatus);
|
|
8053
|
+
await saveState(state2, options.state);
|
|
8054
|
+
options.json ? asJson(session) : console.log(`${session.id} ${session.status}`);
|
|
8055
|
+
});
|
|
8056
|
+
}
|
|
8057
|
+
sessions2.command("send").argument("<id>").argument("<text...>").description("Send one message directly to a bridge session").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (id, textParts, options) => {
|
|
8058
|
+
const config2 = await loadConfig(options.config);
|
|
8059
|
+
const state2 = await loadState(options.state);
|
|
8060
|
+
const message = {
|
|
8061
|
+
id: `cli:${Date.now()}`,
|
|
8062
|
+
channelId: "cli",
|
|
8063
|
+
text: textParts.join(" "),
|
|
8064
|
+
receivedAt: new Date().toISOString()
|
|
8065
|
+
};
|
|
8066
|
+
const result = await sendBridgeSessionMessage(config2, state2, id, message, { writeConsole: false });
|
|
8067
|
+
await saveState(state2, options.state);
|
|
8068
|
+
if (options.json)
|
|
8069
|
+
asJson(result);
|
|
8070
|
+
else
|
|
8071
|
+
process.stdout.write(result.agent?.stdout || result.agent?.stderr || result.message || "");
|
|
8072
|
+
process.exitCode = result.agent?.exitCode ?? 0;
|
|
8073
|
+
});
|
|
8074
|
+
sessions2.command("route-message").description("Route one synthetic message through session bindings").requiredOption("--channel <id>", "source channel id").requiredOption("--text <text>", "message text").option("--chat-id <id>", "chat id").option("--from <from>", "sender").option("--fallback-routes", "fall back to compatibility routes when no session is bound").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "output JSON").action(async (options) => {
|
|
8075
|
+
const config2 = await loadConfig(options.config);
|
|
8076
|
+
const state2 = await loadState(options.state);
|
|
8077
|
+
const result = await dispatchMessageWithSessions(config2, state2, {
|
|
8078
|
+
id: `cli:${Date.now()}`,
|
|
8079
|
+
channelId: options.channel,
|
|
8080
|
+
text: options.text,
|
|
8081
|
+
chatId: options.chatId,
|
|
8082
|
+
from: options.from,
|
|
8083
|
+
receivedAt: new Date().toISOString()
|
|
8084
|
+
}, {
|
|
8085
|
+
writeConsole: options.json ? false : undefined,
|
|
8086
|
+
fallbackToRoutes: Boolean(options.fallbackRoutes),
|
|
8087
|
+
persistState: async (nextState) => saveState(nextState, options.state)
|
|
8088
|
+
});
|
|
8089
|
+
await saveState(state2, options.state);
|
|
8090
|
+
options.json ? asJson(result) : printList(result.session ? [result.session] : result.routes || []);
|
|
8091
|
+
});
|
|
6681
8092
|
program2.command("send").argument("<channel>").argument("[chatId]").argument("[text...]").description("Send a message through a channel").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (channelId, chatId, textParts, options) => {
|
|
6682
8093
|
const config2 = await loadConfig(options.config);
|
|
6683
8094
|
const channel = config2.channels[channelId];
|
|
6684
8095
|
if (!channel)
|
|
6685
8096
|
throw new Error(`Channel not found: ${channelId}`);
|
|
6686
8097
|
let targetChat = chatId;
|
|
6687
|
-
|
|
6688
|
-
|
|
8098
|
+
const textArgParts = textParts;
|
|
8099
|
+
let text = textArgParts.join(" ");
|
|
8100
|
+
if (channel.kind === "console" && !text && targetChat) {
|
|
6689
8101
|
text = targetChat;
|
|
6690
8102
|
targetChat = undefined;
|
|
6691
8103
|
}
|
|
@@ -6707,6 +8119,26 @@ program2.command("send").argument("<channel>").argument("[chatId]").argument("[t
|
|
|
6707
8119
|
console.log(text);
|
|
6708
8120
|
return;
|
|
6709
8121
|
}
|
|
8122
|
+
if (channel.kind === "imessage") {
|
|
8123
|
+
if (!text && targetChat) {
|
|
8124
|
+
const looksLikeHandle = targetChat.startsWith("+") || targetChat.includes("@") || imessageHandleAllowed(channel, targetChat);
|
|
8125
|
+
if (looksLikeHandle)
|
|
8126
|
+
throw new Error("message text is required when an iMessage handle is provided");
|
|
8127
|
+
text = targetChat;
|
|
8128
|
+
targetChat = undefined;
|
|
8129
|
+
}
|
|
8130
|
+
targetChat = targetChat || channel.defaultHandle;
|
|
8131
|
+
if (!targetChat)
|
|
8132
|
+
throw new Error("chatId/handle argument or channel.defaultHandle is required");
|
|
8133
|
+
if (!text)
|
|
8134
|
+
throw new Error("message text is required");
|
|
8135
|
+
if (!imessageHandleAllowed(channel, targetChat)) {
|
|
8136
|
+
throw new Error(`iMessage handle ${targetChat} is not allowed for channel ${channel.id}`);
|
|
8137
|
+
}
|
|
8138
|
+
const result = await sendIMessage(channel, targetChat, text);
|
|
8139
|
+
options.json ? asJson(result) : console.log("sent");
|
|
8140
|
+
return;
|
|
8141
|
+
}
|
|
6710
8142
|
throw new Error(`Sending through ${channel.kind} is not implemented yet`);
|
|
6711
8143
|
});
|
|
6712
8144
|
program2.command("ask").argument("<agent>").argument("<text...>").description("Run one agent directly").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (agentId, textParts, options) => {
|
|
@@ -6722,6 +8154,84 @@ program2.command("ask").argument("<agent>").argument("<text...>").description("R
|
|
|
6722
8154
|
process.exitCode = result.exitCode ?? 1;
|
|
6723
8155
|
});
|
|
6724
8156
|
program2.command("serve").description("Poll configured channels and route messages to agents").option("--once", "poll once and exit").option("--interval <ms>", "delay between polls", "1000").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--json", "emit routed message JSON").action(runServe);
|
|
8157
|
+
var daemon2 = program2.command("daemon").description("Manage the bridge background daemon");
|
|
8158
|
+
daemon2.command("status").description("Show daemon status").option("--supervisor <type>", "process, launchd, systemd, or auto", "process").option("--daemon-dir <path>", "daemon metadata/log directory").option("--json", "output JSON").action(async (options) => {
|
|
8159
|
+
const status = await daemonStatus({ supervisor: options.supervisor, daemonDir: options.daemonDir });
|
|
8160
|
+
if (options.json)
|
|
8161
|
+
asJson(status);
|
|
8162
|
+
else {
|
|
8163
|
+
console.log(`${status.running ? "running" : status.stale ? "stale" : "stopped"} ${status.supervisor}${status.pid ? ` pid=${status.pid}` : ""}`);
|
|
8164
|
+
console.log(`daemonDir=${status.paths.dir}`);
|
|
8165
|
+
console.log(`stdout=${status.paths.stdoutLog}`);
|
|
8166
|
+
console.log(`stderr=${status.paths.stderrLog}`);
|
|
8167
|
+
if (status.telegramApiBase.error) {
|
|
8168
|
+
console.log(`telegramApiBaseError=${status.telegramApiBase.error}`);
|
|
8169
|
+
} else if (status.telegramApiBase.overridden) {
|
|
8170
|
+
console.log(`telegramApiBase=${status.telegramApiBase.origin}${status.telegramApiBase.pathname}`);
|
|
8171
|
+
}
|
|
8172
|
+
}
|
|
8173
|
+
});
|
|
8174
|
+
daemon2.command("start").description("Start bridge serve in the background").option("--supervisor <type>", "process, launchd, systemd, or auto", "process").option("--daemon-dir <path>", "daemon metadata/log directory").option("--interval <ms>", "delay between polls", "1000").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--serve-json", "emit routed message JSON to daemon stdout log").option("--json", "output JSON").action(async (options) => {
|
|
8175
|
+
const intervalMs = parseNonNegativeInt(options.interval, "--interval");
|
|
8176
|
+
const supervisor = options.supervisor;
|
|
8177
|
+
const result = supervisor === "process" ? await startProcessDaemon({ daemonDir: options.daemonDir, configPath: options.config, statePath: options.state, intervalMs, serveJson: options.serveJson }) : await startInstalledDaemon({ supervisor, daemonDir: options.daemonDir, configPath: options.config, statePath: options.state, intervalMs, serveJson: options.serveJson });
|
|
8178
|
+
options.json ? asJson(result) : console.log("started");
|
|
8179
|
+
});
|
|
8180
|
+
daemon2.command("stop").description("Stop the bridge daemon").option("--supervisor <type>", "process, launchd, systemd, or auto", "process").option("--daemon-dir <path>", "daemon metadata/log directory").option("--timeout-ms <ms>", "graceful stop timeout", "5000").option("--force", "force kill after timeout").option("--json", "output JSON").action(async (options) => {
|
|
8181
|
+
const timeoutMs = parseNonNegativeInt(options.timeoutMs, "--timeout-ms");
|
|
8182
|
+
const result = options.supervisor === "process" ? await stopProcessDaemon({ daemonDir: options.daemonDir, timeoutMs, force: options.force }) : await stopInstalledDaemon({ supervisor: options.supervisor, daemonDir: options.daemonDir, timeoutMs, force: options.force });
|
|
8183
|
+
options.json ? asJson(result || { stopped: true }) : console.log("stopped");
|
|
8184
|
+
});
|
|
8185
|
+
daemon2.command("restart").description("Restart the bridge daemon").option("--supervisor <type>", "process, launchd, systemd, or auto", "process").option("--daemon-dir <path>", "daemon metadata/log directory").option("--interval <ms>", "delay between polls").option("-c, --config <path>", "config path").option("--state <path>", "state path").option("--serve-json", "emit routed message JSON to daemon stdout log").option("--timeout-ms <ms>", "graceful stop timeout", "5000").option("--force", "force kill after timeout").option("--json", "output JSON").action(async (options) => {
|
|
8186
|
+
const restartOptions = {
|
|
8187
|
+
supervisor: options.supervisor,
|
|
8188
|
+
daemonDir: options.daemonDir,
|
|
8189
|
+
configPath: options.config,
|
|
8190
|
+
statePath: options.state,
|
|
8191
|
+
intervalMs: options.interval ? parseNonNegativeInt(options.interval, "--interval") : undefined,
|
|
8192
|
+
serveJson: options.serveJson,
|
|
8193
|
+
timeoutMs: parseNonNegativeInt(options.timeoutMs, "--timeout-ms"),
|
|
8194
|
+
force: options.force
|
|
8195
|
+
};
|
|
8196
|
+
const result = options.supervisor === "process" ? await restartProcessDaemon(restartOptions) : await restartInstalledDaemon(restartOptions);
|
|
8197
|
+
options.json ? asJson(result) : console.log("restarted");
|
|
8198
|
+
});
|
|
8199
|
+
daemon2.command("logs").description("Print daemon logs").option("--daemon-dir <path>", "daemon metadata/log directory").option("--lines <n>", "number of lines", "100").option("--follow", "follow logs").option("--json", "output JSON").action(async (options) => {
|
|
8200
|
+
const lines = parseNonNegativeInt(options.lines, "--lines") || 100;
|
|
8201
|
+
if (options.follow) {
|
|
8202
|
+
const paths2 = daemonPaths(options.daemonDir);
|
|
8203
|
+
const tail = Bun.spawn(["tail", "-n", String(lines), "-f", paths2.stdoutLog, paths2.stderrLog], {
|
|
8204
|
+
stdout: "inherit",
|
|
8205
|
+
stderr: "inherit"
|
|
8206
|
+
});
|
|
8207
|
+
await tail.exited;
|
|
8208
|
+
return;
|
|
8209
|
+
}
|
|
8210
|
+
const logs = await daemonLogs({ daemonDir: options.daemonDir, lines });
|
|
8211
|
+
if (options.json)
|
|
8212
|
+
asJson(logs);
|
|
8213
|
+
else {
|
|
8214
|
+
if (logs.stdout)
|
|
8215
|
+
console.log(logs.stdout);
|
|
8216
|
+
if (logs.stderr)
|
|
8217
|
+
console.error(logs.stderr);
|
|
8218
|
+
}
|
|
8219
|
+
});
|
|
8220
|
+
daemon2.command("install").description("Write launchd or systemd user supervisor files").option("--supervisor <type>", "launchd, systemd, or auto", "auto").option("--daemon-dir <path>", "daemon metadata/log directory").option("--interval <ms>", "delay between polls", "1000").option("-c, --config <path>", "config path", defaultConfigPath()).option("--state <path>", "state path", defaultStatePath()).option("--serve-json", "emit routed message JSON to daemon stdout log").option("--json", "output JSON").action(async (options) => {
|
|
8221
|
+
const result = await installDaemon({
|
|
8222
|
+
supervisor: options.supervisor,
|
|
8223
|
+
daemonDir: options.daemonDir,
|
|
8224
|
+
configPath: options.config,
|
|
8225
|
+
statePath: options.state,
|
|
8226
|
+
intervalMs: parseNonNegativeInt(options.interval, "--interval"),
|
|
8227
|
+
serveJson: options.serveJson
|
|
8228
|
+
});
|
|
8229
|
+
options.json ? asJson(result) : console.log(`installed ${result.supervisor}: ${result.path}`);
|
|
8230
|
+
});
|
|
8231
|
+
daemon2.command("uninstall").description("Remove launchd/systemd supervisor files or process metadata").option("--supervisor <type>", "process, launchd, systemd, or auto", "auto").option("--daemon-dir <path>", "daemon metadata/log directory").option("--json", "output JSON").action(async (options) => {
|
|
8232
|
+
const result = await uninstallDaemon({ supervisor: options.supervisor, daemonDir: options.daemonDir });
|
|
8233
|
+
options.json ? asJson(result) : console.log(`removed ${result.removed.join(", ")}`);
|
|
8234
|
+
});
|
|
6725
8235
|
program2.command("route-message").description("Route one synthetic message; useful for tests and MCP-style probes").requiredOption("--channel <id>", "source channel id").requiredOption("--text <text>", "message text").option("--chat-id <id>", "chat id").option("--from <from>", "sender").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (options) => {
|
|
6726
8236
|
const config2 = await loadConfig(options.config);
|
|
6727
8237
|
const result = await routeMessage(config2, {
|