@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 +2 -1
- package/src/agents/pi.ts +59 -68
- package/src/agents/registry.ts +109 -16
- package/src/cli/cli.ts +42 -82
- package/src/cli/doctor/checks/disk.ts +2 -2
- package/src/cli/qr.ts +24 -0
- package/src/cli/setup-logger.ts +142 -0
- package/src/cli/setup-prompts.ts +78 -0
- package/src/cli/setup-telegram.ts +12 -5
- package/src/cli/setup.ts +446 -71
- package/src/config.ts +3 -2
- package/src/cron/runner.ts +9 -8
- package/src/gateway.ts +179 -39
- package/src/memory/state.ts +3 -15
- package/src/pairing.ts +112 -0
- package/src/util.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inceptionstack/roundhouse",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
* ~/.
|
|
6
|
+
* ~/.roundhouse/sessions/<thread_id>/<session>.jsonl
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { mkdir
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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);
|
package/src/agents/registry.ts
CHANGED
|
@@ -1,33 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agents/registry.ts — Agent adapter registry
|
|
3
3
|
*
|
|
4
|
-
* Maps agent type names to their factory
|
|
5
|
-
*
|
|
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
|
-
|
|
12
|
-
const sdkPackages = new Map<string, string>();
|
|
13
|
+
// ── Types ────────────────────────────────────────────
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
if (!factory) {
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
.filter((
|
|
289
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
+
}
|