@inceptionstack/roundhouse 0.5.7 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
package/src/cli/cli.ts CHANGED
@@ -370,6 +370,7 @@ Commands:
370
370
  doctor [--fix] Check system health and configuration
371
371
  Options: --fix, --json, --verbose
372
372
  cron <command> Manage scheduled jobs (add, list, trigger, etc.)
373
+ message "text" Send a message to active transports via gateway
373
374
 
374
375
  Config:
375
376
  ~/.roundhouse/gateway.config.json
@@ -388,6 +389,7 @@ import { cmdDoctor } from "./doctor";
388
389
  import { cmdAgent } from "./agent-command";
389
390
  import { cmdCron } from "./cron";
390
391
  import { cmdSetup, cmdPair } from "./setup";
392
+ import { cmdMessage } from "./message";
391
393
 
392
394
  const command = process.argv[2];
393
395
 
@@ -407,6 +409,7 @@ const commands: Record<string, () => void | Promise<void>> = {
407
409
  tui: cmdTui,
408
410
  doctor: () => cmdDoctor(process.argv.slice(3)),
409
411
  cron: () => cmdCron(process.argv.slice(3)),
412
+ message: () => cmdMessage(process.argv.slice(3)),
410
413
  agent: cmdAgent,
411
414
  };
412
415
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * cli/message.ts — Send a message to the running gateway via IPC
3
+ *
4
+ * Usage:
5
+ * roundhouse message "Hello from CLI"
6
+ * roundhouse message --session main "Hello"
7
+ */
8
+
9
+ import { sendIpc } from "../ipc";
10
+
11
+ export async function cmdMessage(args: string[]): Promise<void> {
12
+ let session: string | undefined;
13
+ const positional: string[] = [];
14
+
15
+ for (let i = 0; i < args.length; i++) {
16
+ if (args[i] === "--session" && args[i + 1]) {
17
+ session = args[++i];
18
+ } else {
19
+ positional.push(args[i]);
20
+ }
21
+ }
22
+
23
+ const text = positional.join(" ").trim();
24
+ if (!text) {
25
+ console.error('Usage: roundhouse message [--session <name>] "<message>"');
26
+ process.exit(1);
27
+ }
28
+
29
+ try {
30
+ const response = await sendIpc({ type: "notify", text, session });
31
+ if (response.ok) {
32
+ console.log("✅ Message delivered to gateway");
33
+ } else {
34
+ console.error(`❌ ${response.error}`);
35
+ process.exit(1);
36
+ }
37
+ } catch (err: any) {
38
+ console.error(`❌ ${err.message}`);
39
+ process.exit(1);
40
+ }
41
+ }
@@ -13,6 +13,7 @@ import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from
13
13
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner";
14
14
  import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config";
15
15
  import { CronSchedulerService } from "../cron/scheduler";
16
+ import { IpcServer, type IpcRequest } from "../ipc";
16
17
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact } from "../memory/lifecycle";
17
18
  import { maxPressure } from "../memory/policy";
18
19
  import type { PressureLevel } from "../memory/types";
@@ -82,6 +83,7 @@ export class Gateway {
82
83
  private pairingComplete = false;
83
84
  private sttService: SttService | null = null;
84
85
  private cronScheduler: CronSchedulerService | null = null;
86
+ private ipcServer: IpcServer | null = null;
85
87
 
86
88
  constructor(router: AgentRouter, config: GatewayConfig) {
87
89
  this.router = router;
@@ -330,6 +332,41 @@ export class Gateway {
330
332
  console.error("[roundhouse] cron scheduler start failed:", (err as Error).message);
331
333
  }
332
334
 
335
+ // Start IPC server for CLI → gateway communication
336
+ this.ipcServer = new IpcServer(async (req: IpcRequest) => {
337
+ if (req.type === "ping") return { ok: true };
338
+ if (req.type === "notify") {
339
+ const allChatIds = this.config.chat.notifyChatIds ?? [];
340
+ if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" };
341
+
342
+ // Session routing:
343
+ // "main" = first notifyChatId (primary user chat)
344
+ // numeric string = that specific chat ID
345
+ // anything else / undefined = broadcast to all notifyChatIds
346
+ let targetIds: number[];
347
+ if (req.session === "main") {
348
+ targetIds = [allChatIds[0]];
349
+ } else if (req.session && /^-?\d+$/.test(req.session)) {
350
+ targetIds = [Number(req.session)];
351
+ } else {
352
+ targetIds = allChatIds; // broadcast to all
353
+ }
354
+
355
+ try {
356
+ await this.transport.notify(targetIds, req.text);
357
+ return { ok: true };
358
+ } catch (e: any) {
359
+ return { ok: false, error: e.message };
360
+ }
361
+ }
362
+ return { ok: false, error: "Unknown request type" };
363
+ });
364
+ try {
365
+ await this.ipcServer.start();
366
+ } catch (err) {
367
+ console.error("[roundhouse] IPC server start failed:", (err as Error).message);
368
+ }
369
+
333
370
  // Send startup notification (after cron init so we can include job counts)
334
371
  await this.notifyStartup(platforms);
335
372
  }
@@ -746,6 +783,9 @@ export class Gateway {
746
783
  }
747
784
 
748
785
  async stop() {
786
+ if (this.ipcServer) {
787
+ this.ipcServer.stop();
788
+ }
749
789
  if (this.cronScheduler) {
750
790
  try { await this.cronScheduler.stop(); } catch (e) { console.warn("[roundhouse] cron stop error:", e); }
751
791
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ipc/client.ts — CLI client to send messages to the running gateway
3
+ *
4
+ * Connects to ~/.roundhouse/gateway.sock, sends JSON, reads response, closes.
5
+ */
6
+
7
+ import { createConnection } from "node:net";
8
+ import { SOCKET_PATH } from "./server";
9
+ import type { IpcRequest, IpcResponse } from "./types";
10
+
11
+ /**
12
+ * Send a request to the running gateway via IPC.
13
+ * Returns the response, or throws if gateway is unreachable.
14
+ */
15
+ export async function sendIpc(request: IpcRequest, opts?: { timeoutMs?: number; socketPath?: string }): Promise<IpcResponse> {
16
+ const { timeoutMs = 5000, socketPath = SOCKET_PATH } = opts ?? {};
17
+ return new Promise((resolve, reject) => {
18
+ const conn = createConnection(socketPath);
19
+ let data = "";
20
+ let done = false;
21
+ let timer: ReturnType<typeof setTimeout>;
22
+
23
+ const finish = (result: IpcResponse | Error) => {
24
+ if (done) return;
25
+ done = true;
26
+ clearTimeout(timer);
27
+ conn.destroy();
28
+ if (result instanceof Error) reject(result);
29
+ else resolve(result);
30
+ };
31
+
32
+ conn.on("connect", () => {
33
+ conn.write(JSON.stringify(request) + "\n");
34
+ });
35
+
36
+ conn.on("data", (chunk) => {
37
+ data += chunk.toString();
38
+ const newlineIdx = data.indexOf("\n");
39
+ if (newlineIdx === -1) return;
40
+ try {
41
+ finish(JSON.parse(data.slice(0, newlineIdx)));
42
+ } catch {
43
+ finish(new Error("Invalid response from gateway"));
44
+ }
45
+ });
46
+
47
+ conn.on("error", (err: NodeJS.ErrnoException) => {
48
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
49
+ finish(new Error("Gateway is not running. Start with: roundhouse start"));
50
+ } else {
51
+ finish(err);
52
+ }
53
+ });
54
+
55
+ conn.on("close", () => finish(new Error("Connection closed without response")));
56
+
57
+ timer = setTimeout(() => finish(new Error("IPC timeout")), timeoutMs);
58
+ });
59
+ }
@@ -0,0 +1,3 @@
1
+ export { IpcServer, SOCKET_PATH } from "./server";
2
+ export { sendIpc } from "./client";
3
+ export type { IpcRequest, IpcResponse } from "./types";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ipc/server.ts — Unix socket server for gateway IPC
3
+ *
4
+ * Listens on ~/.roundhouse/gateway.sock.
5
+ * Protocol: newline-delimited JSON (one request, one response, close).
6
+ */
7
+
8
+ import { createServer, type Server } from "node:net";
9
+ import { unlinkSync, chmodSync, existsSync } from "node:fs";
10
+ import { ROUNDHOUSE_DIR } from "../config";
11
+ import { resolve } from "node:path";
12
+ import type { IpcRequest, IpcResponse } from "./types";
13
+
14
+ export const SOCKET_PATH = resolve(ROUNDHOUSE_DIR, "gateway.sock");
15
+
16
+ export type IpcHandler = (request: IpcRequest) => Promise<IpcResponse>;
17
+
18
+ export class IpcServer {
19
+ private server: Server | null = null;
20
+ private socketPath: string;
21
+
22
+ constructor(private handler: IpcHandler, socketPath?: string) {
23
+ this.socketPath = socketPath ?? SOCKET_PATH;
24
+ }
25
+
26
+ getSocketPath(): string { return this.socketPath; }
27
+
28
+ async start(): Promise<void> {
29
+ // Remove stale socket if present (TOCTOU race acknowledged — no fix without flock)
30
+ if (existsSync(this.socketPath)) {
31
+ const { createConnection } = await import("node:net");
32
+ const alive = await new Promise<boolean>((res) => {
33
+ const conn = createConnection(this.socketPath);
34
+ const timer = setTimeout(() => { conn.destroy(); res(false); }, 500);
35
+ conn.on("connect", () => { clearTimeout(timer); conn.end(); res(true); });
36
+ conn.on("error", () => { clearTimeout(timer); res(false); });
37
+ });
38
+ if (alive) {
39
+ throw new Error("Another gateway is already running (socket in use)");
40
+ }
41
+ try { unlinkSync(this.socketPath); } catch {}
42
+ }
43
+
44
+ this.server = createServer((conn) => {
45
+ let data = "";
46
+ let handled = false;
47
+ const MAX_BYTES = 64 * 1024; // 64KB
48
+
49
+ conn.on("data", (chunk) => {
50
+ if (handled) return;
51
+ data += chunk.toString();
52
+ if (data.length > MAX_BYTES) {
53
+ conn.destroy();
54
+ return;
55
+ }
56
+ const newlineIdx = data.indexOf("\n");
57
+ if (newlineIdx === -1) return;
58
+
59
+ handled = true;
60
+ const line = data.slice(0, newlineIdx);
61
+
62
+ let request: IpcRequest;
63
+ try {
64
+ request = JSON.parse(line);
65
+ } catch {
66
+ conn.end(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
67
+ return;
68
+ }
69
+
70
+ this.handler(request).then((response) => {
71
+ conn.end(JSON.stringify(response) + "\n");
72
+ }).catch((err) => {
73
+ conn.end(JSON.stringify({ ok: false, error: err.message }) + "\n");
74
+ });
75
+ });
76
+
77
+ // Timeout connections that send nothing
78
+ conn.setTimeout(5000, () => conn.destroy());
79
+ });
80
+
81
+ await new Promise<void>((resolve, reject) => {
82
+ const onError = (err: Error) => reject(err);
83
+ this.server!.on("error", onError);
84
+ this.server!.listen(this.socketPath, () => {
85
+ this.server!.removeListener("error", onError);
86
+ this.server!.on("error", (e) => console.error("[roundhouse] IPC server error:", e.message));
87
+ // Restrict permissions: owner only
88
+ chmodSync(this.socketPath, 0o600);
89
+ console.log(`[roundhouse] IPC listening on ${this.socketPath}`);
90
+ resolve();
91
+ });
92
+ });
93
+ }
94
+
95
+ stop(): void {
96
+ if (this.server) {
97
+ this.server.close();
98
+ this.server = null;
99
+ }
100
+ try { unlinkSync(this.socketPath); } catch {}
101
+ }
102
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ipc/types.ts — Shared types for the IPC protocol
3
+ */
4
+
5
+ /** Messages the CLI can send to the gateway */
6
+ export type IpcRequest =
7
+ | { type: "notify"; text: string; session?: string }
8
+ | { type: "ping" };
9
+
10
+ /** Responses the gateway sends back */
11
+ export type IpcResponse =
12
+ | { ok: true }
13
+ | { ok: false; error: string };
@@ -226,6 +226,7 @@ export function provisionBundle(opts: ProvisionOpts = {}): void {
226
226
  provisionMcporterConfig(opts);
227
227
  provisionExtensionFiles(opts);
228
228
  provisionExtensions(opts);
229
+ provisionWorkspaceFiles(opts);
229
230
  }
230
231
 
231
232
  /**
@@ -306,3 +307,33 @@ export function provisionExtensions(opts: ProvisionOpts = {}): void {
306
307
  log.warn(`extensions provisioning failed: ${err.message}`);
307
308
  }
308
309
  }
310
+
311
+ /**
312
+ * Copy workspace files (tools.md, etc.) to ~/.roundhouse/ if not already present.
313
+ * Never overwrites — user's customized version always wins.
314
+ */
315
+ export function provisionWorkspaceFiles(opts: ProvisionOpts = {}): void {
316
+ const { force = false, log = consoleLog } = opts;
317
+ const roundhouseDir = resolve(homedir(), ".roundhouse");
318
+ const bundledDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "gateway");
319
+
320
+ // Files to provision: [bundled filename, target filename]
321
+ const files: [string, string][] = [
322
+ ["tools.md", "tools.md"],
323
+ ];
324
+
325
+ try {
326
+ mkdirSync(roundhouseDir, { recursive: true });
327
+
328
+ for (const [src, dest] of files) {
329
+ const srcPath = resolve(bundledDir, src);
330
+ const destPath = resolve(roundhouseDir, dest);
331
+ if (!existsSync(srcPath)) continue;
332
+ if (existsSync(destPath) && !force) continue; // never overwrite unless forced
333
+ copyFileSync(srcPath, destPath);
334
+ log.ok(`${dest} provisioned to ~/.roundhouse/`);
335
+ }
336
+ } catch (err: any) {
337
+ log.warn(`workspace files provisioning failed: ${err.message}`);
338
+ }
339
+ }