@inceptionstack/roundhouse 0.5.2 → 0.5.4

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/architecture.md CHANGED
@@ -258,37 +258,99 @@ The gateway and agent adapters don't change — only the router.
258
258
  ## Module dependency graph
259
259
 
260
260
  ```
261
- index.ts
262
- ├── config.ts (loadConfig, applyEnvOverrides)
263
- ├── agents/registry.ts
264
- └── agents/pi.ts
265
- └── util.ts (threadIdToDir)
266
- ├── router.ts
267
- ├── gateway.ts
268
- └── util.ts (splitMessage, isAllowed, threadIdToDir, generateAttachmentId)
269
- └── voice/stt-service.ts
270
- └── voice/providers/whisper.ts
271
- └── voice/types.ts
272
- └── types.ts (shared interfaces, pure types only)
273
-
274
- cli/cli.ts
275
- ├── config.ts (DEFAULT_CONFIG, CONFIG_PATH, loadConfig, etc.)
276
- ├── agents/registry.ts (getAgentSdkPackage)
277
- ├── cli/env-file.ts (parseEnvFile, serializeEnvFile, envQuote)
278
- ├── cli/systemd.ts (resolveExecStart, generateUnit, writeServiceUnit, systemctl, etc.)
279
- ├── cli/launchd.ts (generatePlist, installLaunchAgent, uninstallLaunchAgent, isLaunchAgentRunning)
280
- ├── cli/doctor.ts → cli/doctor/runner.ts → cli/doctor/checks/*
281
- ├── cli/cron.ts → cron/store.ts, cron/runner.ts, cron/helpers.ts
282
- └── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/launchd.ts, cli/setup-telegram.ts, bundle.ts
283
-
284
- gateway.ts also imports:
285
- commands/update.ts bundle.ts (bundle provisioning)
286
- cli/doctor/runner.ts for /doctor command
287
- cron/scheduler.ts cron/runner.ts cron/store.ts
288
- cron/helpers.ts, cron/format.ts
289
- notify/telegram.ts
261
+ src/
262
+ ├── index.ts # Entry: loads config → creates agent → starts gateway
263
+ ├── config.ts # loadConfig, applyEnvOverrides, path constants
264
+ ├── router.ts # SingleAgentRouter (future: Multi/Fallback/UserChoice)
265
+ ├── types.ts # AgentAdapter, AgentMessage, AgentStreamEvent interfaces
266
+ │ └── agents/
267
+ ├── registry.ts # Agent factory lookup, adapter definitions
268
+ ├── base-adapter.ts # BaseAdapter abstract class (shared defaults)
269
+ ├── index.ts # Barrel re-export
270
+ ├── pi/
271
+ │ ├── pi-adapter.ts # Pi factory: sessions, prompt/stream, lifecycle
272
+ │ │ └── message-format.ts # Pure: formatMessage, extractCustomMessage
273
+ │ └── kiro/
274
+ │ ├── kiro-adapter.ts # Kiro class: ACP protocol, tool dispatch
275
+ ├── session.ts # Session lifecycle
276
+ ├── tool-names.ts # Tool name mapping
277
+ │ └── acp/ # Agent Communication Protocol client
278
+ ├── client.ts
279
+ ├── process.ts
280
+ ├── types.ts
281
+ │ └── index.ts
282
+
283
+ ├── gateway.ts # Gateway class: chat SDK wiring, handleAgentTurn
284
+ │ ├── gateway/
285
+ │ │ ├── commands.ts # 9 Telegram command handlers (/new, /stop, /status, etc.)
286
+ │ │ ├── streaming.ts # Agent event → Telegram message stream mapper
287
+ │ │ ├── attachments.ts # File save, validation, size limits
288
+ │ │ ├── helpers.ts # Pure utils: splitMessage, isAllowed, threadIdToDir
289
+ │ │ └── index.ts # Barrel re-export
290
+ │ ├── commands/update.ts # /update handler → bundle provisioning
291
+ │ ├── cron/scheduler.ts # Tick loop, catch-up, job dispatch
292
+ │ ├── memory/ # Session memory hooks (flush, compact, inject)
293
+ │ ├── notify/telegram.ts # Startup/error notifications
294
+ │ └── voice/
295
+ │ ├── stt-service.ts # STT orchestration (provider chain)
296
+ │ └── providers/whisper.ts # Whisper CLI provider
297
+
298
+ ├── cli/
299
+ │ ├── cli.ts # CLI dispatcher: start/stop/status/logs/doctor/cron/setup
300
+ │ ├── agent-command.ts # `roundhouse agent` — one-shot prompt pipeline
301
+ │ ├── service-manager.ts # ServiceManager interface + Launchd/Systemd impls
302
+ │ ├── shell.ts # Shell execution utilities
303
+ │ ├── cron.ts # Thin dispatcher → cron-commands.ts
304
+ │ ├── cron-commands.ts # 10 cron command handlers (add/list/show/trigger/...)
305
+ │ ├── detect.ts # Agent environment detection
306
+ │ ├── env-file.ts # .env parser/serializer
307
+ │ ├── systemd.ts # systemd unit generation, systemctl wrappers
308
+ │ ├── launchd.ts # macOS plist generation, launchctl wrappers
309
+ │ ├── setup.ts # Setup dispatcher (300 lines): cmdSetup, cmdPair, help
310
+ │ ├── setup/
311
+ │ │ ├── steps.ts # 11 step functions (preflight → postflight)
312
+ │ │ ├── flows.ts # Interactive + headless orchestrators
313
+ │ │ ├── runtime.ts # Logger factories, agent resolution
314
+ │ │ ├── args.ts # Argument parser
315
+ │ │ ├── helpers.ts # Atomic writes, exec wrappers
316
+ │ │ ├── types.ts # SetupOptions, StepLog interface
317
+ │ │ └── index.ts # Barrel export
318
+ │ ├── setup-telegram.ts # Telegram API: validate token, pair, register commands
319
+ │ ├── setup-prompts.ts # TTY prompt helpers
320
+ │ ├── setup-logger.ts # JSON/text logger for headless diagnostics
321
+ │ ├── qr.ts # QR code generation for pairing links
322
+ │ └── doctor/ # Health checks (8 check modules + runner)
323
+
324
+ ├── cron/ # Cron job engine
325
+ │ ├── store.ts # Job CRUD, run history persistence
326
+ │ ├── runner.ts # Execute job → agent prompt → record result
327
+ │ ├── scheduler.ts # Tick loop with catch-up
328
+ │ ├── schedule.ts, durations.ts # Parsing: cron expressions, intervals
329
+ │ ├── template.ts # Variable substitution in prompts
330
+ │ ├── format.ts, helpers.ts # Display formatting, validation
331
+ │ ├── constants.ts, types.ts # Shared constants and interfaces
332
+
333
+ ├── memory/ # Roundhouse-managed session memory
334
+ │ ├── lifecycle.ts # Flush/compact orchestration
335
+ │ ├── policy.ts # Pressure detection, token thresholds
336
+ │ ├── prompts.ts # LLM prompts for summarization
337
+ │ ├── files.ts # Memory file I/O
338
+ │ ├── bootstrap.ts, inject.ts # Session bootstrapping, context injection
339
+ │ ├── state.ts, types.ts # State tracking, interfaces
340
+
341
+ ├── telegram-format.ts # Markdown → Telegram HTML converter
342
+ ├── telegram-html.ts # HTML entity utilities
343
+ ├── telegram-progress.ts # Typing indicator + progress edits
344
+ ├── bundle.ts # Skill/extension bundle provisioning
345
+ ├── pairing.ts # Nonce-based Telegram pairing protocol
346
+ ├── commands.ts # Bot command definitions
347
+ └── util.ts # Runtime helpers (crypto, path)
290
348
  ```
291
349
 
292
- No circular dependencies. `types.ts` and `config.ts` are pure leaf modules.
293
- `util.ts` is a leaf module with runtime helpers (`node:crypto` for attachment IDs).
294
- `bundle.ts` is a pure leaf module (only `node:*` imports) consumed by `cli/setup.ts` and `commands/update.ts`.
350
+ **Dependency rules:**
351
+ - No circular dependencies
352
+ - `types.ts`, `config.ts`, `util.ts` are pure leaf modules
353
+ - `bundle.ts` is a leaf (only `node:*` imports)
354
+ - Gateway modules (`gateway/*.ts`) import from `../types`, `../config`, `../util`, `../memory/*`, `../telegram-*`
355
+ - CLI modules never import from `gateway.ts` (separation of concerns)
356
+ - Agent adapters depend on their SDK + `../../types`, `../../config`, `../../util`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { homedir } from "node:os";
14
14
  import { resolve } from "node:path";
15
- import type { AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, AdapterInfo } from "../../types.js";
15
+ import type { AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, AdapterInfo, MessageContext } from "../../types.js";
16
16
  import { ROUNDHOUSE_VERSION } from "../../config.js";
17
17
  import { BaseAdapter } from "../base-adapter.js";
18
18
  import { spawnKiroCli, shutdownProcess, getKiroCliVersion, type AcpProcess, type InitializeResult, type SessionNewResult } from "./acp/index.js";
@@ -180,6 +180,13 @@ class KiroAdapter extends BaseAdapter {
180
180
  };
181
181
  }
182
182
 
183
+ prepareMessage(_threadId: string, message: AgentMessage, context: MessageContext): AgentMessage {
184
+ if (context.platform === "telegram" && message.text) {
185
+ return { ...message, text: message.text + "\n\n[Format your final answer for Telegram: concise, use markdown sparingly, avoid long code blocks.]" };
186
+ }
187
+ return message;
188
+ }
189
+
183
190
  // ── Private: process lifecycle ───────────────────────
184
191
 
185
192
  private async ensureProcess(): Promise<AcpProcess> {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * agents/pi/message-format.ts — Message formatting utilities for Pi adapter
3
+ *
4
+ * Pure functions that transform AgentMessage → pi prompt text
5
+ * and extract custom messages from session events.
6
+ */
7
+
8
+ import type { AgentMessage } from "../../types";
9
+ import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent";
10
+
11
+ /**
12
+ * Convert custom message content (string or array of parts) to plain text.
13
+ */
14
+ export function customContentToText(content: unknown): string {
15
+ if (typeof content === "string") return content;
16
+ if (Array.isArray(content)) {
17
+ return content
18
+ .filter((part): part is { type: "text"; text: string } =>
19
+ !!part && typeof part === "object" && (part as any).type === "text"
20
+ )
21
+ .map((part) => part.text)
22
+ .join("");
23
+ }
24
+ return "";
25
+ }
26
+
27
+ /**
28
+ * Extract displayable text from a session event if it is an extension custom
29
+ * message (e.g. pi-lgtm review) with display=true. Returns null otherwise.
30
+ */
31
+ export function extractCustomMessage(event: AgentSessionEvent): { customType: string; content: string } | null {
32
+ if (event.type !== "message_end") return null;
33
+ const message = (event as any).message;
34
+ if (!message || message.role !== "custom" || !message.display) return null;
35
+ const content = customContentToText(message.content);
36
+ if (!content.trim()) return null;
37
+ const customType = message.customType ?? "";
38
+ return { customType, content };
39
+ }
40
+
41
+ /**
42
+ * Format an AgentMessage into the text string that pi's session.prompt() expects.
43
+ * Handles attachment manifests with transcription data.
44
+ */
45
+ export function formatMessage(message: AgentMessage): string {
46
+ let text = message.text;
47
+ if (message.attachments?.length) {
48
+ const manifest = JSON.stringify(
49
+ message.attachments.map((a) => {
50
+ const entry: Record<string, unknown> = {
51
+ id: a.id,
52
+ type: a.mediaType,
53
+ name: a.name,
54
+ localPath: a.localPath,
55
+ mime: a.mime,
56
+ sizeBytes: a.sizeBytes,
57
+ untrusted: true,
58
+ };
59
+ if (a.transcript?.status === "completed" && a.transcript.text) {
60
+ entry.transcript = {
61
+ text: a.transcript.text,
62
+ language: a.transcript.language,
63
+ provider: a.transcript.provider,
64
+ approximate: true,
65
+ };
66
+ } else if (a.transcript?.status === "failed") {
67
+ entry.transcript = {
68
+ status: "failed",
69
+ error: a.transcript.error,
70
+ approximate: true,
71
+ };
72
+ }
73
+ return entry;
74
+ }),
75
+ null,
76
+ 2,
77
+ );
78
+ const block = [
79
+ "Chat attachments saved locally. Inspect files with tools before making claims. Transcripts are approximate; use the raw file if exact wording matters.",
80
+ "```json",
81
+ manifest,
82
+ "```",
83
+ ].join("\n");
84
+ text = text ? `${text}\n\n${block}` : block;
85
+ }
86
+ return text;
87
+ }
@@ -26,7 +26,8 @@ import {
26
26
  type AgentSessionEvent,
27
27
  } from "@mariozechner/pi-coding-agent";
28
28
 
29
- import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../../types";
29
+ import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, MessageContext } from "../../types";
30
+ import { formatMessage, extractCustomMessage, customContentToText } from "./message-format";
30
31
  import { SESSIONS_DIR } from "../../config";
31
32
  import { DEBUG_STREAM, threadIdToDir } from "../../util";
32
33
 
@@ -78,33 +79,6 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
78
79
  }
79
80
  }
80
81
 
81
- function customContentToText(content: unknown): string {
82
- if (typeof content === "string") return content;
83
- if (Array.isArray(content)) {
84
- return content
85
- .filter((part): part is { type: "text"; text: string } =>
86
- !!part && typeof part === "object" && (part as any).type === "text"
87
- )
88
- .map((part) => part.text)
89
- .join("");
90
- }
91
- return "";
92
- }
93
-
94
- /**
95
- * Extract displayable text from a session event if it is an extension custom
96
- * message (e.g. pi-lgtm review) with display=true. Returns null otherwise.
97
- * Shared helper so promptStream() and doPrompt() use identical filter logic.
98
- */
99
- function extractCustomMessage(event: AgentSessionEvent): { customType: string; content: string } | null {
100
- if (event.type !== "message_end") return null;
101
- const message = (event as any).message;
102
- if (!message || message.role !== "custom" || !message.display) return null;
103
- const content = customContentToText(message.content);
104
- if (!content.trim()) return null;
105
- const customType = message.customType ?? "";
106
- return { customType, content };
107
- }
108
82
 
109
83
  async function runPromptAndFollowUps(entry: SessionEntry, text: string, onDraining?: () => void, onDrainComplete?: () => void): Promise<void> {
110
84
  entry.inFlight++;
@@ -231,6 +205,23 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
231
205
  } else {
232
206
  console.log(`[pi-agent] no memory extension detected — roundhouse memory will manage`);
233
207
  }
208
+
209
+ // Warn about pi extensions that bridge a chat platform directly.
210
+ // They hijack agent_start/message_update/agent_end and short-circuit
211
+ // Roundhouse's streaming pipeline — Telegram shows "typing" forever.
212
+ const conflicting = extNames.filter((n) => /pi-telegram(\b|[\/\\])/i.test(n));
213
+ if (conflicting.length > 0) {
214
+ const lines = [
215
+ "",
216
+ "\u26a0\ufe0f CONFLICT: detected pi extension(s) that bridge a chat platform directly:",
217
+ ...conflicting.map((n) => ` - ${n}`),
218
+ " Roundhouse already drives Telegram. Loading a bridge extension inside",
219
+ " the pi session causes lost replies (typing indicator without text).",
220
+ " Remove the extension from ~/.pi/agent/extensions or pi config and restart.",
221
+ "",
222
+ ];
223
+ for (const line of lines) console.warn(line);
224
+ }
234
225
  }
235
226
 
236
227
  return entry;
@@ -283,50 +274,6 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
283
274
  * Format an AgentMessage into the text string sent to the Pi session.
284
275
  * Attachments are rendered as a fenced JSON manifest appended to the user text.
285
276
  */
286
- function formatMessage(message: AgentMessage): string {
287
- let text = message.text;
288
- if (message.attachments?.length) {
289
- const manifest = JSON.stringify(
290
- message.attachments.map((a) => {
291
- const entry: Record<string, unknown> = {
292
- id: a.id,
293
- type: a.mediaType,
294
- name: a.name,
295
- localPath: a.localPath,
296
- mime: a.mime,
297
- sizeBytes: a.sizeBytes,
298
- untrusted: true,
299
- };
300
- if (a.transcript?.status === "completed" && a.transcript.text) {
301
- entry.transcript = {
302
- text: a.transcript.text,
303
- language: a.transcript.language,
304
- provider: a.transcript.provider,
305
- approximate: true,
306
- };
307
- } else if (a.transcript?.status === "failed") {
308
- entry.transcript = {
309
- status: "failed",
310
- error: a.transcript.error,
311
- approximate: true,
312
- };
313
- }
314
- return entry;
315
- }),
316
- null,
317
- 2,
318
- );
319
- const block = [
320
- "Chat attachments saved locally. Inspect files with tools before making claims. Transcripts are approximate; use the raw file if exact wording matters.",
321
- "```json",
322
- manifest,
323
- "```",
324
- ].join("\n");
325
- text = text ? `${text}\n\n${block}` : block;
326
- }
327
- return text;
328
- }
329
-
330
277
  const adapter: AgentAdapter = {
331
278
  name: "pi",
332
279
 
@@ -423,6 +370,13 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
423
370
  streamEvent = { type: "tool_end", toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError };
424
371
  } else if (event.type === "turn_end") {
425
372
  streamEvent = { type: "turn_end" };
373
+ } else if (event.type === "message_end") {
374
+ // Pi records provider failures (auth, throttling, etc.) on the
375
+ // assistant message instead of throwing — surface them.
376
+ const msg = (event as any).message;
377
+ if (msg?.role === "assistant" && msg.stopReason === "error" && msg.errorMessage) {
378
+ streamEvent = { type: "model_error", message: msg.errorMessage };
379
+ }
426
380
  }
427
381
  }
428
382
 
@@ -621,6 +575,13 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
621
575
  extensions: memoryCapabilities?.extensions ?? [],
622
576
  };
623
577
  },
578
+
579
+ prepareMessage(_threadId: string, message: AgentMessage, context: MessageContext): AgentMessage {
580
+ if (context.platform === "telegram" && message.text) {
581
+ return { ...message, text: message.text + "\n\n[Format your final answer for Telegram: concise, use markdown sparingly, avoid long code blocks.]" };
582
+ }
583
+ return message;
584
+ },
624
585
  };
625
586
 
626
587
  async function doPrompt(threadId: string, text: string): Promise<AgentResponse> {
@@ -0,0 +1,210 @@
1
+ /**
2
+ * cli/agent-command.ts — `roundhouse agent` implementation
3
+ *
4
+ * Extracted from cli.ts for single responsibility.
5
+ * Each sub-concern is a small, testable function.
6
+ */
7
+
8
+ import { loadConfig } from "../config";
9
+ import type { AgentAdapter } from "../types";
10
+
11
+ // ── Types ────────────────────────────────────────────
12
+
13
+ export interface AgentOptions {
14
+ threadId: string;
15
+ messageText: string;
16
+ timeoutMs: number;
17
+ verbose: boolean;
18
+ }
19
+
20
+ // ── Arg Parsing ──────────────────────────────────────
21
+
22
+ /**
23
+ * Parse `roundhouse agent` CLI arguments into structured options.
24
+ * Exits the process on invalid input.
25
+ */
26
+ export function parseAgentArgs(argv: string[]): { options: AgentOptions; useStdin: boolean } {
27
+ let threadId = "";
28
+ let messageText = "";
29
+ let useStdin = false;
30
+ let timeoutMs = 120_000;
31
+ let verbose = false;
32
+ let ephemeral = false;
33
+
34
+ for (let i = 0; i < argv.length; i++) {
35
+ if (argv[i] === "--thread" && argv[i + 1]) {
36
+ threadId = argv[++i];
37
+ } else if (argv[i] === "--stdin") {
38
+ useStdin = true;
39
+ } else if (argv[i] === "--timeout" && argv[i + 1]) {
40
+ const val = parseInt(argv[++i], 10);
41
+ if (isNaN(val) || val <= 0) {
42
+ console.error("--timeout must be a positive number (seconds)");
43
+ process.exit(1);
44
+ }
45
+ timeoutMs = val * 1000;
46
+ } else if (argv[i] === "--no-timeout") {
47
+ timeoutMs = 0;
48
+ } else if (argv[i] === "--verbose") {
49
+ verbose = true;
50
+ } else if (argv[i] === "--ephemeral") {
51
+ ephemeral = true;
52
+ } else if (argv[i].startsWith("-")) {
53
+ console.error(`Unknown flag: ${argv[i]}`);
54
+ process.exit(1);
55
+ } else {
56
+ messageText = argv.slice(i).join(" ");
57
+ break;
58
+ }
59
+ }
60
+
61
+ if (threadId && ephemeral) {
62
+ console.error("--thread and --ephemeral cannot be used together");
63
+ process.exit(1);
64
+ }
65
+
66
+ if (!threadId) {
67
+ threadId = ephemeral
68
+ ? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
69
+ : "main";
70
+ }
71
+
72
+ return { options: { threadId, messageText, timeoutMs, verbose }, useStdin };
73
+ }
74
+
75
+ // ── Stdin Reader ─────────────────────────────────────
76
+
77
+ const MAX_STDIN_BYTES = 1024 * 1024; // 1 MB
78
+
79
+ /**
80
+ * Read stdin with a size limit. Returns the text content.
81
+ * Exits the process if input exceeds limit.
82
+ */
83
+ export async function readStdinWithLimit(): Promise<string> {
84
+ const chunks: Buffer[] = [];
85
+ let totalBytes = 0;
86
+
87
+ for await (const chunk of process.stdin) {
88
+ totalBytes += chunk.length;
89
+ if (totalBytes > MAX_STDIN_BYTES) {
90
+ console.error(`Input exceeds ${MAX_STDIN_BYTES / 1024}KB limit. Use a file instead.`);
91
+ process.exit(1);
92
+ }
93
+ chunks.push(chunk);
94
+ }
95
+
96
+ let raw = Buffer.concat(chunks).toString("utf8");
97
+ // Strip single trailing newline (shell echo adds one)
98
+ if (raw.endsWith("\n")) raw = raw.slice(0, -1);
99
+ return raw;
100
+ }
101
+
102
+ // ── Agent Runner ─────────────────────────────────────
103
+
104
+ /**
105
+ * Run the agent with timeout and signal handling.
106
+ * Streams output to stdout if streaming is available.
107
+ */
108
+ export async function runAgentWithTimeout(opts: AgentOptions): Promise<void> {
109
+ const { threadId, messageText, timeoutMs, verbose } = opts;
110
+
111
+ // Suppress logs unless verbose
112
+ const origLog = console.log;
113
+ const origWarn = console.warn;
114
+ const origError = console.error;
115
+ if (!verbose) {
116
+ console.log = () => {};
117
+ console.warn = () => {};
118
+ }
119
+
120
+ let agent: AgentAdapter | undefined;
121
+ let aborted = false;
122
+
123
+ const handleSignal = async () => {
124
+ if (aborted) return;
125
+ aborted = true;
126
+ console.log = origLog;
127
+ console.warn = origWarn;
128
+ console.error = origError;
129
+ try { await agent?.abort?.(threadId); } catch {}
130
+ try { await agent?.dispose(); } catch {}
131
+ process.exit(130);
132
+ };
133
+ process.on("SIGINT", handleSignal);
134
+ process.on("SIGTERM", handleSignal);
135
+
136
+ let timer: ReturnType<typeof setTimeout> | undefined;
137
+ const timeoutPromise = timeoutMs > 0
138
+ ? new Promise<never>((_, reject) => {
139
+ timer = setTimeout(async () => {
140
+ aborted = true;
141
+ try { await agent?.abort?.(threadId); } catch {}
142
+ reject(new Error(`Timeout after ${timeoutMs / 1000}s`));
143
+ }, timeoutMs);
144
+ })
145
+ : null;
146
+
147
+ try {
148
+ const config = await loadConfig();
149
+ const { getAgentFactory } = await import("../agents/registry");
150
+ const factory = getAgentFactory(config.agent.type);
151
+ agent = factory(config.agent);
152
+
153
+ const run = async () => {
154
+ if (agent!.promptStream) {
155
+ for await (const event of agent!.promptStream(threadId, { text: messageText })) {
156
+ if (event.type === "text_delta") {
157
+ process.stdout.write(event.text);
158
+ }
159
+ }
160
+ process.stdout.write("\n");
161
+ } else {
162
+ const response = await agent!.prompt(threadId, { text: messageText });
163
+ origLog(response.text);
164
+ }
165
+ };
166
+
167
+ if (timeoutPromise) {
168
+ await Promise.race([run(), timeoutPromise]);
169
+ } else {
170
+ await run();
171
+ }
172
+ } catch (err: any) {
173
+ console.error = origError;
174
+ console.error(`Error: ${err.message}`);
175
+ process.exit(aborted ? 124 : 1);
176
+ } finally {
177
+ if (timer) clearTimeout(timer);
178
+ process.off("SIGINT", handleSignal);
179
+ process.off("SIGTERM", handleSignal);
180
+ console.log = origLog;
181
+ console.warn = origWarn;
182
+ console.error = origError;
183
+ if (!aborted) await agent?.dispose();
184
+ }
185
+ }
186
+
187
+ // ── Entry Point ──────────────────────────────────────
188
+
189
+ /**
190
+ * Main entry for `roundhouse agent` command.
191
+ */
192
+ export async function cmdAgent(): Promise<void> {
193
+ const { options, useStdin } = parseAgentArgs(process.argv.slice(3));
194
+
195
+ if (useStdin) {
196
+ options.messageText = await readStdinWithLimit();
197
+ }
198
+
199
+ if (!options.messageText) {
200
+ console.error("Usage: roundhouse agent <message>");
201
+ console.error(" roundhouse agent --thread <id> <message>");
202
+ console.error(" echo \"message\" | roundhouse agent --stdin");
203
+ console.error(" roundhouse agent --timeout 60 <message>");
204
+ console.error(" roundhouse agent --verbose <message>");
205
+ console.error(" roundhouse agent --ephemeral <message>");
206
+ process.exit(1);
207
+ }
208
+
209
+ await runAgentWithTimeout(options);
210
+ }