diff --git a/bin/lousho.js b/bin/lousho.js
index 78320490..d23b4b24 100755
--- a/bin/lousho.js
+++ b/bin/lousho.js
@@ -11,6 +11,7 @@ const USAGE = [
'',
'Commands:',
' lousho init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force] [--sdk-path
]',
+ ' [--bot-name N --bot-token T --bot-username U --allowed-chats IDS --persona TXT] (telegram template only)',
' Scaffold a new agent project (what `npm create lousho-agent` runs).',
' lousho dev [--port N] [--host H] [--allowed-hosts a,b] [--no-schedules] [--traces[=dir]]',
' Serve an agent with a local chat UI and HTTP API; reloads on file changes.',
diff --git a/src/cli/init.ts b/src/cli/init.ts
index a114f09f..45bab784 100644
--- a/src/cli/init.ts
+++ b/src/cli/init.ts
@@ -16,6 +16,7 @@ import {
PACKAGE_MANAGERS,
PROVIDER_NAMES,
TEMPLATES,
+ botFlagsUsed,
detectPackageManager,
expectOneOf,
parseInitArgs,
@@ -161,6 +162,11 @@ async function scaffold(options: InitOptions, environment: InitEnvironment): Pro
const dir = path.resolve(environment.cwd, choices.dir);
assertWritable(dir, options.force);
+ const botFlags = botFlagsUsed(options);
+ if (choices.template !== 'telegram' && botFlags.length) {
+ throw new InitUsageError(`lousho init: ${botFlags.join(', ')} only work with --template telegram.`);
+ }
+
const sdkDependency = resolveSdkDependency(environment.sdk.version, dir, options.sdkPath);
const files = renderProject({
name: packageNameFor(dir),
@@ -169,6 +175,13 @@ async function scaffold(options: InitOptions, environment: InitEnvironment): Pro
packageManager: choices.packageManager,
sdkDependency,
sdk: environment.sdk,
+ bot: {
+ name: options.botName,
+ token: options.botToken,
+ username: options.botUsername,
+ allowedChats: options.allowedChats,
+ persona: options.persona,
+ },
});
environment.write(`Creating ${choices.template} ${choices.provider} agent in ${dir}\n`);
writeFiles(dir, files);
diff --git a/src/cli/init/__snapshots__/templates.test.ts.snap b/src/cli/init/__snapshots__/templates.test.ts.snap
index e82d34a4..72a7ac3b 100644
--- a/src/cli/init/__snapshots__/templates.test.ts.snap
+++ b/src/cli/init/__snapshots__/templates.test.ts.snap
@@ -37,6 +37,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: anthropic / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'anthropic/claude-sonnet-4-5', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: anthropic / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -127,6 +171,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: google / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'google/gemini-2.5-flash', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: google / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -252,6 +340,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: ollama / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'ollama/llama3', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: ollama / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -342,6 +474,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: openai / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'openai/gpt-4.1-mini', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: openai / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -432,6 +608,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: openrouter / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'openrouter/openai/gpt-4.1-mini', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: openrouter / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -522,6 +742,50 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: pi / telegram agent.ts 1`] = `
+"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
+import { z } from 'zod';
+
+/** An example tool: the model calls it when it needs the current date or time. */
+export const currentTime = defineTool({
+ name: 'current_time',
+ description: 'Get the current date and time, optionally in an IANA time zone.',
+ input: z.object({
+ timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'),
+ }),
+ execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }),
+});
+
+const BOT = process.env.BOT_NAME ?? "Bot";
+const persona = "a helpful assistant that lives on Telegram";
+
+const instructions = [
+ \`You are \${BOT}, \${persona}.\`,
+ 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.',
+].join('\\n\\n');
+
+const tools = [currentTime];
+// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the
+// model's full output limit up front). Raise it when you need longer answers.
+const modelSettings = { maxTokens: 4096 };
+
+/**
+ * The agent's options. The default export is what \`lousho dev src/agent.ts\`
+ * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve.
+ */
+const config: CreateAgentConfig = { model: 'pi/openrouter/openai/gpt-4.1-mini', instructions, tools, modelSettings };
+export default config;
+
+/**
+ * The agent. \`createAgent({ model, instructions })\` is the whole idea; the
+ * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts).
+ */
+export function buildAgent(provider?: LLMProvider) {
+ return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config);
+}
+"
+`;
+
exports[`renderProject > snapshot: pi / tools agent.ts 1`] = `
"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent';
import { z } from 'zod';
@@ -575,6 +839,43 @@ export function buildAgent(provider?: LLMProvider) {
"
`;
+exports[`renderProject > snapshot: telegram package.json 1`] = `
+"{
+ "name": "demo-agent",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx src/index.ts",
+ "dev:ui": "lousho dev src/agent.ts",
+ "telegram": "tsx src/index.ts --telegram",
+ "task": "tsx src/index.ts --task-file",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit",
+ "doctor": "lousho doctor src/agent.ts",
+ "build": "lousho build src/agent.ts --target=node-server"
+ },
+ "dependencies": {
+ "@ai-sdk/openai": "^4.0.0",
+ "@lousho/build-ai-agent": "^1.0.0-alpha.8",
+ "ai": "^7.0.0",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "tsup": "^8.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.6.0",
+ "vite": "^8.0.0",
+ "vitest": "^5.0.3"
+ },
+ "engines": {
+ "node": ">=22.19.0"
+ }
+}
+"
+`;
+
exports[`renderProject > snapshot: tools package.json 1`] = `
"{
"name": "demo-agent",
diff --git a/src/cli/init/options.ts b/src/cli/init/options.ts
index 9d9e4268..3a0e09be 100644
--- a/src/cli/init/options.ts
+++ b/src/cli/init/options.ts
@@ -4,7 +4,7 @@
import { parseArgs } from 'node:util';
import { listProviders } from '../../providers/providerSpec';
-export const TEMPLATES = ['minimal', 'tools', 'yaml'] as const;
+export const TEMPLATES = ['minimal', 'tools', 'yaml', 'telegram'] as const;
export type Template = (typeof TEMPLATES)[number];
export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const;
@@ -28,6 +28,13 @@ Options:
--sdk-path Local development: depend on a checkout or packed .tgz of the SDK
instead of the published version (env: LOUSHO_SDK_PATH).
See docs/installation.md#installing-from-a-local-build
+
+Telegram template options (only with --template telegram):
+ --bot-name The bot's persona name (env BOT_NAME; e.g. Ghost)
+ --bot-token BotFather token, written to .env as TELEGRAM_BOT_TOKEN
+ --bot-username Bot's @username without @ (env BOT_USERNAME)
+ --allowed-chats Comma-separated chat IDs allowed besides the owner (env ALLOWED_CHATS)
+ --persona One-paragraph persona baked into the agent's instructions
--help, -h Show this help`;
export interface InitOptions {
@@ -41,6 +48,12 @@ export interface InitOptions {
git: boolean;
force: boolean;
sdkPath?: string;
+ /** Telegram template only. */
+ botName?: string;
+ botToken?: string;
+ botUsername?: string;
+ allowedChats?: string;
+ persona?: string;
}
/** Thrown for any bad flag or value; the message is printed as-is. */
@@ -64,6 +77,11 @@ export function parseInitArgs(argv: readonly string[], env: NodeJS.ProcessEnv =
template: { type: 'string' },
'package-manager': { type: 'string' },
'sdk-path': { type: 'string' },
+ 'bot-name': { type: 'string' },
+ 'bot-token': { type: 'string' },
+ 'bot-username': { type: 'string' },
+ 'allowed-chats': { type: 'string' },
+ persona: { type: 'string' },
},
});
} catch (error) {
@@ -84,9 +102,29 @@ export function parseInitArgs(argv: readonly string[], env: NodeJS.ProcessEnv =
git: values['no-git'] !== true,
force: values.force === true,
sdkPath: (values['sdk-path'] as string | undefined) ?? (env.LOUSHO_SDK_PATH || undefined),
+ botName: values['bot-name'] as string | undefined,
+ botToken: values['bot-token'] as string | undefined,
+ botUsername: values['bot-username'] as string | undefined,
+ allowedChats: values['allowed-chats'] as string | undefined,
+ persona: values.persona as string | undefined,
};
}
+/** The telegram-template flags → the InitOptions field each sets; used to reject them on other templates. */
+const BOT_FLAG_FIELDS = {
+ '--bot-name': 'botName',
+ '--bot-token': 'botToken',
+ '--bot-username': 'botUsername',
+ '--allowed-chats': 'allowedChats',
+ '--persona': 'persona',
+} as const satisfies Record;
+
+const BOT_FLAGS = Object.keys(BOT_FLAG_FIELDS) as Array;
+
+export function botFlagsUsed(options: InitOptions): string[] {
+ return BOT_FLAGS.filter((flag) => options[BOT_FLAG_FIELDS[flag]] !== undefined);
+}
+
/** Returns `value` if it is one of `allowed`, otherwise throws naming the flag and the allowed set. */
export function expectOneOf(flag: string, value: string, allowed: readonly T[]): T {
if ((allowed as readonly string[]).includes(value)) return value as T;
diff --git a/src/cli/init/projectConfig.ts b/src/cli/init/projectConfig.ts
new file mode 100644
index 00000000..756aac63
--- /dev/null
+++ b/src/cli/init/projectConfig.ts
@@ -0,0 +1,38 @@
+/**
+ * The shared init-project contract: the shape `renderProject` consumes and the
+ * provider resolver both `templates.ts` and `telegramTemplate.ts` need. Kept in
+ * its own module so template files stay acyclic (telegramTemplate -> here,
+ * templates -> telegramTemplate).
+ */
+import { listProviders } from '../../providers/providerSpec';
+import { ConfigurationError } from '../../execution/errors';
+import type { PackageManager, Template } from './options';
+import type { SdkManifest } from './sdkDependency';
+
+/** Bot options collected from `--bot-*`/`--persona` flags. */
+export interface BotOptions {
+ name?: string;
+ token?: string;
+ username?: string;
+ allowedChats?: string;
+ persona?: string;
+}
+
+export interface ProjectConfig {
+ /** npm package name (also the directory's base name). */
+ name: string;
+ provider: string;
+ template: Template;
+ packageManager: PackageManager;
+ /** `dependencies["@lousho/build-ai-agent"]`: a range, or `file:./`. */
+ sdkDependency: string;
+ sdk: SdkManifest;
+ /** Telegram template only: --bot-* / --persona flag values baked into .env and the persona. */
+ bot?: BotOptions;
+}
+
+export function providerInfo(provider: string) {
+ const info = listProviders().find((candidate) => candidate.name === provider);
+ if (!info) throw new ConfigurationError(`lousho init: unknown provider '${provider}'.`, 'provider', 'LOUSHO_PROVIDER_UNKNOWN');
+ return info;
+}
diff --git a/src/cli/init/prompts.ts b/src/cli/init/prompts.ts
index b052f876..48cdd0f2 100644
--- a/src/cli/init/prompts.ts
+++ b/src/cli/init/prompts.ts
@@ -20,6 +20,7 @@ const TEMPLATE_HINTS: Record = {
minimal: 'one agent, one example tool, an offline test',
tools: 'several example tools',
yaml: 'an agent.yaml spec, run with `lousho dev`',
+ telegram: 'a Telegram bot: polling bridge, owner lock, approvals',
};
function choices(values: readonly string[], hints: Record = {}) {
diff --git a/src/cli/init/telegramTemplate.ts b/src/cli/init/telegramTemplate.ts
new file mode 100644
index 00000000..6cc6061c
--- /dev/null
+++ b/src/cli/init/telegramTemplate.ts
@@ -0,0 +1,781 @@
+/**
+ * File contents for the `telegram` init template: a long-polling Telegram
+ * bridge around the generated agent — owner lock, an allowed-chats
+ * whitelist, approval buttons, markdown→Telegram-HTML rendering, photo/file
+ * intake, terminal and `--task-file` one-shot modes.
+ */
+import { providerInfo, type BotOptions, type ProjectConfig } from './projectConfig';
+
+export type { BotOptions } from './projectConfig';
+
+const MD_TS = [
+ '/**',
+ ' * Markdown-ish → Telegram HTML. Escapes raw HTML first, then rewrites',
+ ' * the common markers the model emits. Output is always well-formed;',
+ ' * senders still fall back to plain text if Telegram rejects it.',
+ ' */',
+ 'export function mdToTelegramHtml(src: string): string {',
+ " let s = src.replace(/&/g, '&').replace(//g, '>');",
+ " s = s.replace(/```[^\\n]*\\n?([\\s\\S]*?)```/g, (_m, code: string) => `${code.trim()}`);",
+ " s = s.replace(/`([^`\\n]+)`/g, '$1');",
+ " s = s.replace(/\\*\\*([^*\\n]+)\\*\\*/g, '$1');",
+ " s = s.replace(/__([^_\\n]+)__/g, '$1');",
+ " s = s.replace(/(^|[\\s(>])\\*([^*\\n]+)\\*(?![\\w*])/g, '$1$2');",
+ " s = s.replace(/(^|[\\s(>])_([^_\\n]+)_(?![\\w_])/g, '$1$2');",
+ " s = s.replace(/~~([^~\\n]+)~~/g, '$1');",
+ " s = s.replace(/^#{1,6}\\s*(.+)$/gm, '$1');",
+ ' s = s.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g, \'$1\');',
+ " s = s.replace(/^\\s*[-*]\\s+/gm, '• ');",
+ ' return s;',
+ '}',
+ '',
+].join('\n');
+
+const API_TS = [
+ "import { readFileSync } from 'node:fs';",
+ "import { basename } from 'node:path';",
+ "import { mdToTelegramHtml } from './md.js';",
+ '',
+ '// Read per call: .env is loaded by index.ts after this module is imported.',
+ 'const token = () => {',
+ ' const t = process.env.TELEGRAM_BOT_TOKEN;',
+ " if (!t) throw new Error('TELEGRAM_BOT_TOKEN is not set — put it in .env');",
+ ' return t;',
+ '};',
+ 'const api = (method: string) => `https://api.telegram.org/bot${token()}/${method}`;',
+ '',
+ 'export async function tgCall(method: string, body?: Record): Promise {',
+ ' const res = await fetch(api(method), {',
+ " method: 'POST',",
+ " headers: { 'content-type': 'application/json' },",
+ ' body: JSON.stringify(body ?? {}),',
+ ' });',
+ ' const data = (await res.json()) as { ok: boolean; result: T; description?: string };',
+ ' if (!data.ok) throw new Error(`telegram ${method} failed: ${data.description ?? res.status}`);',
+ ' return data.result;',
+ '}',
+ '',
+ 'export interface TgMessage {',
+ ' message_id: number;',
+ ' from?: { id: number; is_bot?: boolean; first_name?: string; username?: string };',
+ ' chat: { id: number; type: string };',
+ ' text?: string;',
+ ' caption?: string;',
+ ' photo?: { file_id: string }[];',
+ ' document?: { file_id: string; file_name?: string };',
+ ' voice?: { file_id: string; duration?: number };',
+ ' audio?: { file_id: string; duration?: number; file_name?: string };',
+ ' video?: { file_id: string; duration?: number; thumb?: { file_id: string } };',
+ ' video_note?: { file_id: string; duration?: number };',
+ ' sticker?: { file_id: string; emoji?: string };',
+ ' location?: { latitude: number; longitude: number; live_period?: number };',
+ ' venue?: { title?: string; address?: string; location: { latitude: number; longitude: number } };',
+ ' media_group_id?: string;',
+ ' reply_to_message?: TgMessage;',
+ '}',
+ 'export interface TgCallbackQuery {',
+ ' id: string;',
+ ' from: { id: number };',
+ ' data?: string;',
+ ' message?: TgMessage;',
+ '}',
+ 'export interface TgUpdate {',
+ ' update_id: number;',
+ ' message?: TgMessage;',
+ ' callback_query?: TgCallbackQuery;',
+ '}',
+ '',
+ 'export const getUpdates = (offset: number) =>',
+ " tgCall('getUpdates', { offset, timeout: 30, allowed_updates: ['message', 'callback_query'] });",
+ '',
+ 'const chunk = (text: string, size = 4000) => {',
+ ' const parts: string[] = [];',
+ ' for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size));',
+ ' return parts;',
+ '};',
+ '',
+ 'export async function sendMessage(chatId: number, text: string, extra: Record = {}): Promise {',
+ ' let lastId: number | undefined;',
+ ' for (const part of chunk(text)) {',
+ ' try {',
+ " const msg = await tgCall<{ message_id: number }>('sendMessage', {",
+ ' chat_id: chatId,',
+ ' text: mdToTelegramHtml(part),',
+ " parse_mode: 'HTML',",
+ ' ...extra,',
+ ' });',
+ ' lastId = msg?.message_id;',
+ ' } catch {',
+ " const msg = await tgCall<{ message_id: number }>('sendMessage', { chat_id: chatId, text: part, ...extra });",
+ ' lastId = msg?.message_id;',
+ ' }',
+ ' }',
+ ' return lastId;',
+ '}',
+ '',
+ "export const sendChatAction = (chatId: number, action: 'typing' | 'upload_document' | 'upload_photo' | 'record_voice') =>",
+ " tgCall('sendChatAction', { chat_id: chatId, action });",
+ 'export const answerCallbackQuery = (id: string, text?: string) => tgCall(\'answerCallbackQuery\', { callback_query_id: id, text });',
+ 'export const editMessageMarkup = (chatId: number, messageId: number) =>',
+ " tgCall('editMessageReplyMarkup', { chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } });",
+ '',
+ 'export const sendDocument = (chatId: number, filePath: string, caption?: string) =>',
+ " sendBinary('sendDocument', 'document', chatId, filePath, caption);",
+ 'export const sendPhoto = (chatId: number, filePath: string, caption?: string) =>',
+ " sendBinary('sendPhoto', 'photo', chatId, filePath, caption);",
+ '',
+ 'async function sendBinary(',
+ " method: 'sendDocument' | 'sendPhoto' | 'sendAudio' | 'sendVideo',",
+ " field: 'document' | 'photo' | 'audio' | 'video',",
+ ' chatId: number,',
+ ' filePath: string,',
+ ' caption?: string,',
+ '): Promise {',
+ ' const form = new FormData();',
+ " form.set('chat_id', String(chatId));",
+ ' if (caption) {',
+ " form.set('caption', mdToTelegramHtml(caption.slice(0, 1024)));",
+ " form.set('parse_mode', 'HTML');",
+ ' }',
+ ' form.set(field, new Blob([readFileSync(filePath)]), basename(filePath));',
+ " const res = await fetch(api(method), { method: 'POST', body: form });",
+ ' const data = (await res.json()) as { ok: boolean; description?: string };',
+ ' if (!data.ok) throw new Error(`telegram ${method} failed: ${data.description ?? res.status}`);',
+ '}',
+ '',
+ '/** Downloads a Telegram file by file_id. Returns the bytes and the path Telegram reports. */',
+ 'export async function downloadFile(fileId: string): Promise<{ bytes: Uint8Array; path: string }> {',
+ " const file = await tgCall<{ file_path: string }>('getFile', { file_id: fileId });",
+ ' const res = await fetch(`https://api.telegram.org/file/bot${token()}/${file.file_path}`);',
+ ' if (!res.ok) throw new Error(`telegram file download failed: ${res.status}`);',
+ ' return { bytes: new Uint8Array(await res.arrayBuffer()), path: file.file_path };',
+ '}',
+ '',
+].join('\n');
+
+const APPROVALS_TS = [
+ '/**',
+ ' * "Always allow" rules: when the bot asks approval for a tool call, a saved',
+ ' * matching rule resolves it without pinging. Stored in .lousho/approvals.json.',
+ ' */',
+ "import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
+ "import { dirname } from 'node:path';",
+ '',
+ 'export interface ApprovalRule {',
+ ' tool: string;',
+ ' /** Optional substring an arg must contain (e.g. a command prefix). */',
+ ' scope?: string;',
+ '}',
+ '',
+ "const FILE = '.lousho/approvals.json';",
+ '',
+ 'export function loadRules(): ApprovalRule[] {',
+ ' try {',
+ " return JSON.parse(readFileSync(FILE, 'utf8'));",
+ ' } catch {',
+ ' return [];',
+ ' }',
+ '}',
+ '',
+ 'export function saveRules(rules: ApprovalRule[]): void {',
+ ' mkdirSync(dirname(FILE), { recursive: true });',
+ ' writeFileSync(FILE, JSON.stringify(rules, null, 2));',
+ '}',
+ '',
+ '/** True when a saved rule covers this tool call — auto-approve without asking. */',
+ 'export function findMatch(tool: string, args: Record): boolean {',
+ ' return loadRules().some((r) => {',
+ ' if (r.tool !== tool) return false;',
+ ' if (!r.scope) return true;',
+ ' return JSON.stringify(args).includes(r.scope);',
+ ' });',
+ '}',
+ '',
+ 'export function addRule(tool: string, args?: Record): ApprovalRule {',
+ " const scope = typeof args?.cmd === 'string' ? String(args.cmd).split(' ')[0] : undefined;",
+ ' const rule: ApprovalRule = { tool, ...(scope ? { scope } : {}) };',
+ ' saveRules([...loadRules(), rule]);',
+ ' return rule;',
+ '}',
+ '',
+ 'export function removeRule(index: number): ApprovalRule | undefined {',
+ ' const rules = loadRules();',
+ ' if (index < 0 || index >= rules.length) return undefined;',
+ ' const [removed] = rules.splice(index, 1);',
+ ' saveRules(rules);',
+ ' return removed;',
+ '}',
+ '',
+ 'export const describeRule = (r: ApprovalRule, i: number) => `${i + 1}. ${r.tool}${r.scope ? ` when args contain "${r.scope}"` : \' (all calls)\'}`;',
+ '',
+].join('\n');
+
+const BRIDGE_TS = [
+ '/**',
+ ' * Long-polling Telegram bridge. One process per bot token — a second poller',
+ ' * makes Telegram return "Conflict: terminated by other getUpdates request".',
+ ' *',
+ ' * Access model:',
+ ' * - the FIRST chat that messages the bot becomes the owner (persisted),',
+ ' * - ALLOWED_CHATS="id1,id2" whitelists other chats,',
+ ' * - in groups (ALLOW_GROUPS=true) it answers @mentions and replies only,',
+ ' * - everyone else is logged and ignored — this bot can run tools on your',
+ ' * machine, strangers must not get turns.',
+ ' */',
+ "import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
+ "import { basename, dirname, join } from 'node:path';",
+ "import type { Agent, Session } from '@lousho/build-ai-agent';",
+ 'import {',
+ ' answerCallbackQuery,',
+ ' downloadFile,',
+ ' editMessageMarkup,',
+ ' getUpdates,',
+ ' sendChatAction,',
+ ' sendMessage,',
+ ' type TgCallbackQuery,',
+ ' type TgMessage,',
+ ' type TgUpdate,',
+ "} from './api.js';",
+ "import { addRule, describeRule, findMatch, loadRules, removeRule } from '../approvals.js';",
+ '',
+ "const STATE_FILE = '.lousho/state.json';",
+ "const INBOX = '.lousho/inbox';",
+ 'const ensureDir = (p: string) => (mkdirSync(p, { recursive: true }), p);',
+ 'const STARTED_AT = Date.now();',
+ '',
+ 'interface BridgeState {',
+ ' ownerChatId?: number;',
+ ' ownerUserId?: number;',
+ '}',
+ 'const loadState = (): BridgeState => {',
+ ' try {',
+ " return JSON.parse(readFileSync(STATE_FILE, 'utf8'));",
+ ' } catch {',
+ ' return {};',
+ ' }',
+ '};',
+ 'const saveState = (patch: BridgeState) => {',
+ ' ensureDir(dirname(STATE_FILE));',
+ ' writeFileSync(STATE_FILE, JSON.stringify({ ...loadState(), ...patch }));',
+ '};',
+ '',
+ "const botUsername = () => process.env.BOT_USERNAME ?? '';",
+ "const groupsAllowed = () => /^true$/i.test(process.env.ALLOW_GROUPS ?? '');",
+ 'const allowedChats = () =>',
+ " (process.env.ALLOWED_CHATS ?? '')",
+ " .split(',')",
+ ' .map((s) => Number(s.trim()))',
+ ' .filter((n) => Number.isFinite(n) && n !== 0);',
+ '',
+ '/** Who may talk in DMs: the owner or a whitelisted chat ID. */',
+ 'const chatAllowed = (chatId: number) => {',
+ ' const { ownerChatId } = loadState();',
+ ' return chatId === ownerChatId || allowedChats().includes(chatId);',
+ '};',
+ '',
+ '/** In groups the bot only speaks when @mentioned or replied to. */',
+ 'function groupTrigger(msg: TgMessage): boolean {',
+ " const text = msg.text ?? msg.caption ?? '';",
+ ' const name = botUsername();',
+ ' return (name.length > 0 && text.includes(`@${name}`)) || msg.reply_to_message?.from?.username === name;',
+ '}',
+ '',
+ 'const sessions = new Map();',
+ 'const sessionFor = (agent: Agent, chatId: number) => {',
+ ' let s = sessions.get(chatId);',
+ ' if (!s) {',
+ ' s = agent.session({ id: `tg-${chatId}` });',
+ ' sessions.set(chatId, s);',
+ ' }',
+ ' return s;',
+ '};',
+ '',
+ '// Serializes turns per chat — sessions are stateful, two concurrent turns corrupt order.',
+ 'const queues = new Map>();',
+ 'function enqueue(chatId: number, work: () => Promise) {',
+ ' const prev = queues.get(chatId) ?? Promise.resolve();',
+ ' const next = prev.then(work, work);',
+ ' queues.set(chatId, next.catch(() => {}));',
+ ' void next;',
+ '}',
+ '',
+ 'const pendingApprovals = new Map(); // chatId → approvalId',
+ '',
+ "async function resolvePending(agent: Agent, chatId: number, verdict: 'allow' | 'deny' | 'always'): Promise {",
+ ' const id = pendingApprovals.get(chatId);',
+ ' if (!id) return false;',
+ ' const pending = await agent.approvals.get(id);',
+ " if (verdict === 'always' && pending) {",
+ ' const rule = addRule(pending.toolName, pending.args as Record);',
+ ' await sendMessage(chatId, `got it — auto-allowing ${describeRule(rule, 0).slice(3)} from now on. /approvals manages these.`);',
+ ' }',
+ ' pendingApprovals.delete(chatId);',
+ " const result = await agent.approvals.resolve({ id, approved: verdict !== 'deny' });",
+ ' if (result?.text) await sendMessage(chatId, result.text);',
+ ' return true;',
+ '}',
+ '',
+ 'function approvalPrompt(toolName: string, args: Record): string {',
+ " const show = (v: unknown) => String(v ?? '').slice(0, 300);",
+ ' const bits = Object.entries(args).map(([k, v]) => `${k}: ${show(v)}`).join(\'\\n\');',
+ ' return `i want to run ${toolName}${bits ? `:\\n\\n${bits}` : \'\'}`;',
+ '}',
+ '',
+ "async function finishTurn(agent: Agent, chatId: number, result: { text?: string | null; finishReason?: string; approvalId?: string }) {",
+ ' if (result.text?.trim()) await sendMessage(chatId, result.text);',
+ " if (result.finishReason === 'awaiting-approval' && result.approvalId) {",
+ ' const pending = await agent.approvals.get(result.approvalId);',
+ " const toolName = pending?.toolName ?? '?';",
+ ' const args = (pending?.args ?? {}) as Record;',
+ ' if (findMatch(toolName, args)) {',
+ ' const r = await agent.approvals.resolve({ id: result.approvalId, approved: true });',
+ ' if (r?.text) await sendMessage(chatId, r.text);',
+ ' return;',
+ ' }',
+ ' pendingApprovals.set(chatId, result.approvalId);',
+ ' const id = result.approvalId;',
+ ' await sendMessage(chatId, `${approvalPrompt(toolName, args)}\\n\\nallow or deny?`, {',
+ ' reply_markup: {',
+ ' inline_keyboard: [[',
+ " { text: 'allow', callback_data: `ap:${id}:a` },",
+ " { text: 'deny', callback_data: `ap:${id}:d` },",
+ " { text: 'always', callback_data: `ap:${id}:w` },",
+ ' ]],',
+ ' },',
+ ' });',
+ ' }',
+ '}',
+ '',
+ 'async function runTurn(agent: Agent, chatId: number, input: Parameters[0]) {',
+ " void sendChatAction(chatId, 'typing').catch(() => {});",
+ ' return sessionFor(agent, chatId).send(input);',
+ '}',
+ '',
+ 'async function handleCommand(agent: Agent, chatId: number, text: string): Promise {',
+ ' const [cmd] = text.split(/\\s/, 1);',
+ ' switch (cmd) {',
+ " case '/start':",
+ ' await sendMessage(chatId, "online. first chat locks as owner — that\'s you.");',
+ ' return true;',
+ " case '/status': {",
+ ' const up = Math.round((Date.now() - STARTED_AT) / 60_000);',
+ ' const { ownerChatId } = loadState();',
+ ' await sendMessage(chatId, [',
+ " 'status: up',",
+ ' `up: ${up < 60 ? `${up} min` : `${Math.floor(up / 60)}h ${up % 60}m`}`,',
+ " `owner chat: ${ownerChatId ?? 'not locked yet'}`,",
+ " `allowed chats: ${allowedChats().join(', ') || 'none'}`,",
+ ' `pending approvals: ${pendingApprovals.size}`,',
+ " ].join('\\n'));",
+ ' return true;',
+ ' }',
+ " case '/approvals': {",
+ ' const rules = loadRules();',
+ ' await sendMessage(',
+ ' chatId,',
+ ' rules.length',
+ ' ? `auto-allow rules:\\n${rules.map(describeRule).join(\'\\n\')}\\n\\nreply /unallow to remove one`',
+ ' : \'no auto-allow rules yet — reply "always" when i ask for permission and i\\\'ll save one\',',
+ ' );',
+ ' return true;',
+ ' }',
+ " case '/unallow': {",
+ ' const n = Number(text.split(/\\s+/)[1]);',
+ ' const removed = Number.isFinite(n) ? removeRule(n - 1) : undefined;',
+ ' await sendMessage(chatId, removed ? `removed auto-allow for ${removed.tool}` : \'usage: /unallow \');',
+ ' return true;',
+ ' }',
+ " case '/help':",
+ " await sendMessage(chatId, '/start /status /approvals /unallow — otherwise just talk. Send photos and files, i can see them.');",
+ ' return true;',
+ ' default:',
+ ' return false;',
+ ' }',
+ '}',
+ '',
+ 'async function handleMessage(agent: Agent, msg: TgMessage): Promise {',
+ ' const chatId = msg.chat.id;',
+ " const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';",
+ ' const { ownerChatId } = loadState();',
+ '',
+ ' // Owner lock: the first private chat claims the bot. Everyone else needs',
+ ' // ALLOWED_CHATS (DMs) or a group trigger.',
+ ' if (isGroup) {',
+ ' if (!groupsAllowed() || !groupTrigger(msg)) return;',
+ ' } else if (!ownerChatId) {',
+ ' saveState({ ownerChatId: chatId, ...(msg.from?.id ? { ownerUserId: msg.from.id } : {}) });',
+ ' } else if (!chatAllowed(chatId)) {',
+ ' console.log(`[bridge] ignored message from non-owner chat ${chatId}`);',
+ ' return;',
+ ' }',
+ '',
+ ' const session = sessionFor(agent, chatId);',
+ " const name = msg.from?.first_name ?? msg.from?.username ?? 'there';",
+ ' const from = isGroup ? `[${name} in a group chat]: ` : \'\';',
+ '',
+ ' try {',
+ ' if (msg.photo?.length) {',
+ ' const photo = msg.photo[msg.photo.length - 1]!;',
+ ' const { bytes, path } = await downloadFile(photo.file_id);',
+ ' const file = join(ensureDir(INBOX), `tg-${msg.message_id}-${basename(path)}`);',
+ ' writeFileSync(file, bytes);',
+ ' const result = await runTurn(agent, chatId, [',
+ " { type: 'text', text: `${from}${name} sent a photo (saved as ${basename(file)}). ${msg.caption ?? ''}` },",
+ " { type: 'image', image: bytes, mimeType: 'image/jpeg' },",
+ ' ]);',
+ ' await finishTurn(agent, chatId, result);',
+ ' return;',
+ ' }',
+ '',
+ ' const media = msg.document ?? msg.voice ?? msg.audio ?? msg.video ?? msg.video_note;',
+ ' if (media) {',
+ ' const { bytes, path } = await downloadFile(media.file_id);',
+ " const named = 'file_name' in media ? media.file_name : undefined;",
+ ' const file = join(ensureDir(INBOX), `${msg.message_id}-${basename(named ?? path)}`);',
+ ' writeFileSync(file, bytes);',
+ " const kind = msg.document ? 'a file' : msg.video ? 'a video' : msg.video_note ? 'a video note' : 'a voice message';",
+ ' const result = await runTurn(',
+ ' agent,',
+ ' chatId,',
+ ' `${from}${name} sent ${kind} "${basename(file)}" saved at ${file}. Caption: ${msg.caption ?? \'none\'}. Read it from disk if useful.`,',
+ ' );',
+ ' await finishTurn(agent, chatId, result);',
+ ' return;',
+ ' }',
+ '',
+ ' if (msg.location || msg.venue) {',
+ ' const lat = msg.location?.latitude ?? msg.venue!.location.latitude;',
+ ' const lng = msg.location?.longitude ?? msg.venue!.location.longitude;',
+ ' const result = await runTurn(agent, chatId, `${from}${name} shared a location: lat ${lat}, lng ${lng}.`);',
+ ' await finishTurn(agent, chatId, result);',
+ ' return;',
+ ' }',
+ '',
+ ' if (msg.text) {',
+ " if (msg.text.startsWith('/') && (await handleCommand(agent, chatId, msg.text))) return;",
+ ' const verdict = /^\\s*(allow|deny|always)\\s*$/i.exec(msg.text)?.[1]?.toLowerCase();',
+ " if (verdict && (await resolvePending(agent, chatId, verdict as 'allow' | 'deny' | 'always'))) return;",
+ ' enqueue(chatId, async () => {',
+ ' try {',
+ ' const result = await runTurn(agent, chatId, `${from}${msg.text!}`);',
+ ' await finishTurn(agent, chatId, result);',
+ ' } catch (error) {',
+ ' console.error(`[bridge] turn failed for chat ${chatId}: ${error instanceof Error ? error.message : error}`);',
+ " await sendMessage(chatId, 'something glitched on my end — resend?').catch(() => {});",
+ ' }',
+ ' });',
+ ' return;',
+ ' }',
+ ' } catch (error) {',
+ ' console.error(`[bridge] handler failed for chat ${chatId}: ${error instanceof Error ? error.message : error}`);',
+ " await sendMessage(chatId, 'something glitched on my end — resend?').catch(() => {});",
+ ' }',
+ '}',
+ '',
+ 'async function handleCallback(agent: Agent, cq: TgCallbackQuery): Promise {',
+ " const m = /^ap:([^:]+):([adw])$/.exec(cq.data ?? '');",
+ ' const chatId = cq.message?.chat.id;',
+ ' if (!m || !chatId) return;',
+ ' const [, approvalId, code] = m;',
+ ' const ownerUid = loadState().ownerUserId;',
+ ' if (ownerUid && cq.from.id !== ownerUid) {',
+ " await answerCallbackQuery(cq.id, 'not yours to decide').catch(() => {});",
+ ' return;',
+ ' }',
+ ' await answerCallbackQuery(cq.id).catch(() => {});',
+ ' await editMessageMarkup(chatId, cq.message!.message_id).catch(() => {});',
+ " const verdict = code === 'a' ? 'allow' : code === 'd' ? 'deny' : 'always';",
+ ' pendingApprovals.set(chatId, approvalId!);',
+ ' enqueue(chatId, async () => {',
+ ' try {',
+ ' await resolvePending(agent, chatId, verdict);',
+ ' } catch (error) {',
+ ' console.error(`[bridge] approval failed: ${error instanceof Error ? error.message : error}`);',
+ ' }',
+ ' });',
+ '}',
+ '',
+ 'export async function runBridge(agent: Agent): Promise {',
+ ' let offset = 0;',
+ ' const name = botUsername();',
+ " console.log(`[bridge] polling Telegram${name ? ` for @${name}` : ''} updates…`);",
+ ' for (;;) {',
+ ' let updates: TgUpdate[] = [];',
+ ' try {',
+ ' updates = await getUpdates(offset);',
+ ' } catch (error) {',
+ ' console.error(`[bridge] getUpdates failed: ${error instanceof Error ? error.message : error} — retrying in 5s`);',
+ ' await new Promise((r) => setTimeout(r, 5_000));',
+ ' continue;',
+ ' }',
+ ' for (const update of updates) {',
+ ' offset = Math.max(offset, update.update_id + 1);',
+ ' if (update.callback_query) void handleCallback(agent, update.callback_query);',
+ ' else if (update.message) void handleMessage(agent, update.message);',
+ ' }',
+ ' }',
+ '}',
+ '',
+].join('\n');
+
+const INDEX_TS = [
+ "import { readFileSync } from 'node:fs';",
+ "import { createInterface } from 'node:readline/promises';",
+ "import { parseEnv } from 'node:util';",
+ "import { buildAgent } from './agent.js';",
+ "import { runBridge } from './telegram/bridge.js';",
+ '',
+ '// Load .env when there is one; variables already set in the environment win. The byte order mark',
+ '// some editors write (PowerShell\'s Set-Content -Encoding utf8) is dropped, or the first key is lost.',
+ 'try {',
+ " const vars = parseEnv(readFileSync('.env', 'utf8').replace(/^\\uFEFF/, ''));",
+ ' for (const [name, value] of Object.entries(vars)) {',
+ ' if (process.env[name] === undefined) process.env[name] = value;',
+ ' }',
+ '} catch (error) {',
+ " if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;",
+ '}',
+ '',
+ 'const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));',
+ "const telegram = process.argv.includes('--telegram');",
+ "const BOT = process.env.BOT_NAME ?? 'the bot';",
+ '',
+ 'const agent = buildAgent();',
+ '',
+ '// One-shot delegation: `tsx src/index.ts --task-file ` sends one message',
+ '// and exits. Approvals auto-resolve: the delegation itself was approved upstream.',
+ "const tfIdx = process.argv.indexOf('--task-file');",
+ 'if (tfIdx > -1) {',
+ " const task = readFileSync(process.argv[tfIdx + 1]!, 'utf8').trim();",
+ " const session = agent.session({ id: 'cli' });",
+ ' let result = await session.send(task);',
+ " while (result.finishReason === 'awaiting-approval' && result.approvalId) {",
+ ' result = await agent.approvals.resolve({ id: result.approvalId, approved: true });',
+ ' }',
+ ' console.log(`agent> ${result.text}`);',
+ ' process.exit(0);',
+ '}',
+ '',
+ 'if (telegram) {',
+ ' await runBridge(agent); // never returns — one poller per bot token',
+ '} else {',
+ ' const rl = createInterface({ input: process.stdin, output: process.stdout });',
+ " rl.on('close', () => process.exit(0));",
+ " const session = agent.session({ id: 'terminal' });",
+ ' console.log(`Chatting with ${BOT} (terminal mode; npm run telegram for the bot). Ctrl+C to quit.`);',
+ ' for (;;) {',
+ " const message = (await rl.question('you> ')).trim();",
+ ' if (!message) continue;',
+ ' try {',
+ ' const result = await session.send(message);',
+ ' console.log(`agent> ${result.text}`);',
+ " if (result.finishReason === 'awaiting-approval') {",
+ " console.log('(a tool call needs approval — auto-approved in terminal mode)');",
+ ' await agent.approvals.resolve({ id: result.approvalId!, approved: true });',
+ ' }',
+ ' } catch (error) {',
+ ' console.error(`error: ${errorText(error)}`);',
+ ' }',
+ ' }',
+ '}',
+ '',
+].join('\n');
+
+const MD_TEST_TS = [
+ "import { describe, expect, it } from 'vitest';",
+ "import { mdToTelegramHtml } from './md.js';",
+ '',
+ "describe('mdToTelegramHtml', () => {",
+ " it('converts bold, italic, code, bullets and links', () => {",
+ " expect(mdToTelegramHtml('**bold** and *it* and `code`')).toBe('bold and it and code');",
+ " expect(mdToTelegramHtml('- item\\n* item2')).toBe('• item\\n• item2');",
+ " expect(mdToTelegramHtml('[x](https://y.co)')).toBe('x');",
+ ' });',
+ '',
+ " it('escapes raw HTML before marking up', () => {",
+ " expect(mdToTelegramHtml('a < b & **c**')).toBe('a < b & c');",
+ " expect(mdToTelegramHtml('