@inceptionstack/roundhouse 0.3.17 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "chat": "^4.26.0",
43
43
  "croner": "^10.0.1",
44
44
  "p-queue": "^9.2.0",
45
+ "qrcode-terminal": "^0.12.0",
45
46
  "tsx": "^4.0.0"
46
47
  },
47
48
  "devDependencies": {
package/src/agents/pi.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Wraps pi's SDK (createAgentSession) as an AgentAdapter.
5
5
  * One persistent session per thread, stored at:
6
- * ~/.pi/agent/gateway-sessions/<thread_id>/<session>.jsonl
6
+ * ~/.roundhouse/sessions/<thread_id>/<session>.jsonl
7
7
  */
8
8
 
9
- import { mkdir, stat } from "node:fs/promises";
9
+ import { mkdir } from "node:fs/promises";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join, dirname } from "node:path";
12
12
  import { homedir } from "node:os";
@@ -24,19 +24,16 @@ import {
24
24
  } from "@mariozechner/pi-coding-agent";
25
25
 
26
26
  import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
27
- import { DEBUG_STREAM, threadIdToDir, threadIdToDirLegacy } from "../util";
27
+ import { SESSIONS_DIR } from "../config";
28
+ import { DEBUG_STREAM, threadIdToDir } from "../util";
28
29
 
29
30
  interface SessionEntry {
30
31
  session: AgentSession;
31
32
  lastUsed: number;
33
+ inFlight: number;
32
34
  }
33
35
 
34
- const DEFAULT_SESSIONS_DIR = join(
35
- homedir(),
36
- ".pi",
37
- "agent",
38
- "gateway-sessions"
39
- );
36
+ const DEFAULT_SESSIONS_DIR = SESSIONS_DIR;
40
37
  const DEFAULT_MAX_IDLE_MS = 30 * 60 * 1000;
41
38
 
42
39
  export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
@@ -107,72 +104,65 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
107
104
  }
108
105
 
109
106
  async function runPromptAndFollowUps(entry: SessionEntry, text: string, onDraining?: () => void, onDrainComplete?: () => void): Promise<void> {
110
- await entry.session.prompt(text);
111
- await drainSessionEvents(entry.session);
112
-
113
- // Check for pending follow-up work AFTER drainSessionEvents — that's
114
- // where agent_end extension handlers run and queue follow-up messages
115
- // (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
116
- // The actual long wait is in the while loop's waitForIdle() below.
117
- let notifiedDraining = false;
118
- if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
119
- onDraining();
120
- notifiedDraining = true;
121
- }
107
+ entry.inFlight++;
108
+ try {
109
+ await entry.session.prompt(text);
110
+ await drainSessionEvents(entry.session);
122
111
 
123
- // Loop until the session is fully idle. Two separate conditions can keep
124
- // work in flight after the initial prompt resolves:
125
- //
126
- // (a) `hasQueuedMessages()` an extension called `pi.sendMessage(...,
127
- // { triggerTurn: true, deliverAs: "followUp" })` *while isStreaming
128
- // was true* pi queued onto `agent.followUp()`, so we manually
129
- // drain it with `continue()`.
130
- //
131
- // (b) `isStreaming === true` — an extension called the same sendMessage
132
- // *after isStreaming became false*. In that path pi's
133
- // `sendCustomMessage` skips the queue entirely and calls
134
- // `agent.prompt(appMessage)` directly as fire-and-forget, kicking
135
- // off a brand-new agent run. `hasQueuedMessages()` returns false
136
- // for this run, but `isStreaming` is true — we have to
137
- // `waitForIdle()` so subscribers see the new run's events (e.g. the
138
- // agent's reply to a pi-lgtm code review) before we unsubscribe.
139
- //
140
- // Without (b), pi CLI works (its subscriber stays attached across runs)
141
- // but roundhouse delivers the review bubble then goes silent.
142
- while (true) {
143
- if (entry.session.isStreaming) {
144
- await entry.session.agent.waitForIdle();
145
- await drainSessionEvents(entry.session);
146
- continue;
112
+ // Check for pending follow-up work AFTER drainSessionEvents that's
113
+ // where agent_end extension handlers run and queue follow-up messages
114
+ // (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
115
+ // The actual long wait is in the while loop's waitForIdle() below.
116
+ let notifiedDraining = false;
117
+ if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
118
+ onDraining();
119
+ notifiedDraining = true;
147
120
  }
148
- if (entry.session.agent.hasQueuedMessages()) {
149
- await entry.session.agent.continue();
150
- await drainSessionEvents(entry.session);
151
- continue;
121
+
122
+ // Loop until the session is fully idle. Two separate conditions can keep
123
+ // work in flight after the initial prompt resolves:
124
+ //
125
+ // (a) `hasQueuedMessages()` — an extension called `pi.sendMessage(...,
126
+ // { triggerTurn: true, deliverAs: "followUp" })` *while isStreaming
127
+ // was true* — pi queued onto `agent.followUp()`, so we manually
128
+ // drain it with `continue()`.
129
+ //
130
+ // (b) `isStreaming === true` — an extension called the same sendMessage
131
+ // *after isStreaming became false*. In that path pi's
132
+ // `sendCustomMessage` skips the queue entirely and calls
133
+ // `agent.prompt(appMessage)` directly as fire-and-forget, kicking
134
+ // off a brand-new agent run. `hasQueuedMessages()` returns false
135
+ // for this run, but `isStreaming` is true — we have to
136
+ // `waitForIdle()` so subscribers see the new run's events (e.g. the
137
+ // agent's reply to a pi-lgtm code review) before we unsubscribe.
138
+ //
139
+ // Without (b), pi CLI works (its subscriber stays attached across runs)
140
+ // but roundhouse delivers the review bubble then goes silent.
141
+ while (true) {
142
+ if (entry.session.isStreaming) {
143
+ await entry.session.agent.waitForIdle();
144
+ await drainSessionEvents(entry.session);
145
+ continue;
146
+ }
147
+ if (entry.session.agent.hasQueuedMessages()) {
148
+ await entry.session.agent.continue();
149
+ await drainSessionEvents(entry.session);
150
+ continue;
151
+ }
152
+ break;
152
153
  }
153
- break;
154
- }
155
154
 
156
- if (notifiedDraining && onDrainComplete) {
157
- onDrainComplete();
155
+ if (notifiedDraining && onDrainComplete) {
156
+ onDrainComplete();
157
+ }
158
+ } finally {
159
+ entry.inFlight--;
160
+ entry.lastUsed = Date.now();
158
161
  }
159
162
  }
160
163
 
161
164
  async function createSession(threadId: string): Promise<SessionEntry> {
162
- let dirName = threadIdToDir(threadId);
163
- const newPath = join(sessionsDir, dirName);
164
- const legacyPath = join(sessionsDir, threadIdToDirLegacy(threadId));
165
- // Migrate: if legacy dir exists but new doesn't, use legacy
166
- if (dirName !== threadIdToDirLegacy(threadId)) {
167
- try {
168
- await stat(legacyPath);
169
- // Legacy exists — check if new exists
170
- try { await stat(newPath); } catch {
171
- // New doesn't exist, use legacy to preserve session history
172
- dirName = threadIdToDirLegacy(threadId);
173
- }
174
- } catch { /* legacy doesn't exist either, use new */ }
175
- }
165
+ const dirName = threadIdToDir(threadId);
176
166
  const threadDir = join(sessionsDir, dirName);
177
167
  await mkdir(threadDir, { recursive: true });
178
168
 
@@ -214,7 +204,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
214
204
  console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
215
205
  }
216
206
 
217
- const entry: SessionEntry = { session: result.session, lastUsed: Date.now() };
207
+ const entry: SessionEntry = { session: result.session, lastUsed: Date.now(), inFlight: 0 };
218
208
  sessions.set(threadId, entry);
219
209
 
220
210
  // Detect memory capabilities from loaded extensions (first session only)
@@ -260,6 +250,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
260
250
  function reap() {
261
251
  const now = Date.now();
262
252
  for (const [id, entry] of sessions) {
253
+ if (entry.inFlight > 0) continue; // skip busy sessions
263
254
  if (now - entry.lastUsed > maxIdleMs) {
264
255
  entry.session.dispose();
265
256
  sessions.delete(id);
@@ -1,33 +1,126 @@
1
1
  /**
2
2
  * agents/registry.ts — Agent adapter registry
3
3
  *
4
- * Maps agent type names to their factory functions.
5
- * Add new agents here.
4
+ * Maps agent type names to their definitions including factory, install
5
+ * requirements, config defaults, and doctor checks.
6
6
  */
7
7
 
8
8
  import type { AgentAdapterFactory } from "../types";
9
9
  import { createPiAgentAdapter } from "./pi";
10
+ import { homedir } from "node:os";
11
+ import { resolve } from "node:path";
10
12
 
11
- const registry = new Map<string, AgentAdapterFactory>();
12
- const sdkPackages = new Map<string, string>();
13
+ // ── Types ────────────────────────────────────────────
13
14
 
14
- registry.set("pi", createPiAgentAdapter);
15
- sdkPackages.set("pi", "@mariozechner/pi-coding-agent");
16
- // registry.set("kiro", createKiroAgentAdapter);
17
- // sdkPackages.set("kiro", "@kiro/...");
15
+ export interface AgentPackageRequirement {
16
+ /** Human-readable label (defaults to packageName) */
17
+ name?: string;
18
+ /** npm package to install */
19
+ packageName: string;
20
+ /** Install scope */
21
+ install: "global" | "local";
22
+ /** Executable that proves the package is installed */
23
+ binary?: string;
24
+ }
25
+
26
+ export interface AgentSetupContext {
27
+ provider: string;
28
+ model: string;
29
+ cwd: string;
30
+ force: boolean;
31
+ psst: boolean;
32
+ extensions: string[];
33
+ }
34
+
35
+ export interface AgentDefinition {
36
+ /** Stable config/CLI type, e.g. "pi" */
37
+ type: string;
38
+ /** Display name, e.g. "Pi" */
39
+ name: string;
40
+ /** Runtime adapter factory */
41
+ factory?: AgentAdapterFactory;
42
+ /** Can users select this today? */
43
+ available: boolean;
44
+ /** Packages setup should install */
45
+ packages: AgentPackageRequirement[];
46
+ /** Package used for version display */
47
+ sdkPackage?: string;
48
+ /** Default config merged into gatewayConfig.agent */
49
+ configDefaults: Record<string, unknown>;
50
+ /** Dirs to create during preflight */
51
+ configDirs?: string[];
52
+ /** Agent-specific config writer */
53
+ configure?: (ctx: AgentSetupContext) => Promise<void>;
54
+ /** Agent-specific extension installer */
55
+ installExtension?: (ext: string) => Promise<void>;
56
+ /** Agent-specific doctor checks (future: loaded dynamically by doctor runner) */
57
+ doctorChecks?: unknown[];
58
+ }
59
+
60
+ // ── Pi Definition ────────────────────────────────────
61
+
62
+
63
+ const piDefinition: AgentDefinition = {
64
+ type: "pi",
65
+ name: "Pi",
66
+ factory: createPiAgentAdapter,
67
+ available: true,
68
+ packages: [
69
+ {
70
+ name: "Pi coding agent",
71
+ packageName: "@mariozechner/pi-coding-agent",
72
+ install: "global",
73
+ binary: "pi",
74
+ },
75
+ ],
76
+ sdkPackage: "@mariozechner/pi-coding-agent",
77
+ configDefaults: {},
78
+ configDirs: [resolve(homedir(), ".pi", "agent")],
79
+ // configure and installExtension are set by setup.ts since they need
80
+ // setup-specific helpers (execOrFail, atomicWriteJson, etc.)
81
+ };
82
+
83
+ // ── Registry ─────────────────────────────────────────
84
+
85
+ const definitions = new Map<string, AgentDefinition>();
86
+ definitions.set("pi", piDefinition);
87
+
88
+ // Future:
89
+ // definitions.set("kiro", kiroDefinition);
90
+
91
+ // ── Public API ───────────────────────────────────────
92
+
93
+ export function getAgentDefinition(type: string): AgentDefinition {
94
+ const def = definitions.get(type);
95
+ if (!def) {
96
+ const available = listAvailableAgentTypes().join(", ");
97
+ throw new Error(`Unknown agent type "${type}". Available: ${available}`);
98
+ }
99
+ if (!def.available) {
100
+ throw new Error(`Agent type "${type}" is not yet available.`);
101
+ }
102
+ return def;
103
+ }
104
+
105
+ export function listAvailableAgentTypes(): string[] {
106
+ return [...definitions.values()].filter(d => d.available).map(d => d.type);
107
+ }
108
+
109
+ /** Check if an agent type is registered (for future plugin validation) */
110
+ export function isKnownAgentType(type: string): boolean {
111
+ return definitions.has(type);
112
+ }
18
113
 
114
+ /** Get the runtime adapter factory for an agent type */
19
115
  export function getAgentFactory(type: string): AgentAdapterFactory {
20
- const factory = registry.get(type);
21
- if (!factory) {
22
- const available = [...registry.keys()].join(", ");
23
- throw new Error(
24
- `Unknown agent type "${type}". Available: ${available}`
25
- );
116
+ const def = getAgentDefinition(type);
117
+ if (!def.factory) {
118
+ throw new Error(`Agent type "${type}" has no runtime adapter.`);
26
119
  }
27
- return factory;
120
+ return def.factory;
28
121
  }
29
122
 
30
123
  /** Get the npm package name for an agent type's SDK (for version display) */
31
124
  export function getAgentSdkPackage(type: string): string | undefined {
32
- return sdkPackages.get(type);
125
+ return definitions.get(type)?.sdkPackage;
33
126
  }
package/src/cli/cli.ts CHANGED
@@ -5,10 +5,9 @@
5
5
  */
6
6
 
7
7
  import { resolve, dirname } from "node:path";
8
- import { homedir } from "node:os";
9
8
  import { readFile, writeFile, mkdir } from "node:fs/promises";
10
9
  import { readdirSync, statSync } from "node:fs";
11
- import { execSync, spawn } from "node:child_process";
10
+ import { execSync, execFileSync, spawn } from "node:child_process";
12
11
  import { fileURLToPath } from "node:url";
13
12
 
14
13
  import {
@@ -16,12 +15,14 @@ import {
16
15
  CONFIG_PATH,
17
16
  ENV_FILE_PATH,
18
17
  DEFAULT_CONFIG,
18
+ SESSIONS_DIR,
19
19
  SERVICE_NAME,
20
20
  fileExists,
21
21
  loadConfig,
22
22
  resolveEnvFilePath,
23
23
  } from "../config";
24
24
  import { getAgentSdkPackage } from "../agents/registry";
25
+ import { threadIdToDir } from "../util";
25
26
  import { parseEnvFile, serializeEnvFile, envQuote } from "./env-file";
26
27
  import {
27
28
  SERVICE_PATH,
@@ -40,6 +41,11 @@ const __dirname = dirname(__filename);
40
41
 
41
42
  // ── Shell helpers ───────────────────────────────────
42
43
 
44
+ /**
45
+ * Shell helper — WARNING: passes `cmd` through the system shell.
46
+ * Only call with trusted/hardcoded strings. Any dynamic segments must be
47
+ * validated (e.g. `/^\d+$/.test(pid)`) before interpolation.
48
+ */
43
49
  function run(cmd: string, opts?: { silent?: boolean }): string {
44
50
  try {
45
51
  return execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" }).trim();
@@ -79,10 +85,10 @@ async function cmdRun() {
79
85
  await import(jsPath);
80
86
  } else {
81
87
  const tsxPath = resolve(__dirname, "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
82
- execSync(
83
- `node ${tsxPath} ${indexPath}`,
84
- { stdio: "inherit", env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH } },
85
- );
88
+ execFileSync(process.execPath, [tsxPath, indexPath], {
89
+ stdio: "inherit",
90
+ env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH },
91
+ });
86
92
  }
87
93
  }
88
94
 
@@ -279,87 +285,30 @@ async function cmdTui() {
279
285
  process.exit(1);
280
286
  }
281
287
 
282
- const sessionsBase = (config.agent as any)?.sessionDir
283
- ?? resolve(homedir(), ".pi", "agent", "gateway-sessions");
284
-
285
- let threadDirs: string[] = [];
288
+ const threadArg = process.argv[3];
289
+ const threadId = threadArg || "main";
290
+ const threadDir = threadIdToDir(threadId);
291
+ const threadPath = resolve(SESSIONS_DIR, threadDir);
292
+ let candidates: Array<{ sessionFile: string; mtime: number }> = [];
286
293
  try {
287
- threadDirs = readdirSync(sessionsBase)
288
- .filter((d) => { try { return statSync(resolve(sessionsBase, d)).isDirectory(); } catch { return false; } })
289
- .sort();
294
+ candidates = readdirSync(threadPath)
295
+ .filter((f) => f.endsWith(".jsonl"))
296
+ .map((f) => {
297
+ const sessionFile = resolve(threadPath, f);
298
+ return { sessionFile, mtime: statSync(sessionFile).mtimeMs };
299
+ })
300
+ .sort((a, b) => b.mtime - a.mtime);
290
301
  } catch {
291
- console.error(`No gateway sessions found at ${sessionsBase}`);
302
+ console.error(`No session directory found at ${threadPath}.`);
292
303
  process.exit(1);
293
304
  }
294
305
 
295
- if (threadDirs.length === 0) {
296
- console.error("No gateway sessions found. Send a message via Telegram/Slack first.");
297
- process.exit(1);
298
- }
299
-
300
- const threadArg = process.argv[3];
301
-
302
- interface SessionCandidate { threadDir: string; sessionFile: string; mtime: number; }
303
-
304
- const candidates: SessionCandidate[] = [];
305
- for (const dir of threadDirs) {
306
- if (threadArg && !dir.includes(threadArg)) continue;
307
- const threadPath = resolve(sessionsBase, dir);
308
- try {
309
- for (const f of readdirSync(threadPath).filter((f) => f.endsWith(".jsonl"))) {
310
- const fullPath = resolve(threadPath, f);
311
- candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: statSync(fullPath).mtimeMs });
312
- }
313
- } catch {}
314
- }
315
-
316
306
  if (candidates.length === 0) {
317
- if (threadArg) {
318
- console.error(`No sessions found matching "${threadArg}".`);
319
- console.log("Available threads:");
320
- for (const d of threadDirs) console.log(` ${d}`);
321
- } else {
322
- console.error("No session files found.");
323
- }
307
+ console.error(`No session files found at ${threadPath}.`);
324
308
  process.exit(1);
325
309
  }
326
310
 
327
- candidates.sort((a, b) => b.mtime - a.mtime);
328
-
329
- let selected: SessionCandidate;
330
- const uniqueThreads = [...new Set(candidates.map((c) => c.threadDir))];
331
-
332
- if (uniqueThreads.length === 1 || threadArg) {
333
- selected = candidates[0];
334
- } else {
335
- console.log("Available sessions (most recent first):\n");
336
- const shown: SessionCandidate[] = [];
337
- const seen = new Set<string>();
338
- for (const c of candidates) {
339
- if (seen.has(c.threadDir)) continue;
340
- seen.add(c.threadDir);
341
- shown.push(c);
342
- }
343
- for (let i = 0; i < shown.length; i++) {
344
- const age = Math.round((Date.now() - shown[i].mtime) / 60000);
345
- const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
346
- console.log(` [${i + 1}] ${shown[i].threadDir} (${ageStr})`);
347
- }
348
- console.log();
349
-
350
- const readline = await import("node:readline");
351
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
352
- const answer = await new Promise<string>((r) => {
353
- rl.question("Pick a session [1]: ", (ans) => { rl.close(); r(ans.trim() || "1"); });
354
- });
355
-
356
- const idx = parseInt(answer, 10) - 1;
357
- if (isNaN(idx) || idx < 0 || idx >= shown.length) {
358
- console.error("Invalid selection.");
359
- process.exit(1);
360
- }
361
- selected = candidates.find((c) => c.threadDir === shown[idx].threadDir)!;
362
- }
311
+ const selected = candidates[0];
363
312
 
364
313
  console.log(`\nOpening: ${selected.sessionFile}\n`);
365
314
 
@@ -394,7 +343,7 @@ Commands:
394
343
  config Show config path and contents
395
344
  agent <message> Send a message to the agent and print response
396
345
  Options: --thread <id>, --stdin, --timeout <sec>,
397
- --no-timeout, --verbose
346
+ --no-timeout, --verbose, --ephemeral
398
347
  doctor [--fix] Check system health and configuration
399
348
  Options: --fix, --json, --verbose
400
349
  cron <command> Manage scheduled jobs (add, list, trigger, etc.)
@@ -414,6 +363,7 @@ Environment:
414
363
  async function cmdAgent() {
415
364
  // Usage: roundhouse agent <message>
416
365
  // roundhouse agent --thread <id> <message>
366
+ // roundhouse agent --ephemeral <message>
417
367
  // echo "message" | roundhouse agent --stdin
418
368
  const args = process.argv.slice(3);
419
369
  let threadId = "";
@@ -421,6 +371,7 @@ async function cmdAgent() {
421
371
  let useStdin = false;
422
372
  let timeoutMs = 120_000;
423
373
  let verbose = false;
374
+ let ephemeral = false;
424
375
 
425
376
  for (let i = 0; i < args.length; i++) {
426
377
  if (args[i] === "--thread" && args[i + 1]) {
@@ -435,6 +386,8 @@ async function cmdAgent() {
435
386
  timeoutMs = 0;
436
387
  } else if (args[i] === "--verbose") {
437
388
  verbose = true;
389
+ } else if (args[i] === "--ephemeral") {
390
+ ephemeral = true;
438
391
  } else if (args[i].startsWith("-")) {
439
392
  console.error(`Unknown flag: ${args[i]}`);
440
393
  process.exit(1);
@@ -468,13 +421,20 @@ async function cmdAgent() {
468
421
  console.error(" echo \"message\" | roundhouse agent --stdin");
469
422
  console.error(" roundhouse agent --timeout 60 <message>");
470
423
  console.error(" roundhouse agent --verbose <message>");
424
+ console.error(" roundhouse agent --ephemeral <message>");
425
+ process.exit(1);
426
+ }
427
+
428
+ if (threadId && ephemeral) {
429
+ console.error("--thread and --ephemeral cannot be used together");
471
430
  process.exit(1);
472
431
  }
473
432
 
474
- // Default: ephemeral thread ID (no session persistence across invocations)
475
- // --thread <id> opts into persistent/shared sessions
433
+ // Default: shared main session. --ephemeral restores one-off CLI behavior.
476
434
  if (!threadId) {
477
- threadId = `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
435
+ threadId = ephemeral
436
+ ? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
437
+ : "main";
478
438
  }
479
439
 
480
440
  // Suppress debug/info logs unless --verbose
@@ -2,15 +2,15 @@
2
2
  * Disk and directory checks
3
3
  */
4
4
 
5
- import { access, stat, readdir, constants } from "node:fs/promises";
5
+ import { access, readdir, constants } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { mkdirSync } from "node:fs";
9
9
  import type { DoctorCheck } from "../types";
10
10
  import { run } from "../shell";
11
+ import { SESSIONS_DIR } from "../../../config";
11
12
 
12
13
  const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR ?? join(homedir(), ".roundhouse", "incoming");
13
- const SESSIONS_DIR = join(homedir(), ".pi", "agent", "gateway-sessions");
14
14
 
15
15
  export const diskChecks: DoctorCheck[] = [
16
16
  {
package/src/cli/qr.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * cli/qr.ts — Terminal QR code rendering.
3
+ * Wraps qrcode-terminal with graceful fallback.
4
+ */
5
+
6
+ export type QrMode = "auto" | "always" | "never";
7
+
8
+ /**
9
+ * Print a QR code to stdout if conditions allow.
10
+ * Falls back silently if the terminal can't render it.
11
+ */
12
+ export function printQr(url: string, mode: QrMode = "auto"): void {
13
+ if (mode === "never") return;
14
+ if (mode === "auto" && !process.stdout.isTTY) return;
15
+
16
+ try {
17
+ // Dynamic import to keep it optional
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const qrcode = require("qrcode-terminal") as { generate: (text: string, opts: { small: boolean }, cb?: (code: string) => void) => void };
20
+ qrcode.generate(url, { small: true });
21
+ } catch {
22
+ // Package not available — skip silently
23
+ }
24
+ }