@lapage/codex-telegram-bridge 0.1.0 → 0.1.1

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 CHANGED
@@ -1,17 +1,29 @@
1
+ # Required. Telegram bot token from BotFather.
1
2
  TELEGRAM_BOT_TOKEN=123456:replace_me
3
+
4
+ # Required. Allowed Telegram user IDs, comma-separated.
2
5
  TELEGRAM_ALLOWED_USER_IDS=123456789
6
+
7
+ # Codex workspace path. Options: ~, /absolute/path.
3
8
  CODEX_CWD=~
9
+
10
+ # Codex binary. Options: codex, /absolute/path/to/codex.
4
11
  CODEX_COMMAND=codex
5
- CODEX_ARGS=--search --yolo
6
- CODEX_SUBMIT_KEY=Enter
7
- CODEX_SUBMIT_DELAY_MS=800
8
- TMUX_SESSION=codex-telegram-bridge
9
- CODEX_COLS=120
10
- CODEX_ROWS=40
11
- FLUSH_INTERVAL_MS=1200
12
- POLL_INTERVAL_MS=500
13
- CODEX_STARTUP_DELAY_MS=1500
12
+
13
+ # Approval mode. Options: never, on-request, on-failure, untrusted.
14
+ CODEX_APPROVAL_POLICY=never
15
+
16
+ # Sandbox mode. Options: danger-full-access, workspace-write, read-only.
17
+ CODEX_SANDBOX=danger-full-access
18
+
19
+ # Stream edit throttle in ms. Options: 500-1500 typical.
14
20
  STREAM_EDIT_INTERVAL_MS=650
21
+
22
+ # Min chars before stream edit. Options: 10-100 typical.
15
23
  STREAM_MIN_CHANGE_CHARS=24
24
+
25
+ # Telegram typing interval in ms. Options: 3000-5000 typical.
16
26
  TYPING_INTERVAL_MS=4000
27
+
28
+ # Max Telegram chunk size. Options: <=3900 recommended.
17
29
  MAX_TELEGRAM_CHARS=3500
package/README.md CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  Control a local **Codex CLI** session from Telegram without opening any inbound ports to the internet.
4
4
 
5
- This package is useful when Codex runs on a home PC, workstation, homelab box, or private server and you want a simple mobile chat interface. The bridge starts Codex inside a detached `tmux` session, receives Telegram messages from an allowlisted user, sends them into Codex, and relays Codex output back to Telegram.
5
+ This package is useful when Codex runs on a home PC, workstation, homelab box, or private server and you want a simple mobile chat interface. The bridge starts `codex app-server` over stdio, receives Telegram messages from an allowlisted user, sends them into Codex, and streams Codex output back to Telegram.
6
6
 
7
7
  ## Why Use This
8
8
 
9
9
  - No public SSH, HTTP, webhook, tunnel, or reverse proxy is required.
10
10
  - Telegram provides the outbound connection path from your machine.
11
- - Codex keeps running in `tmux`, so the session can survive terminal disconnects.
11
+ - Codex uses the app-server JSON-RPC protocol instead of terminal screen scraping.
12
+ - Stdio transport stays local-only and avoids exposing a network listener.
12
13
  - Access is restricted to the Telegram user IDs you allowlist.
13
14
 
14
15
  ## Requirements
15
16
 
16
17
  - Node.js 22 or newer
17
18
  - npm
18
- - `tmux`
19
19
  - Codex CLI available on `PATH`
20
20
  - A Telegram bot token from [@BotFather](https://t.me/BotFather)
21
21
  - Your numeric Telegram user ID, for example from [@userinfobot](https://t.me/userinfobot)
@@ -25,26 +25,9 @@ Check prerequisites:
25
25
  ```sh
26
26
  node --version
27
27
  npm --version
28
- tmux -V
29
28
  codex --version
30
29
  ```
31
30
 
32
- Install `tmux` if it is missing:
33
-
34
- ```sh
35
- # macOS
36
- brew install tmux
37
-
38
- # Ubuntu / Debian
39
- sudo apt update && sudo apt install -y tmux
40
-
41
- # Fedora
42
- sudo dnf install -y tmux
43
-
44
- # Arch Linux
45
- sudo pacman -S tmux
46
- ```
47
-
48
31
  ## Quick Start
49
32
 
50
33
  Install the package globally:
@@ -87,6 +70,10 @@ codex-telegram-bridge
87
70
 
88
71
  Open your Telegram bot chat and send `/status`. Any normal message after that is sent to Codex as a prompt.
89
72
 
73
+ Codex output updates when app-server reports completed items. The bridge keeps a per-turn cache of completed messages, command summaries, and tool summaries, then edits/splits Telegram messages from that cache until the turn completes.
74
+
75
+ You can also send screenshots, documents, PDFs, videos, audio, or voice notes. The bridge downloads each attachment to `/tmp/codex-telegram-bridge/` with a random filename, includes the local path in the Codex prompt, and attaches images as `localImage` inputs for Codex vision.
76
+
90
77
  ## Configuration File
91
78
 
92
79
  By default, the CLI reads:
@@ -111,15 +98,8 @@ CODEX_TELEGRAM_BRIDGE_ENV=/path/to/.env codex-telegram-bridge
111
98
  | `TELEGRAM_ALLOWED_USER_IDS` | required | Comma-separated numeric Telegram user IDs allowed to use the bot. |
112
99
  | `CODEX_CWD` | current directory | Working directory where Codex starts. `~` is supported. |
113
100
  | `CODEX_COMMAND` | `codex` | Codex command or binary path. |
114
- | `CODEX_ARGS` | `--search --yolo` | Extra Codex startup arguments. |
115
- | `CODEX_SUBMIT_KEY` | `Enter` | tmux key used to submit a prompt to Codex. |
116
- | `CODEX_SUBMIT_DELAY_MS` | `800` | Delay after pasting text before pressing submit. |
117
- | `CODEX_STARTUP_DELAY_MS` | `1500` | Delay before first prompt if Codex has not produced output yet. |
118
- | `TMUX_SESSION` | `codex-telegram-bridge` | Detached tmux session name. |
119
- | `CODEX_COLS` | `120` | tmux pane width. |
120
- | `CODEX_ROWS` | `40` | tmux pane height. |
121
- | `POLL_INTERVAL_MS` | `500` | How often the bridge polls tmux output. |
122
- | `FLUSH_INTERVAL_MS` | `1200` | Delay before flushing non-stream fallback output. |
101
+ | `CODEX_APPROVAL_POLICY` | `never` | App-server thread approval policy: `never`, `on-request`, `on-failure`, or `untrusted`. |
102
+ | `CODEX_SANDBOX` | `danger-full-access` | App-server thread sandbox: `danger-full-access`, `workspace-write`, or `read-only`. |
123
103
  | `STREAM_EDIT_INTERVAL_MS` | `650` | Minimum interval between Telegram message edits. |
124
104
  | `STREAM_MIN_CHANGE_CHARS` | `24` | Minimum text growth before editing mid-response. |
125
105
  | `TYPING_INTERVAL_MS` | `4000` | How often to send Telegram typing action. |
@@ -127,38 +107,32 @@ CODEX_TELEGRAM_BRIDGE_ENV=/path/to/.env codex-telegram-bridge
127
107
 
128
108
  ## Telegram Commands
129
109
 
130
- - `/status` — show bridge state, tmux session, working directory, and Codex command.
110
+ - `/status` — show bridge state, stdio transport, working directory, and Codex command.
131
111
  - `/flush` — force-read and relay the latest Codex response.
132
- - `/interrupt` — send Ctrl-C to Codex.
133
- - `/restart` — restart the Codex tmux session.
134
- - `/stop` — stop the Codex tmux session.
112
+ - `/interrupt` — interrupt the active Codex turn.
113
+ - `/restart` — restart Codex app-server.
114
+ - `/stop` — stop Codex app-server.
135
115
  - Any other text is sent directly to Codex as a prompt.
136
116
 
137
117
  ## Running as a Background Service
138
118
 
139
- For a long-running home server setup, run the bridge with your preferred process manager, for example `systemd`, `pm2`, `launchd`, or a persistent `tmux` session.
119
+ For a long-running home server setup, run the bridge with your preferred process manager, for example `systemd`, `pm2`, or `launchd`.
140
120
 
141
- Example with `tmux`:
121
+ Example with `pm2`:
142
122
 
143
123
  ```sh
144
- tmux new -s codex-telegram-bridge-runner 'codex-telegram-bridge'
145
- ```
146
-
147
- Detach from tmux with:
148
-
149
- ```text
150
- Ctrl-b, then d
124
+ pm2 start codex-telegram-bridge --name codex-telegram-bridge
151
125
  ```
152
126
 
153
127
  ## Debugging Codex
154
128
 
155
- The bridge itself runs Codex inside another detached `tmux` session. Attach to it with:
129
+ The bridge runs Codex as a child process with stdio JSON-RPC. To inspect protocol behavior directly, run:
156
130
 
157
131
  ```sh
158
- tmux attach -t codex-telegram-bridge
132
+ codex app-server --stdio
159
133
  ```
160
134
 
161
- If `TMUX_SESSION` is changed in the config file, use that session name instead.
135
+ The bridge always starts Codex with `app-server --stdio`; `CODEX_COMMAND` only changes the binary path.
162
136
 
163
137
  ## Install From Source
164
138
 
@@ -197,7 +171,7 @@ Recommended safeguards:
197
171
  - Do not share your Telegram bot token.
198
172
  - Run the bridge under a user account with appropriate file permissions.
199
173
  - Point `CODEX_CWD` at a workspace you are comfortable controlling remotely.
200
- - Understand that `CODEX_ARGS=--yolo` allows Codex to act with fewer confirmations.
174
+ - Understand that `CODEX_APPROVAL_POLICY=never` and `CODEX_SANDBOX=danger-full-access` allow Codex to act with fewer confirmations and broader machine access.
201
175
 
202
176
  ## Repository
203
177
 
@@ -1,9 +1,17 @@
1
- import { capturePane, runTmux, shellCommand, tmuxSessionExists } from './tmux.js';
2
- export class CodexSession {
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { createInterface } from 'node:readline';
4
+ export class CodexSession extends EventEmitter {
3
5
  config;
6
+ process = null;
4
7
  running = false;
5
8
  startPromise = null;
9
+ requestId = 1;
10
+ pendingRequests = new Map();
11
+ threadId = null;
12
+ activeTurnId = null;
6
13
  constructor(config) {
14
+ super();
7
15
  this.config = config;
8
16
  }
9
17
  get isRunning() {
@@ -13,64 +21,229 @@ export class CodexSession {
13
21
  if (this.startPromise) {
14
22
  return this.startPromise;
15
23
  }
16
- this.startPromise = this.startSession().finally(() => {
24
+ this.startPromise = this.startServer().finally(() => {
17
25
  this.startPromise = null;
18
26
  });
19
27
  return this.startPromise;
20
28
  }
21
29
  async stop() {
22
- if (await this.exists()) {
23
- await runTmux(['kill-session', '-t', this.config.tmuxSession]).catch(() => undefined);
24
- }
25
30
  this.running = false;
31
+ this.threadId = null;
32
+ this.activeTurnId = null;
33
+ for (const pending of this.pendingRequests.values()) {
34
+ pending.reject(new Error('Codex app-server stopped.'));
35
+ }
36
+ this.pendingRequests.clear();
37
+ if (this.process && !this.process.killed) {
38
+ this.process.kill('SIGTERM');
39
+ }
40
+ this.process = null;
26
41
  }
27
42
  async restart() {
28
43
  await this.stop();
29
44
  await this.start();
30
45
  }
31
- async sendText(text) {
32
- await runTmux(['set-buffer', '-b', 'codex-telegram-input', text]);
33
- await runTmux(['paste-buffer', '-b', 'codex-telegram-input', '-t', this.config.tmuxSession]);
34
- await sleep(this.config.codexSubmitDelayMs);
35
- await runTmux(['send-keys', '-t', this.config.tmuxSession, this.config.codexSubmitKey]);
46
+ async sendText(text, attachments = []) {
47
+ await this.start();
48
+ if (!this.threadId) {
49
+ throw new Error('Codex thread is not ready.');
50
+ }
51
+ const input = [{
52
+ type: 'text',
53
+ text: textWithAttachmentContext(text, attachments),
54
+ text_elements: [],
55
+ }];
56
+ for (const attachment of attachments) {
57
+ if (attachment.kind === 'image') {
58
+ input.push({
59
+ type: 'localImage',
60
+ path: attachment.path,
61
+ });
62
+ }
63
+ }
64
+ const result = await this.request('turn/start', {
65
+ threadId: this.threadId,
66
+ input,
67
+ });
68
+ const turnId = getString(result?.turn?.id);
69
+ if (turnId) {
70
+ this.activeTurnId = turnId;
71
+ }
36
72
  }
37
73
  async interrupt() {
38
- if (await this.exists()) {
39
- await runTmux(['send-keys', '-t', this.config.tmuxSession, 'C-c']);
74
+ if (!this.threadId || !this.activeTurnId) {
75
+ return;
40
76
  }
77
+ await this.request('turn/interrupt', {
78
+ threadId: this.threadId,
79
+ turnId: this.activeTurnId,
80
+ }).catch(() => undefined);
41
81
  }
42
- async waitUntilReady(hasOutput) {
43
- if (this.config.startupDelayMs > 0 && !hasOutput) {
44
- await sleep(this.config.startupDelayMs);
82
+ async startServer() {
83
+ if (this.running && this.process && !this.process.killed) {
84
+ return;
45
85
  }
86
+ this.process = spawn(this.config.codexCommand, ['app-server', '--stdio'], {
87
+ cwd: this.config.codexCwd,
88
+ env: process.env,
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ });
91
+ this.process.on('exit', (code, signal) => {
92
+ this.running = false;
93
+ this.threadId = null;
94
+ this.activeTurnId = null;
95
+ this.process = null;
96
+ for (const pending of this.pendingRequests.values()) {
97
+ pending.reject(new Error(`Codex app-server exited${code === null ? '' : ` with code ${code}`}.`));
98
+ }
99
+ this.pendingRequests.clear();
100
+ this.emit('exit', code, signal);
101
+ });
102
+ this.process.stderr.on('data', (chunk) => {
103
+ const message = chunk.toString('utf8').trim();
104
+ if (message) {
105
+ this.emit('error', message);
106
+ }
107
+ });
108
+ createInterface({ input: this.process.stdout }).on('line', (line) => this.handleLine(line));
109
+ await this.request('initialize', {
110
+ clientInfo: {
111
+ name: 'lapage-codex-telegram-bridge',
112
+ title: 'LaPage Codex Telegram Bridge',
113
+ version: '0.1.0',
114
+ },
115
+ capabilities: {
116
+ experimentalApi: false,
117
+ requestAttestation: false,
118
+ },
119
+ });
120
+ this.notify('initialized');
121
+ const threadStartResult = await this.request('thread/start', {
122
+ cwd: this.config.codexCwd,
123
+ approvalPolicy: this.config.codexApprovalPolicy,
124
+ sandbox: this.config.codexSandbox,
125
+ threadSource: 'telegram-bridge',
126
+ sessionStartSource: 'startup',
127
+ ephemeral: false,
128
+ });
129
+ const threadId = getString(threadStartResult?.thread
130
+ ?.id);
131
+ if (!threadId) {
132
+ throw new Error('Codex app-server did not return a thread id.');
133
+ }
134
+ this.threadId = threadId;
135
+ this.running = true;
46
136
  }
47
- async exists() {
48
- return tmuxSessionExists(this.config.tmuxSession);
137
+ request(method, params) {
138
+ if (!this.process?.stdin.writable) {
139
+ return Promise.reject(new Error('Codex app-server is not running.'));
140
+ }
141
+ const id = this.requestId++;
142
+ const payload = { method, id, params };
143
+ return new Promise((resolve, reject) => {
144
+ this.pendingRequests.set(id, { resolve, reject });
145
+ this.process.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
146
+ if (error) {
147
+ this.pendingRequests.delete(id);
148
+ reject(error);
149
+ }
150
+ });
151
+ });
49
152
  }
50
- async capturePane() {
51
- return capturePane(this.config.tmuxSession, this.config.rows);
153
+ notify(method, params) {
154
+ if (!this.process?.stdin.writable) {
155
+ return;
156
+ }
157
+ const payload = params === undefined ? { method } : { method, params };
158
+ this.process.stdin.write(`${JSON.stringify(payload)}\n`);
52
159
  }
53
- async startSession() {
54
- if (this.running && await this.exists()) {
160
+ handleLine(line) {
161
+ if (!line.trim()) {
55
162
  return;
56
163
  }
57
- await runTmux(['kill-session', '-t', this.config.tmuxSession]).catch(() => undefined);
58
- await runTmux([
59
- 'new-session',
60
- '-d',
61
- '-s',
62
- this.config.tmuxSession,
63
- '-c',
64
- this.config.codexCwd,
65
- '-x',
66
- String(this.config.cols),
67
- '-y',
68
- String(this.config.rows),
69
- shellCommand([this.config.codexCommand, ...this.config.codexArgs]),
70
- ]);
71
- this.running = true;
164
+ let message;
165
+ try {
166
+ message = JSON.parse(line);
167
+ }
168
+ catch {
169
+ this.emit('error', line);
170
+ return;
171
+ }
172
+ if ('id' in message) {
173
+ this.handleResponse(message);
174
+ return;
175
+ }
176
+ this.handleNotification(message);
177
+ }
178
+ handleResponse(response) {
179
+ const pending = this.pendingRequests.get(response.id);
180
+ if (!pending) {
181
+ return;
182
+ }
183
+ this.pendingRequests.delete(response.id);
184
+ if (response.error) {
185
+ pending.reject(new Error(response.error.message));
186
+ return;
187
+ }
188
+ pending.resolve(response.result);
72
189
  }
190
+ handleNotification(notification) {
191
+ switch (notification.method) {
192
+ case 'turn/started': {
193
+ const turnId = getString(notification.params?.turn
194
+ ?.id);
195
+ if (turnId) {
196
+ this.activeTurnId = turnId;
197
+ }
198
+ this.emit('turnStarted');
199
+ return;
200
+ }
201
+ case 'item/completed': {
202
+ const item = notification.params
203
+ ?.item;
204
+ if (isCompletedItem(item)) {
205
+ this.emit('itemCompleted', item);
206
+ }
207
+ return;
208
+ }
209
+ case 'turn/completed': {
210
+ this.activeTurnId = null;
211
+ this.emit('turnCompleted');
212
+ return;
213
+ }
214
+ case 'error': {
215
+ const message = getString(notification.params?.message);
216
+ if (message) {
217
+ this.emit('error', message);
218
+ }
219
+ return;
220
+ }
221
+ default:
222
+ return;
223
+ }
224
+ }
225
+ }
226
+ function isCompletedItem(value) {
227
+ return (typeof value === 'object' &&
228
+ value !== null &&
229
+ typeof value.type === 'string');
73
230
  }
74
- function sleep(ms) {
75
- return new Promise((resolve) => setTimeout(resolve, ms));
231
+ function getString(value) {
232
+ return typeof value === 'string' ? value : null;
233
+ }
234
+ function textWithAttachmentContext(text, attachments) {
235
+ const trimmed = text.trim();
236
+ if (attachments.length === 0) {
237
+ return trimmed;
238
+ }
239
+ const attachmentLines = attachments.map((attachment, index) => {
240
+ const mime = attachment.mimeType ? `, ${attachment.mimeType}` : '';
241
+ return `${index + 1}. ${attachment.name} (${attachment.kind}${mime}): ${attachment.path}`;
242
+ });
243
+ return [
244
+ trimmed || 'Please inspect the attached file(s).',
245
+ '',
246
+ 'Attached file(s) saved locally:',
247
+ ...attachmentLines,
248
+ ].join('\n');
76
249
  }
package/dist/config.js CHANGED
@@ -11,25 +11,15 @@ export function readConfig() {
11
11
  token,
12
12
  allowedUserIds,
13
13
  codexCommand: process.env.CODEX_COMMAND || 'codex',
14
- codexArgs: parseArgs(process.env.CODEX_ARGS || '--search --yolo'),
15
- codexSubmitKey: process.env.CODEX_SUBMIT_KEY || 'Enter',
16
- codexSubmitDelayMs: readNumber('CODEX_SUBMIT_DELAY_MS', 800),
17
14
  codexCwd: expandHome(process.env.CODEX_CWD || process.cwd()),
18
- tmuxSession: process.env.TMUX_SESSION || 'codex-telegram-bridge',
19
- cols: readNumber('CODEX_COLS', 120),
20
- rows: readNumber('CODEX_ROWS', 40),
21
- flushIntervalMs: readNumber('FLUSH_INTERVAL_MS', 1200),
22
- pollIntervalMs: readNumber('POLL_INTERVAL_MS', 500),
23
- startupDelayMs: readNumber('CODEX_STARTUP_DELAY_MS', 1500),
15
+ codexApprovalPolicy: process.env.CODEX_APPROVAL_POLICY || 'never',
16
+ codexSandbox: process.env.CODEX_SANDBOX || 'danger-full-access',
24
17
  streamEditIntervalMs: readNumber('STREAM_EDIT_INTERVAL_MS', 650),
25
18
  streamMinChangeChars: readNumber('STREAM_MIN_CHANGE_CHARS', 24),
26
19
  typingIntervalMs: readNumber('TYPING_INTERVAL_MS', 4000),
27
20
  maxTelegramChars: Math.min(readNumber('MAX_TELEGRAM_CHARS', 3500), 3900),
28
21
  };
29
22
  }
30
- function parseArgs(value) {
31
- return value.split(' ').map((part) => part.trim()).filter(Boolean);
32
- }
33
23
  function readNumber(name, fallback) {
34
24
  const value = Number(process.env[name]);
35
25
  return Number.isFinite(value) && value > 0 ? value : fallback;
package/dist/env.js CHANGED
@@ -46,15 +46,8 @@ function readEnvTemplate() {
46
46
  'TELEGRAM_ALLOWED_USER_IDS=123456789',
47
47
  'CODEX_CWD=~',
48
48
  'CODEX_COMMAND=codex',
49
- 'CODEX_ARGS=--search --yolo',
50
- 'CODEX_SUBMIT_KEY=Enter',
51
- 'CODEX_SUBMIT_DELAY_MS=800',
52
- 'TMUX_SESSION=codex-telegram-bridge',
53
- 'CODEX_COLS=120',
54
- 'CODEX_ROWS=40',
55
- 'FLUSH_INTERVAL_MS=1200',
56
- 'POLL_INTERVAL_MS=500',
57
- 'CODEX_STARTUP_DELAY_MS=1500',
49
+ 'CODEX_APPROVAL_POLICY=never',
50
+ 'CODEX_SANDBOX=danger-full-access',
58
51
  'STREAM_EDIT_INTERVAL_MS=650',
59
52
  'STREAM_MIN_CHANGE_CHARS=24',
60
53
  'TYPING_INTERVAL_MS=4000',