owlcode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,17 @@
1
+ # Telegram Bot Token (from @BotFather)
2
+ TELEGRAM_BOT_TOKEN=
3
+
4
+ # Allowed Telegram user IDs (comma-separated)
5
+ ALLOWED_USERS=
6
+
7
+ # Default project directory
8
+ DEFAULT_PROJECT_DIR=
9
+
10
+ # Claude Code CLI path (optional, defaults to 'claude')
11
+ CLAUDE_CLI_PATH=claude
12
+
13
+ # Session idle timeout in minutes (default: 30)
14
+ SESSION_TIMEOUT_MIN=30
15
+
16
+ # Log level: debug | info | warn | error
17
+ LOG_LEVEL=info
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # owlcode
2
+
3
+ Remote Claude Code access via Telegram. Send a message, get Claude Code's response — right from your phone.
4
+
5
+ ## Prerequisites
6
+
7
+ - [Node.js](https://nodejs.org/) 20+
8
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
9
+
10
+ ### Claude Code authentication
11
+
12
+ Make sure Claude Code is logged in before starting the bot:
13
+
14
+ ```bash
15
+ claude /login
16
+ ```
17
+
18
+ Verify it works:
19
+
20
+ ```bash
21
+ claude --print "say hello"
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install -g owlcode
28
+ ```
29
+
30
+ Or clone the repo:
31
+
32
+ ```bash
33
+ git clone https://github.com/cr8rcho/owlcode.git
34
+ cd owlcode
35
+ npm install
36
+ npm run build
37
+ ```
38
+
39
+ ## Setup
40
+
41
+ ### 1. Create a Telegram bot
42
+
43
+ 1. Open [@BotFather](https://t.me/botfather) on Telegram
44
+ 2. Send `/newbot` and follow the steps
45
+ 3. Copy the bot token
46
+
47
+ ### 2. Get your Telegram user ID
48
+
49
+ 1. Open [@userinfobot](https://t.me/userinfobot) on Telegram
50
+ 2. It will show your user ID
51
+
52
+ ### 3. Run the setup wizard
53
+
54
+ ```bash
55
+ owlcode init
56
+ ```
57
+
58
+ This will ask for:
59
+ - Telegram bot token
60
+ - Your Telegram user ID (only authorized users can use the bot)
61
+ - Default project directory
62
+ - Claude Code CLI path (defaults to `claude`)
63
+ - Session idle timeout in minutes (defaults to 30)
64
+
65
+ A `.env` file is created with your settings.
66
+
67
+ ### 4. Start the bot
68
+
69
+ ```bash
70
+ owlcode start
71
+ ```
72
+
73
+ ## Commands
74
+
75
+ | Command | Description |
76
+ |---------|-------------|
77
+ | `/ls [path]` | Browse directories with inline buttons |
78
+ | `/status` | Session info — current directory, Claude status, verbose level |
79
+ | `/session_reset` | End current Claude Code conversation and start fresh |
80
+ | `/verbose <0\|1\|2>` | Set output detail: 0 = result only, 1 = tools + result (default), 2 = full |
81
+ | `/help` | Show available commands |
82
+
83
+ Any other message is forwarded to Claude Code as a prompt. Claude Code runs in `--print` (non-interactive) mode with the current project directory as its working directory.
84
+
85
+ ### Directory browsing
86
+
87
+ Use `/ls` to browse directories with tappable inline buttons. Git repos are sorted first and marked with `[git]`. Tap a folder to navigate into it, or tap "Select this directory" to set it as the current project.
88
+
89
+ You can also pass a path directly: `/ls ~/projects`
90
+
91
+ ## How it works
92
+
93
+ ### Session lifecycle
94
+
95
+ 1. Your first message creates a session with the default project directory.
96
+ 2. Each message spawns a Claude Code CLI process (`claude --print`) and passes your message via stdin.
97
+ 3. After the first successful response, subsequent messages use `--continue` to maintain conversation context.
98
+ 4. If no messages are sent for the configured timeout (default 30 min), the CLI process is released to free memory — but the conversation state is preserved. Your next message will resume with `--continue`.
99
+ 5. `/session_reset` fully ends the conversation. The next message starts a new one.
100
+
101
+ ### Session persistence
102
+
103
+ Session state (directory, verbose level) is saved to `.owlcode-sessions.json`. When the bot restarts, sessions are restored — so your project directory and settings survive reboots.
104
+
105
+ ### Architecture
106
+
107
+ ```
108
+ Telegram → TelegramBot (grammY) → SessionManager → ClaudeRunner (CLI subprocess)
109
+ ```
110
+
111
+ - **TelegramBot**: Thin grammY wrapper. All Telegram-specific code isolated here for easy replacement.
112
+ - **SessionManager**: Per-user session with project directory tracking, idle cleanup, and disk persistence.
113
+ - **ClaudeRunner**: Spawns `claude --print` as subprocess. Messages passed via stdin (supports CJK). Supports text and stream-json output modes.
114
+
115
+ ## CLI
116
+
117
+ | Command | Description |
118
+ |---------|-------------|
119
+ | `owlcode init` | Interactive setup wizard |
120
+ | `owlcode start` | Start the bot |
121
+ | `owlcode version` | Show version |
122
+ | `owlcode help` | Show help |
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ git clone https://github.com/cr8rcho/owlcode.git
128
+ cd owlcode
129
+ npm install
130
+ cp .env.example .env
131
+ # Edit .env with your settings
132
+ npm run dev
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from "events";
2
+ export interface ClaudeRunnerOptions {
3
+ cliPath: string;
4
+ cwd: string;
5
+ sessionId?: string;
6
+ }
7
+ export interface ClaudeResponse {
8
+ text: string;
9
+ toolUse?: string[];
10
+ }
11
+ export declare class ClaudeRunner extends EventEmitter {
12
+ private options;
13
+ private process;
14
+ constructor(options: ClaudeRunnerOptions, hasExistingSession?: boolean);
15
+ get isRunning(): boolean;
16
+ get cwd(): string;
17
+ private hasSession;
18
+ private buildArgs;
19
+ /**
20
+ * Send a message to Claude Code and get the response.
21
+ * Uses --print flag with --continue for session continuity.
22
+ * Message is passed via stdin to avoid encoding issues with CJK characters.
23
+ */
24
+ send(message: string): Promise<ClaudeResponse>;
25
+ /**
26
+ * Send a message using streaming mode with JSON output.
27
+ * Message is passed via stdin to avoid encoding issues.
28
+ */
29
+ sendStreaming(message: string, onChunk: (text: string) => void): Promise<ClaudeResponse>;
30
+ kill(): void;
31
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Claude Code CLI runner.
3
+ * Spawns and communicates with Claude Code as a subprocess.
4
+ */
5
+ import { spawn } from "child_process";
6
+ import { EventEmitter } from "events";
7
+ export class ClaudeRunner extends EventEmitter {
8
+ options;
9
+ process = null;
10
+ constructor(options, hasExistingSession = false) {
11
+ super();
12
+ this.options = options;
13
+ this.hasSession = hasExistingSession;
14
+ }
15
+ get isRunning() {
16
+ return this.process !== null && this.process.exitCode === null;
17
+ }
18
+ get cwd() {
19
+ return this.options.cwd;
20
+ }
21
+ hasSession = false;
22
+ buildArgs(outputFormat) {
23
+ const args = ["--print", "--output-format", outputFormat];
24
+ if (outputFormat === "stream-json") {
25
+ args.push("--verbose");
26
+ }
27
+ // Continue previous conversation if one exists
28
+ if (this.hasSession) {
29
+ args.push("--continue");
30
+ }
31
+ return args;
32
+ }
33
+ /**
34
+ * Send a message to Claude Code and get the response.
35
+ * Uses --print flag with --continue for session continuity.
36
+ * Message is passed via stdin to avoid encoding issues with CJK characters.
37
+ */
38
+ async send(message) {
39
+ return new Promise((resolve, reject) => {
40
+ const args = this.buildArgs("text");
41
+ const proc = spawn(this.options.cliPath, args, {
42
+ cwd: this.options.cwd,
43
+ env: {
44
+ ...process.env,
45
+ LANG: "en_US.UTF-8",
46
+ CLAUDE_CODE_DISABLE_NONINTERACTIVE_HINT: "1",
47
+ },
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ });
50
+ this.process = proc;
51
+ let stdout = "";
52
+ let stderr = "";
53
+ proc.stdout?.on("data", (data) => {
54
+ const chunk = data.toString("utf-8");
55
+ stdout += chunk;
56
+ this.emit("chunk", chunk);
57
+ });
58
+ proc.stderr?.on("data", (data) => {
59
+ stderr += data.toString("utf-8");
60
+ });
61
+ proc.on("close", (code) => {
62
+ this.process = null;
63
+ if (code === 0) {
64
+ this.hasSession = true;
65
+ resolve({
66
+ text: stdout.trim(),
67
+ });
68
+ }
69
+ else {
70
+ reject(new Error(`Claude Code exited with code ${code}: ${stderr.trim()}`));
71
+ }
72
+ });
73
+ proc.on("error", (err) => {
74
+ this.process = null;
75
+ reject(err);
76
+ });
77
+ // Write message via stdin then close
78
+ proc.stdin?.write(message, "utf-8");
79
+ proc.stdin?.end();
80
+ });
81
+ }
82
+ /**
83
+ * Send a message using streaming mode with JSON output.
84
+ * Message is passed via stdin to avoid encoding issues.
85
+ */
86
+ async sendStreaming(message, onChunk) {
87
+ return new Promise((resolve, reject) => {
88
+ const args = this.buildArgs("stream-json");
89
+ const proc = spawn(this.options.cliPath, args, {
90
+ cwd: this.options.cwd,
91
+ env: {
92
+ ...process.env,
93
+ LANG: "en_US.UTF-8",
94
+ CLAUDE_CODE_DISABLE_NONINTERACTIVE_HINT: "1",
95
+ },
96
+ stdio: ["pipe", "pipe", "pipe"],
97
+ });
98
+ this.process = proc;
99
+ let fullText = "";
100
+ let resultText = "";
101
+ let stderr = "";
102
+ const toolUse = [];
103
+ proc.stdout?.on("data", (data) => {
104
+ const lines = data.toString("utf-8").split("\n").filter(Boolean);
105
+ for (const line of lines) {
106
+ try {
107
+ const event = JSON.parse(line);
108
+ if (event.type === "assistant" && event.message?.content) {
109
+ for (const block of event.message.content) {
110
+ if (block.type === "text") {
111
+ fullText += block.text;
112
+ onChunk(block.text);
113
+ }
114
+ else if (block.type === "tool_use") {
115
+ toolUse.push(block.name);
116
+ }
117
+ }
118
+ }
119
+ else if (event.type === "result") {
120
+ resultText = event.result ?? "";
121
+ }
122
+ // Ignore other event types (system, user, etc.)
123
+ }
124
+ catch {
125
+ // Non-JSON line — skip to avoid prompt echo
126
+ }
127
+ }
128
+ });
129
+ proc.stderr?.on("data", (data) => {
130
+ stderr += data.toString("utf-8");
131
+ });
132
+ proc.on("close", (code) => {
133
+ this.process = null;
134
+ if (code === 0) {
135
+ this.hasSession = true;
136
+ // Prefer streamed assistant text; fall back to result text only if empty
137
+ const finalText = fullText.trim() || resultText.trim();
138
+ resolve({ text: finalText, toolUse });
139
+ }
140
+ else {
141
+ reject(new Error(`Claude Code exited with code ${code}: ${stderr.trim()}`));
142
+ }
143
+ });
144
+ proc.on("error", (err) => {
145
+ this.process = null;
146
+ reject(err);
147
+ });
148
+ // Write message via stdin then close
149
+ proc.stdin?.write(message, "utf-8");
150
+ proc.stdin?.end();
151
+ });
152
+ }
153
+ kill() {
154
+ if (this.process) {
155
+ this.process.kill("SIGTERM");
156
+ this.process = null;
157
+ }
158
+ this.hasSession = false;
159
+ }
160
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, writeFileSync, readFileSync } from "fs";
3
+ import { resolve, dirname } from "path";
4
+ import { createInterface } from "readline";
5
+ import { fileURLToPath } from "url";
6
+ import { spawn } from "child_process";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ function ask(rl, question, defaultValue) {
9
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
10
+ return new Promise((resolve) => {
11
+ rl.question(`${question}${suffix}: `, (answer) => {
12
+ resolve(answer.trim() || defaultValue || "");
13
+ });
14
+ });
15
+ }
16
+ async function init() {
17
+ console.log("\n🦉 owlcode setup\n");
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
19
+ console.log("1. Create a Telegram bot:");
20
+ console.log(" → Open @BotFather on Telegram");
21
+ console.log(" → Send /newbot and follow the steps");
22
+ console.log(" → Copy the bot token\n");
23
+ const botToken = await ask(rl, "Telegram bot token");
24
+ if (!botToken) {
25
+ console.error("Bot token is required.");
26
+ rl.close();
27
+ process.exit(1);
28
+ }
29
+ console.log("\n2. Get your Telegram user ID:");
30
+ console.log(" → Open @userinfobot on Telegram");
31
+ console.log(" → It will show your user ID\n");
32
+ const allowedUsers = await ask(rl, "Your Telegram user ID");
33
+ if (!allowedUsers) {
34
+ console.error("User ID is required.");
35
+ rl.close();
36
+ process.exit(1);
37
+ }
38
+ const defaultDir = await ask(rl, "Default project directory", process.env.HOME || "~");
39
+ const claudePath = await ask(rl, "Claude Code CLI path", "claude");
40
+ const sessionTimeout = await ask(rl, "Session idle timeout (minutes)", "30");
41
+ rl.close();
42
+ const envContent = [
43
+ `TELEGRAM_BOT_TOKEN=${botToken}`,
44
+ `ALLOWED_USERS=${allowedUsers}`,
45
+ `DEFAULT_PROJECT_DIR=${defaultDir}`,
46
+ `CLAUDE_CLI_PATH=${claudePath}`,
47
+ `SESSION_TIMEOUT_MIN=${sessionTimeout}`,
48
+ `LOG_LEVEL=info`,
49
+ ].join("\n");
50
+ const envPath = resolve(process.cwd(), ".env");
51
+ if (existsSync(envPath)) {
52
+ const overwrite = await new Promise((resolve) => {
53
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
54
+ rl2.question(".env already exists. Overwrite? (y/N): ", (answer) => {
55
+ rl2.close();
56
+ resolve(answer.trim().toLowerCase() === "y");
57
+ });
58
+ });
59
+ if (!overwrite) {
60
+ console.log("Skipped .env creation.");
61
+ return;
62
+ }
63
+ }
64
+ writeFileSync(envPath, envContent + "\n");
65
+ console.log("\n✓ .env created");
66
+ console.log("\nRun 'owlcode start' to launch the bot.");
67
+ }
68
+ async function start() {
69
+ const envPath = resolve(process.cwd(), ".env");
70
+ if (!existsSync(envPath)) {
71
+ console.error("No .env found. Run 'owlcode init' first.");
72
+ process.exit(1);
73
+ }
74
+ console.log("🦉 Starting owlcode...\n");
75
+ // Resolve the actual index.js path
76
+ const indexPath = resolve(__dirname, "index.js");
77
+ if (!existsSync(indexPath)) {
78
+ console.error(`Entry point not found: ${indexPath}`);
79
+ process.exit(1);
80
+ }
81
+ const child = spawn("node", [indexPath], {
82
+ cwd: process.cwd(),
83
+ stdio: "inherit",
84
+ env: process.env,
85
+ });
86
+ child.on("exit", (code) => {
87
+ process.exit(code ?? 0);
88
+ });
89
+ }
90
+ function version() {
91
+ try {
92
+ const pkgPath = resolve(__dirname, "..", "package.json");
93
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
94
+ console.log(`owlcode v${pkg.version}`);
95
+ }
96
+ catch {
97
+ console.log("owlcode (unknown version)");
98
+ }
99
+ }
100
+ function help() {
101
+ console.log(`
102
+ 🦉 owlcode — Remote Claude Code via Telegram
103
+
104
+ Commands:
105
+ init Interactive setup (creates .env)
106
+ start Start the bot
107
+ version Show version
108
+ help Show this help
109
+
110
+ Usage:
111
+ owlcode init
112
+ owlcode start
113
+ `);
114
+ }
115
+ // --- CLI Router ---
116
+ const command = process.argv[2];
117
+ switch (command) {
118
+ case "init":
119
+ init();
120
+ break;
121
+ case "start":
122
+ start();
123
+ break;
124
+ case "version":
125
+ case "--version":
126
+ case "-v":
127
+ version();
128
+ break;
129
+ case "help":
130
+ case "--help":
131
+ case "-h":
132
+ case undefined:
133
+ help();
134
+ break;
135
+ default:
136
+ console.error(`Unknown command: ${command}`);
137
+ help();
138
+ process.exit(1);
139
+ }
@@ -0,0 +1,13 @@
1
+ export interface Config {
2
+ telegram: {
3
+ botToken: string;
4
+ allowedUsers: number[];
5
+ };
6
+ claude: {
7
+ cliPath: string;
8
+ sessionTimeoutMin: number;
9
+ };
10
+ defaultProjectDir: string;
11
+ logLevel: "debug" | "info" | "warn" | "error";
12
+ }
13
+ export declare function loadConfig(): Config;
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ export function loadConfig() {
4
+ // Load .env file if exists
5
+ const envPath = resolve(process.cwd(), ".env");
6
+ if (existsSync(envPath)) {
7
+ const envContent = readFileSync(envPath, "utf-8");
8
+ for (const line of envContent.split("\n")) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed || trimmed.startsWith("#"))
11
+ continue;
12
+ const eqIdx = trimmed.indexOf("=");
13
+ if (eqIdx === -1)
14
+ continue;
15
+ const key = trimmed.slice(0, eqIdx).trim();
16
+ const value = trimmed.slice(eqIdx + 1).trim();
17
+ if (!process.env[key]) {
18
+ process.env[key] = value;
19
+ }
20
+ }
21
+ }
22
+ const botToken = process.env.TELEGRAM_BOT_TOKEN;
23
+ if (!botToken) {
24
+ throw new Error("TELEGRAM_BOT_TOKEN is required");
25
+ }
26
+ const allowedUsers = (process.env.ALLOWED_USERS ?? "")
27
+ .split(",")
28
+ .map((s) => s.trim())
29
+ .filter(Boolean)
30
+ .map(Number);
31
+ if (allowedUsers.length === 0) {
32
+ throw new Error("ALLOWED_USERS is required (comma-separated Telegram user IDs)");
33
+ }
34
+ return {
35
+ telegram: {
36
+ botToken,
37
+ allowedUsers,
38
+ },
39
+ claude: {
40
+ cliPath: process.env.CLAUDE_CLI_PATH ?? "claude",
41
+ sessionTimeoutMin: Number(process.env.SESSION_TIMEOUT_MIN ?? "30"),
42
+ },
43
+ defaultProjectDir: process.env.DEFAULT_PROJECT_DIR ?? process.env.HOME ?? "/tmp",
44
+ logLevel: process.env.LOG_LEVEL ?? "info",
45
+ };
46
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,190 @@
1
+ import { existsSync, readdirSync } from "fs";
2
+ import { resolve, basename } from "path";
3
+ import { loadConfig } from "./config.js";
4
+ import { TelegramBot } from "./telegram/bot.js";
5
+ import { SessionManager } from "./session/manager.js";
6
+ const config = loadConfig();
7
+ const bot = new TelegramBot(config);
8
+ const sessions = new SessionManager(config.claude.cliPath, config.defaultProjectDir, config.claude.sessionTimeoutMin);
9
+ // --- Path cache for short callback data ---
10
+ // Telegram callback_data has a 64-byte limit, so we use short IDs
11
+ const pathCache = new Map();
12
+ let pathCounter = 0;
13
+ function pathToId(fullPath) {
14
+ // Check if already cached
15
+ for (const [id, path] of pathCache) {
16
+ if (path === fullPath)
17
+ return id;
18
+ }
19
+ const id = `p${pathCounter++}`;
20
+ pathCache.set(id, fullPath);
21
+ return id;
22
+ }
23
+ function idToPath(id) {
24
+ return pathCache.get(id);
25
+ }
26
+ // --- Helper: list directories ---
27
+ function listDirs(dirPath) {
28
+ try {
29
+ const entries = readdirSync(dirPath, { withFileTypes: true });
30
+ return entries
31
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
32
+ .map((e) => {
33
+ const fullPath = resolve(dirPath, e.name);
34
+ const isGit = existsSync(resolve(fullPath, ".git"));
35
+ return { name: e.name, path: fullPath, isGit };
36
+ })
37
+ .sort((a, b) => {
38
+ // Git repos first, then alphabetical
39
+ if (a.isGit !== b.isGit)
40
+ return a.isGit ? -1 : 1;
41
+ return a.name.localeCompare(b.name);
42
+ });
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ const MAX_BUTTONS = 30;
49
+ function buildDirButtons(dirPath) {
50
+ const dirs = listDirs(dirPath);
51
+ const buttons = [];
52
+ // Parent directory
53
+ const parent = resolve(dirPath, "..");
54
+ if (parent !== dirPath) {
55
+ buttons.push({ text: ".. (up)", data: `ls:${pathToId(parent)}` });
56
+ }
57
+ // Subdirectories (limit to avoid Telegram errors)
58
+ const limited = dirs.slice(0, MAX_BUTTONS);
59
+ for (const dir of limited) {
60
+ const label = dir.isGit ? `[git] ${dir.name}` : dir.name;
61
+ buttons.push({ text: label, data: `ls:${pathToId(dir.path)}` });
62
+ }
63
+ if (dirs.length > MAX_BUTTONS) {
64
+ buttons.push({ text: `... +${dirs.length - MAX_BUTTONS} more`, data: `noop:0` });
65
+ }
66
+ // Select current directory button
67
+ buttons.push({ text: ">> Select this directory", data: `cd:${pathToId(dirPath)}` });
68
+ return buttons;
69
+ }
70
+ // --- Commands ---
71
+ bot.onCommand("ls", async (msg) => {
72
+ const session = sessions.getOrCreate(msg.userId, msg.chatId);
73
+ const arg = msg.text.replace(/^\/ls\s*/, "").trim();
74
+ const targetDir = arg
75
+ ? resolve(arg.replace("~", process.env.HOME ?? ""))
76
+ : session.cwd;
77
+ if (!existsSync(targetDir)) {
78
+ return `Directory not found: ${targetDir}`;
79
+ }
80
+ const buttons = buildDirButtons(targetDir);
81
+ if (buttons.length <= 1) {
82
+ return `No subdirectories in: ${targetDir}`;
83
+ }
84
+ await bot.sendWithButtons(msg.chatId, targetDir, buttons, 1);
85
+ });
86
+ bot.onCommand("status", async (msg) => {
87
+ const session = sessions.get(msg.userId);
88
+ if (!session)
89
+ return "No active session.";
90
+ return [
91
+ `Project: ${session.cwd}`,
92
+ `Claude: ${session.runner?.isRunning ? "running" : "idle"}`,
93
+ `Verbose: ${session.verbose}`,
94
+ ].join("\n");
95
+ });
96
+ bot.onCommand("session_reset", async (msg) => {
97
+ sessions.stopSession(msg.userId);
98
+ return "Session reset.";
99
+ });
100
+ bot.onCommand("verbose", async (msg) => {
101
+ const level = parseInt(msg.text.replace(/^\/verbose\s*/, "").trim(), 10);
102
+ if (![0, 1, 2].includes(level)) {
103
+ return "Usage: /verbose <0|1|2>";
104
+ }
105
+ sessions.setVerbose(msg.userId, level);
106
+ return `Verbose set to ${level}.`;
107
+ });
108
+ // --- Callback handlers ---
109
+ // ls: browse directories
110
+ bot.onCallback("ls", async (query) => {
111
+ const pathId = query.data.replace("ls:", "");
112
+ const dirPath = idToPath(pathId);
113
+ if (!dirPath || !existsSync(dirPath))
114
+ return "Directory not found";
115
+ const buttons = buildDirButtons(dirPath);
116
+ await bot.editWithButtons(query.chatId, query.messageId, dirPath, buttons, 1);
117
+ });
118
+ // cd: select directory from browse
119
+ bot.onCallback("cd", async (query) => {
120
+ const pathId = query.data.replace("cd:", "");
121
+ const dirPath = idToPath(pathId);
122
+ if (!dirPath || !existsSync(dirPath))
123
+ return "Directory not found";
124
+ sessions.setCwd(query.userId, dirPath);
125
+ await bot.editWithButtons(query.chatId, query.messageId, `Changed to: ${dirPath}`, [], 1);
126
+ return `Changed to ${basename(dirPath)}`;
127
+ });
128
+ // --- Message handler: forward to Claude Code ---
129
+ bot.onMessage(async (msg) => {
130
+ const session = sessions.getOrCreate(msg.userId, msg.chatId);
131
+ const runner = sessions.getRunner(session);
132
+ // Send typing indicator
133
+ await bot.sendTyping(msg.chatId);
134
+ // Set up periodic typing indicator
135
+ const typingInterval = setInterval(() => {
136
+ bot.sendTyping(msg.chatId).catch(() => { });
137
+ }, 4000);
138
+ try {
139
+ if (session.verbose === 0) {
140
+ const response = await runner.send(msg.text);
141
+ sessions.setHasConversation(msg.userId, true);
142
+ await bot.sendMessage(msg.chatId, response.text);
143
+ }
144
+ else {
145
+ // Streaming mode: show progress
146
+ let buffer = "";
147
+ let lastSent = Date.now();
148
+ const response = await runner.sendStreaming(msg.text, (chunk) => {
149
+ buffer += chunk;
150
+ const now = Date.now();
151
+ if (now - lastSent > 2000 && buffer.length > 0) {
152
+ lastSent = now;
153
+ }
154
+ });
155
+ let reply = response.text;
156
+ if (session.verbose >= 1 && response.toolUse && response.toolUse.length > 0) {
157
+ const tools = response.toolUse.map((t) => `• ${t}`).join("\n");
158
+ reply = `${tools}\n\n${reply}`;
159
+ }
160
+ sessions.setHasConversation(msg.userId, true);
161
+ await bot.sendMessage(msg.chatId, reply);
162
+ }
163
+ }
164
+ catch (err) {
165
+ const errorMsg = err instanceof Error ? err.message : String(err);
166
+ await bot.sendMessage(msg.chatId, `Error: ${errorMsg}`);
167
+ }
168
+ finally {
169
+ clearInterval(typingInterval);
170
+ }
171
+ });
172
+ // --- Finalize: register catch-all handlers AFTER commands ---
173
+ bot.finalize();
174
+ // --- Start ---
175
+ sessions.startCleanup();
176
+ bot.start().then(() => {
177
+ console.log("[owlcode] Bot is running");
178
+ });
179
+ // Graceful shutdown
180
+ process.on("SIGINT", async () => {
181
+ console.log("[owlcode] Shutting down...");
182
+ sessions.stopCleanup();
183
+ await bot.stop();
184
+ process.exit(0);
185
+ });
186
+ process.on("SIGTERM", async () => {
187
+ sessions.stopCleanup();
188
+ await bot.stop();
189
+ process.exit(0);
190
+ });
@@ -0,0 +1,38 @@
1
+ import { ClaudeRunner } from "../claude/runner.js";
2
+ export interface Session {
3
+ userId: number;
4
+ chatId: number;
5
+ cwd: string;
6
+ runner: ClaudeRunner | null;
7
+ lastActive: number;
8
+ verbose: 0 | 1 | 2;
9
+ hasConversation: boolean;
10
+ }
11
+ export declare class SessionManager {
12
+ private sessions;
13
+ private cliPath;
14
+ private defaultCwd;
15
+ private timeoutMin;
16
+ private cleanupInterval;
17
+ private statePath;
18
+ constructor(cliPath: string, defaultCwd: string, timeoutMin: number);
19
+ private loadState;
20
+ private saveState;
21
+ getOrCreate(userId: number, chatId: number): Session;
22
+ get(userId: number): Session | undefined;
23
+ setCwd(userId: number, cwd: string): void;
24
+ getRunner(session: Session): ClaudeRunner;
25
+ /** Mark that a conversation exists for this session */
26
+ setHasConversation(userId: number, value: boolean): void;
27
+ setVerbose(userId: number, level: 0 | 1 | 2): void;
28
+ stopSession(userId: number): void;
29
+ /** Start periodic cleanup — only cleans up runner objects, preserves session continuity */
30
+ startCleanup(): void;
31
+ stopCleanup(): void;
32
+ /** Get all active sessions info */
33
+ listSessions(): Array<{
34
+ userId: number;
35
+ cwd: string;
36
+ active: boolean;
37
+ }>;
38
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Session manager — one session per user, tracks current project directory.
3
+ * Persists session state (cwd, verbose) to disk for restart recovery.
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync } from "fs";
6
+ import { resolve } from "path";
7
+ import { ClaudeRunner } from "../claude/runner.js";
8
+ export class SessionManager {
9
+ sessions = new Map();
10
+ cliPath;
11
+ defaultCwd;
12
+ timeoutMin;
13
+ cleanupInterval = null;
14
+ statePath;
15
+ constructor(cliPath, defaultCwd, timeoutMin) {
16
+ this.cliPath = cliPath;
17
+ this.defaultCwd = defaultCwd;
18
+ this.timeoutMin = timeoutMin;
19
+ this.statePath = resolve(process.cwd(), ".owlcode-sessions.json");
20
+ this.loadState();
21
+ }
22
+ loadState() {
23
+ try {
24
+ if (existsSync(this.statePath)) {
25
+ const data = JSON.parse(readFileSync(this.statePath, "utf-8"));
26
+ for (const ps of data) {
27
+ if (existsSync(ps.cwd)) {
28
+ this.sessions.set(ps.userId, {
29
+ userId: ps.userId,
30
+ chatId: ps.chatId,
31
+ cwd: ps.cwd,
32
+ runner: null,
33
+ lastActive: Date.now(),
34
+ verbose: ps.verbose ?? 1,
35
+ hasConversation: true,
36
+ });
37
+ }
38
+ }
39
+ console.log(`[session] Restored ${this.sessions.size} session(s)`);
40
+ }
41
+ }
42
+ catch {
43
+ // Ignore corrupt state file
44
+ }
45
+ }
46
+ saveState() {
47
+ const data = Array.from(this.sessions.values()).map((s) => ({
48
+ userId: s.userId,
49
+ chatId: s.chatId,
50
+ cwd: s.cwd,
51
+ verbose: s.verbose,
52
+ }));
53
+ try {
54
+ writeFileSync(this.statePath, JSON.stringify(data, null, 2));
55
+ }
56
+ catch {
57
+ // Ignore write errors
58
+ }
59
+ }
60
+ getOrCreate(userId, chatId) {
61
+ let session = this.sessions.get(userId);
62
+ if (!session) {
63
+ session = {
64
+ userId,
65
+ chatId,
66
+ cwd: this.defaultCwd,
67
+ runner: null,
68
+ lastActive: Date.now(),
69
+ verbose: 1,
70
+ hasConversation: false,
71
+ };
72
+ this.sessions.set(userId, session);
73
+ this.saveState();
74
+ }
75
+ session.lastActive = Date.now();
76
+ return session;
77
+ }
78
+ get(userId) {
79
+ return this.sessions.get(userId);
80
+ }
81
+ setCwd(userId, cwd) {
82
+ const session = this.sessions.get(userId);
83
+ if (session) {
84
+ if (session.cwd !== cwd) {
85
+ if (session.runner) {
86
+ session.runner.kill();
87
+ session.runner = null;
88
+ }
89
+ session.hasConversation = false;
90
+ }
91
+ session.cwd = cwd;
92
+ this.saveState();
93
+ }
94
+ }
95
+ getRunner(session) {
96
+ if (!session.runner) {
97
+ session.runner = new ClaudeRunner({
98
+ cliPath: this.cliPath,
99
+ cwd: session.cwd,
100
+ }, session.hasConversation);
101
+ }
102
+ return session.runner;
103
+ }
104
+ /** Mark that a conversation exists for this session */
105
+ setHasConversation(userId, value) {
106
+ const session = this.sessions.get(userId);
107
+ if (session) {
108
+ session.hasConversation = value;
109
+ }
110
+ }
111
+ setVerbose(userId, level) {
112
+ const session = this.sessions.get(userId);
113
+ if (session) {
114
+ session.verbose = level;
115
+ this.saveState();
116
+ }
117
+ }
118
+ stopSession(userId) {
119
+ const session = this.sessions.get(userId);
120
+ if (session) {
121
+ if (session.runner) {
122
+ session.runner.kill();
123
+ session.runner = null;
124
+ }
125
+ session.hasConversation = false;
126
+ }
127
+ }
128
+ /** Start periodic cleanup — only cleans up runner objects, preserves session continuity */
129
+ startCleanup() {
130
+ this.cleanupInterval = setInterval(() => {
131
+ const now = Date.now();
132
+ const timeoutMs = this.timeoutMin * 60 * 1000;
133
+ for (const [userId, session] of this.sessions) {
134
+ if (now - session.lastActive > timeoutMs && session.runner) {
135
+ console.log(`[session] Releasing idle runner for user ${userId}`);
136
+ // Only null out the runner object, don't kill (preserves hasSession for --continue)
137
+ session.runner = null;
138
+ }
139
+ }
140
+ }, 60_000);
141
+ }
142
+ stopCleanup() {
143
+ if (this.cleanupInterval) {
144
+ clearInterval(this.cleanupInterval);
145
+ this.cleanupInterval = null;
146
+ }
147
+ }
148
+ /** Get all active sessions info */
149
+ listSessions() {
150
+ return Array.from(this.sessions.values()).map((s) => ({
151
+ userId: s.userId,
152
+ cwd: s.cwd,
153
+ active: s.runner?.isRunning ?? false,
154
+ }));
155
+ }
156
+ }
@@ -0,0 +1,47 @@
1
+ import type { Config } from "../config.js";
2
+ export interface IncomingMessage {
3
+ chatId: number;
4
+ userId: number;
5
+ text: string;
6
+ messageId: number;
7
+ }
8
+ export interface CallbackQuery {
9
+ chatId: number;
10
+ userId: number;
11
+ data: string;
12
+ messageId: number;
13
+ }
14
+ export type MessageHandler = (msg: IncomingMessage) => Promise<void>;
15
+ export type CallbackHandler = (query: CallbackQuery) => Promise<string | void>;
16
+ export declare class TelegramBot {
17
+ private bot;
18
+ private config;
19
+ private handler;
20
+ private callbackHandlers;
21
+ constructor(config: Config);
22
+ onMessage(handler: MessageHandler): void;
23
+ /** Register a command handler (abstracted from grammY) */
24
+ onCommand(command: string, handler: (msg: IncomingMessage) => Promise<string | void>): void;
25
+ /** Register a callback query handler by prefix */
26
+ onCallback(prefix: string, handler: CallbackHandler): void;
27
+ /**
28
+ * Finalize bot setup — registers catch-all handlers.
29
+ * Must be called AFTER all onCommand/onCallback registrations.
30
+ */
31
+ finalize(): void;
32
+ sendMessage(chatId: number, text: string): Promise<void>;
33
+ /** Send a message with inline keyboard buttons */
34
+ sendWithButtons(chatId: number, text: string, buttons: Array<{
35
+ text: string;
36
+ data: string;
37
+ }>, columns?: number): Promise<void>;
38
+ /** Edit an existing message with new buttons */
39
+ editWithButtons(chatId: number, messageId: number, text: string, buttons: Array<{
40
+ text: string;
41
+ data: string;
42
+ }>, columns?: number): Promise<void>;
43
+ sendTyping(chatId: number): Promise<void>;
44
+ private splitMessage;
45
+ start(): Promise<void>;
46
+ stop(): Promise<void>;
47
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Telegram layer — thin wrapper around grammY.
3
+ * All grammY-specific code stays in this directory.
4
+ * The rest of the app communicates through MessageHandler interface.
5
+ */
6
+ import { Bot, InlineKeyboard } from "grammy";
7
+ export class TelegramBot {
8
+ bot;
9
+ config;
10
+ handler = null;
11
+ callbackHandlers = new Map();
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.bot = new Bot(config.telegram.botToken);
15
+ // Auth middleware — must be first
16
+ this.bot.use(async (ctx, next) => {
17
+ const userId = ctx.from?.id;
18
+ if (!userId || !this.config.telegram.allowedUsers.includes(userId)) {
19
+ await ctx.reply("Unauthorized.");
20
+ return;
21
+ }
22
+ await next();
23
+ });
24
+ // Built-in commands
25
+ this.bot.command("start", (ctx) => ctx.reply("owlcode ready. Send a message to start coding."));
26
+ this.bot.command("help", (ctx) => ctx.reply([
27
+ "/projects — List registered projects",
28
+ "/ls — Browse directories (with buttons)",
29
+ "/status — Current session status",
30
+ "/session_reset — Reset Claude Code session",
31
+ "/verbose <0|1|2> — Output detail level",
32
+ "",
33
+ "Any other message is sent to Claude Code.",
34
+ ].join("\n")));
35
+ }
36
+ onMessage(handler) {
37
+ this.handler = handler;
38
+ }
39
+ /** Register a command handler (abstracted from grammY) */
40
+ onCommand(command, handler) {
41
+ this.bot.command(command, async (ctx) => {
42
+ try {
43
+ const result = await handler({
44
+ chatId: ctx.chat.id,
45
+ userId: ctx.from.id,
46
+ text: ctx.message?.text ?? "",
47
+ messageId: ctx.message?.message_id ?? 0,
48
+ });
49
+ if (result) {
50
+ await ctx.reply(result);
51
+ }
52
+ }
53
+ catch (err) {
54
+ console.error(`[command:${command}]`, err);
55
+ await ctx.reply(`Error: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ });
58
+ }
59
+ /** Register a callback query handler by prefix */
60
+ onCallback(prefix, handler) {
61
+ this.callbackHandlers.set(prefix, handler);
62
+ }
63
+ /**
64
+ * Finalize bot setup — registers catch-all handlers.
65
+ * Must be called AFTER all onCommand/onCallback registrations.
66
+ */
67
+ finalize() {
68
+ // Callback query handler (inline buttons)
69
+ this.bot.on("callback_query:data", async (ctx) => {
70
+ const data = ctx.callbackQuery.data;
71
+ const prefix = data.split(":")[0];
72
+ const handler = this.callbackHandlers.get(prefix);
73
+ if (handler) {
74
+ try {
75
+ const result = await handler({
76
+ chatId: ctx.chat?.id ?? 0,
77
+ userId: ctx.from.id,
78
+ data,
79
+ messageId: ctx.callbackQuery.message?.message_id ?? 0,
80
+ });
81
+ if (result) {
82
+ await ctx.answerCallbackQuery({ text: result });
83
+ }
84
+ else {
85
+ await ctx.answerCallbackQuery();
86
+ }
87
+ }
88
+ catch (err) {
89
+ console.error(`[callback:${prefix}]`, err);
90
+ await ctx.answerCallbackQuery({ text: "Error occurred" });
91
+ }
92
+ }
93
+ else {
94
+ await ctx.answerCallbackQuery();
95
+ }
96
+ });
97
+ // Catch-all: forward non-command text messages to handler
98
+ this.bot.on("message:text", async (ctx) => {
99
+ if (!this.handler)
100
+ return;
101
+ const text = ctx.message.text;
102
+ if (text.startsWith("/"))
103
+ return;
104
+ await this.handler({
105
+ chatId: ctx.chat.id,
106
+ userId: ctx.from.id,
107
+ text,
108
+ messageId: ctx.message.message_id,
109
+ });
110
+ });
111
+ }
112
+ async sendMessage(chatId, text) {
113
+ const chunks = this.splitMessage(text);
114
+ for (const chunk of chunks) {
115
+ await this.bot.api.sendMessage(chatId, chunk, {
116
+ parse_mode: "Markdown",
117
+ });
118
+ }
119
+ }
120
+ /** Send a message with inline keyboard buttons */
121
+ async sendWithButtons(chatId, text, buttons, columns = 2) {
122
+ const keyboard = new InlineKeyboard();
123
+ buttons.forEach((btn, i) => {
124
+ keyboard.text(btn.text, btn.data);
125
+ if ((i + 1) % columns === 0)
126
+ keyboard.row();
127
+ });
128
+ await this.bot.api.sendMessage(chatId, text, {
129
+ reply_markup: keyboard,
130
+ });
131
+ }
132
+ /** Edit an existing message with new buttons */
133
+ async editWithButtons(chatId, messageId, text, buttons, columns = 2) {
134
+ const keyboard = new InlineKeyboard();
135
+ buttons.forEach((btn, i) => {
136
+ keyboard.text(btn.text, btn.data);
137
+ if ((i + 1) % columns === 0)
138
+ keyboard.row();
139
+ });
140
+ await this.bot.api.editMessageText(chatId, messageId, text, {
141
+ reply_markup: keyboard,
142
+ });
143
+ }
144
+ async sendTyping(chatId) {
145
+ await this.bot.api.sendChatAction(chatId, "typing");
146
+ }
147
+ splitMessage(text, maxLen = 4096) {
148
+ if (text.length <= maxLen)
149
+ return [text];
150
+ const chunks = [];
151
+ let remaining = text;
152
+ while (remaining.length > 0) {
153
+ if (remaining.length <= maxLen) {
154
+ chunks.push(remaining);
155
+ break;
156
+ }
157
+ let splitIdx = remaining.lastIndexOf("\n", maxLen);
158
+ if (splitIdx === -1 || splitIdx < maxLen / 2) {
159
+ splitIdx = maxLen;
160
+ }
161
+ chunks.push(remaining.slice(0, splitIdx));
162
+ remaining = remaining.slice(splitIdx);
163
+ }
164
+ return chunks;
165
+ }
166
+ async start() {
167
+ console.log("[telegram] Starting bot...");
168
+ // Register command menu (shows when user types /)
169
+ await this.bot.api.setMyCommands([
170
+ { command: "ls", description: "Browse directories" },
171
+ { command: "status", description: "Session status" },
172
+ { command: "session_reset", description: "Reset Claude Code session" },
173
+ { command: "verbose", description: "Output detail level (0/1/2)" },
174
+ { command: "help", description: "Show available commands" },
175
+ ]);
176
+ this.bot.catch((err) => {
177
+ console.error("[bot] Error:", err.message ?? err);
178
+ });
179
+ this.bot.start();
180
+ }
181
+ async stop() {
182
+ await this.bot.stop();
183
+ }
184
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "owlcode",
3
+ "version": "0.1.0",
4
+ "description": "Telegram bot for remote Claude Code access — your owl delivers code commands and brings back results",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "owlcode": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ ".env.example"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsx watch src/index.ts",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "telegram",
23
+ "claude-code",
24
+ "ai",
25
+ "coding-agent",
26
+ "cli"
27
+ ],
28
+ "author": "John Cho <cr8rcho@users.noreply.github.com>",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/cr8rcho/owlcode.git"
33
+ },
34
+ "homepage": "https://github.com/cr8rcho/owlcode",
35
+ "engines": {
36
+ "node": ">=20.0.0"
37
+ },
38
+ "dependencies": {
39
+ "grammy": "^1.40.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^25.3.0",
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.9.3"
45
+ }
46
+ }