@hasna/bridge 0.1.2 → 0.2.1

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/index.js CHANGED
@@ -32,6 +32,11 @@ function mergeEnv(profile, agent) {
32
32
  env["HOME"] = profile.home;
33
33
  return Object.keys(env).length ? env : undefined;
34
34
  }
35
+ function compatibilityDetail(kind) {
36
+ if (kind === "shell")
37
+ return "shell command session; local bridge state is durable";
38
+ return "compatibility mode: this adapter invokes the current CLI one message at a time until a stable create/send/resume API is wired";
39
+ }
35
40
  function resolveAgent(config, agentId) {
36
41
  const agent = config.agents[agentId];
37
42
  if (!agent)
@@ -50,7 +55,7 @@ function buildAgentCommand(config, agentId, input) {
50
55
  const kind = agent.kind;
51
56
  const command = agent.command || profile?.command;
52
57
  const args = agent.args || profile?.args;
53
- const cwd = agent.cwd || profile?.cwd;
58
+ const cwd = input.session?.cwd || agent.cwd || profile?.cwd;
54
59
  const env = mergeEnv(profile, agent);
55
60
  if (command) {
56
61
  return { command: [command, ...renderCustomArgs(args, prompt)], cwd, env };
@@ -72,6 +77,46 @@ function buildAgentCommand(config, agentId, input) {
72
77
  }
73
78
  return { command: ["sh", "-lc", prompt], cwd, env };
74
79
  }
80
+ function createAgentSessionRef(config, agentId) {
81
+ const { agent } = resolveAgent(config, agentId);
82
+ const timestamp = new Date().toISOString();
83
+ return {
84
+ kind: agent.kind,
85
+ mode: "compatibility",
86
+ createdAt: timestamp,
87
+ updatedAt: timestamp,
88
+ detail: compatibilityDetail(agent.kind)
89
+ };
90
+ }
91
+ function resumeAgentSessionRef(session) {
92
+ return {
93
+ supported: session.agentSession?.mode === "durable",
94
+ ref: session.agentSession,
95
+ detail: session.agentSession?.mode === "durable" ? "durable agent session ref is available" : "compatibility sessions do not expose agent-side resume; bridge binding state is still durable"
96
+ };
97
+ }
98
+ function cancelAgentSession(session) {
99
+ return {
100
+ supported: false,
101
+ ref: session.agentSession,
102
+ detail: `cancel is not implemented for ${session.agentSession?.kind || "unknown"} ${session.agentSession?.mode || "compatibility"} sessions`
103
+ };
104
+ }
105
+ function closeAgentSession(session) {
106
+ return {
107
+ supported: session.agentSession?.mode === "durable",
108
+ ref: session.agentSession,
109
+ detail: session.agentSession?.mode === "durable" ? "durable close is adapter-owned" : "compatibility close only updates bridge session state"
110
+ };
111
+ }
112
+ async function sendAgentSessionMessage(config, session, message, options = {}) {
113
+ const run = options.run || runAgent;
114
+ return run(config, session.agentId, {
115
+ message,
116
+ route: { id: `session:${session.id}`, fromChannel: message.channelId, toAgent: session.agentId },
117
+ session
118
+ });
119
+ }
75
120
  async function runAgent(config, agentId, input) {
76
121
  const { agent } = resolveAgent(config, agentId);
77
122
  const built = buildAgentCommand(config, agentId, input);
@@ -4130,7 +4175,14 @@ var channelSchema = exports_external.discriminatedUnion("kind", [
4130
4175
  kind: exports_external.literal("imessage"),
4131
4176
  label: exports_external.string().optional(),
4132
4177
  enabled: exports_external.boolean().optional(),
4133
- account: exports_external.string().optional()
4178
+ account: exports_external.string().optional(),
4179
+ serviceName: exports_external.string().optional(),
4180
+ defaultHandle: exports_external.string().optional(),
4181
+ allowedHandles: exports_external.array(exports_external.string()).optional(),
4182
+ allowAllHandles: exports_external.boolean().optional(),
4183
+ receiveMode: exports_external.enum(["disabled", "chat-db"]).optional(),
4184
+ chatDbPath: exports_external.string().optional(),
4185
+ pollLimit: exports_external.number().int().positive().max(500).optional()
4134
4186
  })
4135
4187
  ]);
4136
4188
  var envSchema = exports_external.record(exports_external.string(), exports_external.string());
@@ -4268,19 +4320,35 @@ import { dirname as dirname3, join as join3, resolve } from "path";
4268
4320
  // src/lib/state.ts
4269
4321
  import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
4270
4322
  import { dirname as dirname2, join as join2 } from "path";
4323
+ var STATE_SCHEMA_VERSION = 2;
4271
4324
  function defaultStatePath() {
4272
4325
  return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
4273
4326
  }
4274
4327
  function emptyState() {
4275
- return { telegramOffsets: {} };
4328
+ return {
4329
+ schemaVersion: STATE_SCHEMA_VERSION,
4330
+ telegramOffsets: {},
4331
+ sessions: {},
4332
+ bindings: {},
4333
+ messageLedger: {},
4334
+ cursors: {}
4335
+ };
4336
+ }
4337
+ function normalizeState(value) {
4338
+ return {
4339
+ schemaVersion: STATE_SCHEMA_VERSION,
4340
+ telegramOffsets: value.telegramOffsets && typeof value.telegramOffsets === "object" ? value.telegramOffsets : {},
4341
+ sessions: value.sessions && typeof value.sessions === "object" ? value.sessions : {},
4342
+ bindings: value.bindings && typeof value.bindings === "object" ? value.bindings : {},
4343
+ messageLedger: value.messageLedger && typeof value.messageLedger === "object" ? value.messageLedger : {},
4344
+ cursors: value.cursors && typeof value.cursors === "object" ? value.cursors : {}
4345
+ };
4276
4346
  }
4277
4347
  async function loadState(statePath = defaultStatePath()) {
4278
4348
  try {
4279
4349
  const raw = await readFile2(statePath, "utf-8");
4280
4350
  const parsed = JSON.parse(raw);
4281
- return {
4282
- telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
4283
- };
4351
+ return normalizeState(parsed);
4284
4352
  } catch (err) {
4285
4353
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
4286
4354
  return emptyState();
@@ -4289,8 +4357,9 @@ async function loadState(statePath = defaultStatePath()) {
4289
4357
  }
4290
4358
  }
4291
4359
  async function saveState(state, statePath = defaultStatePath()) {
4360
+ const normalized = normalizeState(state);
4292
4361
  await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
4293
- await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
4362
+ await writeFile2(statePath, `${JSON.stringify(normalized, null, 2)}
4294
4363
  `, { encoding: "utf-8", mode: 384 });
4295
4364
  await chmod2(statePath, 384);
4296
4365
  }
@@ -4377,6 +4446,7 @@ function telegramUpdateToMessage(channelId, update) {
4377
4446
  channelId,
4378
4447
  text,
4379
4448
  chatId: String(chatId),
4449
+ threadId: update.message?.message_thread_id !== undefined ? String(update.message.message_thread_id) : undefined,
4380
4450
  from: update.message?.from?.username || (update.message?.from?.id !== undefined ? String(update.message.from.id) : undefined),
4381
4451
  receivedAt: update.message?.date ? new Date(update.message.date * 1000).toISOString() : new Date().toISOString(),
4382
4452
  raw: update
@@ -4555,14 +4625,17 @@ function startCommand(options) {
4555
4625
  function telegramChannels(config) {
4556
4626
  return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
4557
4627
  }
4628
+ function imessagePollChannels(config) {
4629
+ return Object.values(config.channels).filter((channel) => channel.kind === "imessage" && channel.enabled !== false && channel.receiveMode === "chat-db");
4630
+ }
4558
4631
  function requiredTelegramEnvVars(config) {
4559
4632
  return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
4560
4633
  }
4561
4634
  async function validateStartConfig(configPath) {
4562
4635
  const config = await loadConfig(configPath);
4563
- const channels = telegramChannels(config);
4636
+ const channels = [...telegramChannels(config), ...imessagePollChannels(config)];
4564
4637
  if (!channels.length)
4565
- throw new Error("No enabled Telegram channels configured; add one before starting the daemon");
4638
+ throw new Error("No enabled pollable channels configured; add Telegram or iMessage receive before starting the daemon");
4566
4639
  for (const envName of requiredTelegramEnvVars(config)) {
4567
4640
  if (!process.env[envName])
4568
4641
  throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
@@ -4964,6 +5037,213 @@ async function daemonLogs(options = {}) {
4964
5037
  }
4965
5038
  // src/lib/doctor.ts
4966
5039
  import { stat as stat2 } from "fs/promises";
5040
+
5041
+ // src/lib/imessage.ts
5042
+ import { access } from "fs/promises";
5043
+ import { join as join4 } from "path";
5044
+ import { Database } from "bun:sqlite";
5045
+ function defaultMessagesDbPath() {
5046
+ return join4(homeDir(), "Library", "Messages", "chat.db");
5047
+ }
5048
+ function imessageHandleAllowed(channel, handle) {
5049
+ if (channel.allowAllHandles)
5050
+ return true;
5051
+ if (!channel.allowedHandles?.length)
5052
+ return false;
5053
+ return Boolean(handle && channel.allowedHandles.includes(handle));
5054
+ }
5055
+ function appleScriptString(value) {
5056
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
5057
+ }
5058
+ function renderSendIMessageScript(channel, handle, text) {
5059
+ const service = channel.serviceName || "iMessage";
5060
+ const serviceSelector = channel.account ? `1st service whose name = ${appleScriptString(service)} and account = ${appleScriptString(channel.account)}` : `1st service whose name = ${appleScriptString(service)}`;
5061
+ const targetLines = handle.startsWith("chat:") ? [
5062
+ `set targetChat to 1st chat whose id = ${appleScriptString(handle.slice("chat:".length))}`,
5063
+ `send ${appleScriptString(text)} to targetChat`
5064
+ ] : [
5065
+ `set targetBuddy to buddy ${appleScriptString(handle)} of targetService`,
5066
+ `send ${appleScriptString(text)} to targetBuddy`
5067
+ ];
5068
+ return [
5069
+ 'tell application "Messages"',
5070
+ `set targetService to ${serviceSelector}`,
5071
+ ...targetLines,
5072
+ "end tell"
5073
+ ].join(`
5074
+ `);
5075
+ }
5076
+ async function defaultRun(command) {
5077
+ const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
5078
+ const [exitCode, stdout, stderr] = await Promise.all([
5079
+ proc.exited,
5080
+ new Response(proc.stdout).text(),
5081
+ new Response(proc.stderr).text()
5082
+ ]);
5083
+ return { exitCode, stdout, stderr };
5084
+ }
5085
+ async function sendIMessage(channel, handle, text, options = {}) {
5086
+ if (!(options.allowChatTarget && handle.startsWith("chat:")) && !imessageHandleAllowed(channel, handle)) {
5087
+ throw new Error(`iMessage handle is not allowed for channel ${channel.id}: ${handle}`);
5088
+ }
5089
+ const script = renderSendIMessageScript(channel, handle, text);
5090
+ const result = await (options.run || defaultRun)(["osascript", "-e", script]);
5091
+ if (result.exitCode !== 0) {
5092
+ throw new Error(`iMessage send failed: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
5093
+ }
5094
+ return { ok: true };
5095
+ }
5096
+ function imessageDateToIso(value) {
5097
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
5098
+ return new Date().toISOString();
5099
+ const appleEpochMs = Date.UTC(2001, 0, 1);
5100
+ if (value > 1000000000000000)
5101
+ return new Date(appleEpochMs + Math.floor(value / 1e6)).toISOString();
5102
+ if (value > 1e9)
5103
+ return new Date(appleEpochMs + value * 1000).toISOString();
5104
+ return new Date(appleEpochMs + value).toISOString();
5105
+ }
5106
+ function getIMessageDbPath(channel) {
5107
+ return channel.chatDbPath || defaultMessagesDbPath();
5108
+ }
5109
+ function tableColumns(db, table) {
5110
+ const rows = db.query(`pragma table_info(${table})`).all();
5111
+ return new Set(rows.map((row) => row.name).filter((name) => Boolean(name)));
5112
+ }
5113
+ function selectColumn(columns, table, column, alias) {
5114
+ return columns.has(column) ? `${table}.${column} as ${alias}` : `null as ${alias}`;
5115
+ }
5116
+ function normalizeIdentifier(value) {
5117
+ return value.trim().toLowerCase();
5118
+ }
5119
+ function valueMatchesConfigured(value, expected) {
5120
+ if (!value || !expected)
5121
+ return false;
5122
+ const normalizedValue = normalizeIdentifier(value);
5123
+ const normalizedExpected = normalizeIdentifier(expected);
5124
+ return normalizedValue === normalizedExpected || normalizedValue.endsWith(`:${normalizedExpected}`) || normalizedValue.endsWith(`;${normalizedExpected}`);
5125
+ }
5126
+ function rowMatchesAccount(channel, row) {
5127
+ if (!channel.account)
5128
+ return true;
5129
+ const rowCandidates = [row.account, row.accountGuid].filter(Boolean);
5130
+ const candidates = rowCandidates.length ? rowCandidates : [row.chatAccount].filter(Boolean);
5131
+ return candidates.some((value) => valueMatchesConfigured(value, channel.account));
5132
+ }
5133
+ function rowMatchesService(channel, row) {
5134
+ const expected = channel.serviceName || "iMessage";
5135
+ const candidates = row.service ? [row.service] : row.handleService ? [row.handleService] : row.chatService ? [row.chatService] : [];
5136
+ if (!candidates.length)
5137
+ return true;
5138
+ return candidates.some((value) => valueMatchesConfigured(value, expected));
5139
+ }
5140
+ function getIMessageMessages(channel, options = {}) {
5141
+ if ((channel.receiveMode || "disabled") !== "chat-db")
5142
+ return [];
5143
+ const db = new Database(getIMessageDbPath(channel), { readonly: true });
5144
+ try {
5145
+ const messageColumns = tableColumns(db, "message");
5146
+ const handleColumns = tableColumns(db, "handle");
5147
+ const chatColumns = tableColumns(db, "chat");
5148
+ const limit = options.limit || channel.pollLimit || 50;
5149
+ const scanLimit = Math.max(limit * 10, limit);
5150
+ const rows = db.query(`
5151
+ select
5152
+ message.ROWID as rowId,
5153
+ handle.id as handle,
5154
+ ${selectColumn(messageColumns, "message", "account", "account")},
5155
+ ${selectColumn(messageColumns, "message", "account_guid", "accountGuid")},
5156
+ ${selectColumn(messageColumns, "message", "service", "service")},
5157
+ ${selectColumn(handleColumns, "handle", "service", "handleService")},
5158
+ ${selectColumn(chatColumns, "chat", "account_login", "chatAccount")},
5159
+ ${selectColumn(chatColumns, "chat", "service_name", "chatService")},
5160
+ chat.guid as chatGuid,
5161
+ chat.display_name as displayName,
5162
+ message.text as text,
5163
+ message.date as date
5164
+ from message
5165
+ left join handle on message.handle_id = handle.ROWID
5166
+ left join chat_message_join on chat_message_join.message_id = message.ROWID
5167
+ left join chat on chat.ROWID = chat_message_join.chat_id
5168
+ where message.ROWID > ?
5169
+ and message.is_from_me = 0
5170
+ and message.text is not null
5171
+ order by message.ROWID asc
5172
+ limit ?
5173
+ `).all(options.afterRowId || 0, scanLimit);
5174
+ return rows.filter((row) => row.handle && row.text && imessageHandleAllowed(channel, row.handle) && rowMatchesAccount(channel, row) && rowMatchesService(channel, row)).slice(0, limit).map((row) => {
5175
+ const item = { rowId: row.rowId, handle: row.handle, text: row.text, date: row.date };
5176
+ if (row.account)
5177
+ item.account = row.account;
5178
+ if (row.accountGuid)
5179
+ item.accountGuid = row.accountGuid;
5180
+ if (row.service || row.handleService || row.chatService)
5181
+ item.service = row.service || row.handleService || row.chatService;
5182
+ if (row.chatGuid)
5183
+ item.chatGuid = row.chatGuid;
5184
+ if (row.displayName)
5185
+ item.displayName = row.displayName;
5186
+ return item;
5187
+ });
5188
+ } finally {
5189
+ db.close();
5190
+ }
5191
+ }
5192
+ function imessageRowToMessage(channelId, row) {
5193
+ return {
5194
+ id: `imessage:${row.rowId}`,
5195
+ channelId,
5196
+ chatId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
5197
+ responseTargetId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
5198
+ from: row.handle,
5199
+ text: row.text,
5200
+ receivedAt: imessageDateToIso(row.date),
5201
+ raw: row
5202
+ };
5203
+ }
5204
+ async function commandExists(command) {
5205
+ const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
5206
+ stdout: "ignore",
5207
+ stderr: "ignore"
5208
+ });
5209
+ return await proc.exited === 0;
5210
+ }
5211
+ async function diagnoseIMessage(channel) {
5212
+ const checks = [];
5213
+ checks.push({
5214
+ name: `imessage-platform:${channel.id}`,
5215
+ ok: process.platform === "darwin",
5216
+ detail: process.platform === "darwin" ? "macOS" : `unsupported platform: ${process.platform}`
5217
+ });
5218
+ checks.push({
5219
+ name: `imessage-osascript:${channel.id}`,
5220
+ ok: await commandExists("osascript"),
5221
+ detail: "required for Messages send automation"
5222
+ });
5223
+ checks.push({
5224
+ name: `imessage-allowlist:${channel.id}`,
5225
+ ok: Boolean(channel.allowAllHandles || channel.allowedHandles?.length),
5226
+ detail: channel.allowAllHandles ? "allowAllHandles=true" : `${channel.allowedHandles?.length || 0} handle(s)`
5227
+ });
5228
+ if ((channel.receiveMode || "disabled") === "chat-db") {
5229
+ const path = getIMessageDbPath(channel);
5230
+ try {
5231
+ await access(path);
5232
+ checks.push({ name: `imessage-chat-db:${channel.id}`, ok: true, detail: path });
5233
+ } catch (err) {
5234
+ checks.push({
5235
+ name: `imessage-chat-db:${channel.id}`,
5236
+ ok: false,
5237
+ detail: `${path}: ${err instanceof Error ? err.message : String(err)}. Grant Full Disk Access to the terminal/daemon host or disable receive mode.`
5238
+ });
5239
+ }
5240
+ } else {
5241
+ checks.push({ name: `imessage-receive:${channel.id}`, ok: true, detail: "receiveMode=disabled" });
5242
+ }
5243
+ return checks;
5244
+ }
5245
+
5246
+ // src/lib/doctor.ts
4967
5247
  function isNotFound2(err) {
4968
5248
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
4969
5249
  }
@@ -4991,7 +5271,7 @@ async function privateDirCheck(name, path) {
4991
5271
  return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
4992
5272
  }
4993
5273
  }
4994
- async function commandExists(command) {
5274
+ async function commandExists2(command) {
4995
5275
  const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
4996
5276
  stdout: "ignore",
4997
5277
  stderr: "ignore"
@@ -5029,7 +5309,7 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
5029
5309
  for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
5030
5310
  checks.push({
5031
5311
  name: `command:${command}`,
5032
- ok: command === "bridge" ? true : await commandExists(command),
5312
+ ok: command === "bridge" ? true : await commandExists2(command),
5033
5313
  detail: command === "bridge" ? "current package" : undefined
5034
5314
  });
5035
5315
  }
@@ -5054,6 +5334,10 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
5054
5334
  detail: `${route.fromChannel} -> ${route.toAgent}`
5055
5335
  });
5056
5336
  }
5337
+ const imessageChannels = Object.values(config.channels).filter((channel) => channel.kind === "imessage");
5338
+ for (const channel of imessageChannels) {
5339
+ checks.push(...await diagnoseIMessage(channel));
5340
+ }
5057
5341
  return { ok: checks.every((check) => check.ok), configPath, checks };
5058
5342
  }
5059
5343
  // src/lib/router.ts
@@ -5064,6 +5348,9 @@ function matchingRoutes(config, message) {
5064
5348
  if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
5065
5349
  return [];
5066
5350
  }
5351
+ if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
5352
+ return [];
5353
+ }
5067
5354
  return config.routes.filter((route) => {
5068
5355
  if (route.enabled === false)
5069
5356
  return false;
@@ -5103,16 +5390,367 @@ async function routeMessage(config, message, options = {}) {
5103
5390
  if (options.writeConsole !== false)
5104
5391
  (options.writeConsole || console.log)(responseText);
5105
5392
  deliveredResponse = true;
5393
+ } else if (responseText && channel?.kind === "imessage") {
5394
+ const handle = message.responseTargetId || message.chatId || message.from;
5395
+ const allowedIdentity = message.from || (handle?.startsWith("chat:") ? undefined : handle);
5396
+ if (handle && imessageHandleAllowed(channel, allowedIdentity)) {
5397
+ await sendIMessage(channel, handle, responseText, { allowChatTarget: handle.startsWith("chat:") });
5398
+ deliveredResponse = true;
5399
+ }
5106
5400
  }
5107
5401
  results.push({ route, agent, deliveredResponse });
5108
5402
  }
5109
5403
  return results;
5110
5404
  }
5405
+ // src/lib/sessions.ts
5406
+ import { randomUUID } from "crypto";
5407
+ function nowIso() {
5408
+ return new Date().toISOString();
5409
+ }
5410
+ function newSessionId() {
5411
+ return `ses_${randomUUID()}`;
5412
+ }
5413
+ function normalizeConversationId(channel, conversation) {
5414
+ if (conversation.includes(":") && conversation.startsWith(`${channel.kind}:`))
5415
+ return conversation;
5416
+ if (channel.kind === "telegram")
5417
+ return `telegram:${channel.id}:${conversation}`;
5418
+ if (channel.kind === "imessage")
5419
+ return `imessage:${channel.id}:${conversation}`;
5420
+ return `${channel.kind}:${channel.id}:${conversation || "default"}`;
5421
+ }
5422
+ function messageConversationId(config, message) {
5423
+ const channel = config.channels[message.channelId];
5424
+ if (!channel)
5425
+ return;
5426
+ if (channel.kind === "telegram") {
5427
+ if (!message.chatId)
5428
+ return;
5429
+ return normalizeConversationId(channel, message.threadId ? `${message.chatId}:${message.threadId}` : message.chatId);
5430
+ }
5431
+ if (channel.kind === "imessage") {
5432
+ const conversation = message.chatId || message.from;
5433
+ return conversation ? normalizeConversationId(channel, conversation) : undefined;
5434
+ }
5435
+ return normalizeConversationId(channel, message.chatId || message.from || "default");
5436
+ }
5437
+ function bindingId(channelId, conversationId) {
5438
+ return `${channelId}::${conversationId}`;
5439
+ }
5440
+ function ledgerId(message) {
5441
+ return `${message.channelId}::${message.id}`;
5442
+ }
5443
+ function createBridgeSession(config, state, input) {
5444
+ const { agent, profile } = resolveAgent(config, input.agentId);
5445
+ const timestamp = nowIso();
5446
+ const session = {
5447
+ id: input.id || newSessionId(),
5448
+ agentId: agent.id,
5449
+ profileId: agent.profileId,
5450
+ cwd: input.cwd || agent.cwd || profile?.cwd,
5451
+ title: input.title,
5452
+ status: "active",
5453
+ createdAt: timestamp,
5454
+ updatedAt: timestamp,
5455
+ agentSession: createAgentSessionRef(config, agent.id)
5456
+ };
5457
+ state.sessions[session.id] = session;
5458
+ return session;
5459
+ }
5460
+ function getBridgeSession(state, sessionId) {
5461
+ const session = state.sessions[sessionId];
5462
+ if (!session)
5463
+ throw new Error(`Session not found: ${sessionId}`);
5464
+ return session;
5465
+ }
5466
+ function listBridgeSessions(state) {
5467
+ return Object.values(state.sessions).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
5468
+ }
5469
+ function updateBridgeSessionStatus(state, sessionId, status) {
5470
+ const session = getBridgeSession(state, sessionId);
5471
+ if (status === "closed")
5472
+ closeAgentSession(session);
5473
+ session.status = status;
5474
+ session.updatedAt = nowIso();
5475
+ return session;
5476
+ }
5477
+ function attachBridgeSession(config, state, input) {
5478
+ const channel = config.channels[input.channelId];
5479
+ if (!channel)
5480
+ throw new Error(`Channel not found: ${input.channelId}`);
5481
+ const session = getBridgeSession(state, input.sessionId);
5482
+ if (session.status === "closed")
5483
+ throw new Error(`Cannot attach closed session: ${session.id}`);
5484
+ const conversationId = normalizeConversationId(channel, input.conversation);
5485
+ const id = bindingId(channel.id, conversationId);
5486
+ const existing = state.bindings[id];
5487
+ const timestamp = nowIso();
5488
+ const binding = {
5489
+ id,
5490
+ channelId: channel.id,
5491
+ conversationId,
5492
+ activeSessionId: session.id,
5493
+ defaultSessionId: input.makeDefault ? session.id : existing?.defaultSessionId,
5494
+ createdAt: existing?.createdAt || timestamp,
5495
+ updatedAt: timestamp,
5496
+ authorization: input.authorization || existing?.authorization || (channel.kind === "telegram" ? { chatId: input.conversation.split(":")[0] } : undefined)
5497
+ };
5498
+ state.bindings[id] = binding;
5499
+ return binding;
5500
+ }
5501
+ function detachBridgeBinding(config, state, channelId, conversation) {
5502
+ const channel = config.channels[channelId];
5503
+ if (!channel)
5504
+ throw new Error(`Channel not found: ${channelId}`);
5505
+ const conversationId = normalizeConversationId(channel, conversation);
5506
+ const id = bindingId(channel.id, conversationId);
5507
+ const existing = state.bindings[id];
5508
+ delete state.bindings[id];
5509
+ return existing;
5510
+ }
5511
+ function findBridgeBinding(config, state, message) {
5512
+ const conversationId = messageConversationId(config, message);
5513
+ if (!conversationId)
5514
+ return;
5515
+ return state.bindings[bindingId(message.channelId, conversationId)];
5516
+ }
5517
+ function noSessionText(channelId, conversationId) {
5518
+ return [
5519
+ "No bridge session is attached to this conversation.",
5520
+ "Create and attach one locally:",
5521
+ "bridge sessions create --agent <agent-id>",
5522
+ `bridge sessions attach <session-id> --channel ${channelId}${conversationId ? ` --conversation ${conversationId}` : " --conversation <conversation-id>"}`
5523
+ ].join(`
5524
+ `);
5525
+ }
5526
+ async function deliverResponse(config, message, text, options) {
5527
+ const channel = config.channels[message.channelId];
5528
+ if (!text || !channel || channel.enabled === false)
5529
+ return false;
5530
+ if (channel.kind === "telegram" && message.chatId) {
5531
+ if (!telegramChatAllowed(channel, message.chatId))
5532
+ return false;
5533
+ await (options.sendTelegram || sendTelegramMessage)(telegramToken(channel), message.chatId, text);
5534
+ return true;
5535
+ }
5536
+ if (channel.kind === "console") {
5537
+ if (options.writeConsole !== false)
5538
+ (options.writeConsole || console.log)(text);
5539
+ return true;
5540
+ }
5541
+ if (channel.kind === "imessage" && message.chatId) {
5542
+ const allowedIdentity = message.from || (message.chatId.startsWith("chat:") ? undefined : message.chatId);
5543
+ if (!imessageHandleAllowed(channel, allowedIdentity))
5544
+ return false;
5545
+ await sendIMessage(channel, message.responseTargetId || message.chatId, text, { allowChatTarget: Boolean(message.responseTargetId?.startsWith("chat:") || message.chatId.startsWith("chat:")) });
5546
+ return true;
5547
+ }
5548
+ return false;
5549
+ }
5550
+ async function deliverStoredResponse(config, state, binding, message, entry, options) {
5551
+ const session = getBridgeSession(state, binding.activeSessionId);
5552
+ const responseText = entry.responseText || "";
5553
+ const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
5554
+ completeLedger(entry, "delivered", session.id);
5555
+ entry.deliveredResponse = deliveredResponse;
5556
+ return {
5557
+ kind: "session",
5558
+ session,
5559
+ binding,
5560
+ conversationId: binding.conversationId,
5561
+ deliveredResponse,
5562
+ status: responseText ? "delivered" : "no_output"
5563
+ };
5564
+ }
5565
+ function channelAuthorized(config, message) {
5566
+ const channel = config.channels[message.channelId];
5567
+ if (!channel || channel.enabled === false)
5568
+ return false;
5569
+ if (channel.kind === "telegram")
5570
+ return telegramChatAllowed(channel, message.chatId);
5571
+ if (channel.kind === "imessage")
5572
+ return imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId));
5573
+ return true;
5574
+ }
5575
+ function bindingAuthorized(binding, message) {
5576
+ if (binding.authorization?.chatId && binding.authorization.chatId !== message.chatId)
5577
+ return false;
5578
+ if (binding.authorization?.from && binding.authorization.from !== message.from)
5579
+ return false;
5580
+ return true;
5581
+ }
5582
+ async function sendBridgeSessionMessage(config, state, sessionId, message, options = {}) {
5583
+ const session = getBridgeSession(state, sessionId);
5584
+ if (session.status === "paused")
5585
+ return { kind: "session", session, status: "paused", message: "Session is paused" };
5586
+ if (session.status === "closed")
5587
+ return { kind: "session", session, status: "closed", message: "Session is closed" };
5588
+ const agent = await sendAgentSessionMessage(config, session, message, { run: options.run });
5589
+ const timestamp = nowIso();
5590
+ session.lastMessageAt = timestamp;
5591
+ session.updatedAt = timestamp;
5592
+ if (session.agentSession)
5593
+ session.agentSession.updatedAt = timestamp;
5594
+ if (agent.timedOut || agent.exitCode !== null && agent.exitCode !== 0) {
5595
+ return {
5596
+ kind: "session",
5597
+ session,
5598
+ agent,
5599
+ deliveredResponse: false,
5600
+ status: "failed",
5601
+ message: agent.stderr.trim() || agent.stdout.trim() || (agent.timedOut ? "Agent timed out" : `Agent exited ${agent.exitCode}`)
5602
+ };
5603
+ }
5604
+ const responseText = agent.stdout.trim();
5605
+ await options.beforeDeliver?.(agent, responseText);
5606
+ const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
5607
+ return {
5608
+ kind: "session",
5609
+ session,
5610
+ agent,
5611
+ deliveredResponse,
5612
+ status: responseText ? "delivered" : "no_output"
5613
+ };
5614
+ }
5615
+ async function routeSessionMessage(config, state, message, options = {}) {
5616
+ const channel = config.channels[message.channelId];
5617
+ if (!channel || channel.enabled === false) {
5618
+ return { kind: "session", status: "unauthorized", message: `Channel not enabled: ${message.channelId}` };
5619
+ }
5620
+ if (!channelAuthorized(config, message)) {
5621
+ return { kind: "session", status: "unauthorized", message: "Message is not authorized for this channel" };
5622
+ }
5623
+ const conversationId = messageConversationId(config, message);
5624
+ const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
5625
+ if (!binding) {
5626
+ const text = noSessionText(message.channelId, conversationId);
5627
+ if (options.respondOnNoSession !== false)
5628
+ await deliverResponse(config, message, text, options);
5629
+ return { kind: "session", conversationId, status: "no_session", message: text };
5630
+ }
5631
+ if (!bindingAuthorized(binding, message)) {
5632
+ return { kind: "session", binding, conversationId, status: "unauthorized", message: "Message does not match binding authorization" };
5633
+ }
5634
+ const result = await sendBridgeSessionMessage(config, state, binding.activeSessionId, message, options);
5635
+ return { ...result, binding, conversationId };
5636
+ }
5637
+ function beginLedger(state, message, conversationId) {
5638
+ const id = ledgerId(message);
5639
+ const existing = state.messageLedger[id];
5640
+ if (existing && ["delivered", "skipped", "unauthorized"].includes(existing.status)) {
5641
+ return { entry: existing, shouldProcess: false };
5642
+ }
5643
+ const timestamp = nowIso();
5644
+ const entry = existing || {
5645
+ id,
5646
+ channelId: message.channelId,
5647
+ messageId: message.id,
5648
+ conversationId,
5649
+ status: "processing",
5650
+ attempts: 0,
5651
+ firstSeenAt: timestamp,
5652
+ updatedAt: timestamp
5653
+ };
5654
+ if (entry.status !== "agent_completed")
5655
+ entry.status = "processing";
5656
+ entry.attempts += 1;
5657
+ entry.conversationId = conversationId || entry.conversationId;
5658
+ entry.updatedAt = timestamp;
5659
+ delete entry.error;
5660
+ state.messageLedger[id] = entry;
5661
+ return { entry, shouldProcess: true };
5662
+ }
5663
+ function completeLedger(entry, status, sessionId, error) {
5664
+ const timestamp = nowIso();
5665
+ entry.status = status;
5666
+ entry.sessionId = sessionId || entry.sessionId;
5667
+ entry.updatedAt = timestamp;
5668
+ if (["delivered", "skipped", "unauthorized"].includes(status))
5669
+ entry.terminalAt = timestamp;
5670
+ if (error)
5671
+ entry.error = error;
5672
+ return entry;
5673
+ }
5674
+ function recordAgentCompleted(entry, sessionId, agent, responseText) {
5675
+ const timestamp = nowIso();
5676
+ entry.status = "agent_completed";
5677
+ entry.sessionId = sessionId || entry.sessionId;
5678
+ entry.responseText = responseText;
5679
+ entry.agentExitCode = agent.exitCode;
5680
+ entry.agentTimedOut = agent.timedOut;
5681
+ entry.updatedAt = timestamp;
5682
+ delete entry.error;
5683
+ return entry;
5684
+ }
5685
+ async function dispatchMessageWithSessions(config, state, message, options = {}) {
5686
+ const conversationId = messageConversationId(config, message);
5687
+ const { entry, shouldProcess } = beginLedger(state, message, conversationId);
5688
+ if (!shouldProcess)
5689
+ return { message, ledger: entry };
5690
+ await options.persistState?.(state);
5691
+ try {
5692
+ const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
5693
+ if (binding) {
5694
+ if (!bindingAuthorized(binding, message)) {
5695
+ const session3 = {
5696
+ kind: "session",
5697
+ binding,
5698
+ conversationId,
5699
+ status: "unauthorized",
5700
+ message: "Message does not match binding authorization"
5701
+ };
5702
+ completeLedger(entry, "unauthorized");
5703
+ return { message, session: session3, ledger: entry };
5704
+ }
5705
+ if (entry.status === "agent_completed") {
5706
+ const session3 = await deliverStoredResponse(config, state, binding, message, entry, options);
5707
+ return { message, session: session3, ledger: entry };
5708
+ }
5709
+ const session2 = await routeSessionMessage(config, state, message, {
5710
+ ...options,
5711
+ beforeDeliver: async (agent, responseText) => {
5712
+ recordAgentCompleted(entry, binding.activeSessionId, agent, responseText);
5713
+ await options.persistState?.(state);
5714
+ await options.beforeDeliver?.(agent, responseText);
5715
+ }
5716
+ });
5717
+ if (session2.status === "failed") {
5718
+ completeLedger(entry, "failed", session2.session?.id, session2.message);
5719
+ throw new Error(session2.message || "Agent session failed");
5720
+ }
5721
+ const terminal = session2.status === "unauthorized" ? "unauthorized" : session2.status === "delivered" || session2.status === "no_output" ? "delivered" : "skipped";
5722
+ completeLedger(entry, terminal, session2.session?.id);
5723
+ entry.deliveredResponse = session2.deliveredResponse;
5724
+ return { message, session: session2, ledger: entry };
5725
+ }
5726
+ if (options.fallbackToRoutes) {
5727
+ const routes = await routeMessage(config, message, options);
5728
+ if (routes.length) {
5729
+ completeLedger(entry, "delivered");
5730
+ return { message, routes, ledger: entry };
5731
+ }
5732
+ }
5733
+ const session = await routeSessionMessage(config, state, message, options);
5734
+ const status = session.status === "unauthorized" ? "unauthorized" : "skipped";
5735
+ completeLedger(entry, status, session.session?.id);
5736
+ return { message, session, ledger: entry };
5737
+ } catch (err) {
5738
+ const messageText = err instanceof Error ? err.message : String(err);
5739
+ if (entry.status === "agent_completed") {
5740
+ entry.error = messageText;
5741
+ entry.updatedAt = nowIso();
5742
+ } else {
5743
+ completeLedger(entry, "failed", undefined, messageText);
5744
+ }
5745
+ throw err;
5746
+ }
5747
+ }
5111
5748
  export {
5112
5749
  upsertRoute,
5113
5750
  upsertProfile,
5114
5751
  upsertChannel,
5115
5752
  upsertAgent,
5753
+ updateBridgeSessionStatus,
5116
5754
  uninstallDaemon,
5117
5755
  telegramUpdateToMessage,
5118
5756
  telegramToken,
@@ -5124,38 +5762,65 @@ export {
5124
5762
  startProcessDaemon,
5125
5763
  startInstalledDaemon,
5126
5764
  sendTelegramMessage,
5765
+ sendIMessage,
5766
+ sendBridgeSessionMessage,
5767
+ sendAgentSessionMessage,
5127
5768
  saveState,
5128
5769
  saveConfig,
5129
5770
  runAgent,
5771
+ routeSessionMessage,
5130
5772
  routeMessage,
5773
+ resumeAgentSessionRef,
5131
5774
  restartProcessDaemon,
5132
5775
  restartInstalledDaemon,
5133
5776
  resolveSupervisor,
5134
5777
  resolveAgent,
5135
5778
  requiredTelegramEnvVars,
5136
5779
  renderSystemdUnit,
5780
+ renderSendIMessageScript,
5137
5781
  renderLaunchdPlist,
5138
5782
  redactConfig,
5139
5783
  parseConfig,
5784
+ normalizeConversationId,
5785
+ messageConversationId,
5140
5786
  matchingRoutes,
5141
5787
  loadState,
5142
5788
  loadConfig,
5789
+ listBridgeSessions,
5790
+ ledgerId,
5143
5791
  installDaemon,
5792
+ imessageRowToMessage,
5793
+ imessageHandleAllowed,
5144
5794
  homeDir,
5145
5795
  getTelegramUpdates,
5796
+ getIMessageMessages,
5797
+ getIMessageDbPath,
5798
+ getBridgeSession,
5799
+ findBridgeBinding,
5146
5800
  ensureDaemonDir,
5147
5801
  ensureConfig,
5148
5802
  emptyState,
5149
5803
  emptyConfig,
5150
5804
  doctor,
5805
+ dispatchMessageWithSessions,
5806
+ diagnoseIMessage,
5807
+ detachBridgeBinding,
5151
5808
  defaultStatePath,
5809
+ defaultMessagesDbPath,
5152
5810
  defaultDaemonDir,
5153
5811
  defaultConfigPath,
5154
5812
  daemonStatus,
5155
5813
  daemonPaths,
5156
5814
  daemonLogs,
5815
+ createBridgeSession,
5816
+ createAgentSessionRef,
5817
+ closeAgentSession,
5818
+ cancelAgentSession,
5157
5819
  buildAgentCommand,
5158
5820
  bridgeHome,
5821
+ bindingId,
5822
+ attachBridgeSession,
5823
+ STATE_SCHEMA_VERSION,
5159
5824
  CONFIG_VERSION,
5160
5825
  CHANNEL_KINDS,
5161
5826
  AGENT_KINDS