@inceptionstack/roundhouse 0.3.18 → 0.3.20

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.18",
3
+ "version": "0.3.20",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
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);
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/config.ts CHANGED
@@ -22,6 +22,7 @@ export const LEGACY_CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
22
22
  export const CONFIG_DIR = ROUNDHOUSE_DIR;
23
23
  export const CONFIG_PATH = resolve(ROUNDHOUSE_DIR, "gateway.config.json");
24
24
  export const ENV_FILE_PATH = resolve(ROUNDHOUSE_DIR, ".env");
25
+ export const SESSIONS_DIR = resolve(ROUNDHOUSE_DIR, "sessions");
25
26
 
26
27
  /** Legacy env file name (deprecated) */
27
28
  export const LEGACY_ENV_FILE_PATH = resolve(ROUNDHOUSE_DIR, "env");
@@ -181,11 +182,11 @@ export async function loadConfig(): Promise<GatewayConfig> {
181
182
  console.error(`[roundhouse] failed to parse config at ${resolved.path}: ${err.message}`);
182
183
  process.exit(1);
183
184
  }
184
- // Try cwd
185
+ // Try cwd (with security warning)
185
186
  try {
186
187
  const cwdPath = resolve(process.cwd(), "gateway.config.json");
187
188
  const raw = await readFile(cwdPath, "utf8");
188
- console.log("[roundhouse] loaded gateway.config.json from cwd");
189
+ console.warn(`[roundhouse] ⚠️ loaded gateway.config.json from cwd (${cwdPath}) — consider using ~/.roundhouse/gateway.config.json instead`);
189
190
  config = JSON.parse(raw) as GatewayConfig;
190
191
  } catch (cwdErr: any) {
191
192
  if (cwdErr.code !== "ENOENT") {
@@ -31,19 +31,20 @@ export class CronRunner {
31
31
  const threadId = buildCronThreadId(job.id, runId);
32
32
  const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33
33
 
34
- // Render prompt
35
- const tz = job.schedule.type === "cron" ? job.schedule.tz : job.schedule.type === "once" ? job.schedule.tz : undefined;
36
- const ctx = buildTemplateContext(job.id, job.description, runId, scheduledAt, startedAt, tz, process.cwd(), job.vars ?? {});
37
- const prompt = renderTemplate(job.prompt, ctx) + CRON_PROMPT_SUFFIX;
38
-
39
- console.log(`[cron] starting ${job.id} [${runId}] kind=${kind}`);
40
-
41
- // Create fresh agent — use provided config or load dynamically for CLI trigger
34
+ // Load agent config before rendering prompt (template needs agentCfg.cwd)
42
35
  let agentCfg = this.agentConfig;
43
36
  if (!agentCfg) {
44
37
  const { loadConfig } = await import("../config");
45
38
  agentCfg = (await loadConfig()).agent;
46
39
  }
40
+
41
+ // Render prompt
42
+ const tz = job.schedule.type === "cron" ? job.schedule.tz : job.schedule.type === "once" ? job.schedule.tz : undefined;
43
+ const agentCwd = (agentCfg.cwd as string) ?? process.cwd();
44
+ const ctx = buildTemplateContext(job.id, job.description, runId, scheduledAt, startedAt, tz, agentCwd, job.vars ?? {});
45
+ const prompt = renderTemplate(job.prompt, ctx) + CRON_PROMPT_SUFFIX;
46
+
47
+ console.log(`[cron] starting ${job.id} [${runId}] kind=${kind}`);
47
48
  const { type, ...rest } = agentCfg;
48
49
  const factory = getAgentFactory(type);
49
50
  const agent = factory({ ...rest, sessionDir: undefined });
package/src/gateway.ts CHANGED
@@ -68,6 +68,33 @@ const ROUNDHOUSE_VERSION: string = (() => {
68
68
  catch { return "unknown"; }
69
69
  })();
70
70
 
71
+ function telegramChatIdFromThreadId(threadId: unknown): number | null {
72
+ if (typeof threadId !== "string") return null;
73
+ const match = threadId.match(/^telegram:(-?\d+)/);
74
+ if (!match) return null;
75
+ const parsed = parseInt(match[1], 10);
76
+ return Number.isNaN(parsed) ? null : parsed;
77
+ }
78
+
79
+ function getChatId(thread: any, message: any): string {
80
+ const id = message?.chat?.id ?? message?.chatId ?? thread?.chatId;
81
+ if (id !== undefined && id !== null) return String(id);
82
+ return String(thread?.id ?? "unknown");
83
+ }
84
+
85
+ function resolveAgentThreadId(thread: any, message: any): string {
86
+ const chatType = String(message?.chat?.type ?? thread?.chat?.type ?? thread?.type ?? "").toLowerCase();
87
+ if (["private", "dm", "direct", "im"].includes(chatType)) return "main";
88
+ if (["group", "supergroup", "channel"].includes(chatType)) return `group:${getChatId(thread, message)}`;
89
+
90
+ const telegramChatId = telegramChatIdFromThreadId(thread?.id);
91
+ if (telegramChatId !== null) {
92
+ return telegramChatId < 0 ? `group:${telegramChatId}` : "main";
93
+ }
94
+
95
+ return String(thread?.id ?? "main");
96
+ }
97
+
71
98
  // ── Chat SDK adapter factories ───────────────────────
72
99
  // Lazy-imported so we don't crash if an adapter package isn't installed.
73
100
 
@@ -156,7 +183,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
156
183
  // Per-message directory: <thread>/<timestamp_nonce>/
157
184
  const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
158
185
  try {
159
- mkdirSync(msgDir, { recursive: true });
186
+ mkdirSync(msgDir, { recursive: true, mode: 0o700 });
160
187
  } catch (err) {
161
188
  console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
162
189
  return { saved: [], skipped: ["Failed to create storage directory"] };
@@ -215,7 +242,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
215
242
  const fileName = `${i}-${rawName}`;
216
243
  const filePath = join(msgDir, fileName);
217
244
 
218
- await writeFile(filePath, buf);
245
+ await writeFile(filePath, buf, { mode: 0o600 });
219
246
 
220
247
  const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
221
248
  const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
@@ -279,9 +306,13 @@ export class Gateway {
279
306
  : typeof thread.id === "string" && thread.id.startsWith("telegram:")
280
307
  ? parseInt(thread.id.split(":")[1], 10)
281
308
  : undefined;
282
- const userId = typeof message.author?.id === "string"
283
- ? parseInt(message.author.id, 10)
284
- : undefined;
309
+ // Chat SDK Telegram adapter provides userId (not id)
310
+ const rawUserId = message.author?.userId ?? message.author?.id ?? message.raw?.from?.id;
311
+ const userId = typeof rawUserId === "number"
312
+ ? rawUserId
313
+ : typeof rawUserId === "string"
314
+ ? parseInt(rawUserId, 10)
315
+ : undefined;
285
316
 
286
317
  if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
287
318
  console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
@@ -372,6 +403,13 @@ export class Gateway {
372
403
  if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
373
404
  const allowedUserIds = this.config.chat.allowedUserIds;
374
405
 
406
+ // SECURITY: Warn (loudly) when no auth allowlist is configured
407
+ if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
408
+ console.warn("\n⚠️ WARNING: No allowedUsers or allowedUserIds configured!");
409
+ console.warn(" Any Telegram user who finds this bot can interact with the agent.");
410
+ console.warn(" Run: roundhouse setup --telegram --user <your-username>\n");
411
+ }
412
+
375
413
  // Per-thread verbose toggle (shows tool_start messages)
376
414
  const verboseThreads = new Set<string>();
377
415
 
@@ -383,12 +421,13 @@ export class Gateway {
383
421
 
384
422
  // ── Unified handler ────────────────────────────
385
423
  const handle = async (thread: any, message: any) => {
424
+ const agentThreadId = resolveAgentThreadId(thread, message);
386
425
  const userText = message.text ?? "";
387
426
  const authorName = message.author?.userName ?? message.author?.userId ?? "?";
388
427
  const rawAttachments = message.attachments ?? [];
389
428
 
390
429
  console.log(
391
- `[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
430
+ `[roundhouse] ${thread.id} -> ${agentThreadId} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
392
431
  );
393
432
 
394
433
  // Check for pending Telegram pairing (headless setup)
@@ -407,14 +446,14 @@ export class Gateway {
407
446
 
408
447
  // Handle /new command — dispose current session, start fresh
409
448
  if (isCommand(userText.trim(), "/new")) {
410
- const agent = this.router.resolve(thread.id);
449
+ const agent = this.router.resolve(agentThreadId);
411
450
  if (agent.restart) {
412
- await agent.restart(thread.id);
451
+ await agent.restart(agentThreadId);
413
452
  await thread.post("🔄 Session restarted. Send a message to begin a new conversation.");
414
453
  } else {
415
454
  await thread.post("⚠️ New session not supported for this agent.");
416
455
  }
417
- console.log(`[roundhouse] /new for thread=${thread.id}`);
456
+ console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
418
457
  return;
419
458
  }
420
459
 
@@ -437,13 +476,22 @@ export class Gateway {
437
476
  }
438
477
 
439
478
  // Handle /compact command — flush memory then compact session context
479
+ // Routed through the per-thread lock to prevent concurrent agent access
440
480
  if (isCommand(userText.trim(), "/compact")) {
441
- const agent = this.router.resolve(thread.id);
481
+ const agent = this.router.resolve(agentThreadId);
442
482
  if (!agent.compact) {
443
483
  await thread.post("⚠️ Compaction not supported for this agent.");
444
484
  return;
445
485
  }
446
- console.log(`[roundhouse] /compact for thread=${thread.id}`);
486
+ console.log(`[roundhouse] /compact for thread=${thread.id} agentThread=${agentThreadId}`);
487
+
488
+ // Acquire per-thread lock (same as normal prompts)
489
+ const prevLock = threadLocks.get(agentThreadId);
490
+ let releaseLock: () => void;
491
+ const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
492
+ threadLocks.set(agentThreadId, lockPromise);
493
+ if (prevLock) await prevLock;
494
+
447
495
  await thread.post("📝 Saving memory and compacting...");
448
496
  const stopTyping = startTypingLoop(thread);
449
497
  try {
@@ -451,7 +499,7 @@ export class Gateway {
451
499
  const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
452
500
  // If memory is disabled, compact directly without flush
453
501
  if (this.config.memory?.enabled === false) {
454
- const result = await agent.compact(thread.id);
502
+ const result = await agent.compact(agentThreadId);
455
503
  if (!result) {
456
504
  await thread.post("⚠️ No active session to compact. Send a message first.");
457
505
  } else {
@@ -459,7 +507,7 @@ export class Gateway {
459
507
  await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
460
508
  }
461
509
  } else {
462
- const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, "manual", this.config.memory);
510
+ const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory);
463
511
  if (!result) {
464
512
  await thread.post("⚠️ No active session to compact. Send a message first.");
465
513
  } else {
@@ -472,13 +520,17 @@ export class Gateway {
472
520
  await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
473
521
  } finally {
474
522
  stopTyping();
523
+ releaseLock!();
524
+ if (threadLocks.get(agentThreadId) === lockPromise) {
525
+ threadLocks.delete(agentThreadId);
526
+ }
475
527
  }
476
528
  return;
477
529
  }
478
530
 
479
531
  // Handle /status command — show gateway details
480
532
  if (isCommand(userText.trim(), "/status")) {
481
- const agent = this.router.resolve(thread.id);
533
+ const agent = this.router.resolve(agentThreadId);
482
534
  const uptimeSec = process.uptime();
483
535
  const uptimeStr = uptimeSec < 3600
484
536
  ? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
@@ -488,7 +540,7 @@ export class Gateway {
488
540
  const nodeVer = process.version;
489
541
  const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
490
542
 
491
- const info = agent.getInfo ? agent.getInfo(thread.id) : {};
543
+ const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
492
544
  const agentVersion = info.version ? `v${info.version}` : "";
493
545
  const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
494
546
 
@@ -509,7 +561,7 @@ export class Gateway {
509
561
  `💾 Memory: ${memMB} MB`,
510
562
  `🟢 Node: ${nodeVer}`,
511
563
  `🔧 Debug stream: ${debugStream ? "on" : "off"}`,
512
- `📢 Verbose: ${verboseThreads.has(thread.id) ? "on" : "off"}`,
564
+ `📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
513
565
  );
514
566
 
515
567
  const allowedCount = allowedUsers.length;
@@ -551,14 +603,14 @@ export class Gateway {
551
603
  }
552
604
 
553
605
  await thread.post({ markdown: lines.join("\n") });
554
- console.log(`[roundhouse] /status for thread=${thread.id}`);
606
+ console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
555
607
  return;
556
608
  }
557
609
 
558
610
  // Save any attachments (voice messages, images, files, etc.)
559
611
  let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
560
612
  try {
561
- attachmentResult = await saveAttachments(thread.id, rawAttachments);
613
+ attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
562
614
  } catch (err) {
563
615
  console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
564
616
  if (!userText.trim()) {
@@ -588,16 +640,16 @@ export class Gateway {
588
640
  return;
589
641
  }
590
642
 
591
- const agent = this.router.resolve(thread.id);
643
+ const agent = this.router.resolve(agentThreadId);
592
644
 
593
645
  // Serialize prompts per-thread (concurrent mode allows /stop to bypass)
594
- const prevLock = threadLocks.get(thread.id);
646
+ const prevLock = threadLocks.get(agentThreadId);
595
647
  let releaseLock: () => void;
596
648
  const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
597
- threadLocks.set(thread.id, lockPromise);
649
+ threadLocks.set(agentThreadId, lockPromise);
598
650
  if (prevLock) await prevLock;
599
651
 
600
- console.log(`[roundhouse] → ${agent.name} | thread=${thread.id}`);
652
+ console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
601
653
 
602
654
  // Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
603
655
  if (this.sttService && agentMessage.attachments?.length) {
@@ -624,7 +676,7 @@ export class Gateway {
624
676
  const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
625
677
  let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
626
678
  try {
627
- memoryPrepared = await prepareMemoryForTurn(thread.id, agentMessage, agent, memoryRoot, this.config.memory);
679
+ memoryPrepared = await prepareMemoryForTurn(agentThreadId, agentMessage, agent, memoryRoot, this.config.memory);
628
680
  agentMessage = memoryPrepared.message;
629
681
  } catch (err) {
630
682
  console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
@@ -635,15 +687,15 @@ export class Gateway {
635
687
  try {
636
688
  if (agent.promptStream) {
637
689
  const ac = new AbortController();
638
- abortControllers.set(thread.id, ac);
690
+ abortControllers.set(agentThreadId, ac);
639
691
  try {
640
- await this.handleStreaming(thread, agent.promptStream(thread.id, agentMessage), verboseThreads.has(thread.id), ac.signal);
692
+ await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
641
693
  } finally {
642
- abortControllers.delete(thread.id);
694
+ abortControllers.delete(agentThreadId);
643
695
  }
644
696
  } else {
645
697
  // Fallback: non-streaming prompt
646
- const reply = await agent.prompt(thread.id, agentMessage);
698
+ const reply = await agent.prompt(agentThreadId, agentMessage);
647
699
  if (reply.text) {
648
700
  await this.postWithFallback(thread, reply.text);
649
701
  }
@@ -652,7 +704,7 @@ export class Gateway {
652
704
  // ── Memory: post-turn finalize + pressure check ───
653
705
  try {
654
706
  const pressure = await finalizeMemoryForTurn(
655
- thread.id,
707
+ agentThreadId,
656
708
  memoryPrepared?.beforeDigest ?? null,
657
709
  agent, memoryRoot, this.config.memory,
658
710
  );
@@ -661,7 +713,7 @@ export class Gateway {
661
713
  if (effectivePressure !== "none") {
662
714
  // Run flush/compact INSIDE the thread lock to prevent race with next user message
663
715
  try {
664
- await this.handleContextPressure(thread, agent, memoryRoot, effectivePressure);
716
+ await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
665
717
  } catch (err) {
666
718
  console.error(`[roundhouse] context pressure handler error:`, (err as Error).message);
667
719
  }
@@ -679,34 +731,35 @@ export class Gateway {
679
731
  } finally {
680
732
  stopTyping();
681
733
  releaseLock!();
682
- if (threadLocks.get(thread.id) === lockPromise) {
683
- threadLocks.delete(thread.id);
734
+ if (threadLocks.get(agentThreadId) === lockPromise) {
735
+ threadLocks.delete(agentThreadId);
684
736
  }
685
737
  }
686
738
  };
687
739
 
688
740
  // ── Wire Chat SDK events ───────────────────────
689
741
  const handleOrAbort = async (thread: any, message: any) => {
742
+ const agentThreadId = resolveAgentThreadId(thread, message);
690
743
  const text = (message.text ?? "").trim();
691
744
  // /stop is handled immediately — abort the in-flight agent run
692
745
  // without waiting for the current handler to finish
693
746
  if (isCommand(text, "/stop")) {
694
747
  if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
695
- const agent = this.router.resolve(thread.id);
748
+ const agent = this.router.resolve(agentThreadId);
696
749
  if (agent.abort) {
697
- await agent.abort(thread.id);
698
- abortControllers.get(thread.id)?.abort();
750
+ await agent.abort(agentThreadId);
751
+ abortControllers.get(agentThreadId)?.abort();
699
752
  try { await thread.post("⏹️ Stopped."); } catch {}
700
753
  } else {
701
754
  try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
702
755
  }
703
- console.log(`[roundhouse] /stop for thread=${thread.id}`);
756
+ console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
704
757
  return;
705
758
  }
706
759
  // /verbose is a gateway toggle — runs immediately, no queuing
707
760
  if (isCommand(text, "/verbose")) {
708
761
  if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
709
- const threadId = thread.id;
762
+ const threadId = agentThreadId;
710
763
  if (verboseThreads.has(threadId)) {
711
764
  verboseThreads.delete(threadId);
712
765
  try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
@@ -714,7 +767,7 @@ export class Gateway {
714
767
  verboseThreads.add(threadId);
715
768
  try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
716
769
  }
717
- console.log(`[roundhouse] /verbose for thread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
770
+ console.log(`[roundhouse] /verbose for thread=${thread.id} agentThread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
718
771
  return;
719
772
  }
720
773
  // /doctor runs health checks immediately — no agent access needed
@@ -834,16 +887,16 @@ export class Gateway {
834
887
  * Handle context pressure — flush memory and/or compact.
835
888
  * Runs inside the thread lock after a turn completes.
836
889
  */
837
- private async handleContextPressure(thread: any, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
890
+ private async handleContextPressure(thread: any, agentThreadId: string, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
838
891
  if (pressure === "none") return;
839
892
 
840
- console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id}`);
893
+ console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
841
894
 
842
895
  if (pressure === "soft") {
843
896
  // Soft: prompt agent to save facts, no compact
844
897
  // Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
845
898
  try {
846
- await flushMemoryThenCompact(thread.id, agent, memoryRoot, "soft", this.config.memory);
899
+ await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
847
900
  } catch (err) {
848
901
  console.error(`[roundhouse] soft flush error:`, (err as Error).message);
849
902
  }
@@ -853,7 +906,7 @@ export class Gateway {
853
906
  // Hard or emergency: flush + compact
854
907
  try {
855
908
  await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
856
- const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, pressure, this.config.memory);
909
+ const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory);
857
910
  if (result) {
858
911
  const beforeK = (result.tokensBefore / 1000).toFixed(1);
859
912
  await thread.post(`✅ Auto-compacted: ${beforeK}K tokens → summary.`);
@@ -9,7 +9,7 @@ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
9
9
  import { resolve, dirname } from "node:path";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { ROUNDHOUSE_DIR } from "../config";
12
- import { threadIdToDir, threadIdToDirLegacy } from "../util";
12
+ import { threadIdToDir } from "../util";
13
13
  import type { ThreadMemoryState } from "./types";
14
14
 
15
15
  const STATE_DIR = resolve(ROUNDHOUSE_DIR, "memory-state");
@@ -18,24 +18,12 @@ function stateFilePath(threadId: string): string {
18
18
  return resolve(STATE_DIR, `${threadIdToDir(threadId)}.json`);
19
19
  }
20
20
 
21
- function legacyStateFilePath(threadId: string): string {
22
- return resolve(STATE_DIR, `${threadIdToDirLegacy(threadId)}.json`);
23
- }
24
-
25
21
  /** Load per-thread memory state (returns empty state if none exists) */
26
22
  export async function loadThreadMemoryState(threadId: string): Promise<ThreadMemoryState> {
27
23
  try {
28
24
  const raw = await readFile(stateFilePath(threadId), "utf8");
29
25
  return JSON.parse(raw) as ThreadMemoryState;
30
26
  } catch {
31
- // Fallback to legacy encoding for pre-v0.4 state files
32
- try {
33
- const legacyPath = legacyStateFilePath(threadId);
34
- if (legacyPath !== stateFilePath(threadId)) {
35
- const raw = await readFile(legacyPath, "utf8");
36
- return JSON.parse(raw) as ThreadMemoryState;
37
- }
38
- } catch {}
39
27
  return {};
40
28
  }
41
29
  }
@@ -43,10 +31,10 @@ export async function loadThreadMemoryState(threadId: string): Promise<ThreadMem
43
31
  /** Save per-thread memory state (atomic write to prevent corruption) */
44
32
  export async function saveThreadMemoryState(threadId: string, state: ThreadMemoryState): Promise<void> {
45
33
  const path = stateFilePath(threadId);
46
- await mkdir(dirname(path), { recursive: true });
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
47
35
  const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
48
36
  try {
49
- await writeFile(tmp, JSON.stringify(state, null, 2) + "\n");
37
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
50
38
  await rename(tmp, path);
51
39
  } catch (err) {
52
40
  try { await unlink(tmp); } catch {}
package/src/util.ts CHANGED
@@ -105,14 +105,6 @@ export function threadIdToDir(threadId: string): string {
105
105
  .replace(/[^a-zA-Z0-9_-]/g, (ch) => `_x${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
106
106
  }
107
107
 
108
- /** Legacy encoding (v0.3.x) for migration fallback */
109
- export function threadIdToDirLegacy(threadId: string): string {
110
- return threadId
111
- .replace(/_/g, "_u")
112
- .replace(/:/g, "_c")
113
- .replace(/[^a-zA-Z0-9_-]/g, "_x");
114
- }
115
-
116
108
  /**
117
109
  * Generate a short random attachment ID (e.g. "att_a1b2c3d4").
118
110
  */