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