@inceptionstack/roundhouse 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/architecture.md +37 -18
  2. package/package.json +2 -1
  3. package/skills/pr-merge-discipline/SKILL.md +36 -0
  4. package/skills/roundhouse-cron/SKILL.md +136 -0
  5. package/src/agents/kiro/kiro-adapter.ts +1 -4
  6. package/src/agents/pi/pi-adapter.ts +1 -4
  7. package/src/cli/cli.ts +1 -1
  8. package/src/cli/setup/args.ts +8 -9
  9. package/src/cli/setup/flows.ts +47 -14
  10. package/src/cli/{setup-logger.ts → setup/logger.ts} +4 -4
  11. package/src/cli/{setup-prompts.ts → setup/prompts.ts} +23 -2
  12. package/src/cli/setup/runtime.ts +1 -1
  13. package/src/cli/setup/steps.ts +3 -3
  14. package/src/cli/{setup-telegram.ts → setup/telegram.ts} +4 -4
  15. package/src/cli/setup/types.ts +4 -3
  16. package/src/cli/setup.ts +8 -8
  17. package/src/cli/systemd.ts +2 -0
  18. package/src/{commands → cli}/update.ts +1 -1
  19. package/src/cron/runner.ts +2 -1
  20. package/src/gateway/commands.ts +4 -3
  21. package/src/{gateway.ts → gateway/gateway.ts} +63 -97
  22. package/src/gateway/helpers.ts +1 -1
  23. package/src/gateway/index.ts +2 -5
  24. package/src/gateway/streaming.ts +1 -1
  25. package/src/{bundle.ts → provisioning/bundle.ts} +32 -0
  26. package/src/transports/index.ts +6 -0
  27. package/src/{telegram-html.ts → transports/telegram/html.ts} +2 -2
  28. package/src/{pairing.ts → transports/telegram/pairing.ts} +1 -1
  29. package/src/transports/telegram/telegram-adapter.ts +111 -0
  30. package/src/transports/types.ts +71 -0
  31. /package/src/{commands.ts → transports/telegram/bot-commands.ts} +0 -0
  32. /package/src/{telegram-format.ts → transports/telegram/format.ts} +0 -0
  33. /package/src/{notify/telegram.ts → transports/telegram/notify.ts} +0 -0
  34. /package/src/{telegram-progress.ts → transports/telegram/progress.ts} +0 -0
package/src/cli/setup.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { readFile } from "node:fs/promises";
13
- import { BOT_COMMANDS } from "../commands";
13
+ import { BOT_COMMANDS } from "../transports/telegram/bot-commands";
14
14
  import { atomicWriteJson, execSafe } from "./setup/helpers";
15
15
  import { type SetupOptions } from "./setup/types";
16
16
  import { parseSetupArgs } from "./setup/args";
@@ -27,7 +27,7 @@ import {
27
27
  import {
28
28
  validateBotToken,
29
29
  pairTelegram,
30
- } from "./setup-telegram";
30
+ } from "./setup/telegram";
31
31
  import {
32
32
  stepPreflight,
33
33
  stepValidateToken,
@@ -42,7 +42,7 @@ import {
42
42
  stepPostflight,
43
43
  } from "./setup/steps";
44
44
  import { resolveAgentForSetup, textLog, textStepLog } from "./setup/runtime";
45
- import { runInteractiveTelegramSetup, runHeadlessTelegramSetup } from "./setup/flows";
45
+ import { runInteractiveTelegramSetup, runNonInteractiveTelegramSetup } from "./setup/flows";
46
46
 
47
47
  // ── Orchestrator ─────────────────────────────────────
48
48
 
@@ -63,8 +63,8 @@ export async function cmdSetup(argv: string[]): Promise<void> {
63
63
 
64
64
  // Route to --telegram flows
65
65
  if (opts.telegram) {
66
- if (opts.headless) {
67
- await runHeadlessTelegramSetup(opts);
66
+ if (opts.nonInteractive) {
67
+ await runNonInteractiveTelegramSetup(opts);
68
68
  } else {
69
69
  await runInteractiveTelegramSetup(opts);
70
70
  }
@@ -258,12 +258,12 @@ function printSetupHelp(): void {
258
258
  console.log(`
259
259
  Usage:
260
260
  roundhouse setup --telegram Interactive wizard (recommended)
261
- TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --telegram --headless --user USERNAME Headless automation (SSM/cloud-init)
261
+ TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --telegram --non-interactive --user USERNAME Non-interactive automation (SSM/cloud-init)
262
262
  TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --user USERNAME Legacy (non-wizard) setup
263
263
 
264
264
  Modes:
265
- --telegram Telegram-focused setup (wizard or headless)
266
- --headless Non-interactive automation (implies --non-interactive)
265
+ --telegram Telegram-focused setup (wizard or non-interactive)
266
+ --non-interactive Suppress all prompts (for automation/SSM/cloud-init)
267
267
  Requires TELEGRAM_BOT_TOKEN env var and --user
268
268
 
269
269
  Required (or prompted in interactive --telegram):
@@ -127,6 +127,8 @@ WorkingDirectory=${home}
127
127
  ExecStart=${opts.execStart}
128
128
  Restart=on-failure
129
129
  RestartSec=5
130
+ TimeoutStopSec=15
131
+ KillMode=mixed
130
132
  EnvironmentFile=-${envFilePath}
131
133
  Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
132
134
  Environment=NODE_ENV=production
@@ -8,7 +8,7 @@
8
8
  import { homedir } from "node:os";
9
9
  import { execSync } from "node:child_process";
10
10
  import { readFileSync, writeFileSync } from "node:fs";
11
- import { provisionBundle } from "../bundle";
11
+ import { provisionBundle } from "../provisioning/bundle";
12
12
 
13
13
  export interface UpdateProgress {
14
14
  update(text: string): Promise<void>;
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { getAgentFactory } from "../agents/registry";
9
- import { sendTelegramToMany } from "../notify/telegram";
9
+ // TODO: route through TransportAdapter.notify() when multi-transport lands
10
+ import { sendTelegramToMany } from "../transports/telegram/notify";
10
11
  import { CronStore, generateRunId } from "./store";
11
12
  import { buildTemplateContext, renderTemplate } from "./template";
12
13
  import type { CronJobConfig, CronRunRecord } from "./types";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * gateway/commands.ts — Telegram command handlers
2
+ * gateway/commands.ts — Chat command handlers
3
3
  *
4
4
  * Each handler is a standalone async function that receives a CommandContext.
5
5
  * Extracted from Gateway.start() to reduce method size and enable unit testing.
@@ -9,7 +9,8 @@ import type { AgentAdapter, AgentStreamEvent, GatewayConfig } from "../types";
9
9
  import { ROUNDHOUSE_VERSION } from "../config";
10
10
  import { startTypingLoop } from "../util";
11
11
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "../memory/lifecycle";
12
- import { createProgressMessage } from "../telegram-progress";
12
+ // TODO: move progress into TransportAdapter when multi-transport lands
13
+ import { createProgressMessage } from "../transports/telegram/progress";
13
14
  import { getSystemResources } from "./helpers";
14
15
 
15
16
  // ── Types ────────────────────────────────────────────
@@ -70,7 +71,7 @@ export async function handleUpdate(ctx: CommandContext): Promise<void> {
70
71
  console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
71
72
  const progress = await createProgressMessage(thread, "📦 Checking for updates...");
72
73
  try {
73
- const { performUpdate } = await import("../commands/update");
74
+ const { performUpdate } = await import("../cli/update");
74
75
  const result = await performUpdate(progress);
75
76
  if (result.action === "already-latest") {
76
77
  await progress.update(`✅ Already on latest (v${result.currentVersion})`);
@@ -7,29 +7,30 @@
7
7
 
8
8
  import { Chat } from "chat";
9
9
  import { createMemoryState } from "@chat-adapter/state-memory";
10
- import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig } from "./types";
11
- import { splitMessage, isAllowed, startTypingLoop } from "./util";
12
- import { isTelegramThread, postTelegramHtml } from "./telegram-html";
13
- import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
14
- import { sendTelegramToMany } from "./notify/telegram";
15
- import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
16
- import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "./config";
17
- import { CronSchedulerService } from "./cron/scheduler";
18
- import { BOT_COMMANDS } from "./commands";
19
- import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
20
- import { maxPressure } from "./memory/policy";
21
- import type { PressureLevel, CompactResult } from "./memory/types";
22
- import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
23
- import { createProgressMessage } from "./telegram-progress";
24
- import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes, toolIcon as _toolIcon } from "./gateway/helpers";
25
- import { saveAttachments as _saveAttachments, type AttachmentResult } from "./gateway/attachments";
26
- import { handleStreaming as _handleStream, type StreamResult } from "./gateway/streaming";
27
- import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext, type StopContext, type VerboseContext, type DoctorContext, type CronsContext } from "./gateway/commands";
28
-
29
- /** Match a Telegram command, handling optional @botname suffix */
10
+ import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig } from "../types";
11
+ import { splitMessage, isAllowed, startTypingLoop } from "../util";
12
+ import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "../voice/stt-service";
13
+ import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner";
14
+ import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config";
15
+ import { CronSchedulerService } from "../cron/scheduler";
16
+ import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact } from "../memory/lifecycle";
17
+ import { maxPressure } from "../memory/policy";
18
+ import type { PressureLevel } from "../memory/types";
19
+ // TODO: move progress into TransportAdapter when multi-transport lands
20
+ import { createProgressMessage } from "../transports/telegram/progress";
21
+ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes } from "./helpers";
22
+ import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
23
+ import { handleStreaming as _handleStream } from "./streaming";
24
+ import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
25
+ import { TelegramAdapter } from "../transports";
26
+ import type { TransportAdapter } from "../transports";
27
+ import { hostname } from "node:os";
28
+ import { join } from "node:path";
29
+
30
30
  /** Bot username for command suffix validation (set during gateway init) */
31
31
  let _botUsername = "";
32
32
 
33
+ /** Match a bot command, handling optional @botname suffix */
33
34
  function isCommand(text: string, cmd: string): boolean {
34
35
  return _isCmd(text, cmd, _botUsername);
35
36
  }
@@ -38,12 +39,10 @@ function isCommand(text: string, cmd: string): boolean {
38
39
  function isCommandWithArgs(text: string, cmd: string): boolean {
39
40
  return _isCmdArgs(text, cmd, _botUsername);
40
41
  }
41
- import { hostname } from "node:os";
42
42
 
43
43
  function getSystemResources() {
44
44
  return _getSysRes();
45
45
  }
46
- import { join } from "node:path";
47
46
 
48
47
 
49
48
  function resolveAgentThreadId(thread: any, message: any): string {
@@ -68,11 +67,6 @@ async function buildChatAdapters(
68
67
  return adapters;
69
68
  }
70
69
 
71
- function toolIcon(name: string): string {
72
- return _toolIcon(name);
73
- }
74
-
75
-
76
70
  async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
77
71
  return _saveAttachments(threadId, attachments);
78
72
  }
@@ -83,6 +77,7 @@ export class Gateway {
83
77
  private chat!: Chat;
84
78
  private router: AgentRouter;
85
79
  private config: GatewayConfig;
80
+ private transport: TransportAdapter;
86
81
  private pairingComplete = false;
87
82
  private sttService: SttService | null = null;
88
83
  private cronScheduler: CronSchedulerService | null = null;
@@ -90,62 +85,41 @@ export class Gateway {
90
85
  constructor(router: AgentRouter, config: GatewayConfig) {
91
86
  this.router = router;
92
87
  this.config = config;
88
+ this.transport = new TelegramAdapter();
93
89
  _botUsername = config.chat.botUsername || "";
94
90
  }
95
91
 
96
- /** Handle pending Telegram pairing from headless setup. Returns true if handled. */
92
+ /** Handle pending pairing via transport adapter. Returns true if handled. */
97
93
  private async handlePendingPairing(
98
- text: string,
99
94
  message: any,
100
95
  thread: any,
101
- authorName: string,
102
96
  ): Promise<boolean> {
103
97
  try {
104
- const pending = await readPendingPairing();
105
- if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
106
- return false;
107
- }
98
+ const result = await this.transport.handlePairing(thread, message);
99
+ if (!result) return false;
108
100
 
109
- const fromUser = authorName.toLowerCase();
110
- const allowed = pending.allowedUsers.map(u => u.toLowerCase());
111
- if (!fromUser || !allowed.includes(fromUser)) {
112
- console.log(`[roundhouse] Pairing nonce from unauthorized user @${authorName}`);
113
- return false;
114
- }
101
+ const { threadId: rawThreadId, userId: rawUserId, username } = result;
102
+ // Config arrays are currently number[] — coerce with guard.
103
+ // When a string-ID transport (Slack/Discord) arrives, widen config types too.
104
+ const threadId = typeof rawThreadId === "string" ? Number(rawThreadId) : rawThreadId;
105
+ const userId = typeof rawUserId === "string" ? Number(rawUserId) : rawUserId;
115
106
 
116
- // Extract IDs from the chat adapter message
117
- const chatId = typeof message.chatId === "number"
118
- ? message.chatId
119
- : typeof thread.id === "string" && thread.id.startsWith("telegram:")
120
- ? parseInt(thread.id.split(":")[1], 10)
121
- : undefined;
122
- // Chat SDK Telegram adapter provides userId (not id)
123
- const rawUserId = message.author?.userId ?? message.author?.id ?? message.raw?.from?.id;
124
- const userId = typeof rawUserId === "number"
125
- ? rawUserId
126
- : typeof rawUserId === "string"
127
- ? parseInt(rawUserId, 10)
128
- : undefined;
129
-
130
- if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
131
- console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
132
- await thread.post("⚠️ Pairing nonce accepted but could not capture your Telegram IDs. Try sending /start again, or run: roundhouse pair");
133
- return true;
107
+ if (!Number.isFinite(threadId) || !Number.isFinite(userId)) {
108
+ console.error(`[roundhouse] Pairing returned non-numeric IDs: threadId=${rawThreadId} userId=${rawUserId}`);
109
+ return false;
134
110
  }
135
111
 
136
- await completePendingPairing({ chatId, userId, username: authorName });
137
-
138
112
  // Update in-memory config
139
113
  if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
140
114
  if (!this.config.chat.allowedUserIds.includes(userId)) {
141
115
  this.config.chat.allowedUserIds.push(userId);
142
116
  }
143
117
  if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
144
- if (!this.config.chat.notifyChatIds.includes(chatId)) {
145
- this.config.chat.notifyChatIds.push(chatId);
118
+ if (!this.config.chat.notifyChatIds.includes(threadId)) {
119
+ this.config.chat.notifyChatIds.push(threadId);
146
120
  }
147
121
 
148
- // Atomic config file update
122
+ // Persist config atomically
149
123
  try {
150
124
  const { readFile: rf, rename: mvf, writeFile: wf, unlink: ulf } = await import("node:fs/promises");
151
125
  const { randomBytes: rb } = await import("node:crypto");
@@ -155,7 +129,7 @@ export class Gateway {
155
129
  if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = [];
156
130
  if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId);
157
131
  if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = [];
158
- if (!configRaw.chat.notifyChatIds.includes(chatId)) configRaw.chat.notifyChatIds.push(chatId);
132
+ if (!configRaw.chat.notifyChatIds.includes(threadId)) configRaw.chat.notifyChatIds.push(threadId);
159
133
  const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`;
160
134
  await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n");
161
135
  await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; });
@@ -163,7 +137,7 @@ export class Gateway {
163
137
  console.error("[roundhouse] failed to update config after pairing:", cfgErr);
164
138
  }
165
139
 
166
- console.log(`[roundhouse] Telegram pairing complete: @${authorName} chatId=${chatId} userId=${userId}`);
140
+ console.log(`[roundhouse] Pairing complete: @${username} threadId=${threadId} userId=${userId}`);
167
141
  this.pairingComplete = true;
168
142
  await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working.");
169
143
  return true;
@@ -243,9 +217,9 @@ export class Gateway {
243
217
  `[roundhouse] ${thread.id} -> ${agentThreadId} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
244
218
  );
245
219
 
246
- // Check for pending Telegram pairing (headless setup)
247
- if (userText.trim().startsWith("/start ") && !this.pairingComplete) {
248
- const handled = await this.handlePendingPairing(userText.trim(), message, thread, authorName ?? "");
220
+ // Check for pending pairing via transport adapter
221
+ if (!this.pairingComplete && await this.transport.isPairingPending()) {
222
+ const handled = await this.handlePendingPairing(message, thread);
249
223
  if (handled) return;
250
224
  }
251
225
 
@@ -385,14 +359,23 @@ export class Gateway {
385
359
  try {
386
360
  console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
387
361
 
388
- // Enrich audio attachments with transcripts (STT)
389
- await this.enrichWithStt(thread, agentMessage);
362
+ // Enrich audio attachments with transcripts (STT) — show typing while processing
363
+ if (agentMessage.attachments?.some((a: any) => a.mediaType === "audio")) {
364
+ const sttTyping = startTypingLoop(thread);
365
+ try {
366
+ await this.enrichWithStt(thread, agentMessage);
367
+ } finally {
368
+ sttTyping();
369
+ }
370
+ } else {
371
+ await this.enrichWithStt(thread, agentMessage);
372
+ }
390
373
 
391
374
  // Let the agent adapter apply platform-specific message transforms
392
375
  if (agent.prepareMessage) {
393
376
  try {
394
377
  agentMessage = agent.prepareMessage(agentThreadId, agentMessage, {
395
- platform: "telegram",
378
+ platform: this.transport.name,
396
379
  hasAttachments: !!(agentMessage.attachments?.length),
397
380
  });
398
381
  } catch (err) {
@@ -528,6 +511,11 @@ export class Gateway {
528
511
  return null;
529
512
  }
530
513
 
514
+ // Enrich prompt via transport adapter
515
+ if (agentMessage.text) {
516
+ agentMessage.text = this.transport.enrichPrompt(agentMessage.text);
517
+ }
518
+
531
519
  return agentMessage;
532
520
  }
533
521
 
@@ -612,9 +600,8 @@ export class Gateway {
612
600
 
613
601
  /** Post text with markdown, falling back to plain text */
614
602
  private async postWithFallback(thread: any, text: string) {
615
- // Telegram: send as HTML for proper markdown rendering
616
- if (isTelegramThread(thread)) {
617
- await postTelegramHtml(thread, text);
603
+ if (this.transport.ownsThread(thread)) {
604
+ await this.transport.postMessage(thread, text);
618
605
  return;
619
606
  }
620
607
  for (const chunk of splitMessage(text, 4000)) {
@@ -636,25 +623,9 @@ export class Gateway {
636
623
  */
637
624
  private async registerBotCommands() {
638
625
  if (!this.config.chat.adapters.telegram) return;
639
-
640
626
  const token = process.env.TELEGRAM_BOT_TOKEN;
641
627
  if (!token) return;
642
-
643
- try {
644
- const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
645
- method: "POST",
646
- headers: { "Content-Type": "application/json" },
647
- body: JSON.stringify({ commands: BOT_COMMANDS }),
648
- });
649
- if (res.ok) {
650
- console.log(`[roundhouse] registered ${BOT_COMMANDS.length} bot commands with Telegram`);
651
- } else {
652
- const body = await res.text().catch(() => "");
653
- console.warn(`[roundhouse] failed to register bot commands (${res.status}): ${body.slice(0, 200)}`);
654
- }
655
- } catch (err) {
656
- console.warn(`[roundhouse] failed to register bot commands:`, (err as Error).message);
657
- }
628
+ await this.transport.registerCommands(token);
658
629
  }
659
630
 
660
631
  /**
@@ -666,11 +637,6 @@ export class Gateway {
666
637
  const chatIds = this.config.chat.notifyChatIds;
667
638
  if (!chatIds?.length) return;
668
639
 
669
- if (!process.env.TELEGRAM_BOT_TOKEN) {
670
- console.warn("[roundhouse] notifyChatIds configured but TELEGRAM_BOT_TOKEN not set — skipping startup notification");
671
- return;
672
- }
673
-
674
640
  const bootTime = process.uptime();
675
641
  const host = hostname();
676
642
  const agentName = this.config.agent.type;
@@ -715,7 +681,7 @@ export class Gateway {
715
681
  ` Process: ${memMB} MB RSS`,
716
682
  ].filter(line => line != null).join("\n");
717
683
 
718
- await sendTelegramToMany([chatId], perChatText);
684
+ await this.transport.notify([chatId], perChatText);
719
685
  }
720
686
  }
721
687
 
@@ -10,7 +10,7 @@ import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
10
10
  // ── Command Matching ─────────────────────────────────
11
11
 
12
12
  /**
13
- * Match a Telegram command, handling optional @botname suffix.
13
+ * Match a bot command, handling optional @botname suffix.
14
14
  */
15
15
  export function isCommand(text: string, cmd: string, botUsername: string): boolean {
16
16
  if (text === cmd) return true;
@@ -1,11 +1,8 @@
1
1
  /**
2
- * gateway/index.ts — Barrel export for gateway sub-modules
3
- *
4
- * Re-exports helpers, attachments, streaming, and commands for
5
- * external consumers. The Gateway class itself lives at src/gateway.ts
6
- * and is imported directly (not through this barrel).
2
+ * gateway/index.ts — Barrel export for gateway module
7
3
  */
8
4
 
5
+ export { Gateway } from "./gateway";
9
6
  export { isCommand, isCommandWithArgs, resolveAgentThreadId, getSystemResources, toolIcon } from "./helpers";
10
7
  export { saveAttachments } from "./attachments";
11
8
  export { handleStreaming } from "./streaming";
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { AgentStreamEvent } from "../types";
12
12
  import { READ_ONLY_TOOLS } from "../memory/types";
13
- import { isTelegramThread, handleTelegramHtmlStream } from "../telegram-html";
13
+ import { isTelegramThread, handleTelegramHtmlStream } from "../transports/telegram/html";
14
14
  import { DEBUG_STREAM } from "../util";
15
15
  import { toolIcon } from "./helpers";
16
16
 
@@ -182,12 +182,44 @@ export function provisionMcporterConfig(opts: ProvisionOpts = {}): void {
182
182
  }
183
183
  }
184
184
 
185
+ /**
186
+ * Sync bundled skills that ship with roundhouse (additive, overwrites on each provision).
187
+ */
188
+ export function syncBundledSkills(opts: ProvisionOpts = {}): void {
189
+ const log = opts.log ?? consoleLog;
190
+ // Resolves to <package-root>/skills/ (two levels up from src/provisioning/)
191
+ const bundledDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
192
+ if (!existsSync(bundledDir)) return;
193
+
194
+ const entries = readdirSync(bundledDir, { withFileTypes: true })
195
+ .filter(e => e.isDirectory() && !e.name.startsWith("."));
196
+ if (entries.length === 0) return;
197
+
198
+ mkdirSync(SKILLS_DIR, { recursive: true });
199
+ let count = 0;
200
+ for (const entry of entries) {
201
+ const src = resolve(bundledDir, entry.name);
202
+ const dest = resolve(SKILLS_DIR, entry.name);
203
+ if (!dest.startsWith(SKILLS_DIR + "/")) continue;
204
+ try {
205
+ execFileSync("rm", ["-rf", dest], { stdio: "pipe", timeout: 10_000 });
206
+ execFileSync("cp", ["-r", src, dest], { stdio: "pipe", timeout: 30_000 });
207
+ count++;
208
+ } catch (e: any) {
209
+ log.warn(`Failed to copy bundled skill '${entry.name}': ${e.message}`);
210
+ }
211
+ }
212
+ if (count > 0) log.ok(`${count} bundled skill(s) synced`);
213
+ }
214
+
185
215
  /**
186
216
  * Provision all bundle dependencies (skills + CLI tools + config + extensions).
187
217
  * Non-fatal — logs warnings on failure but never throws.
188
218
  */
189
219
  export function provisionBundle(opts: ProvisionOpts = {}): void {
190
220
  syncSkillsFromRepo(opts);
221
+ // Must run after syncSkillsFromRepo so bundled skills take precedence on name collision
222
+ syncBundledSkills(opts);
191
223
  provisionMcporter(opts);
192
224
  provisionPlaywright(opts);
193
225
  provisionUvx(opts);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * transports/index.ts — Transport adapter registry
3
+ */
4
+
5
+ export type { TransportAdapter, ChatThread, IncomingMessage, PairingResult } from "./types";
6
+ export { TelegramAdapter } from "./telegram/telegram-adapter";
@@ -8,8 +8,8 @@
8
8
  * typing indicators, command handling, authorization, message history.
9
9
  */
10
10
 
11
- import { markdownToTelegramHtml, truncateHtmlSafe } from "./telegram-format";
12
- import { splitMessage } from "./util";
11
+ import { markdownToTelegramHtml, truncateHtmlSafe } from "./format";
12
+ import { splitMessage } from "../../util";
13
13
 
14
14
  /** Max Telegram message length */
15
15
  const TELEGRAM_LIMIT = 4096;
@@ -8,7 +8,7 @@
8
8
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
9
9
  import { dirname, resolve } from "node:path";
10
10
  import { randomBytes } from "node:crypto";
11
- import { ROUNDHOUSE_DIR } from "./config";
11
+ import { ROUNDHOUSE_DIR } from "../../config";
12
12
 
13
13
  export interface PendingPairing {
14
14
  version: 1;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * transports/telegram/telegram-adapter.ts — Telegram transport adapter
3
+ *
4
+ * Implements TransportAdapter for Telegram, composing existing
5
+ * utility modules (format, html, progress, notify, bot-commands).
6
+ */
7
+
8
+ import type { TransportAdapter, ChatThread, IncomingMessage, PairingResult } from "../types";
9
+ import { isTelegramThread, postTelegramHtml } from "./html";
10
+ import { sendTelegramToMany } from "./notify";
11
+ import { BOT_COMMANDS } from "./bot-commands";
12
+ import { readPendingPairing, completePendingPairing, clearPendingPairing, isStartForNonce } from "./pairing";
13
+
14
+ const TELEGRAM_FORMAT_HINT = "[Format your final answer to be telegram-friendly.]";
15
+
16
+ export class TelegramAdapter implements TransportAdapter {
17
+ readonly name = "telegram";
18
+
19
+ enrichPrompt(text: string): string {
20
+ return `${text}\n\n${TELEGRAM_FORMAT_HINT}`;
21
+ }
22
+
23
+ async postMessage(thread: ChatThread, text: string): Promise<void> {
24
+ if (!isTelegramThread(thread as any)) {
25
+ throw new Error("TelegramAdapter.postMessage called with non-Telegram thread");
26
+ }
27
+ await postTelegramHtml(thread as any, text);
28
+ }
29
+
30
+ async registerCommands(token: string): Promise<void> {
31
+ if (!token) return;
32
+ try {
33
+ const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({ commands: BOT_COMMANDS }),
37
+ });
38
+ if (res.ok) {
39
+ console.log(`[roundhouse] registered ${BOT_COMMANDS.length} bot commands with Telegram`);
40
+ } else {
41
+ const body = await res.text().catch(() => "");
42
+ console.warn(`[roundhouse] failed to register bot commands (${res.status}): ${body.slice(0, 200)}`);
43
+ }
44
+ } catch (err) {
45
+ console.warn(`[roundhouse] bot command registration error:`, (err as Error).message);
46
+ }
47
+ }
48
+
49
+ ownsThread(thread: ChatThread): boolean {
50
+ return isTelegramThread(thread as any);
51
+ }
52
+
53
+ async notify(chatIds: number[], text: string): Promise<void> {
54
+ if (!process.env.TELEGRAM_BOT_TOKEN) {
55
+ console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification");
56
+ return;
57
+ }
58
+ await sendTelegramToMany(chatIds, text);
59
+ }
60
+
61
+ async isPairingPending(): Promise<boolean> {
62
+ const pending = await readPendingPairing();
63
+ return pending?.status === "pending";
64
+ }
65
+
66
+ async handlePairing(thread: ChatThread, message: IncomingMessage): Promise<PairingResult | null> {
67
+ const text = (message.text ?? "").trim();
68
+ if (!text) return null;
69
+
70
+ const pending = await readPendingPairing();
71
+ if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
72
+ return null;
73
+ }
74
+
75
+ // Verify author is allowed
76
+ const authorName = (message.author?.userName ?? message.author?.name ?? "").toLowerCase();
77
+ const originalName = message.author?.userName ?? message.author?.name ?? "";
78
+ const allowed = pending.allowedUsers.map(u => u.toLowerCase());
79
+ if (!authorName || !allowed.includes(authorName)) {
80
+ console.log(`[roundhouse] Pairing nonce from unauthorized user @${originalName}`);
81
+ return null;
82
+ }
83
+
84
+ // Extract Telegram-specific IDs
85
+ const msg = message as any;
86
+ const chatId = typeof msg.chatId === "number"
87
+ ? msg.chatId
88
+ : typeof thread.id === "string" && thread.id.startsWith("telegram:")
89
+ ? parseInt(thread.id.split(":")[1], 10)
90
+ : undefined;
91
+
92
+ const rawUserId = msg.author?.userId ?? msg.author?.id ?? msg.raw?.from?.id;
93
+ const userId = typeof rawUserId === "number"
94
+ ? rawUserId
95
+ : typeof rawUserId === "string"
96
+ ? parseInt(rawUserId, 10)
97
+ : undefined;
98
+
99
+ if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
100
+ console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId} (raw: msg.chatId=${message.chatId}, thread.id=${thread.id}, author.userId=${message.author?.userId}, author.id=${message.author?.id}, raw.from.id=${message.raw?.from?.id})`);
101
+ await clearPendingPairing();
102
+ await thread.post("⚠️ Pairing failed — could not capture your Telegram IDs. Run: roundhouse setup --telegram");
103
+ return null;
104
+ }
105
+
106
+ // Mark pairing complete in transport state
107
+ await completePendingPairing({ chatId, userId, username: originalName });
108
+
109
+ return { threadId: chatId, userId, username: originalName };
110
+ }
111
+ }