@inceptionstack/roundhouse 0.4.3 → 0.4.5

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/README.md CHANGED
@@ -58,7 +58,8 @@ When you run `roundhouse setup`, the following are installed automatically:
58
58
 
59
59
  - **30+ Skills** (agent knowledge): Synced from [loki-skills](https://github.com/inceptionstack/loki-skills) (AWS, infrastructure, DevOps patterns)
60
60
  - **CLI Tools**: `mcporter` (MCP server bridge), `@playwright/cli` (browser automation), `uv`/`uvx` (Python package runner)
61
- - **Extensions** (shipped in npm, auto-discovered by pi): `web-search` (Tavily API integration)
61
+ - **Extensions** (copied to `~/.pi/agent/extensions/` if not present; never overwrites user copies): `web-search` (Tavily)
62
+ - **Extension packages** (registered in `settings.json`): `pi-hard-no` (code review), `pi-branch-enforcer` (branch protection)
62
63
  - **Config**: MCP server definitions copied to `~/.mcporter/mcporter.json`
63
64
 
64
65
  This gives the agent access to:
@@ -177,7 +178,7 @@ Without a config file, defaults are used with env vars (`TELEGRAM_BOT_TOKEN`, `B
177
178
 
178
179
  | Field | Description |
179
180
  |-------|-------------|
180
- | `agent.type` | Agent backend: `"pi"` (more coming) |
181
+ | `agent.type` | Agent backend: `"pi"`, `"kiro"` |
181
182
  | `agent.cwd` | Working directory for the agent |
182
183
  | `agent.sessionDir` | Override session storage path |
183
184
  | `chat.botUsername` | Bot display name for Chat SDK |
@@ -429,24 +430,30 @@ pi install git:github.com/inceptionstack/pi-autoreview
429
430
 
430
431
  ## Adding a new agent backend
431
432
 
432
- 1. Create `src/agents/kiro.ts` implementing `AgentAdapter`
433
- 2. Register in `src/agents/registry.ts`: `registry.set("kiro", createKiroAgentAdapter)`
434
- 3. Set `"agent": { "type": "kiro" }` in config
433
+ 1. Create `src/agents/myagent/myagent-adapter.ts` extending `BaseAdapter`
434
+ 2. Register in `src/agents/registry.ts`
435
+ 3. Set `"agent": { "type": "myagent" }` in config
435
436
 
436
437
  ```typescript
437
- import type { AgentAdapter, AgentAdapterFactory } from "../types";
438
-
439
- export const createKiroAgentAdapter: AgentAdapterFactory = (config) => {
440
- return {
441
- name: "kiro",
442
- async prompt(threadId, message) {
443
- // message.text contains user text
444
- // message.attachments contains saved file metadata
445
- return { text: "response" };
446
- },
447
- async dispose() {},
448
- };
449
- };
438
+ import type { AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../../types.js";
439
+ import { BaseAdapter } from "../base-adapter.js";
440
+
441
+ class MyAgentAdapter extends BaseAdapter {
442
+ readonly name = "myagent";
443
+
444
+ async prompt(threadId: string, message: AgentMessage): Promise<AgentResponse> {
445
+ return { text: "response" };
446
+ }
447
+
448
+ async *promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent> {
449
+ yield { type: "text_delta", text: "response" };
450
+ yield { type: "agent_end" };
451
+ }
452
+
453
+ async dispose(): Promise<void> {}
454
+ }
455
+
456
+ export const createMyAgentAdapter: AgentAdapterFactory = (config) => new MyAgentAdapter();
450
457
  ```
451
458
 
452
459
  ## Adding a new chat platform
@@ -480,10 +487,12 @@ No other changes needed — the gateway's unified handler covers all platforms.
480
487
  | `src/cron/` | Cron scheduler, runner, store, schedule, template, format |
481
488
  | `src/cron/helpers.ts` | Shared cron constants and utilities |
482
489
  | `src/notify/telegram.ts` | Shared Telegram Bot API sender |
483
- | `src/agents/pi.ts` | Pi agent adapter (persistent sessions via pi SDK) |
490
+ | `src/agents/pi/pi-adapter.ts` | Pi agent adapter (persistent sessions via pi SDK) |
491
+ | `src/agents/kiro/kiro-adapter.ts` | Kiro CLI agent adapter (ACP over stdio) |
492
+ | `src/agents/base-adapter.ts` | Abstract base class — adapter interface contract |
484
493
  | `src/agents/registry.ts` | Agent type → factory registry |
485
494
  | `src/config.ts` | Shared config loading, defaults, env overrides |
486
- | `test/` | Unit tests (vitest, 75 passing) |
495
+ | `test/` | Unit + integration tests (vitest, 311 passing) |
487
496
 
488
497
  ## CI/CD
489
498
 
package/architecture.md CHANGED
@@ -143,16 +143,22 @@ interface AttachmentTranscript {
143
143
  }
144
144
 
145
145
  interface AgentAdapter {
146
- name: string;
147
- prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>;
148
- promptStream?(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>;
146
+ readonly name: string;
147
+ prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>; // required
148
+ promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>; // required
149
+ dispose(): Promise<void>; // required
150
+ promptWithModel?(threadId: string, message: AgentMessage, modelId: string): Promise<AgentResponse>;
149
151
  restart?(threadId: string): Promise<void>;
150
152
  compact?(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
153
+ compactWithModel?(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
151
154
  abort?(threadId: string): Promise<void>;
152
155
  getInfo?(threadId?: string): Record<string, unknown>;
153
- dispose(): Promise<void>;
154
156
  }
155
157
 
158
+ // New adapters extend BaseAdapter (src/agents/base-adapter.ts) which
159
+ // provides default implementations for optional methods.
160
+ // Each adapter lives in its own directory: pi/pi-adapter.ts, kiro/kiro-adapter.ts
161
+
156
162
  interface AgentResponse {
157
163
  text: string;
158
164
  metadata?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -48,10 +48,5 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "vitest": "^4.1.5"
51
- },
52
- "pi": {
53
- "extensions": [
54
- "./pi/extensions"
55
- ]
56
51
  }
57
52
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "typebox";
2
+ import { Type } from "@sinclair/typebox";
3
3
 
4
4
  export default function (pi: ExtensionAPI) {
5
5
  pi.registerTool({
@@ -31,8 +31,7 @@ export default function (pi: ExtensionAPI) {
31
31
  const startTime = Date.now();
32
32
  const controller = new AbortController();
33
33
  const timeout = setTimeout(() => controller.abort(), 30_000);
34
- if (signal?.aborted) controller.abort();
35
- else if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
34
+ if (signal) signal.addEventListener("abort", () => controller.abort());
36
35
 
37
36
  let response: Response;
38
37
  try {
@@ -49,10 +48,10 @@ export default function (pi: ExtensionAPI) {
49
48
  });
50
49
  } catch (err: any) {
51
50
  clearTimeout(timeout);
52
- const msg = err.name === "AbortError" ? "Request timed out or was cancelled" : err.message;
51
+ const msg = err.name === "AbortError" ? "Request timed out (30s)" : `Network error: ${err.message}`;
53
52
  return {
54
- content: [{ type: "text", text: `Web search failed: ${msg}` }],
55
- details: { query: params.query, error: msg },
53
+ content: [{ type: "text", text: msg }],
54
+ details: { query: params.query, error: err.name },
56
55
  };
57
56
  } finally {
58
57
  clearTimeout(timeout);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * agents/base-adapter.ts — Abstract base class for agent adapters.
3
+ *
4
+ * Defines the fixed interface contract that every adapter must fulfill.
5
+ * Required methods are abstract; optional capabilities have default
6
+ * implementations that callers can rely on.
7
+ *
8
+ * Each concrete adapter (PiAdapter, KiroAdapter) extends this class
9
+ * and lives in its own directory with no cross-adapter imports.
10
+ */
11
+
12
+ import type { AgentAdapter, AgentMessage, AgentResponse, AgentStreamEvent, AdapterInfo } from "../types.js";
13
+ export type { AdapterInfo } from "../types.js";
14
+
15
+ /**
16
+ * Abstract base class for all agent adapters.
17
+ *
18
+ * Subclasses MUST implement:
19
+ * - name (property)
20
+ * - prompt()
21
+ * - promptStream()
22
+ * - dispose()
23
+ *
24
+ * Subclasses MAY override:
25
+ * - promptWithModel() — defaults to prompt() ignoring model
26
+ * - restart() — defaults to no-op
27
+ * - compact() — defaults to null (not supported)
28
+ * - compactWithModel() — defaults to compact() ignoring model
29
+ * - abort() — defaults to no-op
30
+ * - getInfo() — defaults to empty object
31
+ */
32
+ export abstract class BaseAdapter implements AgentAdapter {
33
+ /** Unique agent type identifier, e.g. "pi", "kiro" */
34
+ abstract readonly name: string;
35
+
36
+ // ── Required: every adapter must implement these ─────
37
+
38
+ /** Send a user message and return the full assistant response. */
39
+ abstract prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>;
40
+
41
+ /** Send a user message and stream back events in real time. */
42
+ abstract promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>;
43
+
44
+ /** Tear down all sessions and release resources. */
45
+ abstract dispose(): Promise<void>;
46
+
47
+ // ── Optional: override for adapter-specific behavior ─
48
+
49
+ /**
50
+ * Send a prompt using a specific model (e.g. Haiku for memory flush).
51
+ * Default: ignores modelId, delegates to prompt().
52
+ */
53
+ async promptWithModel(threadId: string, message: AgentMessage, _modelId: string): Promise<AgentResponse> {
54
+ return this.prompt(threadId, message);
55
+ }
56
+
57
+ /**
58
+ * Dispose the session for a thread and start fresh on next prompt.
59
+ * Default: no-op.
60
+ */
61
+ async restart(_threadId: string): Promise<void> {}
62
+
63
+ /**
64
+ * Compact the session context for a thread.
65
+ * Default: returns null (not supported).
66
+ */
67
+ async compact(_threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Compact with a specific model.
73
+ * Default: ignores modelId, delegates to compact().
74
+ */
75
+ async compactWithModel(threadId: string, _modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
76
+ return this.compact(threadId);
77
+ }
78
+
79
+ /**
80
+ * Abort the current agent run for a thread.
81
+ * Default: no-op.
82
+ */
83
+ async abort(_threadId: string): Promise<void> {}
84
+
85
+ /**
86
+ * Return runtime info about the agent (model, version, context usage, etc.).
87
+ * Default: returns empty object.
88
+ */
89
+ getInfo(_threadId?: string): AdapterInfo {
90
+ return {};
91
+ }
92
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * agents/index.ts — Public API for the agents subsystem.
3
+ */
4
+
5
+ export { BaseAdapter } from "./base-adapter.js";
6
+ export { getAgentDefinition, getAgentFactory, listAvailableAgentTypes, isKnownAgentType, getAgentSdkPackage } from "./registry.js";
7
+ export type { AgentDefinition, AgentPackageRequirement, AgentSetupContext } from "./registry.js";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * acp/client.ts — JSON-RPC stdio transport for kiro-cli ACP
3
+ *
4
+ * Handles request/response correlation and notification dispatch.
5
+ * Does NOT manage process lifecycle — that's acp/process.ts.
6
+ */
7
+
8
+ import { EventEmitter } from "node:events";
9
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
10
+
11
+ export interface PendingRequest {
12
+ resolve: (value: unknown) => void;
13
+ reject: (error: unknown) => void;
14
+ timer: ReturnType<typeof setTimeout>;
15
+ }
16
+
17
+ export class AcpClient extends EventEmitter {
18
+ private buf = "";
19
+ private pending = new Map<number, PendingRequest>();
20
+ private nextId = 1;
21
+ private closed = false;
22
+
23
+ constructor(private proc: ChildProcessWithoutNullStreams, private requestTimeoutMs = 60_000) {
24
+ super();
25
+ this.proc.stdout.on("data", (chunk: Buffer) => this.onData(chunk));
26
+ this.proc.on("exit", (code) => {
27
+ this.closed = true;
28
+ this.rejectAll(new Error(`kiro-cli exited with code ${code}`));
29
+ this.emit("exit", code);
30
+ });
31
+ }
32
+
33
+ /** Send a JSON-RPC request and await its response. */
34
+ async call<T = unknown>(method: string, params?: unknown): Promise<T> {
35
+ if (this.closed) throw new Error("ACP client is closed");
36
+
37
+ const id = this.nextId++;
38
+ const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
39
+ this.proc.stdin.write(payload + "\n");
40
+
41
+ return new Promise<T>((resolve, reject) => {
42
+ const timer = setTimeout(() => {
43
+ this.pending.delete(id);
44
+ reject(new Error(`ACP call "${method}" timed out after ${this.requestTimeoutMs}ms`));
45
+ }, this.requestTimeoutMs);
46
+
47
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
48
+ });
49
+ }
50
+
51
+ /** Send a JSON-RPC notification (no response expected). */
52
+ notify(method: string, params?: unknown): void {
53
+ if (this.closed) return;
54
+ const payload = JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} });
55
+ this.proc.stdin.write(payload + "\n");
56
+ }
57
+
58
+ /** Gracefully close — reject pending requests. */
59
+ close(): void {
60
+ this.closed = true;
61
+ this.rejectAll(new Error("ACP client closed"));
62
+ }
63
+
64
+ get isClosed(): boolean {
65
+ return this.closed;
66
+ }
67
+
68
+ // ── Private ──────────────────────────────────────────
69
+
70
+ private onData(chunk: Buffer): void {
71
+ this.buf += chunk.toString("utf8");
72
+ let idx: number;
73
+ while ((idx = this.buf.indexOf("\n")) >= 0) {
74
+ const line = this.buf.slice(0, idx).trim();
75
+ this.buf = this.buf.slice(idx + 1);
76
+ if (!line) continue;
77
+ this.parseLine(line);
78
+ }
79
+ }
80
+
81
+ private parseLine(line: string): void {
82
+ let msg: Record<string, unknown>;
83
+ try {
84
+ msg = JSON.parse(line);
85
+ } catch (e) {
86
+ this.emit("parse_error", e, line);
87
+ return;
88
+ }
89
+
90
+ // Response to a pending request
91
+ if ("id" in msg && typeof msg.id === "number" && this.pending.has(msg.id)) {
92
+ const { resolve, reject, timer } = this.pending.get(msg.id)!;
93
+ clearTimeout(timer);
94
+ this.pending.delete(msg.id);
95
+ if (msg.error) reject(msg.error);
96
+ else resolve(msg.result);
97
+ return;
98
+ }
99
+
100
+ // Notification from kiro-cli
101
+ if ("method" in msg && typeof msg.method === "string") {
102
+ this.emit(msg.method, msg.params);
103
+ this.emit("notification", msg.method, msg.params);
104
+ return;
105
+ }
106
+
107
+ this.emit("unknown_message", msg);
108
+ }
109
+
110
+ private rejectAll(error: Error): void {
111
+ for (const [id, { reject, timer }] of this.pending) {
112
+ clearTimeout(timer);
113
+ reject(error);
114
+ }
115
+ this.pending.clear();
116
+ }
117
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * kiro/acp/index.ts — Barrel export for ACP module
3
+ */
4
+
5
+ export { AcpClient } from "./client.js";
6
+ export { spawnKiroCli, shutdownProcess, getKiroCliVersion } from "./process.js";
7
+ export type { AcpProcess, SpawnOptions } from "./process.js";
8
+ export type * from "./types.js";
@@ -0,0 +1,111 @@
1
+ /**
2
+ * acp/process.ts — kiro-cli process lifecycle management
3
+ *
4
+ * Handles spawning, stderr capture, orphan guards, and graceful shutdown.
5
+ * One process per agent config (main or flush).
6
+ */
7
+
8
+ import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from "node:child_process";
9
+ import { AcpClient } from "./client.js";
10
+
11
+ export interface SpawnOptions {
12
+ agentName: string;
13
+ cwd: string;
14
+ env?: Record<string, string>;
15
+ /** Max stderr buffer in bytes (default 1MB) */
16
+ maxStderrBytes?: number;
17
+ }
18
+
19
+ export interface AcpProcess {
20
+ client: AcpClient;
21
+ proc: ChildProcessWithoutNullStreams;
22
+ stderr: string[];
23
+ kill(signal?: NodeJS.Signals): void;
24
+ killGroup(): void;
25
+ }
26
+
27
+ /**
28
+ * Spawn kiro-cli in ACP mode and return the wrapped process + client.
29
+ * Throws if the binary is not found.
30
+ */
31
+ export function spawnKiroCli(opts: SpawnOptions): AcpProcess {
32
+ const { agentName, cwd, env, maxStderrBytes = 1_048_576 } = opts;
33
+
34
+ const proc = spawn("kiro-cli", ["chat", "--agent", agentName, "--acp"], {
35
+ cwd,
36
+ env: { ...process.env, ...env },
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ detached: true, // own process group for clean kill
39
+ });
40
+
41
+ // Handle spawn failures (e.g. ENOENT if kiro-cli not on PATH)
42
+ proc.on("error", (err) => {
43
+ console.error(`[kiro] failed to spawn kiro-cli: ${err.message}`);
44
+ // Emit exit so AcpClient rejects pending requests immediately
45
+ proc.emit("exit", 1);
46
+ });
47
+
48
+ // Buffer stderr for diagnostics (capped)
49
+ const stderr: string[] = [];
50
+ let stderrBytes = 0;
51
+ proc.stderr.on("data", (chunk: Buffer) => {
52
+ const str = chunk.toString("utf8");
53
+ stderrBytes += chunk.length;
54
+ if (stderrBytes <= maxStderrBytes) {
55
+ stderr.push(str);
56
+ }
57
+ });
58
+
59
+ const client = new AcpClient(proc);
60
+
61
+ return {
62
+ client,
63
+ proc,
64
+ stderr,
65
+ kill(signal: NodeJS.Signals = "SIGTERM") {
66
+ try { proc.kill(signal); } catch {}
67
+ },
68
+ killGroup() {
69
+ if (proc.pid) {
70
+ try { process.kill(-proc.pid, "SIGKILL"); } catch {}
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Gracefully shutdown a kiro-cli process:
78
+ * SIGTERM → wait up to `gracePeriodMs` → SIGKILL the process group.
79
+ */
80
+ export async function shutdownProcess(acpProc: AcpProcess, gracePeriodMs = 5_000): Promise<void> {
81
+ acpProc.client.close();
82
+ acpProc.kill("SIGTERM");
83
+
84
+ await new Promise<void>((resolve) => {
85
+ const timer = setTimeout(() => {
86
+ acpProc.killGroup();
87
+ resolve();
88
+ }, gracePeriodMs);
89
+
90
+ acpProc.proc.on("exit", () => {
91
+ clearTimeout(timer);
92
+ resolve();
93
+ });
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Check if kiro-cli is available on PATH.
99
+ * Returns the version string or null if not found.
100
+ */
101
+ export function getKiroCliVersion(): string | null {
102
+ try {
103
+ const output = execFileSync("kiro-cli", ["--version"], {
104
+ encoding: "utf8",
105
+ timeout: 5_000,
106
+ }).trim();
107
+ return output || null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * acp/types.ts — ACP (Agent Control Protocol) type definitions
3
+ *
4
+ * Discriminated union of events received from kiro-cli over JSON-RPC stdio.
5
+ */
6
+
7
+ // ── Stop reasons ─────────────────────────────────────
8
+
9
+ export type StopReason = "end_turn" | "cancelled" | "max_turns" | "error";
10
+
11
+ // ── ACP Events (notifications from kiro-cli) ─────────
12
+
13
+ export interface AcpTextChunk {
14
+ type: "text_chunk";
15
+ text: string;
16
+ }
17
+
18
+ export interface AcpThinkingChunk {
19
+ type: "thinking_chunk";
20
+ text: string;
21
+ }
22
+
23
+ export interface AcpToolCall {
24
+ type: "tool_call";
25
+ tool_call_id: string;
26
+ title: string;
27
+ tool_name: string;
28
+ tool_input: Record<string, unknown>;
29
+ tool_kind?: string;
30
+ }
31
+
32
+ export interface AcpToolResult {
33
+ type: "tool_result";
34
+ tool_call_id: string;
35
+ output: string;
36
+ exit_code: number;
37
+ }
38
+
39
+ export interface AcpPermissionRequest {
40
+ type: "permission_request";
41
+ tool_call_id: string;
42
+ tool_name: string;
43
+ tool_input: Record<string, unknown>;
44
+ title?: string;
45
+ }
46
+
47
+ export interface AcpComplete {
48
+ type: "complete";
49
+ stop_reason: StopReason;
50
+ error?: string;
51
+ }
52
+
53
+ export interface AcpSessionUpdate {
54
+ type: "session/update";
55
+ context_tokens?: number;
56
+ context_window?: number;
57
+ model?: string;
58
+ }
59
+
60
+ export type AcpEvent =
61
+ | AcpTextChunk
62
+ | AcpThinkingChunk
63
+ | AcpToolCall
64
+ | AcpToolResult
65
+ | AcpPermissionRequest
66
+ | AcpComplete
67
+ | AcpSessionUpdate;
68
+
69
+ // ── ACP method results ───────────────────────────────
70
+
71
+ export interface InitializeResult {
72
+ protocolVersion: string;
73
+ capabilities: Record<string, unknown>;
74
+ }
75
+
76
+ export interface SessionNewResult {
77
+ sessionId: string;
78
+ }
79
+
80
+ export interface SessionLoadResult {
81
+ sessionId: string;
82
+ restored: boolean;
83
+ }