@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/mcp/index.js CHANGED
@@ -4008,6 +4008,11 @@ function mergeEnv(profile, agent) {
4008
4008
  env["HOME"] = profile.home;
4009
4009
  return Object.keys(env).length ? env : undefined;
4010
4010
  }
4011
+ function compatibilityDetail(kind) {
4012
+ if (kind === "shell")
4013
+ return "shell command session; local bridge state is durable";
4014
+ return "compatibility mode: this adapter invokes the current CLI one message at a time until a stable create/send/resume API is wired";
4015
+ }
4011
4016
  function resolveAgent(config, agentId) {
4012
4017
  const agent = config.agents[agentId];
4013
4018
  if (!agent)
@@ -4026,7 +4031,7 @@ function buildAgentCommand(config, agentId, input) {
4026
4031
  const kind = agent.kind;
4027
4032
  const command = agent.command || profile?.command;
4028
4033
  const args = agent.args || profile?.args;
4029
- const cwd = agent.cwd || profile?.cwd;
4034
+ const cwd = input.session?.cwd || agent.cwd || profile?.cwd;
4030
4035
  const env = mergeEnv(profile, agent);
4031
4036
  if (command) {
4032
4037
  return { command: [command, ...renderCustomArgs(args, prompt)], cwd, env };
@@ -4048,6 +4053,25 @@ function buildAgentCommand(config, agentId, input) {
4048
4053
  }
4049
4054
  return { command: ["sh", "-lc", prompt], cwd, env };
4050
4055
  }
4056
+ function createAgentSessionRef(config, agentId) {
4057
+ const { agent } = resolveAgent(config, agentId);
4058
+ const timestamp = new Date().toISOString();
4059
+ return {
4060
+ kind: agent.kind,
4061
+ mode: "compatibility",
4062
+ createdAt: timestamp,
4063
+ updatedAt: timestamp,
4064
+ detail: compatibilityDetail(agent.kind)
4065
+ };
4066
+ }
4067
+ async function sendAgentSessionMessage(config, session, message, options = {}) {
4068
+ const run = options.run || runAgent;
4069
+ return run(config, session.agentId, {
4070
+ message,
4071
+ route: { id: `session:${session.id}`, fromChannel: message.channelId, toAgent: session.agentId },
4072
+ session
4073
+ });
4074
+ }
4051
4075
  async function runAgent(config, agentId, input) {
4052
4076
  const { agent } = resolveAgent(config, agentId);
4053
4077
  const built = buildAgentCommand(config, agentId, input);
@@ -4132,7 +4156,14 @@ var channelSchema = exports_external.discriminatedUnion("kind", [
4132
4156
  kind: exports_external.literal("imessage"),
4133
4157
  label: exports_external.string().optional(),
4134
4158
  enabled: exports_external.boolean().optional(),
4135
- account: exports_external.string().optional()
4159
+ account: exports_external.string().optional(),
4160
+ serviceName: exports_external.string().optional(),
4161
+ defaultHandle: exports_external.string().optional(),
4162
+ allowedHandles: exports_external.array(exports_external.string()).optional(),
4163
+ allowAllHandles: exports_external.boolean().optional(),
4164
+ receiveMode: exports_external.enum(["disabled", "chat-db"]).optional(),
4165
+ chatDbPath: exports_external.string().optional(),
4166
+ pollLimit: exports_external.number().int().positive().max(500).optional()
4136
4167
  })
4137
4168
  ]);
4138
4169
  var envSchema = exports_external.record(exports_external.string(), exports_external.string());
@@ -4225,32 +4256,418 @@ async function loadConfig(configPath = defaultConfigPath()) {
4225
4256
  throw err;
4226
4257
  }
4227
4258
  }
4228
- // src/lib/doctor.ts
4229
- import { stat } from "fs/promises";
4259
+ // src/lib/daemon.ts
4260
+ import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
4261
+ import { dirname as dirname2, join as join3, resolve } from "path";
4230
4262
 
4231
4263
  // src/lib/state.ts
4264
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
4232
4265
  import { dirname, join as join2 } from "path";
4266
+ var STATE_SCHEMA_VERSION = 2;
4233
4267
  function defaultStatePath() {
4234
4268
  return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
4235
4269
  }
4270
+ function emptyState() {
4271
+ return {
4272
+ schemaVersion: STATE_SCHEMA_VERSION,
4273
+ telegramOffsets: {},
4274
+ sessions: {},
4275
+ bindings: {},
4276
+ messageLedger: {},
4277
+ cursors: {}
4278
+ };
4279
+ }
4280
+ function normalizeState(value) {
4281
+ return {
4282
+ schemaVersion: STATE_SCHEMA_VERSION,
4283
+ telegramOffsets: value.telegramOffsets && typeof value.telegramOffsets === "object" ? value.telegramOffsets : {},
4284
+ sessions: value.sessions && typeof value.sessions === "object" ? value.sessions : {},
4285
+ bindings: value.bindings && typeof value.bindings === "object" ? value.bindings : {},
4286
+ messageLedger: value.messageLedger && typeof value.messageLedger === "object" ? value.messageLedger : {},
4287
+ cursors: value.cursors && typeof value.cursors === "object" ? value.cursors : {}
4288
+ };
4289
+ }
4290
+ async function loadState(statePath = defaultStatePath()) {
4291
+ try {
4292
+ const raw = await readFile2(statePath, "utf-8");
4293
+ const parsed = JSON.parse(raw);
4294
+ return normalizeState(parsed);
4295
+ } catch (err) {
4296
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
4297
+ return emptyState();
4298
+ }
4299
+ throw err;
4300
+ }
4301
+ }
4302
+ async function saveState(state, statePath = defaultStatePath()) {
4303
+ const normalized = normalizeState(state);
4304
+ await mkdir2(dirname(statePath), { recursive: true, mode: 448 });
4305
+ await writeFile2(statePath, `${JSON.stringify(normalized, null, 2)}
4306
+ `, { encoding: "utf-8", mode: 384 });
4307
+ await chmod2(statePath, 384);
4308
+ }
4236
4309
 
4237
- // src/lib/doctor.ts
4310
+ // src/lib/telegram.ts
4311
+ var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
4312
+ function telegramApiBase() {
4313
+ const raw = process.env["BRIDGE_TELEGRAM_API_BASE"] || DEFAULT_TELEGRAM_API_BASE;
4314
+ const parsed = new URL(raw);
4315
+ if (!["http:", "https:"].includes(parsed.protocol)) {
4316
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must use http or https");
4317
+ }
4318
+ if (parsed.username || parsed.password) {
4319
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain credentials");
4320
+ }
4321
+ if (parsed.search || parsed.hash) {
4322
+ throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain query strings or fragments");
4323
+ }
4324
+ return parsed;
4325
+ }
4326
+ function telegramApiBaseInfo() {
4327
+ const parsed = telegramApiBase();
4328
+ return {
4329
+ overridden: parsed.href.replace(/\/$/, "") !== DEFAULT_TELEGRAM_API_BASE,
4330
+ origin: parsed.origin,
4331
+ pathname: parsed.pathname
4332
+ };
4333
+ }
4334
+ function telegramMethodUrl(token, method) {
4335
+ const base = telegramApiBase();
4336
+ const prefix = base.pathname.replace(/\/$/, "");
4337
+ base.pathname = `${prefix}/bot${token}/${method}`;
4338
+ base.search = "";
4339
+ return base.toString();
4340
+ }
4341
+ function telegramToken(channel) {
4342
+ const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
4343
+ const token = process.env[envName];
4344
+ if (!token)
4345
+ throw new Error(`Missing Telegram bot token env var: ${envName}`);
4346
+ return token;
4347
+ }
4348
+ function telegramChatAllowed(channel, chatId) {
4349
+ if (channel.allowAllChats)
4350
+ return true;
4351
+ if (!channel.allowedChatIds?.length)
4352
+ return false;
4353
+ return Boolean(chatId && channel.allowedChatIds.includes(chatId));
4354
+ }
4355
+ async function sendTelegramMessage(token, chatId, text) {
4356
+ const response = await fetch(telegramMethodUrl(token, "sendMessage"), {
4357
+ method: "POST",
4358
+ headers: { "content-type": "application/json" },
4359
+ body: JSON.stringify({ chat_id: chatId, text })
4360
+ });
4361
+ const body = await response.json().catch(() => {
4362
+ return;
4363
+ });
4364
+ if (!response.ok)
4365
+ throw new Error(`Telegram sendMessage failed (${response.status}): ${JSON.stringify(body)}`);
4366
+ return body;
4367
+ }
4368
+
4369
+ // src/lib/daemon.ts
4238
4370
  function isNotFound(err) {
4239
4371
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
4240
4372
  }
4373
+ function currentPlatformSupervisor() {
4374
+ if (process.platform === "darwin")
4375
+ return "launchd";
4376
+ if (process.platform === "linux")
4377
+ return "systemd";
4378
+ return "process";
4379
+ }
4380
+ function resolveSupervisor(supervisor = "process") {
4381
+ return supervisor === "auto" ? currentPlatformSupervisor() : supervisor;
4382
+ }
4383
+ function defaultDaemonDir() {
4384
+ return join3(bridgeHome(), "daemon");
4385
+ }
4386
+ function daemonPaths(daemonDir = defaultDaemonDir()) {
4387
+ const dir = resolve(daemonDir);
4388
+ return {
4389
+ dir,
4390
+ lockDir: join3(dir, "lock"),
4391
+ metadataFile: join3(dir, "bridge-daemon.json"),
4392
+ stdoutLog: join3(dir, "bridge.out.log"),
4393
+ stderrLog: join3(dir, "bridge.err.log"),
4394
+ launchdPlist: join3(process.env["HOME"] || process.cwd(), "Library", "LaunchAgents", "com.hasna.bridge.plist"),
4395
+ systemdUnit: join3(process.env["HOME"] || process.cwd(), ".config", "systemd", "user", "hasna-bridge.service")
4396
+ };
4397
+ }
4398
+ async function fileExists(path) {
4399
+ try {
4400
+ await stat(path);
4401
+ return true;
4402
+ } catch (err) {
4403
+ if (isNotFound(err))
4404
+ return false;
4405
+ throw err;
4406
+ }
4407
+ }
4408
+ async function readMetadata(paths) {
4409
+ try {
4410
+ return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
4411
+ } catch (err) {
4412
+ if (isNotFound(err))
4413
+ return;
4414
+ throw err;
4415
+ }
4416
+ }
4417
+ function pidAlive(pid) {
4418
+ try {
4419
+ process.kill(pid, 0);
4420
+ return true;
4421
+ } catch {
4422
+ return false;
4423
+ }
4424
+ }
4425
+ async function processCommand(pid) {
4426
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], {
4427
+ stdout: "pipe",
4428
+ stderr: "ignore"
4429
+ });
4430
+ if (await proc.exited !== 0)
4431
+ return;
4432
+ return (await new Response(proc.stdout).text()).trim();
4433
+ }
4434
+ async function processPgid(pid) {
4435
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pgid="], {
4436
+ stdout: "pipe",
4437
+ stderr: "ignore"
4438
+ });
4439
+ if (await proc.exited !== 0)
4440
+ return;
4441
+ const parsed = Number.parseInt((await new Response(proc.stdout).text()).trim(), 10);
4442
+ return Number.isInteger(parsed) ? parsed : undefined;
4443
+ }
4444
+ async function processMatches(metadata) {
4445
+ if (!pidAlive(metadata.pid))
4446
+ return false;
4447
+ const command = await processCommand(metadata.pid);
4448
+ if (!command)
4449
+ return false;
4450
+ if (!metadata.pgid)
4451
+ return false;
4452
+ const pgid = await processPgid(metadata.pid);
4453
+ if (pgid !== metadata.pgid)
4454
+ return false;
4455
+ const requiredArgs = [
4456
+ metadata.command[1],
4457
+ "serve",
4458
+ "--config",
4459
+ metadata.configPath,
4460
+ "--state",
4461
+ metadata.statePath,
4462
+ "--interval",
4463
+ String(metadata.intervalMs)
4464
+ ].filter((arg) => Boolean(arg));
4465
+ if (metadata.serveJson)
4466
+ requiredArgs.push("--json");
4467
+ return requiredArgs.every((arg) => command.includes(arg));
4468
+ }
4469
+ function safeTelegramApiBaseInfo() {
4470
+ try {
4471
+ return telegramApiBaseInfo();
4472
+ } catch (err) {
4473
+ return {
4474
+ overridden: true,
4475
+ origin: "",
4476
+ pathname: "",
4477
+ error: err instanceof Error ? err.message : String(err)
4478
+ };
4479
+ }
4480
+ }
4481
+ async function runCapture(command) {
4482
+ const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
4483
+ const [exitCode, stdout, stderr] = await Promise.all([
4484
+ proc.exited,
4485
+ new Response(proc.stdout).text(),
4486
+ new Response(proc.stderr).text()
4487
+ ]);
4488
+ return { exitCode, stdout, stderr };
4489
+ }
4490
+ async function installedSupervisorStatus(supervisor, paths) {
4491
+ if (supervisor === "launchd") {
4492
+ if (!await fileExists(paths.launchdPlist))
4493
+ return { running: false, detail: "launchd plist not installed" };
4494
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
4495
+ if (uid === undefined)
4496
+ return { running: false, detail: "launchd status requires a numeric uid" };
4497
+ const result = await runCapture(["launchctl", "print", `gui/${uid}/com.hasna.bridge`]);
4498
+ if (result.exitCode !== 0)
4499
+ return { running: false, detail: result.stderr.trim() || result.stdout.trim() || "launchd service not loaded" };
4500
+ const running = /state\s*=\s*running/.test(result.stdout);
4501
+ return { running, detail: running ? "launchd running" : "launchd loaded but not running" };
4502
+ }
4503
+ if (supervisor === "systemd") {
4504
+ if (!await fileExists(paths.systemdUnit))
4505
+ return { running: false, detail: "systemd unit not installed" };
4506
+ const result = await runCapture(["systemctl", "--user", "is-active", "hasna-bridge.service"]);
4507
+ const state = result.stdout.trim() || result.stderr.trim() || "unknown";
4508
+ return { running: result.exitCode === 0 && state === "active", detail: `systemd ${state}` };
4509
+ }
4510
+ return { running: false, detail: "process supervisor has no installed status" };
4511
+ }
4512
+ async function daemonStatus(options = {}) {
4513
+ const supervisor = resolveSupervisor(options.supervisor);
4514
+ const paths = daemonPaths(options.daemonDir);
4515
+ const metadata = await readMetadata(paths);
4516
+ const live = metadata ? await processMatches(metadata) : false;
4517
+ const stale = Boolean(metadata && !live);
4518
+ const startedAt = metadata?.startedAt;
4519
+ const uptimeSeconds = live && startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)) : undefined;
4520
+ const installed = {
4521
+ launchd: await fileExists(paths.launchdPlist),
4522
+ systemd: await fileExists(paths.systemdUnit)
4523
+ };
4524
+ const installedRuntime = supervisor === "process" ? undefined : await installedSupervisorStatus(supervisor, paths);
4525
+ return {
4526
+ running: installedRuntime ? installedRuntime.running : live,
4527
+ stale: installedRuntime ? false : stale,
4528
+ supervisor,
4529
+ pid: metadata?.pid,
4530
+ startedAt,
4531
+ uptimeSeconds,
4532
+ detail: installedRuntime?.detail || (stale ? "stale process metadata" : live ? "running" : "not running"),
4533
+ installedDetail: installedRuntime?.detail,
4534
+ metadata,
4535
+ paths,
4536
+ installed,
4537
+ telegramApiBase: safeTelegramApiBaseInfo()
4538
+ };
4539
+ }
4540
+ // src/lib/doctor.ts
4541
+ import { stat as stat2 } from "fs/promises";
4542
+
4543
+ // src/lib/imessage.ts
4544
+ import { access } from "fs/promises";
4545
+ import { join as join4 } from "path";
4546
+ function defaultMessagesDbPath() {
4547
+ return join4(homeDir(), "Library", "Messages", "chat.db");
4548
+ }
4549
+ function imessageHandleAllowed(channel, handle) {
4550
+ if (channel.allowAllHandles)
4551
+ return true;
4552
+ if (!channel.allowedHandles?.length)
4553
+ return false;
4554
+ return Boolean(handle && channel.allowedHandles.includes(handle));
4555
+ }
4556
+ function appleScriptString(value) {
4557
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
4558
+ }
4559
+ function renderSendIMessageScript(channel, handle, text) {
4560
+ const service = channel.serviceName || "iMessage";
4561
+ const serviceSelector = channel.account ? `1st service whose name = ${appleScriptString(service)} and account = ${appleScriptString(channel.account)}` : `1st service whose name = ${appleScriptString(service)}`;
4562
+ const targetLines = handle.startsWith("chat:") ? [
4563
+ `set targetChat to 1st chat whose id = ${appleScriptString(handle.slice("chat:".length))}`,
4564
+ `send ${appleScriptString(text)} to targetChat`
4565
+ ] : [
4566
+ `set targetBuddy to buddy ${appleScriptString(handle)} of targetService`,
4567
+ `send ${appleScriptString(text)} to targetBuddy`
4568
+ ];
4569
+ return [
4570
+ 'tell application "Messages"',
4571
+ `set targetService to ${serviceSelector}`,
4572
+ ...targetLines,
4573
+ "end tell"
4574
+ ].join(`
4575
+ `);
4576
+ }
4577
+ async function defaultRun(command) {
4578
+ const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
4579
+ const [exitCode, stdout, stderr] = await Promise.all([
4580
+ proc.exited,
4581
+ new Response(proc.stdout).text(),
4582
+ new Response(proc.stderr).text()
4583
+ ]);
4584
+ return { exitCode, stdout, stderr };
4585
+ }
4586
+ async function sendIMessage(channel, handle, text, options = {}) {
4587
+ if (!(options.allowChatTarget && handle.startsWith("chat:")) && !imessageHandleAllowed(channel, handle)) {
4588
+ throw new Error(`iMessage handle is not allowed for channel ${channel.id}: ${handle}`);
4589
+ }
4590
+ const script = renderSendIMessageScript(channel, handle, text);
4591
+ const result = await (options.run || defaultRun)(["osascript", "-e", script]);
4592
+ if (result.exitCode !== 0) {
4593
+ throw new Error(`iMessage send failed: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
4594
+ }
4595
+ return { ok: true };
4596
+ }
4597
+ function getIMessageDbPath(channel) {
4598
+ return channel.chatDbPath || defaultMessagesDbPath();
4599
+ }
4600
+ async function commandExists(command) {
4601
+ const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
4602
+ stdout: "ignore",
4603
+ stderr: "ignore"
4604
+ });
4605
+ return await proc.exited === 0;
4606
+ }
4607
+ async function diagnoseIMessage(channel) {
4608
+ const checks = [];
4609
+ checks.push({
4610
+ name: `imessage-platform:${channel.id}`,
4611
+ ok: process.platform === "darwin",
4612
+ detail: process.platform === "darwin" ? "macOS" : `unsupported platform: ${process.platform}`
4613
+ });
4614
+ checks.push({
4615
+ name: `imessage-osascript:${channel.id}`,
4616
+ ok: await commandExists("osascript"),
4617
+ detail: "required for Messages send automation"
4618
+ });
4619
+ checks.push({
4620
+ name: `imessage-allowlist:${channel.id}`,
4621
+ ok: Boolean(channel.allowAllHandles || channel.allowedHandles?.length),
4622
+ detail: channel.allowAllHandles ? "allowAllHandles=true" : `${channel.allowedHandles?.length || 0} handle(s)`
4623
+ });
4624
+ if ((channel.receiveMode || "disabled") === "chat-db") {
4625
+ const path = getIMessageDbPath(channel);
4626
+ try {
4627
+ await access(path);
4628
+ checks.push({ name: `imessage-chat-db:${channel.id}`, ok: true, detail: path });
4629
+ } catch (err) {
4630
+ checks.push({
4631
+ name: `imessage-chat-db:${channel.id}`,
4632
+ ok: false,
4633
+ detail: `${path}: ${err instanceof Error ? err.message : String(err)}. Grant Full Disk Access to the terminal/daemon host or disable receive mode.`
4634
+ });
4635
+ }
4636
+ } else {
4637
+ checks.push({ name: `imessage-receive:${channel.id}`, ok: true, detail: "receiveMode=disabled" });
4638
+ }
4639
+ return checks;
4640
+ }
4641
+
4642
+ // src/lib/doctor.ts
4643
+ function isNotFound2(err) {
4644
+ return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
4645
+ }
4241
4646
  async function privateFileCheck(name, path) {
4242
4647
  try {
4243
- const info = await stat(path);
4648
+ const info = await stat2(path);
4244
4649
  const mode = info.mode & 511;
4245
4650
  const ok = (mode & 63) === 0;
4246
4651
  return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
4247
4652
  } catch (err) {
4248
- if (isNotFound(err))
4653
+ if (isNotFound2(err))
4249
4654
  return { name, ok: true, detail: `not created yet: ${path}` };
4250
4655
  return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
4251
4656
  }
4252
4657
  }
4253
- async function commandExists(command) {
4658
+ async function privateDirCheck(name, path) {
4659
+ try {
4660
+ const info = await stat2(path);
4661
+ const mode = info.mode & 511;
4662
+ const ok = (mode & 63) === 0;
4663
+ return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
4664
+ } catch (err) {
4665
+ if (isNotFound2(err))
4666
+ return { name, ok: true, detail: `not created yet: ${path}` };
4667
+ return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
4668
+ }
4669
+ }
4670
+ async function commandExists2(command) {
4254
4671
  const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
4255
4672
  stdout: "ignore",
4256
4673
  stderr: "ignore"
@@ -4259,13 +4676,36 @@ async function commandExists(command) {
4259
4676
  }
4260
4677
  async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
4261
4678
  const checks = [];
4262
- let config = await loadConfig(configPath);
4679
+ const config = await loadConfig(configPath);
4680
+ const daemon = await daemonStatus();
4681
+ const paths = daemonPaths();
4263
4682
  checks.push(await privateFileCheck("config", configPath));
4264
4683
  checks.push(await privateFileCheck("state", statePath));
4684
+ checks.push(await privateDirCheck("daemon-dir", paths.dir));
4685
+ checks.push(await privateFileCheck("daemon-metadata", paths.metadataFile));
4686
+ checks.push({
4687
+ name: "daemon-status",
4688
+ ok: !daemon.stale,
4689
+ detail: daemon.running ? `running pid=${daemon.pid}` : daemon.stale ? `stale pid=${daemon.pid}` : "not running"
4690
+ });
4691
+ try {
4692
+ const apiBase = telegramApiBaseInfo();
4693
+ checks.push({
4694
+ name: "telegram-api-base",
4695
+ ok: true,
4696
+ detail: apiBase.overridden ? `overridden: ${apiBase.origin}${apiBase.pathname}` : apiBase.origin
4697
+ });
4698
+ } catch (err) {
4699
+ checks.push({
4700
+ name: "telegram-api-base",
4701
+ ok: false,
4702
+ detail: err instanceof Error ? err.message : String(err)
4703
+ });
4704
+ }
4265
4705
  for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
4266
4706
  checks.push({
4267
4707
  name: `command:${command}`,
4268
- ok: command === "bridge" ? true : await commandExists(command),
4708
+ ok: command === "bridge" ? true : await commandExists2(command),
4269
4709
  detail: command === "bridge" ? "current package" : undefined
4270
4710
  });
4271
4711
  }
@@ -4290,37 +4730,12 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
4290
4730
  detail: `${route.fromChannel} -> ${route.toAgent}`
4291
4731
  });
4292
4732
  }
4733
+ const imessageChannels = Object.values(config.channels).filter((channel) => channel.kind === "imessage");
4734
+ for (const channel of imessageChannels) {
4735
+ checks.push(...await diagnoseIMessage(channel));
4736
+ }
4293
4737
  return { ok: checks.every((check) => check.ok), configPath, checks };
4294
4738
  }
4295
- // src/lib/telegram.ts
4296
- function telegramToken(channel) {
4297
- const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
4298
- const token = process.env[envName];
4299
- if (!token)
4300
- throw new Error(`Missing Telegram bot token env var: ${envName}`);
4301
- return token;
4302
- }
4303
- function telegramChatAllowed(channel, chatId) {
4304
- if (channel.allowAllChats)
4305
- return true;
4306
- if (!channel.allowedChatIds?.length)
4307
- return false;
4308
- return Boolean(chatId && channel.allowedChatIds.includes(chatId));
4309
- }
4310
- async function sendTelegramMessage(token, chatId, text) {
4311
- const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
4312
- method: "POST",
4313
- headers: { "content-type": "application/json" },
4314
- body: JSON.stringify({ chat_id: chatId, text })
4315
- });
4316
- const body = await response.json().catch(() => {
4317
- return;
4318
- });
4319
- if (!response.ok)
4320
- throw new Error(`Telegram sendMessage failed (${response.status}): ${JSON.stringify(body)}`);
4321
- return body;
4322
- }
4323
-
4324
4739
  // src/lib/router.ts
4325
4740
  function matchingRoutes(config, message) {
4326
4741
  const channel = config.channels[message.channelId];
@@ -4329,6 +4744,9 @@ function matchingRoutes(config, message) {
4329
4744
  if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
4330
4745
  return [];
4331
4746
  }
4747
+ if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
4748
+ return [];
4749
+ }
4332
4750
  return config.routes.filter((route) => {
4333
4751
  if (route.enabled === false)
4334
4752
  return false;
@@ -4368,19 +4786,416 @@ async function routeMessage(config, message, options = {}) {
4368
4786
  if (options.writeConsole !== false)
4369
4787
  (options.writeConsole || console.log)(responseText);
4370
4788
  deliveredResponse = true;
4789
+ } else if (responseText && channel?.kind === "imessage") {
4790
+ const handle = message.responseTargetId || message.chatId || message.from;
4791
+ const allowedIdentity = message.from || (handle?.startsWith("chat:") ? undefined : handle);
4792
+ if (handle && imessageHandleAllowed(channel, allowedIdentity)) {
4793
+ await sendIMessage(channel, handle, responseText, { allowChatTarget: handle.startsWith("chat:") });
4794
+ deliveredResponse = true;
4795
+ }
4371
4796
  }
4372
4797
  results.push({ route, agent, deliveredResponse });
4373
4798
  }
4374
4799
  return results;
4375
4800
  }
4801
+ // src/lib/sessions.ts
4802
+ import { randomUUID } from "crypto";
4803
+ function nowIso() {
4804
+ return new Date().toISOString();
4805
+ }
4806
+ function newSessionId() {
4807
+ return `ses_${randomUUID()}`;
4808
+ }
4809
+ function normalizeConversationId(channel, conversation) {
4810
+ if (conversation.includes(":") && conversation.startsWith(`${channel.kind}:`))
4811
+ return conversation;
4812
+ if (channel.kind === "telegram")
4813
+ return `telegram:${channel.id}:${conversation}`;
4814
+ if (channel.kind === "imessage")
4815
+ return `imessage:${channel.id}:${conversation}`;
4816
+ return `${channel.kind}:${channel.id}:${conversation || "default"}`;
4817
+ }
4818
+ function messageConversationId(config, message) {
4819
+ const channel = config.channels[message.channelId];
4820
+ if (!channel)
4821
+ return;
4822
+ if (channel.kind === "telegram") {
4823
+ if (!message.chatId)
4824
+ return;
4825
+ return normalizeConversationId(channel, message.threadId ? `${message.chatId}:${message.threadId}` : message.chatId);
4826
+ }
4827
+ if (channel.kind === "imessage") {
4828
+ const conversation = message.chatId || message.from;
4829
+ return conversation ? normalizeConversationId(channel, conversation) : undefined;
4830
+ }
4831
+ return normalizeConversationId(channel, message.chatId || message.from || "default");
4832
+ }
4833
+ function bindingId(channelId, conversationId) {
4834
+ return `${channelId}::${conversationId}`;
4835
+ }
4836
+ function ledgerId(message) {
4837
+ return `${message.channelId}::${message.id}`;
4838
+ }
4839
+ function createBridgeSession(config, state, input) {
4840
+ const { agent, profile } = resolveAgent(config, input.agentId);
4841
+ const timestamp = nowIso();
4842
+ const session = {
4843
+ id: input.id || newSessionId(),
4844
+ agentId: agent.id,
4845
+ profileId: agent.profileId,
4846
+ cwd: input.cwd || agent.cwd || profile?.cwd,
4847
+ title: input.title,
4848
+ status: "active",
4849
+ createdAt: timestamp,
4850
+ updatedAt: timestamp,
4851
+ agentSession: createAgentSessionRef(config, agent.id)
4852
+ };
4853
+ state.sessions[session.id] = session;
4854
+ return session;
4855
+ }
4856
+ function getBridgeSession(state, sessionId) {
4857
+ const session = state.sessions[sessionId];
4858
+ if (!session)
4859
+ throw new Error(`Session not found: ${sessionId}`);
4860
+ return session;
4861
+ }
4862
+ function listBridgeSessions(state) {
4863
+ return Object.values(state.sessions).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
4864
+ }
4865
+ function attachBridgeSession(config, state, input) {
4866
+ const channel = config.channels[input.channelId];
4867
+ if (!channel)
4868
+ throw new Error(`Channel not found: ${input.channelId}`);
4869
+ const session = getBridgeSession(state, input.sessionId);
4870
+ if (session.status === "closed")
4871
+ throw new Error(`Cannot attach closed session: ${session.id}`);
4872
+ const conversationId = normalizeConversationId(channel, input.conversation);
4873
+ const id = bindingId(channel.id, conversationId);
4874
+ const existing = state.bindings[id];
4875
+ const timestamp = nowIso();
4876
+ const binding = {
4877
+ id,
4878
+ channelId: channel.id,
4879
+ conversationId,
4880
+ activeSessionId: session.id,
4881
+ defaultSessionId: input.makeDefault ? session.id : existing?.defaultSessionId,
4882
+ createdAt: existing?.createdAt || timestamp,
4883
+ updatedAt: timestamp,
4884
+ authorization: input.authorization || existing?.authorization || (channel.kind === "telegram" ? { chatId: input.conversation.split(":")[0] } : undefined)
4885
+ };
4886
+ state.bindings[id] = binding;
4887
+ return binding;
4888
+ }
4889
+ function noSessionText(channelId, conversationId) {
4890
+ return [
4891
+ "No bridge session is attached to this conversation.",
4892
+ "Create and attach one locally:",
4893
+ "bridge sessions create --agent <agent-id>",
4894
+ `bridge sessions attach <session-id> --channel ${channelId}${conversationId ? ` --conversation ${conversationId}` : " --conversation <conversation-id>"}`
4895
+ ].join(`
4896
+ `);
4897
+ }
4898
+ async function deliverResponse(config, message, text, options) {
4899
+ const channel = config.channels[message.channelId];
4900
+ if (!text || !channel || channel.enabled === false)
4901
+ return false;
4902
+ if (channel.kind === "telegram" && message.chatId) {
4903
+ if (!telegramChatAllowed(channel, message.chatId))
4904
+ return false;
4905
+ await (options.sendTelegram || sendTelegramMessage)(telegramToken(channel), message.chatId, text);
4906
+ return true;
4907
+ }
4908
+ if (channel.kind === "console") {
4909
+ if (options.writeConsole !== false)
4910
+ (options.writeConsole || console.log)(text);
4911
+ return true;
4912
+ }
4913
+ if (channel.kind === "imessage" && message.chatId) {
4914
+ const allowedIdentity = message.from || (message.chatId.startsWith("chat:") ? undefined : message.chatId);
4915
+ if (!imessageHandleAllowed(channel, allowedIdentity))
4916
+ return false;
4917
+ await sendIMessage(channel, message.responseTargetId || message.chatId, text, { allowChatTarget: Boolean(message.responseTargetId?.startsWith("chat:") || message.chatId.startsWith("chat:")) });
4918
+ return true;
4919
+ }
4920
+ return false;
4921
+ }
4922
+ async function deliverStoredResponse(config, state, binding, message, entry, options) {
4923
+ const session = getBridgeSession(state, binding.activeSessionId);
4924
+ const responseText = entry.responseText || "";
4925
+ const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
4926
+ completeLedger(entry, "delivered", session.id);
4927
+ entry.deliveredResponse = deliveredResponse;
4928
+ return {
4929
+ kind: "session",
4930
+ session,
4931
+ binding,
4932
+ conversationId: binding.conversationId,
4933
+ deliveredResponse,
4934
+ status: responseText ? "delivered" : "no_output"
4935
+ };
4936
+ }
4937
+ function channelAuthorized(config, message) {
4938
+ const channel = config.channels[message.channelId];
4939
+ if (!channel || channel.enabled === false)
4940
+ return false;
4941
+ if (channel.kind === "telegram")
4942
+ return telegramChatAllowed(channel, message.chatId);
4943
+ if (channel.kind === "imessage")
4944
+ return imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId));
4945
+ return true;
4946
+ }
4947
+ function bindingAuthorized(binding, message) {
4948
+ if (binding.authorization?.chatId && binding.authorization.chatId !== message.chatId)
4949
+ return false;
4950
+ if (binding.authorization?.from && binding.authorization.from !== message.from)
4951
+ return false;
4952
+ return true;
4953
+ }
4954
+ async function sendBridgeSessionMessage(config, state, sessionId, message, options = {}) {
4955
+ const session = getBridgeSession(state, sessionId);
4956
+ if (session.status === "paused")
4957
+ return { kind: "session", session, status: "paused", message: "Session is paused" };
4958
+ if (session.status === "closed")
4959
+ return { kind: "session", session, status: "closed", message: "Session is closed" };
4960
+ const agent = await sendAgentSessionMessage(config, session, message, { run: options.run });
4961
+ const timestamp = nowIso();
4962
+ session.lastMessageAt = timestamp;
4963
+ session.updatedAt = timestamp;
4964
+ if (session.agentSession)
4965
+ session.agentSession.updatedAt = timestamp;
4966
+ if (agent.timedOut || agent.exitCode !== null && agent.exitCode !== 0) {
4967
+ return {
4968
+ kind: "session",
4969
+ session,
4970
+ agent,
4971
+ deliveredResponse: false,
4972
+ status: "failed",
4973
+ message: agent.stderr.trim() || agent.stdout.trim() || (agent.timedOut ? "Agent timed out" : `Agent exited ${agent.exitCode}`)
4974
+ };
4975
+ }
4976
+ const responseText = agent.stdout.trim();
4977
+ await options.beforeDeliver?.(agent, responseText);
4978
+ const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
4979
+ return {
4980
+ kind: "session",
4981
+ session,
4982
+ agent,
4983
+ deliveredResponse,
4984
+ status: responseText ? "delivered" : "no_output"
4985
+ };
4986
+ }
4987
+ async function routeSessionMessage(config, state, message, options = {}) {
4988
+ const channel = config.channels[message.channelId];
4989
+ if (!channel || channel.enabled === false) {
4990
+ return { kind: "session", status: "unauthorized", message: `Channel not enabled: ${message.channelId}` };
4991
+ }
4992
+ if (!channelAuthorized(config, message)) {
4993
+ return { kind: "session", status: "unauthorized", message: "Message is not authorized for this channel" };
4994
+ }
4995
+ const conversationId = messageConversationId(config, message);
4996
+ const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
4997
+ if (!binding) {
4998
+ const text = noSessionText(message.channelId, conversationId);
4999
+ if (options.respondOnNoSession !== false)
5000
+ await deliverResponse(config, message, text, options);
5001
+ return { kind: "session", conversationId, status: "no_session", message: text };
5002
+ }
5003
+ if (!bindingAuthorized(binding, message)) {
5004
+ return { kind: "session", binding, conversationId, status: "unauthorized", message: "Message does not match binding authorization" };
5005
+ }
5006
+ const result = await sendBridgeSessionMessage(config, state, binding.activeSessionId, message, options);
5007
+ return { ...result, binding, conversationId };
5008
+ }
5009
+ function beginLedger(state, message, conversationId) {
5010
+ const id = ledgerId(message);
5011
+ const existing = state.messageLedger[id];
5012
+ if (existing && ["delivered", "skipped", "unauthorized"].includes(existing.status)) {
5013
+ return { entry: existing, shouldProcess: false };
5014
+ }
5015
+ const timestamp = nowIso();
5016
+ const entry = existing || {
5017
+ id,
5018
+ channelId: message.channelId,
5019
+ messageId: message.id,
5020
+ conversationId,
5021
+ status: "processing",
5022
+ attempts: 0,
5023
+ firstSeenAt: timestamp,
5024
+ updatedAt: timestamp
5025
+ };
5026
+ if (entry.status !== "agent_completed")
5027
+ entry.status = "processing";
5028
+ entry.attempts += 1;
5029
+ entry.conversationId = conversationId || entry.conversationId;
5030
+ entry.updatedAt = timestamp;
5031
+ delete entry.error;
5032
+ state.messageLedger[id] = entry;
5033
+ return { entry, shouldProcess: true };
5034
+ }
5035
+ function completeLedger(entry, status, sessionId, error) {
5036
+ const timestamp = nowIso();
5037
+ entry.status = status;
5038
+ entry.sessionId = sessionId || entry.sessionId;
5039
+ entry.updatedAt = timestamp;
5040
+ if (["delivered", "skipped", "unauthorized"].includes(status))
5041
+ entry.terminalAt = timestamp;
5042
+ if (error)
5043
+ entry.error = error;
5044
+ return entry;
5045
+ }
5046
+ function recordAgentCompleted(entry, sessionId, agent, responseText) {
5047
+ const timestamp = nowIso();
5048
+ entry.status = "agent_completed";
5049
+ entry.sessionId = sessionId || entry.sessionId;
5050
+ entry.responseText = responseText;
5051
+ entry.agentExitCode = agent.exitCode;
5052
+ entry.agentTimedOut = agent.timedOut;
5053
+ entry.updatedAt = timestamp;
5054
+ delete entry.error;
5055
+ return entry;
5056
+ }
5057
+ async function dispatchMessageWithSessions(config, state, message, options = {}) {
5058
+ const conversationId = messageConversationId(config, message);
5059
+ const { entry, shouldProcess } = beginLedger(state, message, conversationId);
5060
+ if (!shouldProcess)
5061
+ return { message, ledger: entry };
5062
+ await options.persistState?.(state);
5063
+ try {
5064
+ const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
5065
+ if (binding) {
5066
+ if (!bindingAuthorized(binding, message)) {
5067
+ const session3 = {
5068
+ kind: "session",
5069
+ binding,
5070
+ conversationId,
5071
+ status: "unauthorized",
5072
+ message: "Message does not match binding authorization"
5073
+ };
5074
+ completeLedger(entry, "unauthorized");
5075
+ return { message, session: session3, ledger: entry };
5076
+ }
5077
+ if (entry.status === "agent_completed") {
5078
+ const session3 = await deliverStoredResponse(config, state, binding, message, entry, options);
5079
+ return { message, session: session3, ledger: entry };
5080
+ }
5081
+ const session2 = await routeSessionMessage(config, state, message, {
5082
+ ...options,
5083
+ beforeDeliver: async (agent, responseText) => {
5084
+ recordAgentCompleted(entry, binding.activeSessionId, agent, responseText);
5085
+ await options.persistState?.(state);
5086
+ await options.beforeDeliver?.(agent, responseText);
5087
+ }
5088
+ });
5089
+ if (session2.status === "failed") {
5090
+ completeLedger(entry, "failed", session2.session?.id, session2.message);
5091
+ throw new Error(session2.message || "Agent session failed");
5092
+ }
5093
+ const terminal = session2.status === "unauthorized" ? "unauthorized" : session2.status === "delivered" || session2.status === "no_output" ? "delivered" : "skipped";
5094
+ completeLedger(entry, terminal, session2.session?.id);
5095
+ entry.deliveredResponse = session2.deliveredResponse;
5096
+ return { message, session: session2, ledger: entry };
5097
+ }
5098
+ if (options.fallbackToRoutes) {
5099
+ const routes = await routeMessage(config, message, options);
5100
+ if (routes.length) {
5101
+ completeLedger(entry, "delivered");
5102
+ return { message, routes, ledger: entry };
5103
+ }
5104
+ }
5105
+ const session = await routeSessionMessage(config, state, message, options);
5106
+ const status = session.status === "unauthorized" ? "unauthorized" : "skipped";
5107
+ completeLedger(entry, status, session.session?.id);
5108
+ return { message, session, ledger: entry };
5109
+ } catch (err) {
5110
+ const messageText = err instanceof Error ? err.message : String(err);
5111
+ if (entry.status === "agent_completed") {
5112
+ entry.error = messageText;
5113
+ entry.updatedAt = nowIso();
5114
+ } else {
5115
+ completeLedger(entry, "failed", undefined, messageText);
5116
+ }
5117
+ throw err;
5118
+ }
5119
+ }
4376
5120
  // src/mcp/index.ts
4377
5121
  function text(value) {
4378
5122
  return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
4379
5123
  }
4380
5124
  function buildServer() {
4381
- const server = new McpServer({ name: "bridge", version: "0.1.1" });
5125
+ const server = new McpServer({ name: "bridge", version: "0.2.0" });
4382
5126
  server.tool("bridge_status", {}, async () => text(await doctor()));
4383
5127
  server.tool("bridge_config", {}, async () => text(redactConfig(await loadConfig())));
5128
+ server.tool("bridge_session_list", {}, async () => text(listBridgeSessions(await loadState())));
5129
+ server.tool("bridge_session_status", { sessionId: exports_external.string() }, async (args) => text(getBridgeSession(await loadState(), args.sessionId)));
5130
+ server.tool("bridge_session_create", {
5131
+ agentId: exports_external.string(),
5132
+ title: exports_external.string().optional(),
5133
+ cwd: exports_external.string().optional()
5134
+ }, async (args) => {
5135
+ const config2 = await loadConfig();
5136
+ const state2 = await loadState();
5137
+ const session = createBridgeSession(config2, state2, { agentId: args.agentId, title: args.title, cwd: args.cwd });
5138
+ await saveState(state2);
5139
+ return text(session);
5140
+ });
5141
+ server.tool("bridge_session_attach", {
5142
+ sessionId: exports_external.string(),
5143
+ channelId: exports_external.string(),
5144
+ conversation: exports_external.string(),
5145
+ makeDefault: exports_external.boolean().optional()
5146
+ }, async (args) => {
5147
+ const config2 = await loadConfig();
5148
+ const state2 = await loadState();
5149
+ const binding = attachBridgeSession(config2, state2, {
5150
+ sessionId: args.sessionId,
5151
+ channelId: args.channelId,
5152
+ conversation: args.conversation,
5153
+ makeDefault: args.makeDefault
5154
+ });
5155
+ await saveState(state2);
5156
+ return text(binding);
5157
+ });
5158
+ server.tool("bridge_session_send", {
5159
+ sessionId: exports_external.string(),
5160
+ text: exports_external.string()
5161
+ }, async (args) => {
5162
+ const config2 = await loadConfig();
5163
+ const state2 = await loadState();
5164
+ const result = await sendBridgeSessionMessage(config2, state2, args.sessionId, {
5165
+ id: `mcp:${Date.now()}`,
5166
+ channelId: "mcp",
5167
+ text: args.text,
5168
+ receivedAt: new Date().toISOString()
5169
+ }, { writeConsole: false });
5170
+ await saveState(state2);
5171
+ return text(result);
5172
+ });
5173
+ server.tool("bridge_session_route_message", {
5174
+ channelId: exports_external.string(),
5175
+ text: exports_external.string(),
5176
+ chatId: exports_external.string().optional(),
5177
+ threadId: exports_external.string().optional(),
5178
+ from: exports_external.string().optional(),
5179
+ fallbackRoutes: exports_external.boolean().optional()
5180
+ }, async (args) => {
5181
+ const config2 = await loadConfig();
5182
+ const state2 = await loadState();
5183
+ const result = await dispatchMessageWithSessions(config2, state2, {
5184
+ id: `mcp:${Date.now()}`,
5185
+ channelId: args.channelId,
5186
+ text: args.text,
5187
+ chatId: args.chatId,
5188
+ threadId: args.threadId,
5189
+ from: args.from,
5190
+ receivedAt: new Date().toISOString()
5191
+ }, {
5192
+ writeConsole: false,
5193
+ fallbackToRoutes: Boolean(args.fallbackRoutes),
5194
+ persistState: async (nextState) => saveState(nextState)
5195
+ });
5196
+ await saveState(state2);
5197
+ return text(result);
5198
+ });
4384
5199
  server.tool("bridge_route_message", {
4385
5200
  channelId: exports_external.string(),
4386
5201
  text: exports_external.string(),