@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 +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 +33 -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 +235 -0
- package/src/gateway.ts +212 -763
- package/src/types.ts +16 -1
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++;
|
|
@@ -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
|
+
}
|