@inceptionstack/roundhouse 0.1.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/src/cli/cli.ts ADDED
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * roundhouse CLI entry point
5
+ *
6
+ * Commands:
7
+ * roundhouse start — start the gateway (foreground)
8
+ * roundhouse install — install as a systemd daemon
9
+ * roundhouse uninstall — remove the systemd daemon
10
+ * roundhouse update — update to latest version from npm + restart daemon
11
+ * roundhouse status — show daemon status
12
+ * roundhouse logs — tail daemon logs
13
+ * roundhouse stop — stop the daemon
14
+ * roundhouse restart — restart the daemon
15
+ * roundhouse config — show config path and current config
16
+ */
17
+
18
+ import { resolve, dirname } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
21
+ import { execSync, spawn } from "node:child_process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+
27
+ const SERVICE_NAME = "roundhouse";
28
+ const CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
29
+ const CONFIG_PATH = resolve(CONFIG_DIR, "gateway.config.json");
30
+ const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
31
+
32
+ const DEFAULT_CONFIG = {
33
+ agent: {
34
+ type: "pi",
35
+ cwd: homedir(),
36
+ },
37
+ chat: {
38
+ botUsername: "roundhouse_bot",
39
+ allowedUsers: [] as string[],
40
+ adapters: {
41
+ telegram: { mode: "polling" },
42
+ },
43
+ },
44
+ };
45
+
46
+ function run(cmd: string, opts?: { silent?: boolean }): string {
47
+ try {
48
+ return execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" }).trim();
49
+ } catch (e: any) {
50
+ if (opts?.silent) return "";
51
+ throw e;
52
+ }
53
+ }
54
+
55
+ function runSudo(cmd: string): void {
56
+ execSync(`sudo ${cmd}`, { stdio: "inherit" });
57
+ }
58
+
59
+ async function fileExists(path: string): Promise<boolean> {
60
+ try {
61
+ await access(path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // ── Commands ────────────────────────────────────────
69
+
70
+ async function cmdStart() {
71
+ // Import and run the gateway in-process (foreground)
72
+ process.env.ROUNDHOUSE_CONFIG = CONFIG_PATH;
73
+ const indexPath = resolve(__dirname, "..", "src", "index.ts");
74
+
75
+ // If running from installed npm package, use compiled JS
76
+ const jsPath = resolve(__dirname, "..", "dist", "index.js");
77
+ if (await fileExists(jsPath)) {
78
+ await import(jsPath);
79
+ } else {
80
+ // Dev mode: use tsx
81
+ const { execSync } = await import("node:child_process");
82
+ execSync(`node ${resolve(__dirname, "..", "node_modules", "tsx", "dist", "cli.mjs")} ${indexPath}`, {
83
+ stdio: "inherit",
84
+ env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH },
85
+ });
86
+ }
87
+ }
88
+
89
+ async function cmdInstall() {
90
+ console.log("[roundhouse] Installing as systemd daemon...\n");
91
+
92
+ // 1. Create config if missing
93
+ await mkdir(CONFIG_DIR, { recursive: true });
94
+ if (await fileExists(CONFIG_PATH)) {
95
+ console.log(` Config exists: ${CONFIG_PATH}`);
96
+ } else {
97
+ await writeFile(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
98
+ console.log(` Created config: ${CONFIG_PATH}`);
99
+ console.log(` ⚠️ Edit this file to set allowedUsers and other settings.`);
100
+ }
101
+
102
+ // 2. Find roundhouse binary
103
+ const binPath = run("which roundhouse", { silent: true }) || resolve(__dirname, "cli.ts");
104
+ const nodePath = run("which node", { silent: true }) || process.execPath;
105
+
106
+ // 3. Gather env vars for the service (only known safe ones)
107
+ const envLines: string[] = [];
108
+ for (const key of ["TELEGRAM_BOT_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "BOT_USERNAME", "ALLOWED_USERS"]) {
109
+ if (process.env[key]) {
110
+ envLines.push(`Environment=${key}=${process.env[key]}`);
111
+ }
112
+ }
113
+
114
+ // 4. Create systemd unit
115
+ const unit = `[Unit]
116
+ Description=Roundhouse Chat Gateway
117
+ After=network.target
118
+
119
+ [Service]
120
+ Type=simple
121
+ User=${process.env.USER || "root"}
122
+ WorkingDirectory=${homedir()}
123
+ ExecStart=${nodePath} ${binPath} start
124
+ Restart=on-failure
125
+ RestartSec=5
126
+ ${envLines.join("\n")}
127
+ Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
128
+ Environment=NODE_ENV=production
129
+
130
+ [Install]
131
+ WantedBy=multi-user.target
132
+ `;
133
+
134
+ const tmpUnit = `/tmp/${SERVICE_NAME}.service`;
135
+ await writeFile(tmpUnit, unit);
136
+ runSudo(`cp ${tmpUnit} ${SERVICE_PATH}`);
137
+ runSudo("systemctl daemon-reload");
138
+ runSudo(`systemctl enable ${SERVICE_NAME}`);
139
+ runSudo(`systemctl start ${SERVICE_NAME}`);
140
+
141
+ console.log(`\n ✅ Daemon installed and started.`);
142
+ console.log(`\n Config: ${CONFIG_PATH}`);
143
+ console.log(` Service: ${SERVICE_PATH}`);
144
+ console.log(` Logs: roundhouse logs`);
145
+ console.log(` Status: roundhouse status`);
146
+
147
+ if (envLines.length === 0) {
148
+ console.log(`\n ⚠️ No env vars detected. You may need to add TELEGRAM_BOT_TOKEN etc.`);
149
+ console.log(` Edit ${SERVICE_PATH} or use an EnvironmentFile=`);
150
+ }
151
+ }
152
+
153
+ async function cmdUninstall() {
154
+ console.log("[roundhouse] Removing systemd daemon...");
155
+ try {
156
+ runSudo(`systemctl stop ${SERVICE_NAME}`);
157
+ } catch {}
158
+ try {
159
+ runSudo(`systemctl disable ${SERVICE_NAME}`);
160
+ } catch {}
161
+ try {
162
+ runSudo(`rm -f ${SERVICE_PATH}`);
163
+ } catch {}
164
+ runSudo("systemctl daemon-reload");
165
+ console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
166
+ }
167
+
168
+ async function cmdUpdate() {
169
+ console.log("[roundhouse] Updating to latest version...\n");
170
+ run("npm update -g roundhouse");
171
+ console.log("\n[roundhouse] Restarting daemon...");
172
+ try {
173
+ runSudo(`systemctl restart ${SERVICE_NAME}`);
174
+ console.log(" ✅ Updated and restarted.");
175
+ } catch {
176
+ console.log(" ⚠️ Daemon not running. Start with: roundhouse install");
177
+ }
178
+ }
179
+
180
+ function cmdStatus() {
181
+ try {
182
+ run(`systemctl status ${SERVICE_NAME}`);
183
+ } catch {
184
+ console.log("Daemon is not installed. Run: roundhouse install");
185
+ }
186
+ }
187
+
188
+ function cmdLogs() {
189
+ const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
190
+ stdio: "inherit",
191
+ });
192
+ child.on("error", () => {
193
+ console.log("Could not read logs. Is the daemon installed?");
194
+ });
195
+ }
196
+
197
+ function cmdStop() {
198
+ runSudo(`systemctl stop ${SERVICE_NAME}`);
199
+ console.log(" ✅ Daemon stopped.");
200
+ }
201
+
202
+ function cmdRestart() {
203
+ runSudo(`systemctl restart ${SERVICE_NAME}`);
204
+ console.log(" ✅ Daemon restarted.");
205
+ }
206
+
207
+ async function cmdConfig() {
208
+ console.log(`Config path: ${CONFIG_PATH}\n`);
209
+ if (await fileExists(CONFIG_PATH)) {
210
+ const content = await readFile(CONFIG_PATH, "utf8");
211
+ console.log(content);
212
+ } else {
213
+ console.log("(no config file — defaults will be used)");
214
+ }
215
+ }
216
+
217
+ async function cmdTui() {
218
+ // 1. Load config to determine agent type
219
+ let config: any = DEFAULT_CONFIG;
220
+ if (await fileExists(CONFIG_PATH)) {
221
+ try {
222
+ config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
223
+ } catch {}
224
+ }
225
+
226
+ const agentType = config.agent?.type ?? "pi";
227
+
228
+ if (agentType !== "pi") {
229
+ console.error(`roundhouse tui: agent type "${agentType}" does not support TUI yet.`);
230
+ console.error("Only \"pi\" is supported currently.");
231
+ process.exit(1);
232
+ }
233
+
234
+ // 2. Find gateway sessions
235
+ const sessionsBase = config.agent?.sessionDir ?? resolve(homedir(), ".pi", "agent", "gateway-sessions");
236
+ let threadDirs: string[] = [];
237
+ try {
238
+ const { readdirSync, statSync } = await import("node:fs");
239
+ threadDirs = readdirSync(sessionsBase)
240
+ .filter((d: string) => {
241
+ try {
242
+ return statSync(resolve(sessionsBase, d)).isDirectory();
243
+ } catch {
244
+ return false;
245
+ }
246
+ })
247
+ .sort();
248
+ } catch {
249
+ console.error(`No gateway sessions found at ${sessionsBase}`);
250
+ console.error("Send a message via Telegram/Slack first to create a session.");
251
+ process.exit(1);
252
+ }
253
+
254
+ if (threadDirs.length === 0) {
255
+ console.error("No gateway sessions found. Send a message via Telegram/Slack first.");
256
+ process.exit(1);
257
+ }
258
+
259
+ // 3. Find session files in each thread dir, pick the most recent
260
+ const { readdirSync, statSync } = await import("node:fs");
261
+ const threadArg = process.argv[3]; // optional: roundhouse tui <thread>
262
+
263
+ interface SessionCandidate {
264
+ threadDir: string;
265
+ sessionFile: string;
266
+ mtime: number;
267
+ }
268
+
269
+ const candidates: SessionCandidate[] = [];
270
+ for (const dir of threadDirs) {
271
+ if (threadArg && !dir.includes(threadArg)) continue;
272
+ const threadPath = resolve(sessionsBase, dir);
273
+ try {
274
+ const files = readdirSync(threadPath).filter((f: string) => f.endsWith(".jsonl"));
275
+ for (const f of files) {
276
+ const fullPath = resolve(threadPath, f);
277
+ const st = statSync(fullPath);
278
+ candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: st.mtimeMs });
279
+ }
280
+ } catch {}
281
+ }
282
+
283
+ if (candidates.length === 0) {
284
+ if (threadArg) {
285
+ console.error(`No sessions found matching "${threadArg}".`);
286
+ console.log("Available threads:");
287
+ for (const d of threadDirs) console.log(` ${d}`);
288
+ } else {
289
+ console.error("No session files found.");
290
+ }
291
+ process.exit(1);
292
+ }
293
+
294
+ // Sort by most recently modified
295
+ candidates.sort((a, b) => b.mtime - a.mtime);
296
+
297
+ // If multiple threads and no filter, let user pick
298
+ let selected: SessionCandidate;
299
+ const uniqueThreads = [...new Set(candidates.map((c) => c.threadDir))];
300
+
301
+ if (uniqueThreads.length === 1 || threadArg) {
302
+ selected = candidates[0];
303
+ } else {
304
+ console.log("Available sessions (most recent first):\n");
305
+ const shown: SessionCandidate[] = [];
306
+ const seen = new Set<string>();
307
+ for (const c of candidates) {
308
+ if (seen.has(c.threadDir)) continue;
309
+ seen.add(c.threadDir);
310
+ shown.push(c);
311
+ }
312
+ for (let i = 0; i < shown.length; i++) {
313
+ const age = Math.round((Date.now() - shown[i].mtime) / 60000);
314
+ const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
315
+ console.log(` [${i + 1}] ${shown[i].threadDir} (${ageStr})`);
316
+ }
317
+ console.log();
318
+
319
+ // Simple prompt
320
+ const readline = await import("node:readline");
321
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
322
+ const answer = await new Promise<string>((resolve) => {
323
+ rl.question("Pick a session [1]: ", (ans) => {
324
+ rl.close();
325
+ resolve(ans.trim() || "1");
326
+ });
327
+ });
328
+
329
+ const idx = parseInt(answer, 10) - 1;
330
+ if (isNaN(idx) || idx < 0 || idx >= shown.length) {
331
+ console.error("Invalid selection.");
332
+ process.exit(1);
333
+ }
334
+ // Find most recent session file for the selected thread
335
+ selected = candidates.find((c) => c.threadDir === shown[idx].threadDir)!;
336
+ }
337
+
338
+ console.log(`\nOpening: ${selected.sessionFile}\n`);
339
+
340
+ // 4. Launch pi --resume <session>
341
+ const piArgs = ["--resume", selected.sessionFile];
342
+ const child = spawn("pi", piArgs, { stdio: "inherit" });
343
+
344
+ child.on("error", (err) => {
345
+ if ((err as any).code === "ENOENT") {
346
+ console.error("'pi' not found in PATH. Install pi first.");
347
+ } else {
348
+ console.error("Failed to launch pi:", err.message);
349
+ }
350
+ process.exit(1);
351
+ });
352
+
353
+ child.on("exit", (code) => {
354
+ process.exit(code ?? 0);
355
+ });
356
+ }
357
+
358
+ function printHelp() {
359
+ console.log(`
360
+ roundhouse — Multi-platform chat gateway for AI agents
361
+
362
+ Usage:
363
+ roundhouse <command>
364
+
365
+ Commands:
366
+ start Start the gateway (foreground)
367
+ tui [thread] Open agent TUI on a gateway session
368
+ install Install as a systemd daemon (requires sudo)
369
+ uninstall Remove the systemd daemon
370
+ update Update from npm + restart daemon
371
+ status Show daemon status
372
+ logs Tail daemon logs
373
+ stop Stop the daemon
374
+ restart Restart the daemon
375
+ config Show config path and contents
376
+
377
+ Config:
378
+ ~/.config/roundhouse/gateway.config.json
379
+
380
+ Environment:
381
+ TELEGRAM_BOT_TOKEN Telegram bot token
382
+ ANTHROPIC_API_KEY API key for pi agent
383
+ ALLOWED_USERS Comma-separated usernames
384
+ `);
385
+ }
386
+
387
+ // ── Main ────────────────────────────────────────────
388
+
389
+ const command = process.argv[2];
390
+
391
+ switch (command) {
392
+ case "start":
393
+ cmdStart();
394
+ break;
395
+ case "install":
396
+ cmdInstall();
397
+ break;
398
+ case "uninstall":
399
+ cmdUninstall();
400
+ break;
401
+ case "update":
402
+ cmdUpdate();
403
+ break;
404
+ case "status":
405
+ cmdStatus();
406
+ break;
407
+ case "logs":
408
+ cmdLogs();
409
+ break;
410
+ case "stop":
411
+ cmdStop();
412
+ break;
413
+ case "restart":
414
+ cmdRestart();
415
+ break;
416
+ case "config":
417
+ cmdConfig();
418
+ break;
419
+ case "tui":
420
+ cmdTui();
421
+ break;
422
+ default:
423
+ printHelp();
424
+ break;
425
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * gateway.ts — Roundhouse gateway
3
+ *
4
+ * Owns the Vercel Chat SDK instance and wires all platform events
5
+ * through the agent router.
6
+ */
7
+
8
+ import { Chat } from "chat";
9
+ import { createMemoryState } from "@chat-adapter/state-memory";
10
+ import type { AgentRouter, GatewayConfig } from "./types";
11
+ import { splitMessage, isAllowed, startTypingLoop } from "./util";
12
+
13
+ // ── Chat SDK adapter factories ───────────────────────
14
+ // Lazy-imported so we don't crash if an adapter package isn't installed.
15
+
16
+ async function buildChatAdapters(
17
+ config: GatewayConfig["chat"]["adapters"]
18
+ ): Promise<Record<string, unknown>> {
19
+ const adapters: Record<string, unknown> = {};
20
+
21
+ if (config.telegram) {
22
+ const { createTelegramAdapter } = await import("@chat-adapter/telegram");
23
+ adapters.telegram = createTelegramAdapter({
24
+ mode: (config.telegram.mode as "auto" | "polling" | "webhook") ?? "auto",
25
+ });
26
+ }
27
+
28
+ // Future:
29
+ // if (config.slack) { ... }
30
+ // if (config.discord) { ... }
31
+
32
+ return adapters;
33
+ }
34
+
35
+ // ── Gateway ──────────────────────────────────────────
36
+
37
+ export class Gateway {
38
+ private chat!: Chat;
39
+ private router: AgentRouter;
40
+ private config: GatewayConfig;
41
+
42
+ constructor(router: AgentRouter, config: GatewayConfig) {
43
+ this.router = router;
44
+ this.config = config;
45
+ }
46
+
47
+ async start() {
48
+ const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
49
+
50
+ if (Object.keys(chatAdapters).length === 0) {
51
+ throw new Error("No chat adapters configured. Add at least one in config.chat.adapters.");
52
+ }
53
+
54
+ this.chat = new Chat({
55
+ userName: this.config.chat.botUsername,
56
+ adapters: chatAdapters as any,
57
+ state: createMemoryState(),
58
+ });
59
+
60
+ const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
61
+ u.toLowerCase()
62
+ );
63
+
64
+ // ── Unified handler ────────────────────────────
65
+ const handle = async (thread: any, message: any) => {
66
+ const userText = message.text ?? "";
67
+ const authorName = message.author?.userName ?? message.author?.userId ?? "?";
68
+
69
+ console.log(
70
+ `[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"`
71
+ );
72
+
73
+ if (!isAllowed(message, allowedUsers)) {
74
+ console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
75
+ return;
76
+ }
77
+
78
+ if (!userText.trim() || userText === "/start") return;
79
+
80
+ const agent = this.router.resolve(thread.id);
81
+ console.log(`[roundhouse] → ${agent.name} | thread=${thread.id}`);
82
+
83
+ const stopTyping = startTypingLoop(thread);
84
+
85
+ try {
86
+ const reply = await agent.prompt(thread.id, userText);
87
+ if (reply.text) {
88
+ for (const chunk of splitMessage(reply.text, 4000)) {
89
+ await thread.post(chunk);
90
+ }
91
+ } else {
92
+ await thread.post("(empty response)");
93
+ }
94
+ } catch (err) {
95
+ console.error(`[roundhouse] agent error:`, err);
96
+ try {
97
+ await thread.post("⚠️ Something went wrong.");
98
+ } catch {}
99
+ } finally {
100
+ stopTyping();
101
+ }
102
+ };
103
+
104
+ // ── Wire Chat SDK events ───────────────────────
105
+ this.chat.onDirectMessage(async (thread, message) => {
106
+ await thread.subscribe();
107
+ await handle(thread, message);
108
+ });
109
+
110
+ this.chat.onNewMention(async (thread, message) => {
111
+ await thread.subscribe();
112
+ await handle(thread, message);
113
+ });
114
+
115
+ this.chat.onSubscribedMessage(async (thread, message) => {
116
+ await handle(thread, message);
117
+ });
118
+
119
+ await this.chat.initialize();
120
+
121
+ const platforms = Object.keys(this.config.chat.adapters).join(", ");
122
+ console.log(`[roundhouse] gateway ready (platforms: ${platforms})`);
123
+ }
124
+
125
+ async stop() {
126
+ await this.router.dispose();
127
+ console.log("[roundhouse] stopped");
128
+ }
129
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * index.ts — Roundhouse entry point
3
+ *
4
+ * Loads config, creates the agent + router + gateway, starts up.
5
+ *
6
+ * Usage:
7
+ * TELEGRAM_BOT_TOKEN=... npm start
8
+ * TELEGRAM_BOT_TOKEN=... npm start -- --config ./my-config.json
9
+ */
10
+
11
+ import { readFile } from "node:fs/promises";
12
+ import { resolve } from "node:path";
13
+
14
+ import type { GatewayConfig } from "./types";
15
+ import { getAgentFactory } from "./agents/registry";
16
+ import { SingleAgentRouter } from "./router";
17
+ import { Gateway } from "./gateway";
18
+
19
+ // ── Crash protection ─────────────────────────────────
20
+ process.on("uncaughtException", (err) => {
21
+ console.error("[roundhouse] uncaughtException:", err);
22
+ });
23
+ process.on("unhandledRejection", (reason) => {
24
+ console.error("[roundhouse] unhandledRejection:", reason);
25
+ });
26
+
27
+ // ── Default config ───────────────────────────────────
28
+ const DEFAULT_CONFIG: GatewayConfig = {
29
+ agent: {
30
+ type: "pi",
31
+ cwd: process.cwd(),
32
+ },
33
+ chat: {
34
+ botUsername: process.env.BOT_USERNAME ?? "roundhouse_bot",
35
+ allowedUsers: process.env.ALLOWED_USERS
36
+ ? process.env.ALLOWED_USERS.split(",").map((u) => u.trim())
37
+ : [],
38
+ adapters: {
39
+ telegram: { mode: "polling" },
40
+ },
41
+ },
42
+ };
43
+
44
+ async function loadConfig(): Promise<GatewayConfig> {
45
+ // Check for ROUNDHOUSE_CONFIG env var (set by CLI/daemon)
46
+ const envConfig = process.env.ROUNDHOUSE_CONFIG;
47
+ if (envConfig) {
48
+ try {
49
+ const raw = await readFile(resolve(envConfig), "utf8");
50
+ console.log(`[roundhouse] loaded config from ${envConfig}`);
51
+ return JSON.parse(raw) as GatewayConfig;
52
+ } catch {
53
+ // Fall through to other methods
54
+ }
55
+ }
56
+
57
+ // Check for --config flag
58
+ const configIdx = process.argv.indexOf("--config");
59
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
60
+ const configPath = resolve(process.argv[configIdx + 1]);
61
+ console.log(`[roundhouse] loading config from ${configPath}`);
62
+ const raw = await readFile(configPath, "utf8");
63
+ return JSON.parse(raw) as GatewayConfig;
64
+ }
65
+
66
+ // Try gateway.config.json in cwd
67
+ try {
68
+ const raw = await readFile(
69
+ resolve(process.cwd(), "gateway.config.json"),
70
+ "utf8"
71
+ );
72
+ console.log("[roundhouse] loaded gateway.config.json");
73
+ return JSON.parse(raw) as GatewayConfig;
74
+ } catch {
75
+ // Fall back to defaults + env vars
76
+ console.log("[roundhouse] using default config + env vars");
77
+ return DEFAULT_CONFIG;
78
+ }
79
+ }
80
+
81
+ async function main() {
82
+ const config = await loadConfig();
83
+
84
+ // ── Validate ───────────────────────────────────────
85
+ const hasTelegram = config.chat.adapters.telegram;
86
+ if (hasTelegram && !process.env.TELEGRAM_BOT_TOKEN) {
87
+ console.error("TELEGRAM_BOT_TOKEN is required for Telegram adapter.");
88
+ process.exit(1);
89
+ }
90
+
91
+ // ── Create agent ───────────────────────────────────
92
+ const { type, ...agentConfig } = config.agent;
93
+ const factory = getAgentFactory(type);
94
+ const agent = factory(agentConfig);
95
+ console.log(`[roundhouse] agent: ${agent.name}`);
96
+
97
+ // ── Create router (single agent for now) ───────────
98
+ const router = new SingleAgentRouter(agent);
99
+
100
+ // ── Create gateway ─────────────────────────────────
101
+ const gateway = new Gateway(router, config);
102
+
103
+ // ── Graceful shutdown ──────────────────────────────
104
+ const shutdown = async (signal: string) => {
105
+ console.log(`\n[roundhouse] received ${signal}, shutting down…`);
106
+ await gateway.stop();
107
+ process.exit(0);
108
+ };
109
+ process.on("SIGINT", () => shutdown("SIGINT"));
110
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
111
+
112
+ // ── Start ──────────────────────────────────────────
113
+ console.log("[roundhouse] starting…");
114
+ await gateway.start();
115
+ console.log("[roundhouse] running. Press Ctrl+C to stop.");
116
+ }
117
+
118
+ main().catch((err) => {
119
+ console.error("[roundhouse] fatal:", err);
120
+ process.exit(1);
121
+ });
package/src/router.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * router.ts — Agent routing
3
+ *
4
+ * Today: SingleAgentRouter — all threads go to one agent.
5
+ * Future: swap in MultiAgentRouter, UserChoiceRouter, etc.
6
+ */
7
+
8
+ import type { AgentAdapter, AgentRouter } from "./types";
9
+
10
+ /**
11
+ * Routes every thread to the single configured agent.
12
+ * This is the default and only router for now.
13
+ */
14
+ export class SingleAgentRouter implements AgentRouter {
15
+ constructor(private agent: AgentAdapter) {}
16
+
17
+ resolve(_threadId: string): AgentAdapter {
18
+ return this.agent;
19
+ }
20
+
21
+ async dispose(): Promise<void> {
22
+ await this.agent.dispose();
23
+ }
24
+ }