@hasna/bridge 0.1.2 → 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 dirname4, join as join4 } 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());
@@ -6313,19 +6351,35 @@ import { dirname as dirname3, join as join3, resolve } from "path";
6313
6351
  // src/lib/state.ts
6314
6352
  import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
6315
6353
  import { dirname as dirname2, join as join2 } from "path";
6354
+ var STATE_SCHEMA_VERSION = 2;
6316
6355
  function defaultStatePath() {
6317
6356
  return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
6318
6357
  }
6319
6358
  function emptyState() {
6320
- 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
+ };
6321
6377
  }
6322
6378
  async function loadState(statePath = defaultStatePath()) {
6323
6379
  try {
6324
6380
  const raw = await readFile2(statePath, "utf-8");
6325
6381
  const parsed = JSON.parse(raw);
6326
- return {
6327
- telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
6328
- };
6382
+ return normalizeState(parsed);
6329
6383
  } catch (err) {
6330
6384
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
6331
6385
  return emptyState();
@@ -6334,8 +6388,9 @@ async function loadState(statePath = defaultStatePath()) {
6334
6388
  }
6335
6389
  }
6336
6390
  async function saveState(state, statePath = defaultStatePath()) {
6391
+ const normalized = normalizeState(state);
6337
6392
  await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
6338
- await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
6393
+ await writeFile2(statePath, `${JSON.stringify(normalized, null, 2)}
6339
6394
  `, { encoding: "utf-8", mode: 384 });
6340
6395
  await chmod2(statePath, 384);
6341
6396
  }
@@ -6422,6 +6477,7 @@ function telegramUpdateToMessage(channelId, update) {
6422
6477
  channelId,
6423
6478
  text,
6424
6479
  chatId: String(chatId),
6480
+ threadId: update.message?.message_thread_id !== undefined ? String(update.message.message_thread_id) : undefined,
6425
6481
  from: update.message?.from?.username || (update.message?.from?.id !== undefined ? String(update.message.from.id) : undefined),
6426
6482
  receivedAt: update.message?.date ? new Date(update.message.date * 1000).toISOString() : new Date().toISOString(),
6427
6483
  raw: update
@@ -6600,14 +6656,17 @@ function startCommand(options) {
6600
6656
  function telegramChannels(config) {
6601
6657
  return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
6602
6658
  }
6659
+ function imessagePollChannels(config) {
6660
+ return Object.values(config.channels).filter((channel) => channel.kind === "imessage" && channel.enabled !== false && channel.receiveMode === "chat-db");
6661
+ }
6603
6662
  function requiredTelegramEnvVars(config) {
6604
6663
  return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
6605
6664
  }
6606
6665
  async function validateStartConfig(configPath) {
6607
6666
  const config = await loadConfig(configPath);
6608
- const channels = telegramChannels(config);
6667
+ const channels = [...telegramChannels(config), ...imessagePollChannels(config)];
6609
6668
  if (!channels.length)
6610
- throw new Error("No enabled Telegram channels configured; add one before starting the daemon");
6669
+ throw new Error("No enabled pollable channels configured; add Telegram or iMessage receive before starting the daemon");
6611
6670
  for (const envName of requiredTelegramEnvVars(config)) {
6612
6671
  if (!process.env[envName])
6613
6672
  throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
@@ -7009,6 +7068,167 @@ async function daemonLogs(options = {}) {
7009
7068
  }
7010
7069
  // src/lib/doctor.ts
7011
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
7012
7232
  function isNotFound2(err) {
7013
7233
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
7014
7234
  }
@@ -7036,7 +7256,7 @@ async function privateDirCheck(name, path) {
7036
7256
  return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
7037
7257
  }
7038
7258
  }
7039
- async function commandExists(command) {
7259
+ async function commandExists2(command) {
7040
7260
  const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
7041
7261
  stdout: "ignore",
7042
7262
  stderr: "ignore"
@@ -7074,7 +7294,7 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
7074
7294
  for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
7075
7295
  checks.push({
7076
7296
  name: `command:${command}`,
7077
- ok: command === "bridge" ? true : await commandExists(command),
7297
+ ok: command === "bridge" ? true : await commandExists2(command),
7078
7298
  detail: command === "bridge" ? "current package" : undefined
7079
7299
  });
7080
7300
  }
@@ -7099,6 +7319,10 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
7099
7319
  detail: `${route.fromChannel} -> ${route.toAgent}`
7100
7320
  });
7101
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
+ }
7102
7326
  return { ok: checks.every((check) => check.ok), configPath, checks };
7103
7327
  }
7104
7328
  // src/lib/router.ts
@@ -7109,6 +7333,9 @@ function matchingRoutes(config, message) {
7109
7333
  if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
7110
7334
  return [];
7111
7335
  }
7336
+ if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
7337
+ return [];
7338
+ }
7112
7339
  return config.routes.filter((route) => {
7113
7340
  if (route.enabled === false)
7114
7341
  return false;
@@ -7148,15 +7375,359 @@ async function routeMessage(config, message, options = {}) {
7148
7375
  if (options.writeConsole !== false)
7149
7376
  (options.writeConsole || console.log)(responseText);
7150
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
+ }
7151
7385
  }
7152
7386
  results.push({ route, agent, deliveredResponse });
7153
7387
  }
7154
7388
  return results;
7155
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
+ }
7156
7727
  // src/cli/index.ts
7157
7728
  function version() {
7158
7729
  try {
7159
- const pkgPath = join4(dirname4(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7730
+ const pkgPath = join5(dirname4(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7160
7731
  return JSON.parse(readFileSync(pkgPath, "utf-8")).version || "0.0.0";
7161
7732
  } catch {
7162
7733
  return "0.0.0";
@@ -7198,11 +7769,11 @@ function printList(items) {
7198
7769
  async function runServe(options) {
7199
7770
  const config2 = await loadConfig(options.config);
7200
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");
7201
7773
  const intervalMs = parseNonNegativeInt(options.interval || "1000", "--interval");
7202
- if (!telegramChannels2.length)
7203
- throw new Error("No enabled Telegram channels configured");
7774
+ if (!telegramChannels2.length && !imessageChannels.length)
7775
+ throw new Error("No enabled pollable channels configured");
7204
7776
  const statePath = options.state || defaultStatePath();
7205
- const state2 = await loadState(statePath);
7206
7777
  const errorCounts = new Map;
7207
7778
  let stopping = false;
7208
7779
  const stop = () => {
@@ -7213,20 +7784,79 @@ async function runServe(options) {
7213
7784
  while (!stopping) {
7214
7785
  for (const channel of telegramChannels2) {
7215
7786
  try {
7787
+ const pollState = await loadState(statePath);
7216
7788
  const updates = await getTelegramUpdates(telegramToken(channel), {
7217
- offset: state2.telegramOffsets[channel.id],
7789
+ offset: pollState.telegramOffsets[channel.id],
7218
7790
  timeoutSeconds: channel.pollTimeoutSeconds || 20
7219
7791
  });
7220
7792
  errorCounts.delete(channel.id);
7221
7793
  for (const update of updates) {
7222
- state2.telegramOffsets[channel.id] = update.update_id + 1;
7223
- await saveState(state2, statePath);
7794
+ const state2 = await loadState(statePath);
7224
7795
  const message = telegramUpdateToMessage(channel.id, update);
7225
- if (!message)
7226
- continue;
7227
- const results = await routeMessage(config2, message, { writeConsole: options.json ? false : undefined });
7228
- if (options.json)
7229
- asJson({ message, results });
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
+ }
7230
7860
  }
7231
7861
  } catch (err) {
7232
7862
  if (options.once)
@@ -7294,6 +7924,27 @@ channels.command("add-console").argument("<id>").description("Add a console chan
7294
7924
  const config2 = await upsertChannel({ id, kind: "console", enabled: true }, options.config);
7295
7925
  options.json ? asJson(config2.channels[id]) : console.log(`Added console channel ${id}`);
7296
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
+ });
7297
7948
  var profiles = program2.command("profiles").description("Manage reusable agent profiles");
7298
7949
  profiles.command("list").option("-c, --config <path>", "config path", defaultConfigPath()).option("--json", "output JSON").action(async (options) => {
7299
7950
  const config2 = await loadConfig(options.config);
@@ -7350,14 +8001,103 @@ routes.command("add").argument("<id>").requiredOption("--from <channel>", "sourc
7350
8001
  }, options.config);
7351
8002
  options.json ? asJson(config2.routes.find((route) => route.id === id)) : console.log(`Added route ${id}`);
7352
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
+ });
7353
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) => {
7354
8093
  const config2 = await loadConfig(options.config);
7355
8094
  const channel = config2.channels[channelId];
7356
8095
  if (!channel)
7357
8096
  throw new Error(`Channel not found: ${channelId}`);
7358
8097
  let targetChat = chatId;
7359
- let text = textParts.join(" ");
7360
- if (channel.kind !== "telegram" && !text && targetChat) {
8098
+ const textArgParts = textParts;
8099
+ let text = textArgParts.join(" ");
8100
+ if (channel.kind === "console" && !text && targetChat) {
7361
8101
  text = targetChat;
7362
8102
  targetChat = undefined;
7363
8103
  }
@@ -7379,6 +8119,26 @@ program2.command("send").argument("<channel>").argument("[chatId]").argument("[t
7379
8119
  console.log(text);
7380
8120
  return;
7381
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
+ }
7382
8142
  throw new Error(`Sending through ${channel.kind} is not implemented yet`);
7383
8143
  });
7384
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) => {