@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/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
@@ -20,6 +20,7 @@ import { BOT_COMMANDS } from "./commands";
20
20
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
21
21
  import { maxPressure } from "./memory/policy";
22
22
  import type { PressureLevel } from "./memory/types";
23
+ import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
23
24
 
24
25
  /** Match a Telegram command, handling optional @botname suffix */
25
26
  /** Bot username for command suffix validation (set during gateway init) */
@@ -67,6 +68,33 @@ const ROUNDHOUSE_VERSION: string = (() => {
67
68
  catch { return "unknown"; }
68
69
  })();
69
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
+
70
98
  // ── Chat SDK adapter factories ───────────────────────
71
99
  // Lazy-imported so we don't crash if an adapter package isn't installed.
72
100
 
@@ -155,7 +183,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
155
183
  // Per-message directory: <thread>/<timestamp_nonce>/
156
184
  const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
157
185
  try {
158
- mkdirSync(msgDir, { recursive: true });
186
+ mkdirSync(msgDir, { recursive: true, mode: 0o700 });
159
187
  } catch (err) {
160
188
  console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
161
189
  return { saved: [], skipped: ["Failed to create storage directory"] };
@@ -214,7 +242,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
214
242
  const fileName = `${i}-${rawName}`;
215
243
  const filePath = join(msgDir, fileName);
216
244
 
217
- await writeFile(filePath, buf);
245
+ await writeFile(filePath, buf, { mode: 0o600 });
218
246
 
219
247
  const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
220
248
  const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
@@ -242,6 +270,7 @@ export class Gateway {
242
270
  private chat!: Chat;
243
271
  private router: AgentRouter;
244
272
  private config: GatewayConfig;
273
+ private pairingComplete = false;
245
274
  private sttService: SttService | null = null;
246
275
  private cronScheduler: CronSchedulerService | null = null;
247
276
 
@@ -251,6 +280,86 @@ export class Gateway {
251
280
  _botUsername = config.chat.botUsername || "";
252
281
  }
253
282
 
283
+ /** Handle pending Telegram pairing from headless setup. Returns true if handled. */
284
+ private async handlePendingPairing(
285
+ text: string,
286
+ message: any,
287
+ thread: any,
288
+ authorName: string,
289
+ ): Promise<boolean> {
290
+ try {
291
+ const pending = await readPendingPairing();
292
+ if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
293
+ return false;
294
+ }
295
+
296
+ const fromUser = authorName.toLowerCase();
297
+ const allowed = pending.allowedUsers.map(u => u.toLowerCase());
298
+ if (!fromUser || !allowed.includes(fromUser)) {
299
+ console.log(`[roundhouse] Pairing nonce from unauthorized user @${authorName}`);
300
+ return false;
301
+ }
302
+
303
+ // Extract IDs from the chat adapter message
304
+ const chatId = typeof message.chatId === "number"
305
+ ? message.chatId
306
+ : typeof thread.id === "string" && thread.id.startsWith("telegram:")
307
+ ? parseInt(thread.id.split(":")[1], 10)
308
+ : 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;
316
+
317
+ if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
318
+ console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
319
+ await thread.post("⚠️ Pairing nonce accepted but could not capture your Telegram IDs. Try sending /start again, or run: roundhouse pair");
320
+ return true;
321
+ }
322
+
323
+ await completePendingPairing({ chatId, userId, username: authorName });
324
+
325
+ // Update in-memory config
326
+ if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
327
+ if (!this.config.chat.allowedUserIds.includes(userId)) {
328
+ this.config.chat.allowedUserIds.push(userId);
329
+ }
330
+ if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
331
+ if (!this.config.chat.notifyChatIds.includes(chatId)) {
332
+ this.config.chat.notifyChatIds.push(chatId);
333
+ }
334
+
335
+ // Atomic config file update
336
+ try {
337
+ const { readFile: rf, rename: mvf, writeFile: wf, unlink: ulf } = await import("node:fs/promises");
338
+ const { randomBytes: rb } = await import("node:crypto");
339
+ const cfgPath = join(ROUNDHOUSE_DIR, "gateway.config.json");
340
+ const configRaw = JSON.parse(await rf(cfgPath, "utf8"));
341
+ if (!configRaw.chat) configRaw.chat = {};
342
+ if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = [];
343
+ if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId);
344
+ if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = [];
345
+ if (!configRaw.chat.notifyChatIds.includes(chatId)) configRaw.chat.notifyChatIds.push(chatId);
346
+ const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`;
347
+ await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n");
348
+ await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; });
349
+ } catch (cfgErr) {
350
+ console.error("[roundhouse] failed to update config after pairing:", cfgErr);
351
+ }
352
+
353
+ console.log(`[roundhouse] Telegram pairing complete: @${authorName} chatId=${chatId} userId=${userId}`);
354
+ this.pairingComplete = true;
355
+ await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working.");
356
+ return true;
357
+ } catch (err) {
358
+ console.error("[roundhouse] error checking pending pairing:", err);
359
+ return false;
360
+ }
361
+ }
362
+
254
363
  async start() {
255
364
  const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
256
365
 
@@ -289,7 +398,17 @@ export class Gateway {
289
398
  const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
290
399
  u.toLowerCase()
291
400
  );
292
- const allowedUserIds = this.config.chat.allowedUserIds ?? [];
401
+ // Ensure arrays exist on config so pairing hook mutations are visible to isAllowed
402
+ if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
403
+ if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
404
+ const allowedUserIds = this.config.chat.allowedUserIds;
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
+ }
293
412
 
294
413
  // Per-thread verbose toggle (shows tool_start messages)
295
414
  const verboseThreads = new Set<string>();
@@ -302,14 +421,21 @@ export class Gateway {
302
421
 
303
422
  // ── Unified handler ────────────────────────────
304
423
  const handle = async (thread: any, message: any) => {
424
+ const agentThreadId = resolveAgentThreadId(thread, message);
305
425
  const userText = message.text ?? "";
306
426
  const authorName = message.author?.userName ?? message.author?.userId ?? "?";
307
427
  const rawAttachments = message.attachments ?? [];
308
428
 
309
429
  console.log(
310
- `[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)` : ""}`
311
431
  );
312
432
 
433
+ // Check for pending Telegram pairing (headless setup)
434
+ if (userText.trim().startsWith("/start ") && !this.pairingComplete) {
435
+ const handled = await this.handlePendingPairing(userText.trim(), message, thread, authorName ?? "");
436
+ if (handled) return;
437
+ }
438
+
313
439
  if (!isAllowed(message, allowedUsers, allowedUserIds)) {
314
440
  console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
315
441
  return;
@@ -320,14 +446,14 @@ export class Gateway {
320
446
 
321
447
  // Handle /new command — dispose current session, start fresh
322
448
  if (isCommand(userText.trim(), "/new")) {
323
- const agent = this.router.resolve(thread.id);
449
+ const agent = this.router.resolve(agentThreadId);
324
450
  if (agent.restart) {
325
- await agent.restart(thread.id);
451
+ await agent.restart(agentThreadId);
326
452
  await thread.post("🔄 Session restarted. Send a message to begin a new conversation.");
327
453
  } else {
328
454
  await thread.post("⚠️ New session not supported for this agent.");
329
455
  }
330
- console.log(`[roundhouse] /new for thread=${thread.id}`);
456
+ console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
331
457
  return;
332
458
  }
333
459
 
@@ -350,13 +476,22 @@ export class Gateway {
350
476
  }
351
477
 
352
478
  // Handle /compact command — flush memory then compact session context
479
+ // Routed through the per-thread lock to prevent concurrent agent access
353
480
  if (isCommand(userText.trim(), "/compact")) {
354
- const agent = this.router.resolve(thread.id);
481
+ const agent = this.router.resolve(agentThreadId);
355
482
  if (!agent.compact) {
356
483
  await thread.post("⚠️ Compaction not supported for this agent.");
357
484
  return;
358
485
  }
359
- 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
+
360
495
  await thread.post("📝 Saving memory and compacting...");
361
496
  const stopTyping = startTypingLoop(thread);
362
497
  try {
@@ -364,7 +499,7 @@ export class Gateway {
364
499
  const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
365
500
  // If memory is disabled, compact directly without flush
366
501
  if (this.config.memory?.enabled === false) {
367
- const result = await agent.compact(thread.id);
502
+ const result = await agent.compact(agentThreadId);
368
503
  if (!result) {
369
504
  await thread.post("⚠️ No active session to compact. Send a message first.");
370
505
  } else {
@@ -372,7 +507,7 @@ export class Gateway {
372
507
  await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
373
508
  }
374
509
  } else {
375
- const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, "manual", this.config.memory);
510
+ const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory);
376
511
  if (!result) {
377
512
  await thread.post("⚠️ No active session to compact. Send a message first.");
378
513
  } else {
@@ -385,13 +520,17 @@ export class Gateway {
385
520
  await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
386
521
  } finally {
387
522
  stopTyping();
523
+ releaseLock!();
524
+ if (threadLocks.get(agentThreadId) === lockPromise) {
525
+ threadLocks.delete(agentThreadId);
526
+ }
388
527
  }
389
528
  return;
390
529
  }
391
530
 
392
531
  // Handle /status command — show gateway details
393
532
  if (isCommand(userText.trim(), "/status")) {
394
- const agent = this.router.resolve(thread.id);
533
+ const agent = this.router.resolve(agentThreadId);
395
534
  const uptimeSec = process.uptime();
396
535
  const uptimeStr = uptimeSec < 3600
397
536
  ? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
@@ -401,7 +540,7 @@ export class Gateway {
401
540
  const nodeVer = process.version;
402
541
  const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
403
542
 
404
- const info = agent.getInfo ? agent.getInfo(thread.id) : {};
543
+ const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
405
544
  const agentVersion = info.version ? `v${info.version}` : "";
406
545
  const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
407
546
 
@@ -422,7 +561,7 @@ export class Gateway {
422
561
  `💾 Memory: ${memMB} MB`,
423
562
  `🟢 Node: ${nodeVer}`,
424
563
  `🔧 Debug stream: ${debugStream ? "on" : "off"}`,
425
- `📢 Verbose: ${verboseThreads.has(thread.id) ? "on" : "off"}`,
564
+ `📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
426
565
  );
427
566
 
428
567
  const allowedCount = allowedUsers.length;
@@ -464,14 +603,14 @@ export class Gateway {
464
603
  }
465
604
 
466
605
  await thread.post({ markdown: lines.join("\n") });
467
- console.log(`[roundhouse] /status for thread=${thread.id}`);
606
+ console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
468
607
  return;
469
608
  }
470
609
 
471
610
  // Save any attachments (voice messages, images, files, etc.)
472
611
  let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
473
612
  try {
474
- attachmentResult = await saveAttachments(thread.id, rawAttachments);
613
+ attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
475
614
  } catch (err) {
476
615
  console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
477
616
  if (!userText.trim()) {
@@ -501,16 +640,16 @@ export class Gateway {
501
640
  return;
502
641
  }
503
642
 
504
- const agent = this.router.resolve(thread.id);
643
+ const agent = this.router.resolve(agentThreadId);
505
644
 
506
645
  // Serialize prompts per-thread (concurrent mode allows /stop to bypass)
507
- const prevLock = threadLocks.get(thread.id);
646
+ const prevLock = threadLocks.get(agentThreadId);
508
647
  let releaseLock: () => void;
509
648
  const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
510
- threadLocks.set(thread.id, lockPromise);
649
+ threadLocks.set(agentThreadId, lockPromise);
511
650
  if (prevLock) await prevLock;
512
651
 
513
- console.log(`[roundhouse] → ${agent.name} | thread=${thread.id}`);
652
+ console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
514
653
 
515
654
  // Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
516
655
  if (this.sttService && agentMessage.attachments?.length) {
@@ -537,7 +676,7 @@ export class Gateway {
537
676
  const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
538
677
  let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
539
678
  try {
540
- memoryPrepared = await prepareMemoryForTurn(thread.id, agentMessage, agent, memoryRoot, this.config.memory);
679
+ memoryPrepared = await prepareMemoryForTurn(agentThreadId, agentMessage, agent, memoryRoot, this.config.memory);
541
680
  agentMessage = memoryPrepared.message;
542
681
  } catch (err) {
543
682
  console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
@@ -548,15 +687,15 @@ export class Gateway {
548
687
  try {
549
688
  if (agent.promptStream) {
550
689
  const ac = new AbortController();
551
- abortControllers.set(thread.id, ac);
690
+ abortControllers.set(agentThreadId, ac);
552
691
  try {
553
- 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);
554
693
  } finally {
555
- abortControllers.delete(thread.id);
694
+ abortControllers.delete(agentThreadId);
556
695
  }
557
696
  } else {
558
697
  // Fallback: non-streaming prompt
559
- const reply = await agent.prompt(thread.id, agentMessage);
698
+ const reply = await agent.prompt(agentThreadId, agentMessage);
560
699
  if (reply.text) {
561
700
  await this.postWithFallback(thread, reply.text);
562
701
  }
@@ -565,7 +704,7 @@ export class Gateway {
565
704
  // ── Memory: post-turn finalize + pressure check ───
566
705
  try {
567
706
  const pressure = await finalizeMemoryForTurn(
568
- thread.id,
707
+ agentThreadId,
569
708
  memoryPrepared?.beforeDigest ?? null,
570
709
  agent, memoryRoot, this.config.memory,
571
710
  );
@@ -574,7 +713,7 @@ export class Gateway {
574
713
  if (effectivePressure !== "none") {
575
714
  // Run flush/compact INSIDE the thread lock to prevent race with next user message
576
715
  try {
577
- await this.handleContextPressure(thread, agent, memoryRoot, effectivePressure);
716
+ await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
578
717
  } catch (err) {
579
718
  console.error(`[roundhouse] context pressure handler error:`, (err as Error).message);
580
719
  }
@@ -592,34 +731,35 @@ export class Gateway {
592
731
  } finally {
593
732
  stopTyping();
594
733
  releaseLock!();
595
- if (threadLocks.get(thread.id) === lockPromise) {
596
- threadLocks.delete(thread.id);
734
+ if (threadLocks.get(agentThreadId) === lockPromise) {
735
+ threadLocks.delete(agentThreadId);
597
736
  }
598
737
  }
599
738
  };
600
739
 
601
740
  // ── Wire Chat SDK events ───────────────────────
602
741
  const handleOrAbort = async (thread: any, message: any) => {
742
+ const agentThreadId = resolveAgentThreadId(thread, message);
603
743
  const text = (message.text ?? "").trim();
604
744
  // /stop is handled immediately — abort the in-flight agent run
605
745
  // without waiting for the current handler to finish
606
746
  if (isCommand(text, "/stop")) {
607
747
  if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
608
- const agent = this.router.resolve(thread.id);
748
+ const agent = this.router.resolve(agentThreadId);
609
749
  if (agent.abort) {
610
- await agent.abort(thread.id);
611
- abortControllers.get(thread.id)?.abort();
750
+ await agent.abort(agentThreadId);
751
+ abortControllers.get(agentThreadId)?.abort();
612
752
  try { await thread.post("⏹️ Stopped."); } catch {}
613
753
  } else {
614
754
  try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
615
755
  }
616
- console.log(`[roundhouse] /stop for thread=${thread.id}`);
756
+ console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
617
757
  return;
618
758
  }
619
759
  // /verbose is a gateway toggle — runs immediately, no queuing
620
760
  if (isCommand(text, "/verbose")) {
621
761
  if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
622
- const threadId = thread.id;
762
+ const threadId = agentThreadId;
623
763
  if (verboseThreads.has(threadId)) {
624
764
  verboseThreads.delete(threadId);
625
765
  try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
@@ -627,7 +767,7 @@ export class Gateway {
627
767
  verboseThreads.add(threadId);
628
768
  try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
629
769
  }
630
- 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"}`);
631
771
  return;
632
772
  }
633
773
  // /doctor runs health checks immediately — no agent access needed
@@ -747,16 +887,16 @@ export class Gateway {
747
887
  * Handle context pressure — flush memory and/or compact.
748
888
  * Runs inside the thread lock after a turn completes.
749
889
  */
750
- 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) {
751
891
  if (pressure === "none") return;
752
892
 
753
- console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id}`);
893
+ console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
754
894
 
755
895
  if (pressure === "soft") {
756
896
  // Soft: prompt agent to save facts, no compact
757
897
  // Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
758
898
  try {
759
- await flushMemoryThenCompact(thread.id, agent, memoryRoot, "soft", this.config.memory);
899
+ await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
760
900
  } catch (err) {
761
901
  console.error(`[roundhouse] soft flush error:`, (err as Error).message);
762
902
  }
@@ -766,7 +906,7 @@ export class Gateway {
766
906
  // Hard or emergency: flush + compact
767
907
  try {
768
908
  await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
769
- const result = await flushMemoryThenCompact(thread.id, agent, memoryRoot, pressure, this.config.memory);
909
+ const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory);
770
910
  if (result) {
771
911
  const beforeK = (result.tokensBefore / 1000).toFixed(1);
772
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/pairing.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * pairing.ts — Persistent pending-pairing state for Telegram.
3
+ *
4
+ * Used by:
5
+ * - setup --telegram --headless: writes pending pairing before starting gateway
6
+ * - gateway.ts: detects pending pairing and completes on /start <nonce>
7
+ */
8
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
9
+ import { dirname, resolve } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+ import { ROUNDHOUSE_DIR } from "./config";
12
+
13
+ export interface PendingPairing {
14
+ version: 1;
15
+ nonce: string;
16
+ botUsername: string;
17
+ allowedUsers: string[];
18
+ createdAt: string;
19
+ status: "pending" | "paired";
20
+ pairedAt?: string;
21
+ chatId?: number;
22
+ userId?: number;
23
+ username?: string;
24
+ }
25
+
26
+ export const PAIRING_PATH = resolve(ROUNDHOUSE_DIR, "telegram-pairing.json");
27
+
28
+ /**
29
+ * Generate a pairing nonce: "rh-" + 8 random hex bytes.
30
+ */
31
+ export function createPairingNonce(): string {
32
+ return `rh-${randomBytes(8).toString("hex")}`;
33
+ }
34
+
35
+ /**
36
+ * Build the Telegram deep link for pairing.
37
+ */
38
+ export function createPairingLink(botUsername: string, nonce: string): string {
39
+ return `https://t.me/${botUsername}?start=${nonce}`;
40
+ }
41
+
42
+ /**
43
+ * Check if a message text matches /start <nonce>.
44
+ */
45
+ export function isStartForNonce(text: string, nonce: string): boolean {
46
+ const trimmed = text.trim();
47
+ return trimmed === `/start ${nonce}` || trimmed === nonce;
48
+ }
49
+
50
+ /**
51
+ * Read the pending pairing file. Returns null if not found or invalid.
52
+ */
53
+ export async function readPendingPairing(): Promise<PendingPairing | null> {
54
+ try {
55
+ const raw = await readFile(PAIRING_PATH, "utf8");
56
+ const data = JSON.parse(raw);
57
+ if (data?.version === 1 && data?.nonce && data?.status) {
58
+ return data as PendingPairing;
59
+ }
60
+ return null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write pending pairing state (atomic, mode 0600).
68
+ */
69
+ export async function writePendingPairing(state: PendingPairing): Promise<void> {
70
+ await mkdir(dirname(PAIRING_PATH), { recursive: true });
71
+ const tmp = `${PAIRING_PATH}.tmp.${randomBytes(4).toString("hex")}`;
72
+ try {
73
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
74
+ await rename(tmp, PAIRING_PATH);
75
+ } catch (err) {
76
+ try { await unlink(tmp); } catch {}
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Mark pairing as complete — merges result into existing pending state.
83
+ */
84
+ export async function completePendingPairing(result: {
85
+ chatId: number;
86
+ userId: number;
87
+ username: string;
88
+ }): Promise<PendingPairing | null> {
89
+ const existing = await readPendingPairing();
90
+ if (!existing || existing.status !== "pending") return null;
91
+
92
+ const completed: PendingPairing = {
93
+ ...existing,
94
+ status: "paired",
95
+ pairedAt: new Date().toISOString(),
96
+ chatId: result.chatId,
97
+ userId: result.userId,
98
+ username: result.username,
99
+ };
100
+
101
+ await writePendingPairing(completed);
102
+ return completed;
103
+ }
104
+
105
+ /**
106
+ * Clear the pairing file.
107
+ */
108
+ export async function clearPendingPairing(): Promise<void> {
109
+ try {
110
+ await unlink(PAIRING_PATH);
111
+ } catch {}
112
+ }