@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/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 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/doctor.ts
6308
- import { stat } from "fs/promises";
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 { telegramOffsets: {} };
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(state, null, 2)}
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/doctor.ts
6341
- function isNotFound(err) {
6342
- return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
6343
- }
6344
- async function privateFileCheck(name, path) {
6345
- try {
6346
- const info = await stat(path);
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
- const telegramChannels = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
6376
- for (const channel of telegramChannels) {
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
- for (const route of config.routes) {
6390
- checks.push({
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 { ok: checks.every((check) => check.ok), configPath, checks };
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(`https://api.telegram.org/bot${token}/sendMessage`, {
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(`https://api.telegram.org/bot${token}/getUpdates?${params.toString()}`);
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
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 = join3(dirname3(fileURLToPath(import.meta.url)), "..", "..", "package.json");
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 telegramChannels = Object.values(config2.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
6547
- const intervalMs = Number.parseInt(options.interval || "1000", 10);
6548
- if (!Number.isInteger(intervalMs) || intervalMs < 0)
6549
- throw new Error("--interval must be a non-negative integer");
6550
- if (!telegramChannels.length)
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 state2 = await loadState(statePath);
6554
- while (true) {
6555
- for (const channel of telegramChannels) {
6556
- const updates = await getTelegramUpdates(telegramToken(channel), {
6557
- offset: state2.telegramOffsets[channel.id],
6558
- timeoutSeconds: channel.pollTimeoutSeconds || 20
6559
- });
6560
- for (const update of updates) {
6561
- state2.telegramOffsets[channel.id] = update.update_id + 1;
6562
- await saveState(state2, statePath);
6563
- const message = telegramUpdateToMessage(channel.id, update);
6564
- if (!message)
6565
- continue;
6566
- const results = await routeMessage(config2, message, { writeConsole: options.json ? false : undefined });
6567
- if (options.json)
6568
- asJson({ message, results });
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
- let text = textParts.join(" ");
6688
- if (channel.kind !== "telegram" && !text && targetChat) {
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, {