@inceptionstack/roundhouse 0.2.2 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +321 -9
  2. package/architecture.md +77 -8
  3. package/package.json +3 -1
  4. package/src/agents/pi.ts +433 -26
  5. package/src/agents/registry.ts +8 -0
  6. package/src/cli/cli.ts +384 -189
  7. package/src/cli/cron.ts +296 -0
  8. package/src/cli/doctor/checks/agent.ts +68 -0
  9. package/src/cli/doctor/checks/config.ts +88 -0
  10. package/src/cli/doctor/checks/credentials.ts +62 -0
  11. package/src/cli/doctor/checks/disk.ts +69 -0
  12. package/src/cli/doctor/checks/stt.ts +76 -0
  13. package/src/cli/doctor/checks/system.ts +86 -0
  14. package/src/cli/doctor/checks/systemd.ts +76 -0
  15. package/src/cli/doctor/output.ts +58 -0
  16. package/src/cli/doctor/runner.ts +142 -0
  17. package/src/cli/doctor/shell.ts +33 -0
  18. package/src/cli/doctor/types.ts +44 -0
  19. package/src/cli/doctor.ts +48 -0
  20. package/src/cli/setup-telegram.ts +148 -0
  21. package/src/cli/setup.ts +936 -0
  22. package/src/commands.ts +23 -0
  23. package/src/config.ts +188 -0
  24. package/src/cron/constants.ts +54 -0
  25. package/src/cron/durations.ts +33 -0
  26. package/src/cron/format.ts +139 -0
  27. package/src/cron/helpers.ts +30 -0
  28. package/src/cron/runner.ts +148 -0
  29. package/src/cron/schedule.ts +101 -0
  30. package/src/cron/scheduler.ts +295 -0
  31. package/src/cron/store.ts +125 -0
  32. package/src/cron/template.ts +89 -0
  33. package/src/cron/types.ts +76 -0
  34. package/src/gateway.ts +927 -18
  35. package/src/index.ts +1 -58
  36. package/src/memory/bootstrap.ts +98 -0
  37. package/src/memory/files.ts +100 -0
  38. package/src/memory/inject.ts +41 -0
  39. package/src/memory/lifecycle.ts +245 -0
  40. package/src/memory/policy.ts +122 -0
  41. package/src/memory/prompts.ts +42 -0
  42. package/src/memory/state.ts +43 -0
  43. package/src/memory/types.ts +90 -0
  44. package/src/notify/telegram.ts +48 -0
  45. package/src/types.ts +68 -1
  46. package/src/util.ts +28 -2
  47. package/src/voice/providers/whisper.ts +339 -0
  48. package/src/voice/stt-service.ts +284 -0
  49. package/src/voice/types.ts +63 -0
package/src/cli/cli.ts CHANGED
@@ -2,46 +2,34 @@
2
2
 
3
3
  /**
4
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
5
  */
17
6
 
18
7
  import { resolve, dirname } from "node:path";
19
8
  import { homedir } from "node:os";
20
- import { readFile, writeFile, mkdir, access } from "node:fs/promises";
9
+ import { readFile, writeFile, mkdir, mkdtemp } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { readdirSync, statSync } from "node:fs";
21
12
  import { execSync, spawn } from "node:child_process";
22
13
  import { fileURLToPath } from "node:url";
23
14
 
15
+ import {
16
+ CONFIG_DIR,
17
+ CONFIG_PATH,
18
+ ENV_FILE_PATH,
19
+ DEFAULT_CONFIG,
20
+ SERVICE_NAME,
21
+ fileExists,
22
+ loadConfig,
23
+ resolveEnvFilePath,
24
+ } from "../config";
25
+ import { getAgentSdkPackage } from "../agents/registry";
26
+
24
27
  const __filename = fileURLToPath(import.meta.url);
25
28
  const __dirname = dirname(__filename);
26
29
 
27
- const SERVICE_NAME = "roundhouse";
28
- const CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
29
- const CONFIG_PATH = resolve(CONFIG_DIR, "gateway.config.json");
30
30
  const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
31
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
- };
32
+ // ── Shell helpers ───────────────────────────────────
45
33
 
46
34
  function run(cmd: string, opts?: { silent?: boolean }): string {
47
35
  try {
@@ -56,40 +44,31 @@ function runSudo(cmd: string): void {
56
44
  execSync(`sudo ${cmd}`, { stdio: "inherit" });
57
45
  }
58
46
 
59
- async function fileExists(path: string): Promise<boolean> {
60
- try {
61
- await access(path);
62
- return true;
63
- } catch {
64
- return false;
65
- }
47
+ function systemctl(verb: string, message?: string): void {
48
+ runSudo(`systemctl ${verb} ${SERVICE_NAME}`);
49
+ if (message) console.log(` ✅ ${message}`);
66
50
  }
67
51
 
68
52
  // ── Commands ────────────────────────────────────────
69
53
 
70
54
  async function cmdStart() {
71
- // Import and run the gateway in-process (foreground)
72
55
  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
56
+ const indexPath = resolve(__dirname, "..", "index.ts");
76
57
  const jsPath = resolve(__dirname, "..", "dist", "index.js");
58
+
77
59
  if (await fileExists(jsPath)) {
78
60
  await import(jsPath);
79
61
  } 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
- });
62
+ execSync(
63
+ `node ${resolve(__dirname, "..", "node_modules", "tsx", "dist", "cli.mjs")} ${indexPath}`,
64
+ { stdio: "inherit", env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH } },
65
+ );
86
66
  }
87
67
  }
88
68
 
89
69
  async function cmdInstall() {
90
70
  console.log("[roundhouse] Installing as systemd daemon...\n");
91
71
 
92
- // 1. Create config if missing
93
72
  await mkdir(CONFIG_DIR, { recursive: true });
94
73
  if (await fileExists(CONFIG_PATH)) {
95
74
  console.log(` Config exists: ${CONFIG_PATH}`);
@@ -99,19 +78,55 @@ async function cmdInstall() {
99
78
  console.log(` ⚠️ Edit this file to set allowedUsers and other settings.`);
100
79
  }
101
80
 
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"]) {
81
+ // Write environment file for secrets — merge with existing to preserve manually-added keys
82
+ 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"];
83
+ const existing = new Map<string, string>();
84
+ const resolvedEnvPath = await resolveEnvFilePath();
85
+ if (await fileExists(resolvedEnvPath)) {
86
+ const raw = await readFile(resolvedEnvPath, "utf8");
87
+ for (const line of raw.split("\n")) {
88
+ const trimmed = line.trim();
89
+ if (!trimmed || trimmed.startsWith("#")) continue;
90
+ const eq = trimmed.indexOf("=");
91
+ if (eq > 0) existing.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
92
+ }
93
+ }
94
+ // Override with current env vars for known keys
95
+ let envChanged = false;
96
+ for (const key of ENV_KEYS) {
109
97
  if (process.env[key]) {
110
- envLines.push(`Environment=${key}=${process.env[key]}`);
98
+ existing.set(key, `"${process.env[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$$").replace(/`/g, "\\`").replace(/\n/g, "\\n")}"`);
99
+ envChanged = true;
111
100
  }
112
101
  }
102
+ if (envChanged || !(await fileExists(ENV_FILE_PATH))) {
103
+ if (resolvedEnvPath !== ENV_FILE_PATH && await fileExists(resolvedEnvPath)) {
104
+ console.log(` Copying env file from ${resolvedEnvPath} to ${ENV_FILE_PATH}`);
105
+ }
106
+ const envFileContent = [...existing.entries()].map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
107
+ await writeFile(ENV_FILE_PATH, envFileContent, { mode: 0o600 });
108
+ console.log(` Environment file: ${ENV_FILE_PATH}`);
109
+ }
110
+
111
+ // Resolve paths — prefer the installed bin, fall back to tsx + source
112
+ const binPath = run("which roundhouse", { silent: true });
113
+ const nodePath = run("which node", { silent: true }) || process.execPath;
114
+ const tsxPath = resolve(__dirname, "..", "node_modules", ".bin", "tsx");
115
+ const srcIndex = resolve(__dirname, "..", "index.ts");
116
+
117
+ let execStart: string;
118
+ if (binPath) {
119
+ execStart = `${nodePath} ${binPath} start`;
120
+ } else {
121
+ // No global install — use tsx directly
122
+ const tsxBin = run("which tsx", { silent: true }) || tsxPath;
123
+ execStart = `${tsxBin} ${srcIndex}`;
124
+ }
125
+
126
+ // Compute PATH that includes node's bin dir (for mise/nvm setups)
127
+ const nodeBinDir = dirname(nodePath);
128
+ const pathValue = `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin`;
113
129
 
114
- // 4. Create systemd unit
115
130
  const unit = `[Unit]
116
131
  Description=Roundhouse Chat Gateway
117
132
  After=network.target
@@ -120,47 +135,45 @@ After=network.target
120
135
  Type=simple
121
136
  User=${process.env.USER || "root"}
122
137
  WorkingDirectory=${homedir()}
123
- ExecStart=${nodePath} ${binPath} start
138
+ ExecStart=${execStart}
124
139
  Restart=on-failure
125
140
  RestartSec=5
126
- ${envLines.join("\n")}
141
+ EnvironmentFile=-${ENV_FILE_PATH}
127
142
  Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
128
143
  Environment=NODE_ENV=production
144
+ Environment=PATH=${pathValue}
129
145
 
130
146
  [Install]
131
147
  WantedBy=multi-user.target
132
148
  `;
133
149
 
134
- const tmpUnit = `/tmp/${SERVICE_NAME}.service`;
135
- await writeFile(tmpUnit, unit);
150
+ const tmpDir = await mkdtemp(resolve(tmpdir(), "roundhouse-"));
151
+ const tmpUnit = resolve(tmpDir, `${SERVICE_NAME}.service`);
152
+ await writeFile(tmpUnit, unit, { mode: 0o600 });
136
153
  runSudo(`cp ${tmpUnit} ${SERVICE_PATH}`);
154
+ runSudo(`rm -rf -- ${tmpDir}`);
137
155
  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=`);
156
+ systemctl("enable");
157
+ systemctl("start", "Daemon installed and started.");
158
+
159
+ console.log(`\n Config: ${CONFIG_PATH}`);
160
+ console.log(` Env file: ${ENV_FILE_PATH}`);
161
+ console.log(` Service: ${SERVICE_PATH}`);
162
+ console.log(` Logs: roundhouse logs`);
163
+ console.log(` Status: roundhouse status`);
164
+
165
+ if (!envChanged) {
166
+ console.log(`\n ⚠️ No env vars detected. Edit ${ENV_FILE_PATH} with your secrets:`);
167
+ console.log(` TELEGRAM_BOT_TOKEN=...`);
168
+ console.log(` Then add your API keys and run: roundhouse restart`);
150
169
  }
151
170
  }
152
171
 
153
172
  async function cmdUninstall() {
154
173
  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 {}
174
+ try { systemctl("stop"); } catch {}
175
+ try { systemctl("disable"); } catch {}
176
+ try { runSudo(`rm -f ${SERVICE_PATH}`); } catch {}
164
177
  runSudo("systemctl daemon-reload");
165
178
  console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
166
179
  }
@@ -170,84 +183,143 @@ async function cmdUpdate() {
170
183
  run("npm update -g roundhouse");
171
184
  console.log("\n[roundhouse] Restarting daemon...");
172
185
  try {
173
- runSudo(`systemctl restart ${SERVICE_NAME}`);
174
- console.log(" ✅ Updated and restarted.");
186
+ systemctl("restart", "Updated and restarted.");
175
187
  } catch {
176
188
  console.log(" ⚠️ Daemon not running. Start with: roundhouse install");
177
189
  }
178
190
  }
179
191
 
180
- function cmdStatus() {
192
+ async function cmdStatus() {
193
+ // Show systemd status
194
+ const isActive = run(`systemctl is-active ${SERVICE_NAME}`, { silent: true }) === "active";
195
+
196
+ if (!isActive) {
197
+ console.log("\n ❌ Roundhouse is not running.\n");
198
+ console.log(" Install with: roundhouse install");
199
+ console.log(" Or start foreground: roundhouse start\n");
200
+ return;
201
+ }
202
+
203
+ // Load config for details
204
+ let config: Awaited<ReturnType<typeof loadConfig>> | null = null;
181
205
  try {
182
- run(`systemctl status ${SERVICE_NAME}`);
183
- } catch {
184
- console.log("Daemon is not installed. Run: roundhouse install");
206
+ config = await loadConfig();
207
+ } catch {}
208
+
209
+ // Gather systemd info
210
+ const pid = run(`systemctl show -p MainPID --value ${SERVICE_NAME}`, { silent: true });
211
+ const activeState = run(`systemctl show -p ActiveState --value ${SERVICE_NAME}`, { silent: true });
212
+ const startedAt = run(`systemctl show -p ActiveEnterTimestamp --value ${SERVICE_NAME}`, { silent: true });
213
+
214
+ // Compute uptime
215
+ let uptimeStr = "unknown";
216
+ if (startedAt) {
217
+ const startMs = new Date(startedAt).getTime();
218
+ if (!isNaN(startMs)) {
219
+ const sec = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
220
+ if (sec < 3600) uptimeStr = `${Math.floor(sec / 60)}m ${sec % 60}s`;
221
+ else uptimeStr = `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
222
+ }
223
+ }
224
+
225
+ // Memory from PID
226
+ let memStr = "unknown";
227
+ if (pid && pid !== "0" && /^\d+$/.test(pid)) {
228
+ const rssKb = run(`ps -o rss= -p ${pid}`, { silent: true }).trim();
229
+ if (rssKb) {
230
+ const parsed = parseInt(rssKb, 10);
231
+ if (!isNaN(parsed)) memStr = `${(parsed / 1024).toFixed(1)} MB`;
232
+ }
185
233
  }
234
+
235
+ // Read env file for debug flags
236
+ let debugStream = false;
237
+ const statusEnvPath = await resolveEnvFilePath();
238
+ try {
239
+ const envContent = await readFile(statusEnvPath, "utf8");
240
+ debugStream = envContent.includes("ROUNDHOUSE_DEBUG_STREAM=1") || envContent.includes('ROUNDHOUSE_DEBUG_STREAM="1"');
241
+ } catch {}
242
+
243
+ // Read versions
244
+ let roundhouseVersion = "unknown";
245
+ let agentVersion = "unknown";
246
+ try {
247
+ const pkgPath = resolve(__dirname, "..", "..", "package.json");
248
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
249
+ roundhouseVersion = pkg.version;
250
+ } catch {}
251
+
252
+ // Resolve agent SDK version from registry
253
+ const agentPkg = config ? getAgentSdkPackage(config.agent.type) : undefined;
254
+ if (agentPkg) {
255
+ try {
256
+ const agentPkgPath = resolve(__dirname, "..", "..", "node_modules", ...agentPkg.split("/"), "package.json");
257
+ agentVersion = JSON.parse(await readFile(agentPkgPath, "utf8")).version;
258
+ } catch {}
259
+ }
260
+
261
+ console.log("\n 🟢 Roundhouse is running\n");
262
+ console.log(` Version: v${roundhouseVersion}`);
263
+ console.log(` State: ${activeState}`);
264
+ console.log(` PID: ${pid}`);
265
+ console.log(` Uptime: ${uptimeStr}`);
266
+ console.log(` Memory: ${memStr}`);
267
+
268
+ if (config) {
269
+ const platforms = Object.keys(config.chat.adapters).join(", ");
270
+ const allowedCount = config.chat.allowedUsers?.length ?? 0;
271
+ console.log(` Agent: ${config.agent.type} (v${agentVersion})`);
272
+ console.log(` Agent CWD: ${config.agent.cwd ?? process.cwd()}`);
273
+ console.log(` Platforms: ${platforms}`);
274
+ console.log(` Bot: @${config.chat.botUsername}`);
275
+ console.log(` Allowed users: ${allowedCount === 0 ? "all (no allowlist)" : config.chat.allowedUsers!.join(", ")}`);
276
+ console.log(` Notify chats: ${config.chat.notifyChatIds?.join(", ") ?? "none"}`);
277
+ }
278
+
279
+ console.log(` Debug stream: ${debugStream ? "on" : "off"}`);
280
+ console.log(` Config: ${CONFIG_PATH}`);
281
+ console.log(` Env file: ${statusEnvPath}`);
282
+ console.log();
186
283
  }
187
284
 
188
285
  function cmdLogs() {
189
286
  const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
190
287
  stdio: "inherit",
191
288
  });
192
- child.on("error", () => {
193
- console.log("Could not read logs. Is the daemon installed?");
194
- });
289
+ child.on("error", () => console.log("Could not read logs. Is the daemon installed?"));
195
290
  }
196
291
 
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
- }
292
+ function cmdStop() { systemctl("stop", "Daemon stopped."); }
293
+ function cmdRestart() { systemctl("restart", "Daemon restarted."); }
206
294
 
207
295
  async function cmdConfig() {
208
296
  console.log(`Config path: ${CONFIG_PATH}\n`);
209
297
  if (await fileExists(CONFIG_PATH)) {
210
- const content = await readFile(CONFIG_PATH, "utf8");
211
- console.log(content);
298
+ console.log(await readFile(CONFIG_PATH, "utf8"));
212
299
  } else {
213
300
  console.log("(no config file — defaults will be used)");
214
301
  }
215
302
  }
216
303
 
217
304
  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
-
305
+ const config = await loadConfig();
226
306
  const agentType = config.agent?.type ?? "pi";
227
307
 
228
308
  if (agentType !== "pi") {
229
309
  console.error(`roundhouse tui: agent type "${agentType}" does not support TUI yet.`);
230
- console.error("Only \"pi\" is supported currently.");
231
310
  process.exit(1);
232
311
  }
233
312
 
234
- // 2. Find gateway sessions
235
- const sessionsBase = config.agent?.sessionDir ?? resolve(homedir(), ".pi", "agent", "gateway-sessions");
313
+ const sessionsBase = (config.agent as any)?.sessionDir
314
+ ?? resolve(homedir(), ".pi", "agent", "gateway-sessions");
315
+
236
316
  let threadDirs: string[] = [];
237
317
  try {
238
- const { readdirSync, statSync } = await import("node:fs");
239
318
  threadDirs = readdirSync(sessionsBase)
240
- .filter((d: string) => {
241
- try {
242
- return statSync(resolve(sessionsBase, d)).isDirectory();
243
- } catch {
244
- return false;
245
- }
246
- })
319
+ .filter((d) => { try { return statSync(resolve(sessionsBase, d)).isDirectory(); } catch { return false; } })
247
320
  .sort();
248
321
  } catch {
249
322
  console.error(`No gateway sessions found at ${sessionsBase}`);
250
- console.error("Send a message via Telegram/Slack first to create a session.");
251
323
  process.exit(1);
252
324
  }
253
325
 
@@ -256,26 +328,18 @@ async function cmdTui() {
256
328
  process.exit(1);
257
329
  }
258
330
 
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>
331
+ const threadArg = process.argv[3];
262
332
 
263
- interface SessionCandidate {
264
- threadDir: string;
265
- sessionFile: string;
266
- mtime: number;
267
- }
333
+ interface SessionCandidate { threadDir: string; sessionFile: string; mtime: number; }
268
334
 
269
335
  const candidates: SessionCandidate[] = [];
270
336
  for (const dir of threadDirs) {
271
337
  if (threadArg && !dir.includes(threadArg)) continue;
272
338
  const threadPath = resolve(sessionsBase, dir);
273
339
  try {
274
- const files = readdirSync(threadPath).filter((f: string) => f.endsWith(".jsonl"));
275
- for (const f of files) {
340
+ for (const f of readdirSync(threadPath).filter((f) => f.endsWith(".jsonl"))) {
276
341
  const fullPath = resolve(threadPath, f);
277
- const st = statSync(fullPath);
278
- candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: st.mtimeMs });
342
+ candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: statSync(fullPath).mtimeMs });
279
343
  }
280
344
  } catch {}
281
345
  }
@@ -291,10 +355,8 @@ async function cmdTui() {
291
355
  process.exit(1);
292
356
  }
293
357
 
294
- // Sort by most recently modified
295
358
  candidates.sort((a, b) => b.mtime - a.mtime);
296
359
 
297
- // If multiple threads and no filter, let user pick
298
360
  let selected: SessionCandidate;
299
361
  const uniqueThreads = [...new Set(candidates.map((c) => c.threadDir))];
300
362
 
@@ -316,14 +378,10 @@ async function cmdTui() {
316
378
  }
317
379
  console.log();
318
380
 
319
- // Simple prompt
320
381
  const readline = await import("node:readline");
321
382
  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
- });
383
+ const answer = await new Promise<string>((r) => {
384
+ rl.question("Pick a session [1]: ", (ans) => { rl.close(); r(ans.trim() || "1"); });
327
385
  });
328
386
 
329
387
  const idx = parseInt(answer, 10) - 1;
@@ -331,28 +389,17 @@ async function cmdTui() {
331
389
  console.error("Invalid selection.");
332
390
  process.exit(1);
333
391
  }
334
- // Find most recent session file for the selected thread
335
392
  selected = candidates.find((c) => c.threadDir === shown[idx].threadDir)!;
336
393
  }
337
394
 
338
395
  console.log(`\nOpening: ${selected.sessionFile}\n`);
339
396
 
340
- // 4. Launch pi --resume <session>
341
- const piArgs = ["--resume", selected.sessionFile];
342
- const child = spawn("pi", piArgs, { stdio: "inherit" });
343
-
397
+ const child = spawn("pi", ["--resume", selected.sessionFile], { stdio: "inherit" });
344
398
  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
- }
399
+ console.error((err as any).code === "ENOENT" ? "'pi' not found in PATH." : `Failed: ${err.message}`);
350
400
  process.exit(1);
351
401
  });
352
-
353
- child.on("exit", (code) => {
354
- process.exit(code ?? 0);
355
- });
402
+ child.on("exit", (code) => process.exit(code ?? 0));
356
403
  }
357
404
 
358
405
  function printHelp() {
@@ -363,6 +410,8 @@ Usage:
363
410
  roundhouse <command>
364
411
 
365
412
  Commands:
413
+ setup One-command install & configure (also works via npx)
414
+ pair Pair Telegram account for notifications
366
415
  start Start the gateway (foreground)
367
416
  tui [thread] Open agent TUI on a gateway session
368
417
  install Install as a systemd daemon (requires sudo)
@@ -373,9 +422,15 @@ Commands:
373
422
  stop Stop the daemon
374
423
  restart Restart the daemon
375
424
  config Show config path and contents
425
+ agent <message> Send a message to the agent and print response
426
+ Options: --thread <id>, --stdin, --timeout <sec>,
427
+ --no-timeout, --verbose
428
+ doctor [--fix] Check system health and configuration
429
+ Options: --fix, --json, --verbose
430
+ cron <command> Manage scheduled jobs (add, list, trigger, etc.)
376
431
 
377
432
  Config:
378
- ~/.config/roundhouse/gateway.config.json
433
+ ~/.roundhouse/gateway.config.json
379
434
 
380
435
  Environment:
381
436
  TELEGRAM_BOT_TOKEN Telegram bot token
@@ -386,40 +441,180 @@ Environment:
386
441
 
387
442
  // ── Main ────────────────────────────────────────────
388
443
 
444
+ async function cmdAgent() {
445
+ // Usage: roundhouse agent <message>
446
+ // roundhouse agent --thread <id> <message>
447
+ // echo "message" | roundhouse agent --stdin
448
+ const args = process.argv.slice(3);
449
+ let threadId = "";
450
+ let messageText = "";
451
+ let useStdin = false;
452
+ let timeoutMs = 120_000;
453
+ let verbose = false;
454
+
455
+ for (let i = 0; i < args.length; i++) {
456
+ if (args[i] === "--thread" && args[i + 1]) {
457
+ threadId = args[++i];
458
+ } else if (args[i] === "--stdin") {
459
+ useStdin = true;
460
+ } else if (args[i] === "--timeout" && args[i + 1]) {
461
+ const val = parseInt(args[++i], 10);
462
+ if (isNaN(val) || val <= 0) { console.error("--timeout must be a positive number (seconds)"); process.exit(1); }
463
+ timeoutMs = val * 1000;
464
+ } else if (args[i] === "--no-timeout") {
465
+ timeoutMs = 0;
466
+ } else if (args[i] === "--verbose") {
467
+ verbose = true;
468
+ } else if (args[i].startsWith("-")) {
469
+ console.error(`Unknown flag: ${args[i]}`);
470
+ process.exit(1);
471
+ } else {
472
+ messageText = args.slice(i).join(" ");
473
+ break;
474
+ }
475
+ }
476
+
477
+ if (useStdin) {
478
+ const chunks: Buffer[] = [];
479
+ let totalBytes = 0;
480
+ const MAX_INPUT = 1024 * 1024; // 1 MB
481
+ for await (const chunk of process.stdin) {
482
+ totalBytes += chunk.length;
483
+ if (totalBytes > MAX_INPUT) {
484
+ console.error(`Input exceeds ${MAX_INPUT / 1024}KB limit. Use a file instead.`);
485
+ process.exit(1);
486
+ }
487
+ chunks.push(chunk);
488
+ }
489
+ // Strip single trailing newline (shell echo adds one)
490
+ let raw = Buffer.concat(chunks).toString("utf8");
491
+ if (raw.endsWith("\n")) raw = raw.slice(0, -1);
492
+ messageText = raw;
493
+ }
494
+
495
+ if (!messageText) {
496
+ console.error("Usage: roundhouse agent <message>");
497
+ console.error(" roundhouse agent --thread <id> <message>");
498
+ console.error(" echo \"message\" | roundhouse agent --stdin");
499
+ console.error(" roundhouse agent --timeout 60 <message>");
500
+ console.error(" roundhouse agent --verbose <message>");
501
+ process.exit(1);
502
+ }
503
+
504
+ // Default: ephemeral thread ID (no session persistence across invocations)
505
+ // --thread <id> opts into persistent/shared sessions
506
+ if (!threadId) {
507
+ threadId = `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
508
+ }
509
+
510
+ // Suppress debug/info logs unless --verbose
511
+ const origLog = console.log;
512
+ const origWarn = console.warn;
513
+ const origError = console.error;
514
+ if (!verbose) {
515
+ console.log = () => {};
516
+ console.warn = () => {};
517
+ }
518
+
519
+ let agent: import("../types").AgentAdapter | undefined;
520
+ let aborted = false;
521
+
522
+ // Clean abort on SIGINT/SIGTERM
523
+ const handleSignal = async () => {
524
+ if (aborted) return;
525
+ aborted = true;
526
+ console.log = origLog;
527
+ console.warn = origWarn;
528
+ console.error = origError;
529
+ try { await agent?.abort?.(threadId); } catch {}
530
+ try { await agent?.dispose(); } catch {}
531
+ process.exit(130);
532
+ };
533
+ process.on("SIGINT", handleSignal);
534
+ process.on("SIGTERM", handleSignal);
535
+
536
+ // Timeout race
537
+ let timer: ReturnType<typeof setTimeout> | undefined;
538
+ const timeoutPromise = timeoutMs > 0
539
+ ? new Promise<never>((_, reject) => {
540
+ timer = setTimeout(async () => {
541
+ aborted = true;
542
+ try { await agent?.abort?.(threadId); } catch {}
543
+ reject(new Error(`Timeout after ${timeoutMs / 1000}s`));
544
+ }, timeoutMs);
545
+ })
546
+ : null;
547
+
548
+ try {
549
+ const config = await loadConfig();
550
+ const { getAgentFactory } = await import("../agents/registry");
551
+ const factory = getAgentFactory(config.agent.type);
552
+ agent = factory(config.agent);
553
+
554
+ const runAgent = async () => {
555
+ if (agent!.promptStream) {
556
+ for await (const event of agent!.promptStream(threadId, { text: messageText })) {
557
+ if (event.type === "text_delta") {
558
+ process.stdout.write(event.text);
559
+ }
560
+ }
561
+ process.stdout.write("\n");
562
+ } else {
563
+ const response = await agent!.prompt(threadId, { text: messageText });
564
+ origLog(response.text);
565
+ }
566
+ };
567
+
568
+ if (timeoutPromise) {
569
+ await Promise.race([runAgent(), timeoutPromise]);
570
+ } else {
571
+ await runAgent();
572
+ }
573
+ } catch (err: any) {
574
+ console.error = origError;
575
+ console.error(`Error: ${err.message}`);
576
+ process.exit(aborted ? 124 : 1); // 124 = timeout (like coreutils)
577
+ } finally {
578
+ if (timer) clearTimeout(timer);
579
+ process.off("SIGINT", handleSignal);
580
+ process.off("SIGTERM", handleSignal);
581
+ console.log = origLog;
582
+ console.warn = origWarn;
583
+ console.error = origError;
584
+ if (!aborted) await agent?.dispose();
585
+ }
586
+ }
587
+
588
+ import { cmdDoctor } from "./doctor";
589
+ import { cmdCron } from "./cron";
590
+ import { cmdSetup, cmdPair } from "./setup";
591
+
389
592
  const command = process.argv[2];
390
593
 
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;
594
+ const commands: Record<string, () => void | Promise<void>> = {
595
+ setup: () => cmdSetup(process.argv.slice(3)),
596
+ pair: () => cmdPair(process.argv.slice(3)),
597
+ start: cmdStart,
598
+ install: cmdInstall,
599
+ uninstall: cmdUninstall,
600
+ update: cmdUpdate,
601
+ status: cmdStatus,
602
+ logs: cmdLogs,
603
+ stop: cmdStop,
604
+ restart: cmdRestart,
605
+ config: cmdConfig,
606
+ tui: cmdTui,
607
+ doctor: () => cmdDoctor(process.argv.slice(3)),
608
+ cron: () => cmdCron(process.argv.slice(3)),
609
+ agent: cmdAgent,
610
+ };
611
+
612
+ const fn = command ? commands[command] : undefined;
613
+ if (fn) {
614
+ Promise.resolve(fn()).catch((err) => {
615
+ console.error(`[roundhouse] ${command} failed:`, err);
616
+ process.exit(1);
617
+ });
618
+ } else {
619
+ printHelp();
425
620
  }