43967f7a07b004cdaaeebd303ff32e705e3d0f92

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Convert from opencode plugin to pi extension

- Replace @opencode-ai/plugin and @opencode-ai/sdk with pi extension API
- Use pi.sendUserMessage() to inject Telegram messages into the active session
- Stream responses via pi's message_update, tool_execution_*, and agent_end events
- Register 'telegram' tool (LLM-callable) and /telegram command (TUI)
- Support TELEGRAM_AUTOCONNECT=1 env var for auto-connect on startup
- Keep grammy bot with /abort command
- Preserve streaming with throttled Telegram message edits
- Preserve tool execution tracking and MarkdownV2 formatting
- Ship TypeScript source (loaded via jiti) instead of compiled JS
- Config moved to ~/.config/pi/telegram.json
- Removed multi-session management (pi manages sessions internally)
- Removed opencode-specific events.ts and opencode.ts

Diff

This diff is truncated to protect this page.

  1diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
  2index 2a75edab5560e28328e5fe11ab1ea428405da9d3..a1ab12e723131d73495d411ef8dd683f872ff59c 100644
  3--- a/.github/workflows/publish.yml
  4+++ b/.github/workflows/publish.yml
  5@@ -19,6 +19,6 @@ jobs:
  6           node-version: "24"
  7           registry-url: "https://registry.npmjs.org"
  8       - run: bun install
  9-      - run: bun run build
 10+      - run: bun run typecheck
 11       - run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
 12       - run: npm publish --access public
 13diff --git a/.gitignore b/.gitignore
 14index 87b2330b048843971ef95b9b75e22dda15228d57..f06235c460c2de3d97efc8371125594b7b761b08 100644
 15--- a/.gitignore
 16+++ b/.gitignore
 17@@ -1,3 +1,2 @@
 18 node_modules
 19 dist
 20-sessions.json
 21diff --git a/README.md b/README.md
 22index 210c939e95cef81e79b229cb8abe5a036511b830..aa2e8e5b7ceda514600a57db0a3ad6d8aabaae28 100644
 23--- a/README.md
 24+++ b/README.md
 25@@ -1,65 +1,100 @@
 26-# opencode-telegram
 27+# pi-telegram
 28 
 29-A Telegram bot plugin for [opencode](https://github.com/opencode-ai/opencode) that bridges Telegram chats to opencode. Send prompts and receive streamed responses directly in Telegram.
 30+A [pi](https://github.com/badlogic/pi-mono) extension that bridges your pi coding agent session to Telegram. Send prompts from Telegram and receive streaming responses with tool execution previews.
 31 
 32 ## Features
 33 
 34-- **Streaming responses** — responses are streamed with real-time edit-in-place updates
 35-- **Session management** — create, list, and switch between opencode sessions
 36-- **Agent switching** — list available agents or switch to a specific one
 37-- **Permission handling** — inline keyboard buttons to allow/deny tool execution requests
 38-- **Tool output formatting** — tool calls rendered as code blocks with name, target, and truncated output
 39-- **Message splitting** — long responses are split respecting Telegram's 4096-char limit and code block boundaries
 40-- **Auth** — only allowed Telegram user IDs can interact with the bot
 41+- **Remote access** — interact with your pi session from Telegram
 42+- **Streaming responses** — see the assistant's reply update in real time
 43+- **Tool execution tracking** — tool calls and results shown as code blocks
 44+- **Channel support** — works in channels/supergroups (responses are quoted)
 45+- **Access control** — allowlist of Telegram user IDs
 46+- **Autoconnect** — optionally connect on startup via env var
 47 
 48-### Bot commands
 49+## Setup
 50 
 51-| Command         | Description                                                   |
 52-| --------------- | ------------------------------------------------------------- |
 53-| `/new`          | Create a new opencode session                                 |
 54-| `/sessions`     | List all sessions with inline keyboard to switch between them |
 55-| `/abort`        | Abort the current in-flight prompt                            |
 56-| `/agent [name]` | List available agents or switch to a specific one             |
 57-| _(text)_        | Send as a prompt to the active opencode session               |
 58+### 1. Install the extension
 59 
 60-## Installation
 61+```bash
 62+pi install npm:@theedgeofrage/pi-telegram
 63+```
 64 
 65-Add the plugin to your opencode config (`~/.config/opencode/config.json`):
 66+Or for development, clone and load locally:
 67 
 68-```json
 69-{
 70-  "plugin": ["@theedgeofrage/opencode-telegram@latest"]
 71-}
 72+```bash
 73+pi -e /path/to/pi-telegram
 74 ```
 75 
 76-## Configuration
 77+### 2. Create a Telegram bot
 78 
 79-Create `~/.config/opencode/telegram.json`:
 80+Talk to [@BotFather](https://t.me/BotFather) on Telegram and create a new bot. Save the token.
 81+
 82+### 3. Configure
 83+
 84+Create `~/.config/pi/telegram.json`:
 85 
 86 ```json
 87 {
 88-  "token": "<telegram-bot-token>",
 89+  "token": "YOUR_BOT_TOKEN",
 90   "allowedUsers": [123456789]
 91 }
 92 ```
 93 
 94-- `token` — Telegram Bot API token from [@BotFather](https://t.me/BotFather)
 95-- `allowedUsers` — array of numeric Telegram user IDs authorized to use the bot
 96+Find your Telegram user ID by messaging [@userinfobot](https://t.me/userinfobot).
 97+
 98+### 4. Connect
 99+
100+From pi's TUI, use the command:
101+
102+```
103+/telegram connect
104+```
105+
106+Or set `TELEGRAM_AUTOCONNECT=1` in your environment to connect on startup.
107 
108 ## Usage
109 
110-Once installed and configured, ask opencode to connect to Telegram:
111+### pi commands
112+
113+| Command                           | Description            |
114+| --------------------------------- | ---------------------- |
115+| `/telegram connect`               | Start the Telegram bot |
116+| `/telegram disconnect`            | Stop the Telegram bot  |
117+| `/telegram` or `/telegram status` | Show connection status |
118 
119-> Connect telegram
120+### Telegram bot commands
121 
122-This starts the bot and attaches it to the current opencode session. All messages sent to the bot in Telegram will be routed to this session.
123+| Command  | Description                 |
124+| -------- | --------------------------- |
125diff --git a/bun.lock b/bun.lock
126index cfb9dcec35b4d0bfe5d22d86e54126253736c02c..01ad0a7af44d60a1dbcb3627701b58dad667bba7 100644
127--- a/bun.lock
128+++ b/bun.lock
129@@ -3,14 +3,15 @@
130   "configVersion": 1,
131   "workspaces": {
132     "": {
133-      "name": "opencode-telegram",
134+      "name": "@theedgeofrage/pi-telegram",
135       "dependencies": {
136-        "@opencode-ai/plugin": "^1.4.0",
137-        "@opencode-ai/sdk": "^1.4.0",
138         "grammy": "^1.35.0",
139       },
140       "devDependencies": {
141         "@eslint/js": "^9.23.0",
142+        "@mariozechner/pi-ai": "^0.67.6",
143+        "@mariozechner/pi-coding-agent": "^0.67.6",
144+        "@sinclair/typebox": "^0.34.0",
145         "@types/bun": "latest",
146         "@types/node": "^22.13.14",
147         "eslint": "^9.23.0",
148@@ -18,9 +19,86 @@
149         "typescript": "^5.8.2",
150         "typescript-eslint": "^8.27.0",
151       },
152+      "peerDependencies": {
153+        "@mariozechner/pi-ai": "*",
154+        "@mariozechner/pi-coding-agent": "*",
155+        "@sinclair/typebox": "*",
156+      },
157     },
158   },
159   "packages": {
160diff --git a/package.json b/package.json
161index 061cdad3a6c7ef6c919904c32d8c45226762dd82..9adb8e5ec66df96740cc8102c270f5da9826d0d0 100644
162--- a/package.json
163+++ b/package.json
164@@ -1,31 +1,41 @@
165 {
166-  "name": "@theedgeofrage/opencode-telegram",
167+  "name": "@theedgeofrage/pi-telegram",
168   "version": "0.1.0",
169   "type": "module",
170   "repository": {
171     "type": "git",
172     "url": "https://github.com/TheEdgeOfRage/opencode-telegram"
173   },
174-  "main": "dist/main.js",
175-  "types": "dist/main.d.ts",
176+  "keywords": [
177+    "pi-package"
178+  ],
179+  "pi": {
180+    "extensions": [
181+      "./src/index.ts"
182+    ]
183+  },
184   "files": [
185-    "dist"
186+    "src"
187   ],
188   "scripts": {
189-    "build": "tsc",
190     "typecheck": "tsc --noEmit",
191     "lint": "eslint .",
192     "format": "prettier --write .",
193-    "format:check": "prettier --check .",
194-    "prepublishOnly": "bun run build"
195+    "format:check": "prettier --check ."
196   },
197   "dependencies": {
198-    "@opencode-ai/plugin": "^1.4.0",
199-    "@opencode-ai/sdk": "^1.4.0",
200     "grammy": "^1.35.0"
201   },
202+  "peerDependencies": {
203+    "@mariozechner/pi-coding-agent": "*",
204+    "@mariozechner/pi-ai": "*",
205+    "@sinclair/typebox": "*"
206+  },
207   "devDependencies": {
208     "@eslint/js": "^9.23.0",
209+    "@mariozechner/pi-coding-agent": "^0.67.6",
210+    "@mariozechner/pi-ai": "^0.67.6",
211+    "@sinclair/typebox": "^0.34.0",
212     "@types/bun": "latest",
213     "@types/node": "^22.13.14",
214     "eslint": "^9.23.0",
215diff --git a/src/events.ts b/src/events.ts
216deleted file mode 100644
217index 2ac3c9bd5375522d1f1081d3292d951b30fa1f08..0000000000000000000000000000000000000000
218--- a/src/events.ts
219+++ /dev/null
220@@ -1,82 +0,0 @@
221-import type {
222-  Event,
223-  EventMessageUpdated,
224-  EventMessagePartUpdated,
225-  Part,
226-} from "@opencode-ai/sdk";
227-import * as log from "./log.js";
228-
229-// Server sends `permission.asked` with this shape, which diverges from the SDK's
230-// v1 Permission type. Keep a local interface until the SDK aligns.
231-export interface PermissionEvent {
232-  id: string;
233-  sessionID: string;
234-  permission: string;
235-  patterns: string[];
236-  metadata: Record<string, unknown>;
237-  always?: string[];
238-  tool?: { messageID: string; callID: string };
239-}
240-
241-export type PartHandler = (parts: Part[]) => void;
242-export type PermissionHandler = (perm: PermissionEvent) => void;
243-
244-interface SessionHandler {
245-  onPart: PartHandler;
246-  onPermission: PermissionHandler;
247-  parts: Map<string, Part>;
248-  assistantMessageIDs: Set<string>;
249-}
250-
251-const handlers = new Map<string, SessionHandler>();
252-
253-export function registerSession(
254-  sessionId: string,
255-  onPart: PartHandler,
256-  onPermission: PermissionHandler,
257-): void {
258-  handlers.set(sessionId, {
259-    onPart,
260-    onPermission,
261-    parts: new Map(),
262-    assistantMessageIDs: new Set(),
263-  });
264-}
265-
266-export function unregisterSession(sessionId: string): void {
267-  handlers.delete(sessionId);
268-}
269-
270-export function handleEvent(event: Event): void {
271-  const type = event.type as string;
272-
273-  if (type === "message.updated") {
274-    const info = (event as EventMessageUpdated).properties.info;
275-    if (info.role !== "assistant") return;
276-    const handler = handlers.get(info.sessionID);
277-    if (!handler) return;
278-    handler.assistantMessageIDs.add(info.id);
279-    return;
280-  }
281-
282-  if (type === "message.part.updated") {
283-    const part = (event as EventMessagePartUpdated).properties.part;
284-    const handler = handlers.get(part.sessionID);
285-    if (!handler) return;
286-    if (!handler.assistantMessageIDs.has(part.messageID)) return;
287-    handler.parts.set(part.id, part);
288-    handler.onPart(Array.from(handler.parts.values()));
289-    return;
290-  }
291-
292-  if (type === "permission.asked" || type === "permission.updated") {
293-    const perm = event.properties as unknown as PermissionEvent;
294-    log.info(
295-      `[events] ${type} session=${perm.sessionID} perm=${perm.id} permission=${perm.permission}`,
296-    );
297-    const handler = handlers.get(perm.sessionID);
298-    if (!handler) return;
299-    handler.onPermission(perm);
300-    return;
301-  }
302-}
303diff --git a/src/format.ts b/src/format.ts
304index e8096257f402dbcc901dc8a240df5f1704272cb3..ebd5765438b3c7456da05917b95d0ace63bd9335 100644
305--- a/src/format.ts
306+++ b/src/format.ts
307@@ -1,5 +1,3 @@
308-import type { Part } from "@opencode-ai/sdk/client";
309-
310 const TELEGRAM_MAX = 4096;
311 
312 // eslint-disable-next-line no-useless-escape
313@@ -13,7 +11,7 @@ export function escapeMarkdownV2(text: string): string {
314  * Escape text for MarkdownV2 but preserve code blocks (``` delimited).
315  * Inside code blocks only ` and \ need escaping.
316  */
317-function escapeWithCodeBlocks(text: string): string {
318+export function escapeWithCodeBlocks(text: string): string {
319   const parts: string[] = [];
320   let cursor = 0;
321 
322@@ -44,60 +42,44 @@ function escapeCodeContent(text: string): string {
323   return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
324 }
325 
326-function formatToolPart(part: Extract<Part, { type: "tool" }>): string {
327-  const { state } = part;
328-  const input = state.input;
329+export function formatAsQuote(text: string): string {
330+  const lines = text.split("\n");
331+  return lines.map((line) => `> ${line}`).join("\n");
332+}
333+
334+/** Format a tool execution as a MarkdownV2 code block */
335+export function formatToolBlock(
336+  toolName: string,
337+  args: Record<string, unknown>,
338+  status: string,
339+  result?: string,
340+  error?: string,
341+): string {
342   const target =
343-    (input["path"] as string | undefined) ??
344-    (input["filePath"] as string | undefined) ??
345-    (input["file"] as string | undefined) ??
346-    (input["command"] as string | undefined) ??
347+    (args["path"] as string | undefined) ??
348+    (args["filePath"] as string | undefined) ??
349+    (args["file"] as string | undefined) ??
350+    (args["command"] as string | undefined) ??
351     "";
352   const suffix = target ? ` (${target})` : "";
353-  const header = "Tool: " + part.tool + suffix;
354+  const header = "Tool: " + toolName + suffix;
355 
356-  if (state.status === "completed" && state.output) {
357-    let output = state.output;
358+  if (status === "completed" && result) {
359+    let output = result;
360     if (output.length > 3000)
361       output = output.slice(0, 3000) + "\n... (truncated)";
362     return "```\n" + escapeCodeContent(header + "\n" + output) + "\n```";
363   }
364-  if (state.status === "error") {
365+  if (status === "error") {
366     return (
367-      "```\n" + escapeCodeContent(header + "\nError: " + state.error) + "\n```"
368+      "```\n" +
369+      escapeCodeContent(header + "\nError: " + (error || "unknown")) +
370+      "\n```"
371     );
372   }
373   return "```\n" + escapeCodeContent(header) + "\n```";
374 }
375 
376-/** Format only text parts (no tool summaries) */
377-export function formatTextParts(parts: Part[]): string {
378-  const segments: string[] = [];
379-  for (const part of parts) {
380-    if (part.type === "text") segments.push(escapeWithCodeBlocks(part.text));
381-  }
382-  return segments.join("\n");
383-}
384-
385-/** Extract formatted text from OpenCode Part[] response */
386-export function formatParts(parts: Part[]): string {
387-  const segments: string[] = [];
388-
389-  for (const part of parts) {
390-    switch (part.type) {
391-      case "text":
392-        segments.push(escapeWithCodeBlocks(part.text));
393-        break;
394-      case "tool":
395-        segments.push(formatToolPart(part));
396-        break;
397-      // Other part types (step-start, step-finish, etc.) are ignored
398-    }
399-  }
400-
401-  return segments.join("\n");
402-}
403-
404 /**
405  * Split a formatted message into chunks that fit within Telegram's 4096 char limit.
406  * Respects code block boundaries — won't split inside a fenced block.
407diff --git a/src/index.ts b/src/index.ts
408new file mode 100644
409index 0000000000000000000000000000000000000000..72d75aa62718be4b5205758616e3a2314264d44d
410--- /dev/null
411+++ b/src/index.ts
412@@ -0,0 +1,445 @@
413+import type {
414+  ExtensionAPI,
415+  ExtensionContext,
416+} from "@mariozechner/pi-coding-agent";
417+import { StringEnum, Type } from "@mariozechner/pi-ai";
418+import type { Bot, Context as GrammyContext } from "grammy";
419+import { readFileSync } from "node:fs";
420+import { join } from "node:path";
421+import { homedir } from "node:os";
422+import * as log from "./log.js";
423+import { createBot, BOT_COMMANDS } from "./telegram.js";
424+import {
425+  escapeMarkdownV2,
426+  escapeWithCodeBlocks,
427+  formatAsQuote,
428+  formatToolBlock,
429+  splitMessage,
430+} from "./format.js";
431+
432+interface Config {
433+  token: string;
434+  allowedUsers: number[];
435+}
436+
437+interface ToolExecution {
438+  toolCallId: string;
439+  toolName: string;
440+  args: Record<string, unknown>;
441+  status: "running" | "completed" | "error";
442+  result?: string;
443+  error?: string;
444+}
445+
446+interface PendingRequest {
447+  chatId: number;
448+  api: GrammyContext["api"];
449+  responseMsgId: number;
450+  lastEditTime: number;
451+  editTimer: ReturnType<typeof setTimeout> | null;
452+  typingInterval: ReturnType<typeof setInterval>;
453+  currentTurnText: string;
454+  previousTurnsText: string[];
455+  toolExecutions: ToolExecution[];
456+  isChannel: boolean;
457+}
458+
459+function loadConfig(): Config {
460+  const configPath = join(homedir(), ".config", "pi", "telegram.json");
461+  let raw: string;
462+  try {
463+    raw = readFileSync(configPath, "utf-8");
464+  } catch {
465+    throw new Error(`cannot read config: ${configPath}`);
466+  }
467+  const json = JSON.parse(raw) as Record<string, unknown>;
468+  const token = json.token;
469+  if (typeof token !== "string" || !token) {
470+    throw new Error(`"token" missing in ${configPath}`);
471+  }
472+  const users = json.allowedUsers;
473+  if (!Array.isArray(users) || users.length === 0) {
474+    throw new Error(
475+      `"allowedUsers" must be a non-empty array in ${configPath}`,
476+    );
477+  }
478+  return { token, allowedUsers: users.map(Number) };
479+}
480+
481+const THROTTLE_MS = 2000;
482+
483+export default function (pi: ExtensionAPI) {
484+  let config: Config | null = null;
485+  let configError: string | null = null;
486+  try {
487+    config = loadConfig();
488+  } catch (err) {
489+    configError = String(err instanceof Error ? err.message : err);
490+    log.error("config load failed:", configError);
491+  }
492+
493+  let bot: Bot | null = null;
494+  let pending: PendingRequest | null = null;
495+  let agentBusy = false;
496+  let currentCtx: ExtensionContext | undefined;
497+
498+  const autoConnect = process.env.TELEGRAM_AUTOCONNECT === "1";
499+
500+  // --- Helpers ---
501+
502+  function getToolResultText(result: unknown): string {
503+    if (!result || typeof result !== "object") return "";
504+    const r = result as { content?: Array<{ type: string; text?: string }> };
505+    if (!Array.isArray(r.content)) return "";
506+    return r.content
507+      .filter((c) => c.type === "text" && c.text)
508+      .map((c) => c.text!)
509+      .join("\n");
510+  }
511+
512diff --git a/src/log.ts b/src/log.ts
513index f692e057641b3e6efa396686e133b2501656e16c..9e875c88f91451f0040454db635e820ecea42141 100644
514--- a/src/log.ts
515+++ b/src/log.ts
516@@ -2,7 +2,7 @@ import { createWriteStream, mkdirSync, type WriteStream } from "fs";
517 import { join } from "path";
518 import { homedir } from "os";
519 
520-const LOG_DIR = join(homedir(), ".local", "share", "opencode-telegram", "log");
521+const LOG_DIR = join(homedir(), ".local", "share", "pi-telegram", "log");
522 
523 let stream: WriteStream;
524 
525diff --git a/src/main.ts b/src/main.ts
526deleted file mode 100644
527index 6f8403c1c0e7db1d8e3519a09a2cd6abf396965b..0000000000000000000000000000000000000000
528--- a/src/main.ts
529+++ /dev/null
530@@ -1,103 +0,0 @@
531-import type { Plugin } from "@opencode-ai/plugin";
532-import { tool } from "@opencode-ai/plugin";
533-import type { Bot } from "grammy";
534-import { readFileSync } from "node:fs";
535-import { join } from "node:path";
536-import { homedir } from "node:os";
537-import * as log from "./log.js";
538-import { init, setAttachedSession, clearAttachedSession } from "./opencode.js";
539-import { handleEvent } from "./events.js";
540-import { BOT_COMMANDS, createBot } from "./telegram.js";
541-
542-interface Config {
543-  token: string;
544-  allowedUsers: number[];
545-}
546-
547-function loadConfig(): Config {
548-  const configPath = join(homedir(), ".config", "opencode", "telegram.json");
549-  let raw: string;
550-  try {
551-    raw = readFileSync(configPath, "utf-8");
552-  } catch {
553-    throw new Error(`cannot read config: ${configPath}`);
554-  }
555-  const json = JSON.parse(raw) as Record<string, unknown>;
556-  const token = json.token;
557-  if (typeof token !== "string" || !token) {
558-    throw new Error(`"token" missing in ${configPath}`);
559-  }
560-  const users = json.allowedUsers;
561-  if (!Array.isArray(users) || users.length === 0) {
562-    throw new Error(
563-      `"allowedUsers" must be a non-empty array in ${configPath}`,
564-    );
565-  }
566-  return { token, allowedUsers: users.map(Number) };
567-}
568-
569-export const OpenCodeTelegram: Plugin = async ({ client, directory }) => {
570-  init(client, directory);
571-
572-  let config: Config | null = null;
573-  let configError: string | null = null;
574-  try {
575-    config = loadConfig();
576-  } catch (err) {
577-    configError = String(err instanceof Error ? err.message : err);
578-    log.error("config load failed:", configError);
579-  }
580-
581-  let bot: Bot | null = null;
582-  const autoConnect = process.env.TELEGRAM_AUTOCONNECT === "1";
583-
584-  async function connect(): Promise<string> {
585-    if (!config) return `error: ${configError}`;
586-    if (bot) return "already connected";
587-    bot = createBot(config.token, config.allowedUsers);
588-    await bot.api.setMyCommands(BOT_COMMANDS);
589-    log.info("telegram bot starting long-polling...");
590-    bot.start();
591-    return "connected";
592-  }
593-
594-  async function disconnect(): Promise<string> {
595-    if (!bot) return "not connected";
596-    await bot.stop();
597-    bot = null;
598-    clearAttachedSession();
599-    log.info("telegram bot stopped");
600-    return "disconnected";
601-  }
602-
603-  if (autoConnect) {
604-    connect().catch((err) => log.error("autoconnect failed:", err));
605-  }
606-
607-  return {
608-    event: async ({ event }) => {
609-      try {
610-        handleEvent(event);
611-      } catch (err) {
612-        log.error("event handler error:", err);
613-      }
614-    },
615-    tool: {
616-      telegram: tool({
617-        description:
618-          "Connect or disconnect the Telegram bot. Actions: connect, disconnect, status.",
619-        args: {
620-          action: tool.schema.enum(["connect", "disconnect", "status"]),
621-        },
622-        async execute(args, context) {
623-          if (args.action === "connect") {
624-            setAttachedSession(context.sessionID);
625-            return connect();
626-          }
627-          if (args.action === "disconnect") return disconnect();
628-          return bot ? "connected" : "disconnected";
629-        },
630diff --git a/src/opencode.ts b/src/opencode.ts
631deleted file mode 100644
632index f3175f7bcb93f9d5d441dbe6b9bffb74747074c1..0000000000000000000000000000000000000000
633--- a/src/opencode.ts
634+++ /dev/null
635@@ -1,153 +0,0 @@
636-import type { createOpencodeClient, Part } from "@opencode-ai/sdk";
637-import { readFileSync, writeFileSync } from "fs";
638-import { resolve } from "path";
639-
640-type Client = ReturnType<typeof createOpencodeClient>;
641-
642-let client: Client;
643-let sessionsFile: string;
644-
645-const chatSessions = new Map<number, string>();
646-const chatAgents = new Map<number, string>();
647-
648-// When set, all Telegram messages route to this session (interactive mode).
649-let attachedSession: string | null = null;
650-
651-export function setAttachedSession(sessionId: string): void {
652-  attachedSession = sessionId;
653-}
654-
655-export function clearAttachedSession(): void {
656-  attachedSession = null;
657-}
658-
659-export function init(c: Client, directory: string): void {
660-  client = c;
661-  sessionsFile = resolve(directory, ".opencode", "sessions.json");
662-  loadSessions();
663-}
664-
665-function loadSessions(): void {
666-  try {
667-    const raw = readFileSync(sessionsFile, "utf8");
668-    const obj = JSON.parse(raw) as Record<string, string>;
669-    for (const [k, v] of Object.entries(obj)) chatSessions.set(Number(k), v);
670-  } catch {
671-    // no file or invalid — start fresh
672-  }
673-}
674-
675-function saveSessions(): void {
676-  const obj: Record<string, string> = {};
677-  for (const [k, v] of chatSessions) obj[String(k)] = v;
678-  writeFileSync(sessionsFile, JSON.stringify(obj, null, 2));
679-}
680-
681-export async function getOrCreateSession(
682-  chatId: number,
683-): Promise<{ sessionId: string; fallback: boolean }> {
684-  const target = attachedSession ?? chatSessions.get(chatId);
685-  if (target) {
686-    const list = await client.session.list();
687-    if (!list.error && list.data?.some((s) => s.id === target)) {
688-      return { sessionId: target, fallback: false };
689-    }
690-    // Attached session gone — clear and fall through
691-    if (attachedSession) attachedSession = null;
692-  }
693-
694-  const result = await client.session.create();
695-  if (result.error)
696-    throw new Error(`failed to create session: ${result.error}`);
697-
698-  const sessionId = result.data.id;
699-  chatSessions.set(chatId, sessionId);
700-  saveSessions();
701-  return { sessionId, fallback: target !== undefined };
702-}
703-
704-export async function listAgents(): Promise<string[]> {
705-  const result = await client.app.agents();
706-  if (result.error)
707-    throw new Error(`list agents failed: ${JSON.stringify(result.error)}`);
708-  if (!result.data) return [];
709-  return result.data.map((a) => a.name);
710-}
711-
712-export function getAgent(chatId: number): string | undefined {
713-  return chatAgents.get(chatId);
714-}
715-
716-export function setAgent(chatId: number, agent: string): void {
717-  chatAgents.set(chatId, agent);
718-}
719-
720-export async function sendPrompt(
721-  sessionId: string,
722-  text: string,
723-  agent?: string,
724-): Promise<Part[]> {
725-  const result = await client.session.prompt({
726-    path: { id: sessionId },
727-    body: { parts: [{ type: "text", text }], ...(agent ? { agent } : {}) },
728-  });
729-  if (result.error)
730-    throw new Error(`prompt failed: ${JSON.stringify(result.error)}`);
731-
732-  return result.data.parts;
733-}
734-
735diff --git a/src/telegram.ts b/src/telegram.ts
736index e96a807edd7eac71fe35b8491aca2b36599bda45..a351467410a8d816b9ab5c34fd40b5b6617744f3 100644
737--- a/src/telegram.ts
738+++ b/src/telegram.ts
739@@ -1,101 +1,31 @@
740-import { Bot, InlineKeyboard, type Context } from "grammy";
741+import { Bot, type Context } from "grammy";
742 import type { Chat } from "grammy/types";
743-import type { Part } from "@opencode-ai/sdk";
744 import * as log from "./log.js";
745-import type { PermissionEvent } from "./events.js";
746-import {
747-  escapeMarkdownV2,
748-  formatParts,
749-  formatTextParts,
750-  splitMessage,
751-} from "./format.js";
752-import {
753-  getOrCreateSession,
754-  createNewSession,
755-  listSessions,
756-  getSessionId,
757-  switchSession,
758-  abortSession,
759-  sendPrompt,
760-  replyPermission,
761-  listAgents,
762-  getAgent,
763-  setAgent,
764-} from "./opencode.js";
765-import { registerSession, unregisterSession } from "./events.js";
766 
767 export const BOT_COMMANDS = [
768-  { command: "new", description: "New session" },
769-  { command: "sessions", description: "List and switch sessions" },
770-  { command: "abort", description: "Abort current session" },
771-  { command: "agent", description: "Switch agent (/agent <name>)" },
772+  { command: "abort", description: "Abort current operation" },
773 ];
774 
775 function isChannel(chat: Chat): boolean {
776   return chat.type === "channel" || chat.type === "supergroup";
777 }
778 
779-function formatAsQuote(text: string): string {
780-  const escaped = escapeMarkdownV2(text);
781-  const lines = escaped.split("\n");
782-  const quoted = lines.map((line) => `> ${line}`).join("\n");
783-  return quoted;
784-}
785-
786-const THROTTLE_MS = 2000;
787-
788-// Track channel membership for quoting
789-let channelChatId: number | null = null;
790-
791-// Telegram callback data is limited to 64 bytes. Permission IDs are too long,
792-// so we store them in a map keyed by a short incrementing counter.
793-let permCounter = 0;
794-const pendingPerms = new Map<
795-  string,
796-  { sessionId: string; permissionId: string }
797->();
798-
799-function formatPartsPreview(parts: Part[]): string {
800-  const text = formatParts(parts);
801-  if (!text) return escapeMarkdownV2("thinking...");
802-  // Truncate to fit Telegram's 4096 limit for edits
803-  if (text.length > 4000) return text.slice(0, 4000) + escapeMarkdownV2("...");
804-  return text;
805-}
806-
807-async function editMessage(
808-  ctx: Context,
809-  messageId: number,
810+export type MessageHandler = (
811+  chatId: number,
812   text: string,
813-): Promise<void> {
814-  try {
815-    await ctx.api.editMessageText(ctx.chat!.id, messageId, text, {
816-      parse_mode: "MarkdownV2",
817-    });
818-  } catch {
819-    // Edit may fail if text unchanged or message deleted — ignore
820-  }
821-}
822-
823-function formatPermissionMessage(perm: PermissionEvent): string {
824-  const lines = [escapeMarkdownV2(`Permission: ${perm.permission}`)];
825-  if (perm.patterns.length > 0) {
826-    lines.push(escapeMarkdownV2(perm.patterns.join("\n")));
827-  }
828-  return lines.join("\n");
829-}
830+  ctx: Context,
831+  isChannel: boolean,
832+) => Promise<void>;
833 
834-function trackChannel(ctx: Context): void {
835-  const chat = ctx.chat;
836-  if (!chat) return;
837-  if (isChannel(chat)) {
838-    channelChatId = chat.id;
839diff --git a/tsconfig.json b/tsconfig.json
840index f9ef6afd8702d380b9ddf87d4a9ff0afb1c9f8da..644e56b9c5b72bead1a2f084a6b38877f736ed4f 100644
841--- a/tsconfig.json
842+++ b/tsconfig.json
843@@ -6,12 +6,11 @@
844     "strict": true,
845     "esModuleInterop": true,
846     "skipLibCheck": true,
847-    "outDir": "dist",
848     "rootDir": "src",
849-    "declaration": true,
850     "resolveJsonModule": true,
851     "isolatedModules": true,
852-    "noUncheckedIndexedAccess": true
853+    "noUncheckedIndexedAccess": true,
854+    "noEmit": true
855   },
856   "include": ["src"],
857   "exclude": ["node_modules", "dist"]