@hasna/bridge 0.1.0 → 0.1.2

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/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 dirname3, join as join3 } from "path";
2067
+ import { dirname as dirname4, join as join4 } from "path";
2068
2068
  import { fileURLToPath } from "url";
2069
2069
 
2070
2070
  // node_modules/commander/esm.mjs
@@ -6304,55 +6304,73 @@ async function upsertRoute(route, configPath = defaultConfigPath()) {
6304
6304
  await saveConfig(config, configPath);
6305
6305
  return config;
6306
6306
  }
6307
- // src/lib/doctor.ts
6308
- import { access } from "fs/promises";
6309
- async function commandExists(command) {
6310
- const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
6311
- stdout: "ignore",
6312
- stderr: "ignore"
6313
- });
6314
- return await proc.exited === 0;
6307
+ // src/lib/daemon.ts
6308
+ import { spawn } from "child_process";
6309
+ import { closeSync, openSync } from "fs";
6310
+ import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
6311
+ import { dirname as dirname3, join as join3, resolve } from "path";
6312
+
6313
+ // src/lib/state.ts
6314
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
6315
+ import { dirname as dirname2, join as join2 } from "path";
6316
+ function defaultStatePath() {
6317
+ return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
6315
6318
  }
6316
- async function doctor(configPath = defaultConfigPath()) {
6317
- const checks = [];
6318
- let config = await loadConfig(configPath);
6319
+ function emptyState() {
6320
+ return { telegramOffsets: {} };
6321
+ }
6322
+ async function loadState(statePath = defaultStatePath()) {
6319
6323
  try {
6320
- await access(configPath);
6321
- checks.push({ name: "config", ok: true, detail: configPath });
6322
- } catch {
6323
- checks.push({ name: "config", ok: true, detail: `not created yet: ${configPath}` });
6324
+ const raw = await readFile2(statePath, "utf-8");
6325
+ const parsed = JSON.parse(raw);
6326
+ return {
6327
+ telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
6328
+ };
6329
+ } catch (err) {
6330
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
6331
+ return emptyState();
6332
+ }
6333
+ throw err;
6324
6334
  }
6325
- for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
6326
- checks.push({
6327
- name: `command:${command}`,
6328
- ok: command === "bridge" ? true : await commandExists(command),
6329
- detail: command === "bridge" ? "current package" : undefined
6330
- });
6335
+ }
6336
+ async function saveState(state, statePath = defaultStatePath()) {
6337
+ await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
6338
+ await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
6339
+ `, { encoding: "utf-8", mode: 384 });
6340
+ await chmod2(statePath, 384);
6341
+ }
6342
+
6343
+ // src/lib/telegram.ts
6344
+ var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
6345
+ function telegramApiBase() {
6346
+ const raw = process.env["BRIDGE_TELEGRAM_API_BASE"] || DEFAULT_TELEGRAM_API_BASE;
6347
+ const parsed = new URL(raw);
6348
+ if (!["http:", "https:"].includes(parsed.protocol)) {
6349
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must use http or https");
6331
6350
  }
6332
- const telegramChannels = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
6333
- for (const channel of telegramChannels) {
6334
- const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
6335
- checks.push({
6336
- name: `telegram-token:${channel.id}`,
6337
- ok: Boolean(process.env[envName]),
6338
- detail: envName
6339
- });
6340
- checks.push({
6341
- name: `telegram-allowlist:${channel.id}`,
6342
- ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
6343
- detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
6344
- });
6351
+ if (parsed.username || parsed.password) {
6352
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain credentials");
6345
6353
  }
6346
- for (const route of config.routes) {
6347
- checks.push({
6348
- name: `route:${route.id}`,
6349
- ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
6350
- detail: `${route.fromChannel} -> ${route.toAgent}`
6351
- });
6354
+ if (parsed.search || parsed.hash) {
6355
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain query strings or fragments");
6352
6356
  }
6353
- return { ok: checks.every((check) => check.ok), configPath, checks };
6357
+ return parsed;
6358
+ }
6359
+ function telegramApiBaseInfo() {
6360
+ const parsed = telegramApiBase();
6361
+ return {
6362
+ overridden: parsed.href.replace(/\/$/, "") !== DEFAULT_TELEGRAM_API_BASE,
6363
+ origin: parsed.origin,
6364
+ pathname: parsed.pathname
6365
+ };
6366
+ }
6367
+ function telegramMethodUrl(token, method) {
6368
+ const base = telegramApiBase();
6369
+ const prefix = base.pathname.replace(/\/$/, "");
6370
+ base.pathname = `${prefix}/bot${token}/${method}`;
6371
+ base.search = "";
6372
+ return base.toString();
6354
6373
  }
6355
- // src/lib/telegram.ts
6356
6374
  function telegramToken(channel) {
6357
6375
  const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
6358
6376
  const token = process.env[envName];
@@ -6368,7 +6386,7 @@ function telegramChatAllowed(channel, chatId) {
6368
6386
  return Boolean(chatId && channel.allowedChatIds.includes(chatId));
6369
6387
  }
6370
6388
  async function sendTelegramMessage(token, chatId, text) {
6371
- const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
6389
+ const response = await fetch(telegramMethodUrl(token, "sendMessage"), {
6372
6390
  method: "POST",
6373
6391
  headers: { "content-type": "application/json" },
6374
6392
  body: JSON.stringify({ chat_id: chatId, text })
@@ -6385,7 +6403,7 @@ async function getTelegramUpdates(token, options = {}) {
6385
6403
  if (options.offset !== undefined)
6386
6404
  params.set("offset", String(options.offset));
6387
6405
  params.set("timeout", String(options.timeoutSeconds ?? 20));
6388
- const response = await fetch(`https://api.telegram.org/bot${token}/getUpdates?${params.toString()}`);
6406
+ const response = await fetch(`${telegramMethodUrl(token, "getUpdates")}?${params.toString()}`);
6389
6407
  const body = await response.json().catch(() => {
6390
6408
  return;
6391
6409
  });
@@ -6410,9 +6428,684 @@ function telegramUpdateToMessage(channelId, update) {
6410
6428
  };
6411
6429
  }
6412
6430
 
6431
+ // src/lib/daemon.ts
6432
+ function isNotFound(err) {
6433
+ return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
6434
+ }
6435
+ function currentPlatformSupervisor() {
6436
+ if (process.platform === "darwin")
6437
+ return "launchd";
6438
+ if (process.platform === "linux")
6439
+ return "systemd";
6440
+ return "process";
6441
+ }
6442
+ function resolveSupervisor(supervisor = "process") {
6443
+ return supervisor === "auto" ? currentPlatformSupervisor() : supervisor;
6444
+ }
6445
+ function defaultDaemonDir() {
6446
+ return join3(bridgeHome(), "daemon");
6447
+ }
6448
+ function daemonPaths(daemonDir = defaultDaemonDir()) {
6449
+ const dir = resolve(daemonDir);
6450
+ return {
6451
+ dir,
6452
+ lockDir: join3(dir, "lock"),
6453
+ metadataFile: join3(dir, "bridge-daemon.json"),
6454
+ stdoutLog: join3(dir, "bridge.out.log"),
6455
+ stderrLog: join3(dir, "bridge.err.log"),
6456
+ launchdPlist: join3(process.env["HOME"] || process.cwd(), "Library", "LaunchAgents", "com.hasna.bridge.plist"),
6457
+ systemdUnit: join3(process.env["HOME"] || process.cwd(), ".config", "systemd", "user", "hasna-bridge.service")
6458
+ };
6459
+ }
6460
+ async function ensureDaemonDir(dir = defaultDaemonDir()) {
6461
+ const paths = daemonPaths(dir);
6462
+ await mkdir3(paths.dir, { recursive: true, mode: 448 });
6463
+ await chmod3(paths.dir, 448);
6464
+ return paths;
6465
+ }
6466
+ async function fileExists(path) {
6467
+ try {
6468
+ await stat(path);
6469
+ return true;
6470
+ } catch (err) {
6471
+ if (isNotFound(err))
6472
+ return false;
6473
+ throw err;
6474
+ }
6475
+ }
6476
+ async function readMetadata(paths) {
6477
+ try {
6478
+ return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
6479
+ } catch (err) {
6480
+ if (isNotFound(err))
6481
+ return;
6482
+ throw err;
6483
+ }
6484
+ }
6485
+ async function writeMetadata(paths, metadata) {
6486
+ const tmp = `${paths.metadataFile}.${process.pid}.${Date.now()}.tmp`;
6487
+ await writeFile3(tmp, `${JSON.stringify(metadata, null, 2)}
6488
+ `, { encoding: "utf-8", mode: 384 });
6489
+ await chmod3(tmp, 384);
6490
+ await rename(tmp, paths.metadataFile);
6491
+ await chmod3(paths.metadataFile, 384);
6492
+ }
6493
+ async function withDaemonLock(paths, fn) {
6494
+ try {
6495
+ await mkdir3(paths.lockDir, { mode: 448 });
6496
+ } catch (err) {
6497
+ if (err && typeof err === "object" && "code" in err && err.code === "EEXIST") {
6498
+ throw new Error(`Another bridge daemon operation is already running: ${paths.lockDir}`);
6499
+ }
6500
+ throw err;
6501
+ }
6502
+ try {
6503
+ return await fn();
6504
+ } finally {
6505
+ await rmdir(paths.lockDir).catch(() => {
6506
+ return;
6507
+ });
6508
+ }
6509
+ }
6510
+ function pidAlive(pid) {
6511
+ try {
6512
+ process.kill(pid, 0);
6513
+ return true;
6514
+ } catch {
6515
+ return false;
6516
+ }
6517
+ }
6518
+ async function processCommand(pid) {
6519
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], {
6520
+ stdout: "pipe",
6521
+ stderr: "ignore"
6522
+ });
6523
+ if (await proc.exited !== 0)
6524
+ return;
6525
+ return (await new Response(proc.stdout).text()).trim();
6526
+ }
6527
+ async function processPgid(pid) {
6528
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pgid="], {
6529
+ stdout: "pipe",
6530
+ stderr: "ignore"
6531
+ });
6532
+ if (await proc.exited !== 0)
6533
+ return;
6534
+ const parsed = Number.parseInt((await new Response(proc.stdout).text()).trim(), 10);
6535
+ return Number.isInteger(parsed) ? parsed : undefined;
6536
+ }
6537
+ function shellQuote(value) {
6538
+ return `'${value.replaceAll("'", "'\\''")}'`;
6539
+ }
6540
+ function commandPattern(command) {
6541
+ return command.map(shellQuote).join(" ");
6542
+ }
6543
+ async function processMatches(metadata) {
6544
+ if (!pidAlive(metadata.pid))
6545
+ return false;
6546
+ const command = await processCommand(metadata.pid);
6547
+ if (!command)
6548
+ return false;
6549
+ if (!metadata.pgid)
6550
+ return false;
6551
+ const pgid = await processPgid(metadata.pid);
6552
+ if (pgid !== metadata.pgid)
6553
+ return false;
6554
+ const requiredArgs = [
6555
+ metadata.command[1],
6556
+ "serve",
6557
+ "--config",
6558
+ metadata.configPath,
6559
+ "--state",
6560
+ metadata.statePath,
6561
+ "--interval",
6562
+ String(metadata.intervalMs)
6563
+ ].filter((arg) => Boolean(arg));
6564
+ if (metadata.serveJson)
6565
+ requiredArgs.push("--json");
6566
+ return requiredArgs.every((arg) => command.includes(arg));
6567
+ }
6568
+ async function removeMetadata(paths) {
6569
+ await rm(paths.metadataFile, { force: true });
6570
+ }
6571
+ function safeTelegramApiBaseInfo() {
6572
+ try {
6573
+ return telegramApiBaseInfo();
6574
+ } catch (err) {
6575
+ return {
6576
+ overridden: true,
6577
+ origin: "",
6578
+ pathname: "",
6579
+ error: err instanceof Error ? err.message : String(err)
6580
+ };
6581
+ }
6582
+ }
6583
+ function startCommand(options) {
6584
+ const scriptPath = process.argv[1];
6585
+ const base = scriptPath ? [process.execPath, scriptPath] : ["bridge"];
6586
+ const command = [
6587
+ ...base,
6588
+ "serve",
6589
+ "--config",
6590
+ options.configPath,
6591
+ "--state",
6592
+ options.statePath,
6593
+ "--interval",
6594
+ String(options.intervalMs)
6595
+ ];
6596
+ if (options.serveJson)
6597
+ command.push("--json");
6598
+ return command;
6599
+ }
6600
+ function telegramChannels(config) {
6601
+ return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
6602
+ }
6603
+ function requiredTelegramEnvVars(config) {
6604
+ return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
6605
+ }
6606
+ async function validateStartConfig(configPath) {
6607
+ const config = await loadConfig(configPath);
6608
+ const channels = telegramChannels(config);
6609
+ if (!channels.length)
6610
+ throw new Error("No enabled Telegram channels configured; add one before starting the daemon");
6611
+ for (const envName of requiredTelegramEnvVars(config)) {
6612
+ if (!process.env[envName])
6613
+ throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
6614
+ }
6615
+ }
6616
+ function openPrivateLog(path) {
6617
+ const fd = openSync(path, "a", 384);
6618
+ return fd;
6619
+ }
6620
+ async function ensurePrivateLogFiles(paths) {
6621
+ for (const path of [paths.stdoutLog, paths.stderrLog]) {
6622
+ const fd = openPrivateLog(path);
6623
+ closeSync(fd);
6624
+ await chmod3(path, 384);
6625
+ }
6626
+ }
6627
+ async function runCapture(command) {
6628
+ const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
6629
+ const [exitCode, stdout, stderr] = await Promise.all([
6630
+ proc.exited,
6631
+ new Response(proc.stdout).text(),
6632
+ new Response(proc.stderr).text()
6633
+ ]);
6634
+ return { exitCode, stdout, stderr };
6635
+ }
6636
+ async function installedSupervisorStatus(supervisor, paths) {
6637
+ if (supervisor === "launchd") {
6638
+ if (!await fileExists(paths.launchdPlist))
6639
+ return { running: false, detail: "launchd plist not installed" };
6640
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
6641
+ if (uid === undefined)
6642
+ return { running: false, detail: "launchd status requires a numeric uid" };
6643
+ const result = await runCapture(["launchctl", "print", `gui/${uid}/com.hasna.bridge`]);
6644
+ if (result.exitCode !== 0)
6645
+ return { running: false, detail: result.stderr.trim() || result.stdout.trim() || "launchd service not loaded" };
6646
+ const running = /state\s*=\s*running/.test(result.stdout);
6647
+ return { running, detail: running ? "launchd running" : "launchd loaded but not running" };
6648
+ }
6649
+ if (supervisor === "systemd") {
6650
+ if (!await fileExists(paths.systemdUnit))
6651
+ return { running: false, detail: "systemd unit not installed" };
6652
+ const result = await runCapture(["systemctl", "--user", "is-active", "hasna-bridge.service"]);
6653
+ const state = result.stdout.trim() || result.stderr.trim() || "unknown";
6654
+ return { running: result.exitCode === 0 && state === "active", detail: `systemd ${state}` };
6655
+ }
6656
+ return { running: false, detail: "process supervisor has no installed status" };
6657
+ }
6658
+ async function daemonStatus(options = {}) {
6659
+ const supervisor = resolveSupervisor(options.supervisor);
6660
+ const paths = daemonPaths(options.daemonDir);
6661
+ const metadata = await readMetadata(paths);
6662
+ const live = metadata ? await processMatches(metadata) : false;
6663
+ const stale = Boolean(metadata && !live);
6664
+ const startedAt = metadata?.startedAt;
6665
+ const uptimeSeconds = live && startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)) : undefined;
6666
+ const installed = {
6667
+ launchd: await fileExists(paths.launchdPlist),
6668
+ systemd: await fileExists(paths.systemdUnit)
6669
+ };
6670
+ const installedRuntime = supervisor === "process" ? undefined : await installedSupervisorStatus(supervisor, paths);
6671
+ return {
6672
+ running: installedRuntime ? installedRuntime.running : live,
6673
+ stale: installedRuntime ? false : stale,
6674
+ supervisor,
6675
+ pid: metadata?.pid,
6676
+ startedAt,
6677
+ uptimeSeconds,
6678
+ detail: installedRuntime?.detail || (stale ? "stale process metadata" : live ? "running" : "not running"),
6679
+ installedDetail: installedRuntime?.detail,
6680
+ metadata,
6681
+ paths,
6682
+ installed,
6683
+ telegramApiBase: safeTelegramApiBaseInfo()
6684
+ };
6685
+ }
6686
+ async function startProcessDaemon(options = {}) {
6687
+ const paths = await ensureDaemonDir(options.daemonDir);
6688
+ return withDaemonLock(paths, async () => {
6689
+ const existing = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
6690
+ if (existing.running)
6691
+ return existing;
6692
+ if (existing.stale)
6693
+ await removeMetadata(paths);
6694
+ const configPath = resolve(options.configPath || defaultConfigPath());
6695
+ const statePath = resolve(options.statePath || defaultStatePath());
6696
+ const intervalMs = options.intervalMs ?? 1000;
6697
+ const serveJson = Boolean(options.serveJson);
6698
+ if (!Number.isInteger(intervalMs) || intervalMs < 0)
6699
+ throw new Error("--interval must be a non-negative integer");
6700
+ await validateStartConfig(configPath);
6701
+ const stdoutFd = openPrivateLog(paths.stdoutLog);
6702
+ const stderrFd = openPrivateLog(paths.stderrLog);
6703
+ try {
6704
+ const command = startCommand({ configPath, statePath, intervalMs, serveJson });
6705
+ const child = spawn(command[0], command.slice(1), {
6706
+ cwd: process.cwd(),
6707
+ detached: true,
6708
+ env: process.env,
6709
+ stdio: ["ignore", stdoutFd, stderrFd]
6710
+ });
6711
+ child.unref();
6712
+ const metadata = {
6713
+ version: 1,
6714
+ supervisor: "process",
6715
+ pid: child.pid || 0,
6716
+ pgid: child.pid || undefined,
6717
+ startedAt: new Date().toISOString(),
6718
+ identity: {
6719
+ command: commandPattern(command),
6720
+ cwd: process.cwd(),
6721
+ configPath,
6722
+ statePath,
6723
+ daemonDir: paths.dir,
6724
+ bridgeHome: bridgeHome()
6725
+ },
6726
+ command,
6727
+ cwd: process.cwd(),
6728
+ configPath,
6729
+ statePath,
6730
+ intervalMs,
6731
+ serveJson,
6732
+ daemonDir: paths.dir,
6733
+ bridgeHome: bridgeHome(),
6734
+ stdoutLog: paths.stdoutLog,
6735
+ stderrLog: paths.stderrLog
6736
+ };
6737
+ if (!metadata.pid)
6738
+ throw new Error("Failed to start bridge daemon process");
6739
+ await writeMetadata(paths, metadata);
6740
+ await Bun.sleep(200);
6741
+ const status = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
6742
+ if (!status.running) {
6743
+ await removeMetadata(paths);
6744
+ throw new Error(`Bridge daemon failed to stay running; inspect ${paths.stderrLog}`);
6745
+ }
6746
+ return status;
6747
+ } finally {
6748
+ closeSync(stdoutFd);
6749
+ closeSync(stderrFd);
6750
+ await chmod3(paths.stdoutLog, 384).catch(() => {
6751
+ return;
6752
+ });
6753
+ await chmod3(paths.stderrLog, 384).catch(() => {
6754
+ return;
6755
+ });
6756
+ }
6757
+ });
6758
+ }
6759
+ async function stopPid(pid, force) {
6760
+ process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
6761
+ }
6762
+ async function waitForExit(pid, timeoutMs) {
6763
+ const started = Date.now();
6764
+ while (Date.now() - started < timeoutMs) {
6765
+ if (!pidAlive(pid))
6766
+ return true;
6767
+ await Bun.sleep(100);
6768
+ }
6769
+ return !pidAlive(pid);
6770
+ }
6771
+ async function stopProcessDaemon(options = {}) {
6772
+ const paths = await ensureDaemonDir(options.daemonDir);
6773
+ return withDaemonLock(paths, async () => {
6774
+ const metadata = await readMetadata(paths);
6775
+ if (!metadata)
6776
+ return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
6777
+ if (!await processMatches(metadata)) {
6778
+ await removeMetadata(paths);
6779
+ return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
6780
+ }
6781
+ await stopPid(metadata.pid, false);
6782
+ let exited = await waitForExit(metadata.pid, options.timeoutMs ?? 5000);
6783
+ if (!exited && options.force) {
6784
+ await stopPid(metadata.pid, true);
6785
+ exited = await waitForExit(metadata.pid, 2000);
6786
+ }
6787
+ if (!exited)
6788
+ throw new Error(`Bridge daemon did not stop within ${options.timeoutMs ?? 5000}ms`);
6789
+ await removeMetadata(paths);
6790
+ return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
6791
+ });
6792
+ }
6793
+ async function restartProcessDaemon(options = {}) {
6794
+ const paths = daemonPaths(options.daemonDir);
6795
+ const metadata = await readMetadata(paths);
6796
+ await stopProcessDaemon(options);
6797
+ return startProcessDaemon({
6798
+ ...options,
6799
+ configPath: options.configPath || metadata?.configPath,
6800
+ statePath: options.statePath || metadata?.statePath,
6801
+ intervalMs: options.intervalMs ?? metadata?.intervalMs,
6802
+ serveJson: options.serveJson ?? metadata?.serveJson
6803
+ });
6804
+ }
6805
+ function xmlEscape(value) {
6806
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
6807
+ }
6808
+ function plistArray(values) {
6809
+ return values.map((value) => ` <string>${xmlEscape(value)}</string>`).join(`
6810
+ `);
6811
+ }
6812
+ function renderLaunchdPlist(command, paths) {
6813
+ return `<?xml version="1.0" encoding="UTF-8"?>
6814
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
6815
+ <plist version="1.0">
6816
+ <dict>
6817
+ <key>Label</key>
6818
+ <string>com.hasna.bridge</string>
6819
+ <key>ProgramArguments</key>
6820
+ <array>
6821
+ ${plistArray(command)}
6822
+ </array>
6823
+ <key>RunAtLoad</key>
6824
+ <true/>
6825
+ <key>KeepAlive</key>
6826
+ <true/>
6827
+ <key>StandardOutPath</key>
6828
+ <string>${xmlEscape(paths.stdoutLog)}</string>
6829
+ <key>StandardErrorPath</key>
6830
+ <string>${xmlEscape(paths.stderrLog)}</string>
6831
+ <key>WorkingDirectory</key>
6832
+ <string>${xmlEscape(process.cwd())}</string>
6833
+ </dict>
6834
+ </plist>
6835
+ `;
6836
+ }
6837
+ function systemdEscape(value) {
6838
+ return value.replaceAll("%", "%%").replaceAll(`
6839
+ `, " ");
6840
+ }
6841
+ function systemdQuote(value) {
6842
+ return `"${systemdEscape(value).replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
6843
+ }
6844
+ function renderSystemdUnit(command, paths) {
6845
+ return `[Unit]
6846
+ Description=Hasna Bridge daemon
6847
+ After=network-online.target
6848
+
6849
+ [Service]
6850
+ Type=simple
6851
+ ExecStart=${command.map(systemdQuote).join(" ")}
6852
+ Restart=always
6853
+ RestartSec=5
6854
+ WorkingDirectory=${systemdEscape(process.cwd())}
6855
+ StandardOutput=append:${systemdEscape(paths.stdoutLog)}
6856
+ StandardError=append:${systemdEscape(paths.stderrLog)}
6857
+
6858
+ [Install]
6859
+ WantedBy=default.target
6860
+ `;
6861
+ }
6862
+ async function installFile(path, content) {
6863
+ await mkdir3(dirname3(path), { recursive: true, mode: 448 });
6864
+ await writeFile3(path, content, { encoding: "utf-8", mode: 384 });
6865
+ await chmod3(path, 384);
6866
+ }
6867
+ async function installDaemon(options = {}) {
6868
+ const supervisor = resolveSupervisor(options.supervisor || "auto");
6869
+ if (supervisor === "process") {
6870
+ throw new Error("The process supervisor does not need install; use `bridge daemon start`");
6871
+ }
6872
+ const paths = await ensureDaemonDir(options.daemonDir);
6873
+ await ensurePrivateLogFiles(paths);
6874
+ const configPath = resolve(options.configPath || defaultConfigPath());
6875
+ const statePath = resolve(options.statePath || defaultStatePath());
6876
+ const intervalMs = options.intervalMs ?? 1000;
6877
+ const serveJson = Boolean(options.serveJson);
6878
+ const command = startCommand({ configPath, statePath, intervalMs, serveJson });
6879
+ const config = await loadConfig(configPath);
6880
+ const requiredEnv = requiredTelegramEnvVars(config);
6881
+ if (supervisor === "launchd") {
6882
+ await installFile(paths.launchdPlist, renderLaunchdPlist(command, paths));
6883
+ return {
6884
+ supervisor,
6885
+ path: paths.launchdPlist,
6886
+ command,
6887
+ requiredEnv,
6888
+ warning: "Telegram token values are not written to launchd files. Set them in the launchd environment before starting."
6889
+ };
6890
+ }
6891
+ await installFile(paths.systemdUnit, renderSystemdUnit(command, paths));
6892
+ return {
6893
+ supervisor,
6894
+ path: paths.systemdUnit,
6895
+ command,
6896
+ requiredEnv,
6897
+ warning: "Telegram token values are not written to systemd files. Import them into the user manager environment before starting."
6898
+ };
6899
+ }
6900
+ async function runCommand(command) {
6901
+ const { exitCode, stdout, stderr } = await runCapture(command);
6902
+ if (exitCode !== 0)
6903
+ throw new Error(`${command.join(" ")} failed (${exitCode}): ${stderr || stdout}`);
6904
+ }
6905
+ async function waitForInstalledRunning(supervisor, paths, timeoutMs = 5000) {
6906
+ const started = Date.now();
6907
+ let last = "";
6908
+ while (Date.now() - started < timeoutMs) {
6909
+ const status = await installedSupervisorStatus(supervisor, paths);
6910
+ last = status.detail;
6911
+ if (status.running)
6912
+ return;
6913
+ await Bun.sleep(250);
6914
+ }
6915
+ throw new Error(`${supervisor} service did not report running: ${last}`);
6916
+ }
6917
+ async function startInstalledDaemon(options = {}) {
6918
+ const result = await installDaemon(options);
6919
+ const paths = daemonPaths(options.daemonDir);
6920
+ if (result.supervisor === "launchd") {
6921
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
6922
+ if (uid === undefined)
6923
+ throw new Error("launchd start requires a numeric uid");
6924
+ await runCommand(["launchctl", "bootstrap", `gui/${uid}`, result.path]).catch(async (err) => {
6925
+ if (!String(err).includes("Input/output error"))
6926
+ throw err;
6927
+ await runCommand(["launchctl", "kickstart", "-k", `gui/${uid}/com.hasna.bridge`]);
6928
+ });
6929
+ await waitForInstalledRunning(result.supervisor, paths);
6930
+ return result;
6931
+ }
6932
+ await runCommand(["systemctl", "--user", "daemon-reload"]);
6933
+ await runCommand(["systemctl", "--user", "enable", "--now", "hasna-bridge.service"]);
6934
+ await waitForInstalledRunning(result.supervisor, paths);
6935
+ return result;
6936
+ }
6937
+ async function stopInstalledDaemon(options = {}) {
6938
+ const supervisor = resolveSupervisor(options.supervisor || "auto");
6939
+ const paths = daemonPaths(options.daemonDir);
6940
+ if (supervisor === "launchd") {
6941
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
6942
+ if (uid === undefined)
6943
+ throw new Error("launchd stop requires a numeric uid");
6944
+ await runCommand(["launchctl", "bootout", `gui/${uid}`, paths.launchdPlist]);
6945
+ return;
6946
+ }
6947
+ if (supervisor === "systemd") {
6948
+ await runCommand(["systemctl", "--user", "disable", "--now", "hasna-bridge.service"]);
6949
+ return;
6950
+ }
6951
+ await stopProcessDaemon(options);
6952
+ }
6953
+ async function restartInstalledDaemon(options = {}) {
6954
+ const supervisor = resolveSupervisor(options.supervisor || "auto");
6955
+ if (supervisor === "process")
6956
+ return restartProcessDaemon(options);
6957
+ await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
6958
+ return;
6959
+ });
6960
+ return startInstalledDaemon({ ...options, supervisor });
6961
+ }
6962
+ async function uninstallDaemon(options = {}) {
6963
+ const supervisor = resolveSupervisor(options.supervisor || "auto");
6964
+ const paths = daemonPaths(options.daemonDir);
6965
+ const removed = [];
6966
+ if (supervisor === "launchd") {
6967
+ await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
6968
+ return;
6969
+ });
6970
+ await rm(paths.launchdPlist, { force: true });
6971
+ removed.push(paths.launchdPlist);
6972
+ } else if (supervisor === "systemd") {
6973
+ await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
6974
+ return;
6975
+ });
6976
+ await rm(paths.systemdUnit, { force: true });
6977
+ await runCommand(["systemctl", "--user", "daemon-reload"]).catch(() => {
6978
+ return;
6979
+ });
6980
+ removed.push(paths.systemdUnit);
6981
+ } else {
6982
+ await stopProcessDaemon({ ...options, supervisor }).catch(() => {
6983
+ return;
6984
+ });
6985
+ await removeMetadata(paths);
6986
+ removed.push(paths.metadataFile);
6987
+ }
6988
+ return { supervisor, removed };
6989
+ }
6990
+ async function tailFile(path, lines) {
6991
+ try {
6992
+ const raw = await readFile3(path, "utf-8");
6993
+ return raw.split(/\r?\n/).slice(-Math.max(1, lines)).join(`
6994
+ `);
6995
+ } catch (err) {
6996
+ if (isNotFound(err))
6997
+ return "";
6998
+ throw err;
6999
+ }
7000
+ }
7001
+ async function daemonLogs(options = {}) {
7002
+ const paths = daemonPaths(options.daemonDir);
7003
+ const lines = options.lines ?? 100;
7004
+ return {
7005
+ stdout: await tailFile(paths.stdoutLog, lines),
7006
+ stderr: await tailFile(paths.stderrLog, lines),
7007
+ paths
7008
+ };
7009
+ }
7010
+ // src/lib/doctor.ts
7011
+ import { stat as stat2 } from "fs/promises";
7012
+ function isNotFound2(err) {
7013
+ return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
7014
+ }
7015
+ async function privateFileCheck(name, path) {
7016
+ try {
7017
+ const info = await stat2(path);
7018
+ const mode = info.mode & 511;
7019
+ const ok = (mode & 63) === 0;
7020
+ return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
7021
+ } catch (err) {
7022
+ if (isNotFound2(err))
7023
+ return { name, ok: true, detail: `not created yet: ${path}` };
7024
+ return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
7025
+ }
7026
+ }
7027
+ async function privateDirCheck(name, path) {
7028
+ try {
7029
+ const info = await stat2(path);
7030
+ const mode = info.mode & 511;
7031
+ const ok = (mode & 63) === 0;
7032
+ return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
7033
+ } catch (err) {
7034
+ if (isNotFound2(err))
7035
+ return { name, ok: true, detail: `not created yet: ${path}` };
7036
+ return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
7037
+ }
7038
+ }
7039
+ async function commandExists(command) {
7040
+ const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
7041
+ stdout: "ignore",
7042
+ stderr: "ignore"
7043
+ });
7044
+ return await proc.exited === 0;
7045
+ }
7046
+ async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
7047
+ const checks = [];
7048
+ const config = await loadConfig(configPath);
7049
+ const daemon = await daemonStatus();
7050
+ const paths = daemonPaths();
7051
+ checks.push(await privateFileCheck("config", configPath));
7052
+ checks.push(await privateFileCheck("state", statePath));
7053
+ checks.push(await privateDirCheck("daemon-dir", paths.dir));
7054
+ checks.push(await privateFileCheck("daemon-metadata", paths.metadataFile));
7055
+ checks.push({
7056
+ name: "daemon-status",
7057
+ ok: !daemon.stale,
7058
+ detail: daemon.running ? `running pid=${daemon.pid}` : daemon.stale ? `stale pid=${daemon.pid}` : "not running"
7059
+ });
7060
+ try {
7061
+ const apiBase = telegramApiBaseInfo();
7062
+ checks.push({
7063
+ name: "telegram-api-base",
7064
+ ok: true,
7065
+ detail: apiBase.overridden ? `overridden: ${apiBase.origin}${apiBase.pathname}` : apiBase.origin
7066
+ });
7067
+ } catch (err) {
7068
+ checks.push({
7069
+ name: "telegram-api-base",
7070
+ ok: false,
7071
+ detail: err instanceof Error ? err.message : String(err)
7072
+ });
7073
+ }
7074
+ for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
7075
+ checks.push({
7076
+ name: `command:${command}`,
7077
+ ok: command === "bridge" ? true : await commandExists(command),
7078
+ detail: command === "bridge" ? "current package" : undefined
7079
+ });
7080
+ }
7081
+ const telegramChannels2 = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
7082
+ for (const channel of telegramChannels2) {
7083
+ const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
7084
+ checks.push({
7085
+ name: `telegram-token:${channel.id}`,
7086
+ ok: Boolean(process.env[envName]),
7087
+ detail: envName
7088
+ });
7089
+ checks.push({
7090
+ name: `telegram-allowlist:${channel.id}`,
7091
+ ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
7092
+ detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
7093
+ });
7094
+ }
7095
+ for (const route of config.routes) {
7096
+ checks.push({
7097
+ name: `route:${route.id}`,
7098
+ ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
7099
+ detail: `${route.fromChannel} -> ${route.toAgent}`
7100
+ });
7101
+ }
7102
+ return { ok: checks.every((check) => check.ok), configPath, checks };
7103
+ }
6413
7104
  // src/lib/router.ts
6414
7105
  function matchingRoutes(config, message) {
6415
7106
  const channel = config.channels[message.channelId];
7107
+ if (!channel || channel.enabled === false)
7108
+ return [];
6416
7109
  if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
6417
7110
  return [];
6418
7111
  }
@@ -6440,6 +7133,10 @@ async function routeMessage(config, message, options = {}) {
6440
7133
  let deliveredResponse = false;
6441
7134
  const channel = responseChannel(config, route, message);
6442
7135
  const responseText = agent.stdout.trim();
7136
+ if (channel?.enabled === false) {
7137
+ results.push({ route, agent, deliveredResponse });
7138
+ continue;
7139
+ }
6443
7140
  if (responseText && channel?.kind === "telegram" && message.chatId) {
6444
7141
  if (!telegramChatAllowed(channel, message.chatId)) {
6445
7142
  results.push({ route, agent, deliveredResponse });
@@ -6456,39 +7153,10 @@ async function routeMessage(config, message, options = {}) {
6456
7153
  }
6457
7154
  return results;
6458
7155
  }
6459
- // src/lib/state.ts
6460
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
6461
- import { dirname as dirname2, join as join2 } from "path";
6462
- function defaultStatePath() {
6463
- return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
6464
- }
6465
- function emptyState() {
6466
- return { telegramOffsets: {} };
6467
- }
6468
- async function loadState(statePath = defaultStatePath()) {
6469
- try {
6470
- const raw = await readFile2(statePath, "utf-8");
6471
- const parsed = JSON.parse(raw);
6472
- return {
6473
- telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
6474
- };
6475
- } catch (err) {
6476
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
6477
- return emptyState();
6478
- }
6479
- throw err;
6480
- }
6481
- }
6482
- async function saveState(state, statePath = defaultStatePath()) {
6483
- await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
6484
- await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
6485
- `, { encoding: "utf-8", mode: 384 });
6486
- await chmod2(statePath, 384);
6487
- }
6488
7156
  // src/cli/index.ts
6489
7157
  function version() {
6490
7158
  try {
6491
- const pkgPath = join3(dirname3(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7159
+ const pkgPath = join4(dirname4(fileURLToPath(import.meta.url)), "..", "..", "package.json");
6492
7160
  return JSON.parse(readFileSync(pkgPath, "utf-8")).version || "0.0.0";
6493
7161
  } catch {
6494
7162
  return "0.0.0";
@@ -6512,6 +7180,12 @@ function parseEnv(values) {
6512
7180
  function splitCsv(value) {
6513
7181
  return value?.split(",").map((item) => item.trim()).filter(Boolean);
6514
7182
  }
7183
+ function parseNonNegativeInt(value, name) {
7184
+ const raw = value || "0";
7185
+ if (!/^\d+$/.test(raw))
7186
+ throw new Error(`${name} must be a non-negative integer`);
7187
+ return Number.parseInt(raw, 10);
7188
+ }
6515
7189
  function printList(items) {
6516
7190
  const rows = Array.isArray(items) ? items : Object.values(items);
6517
7191
  if (!rows.length) {
@@ -6523,35 +7197,53 @@ function printList(items) {
6523
7197
  }
6524
7198
  async function runServe(options) {
6525
7199
  const config2 = await loadConfig(options.config);
6526
- const telegramChannels = Object.values(config2.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
6527
- const intervalMs = Number.parseInt(options.interval || "1000", 10);
6528
- if (!Number.isInteger(intervalMs) || intervalMs < 0)
6529
- throw new Error("--interval must be a non-negative integer");
6530
- if (!telegramChannels.length)
7200
+ const telegramChannels2 = Object.values(config2.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
7201
+ const intervalMs = parseNonNegativeInt(options.interval || "1000", "--interval");
7202
+ if (!telegramChannels2.length)
6531
7203
  throw new Error("No enabled Telegram channels configured");
6532
7204
  const statePath = options.state || defaultStatePath();
6533
7205
  const state2 = await loadState(statePath);
6534
- while (true) {
6535
- for (const channel of telegramChannels) {
6536
- const updates = await getTelegramUpdates(telegramToken(channel), {
6537
- offset: state2.telegramOffsets[channel.id],
6538
- timeoutSeconds: channel.pollTimeoutSeconds || 20
6539
- });
6540
- for (const update of updates) {
6541
- state2.telegramOffsets[channel.id] = update.update_id + 1;
6542
- await saveState(state2, statePath);
6543
- const message = telegramUpdateToMessage(channel.id, update);
6544
- if (!message)
6545
- continue;
6546
- const results = await routeMessage(config2, message, { writeConsole: options.json ? false : undefined });
6547
- if (options.json)
6548
- asJson({ message, results });
7206
+ const errorCounts = new Map;
7207
+ let stopping = false;
7208
+ const stop = () => {
7209
+ stopping = true;
7210
+ };
7211
+ process.once("SIGTERM", stop);
7212
+ process.once("SIGINT", stop);
7213
+ while (!stopping) {
7214
+ for (const channel of telegramChannels2) {
7215
+ try {
7216
+ const updates = await getTelegramUpdates(telegramToken(channel), {
7217
+ offset: state2.telegramOffsets[channel.id],
7218
+ timeoutSeconds: channel.pollTimeoutSeconds || 20
7219
+ });
7220
+ errorCounts.delete(channel.id);
7221
+ for (const update of updates) {
7222
+ state2.telegramOffsets[channel.id] = update.update_id + 1;
7223
+ await saveState(state2, statePath);
7224
+ const message = telegramUpdateToMessage(channel.id, update);
7225
+ if (!message)
7226
+ continue;
7227
+ const results = await routeMessage(config2, message, { writeConsole: options.json ? false : undefined });
7228
+ if (options.json)
7229
+ asJson({ message, results });
7230
+ }
7231
+ } catch (err) {
7232
+ if (options.once)
7233
+ throw err;
7234
+ const count = (errorCounts.get(channel.id) || 0) + 1;
7235
+ errorCounts.set(channel.id, count);
7236
+ const message = err instanceof Error ? err.message : String(err);
7237
+ console.error(`[bridge] ${channel.id} poll failed (${count}): ${message}`);
7238
+ await Bun.sleep(Math.min(30000, Math.max(1000, intervalMs * Math.min(count, 30))));
6549
7239
  }
6550
7240
  }
6551
7241
  if (options.once)
6552
7242
  break;
6553
7243
  await Bun.sleep(intervalMs);
6554
7244
  }
7245
+ process.removeListener("SIGTERM", stop);
7246
+ process.removeListener("SIGINT", stop);
6555
7247
  }
6556
7248
  var program2 = new Command;
6557
7249
  program2.name("bridge").description("Agent messaging bridge for Telegram and other channels").version(version());
@@ -6702,6 +7394,84 @@ program2.command("ask").argument("<agent>").argument("<text...>").description("R
6702
7394
  process.exitCode = result.exitCode ?? 1;
6703
7395
  });
6704
7396
  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);
7397
+ var daemon2 = program2.command("daemon").description("Manage the bridge background daemon");
7398
+ 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) => {
7399
+ const status = await daemonStatus({ supervisor: options.supervisor, daemonDir: options.daemonDir });
7400
+ if (options.json)
7401
+ asJson(status);
7402
+ else {
7403
+ console.log(`${status.running ? "running" : status.stale ? "stale" : "stopped"} ${status.supervisor}${status.pid ? ` pid=${status.pid}` : ""}`);
7404
+ console.log(`daemonDir=${status.paths.dir}`);
7405
+ console.log(`stdout=${status.paths.stdoutLog}`);
7406
+ console.log(`stderr=${status.paths.stderrLog}`);
7407
+ if (status.telegramApiBase.error) {
7408
+ console.log(`telegramApiBaseError=${status.telegramApiBase.error}`);
7409
+ } else if (status.telegramApiBase.overridden) {
7410
+ console.log(`telegramApiBase=${status.telegramApiBase.origin}${status.telegramApiBase.pathname}`);
7411
+ }
7412
+ }
7413
+ });
7414
+ 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) => {
7415
+ const intervalMs = parseNonNegativeInt(options.interval, "--interval");
7416
+ const supervisor = options.supervisor;
7417
+ 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 });
7418
+ options.json ? asJson(result) : console.log("started");
7419
+ });
7420
+ 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) => {
7421
+ const timeoutMs = parseNonNegativeInt(options.timeoutMs, "--timeout-ms");
7422
+ 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 });
7423
+ options.json ? asJson(result || { stopped: true }) : console.log("stopped");
7424
+ });
7425
+ 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) => {
7426
+ const restartOptions = {
7427
+ supervisor: options.supervisor,
7428
+ daemonDir: options.daemonDir,
7429
+ configPath: options.config,
7430
+ statePath: options.state,
7431
+ intervalMs: options.interval ? parseNonNegativeInt(options.interval, "--interval") : undefined,
7432
+ serveJson: options.serveJson,
7433
+ timeoutMs: parseNonNegativeInt(options.timeoutMs, "--timeout-ms"),
7434
+ force: options.force
7435
+ };
7436
+ const result = options.supervisor === "process" ? await restartProcessDaemon(restartOptions) : await restartInstalledDaemon(restartOptions);
7437
+ options.json ? asJson(result) : console.log("restarted");
7438
+ });
7439
+ 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) => {
7440
+ const lines = parseNonNegativeInt(options.lines, "--lines") || 100;
7441
+ if (options.follow) {
7442
+ const paths2 = daemonPaths(options.daemonDir);
7443
+ const tail = Bun.spawn(["tail", "-n", String(lines), "-f", paths2.stdoutLog, paths2.stderrLog], {
7444
+ stdout: "inherit",
7445
+ stderr: "inherit"
7446
+ });
7447
+ await tail.exited;
7448
+ return;
7449
+ }
7450
+ const logs = await daemonLogs({ daemonDir: options.daemonDir, lines });
7451
+ if (options.json)
7452
+ asJson(logs);
7453
+ else {
7454
+ if (logs.stdout)
7455
+ console.log(logs.stdout);
7456
+ if (logs.stderr)
7457
+ console.error(logs.stderr);
7458
+ }
7459
+ });
7460
+ 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) => {
7461
+ const result = await installDaemon({
7462
+ supervisor: options.supervisor,
7463
+ daemonDir: options.daemonDir,
7464
+ configPath: options.config,
7465
+ statePath: options.state,
7466
+ intervalMs: parseNonNegativeInt(options.interval, "--interval"),
7467
+ serveJson: options.serveJson
7468
+ });
7469
+ options.json ? asJson(result) : console.log(`installed ${result.supervisor}: ${result.path}`);
7470
+ });
7471
+ 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) => {
7472
+ const result = await uninstallDaemon({ supervisor: options.supervisor, daemonDir: options.daemonDir });
7473
+ options.json ? asJson(result) : console.log(`removed ${result.removed.join(", ")}`);
7474
+ });
6705
7475
  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) => {
6706
7476
  const config2 = await loadConfig(options.config);
6707
7477
  const result = await routeMessage(config2, {