@inceptionstack/roundhouse 0.4.4 → 0.5.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 CHANGED
@@ -9,6 +9,8 @@ Multiple chat inputs (Telegram, Slack, Discord via [Vercel Chat SDK](https://cha
9
9
 
10
10
  ```bash
11
11
  npm install -g @inceptionstack/roundhouse
12
+ roundhouse setup --telegram
13
+ roundhouse start # macOS (foreground) — Linux auto-starts via systemd
12
14
  ```
13
15
 
14
16
  ## Architecture
@@ -178,7 +180,7 @@ Without a config file, defaults are used with env vars (`TELEGRAM_BOT_TOKEN`, `B
178
180
 
179
181
  | Field | Description |
180
182
  |-------|-------------|
181
- | `agent.type` | Agent backend: `"pi"` (more coming) |
183
+ | `agent.type` | Agent backend: `"pi"`, `"kiro"` |
182
184
  | `agent.cwd` | Working directory for the agent |
183
185
  | `agent.sessionDir` | Override session storage path |
184
186
  | `chat.botUsername` | Bot display name for Chat SDK |
@@ -492,7 +494,7 @@ No other changes needed — the gateway's unified handler covers all platforms.
492
494
  | `src/agents/base-adapter.ts` | Abstract base class — adapter interface contract |
493
495
  | `src/agents/registry.ts` | Agent type → factory registry |
494
496
  | `src/config.ts` | Shared config loading, defaults, env overrides |
495
- | `test/` | Unit tests (vitest, 75 passing) |
497
+ | `test/` | Unit + integration tests (vitest, 311 passing) |
496
498
 
497
499
  ## CI/CD
498
500
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -46,6 +46,13 @@ export default function (pi: ExtensionAPI) {
46
46
  }),
47
47
  signal: controller.signal,
48
48
  });
49
+ } catch (err: any) {
50
+ clearTimeout(timeout);
51
+ const msg = err.name === "AbortError" ? "Request timed out (30s)" : `Network error: ${err.message}`;
52
+ return {
53
+ content: [{ type: "text", text: msg }],
54
+ details: { query: params.query, error: err.name },
55
+ };
49
56
  } finally {
50
57
  clearTimeout(timeout);
51
58
  }
@@ -13,6 +13,7 @@
13
13
  import { homedir } from "node:os";
14
14
  import { resolve } from "node:path";
15
15
  import type { AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, AdapterInfo } from "../../types.js";
16
+ import { ROUNDHOUSE_VERSION } from "../../config.js";
16
17
  import { BaseAdapter } from "../base-adapter.js";
17
18
  import { spawnKiroCli, shutdownProcess, getKiroCliVersion, type AcpProcess, type InitializeResult, type SessionNewResult } from "./acp/index.js";
18
19
  import { SessionStore, type SessionEntry } from "./session.js";
@@ -188,7 +189,7 @@ class KiroAdapter extends BaseAdapter {
188
189
 
189
190
  await this.mainProcess.client.call<InitializeResult>("initialize", {
190
191
  protocolVersion: "1.0",
191
- clientInfo: { name: "roundhouse", version: "0.4.3" },
192
+ clientInfo: { name: "roundhouse", version: ROUNDHOUSE_VERSION },
192
193
  });
193
194
 
194
195
  if (!this.reaperInterval) {
package/src/cli/cli.ts CHANGED
@@ -5,17 +5,14 @@
5
5
  */
6
6
 
7
7
  import { resolve, dirname } from "node:path";
8
- import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { readFile } from "node:fs/promises";
9
9
  import { readdirSync, statSync } from "node:fs";
10
10
  import { execSync, execFileSync, spawn } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { performUpdate } from "../commands/update";
13
13
 
14
14
  import {
15
- CONFIG_DIR,
16
15
  CONFIG_PATH,
17
- ENV_FILE_PATH,
18
- DEFAULT_CONFIG,
19
16
  SESSIONS_DIR,
20
17
  SERVICE_NAME,
21
18
  fileExists,
@@ -24,7 +21,7 @@ import {
24
21
  } from "../config";
25
22
  import { getAgentSdkPackage } from "../agents/registry";
26
23
  import { threadIdToDir } from "../util";
27
- import { parseEnvFile, serializeEnvFile, envQuote, unquoteEnvValue } from "./env-file";
24
+ import { parseEnvFile, unquoteEnvValue } from "./env-file";
28
25
  import {
29
26
  SERVICE_PATH,
30
27
  systemctl,
@@ -32,9 +29,6 @@ import {
32
29
  isServiceInstalled,
33
30
  isServiceActive,
34
31
  systemctlShow,
35
- resolveExecStart,
36
- generateUnit,
37
- writeServiceUnit,
38
32
  } from "./systemd";
39
33
 
40
34
  const __filename = fileURLToPath(import.meta.url);
@@ -72,7 +66,12 @@ async function cmdStart() {
72
66
  return;
73
67
  }
74
68
 
75
- // No systemd service — fall back to foreground
69
+ // No systemd service — fall back to foreground. Check config before launching.
70
+ if (!(await fileExists(CONFIG_PATH))) {
71
+ console.error("No config found. Run 'roundhouse setup --telegram' first.");
72
+ process.exit(1);
73
+ }
74
+
76
75
  console.log("No systemd service found. Running in foreground (use Ctrl+C to stop)...");
77
76
  if (process.platform !== "darwin") {
78
77
  console.log(" Tip: run 'roundhouse install' to set up the systemd daemon.\n");
@@ -83,6 +82,12 @@ async function cmdStart() {
83
82
  }
84
83
 
85
84
  async function cmdRun() {
85
+ // Guard: check config exists before launching gateway
86
+ if (!(await fileExists(CONFIG_PATH))) {
87
+ console.error("No config found. Run 'roundhouse setup --telegram' first.");
88
+ process.exit(1);
89
+ }
90
+
86
91
  process.env.ROUNDHOUSE_CONFIG = CONFIG_PATH;
87
92
 
88
93
  // Load .env file so secrets (TELEGRAM_BOT_TOKEN, etc.) are available
@@ -97,7 +102,11 @@ async function cmdRun() {
97
102
  const tsxPath = resolve(__dirname, "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
98
103
  execFileSync(process.execPath, [tsxPath, indexPath], {
99
104
  stdio: "inherit",
100
- env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH },
105
+ env: {
106
+ ...process.env,
107
+ ROUNDHOUSE_CONFIG: CONFIG_PATH,
108
+ NODE_NO_WARNINGS: "1", // Suppress npm deprecation spam
109
+ },
101
110
  });
102
111
  }
103
112
  }
@@ -122,70 +131,23 @@ async function loadEnvFile(): Promise<void> {
122
131
  }
123
132
 
124
133
  async function cmdInstall() {
134
+ console.log("[roundhouse] 'install' is deprecated — use 'roundhouse setup --telegram' instead.\n");
135
+
125
136
  if (process.platform === "darwin") {
126
- console.log("[roundhouse] macOS detected — systemd is not available.\n");
127
- console.log(" On macOS, use 'roundhouse start' to run in foreground,");
128
- console.log(" or set up a launchd plist manually.\n");
129
- console.log(" Tip: run 'roundhouse setup --telegram' to configure first.");
137
+ console.log(" On macOS:");
138
+ console.log(" 1. roundhouse setup --telegram");
139
+ console.log(" 2. roundhouse start\n");
130
140
  process.exitCode = 1;
131
141
  return;
132
142
  }
133
143
 
134
- console.log("[roundhouse] Installing as systemd daemon...\n");
135
-
136
- await mkdir(CONFIG_DIR, { recursive: true });
137
- if (await fileExists(CONFIG_PATH)) {
138
- console.log(` Config exists: ${CONFIG_PATH}`);
139
- } else {
140
- await writeFile(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
141
- console.log(` Created config: ${CONFIG_PATH}`);
142
- console.log(` ⚠️ Edit this file to set allowedUsers and other settings.`);
143
- }
144
-
145
- // Write environment file for secrets — merge with existing to preserve manually-added keys
146
- const ENV_KEYS = ["TELEGRAM_BOT_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "BOT_USERNAME", "ALLOWED_USERS", "NOTIFY_CHAT_IDS", "AWS_PROFILE", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"];
147
- const resolvedEnvPath = await resolveEnvFilePath();
148
- const existing = await fileExists(resolvedEnvPath)
149
- ? parseEnvFile(await readFile(resolvedEnvPath, "utf8"))
150
- : new Map<string, string>();
151
-
152
- // Override with current env vars for known keys
153
- let envChanged = false;
154
- for (const key of ENV_KEYS) {
155
- if (process.env[key]) {
156
- existing.set(key, envQuote(process.env[key]));
157
- envChanged = true;
158
- }
159
- }
160
- if (envChanged || !(await fileExists(ENV_FILE_PATH))) {
161
- if (resolvedEnvPath !== ENV_FILE_PATH && await fileExists(resolvedEnvPath)) {
162
- console.log(` Copying env file from ${resolvedEnvPath} to ${ENV_FILE_PATH}`);
163
- }
164
- await writeFile(ENV_FILE_PATH, serializeEnvFile(existing), { mode: 0o600 });
165
- console.log(` Environment file: ${ENV_FILE_PATH}`);
166
- }
167
-
168
- // Generate and install systemd unit
169
- const { execStart, nodeBinDir } = resolveExecStart();
170
- const unit = generateUnit({ execStart, nodeBinDir });
171
- await writeServiceUnit(unit);
172
- systemctl("enable");
173
- systemctl("start", "Daemon installed and started.");
174
-
175
- console.log(`\n Config: ${CONFIG_PATH}`);
176
- console.log(` Env file: ${ENV_FILE_PATH}`);
177
- console.log(` Service: ${SERVICE_PATH}`);
178
- console.log(` Logs: roundhouse logs`);
179
- console.log(` Status: roundhouse status`);
180
-
181
- if (!envChanged) {
182
- console.log(`\n ⚠️ No env vars detected. Edit ${ENV_FILE_PATH} with your secrets:`);
183
- console.log(` TELEGRAM_BOT_TOKEN=...`);
184
- console.log(` Then add your API keys and run: roundhouse restart`);
185
- }
144
+ console.log(" Recommended:");
145
+ console.log(" roundhouse setup --telegram\n");
146
+ console.log(" This sets up config, installs packages, pairs Telegram,");
147
+ console.log(" and installs the systemd service — all in one command.\n");
186
148
  }
187
-
188
149
  async function cmdUninstall() {
150
+
189
151
  console.log("[roundhouse] Removing systemd daemon...");
190
152
  try { systemctl("stop"); } catch {}
191
153
  try { systemctl("disable"); } catch {}
@@ -0,0 +1,157 @@
1
+ /**
2
+ * detect.ts — Agent environment detection for setup wizard
3
+ *
4
+ * Detects which agent backends are available on the system
5
+ * so setup can skip unnecessary installs and offer smart defaults.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
10
+ import { resolve } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { whichSync } from "./systemd";
13
+
14
+ // ── Types ────────────────────────────────────────────
15
+
16
+ export interface DetectedAgent {
17
+ type: "pi" | "kiro" | "openclaw";
18
+ binary: string | null; // Path to binary (null if not found)
19
+ version: string | null; // Version string (null if couldn't determine)
20
+ configured: boolean; // Has config/settings present
21
+ details: Record<string, string>; // Extra info (provider, model, etc.)
22
+ }
23
+
24
+ export interface DetectedEnvironment {
25
+ agents: DetectedAgent[];
26
+ recommended: "pi" | "kiro" | "openclaw" | null;
27
+ }
28
+
29
+ // ── Detection ────────────────────────────────────────
30
+
31
+ function detectPi(): DetectedAgent | null {
32
+ const binary = whichSync("pi");
33
+ if (!binary) return null;
34
+
35
+ let version: string | null = null;
36
+ try {
37
+
38
+ version = execFileSync("pi", ["--version"], { encoding: "utf8", timeout: 5000 }).trim();
39
+ } catch {}
40
+
41
+ const settingsPath = resolve(homedir(), ".pi", "agent", "settings.json");
42
+ let configured = false;
43
+ const details: Record<string, string> = {};
44
+
45
+ if (existsSync(settingsPath)) {
46
+ configured = true;
47
+ try {
48
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
49
+ if (settings.defaultProvider) details.provider = settings.defaultProvider;
50
+ if (settings.defaultModel) details.model = settings.defaultModel;
51
+ } catch {}
52
+ }
53
+
54
+ return { type: "pi", binary, version, configured, details };
55
+ }
56
+
57
+ function detectKiro(): DetectedAgent | null {
58
+ const binary = whichSync("kiro-cli");
59
+ if (!binary) return null;
60
+
61
+ let version: string | null = null;
62
+ try {
63
+
64
+ version = execFileSync("kiro-cli", ["--version"], { encoding: "utf8", timeout: 5000 }).trim();
65
+ } catch {}
66
+
67
+ // Check for kiro config directory
68
+ const configDir = resolve(homedir(), ".kiro");
69
+ const configured = existsSync(configDir);
70
+ return { type: "kiro", binary, version, configured, details: {} };
71
+ }
72
+
73
+ function detectOpenClaw(): DetectedAgent | null {
74
+ const binary = whichSync("oc");
75
+ if (!binary) return null;
76
+
77
+ let version: string | null = null;
78
+ try {
79
+
80
+ version = execFileSync("oc", ["--version"], { encoding: "utf8", timeout: 5000 }).trim();
81
+ } catch {}
82
+
83
+ const configPath = resolve(homedir(), ".openclaw", "openclaw.json");
84
+ let configured = false;
85
+ const details: Record<string, string> = {};
86
+
87
+ if (existsSync(configPath)) {
88
+ configured = true;
89
+ // Check if gateway is configured
90
+ try {
91
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
92
+ if (config.gateway?.port) details.port = String(config.gateway.port);
93
+ } catch {}
94
+ }
95
+
96
+ return { type: "openclaw", binary, version, configured, details };
97
+ }
98
+
99
+ // ── Public API ───────────────────────────────────────
100
+
101
+ /**
102
+ * Detect which agent backends are available on the system.
103
+ * Returns all detected agents and a recommended default.
104
+ */
105
+ export function detectEnvironment(): DetectedEnvironment {
106
+ const agents: DetectedAgent[] = [];
107
+
108
+ const pi = detectPi();
109
+ if (pi) agents.push(pi);
110
+
111
+ const kiro = detectKiro();
112
+ if (kiro) agents.push(kiro);
113
+
114
+ const oc = detectOpenClaw();
115
+ if (oc) agents.push(oc);
116
+
117
+ // Recommendation: prefer configured agent, then Pi as default
118
+ let recommended: DetectedEnvironment["recommended"] = null;
119
+ const configured = agents.filter(a => a.configured);
120
+ if (configured.length === 1) {
121
+ recommended = configured[0].type;
122
+ } else if (configured.length > 1) {
123
+ // Multiple configured — prefer Pi (most common for roundhouse)
124
+ recommended = configured.find(a => a.type === "pi")?.type ?? configured[0].type;
125
+ } else if (agents.length === 1) {
126
+ recommended = agents[0].type;
127
+ }
128
+
129
+ return { agents, recommended };
130
+ }
131
+
132
+ /**
133
+ * Format detection results for display in setup output.
134
+ */
135
+ export function formatDetectionResults(env: DetectedEnvironment): string[] {
136
+ const lines: string[] = [];
137
+
138
+ if (env.agents.length === 0) {
139
+ lines.push("No agent backends detected (will install Pi)");
140
+ return lines;
141
+ }
142
+
143
+ for (const agent of env.agents) {
144
+ const ver = agent.version ? ` (${agent.version})` : "";
145
+ const status = agent.configured ? "configured" : "found";
146
+ let line = `${agent.type}${ver} — ${status}`;
147
+ if (agent.details.provider) line += ` [${agent.details.provider}]`;
148
+ if (agent.details.model) line += ` [${agent.details.model}]`;
149
+ lines.push(line);
150
+ }
151
+
152
+ if (env.recommended) {
153
+ lines.push(`→ Using: ${env.recommended}`);
154
+ }
155
+
156
+ return lines;
157
+ }
package/src/cli/setup.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  writePendingPairing,
59
59
  type PendingPairing,
60
60
  } from "../pairing";
61
+ import { detectEnvironment, formatDetectionResults } from "./detect";
61
62
 
62
63
  // ── Types ────────────────────────────────────────────
63
64
 
@@ -83,6 +84,8 @@ interface SetupOptions {
83
84
  qr: "auto" | "always" | "never";
84
85
  /** Agent type (default: pi) */
85
86
  agent: string;
87
+ /** Set by detection: skip agent package install if already configured */
88
+ _skipAgentInstall?: boolean;
86
89
  }
87
90
 
88
91
  type StepStatus = "ok" | "warn" | "skip" | "fail";
@@ -231,7 +234,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
231
234
  cwd: homedir(),
232
235
  notifyChatIds: [],
233
236
  systemd: platform() === "linux",
234
- voice: true,
237
+ voice: platform() === "linux", // Default off on macOS (whisper install is heavy)
235
238
  psst: false,
236
239
  nonInteractive: false,
237
240
  force: false,
@@ -443,7 +446,7 @@ async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
443
446
  }
444
447
 
445
448
  async function stepStopGateway(): Promise<void> {
446
- step("", "Checking for running gateway...");
449
+ step("", "Checking for running gateway...");
447
450
 
448
451
  if (platform() !== "linux") {
449
452
  ok("Not Linux — skipping service check");
@@ -464,7 +467,7 @@ async function stepStopGateway(): Promise<void> {
464
467
  }
465
468
 
466
469
  async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
467
- step("", "Installing packages...");
470
+ step("", "Installing packages...");
468
471
 
469
472
  // Roundhouse
470
473
  const rhInstalled = whichSync("roundhouse");
@@ -477,18 +480,22 @@ async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition):
477
480
  }
478
481
 
479
482
  // Agent packages (driven by agent definition)
480
- for (const pkg of agent.packages) {
481
- const label = pkg.name ?? pkg.packageName;
482
- const installed = pkg.binary ? whichSync(pkg.binary) : false;
483
- if (installed && !opts.force) {
484
- ok(`${label} (already installed)`);
485
- } else {
486
- log(` Installing ${label}...`);
487
- const args = pkg.install === "global"
488
- ? ["install", "-g", pkg.packageName]
489
- : ["install", pkg.packageName];
490
- execOrFail("npm", args, `${label} install`);
491
- ok(label);
483
+ if (opts._skipAgentInstall) {
484
+ ok("Agent already configured skipping package install");
485
+ } else {
486
+ for (const pkg of agent.packages) {
487
+ const label = pkg.name ?? pkg.packageName;
488
+ const installed = pkg.binary ? whichSync(pkg.binary) : false;
489
+ if (installed && !opts.force) {
490
+ ok(`${label} (already installed)`);
491
+ } else {
492
+ log(` Installing ${label}...`);
493
+ const args = pkg.install === "global"
494
+ ? ["install", "-g", pkg.packageName]
495
+ : ["install", pkg.packageName];
496
+ execOrFail("npm", args, `${label} install`);
497
+ ok(label);
498
+ }
492
499
  }
493
500
  }
494
501
 
@@ -596,12 +603,12 @@ async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition):
596
603
 
597
604
  async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
598
605
  if (!opts.psst) {
599
- step("", "Storing secrets...");
606
+ step("", "Storing secrets...");
600
607
  ok("Skipped (default — use --with-psst to enable)");
601
608
  return;
602
609
  }
603
610
 
604
- step("", "Storing secrets in psst...");
611
+ step("", "Storing secrets in psst...");
605
612
 
606
613
  const secrets: [string, string][] = [
607
614
  ["TELEGRAM_BOT_TOKEN", opts.botToken],
@@ -640,7 +647,7 @@ async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<v
640
647
  // ── Bundle install ──────────────────────────────────────────────────
641
648
 
642
649
  async function stepInstallBundle(opts: SetupOptions): Promise<void> {
643
- step("⑥b", "Installing bundle (skills + CLI tools)...");
650
+ step("⑥", "Installing bundle (skills + CLI tools)...");
644
651
 
645
652
  const bundleLog: ProvisionLog = {
646
653
  info: (msg) => log(` ${msg}`),
@@ -657,7 +664,7 @@ async function stepConfigure(
657
664
  pairResult: PairResult | null,
658
665
  agent: AgentDefinition,
659
666
  ): Promise<void> {
660
- step("", "Configuring...");
667
+ step("", "Configuring...");
661
668
 
662
669
  await mkdir(ROUNDHOUSE_DIR, { recursive: true });
663
670
 
@@ -766,7 +773,7 @@ async function stepConfigure(
766
773
  }
767
774
 
768
775
  async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
769
- step("", "Pairing with Telegram...");
776
+ step("", "Pairing with Telegram...");
770
777
 
771
778
  // Skip if chat IDs already known
772
779
  if (opts.notifyChatIds.length > 0) {
@@ -822,13 +829,13 @@ async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResul
822
829
  }
823
830
 
824
831
  async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
825
- step("", "Registering bot commands...");
832
+ step("", "Registering bot commands...");
826
833
  await registerBotCommands(opts.botToken);
827
834
  ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
828
835
  }
829
836
 
830
837
  async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
831
- step("", "Installing systemd service...");
838
+ step("⑩b", "Installing systemd service...");
832
839
 
833
840
  if (!opts.systemd) {
834
841
  ok("Skipped (--no-systemd)");
@@ -873,7 +880,7 @@ async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
873
880
  }
874
881
 
875
882
  async function stepPostflight(): Promise<void> {
876
- step("", "Postflight checks...");
883
+ step("", "Postflight checks...");
877
884
 
878
885
  if (platform() === "linux") {
879
886
  if (isServiceActive()) {
@@ -894,6 +901,21 @@ async function stepPostflight(): Promise<void> {
894
901
  if (!whichSync("ffmpeg")) {
895
902
  warn("ffmpeg not found (install for voice support)");
896
903
  }
904
+
905
+ // Whisper STT check (only if voice is enabled)
906
+ if (platform() === "linux" || process.env.ROUNDHOUSE_VOICE === "1") {
907
+ if (!whichSync("whisper")) {
908
+ warn("whisper not found — STT will auto-install on first voice message");
909
+ log(" Pre-install: pip3 install openai-whisper");
910
+ } else {
911
+ ok("whisper available");
912
+ }
913
+ }
914
+
915
+ if (!process.env.TAVILY_API_KEY) {
916
+ warn("TAVILY_API_KEY not set — web search extension won't work");
917
+ log(" Get a free key at https://tavily.com and add to ~/.roundhouse/.env");
918
+ }
897
919
  }
898
920
 
899
921
  // ── BotFather Guide ──────────────────────────────────
@@ -921,6 +943,23 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
921
943
  // Step 1: Preflight
922
944
  await stepPreflight(opts, agent);
923
945
 
946
+ // Detect existing agent installations
947
+ const env = detectEnvironment();
948
+ if (env.agents.length > 0) {
949
+ log("");
950
+ log(" 🔍 Agent detection:");
951
+ for (const line of formatDetectionResults(env)) {
952
+ ok(line);
953
+ }
954
+ // If the selected agent is already configured, skip package install
955
+ if (!opts.force) {
956
+ const selected = env.agents.find(a => a.type === opts.agent);
957
+ if (selected?.configured) {
958
+ opts._skipAgentInstall = true;
959
+ }
960
+ }
961
+ }
962
+
924
963
  // Step 2: Get bot token (prompt if not provided)
925
964
  if (!opts.botToken) {
926
965
  log("");
@@ -951,11 +990,11 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
951
990
  // Step 5: Install packages
952
991
  await stepInstallPackages(opts, agent);
953
992
 
954
- // Step 5b: Install bundle (skills + CLI tools)
993
+ // Step 6: Install bundle (skills + CLI tools)
955
994
  await stepInstallBundle(opts);
956
995
 
957
- // Step 6: Pair via Telegram
958
- step("", "Pairing with Telegram...");
996
+ // Step 7: Pair via Telegram
997
+ step("", "Pairing with Telegram...");
959
998
  const nonce = createPairingNonce();
960
999
  const pairingLink = createPairingLink(botInfo.username, nonce);
961
1000
  log(`\n Open this link to pair:\n`);
@@ -964,6 +1003,16 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
964
1003
  log(` Or send /start ${nonce} to @${botInfo.username}`);
965
1004
  log("");
966
1005
 
1006
+ // Auto-open the pairing link on macOS
1007
+ if (process.platform === "darwin") {
1008
+ try {
1009
+ execFileSync("open", [pairingLink], { stdio: "ignore" });
1010
+ log(" (Opened in Telegram — switch to the app to complete pairing)");
1011
+ } catch { /* ignore if open fails */ }
1012
+ }
1013
+
1014
+ log(" Waiting for you to tap the link in Telegram...");
1015
+
967
1016
  const pairResult = await pairTelegram(
968
1017
  opts.botToken, botInfo.username, opts.users,
969
1018
  300_000, log, { nonce, showLink: false },
@@ -977,17 +1026,17 @@ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
977
1026
  }
978
1027
  }
979
1028
 
980
- // Step 7: Store secrets
1029
+ // Step 8: Store secrets
981
1030
  await stepStoreSecrets(opts, botInfo);
982
1031
 
983
- // Step 8: Write config
1032
+ // Step 9: Write config
984
1033
  await stepConfigure(opts, botInfo, pairResult, agent);
985
1034
 
986
- // Step 9: Register commands + install service
1035
+ // Step 10: Register commands + install service
987
1036
  await stepRegisterCommands(opts);
988
1037
  await stepInstallSystemd(opts);
989
1038
 
990
- // Step 10: Verify
1039
+ // Step 11: Verify
991
1040
  await stepPostflight();
992
1041
 
993
1042
  // Done!
package/src/config.ts CHANGED
@@ -8,8 +8,21 @@
8
8
  import { homedir } from "node:os";
9
9
  import { resolve } from "node:path";
10
10
  import { readFile, access } from "node:fs/promises";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
11
14
  import type { GatewayConfig } from "./types";
12
15
 
16
+ // ── Version ──────────────────────────────────────────
17
+
18
+ const __configDir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ /** Roundhouse package version (read from package.json at startup) */
21
+ export const ROUNDHOUSE_VERSION: string = (() => {
22
+ try { return JSON.parse(readFileSync(join(__configDir, "..", "package.json"), "utf8")).version; }
23
+ catch { return "unknown"; }
24
+ })();
25
+
13
26
  // ── Path constants ───────────────────────────────────
14
27
 
15
28
  /** New canonical config root */
package/src/gateway.ts CHANGED
@@ -13,7 +13,7 @@ import { isTelegramThread, postTelegramHtml, handleTelegramHtmlStream } from "./
13
13
  import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
14
14
  import { sendTelegramToMany } from "./notify/telegram";
15
15
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
16
- import { ROUNDHOUSE_DIR } from "./config";
16
+ import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "./config";
17
17
  import { CronSchedulerService } from "./cron/scheduler";
18
18
  import { isBuiltinJob } from "./cron/helpers";
19
19
  import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format";
@@ -65,10 +65,6 @@ import { join, dirname, basename } from "node:path";
65
65
  import { fileURLToPath } from "node:url";
66
66
 
67
67
  const __gatewayDir = dirname(fileURLToPath(import.meta.url));
68
- const ROUNDHOUSE_VERSION: string = (() => {
69
- try { return JSON.parse(readFileSync(join(__gatewayDir, "..", "package.json"), "utf8")).version; }
70
- catch { return "unknown"; }
71
- })();
72
68
 
73
69
  function telegramChatIdFromThreadId(threadId: unknown): number | null {
74
70
  if (typeof threadId !== "string") return null;