@inceptionstack/roundhouse 0.5.2 → 0.5.3
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 +94 -32
- package/package.json +1 -1
- package/src/agents/kiro/kiro-adapter.ts +8 -1
- package/src/agents/pi/message-format.ts +87 -0
- package/src/agents/pi/pi-adapter.ts +9 -72
- package/src/cli/agent-command.ts +210 -0
- package/src/cli/cli.ts +63 -305
- package/src/cli/cron-commands.ts +258 -0
- package/src/cli/cron.ts +26 -267
- package/src/cli/launchd.ts +1 -1
- package/src/cli/service-manager.ts +192 -0
- package/src/cli/setup/args.ts +109 -0
- package/src/cli/setup/flows.ts +273 -0
- package/src/cli/setup/helpers.ts +66 -0
- package/src/cli/setup/index.ts +7 -0
- package/src/cli/setup/runtime.ts +109 -0
- package/src/cli/setup/steps.ts +617 -0
- package/src/cli/setup/types.ts +52 -0
- package/src/cli/setup.ts +79 -1275
- package/src/cli/shell.ts +49 -0
- package/src/cli/systemd.ts +6 -33
- package/src/config.ts +67 -53
- package/src/gateway/attachments.ts +147 -0
- package/src/gateway/commands.ts +371 -0
- package/src/gateway/helpers.ts +104 -0
- package/src/gateway/index.ts +11 -0
- package/src/gateway/streaming.ts +211 -0
- package/src/gateway.ts +212 -763
- package/src/types.ts +14 -0
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
gateway
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
`
|
|
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
|
@@ -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++;
|
|
@@ -283,50 +257,6 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
283
257
|
* Format an AgentMessage into the text string sent to the Pi session.
|
|
284
258
|
* Attachments are rendered as a fenced JSON manifest appended to the user text.
|
|
285
259
|
*/
|
|
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
260
|
const adapter: AgentAdapter = {
|
|
331
261
|
name: "pi",
|
|
332
262
|
|
|
@@ -621,6 +551,13 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
621
551
|
extensions: memoryCapabilities?.extensions ?? [],
|
|
622
552
|
};
|
|
623
553
|
},
|
|
554
|
+
|
|
555
|
+
prepareMessage(_threadId: string, message: AgentMessage, context: MessageContext): AgentMessage {
|
|
556
|
+
if (context.platform === "telegram" && message.text) {
|
|
557
|
+
return { ...message, text: message.text + "\n\n[Format your final answer for Telegram: concise, use markdown sparingly, avoid long code blocks.]" };
|
|
558
|
+
}
|
|
559
|
+
return message;
|
|
560
|
+
},
|
|
624
561
|
};
|
|
625
562
|
|
|
626
563
|
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
|
+
}
|