@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/README.md +79 -3
- package/dist/cli/index.js +786 -26
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +630 -11
- package/dist/lib/agents.d.ts +14 -1
- package/dist/lib/agents.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/daemon.d.ts.map +1 -1
- package/dist/lib/doctor.d.ts.map +1 -1
- package/dist/lib/imessage.d.ts +36 -0
- package/dist/lib/imessage.d.ts.map +1 -0
- package/dist/lib/router.d.ts.map +1 -1
- package/dist/lib/sessions.d.ts +48 -0
- package/dist/lib/sessions.d.ts.map +1 -0
- package/dist/lib/state.d.ts +8 -1
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/telegram.d.ts +1 -0
- package/dist/lib/telegram.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +584 -7
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/architecture.md +69 -15
- package/docs/session-bridge-plan.md +204 -0
- package/package.json +1 -1
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());
|
|
@@ -4226,14 +4257,55 @@ async function loadConfig(configPath = defaultConfigPath()) {
|
|
|
4226
4257
|
}
|
|
4227
4258
|
}
|
|
4228
4259
|
// src/lib/daemon.ts
|
|
4229
|
-
import { chmod as
|
|
4260
|
+
import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
|
|
4230
4261
|
import { dirname as dirname2, join as join3, resolve } from "path";
|
|
4231
4262
|
|
|
4232
4263
|
// src/lib/state.ts
|
|
4264
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
4233
4265
|
import { dirname, join as join2 } from "path";
|
|
4266
|
+
var STATE_SCHEMA_VERSION = 2;
|
|
4234
4267
|
function defaultStatePath() {
|
|
4235
4268
|
return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
|
|
4236
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
|
+
}
|
|
4237
4309
|
|
|
4238
4310
|
// src/lib/telegram.ts
|
|
4239
4311
|
var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
@@ -4335,7 +4407,7 @@ async function fileExists(path) {
|
|
|
4335
4407
|
}
|
|
4336
4408
|
async function readMetadata(paths) {
|
|
4337
4409
|
try {
|
|
4338
|
-
return JSON.parse(await
|
|
4410
|
+
return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
|
|
4339
4411
|
} catch (err) {
|
|
4340
4412
|
if (isNotFound(err))
|
|
4341
4413
|
return;
|
|
@@ -4467,6 +4539,107 @@ async function daemonStatus(options = {}) {
|
|
|
4467
4539
|
}
|
|
4468
4540
|
// src/lib/doctor.ts
|
|
4469
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
|
|
4470
4643
|
function isNotFound2(err) {
|
|
4471
4644
|
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
4472
4645
|
}
|
|
@@ -4494,7 +4667,7 @@ async function privateDirCheck(name, path) {
|
|
|
4494
4667
|
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
4495
4668
|
}
|
|
4496
4669
|
}
|
|
4497
|
-
async function
|
|
4670
|
+
async function commandExists2(command) {
|
|
4498
4671
|
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
4499
4672
|
stdout: "ignore",
|
|
4500
4673
|
stderr: "ignore"
|
|
@@ -4532,7 +4705,7 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
|
|
|
4532
4705
|
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
4533
4706
|
checks.push({
|
|
4534
4707
|
name: `command:${command}`,
|
|
4535
|
-
ok: command === "bridge" ? true : await
|
|
4708
|
+
ok: command === "bridge" ? true : await commandExists2(command),
|
|
4536
4709
|
detail: command === "bridge" ? "current package" : undefined
|
|
4537
4710
|
});
|
|
4538
4711
|
}
|
|
@@ -4557,6 +4730,10 @@ async function doctor(configPath = defaultConfigPath(), statePath = defaultState
|
|
|
4557
4730
|
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
4558
4731
|
});
|
|
4559
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
|
+
}
|
|
4560
4737
|
return { ok: checks.every((check) => check.ok), configPath, checks };
|
|
4561
4738
|
}
|
|
4562
4739
|
// src/lib/router.ts
|
|
@@ -4567,6 +4744,9 @@ function matchingRoutes(config, message) {
|
|
|
4567
4744
|
if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
|
|
4568
4745
|
return [];
|
|
4569
4746
|
}
|
|
4747
|
+
if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
|
|
4748
|
+
return [];
|
|
4749
|
+
}
|
|
4570
4750
|
return config.routes.filter((route) => {
|
|
4571
4751
|
if (route.enabled === false)
|
|
4572
4752
|
return false;
|
|
@@ -4606,19 +4786,416 @@ async function routeMessage(config, message, options = {}) {
|
|
|
4606
4786
|
if (options.writeConsole !== false)
|
|
4607
4787
|
(options.writeConsole || console.log)(responseText);
|
|
4608
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
|
+
}
|
|
4609
4796
|
}
|
|
4610
4797
|
results.push({ route, agent, deliveredResponse });
|
|
4611
4798
|
}
|
|
4612
4799
|
return results;
|
|
4613
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
|
+
}
|
|
4614
5120
|
// src/mcp/index.ts
|
|
4615
5121
|
function text(value) {
|
|
4616
5122
|
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
|
|
4617
5123
|
}
|
|
4618
5124
|
function buildServer() {
|
|
4619
|
-
const server = new McpServer({ name: "bridge", version: "0.
|
|
5125
|
+
const server = new McpServer({ name: "bridge", version: "0.2.0" });
|
|
4620
5126
|
server.tool("bridge_status", {}, async () => text(await doctor()));
|
|
4621
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
|
+
});
|
|
4622
5199
|
server.tool("bridge_route_message", {
|
|
4623
5200
|
channelId: exports_external.string(),
|
|
4624
5201
|
text: exports_external.string(),
|