@stage-labs/metro 0.1.0-beta.2 → 0.1.0-beta.3

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/README.md CHANGED
@@ -1,50 +1,70 @@
1
1
  # Metro
2
2
 
3
- Chat with your Claude Code or Codex agent over Telegram and Discord.
3
+ Run a long-lived daemon that bridges Discord and Telegram to your Codex + Claude Code agents. Each chat thread/topic gets its own agent session with streaming responses and live tool-call status. Both agents run side-by-side — pick per-message with a `with claude` / `with codex` suffix.
4
4
 
5
- ## Quickstart
5
+ ## Prereqs
6
+
7
+ - **Node ≥ 22** (or Bun ≥ 1.3).
8
+ - **One or both agent CLIs** installed and authenticated:
9
+ - **Claude Code** — run `claude` once interactively to log in. Metro shells out per turn and inherits your auth, plugins, settings.
10
+ - **Codex** — run `codex` once interactively to log in. Metro spawns `codex app-server` and inherits your auth, MCPs, sandboxing.
11
+ - **Discord bot** (optional) with **Message Content Intent** enabled (Developer Portal → Bot → Privileged Gateway Intents).
12
+ - **Telegram bot** (optional). In supergroup forums, the bot also needs the **Manage Topics** admin permission so it can auto-create topics on @-mention.
6
13
 
7
- In your shell:
14
+ ## Quickstart
8
15
 
9
16
  ```bash
10
17
  npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
11
18
 
12
- metro setup telegram <token> # https://t.me/BotFather
13
- metro setup discord <token> # https://discord.com/developers/applications
19
+ metro setup discord <token> # https://discord.com/developers/applications
20
+ metro setup telegram <token> # https://t.me/BotFather
14
21
 
15
- metro setup skill # writes SKILL.md so Claude Code + Codex auto-onboard
16
22
  metro doctor # verify
23
+ metro # run the orchestrator
17
24
  ```
18
25
 
19
- > **Discord setup:** toggle **Message Content Intent** in Developer Portal Bot Privileged Gateway Intents.
26
+ Metro starts both agents at boot and listens on whichever platforms are configured. Each scope defaults to **Claude** for the first turn; once you've used an agent there, follow-up messages stick with it. Switch per-message by suffixing `with claude` or `with codex` (any casing):
20
27
 
21
- ### Run with Claude Code
28
+ ```
29
+ @Metro draft a release note
30
+ → uses Claude (default for a new scope)
22
31
 
23
- ```bash
24
- claude
25
- > Run metro in the background.
32
+ How would Codex have done this? with codex
33
+ → routes this turn to Codex; stays Codex on subsequent turns
26
34
  ```
27
35
 
28
- Then DM your bot. The bundled skill auto-triggers — the agent launches metro via Bash + Monitor, watches stdout, and replies.
36
+ ### Discord
29
37
 
30
- ### Run with Codex
38
+ @-mention the bot in any channel:
39
+ 1. Metro creates a thread anchored on your message (named after the message).
40
+ 2. Spins up an agent session for that thread.
41
+ 3. Streams the agent's reply with tool-call status (`Running: <command>`, `Editing 3 files`, `Thinking…`).
31
42
 
32
- Codex's `unified_exec` is poll-only ([#4751](https://github.com/openai/codex/issues/4751)) — there's no Monitor equivalent. Metro instead pushes each inbound into the agent's history via JSON-RPC. Two terminals plus a prompt the TUI's `--remote` flag only accepts `ws://`, so daemon and TUI share one URL:
43
+ Follow-ups in the thread route automaticallyno @-mention needed.
33
44
 
34
- ```bash
35
- # Terminal 1 — daemon (must be running first)
36
- codex app-server --listen ws://127.0.0.1:8421
45
+ ### Telegram
37
46
 
38
- # Terminal 2TUI attached to the daemon
39
- codex --remote ws://127.0.0.1:8421
40
- > Run metro in the background.
41
- ```
47
+ - **DM the bot** every message is implicitly addressed to it; one scope per chat.
48
+ - **@-mention the bot in a forum supergroup's General topic** — metro auto-creates a new topic for the conversation (Discord-style "thread from message"). Subsequent messages in that topic route automatically.
49
+ - **Inside an existing custom topic** — routes to that topic's scope on every message.
42
50
 
43
- The agent launches `metro` (with `METRO_CODEX_RC=ws://127.0.0.1:8421` set) via its shell tool. Metro connects to the daemon and pushes each inbound as a `turn/start` on the active thread the agent in terminal 2 reacts on its next turn. `codex remote-control` is stdio-only (no listener), so don't use it for this flow.
51
+ Regular (non-forum) groups are not routedthey have no thread boundary.
44
52
 
45
- Bare `codex` (no `--remote`) can't work with metro — the agent has no daemon to push to. The TUI must be attached to a running app-server.
53
+ ## How it works
46
54
 
47
- `METRO_CODEX_RC` accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only — the daemon supports UDS but the TUI doesn't).
55
+ ```
56
+ Discord gateway ──┐ ┌─▶ codex app-server (long-lived subprocess, UDS JSON-RPC)
57
+ ├─▶ metro orchestrator ─┤
58
+ Telegram poller ──┘ └─▶ claude -p ... (per-turn subprocess, stream-json)
59
+
60
+ └──── scope map (scopes.json)
61
+ ```
62
+
63
+ - **One metro = one daemon.** Lockfile at `$METRO_STATE_DIR/.tail-lock` keeps things singleton.
64
+ - **Both agents side-by-side.** A scope can have up to one session per agent — independent histories. Routing is per-message: explicit `with claude` / `with codex` suffix, otherwise the scope's last-used agent, otherwise Claude.
65
+ - **Streaming.** Replies edit one message every ~1500 ms while deltas stream in (leading-edge first flush for fast initial feedback). Tool calls show as a status line; long replies split past ~1900 chars onto a follow-up message.
66
+ - **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
67
+ - **Catchup-on-restart.** Discord uses a per-scope `lastSeenMessageId` watermark and REST-fetches anything newer when metro comes back up. Telegram leans on its own update-id queue (persisted offset in `telegram-offset.json`).
48
68
 
49
69
  ## Config
50
70
 
@@ -52,27 +72,43 @@ Bare `codex` (no `--remote`) can't work with metro — the agent has no daemon t
52
72
  |---|---|---|
53
73
  | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
54
74
  | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
55
- | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, attachment cache, default download dir. |
75
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, scope cache, codex socket, telegram offset, claude session set. |
56
76
  | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
57
- | `METRO_CODEX_RC` | — | Codex app-server URL (e.g. `ws://127.0.0.1:8421`). When set, metro pushes each inbound into the agent's history via JSON-RPC `turn/start` — the Codex equivalent of Claude Code's Monitor. Accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only). See [Codex setup](#codex-setup). |
58
77
 
59
78
  Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
60
79
 
80
+ ## Develop locally
81
+
82
+ ```bash
83
+ git clone https://github.com/bonustrack/metro && cd metro
84
+ bun install && bun run build
85
+ bun link # makes `metro` resolve to this checkout
86
+ METRO_LOG_LEVEL=debug metro
87
+ ```
88
+
61
89
  ## Reference
62
90
 
63
91
  - `metro --help` — command surface
64
- - `metro doctor` — health check
65
- - [SKILL.md](skills/metro/SKILL.md) — agent-facing flow
92
+ - `metro doctor` — health check (tokens + gateway/poller reachability + orchestrator status)
93
+ - State files (`$METRO_STATE_DIR`, defaults to `~/.cache/metro/`):
94
+ - `scopes.json` — Discord-thread / Telegram-topic ↔ agent-session map
95
+ - `.tail-lock` — orchestrator pid
96
+ - `codex-app-server.sock` — codex's UDS
97
+ - `telegram-offset.json` — last processed update id (used for catchup on restart)
98
+ - `claude-sessions.json` — set of started Claude session uuids (so restarts use `--resume` instead of `--session-id`)
66
99
 
67
100
  ## Uninstall
68
101
 
69
102
  ```bash
70
- metro setup clear; metro setup skill --clear
103
+ metro setup clear
71
104
  rm -rf ~/.cache/metro/
72
105
  npm uninstall -g @stage-labs/metro
73
106
  ```
74
107
 
75
108
  ## Caveats
76
109
 
77
- - **No allowlist.** Anyone who can DM your bot or @-mention it can talk to your session. Run against bots you own.
78
- - **Latency.** Inbounds surface at the next agent decision boundarysub-second on Claude Code, longer on Codex turns.
110
+ - **No allowlist.** Anyone who can DM/@-mention your bot can spawn an agent session. Run against bots you own.
111
+ - **Per-agent histories are separate.** Switching with `with codex` mid-scope spins up a fresh Codex session it has no idea what you discussed with Claude in the same scope. Each agent only sees what was sent to it.
112
+ - **One agent missing is OK.** If only `claude` or only `codex` is installed/authenticated, metro still starts; messages asking for the missing one surface an error in chat.
113
+ - **Telegram non-forum groups are skipped.** Without a per-topic thread boundary the routing model breaks down. DMs and forum topics (incl. auto-created ones from General) work normally.
114
+ - **Telegram bot privacy.** Default Telegram bot privacy is *enabled*, which can block group messages even with @-mentions. Disable in [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy → Turn off, then kick + re-invite the bot.
@@ -0,0 +1,229 @@
1
+ // Claude Code agent adapter. Spawns `claude -p --output-format stream-json
2
+ // --include-partial-messages --verbose` per turn, parses the line-delimited
3
+ // JSON event stream, and exposes the same `Agent` surface as codex.ts.
4
+ //
5
+ // Unlike codex (long-running app-server daemon), Claude Code has no daemon
6
+ // mode — each turn is a fresh subprocess. Session continuity is achieved
7
+ // by passing the same uuid via `--session-id` for the first turn and
8
+ // `--resume` for every subsequent turn.
9
+ import { spawn } from 'node:child_process';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { errMsg, log } from '../log.js';
14
+ import { STATE_DIR } from '../paths.js';
15
+ // Persisted across metro restarts. Without this, the first message after
16
+ // a restart would call `claude -p --session-id <uuid>` on an existing
17
+ // session, which fails silently (no result event) and leaves the user
18
+ // staring at "Thinking…" forever. Codex doesn't have this problem
19
+ // because codex app-server is the state authority and reads its on-disk
20
+ // thread store on spawn.
21
+ const STARTED_FILE = join(STATE_DIR, 'claude-sessions.json');
22
+ function loadStarted() {
23
+ if (!existsSync(STARTED_FILE))
24
+ return new Set();
25
+ try {
26
+ return new Set(JSON.parse(readFileSync(STARTED_FILE, 'utf8')));
27
+ }
28
+ catch (err) {
29
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude agent: started cache read failed; treating as empty');
30
+ return new Set();
31
+ }
32
+ }
33
+ export class ClaudeAgent {
34
+ // Threads that have had at least one turn run (so `--session-id` was
35
+ // already consumed and subsequent turns must use `--resume`).
36
+ started = loadStarted();
37
+ children = new Set();
38
+ persistStarted() {
39
+ try {
40
+ writeFileSync(STARTED_FILE, JSON.stringify([...this.started]));
41
+ }
42
+ catch (err) {
43
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude agent: started cache write failed');
44
+ }
45
+ }
46
+ async start() {
47
+ // No daemon to bring up — sanity-check that `claude` is on PATH so we
48
+ // fail loud at boot rather than on the first inbound message.
49
+ await new Promise((resolve, reject) => {
50
+ const c = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
51
+ let out = '';
52
+ c.stdout.on('data', d => { out += String(d); });
53
+ c.on('error', reject);
54
+ c.on('exit', code => {
55
+ if (code === 0) {
56
+ log.info({ version: out.trim() }, 'claude agent: ready');
57
+ resolve();
58
+ }
59
+ else
60
+ reject(new Error(`claude --version exited with ${code}`));
61
+ });
62
+ });
63
+ }
64
+ async stop() {
65
+ for (const c of this.children) {
66
+ try {
67
+ c.kill('SIGTERM');
68
+ }
69
+ catch { /* ignore */ }
70
+ }
71
+ this.children.clear();
72
+ }
73
+ async createThread() {
74
+ // Pre-allocate a uuid so the Discord thread can be named with it before
75
+ // the first turn runs. Claude Code accepts any valid uuid via
76
+ // `--session-id` and persists the session under that id.
77
+ const id = randomUUID();
78
+ log.info({ thread: id }, 'claude agent: thread allocated');
79
+ return id;
80
+ }
81
+ async sendTurn(threadId, text, callbacks) {
82
+ const args = [
83
+ '-p',
84
+ '--output-format', 'stream-json',
85
+ '--include-partial-messages',
86
+ '--verbose',
87
+ ];
88
+ if (this.started.has(threadId))
89
+ args.push('--resume', threadId);
90
+ else
91
+ args.push('--session-id', threadId);
92
+ args.push(text);
93
+ const child = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
94
+ this.children.add(child);
95
+ log.debug({ thread: threadId, args: args.slice(0, -1) }, 'claude agent: turn started');
96
+ const session = new TurnSession(callbacks);
97
+ let buffer = '';
98
+ child.stdout?.on('data', d => {
99
+ buffer += String(d);
100
+ let nl;
101
+ while ((nl = buffer.indexOf('\n')) !== -1) {
102
+ const line = buffer.slice(0, nl).trim();
103
+ buffer = buffer.slice(nl + 1);
104
+ if (!line)
105
+ continue;
106
+ try {
107
+ session.handle(JSON.parse(line));
108
+ }
109
+ catch (err) {
110
+ log.warn({ err: errMsg(err) }, 'claude agent: malformed event');
111
+ }
112
+ }
113
+ });
114
+ child.stderr?.on('data', d => log.trace({ src: 'claude-stderr' }, String(d).trim()));
115
+ child.on('exit', code => {
116
+ this.children.delete(child);
117
+ if (!this.started.has(threadId)) {
118
+ this.started.add(threadId);
119
+ this.persistStarted();
120
+ }
121
+ // If the subprocess exits without a `result` event (crash, OOM, kill),
122
+ // surface that as an error so the orchestrator unsticks the thread.
123
+ if (!session.done) {
124
+ if (code === 0)
125
+ session.fireComplete();
126
+ else
127
+ session.fireError(new Error(`claude exited with code ${code}`));
128
+ }
129
+ });
130
+ child.on('error', err => {
131
+ this.children.delete(child);
132
+ if (!session.done)
133
+ session.fireError(err);
134
+ });
135
+ }
136
+ }
137
+ // Owns the once-only firing of onComplete/onError and the per-index tool
138
+ // tracking so block_stop events can fire onToolEnd correctly.
139
+ class TurnSession {
140
+ cb;
141
+ done = false;
142
+ // Show "Thinking…" until the first real text/tool event lands. Claude
143
+ // Code doesn't emit a separate reasoning event the way codex does, so
144
+ // without this the user sees nothing while the agent is still on its
145
+ // initial API call — feels like the bot's frozen.
146
+ thinking = true;
147
+ // Track which content-block indexes belong to tool_use vs text so
148
+ // block_stop fires onToolEnd only for tool blocks.
149
+ tools = new Map();
150
+ constructor(cb) {
151
+ this.cb = cb;
152
+ cb.onToolStart('thinking', 'Thinking…');
153
+ }
154
+ fireComplete() {
155
+ if (this.done)
156
+ return;
157
+ this.done = true;
158
+ this.clearThinking();
159
+ this.cb.onComplete();
160
+ }
161
+ fireError(err) {
162
+ if (this.done)
163
+ return;
164
+ this.done = true;
165
+ this.clearThinking();
166
+ this.cb.onError(err);
167
+ }
168
+ clearThinking() {
169
+ if (!this.thinking)
170
+ return;
171
+ this.thinking = false;
172
+ this.cb.onToolEnd('thinking');
173
+ }
174
+ handle(ev) {
175
+ if (ev.type === 'result') {
176
+ if (ev.is_error)
177
+ this.fireError(new Error(typeof ev.result === 'string' ? ev.result : 'claude error'));
178
+ else
179
+ this.fireComplete();
180
+ return;
181
+ }
182
+ if (ev.type !== 'stream_event' || !ev.event)
183
+ return;
184
+ const e = ev.event;
185
+ if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use') {
186
+ this.clearThinking();
187
+ this.tools.set(e.index ?? -1, e.content_block.name ?? 'tool');
188
+ this.cb.onToolStart(e.content_block.name ?? 'tool', summarizeTool(e.content_block.name, e.content_block.input));
189
+ }
190
+ else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') {
191
+ this.clearThinking();
192
+ this.cb.onDelta(e.delta.text ?? '');
193
+ }
194
+ else if (e.type === 'content_block_stop') {
195
+ const kind = this.tools.get(e.index ?? -1);
196
+ if (kind !== undefined) {
197
+ this.tools.delete(e.index ?? -1);
198
+ this.cb.onToolEnd(kind);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ function summarizeTool(name, input) {
204
+ const n = (name ?? 'Tool')[0].toUpperCase() + (name ?? 'Tool').slice(1);
205
+ if (!input)
206
+ return n;
207
+ const path = (input.file_path ?? input.path);
208
+ const cmd = input.command;
209
+ const pattern = input.pattern;
210
+ const url = input.url;
211
+ switch (name) {
212
+ case 'Bash': return cmd ? `Running: ${truncate(cmd, 60)}` : n;
213
+ case 'Edit':
214
+ case 'Write':
215
+ case 'NotebookEdit':
216
+ return path ? `Editing ${path}` : n;
217
+ case 'Read': return path ? `Reading ${path}` : n;
218
+ case 'Grep':
219
+ case 'Glob':
220
+ return pattern ? `Searching: ${truncate(pattern, 60)}` : n;
221
+ case 'WebFetch': return url ? `Fetching ${url}` : n;
222
+ case 'WebSearch': return 'Searching the web';
223
+ case 'Task': return 'Spawning subagent';
224
+ default: return n;
225
+ }
226
+ }
227
+ function truncate(s, n) {
228
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
229
+ }
@@ -0,0 +1,282 @@
1
+ // Codex agent adapter. Spawns `codex app-server --listen unix://…` as a
2
+ // child process, talks to it over WebSocket-over-UDS, manages one codex
3
+ // thread per scope, and streams per-turn events back to the orchestrator
4
+ // (text deltas, tool-call lifecycle, completion).
5
+ //
6
+ // The orchestrator never speaks codex directly — it asks this adapter to
7
+ // `ensureThread(scopeKey)` and `sendTurn(threadId, text, callbacks)`.
8
+ import { spawn } from 'node:child_process';
9
+ import { existsSync, unlinkSync } from 'node:fs';
10
+ import { createConnection } from 'node:net';
11
+ import { join } from 'node:path';
12
+ import { WebSocket } from 'ws';
13
+ import { errMsg, log } from '../log.js';
14
+ import { STATE_DIR } from '../paths.js';
15
+ const SOCKET_PATH = join(STATE_DIR, 'codex-app-server.sock');
16
+ const READY_TIMEOUT_MS = 15_000;
17
+ const READY_POLL_MS = 100;
18
+ export class CodexAgent {
19
+ clientVersion;
20
+ ws = null;
21
+ daemon = null;
22
+ nextId = 1;
23
+ pending = new Map();
24
+ turnCallbacks = new Map(); // keyed by thread_id
25
+ constructor(clientVersion) {
26
+ this.clientVersion = clientVersion;
27
+ }
28
+ async start() {
29
+ // Fail loud at boot if codex isn't installed, rather than 15s later
30
+ // via a "socket didn't appear" timeout.
31
+ await this.checkCodexInstalled();
32
+ // Stale socket file from a previous run would let us connect to nothing
33
+ // (no listener) — wipe before spawning so codex binds fresh.
34
+ try {
35
+ unlinkSync(SOCKET_PATH);
36
+ }
37
+ catch { /* missing is fine */ }
38
+ log.info({ socket: SOCKET_PATH }, 'codex agent: starting app-server');
39
+ // Spawn the daemon listening on our UDS. Inherits CODEX_HOME so it picks
40
+ // up the user's auth, settings, MCPs, etc.
41
+ this.daemon = spawn('codex', ['app-server', '--listen', `unix://${SOCKET_PATH}`], {
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ });
44
+ // Capture daemon stderr so a startup failure surfaces with the actual
45
+ // error message, not just a generic timeout. Once the daemon is ready,
46
+ // stderr goes back to trace (its own tracing is noisy).
47
+ let bootStderr = '';
48
+ let daemonExited = false;
49
+ let daemonExitCode = null;
50
+ const onBootStderr = (d) => {
51
+ bootStderr += String(d);
52
+ log.trace({ src: 'codex-stderr' }, String(d).trim());
53
+ };
54
+ this.daemon.stdout?.on('data', d => log.trace({ src: 'codex-stdout' }, String(d).trim()));
55
+ this.daemon.stderr?.on('data', onBootStderr);
56
+ this.daemon.on('exit', code => {
57
+ daemonExited = true;
58
+ daemonExitCode = code;
59
+ log.warn({ code }, 'codex daemon exited');
60
+ });
61
+ try {
62
+ await this.waitForSocket(() => daemonExited, () => bootStderr.trim() || `exit code ${daemonExitCode}`);
63
+ await this.connect();
64
+ }
65
+ catch (err) {
66
+ // Make the failure self-explanatory in the orchestrator's catch log.
67
+ const detail = bootStderr.trim() ? ` (codex stderr: ${bootStderr.trim().slice(0, 200)})` : '';
68
+ throw new Error(`${errMsg(err)}${detail}`);
69
+ }
70
+ log.info('codex agent: ready');
71
+ }
72
+ async checkCodexInstalled() {
73
+ await new Promise((resolve, reject) => {
74
+ const c = spawn('codex', ['--version'], { stdio: ['ignore', 'ignore', 'pipe'] });
75
+ let stderr = '';
76
+ c.stderr?.on('data', d => { stderr += String(d); });
77
+ c.on('error', err => reject(new Error(`codex CLI not found on PATH: ${errMsg(err)}`)));
78
+ c.on('exit', code => code === 0
79
+ ? resolve()
80
+ : reject(new Error(`codex --version exited ${code}: ${stderr.trim()}`)));
81
+ });
82
+ }
83
+ async stop() {
84
+ this.ws?.close();
85
+ this.ws = null;
86
+ this.daemon?.kill('SIGTERM');
87
+ this.daemon = null;
88
+ }
89
+ /**
90
+ * Create a new codex thread and return its id. The caller is responsible
91
+ * for caching the mapping `scopeKey → threadId` and only calling this
92
+ * once per scope (subsequent inbounds reuse the thread via `sendTurn`).
93
+ */
94
+ async createThread() {
95
+ const result = await this.call('thread/start', {});
96
+ log.info({ thread: result.thread.id }, 'codex agent: thread created');
97
+ return result.thread.id;
98
+ }
99
+ /**
100
+ * Send a user message to a thread and stream the agent's response via
101
+ * the provided callbacks. Resolves immediately after `turn/start`
102
+ * acknowledges; callbacks fire as notifications arrive and conclude
103
+ * with `onComplete` or `onError`.
104
+ */
105
+ async sendTurn(threadId, text, callbacks) {
106
+ this.turnCallbacks.set(threadId, callbacks);
107
+ try {
108
+ // Wire format per codex's generated TS bindings: `text_elements`
109
+ // (snake_case), not camelCase. Sending camelCase silently degrades
110
+ // — accepted by the server but doesn't echo back in items.
111
+ await this.call('turn/start', {
112
+ threadId,
113
+ input: [{ type: 'text', text, text_elements: [] }],
114
+ });
115
+ }
116
+ catch (err) {
117
+ this.turnCallbacks.delete(threadId);
118
+ callbacks.onError(err instanceof Error ? err : new Error(String(err)));
119
+ }
120
+ }
121
+ // --- transport ---
122
+ async waitForSocket(exited, exitReason) {
123
+ const deadline = Date.now() + READY_TIMEOUT_MS;
124
+ while (Date.now() < deadline) {
125
+ if (exited())
126
+ throw new Error(`codex app-server exited before listening: ${exitReason()}`);
127
+ if (existsSync(SOCKET_PATH))
128
+ return;
129
+ await new Promise(r => setTimeout(r, READY_POLL_MS));
130
+ }
131
+ throw new Error(`codex app-server didn't appear at ${SOCKET_PATH} within ${READY_TIMEOUT_MS}ms`);
132
+ }
133
+ async connect() {
134
+ // perMessageDeflate disabled: codex 0.130's WS upgrade handler closes
135
+ // the connection if a client offers `Sec-WebSocket-Extensions:
136
+ // permessage-deflate` (the `ws` library's default). Disabling it makes
137
+ // the upgrade succeed cleanly. Compression is irrelevant over a UDS.
138
+ const ws = new WebSocket('ws://localhost/', {
139
+ perMessageDeflate: false,
140
+ createConnection: () => {
141
+ const sock = createConnection({ path: SOCKET_PATH });
142
+ // Swallow socket-level errors so a transport hiccup during the HTTP
143
+ // upgrade doesn't surface as an uncaught error on the http_client
144
+ // request (Node's http.ClientRequest re-emits this).
145
+ sock.on('error', err => log.warn({ err: errMsg(err) }, 'codex agent: socket error'));
146
+ return sock;
147
+ },
148
+ });
149
+ this.ws = ws;
150
+ ws.on('error', err => log.warn({ err: errMsg(err) }, 'codex agent: websocket error'));
151
+ await new Promise((resolve, reject) => {
152
+ ws.once('open', () => resolve());
153
+ ws.once('error', err => reject(err));
154
+ });
155
+ ws.on('message', data => this.onMessage(data));
156
+ ws.on('close', () => {
157
+ log.warn('codex agent: websocket closed');
158
+ this.ws = null;
159
+ // Drain stranded turns + RPC promises so the orchestrator can
160
+ // release its in-flight gate and the user sees an error rather
161
+ // than a permanent "Thinking…".
162
+ this.drainPending('codex websocket closed');
163
+ });
164
+ await this.call('initialize', {
165
+ clientInfo: { name: 'metro', version: this.clientVersion, title: null },
166
+ });
167
+ }
168
+ onMessage(raw) {
169
+ let msg;
170
+ try {
171
+ msg = JSON.parse(raw.toString());
172
+ }
173
+ catch (err) {
174
+ log.warn({ err: errMsg(err) }, 'codex agent: malformed message');
175
+ return;
176
+ }
177
+ // RPC response
178
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
179
+ const p = this.pending.get(msg.id);
180
+ this.pending.delete(msg.id);
181
+ if (msg.error)
182
+ p.reject(new Error(msg.error.message ?? 'rpc error'));
183
+ else
184
+ p.resolve(msg.result);
185
+ return;
186
+ }
187
+ // Notifications.
188
+ if (!msg.method)
189
+ return;
190
+ log.trace({ method: msg.method }, 'codex agent: notification');
191
+ const params = msg.params;
192
+ const threadId = params?.threadId;
193
+ if (!threadId)
194
+ return;
195
+ const cb = this.turnCallbacks.get(threadId);
196
+ if (!cb)
197
+ return; // no active turn for this thread (yet or anymore)
198
+ if (msg.method === 'item/agentMessage/delta') {
199
+ const p = msg.params;
200
+ cb.onDelta(p.delta);
201
+ }
202
+ else if (msg.method === 'item/started') {
203
+ const p = msg.params;
204
+ const summary = summarizeItem(p.item);
205
+ if (summary && p.item.type !== 'agentMessage' && p.item.type !== 'userMessage') {
206
+ cb.onToolStart(p.item.type, summary);
207
+ }
208
+ }
209
+ else if (msg.method === 'item/completed') {
210
+ const p = msg.params;
211
+ if (p.item.type !== 'agentMessage' && p.item.type !== 'userMessage')
212
+ cb.onToolEnd(p.item.type);
213
+ }
214
+ else if (msg.method === 'thread/status/changed') {
215
+ // Codex 0.130 doesn't reliably emit `turn/completed` — `thread/status`
216
+ // returning to `idle` is the dependable completion signal. The same
217
+ // notification fires on `active` when a turn starts; ignore those.
218
+ const p = msg.params;
219
+ if (p.status?.type === 'idle') {
220
+ this.turnCallbacks.delete(threadId);
221
+ cb.onComplete();
222
+ }
223
+ else if (p.status?.type === 'systemError') {
224
+ this.turnCallbacks.delete(threadId);
225
+ cb.onError(new Error('codex thread entered systemError'));
226
+ }
227
+ }
228
+ else if (msg.method === 'turn/completed') {
229
+ // Belt-and-braces: if codex does emit it, take it.
230
+ this.turnCallbacks.delete(threadId);
231
+ cb.onComplete();
232
+ }
233
+ else if (msg.method === 'error') {
234
+ const p = msg.params;
235
+ this.turnCallbacks.delete(threadId);
236
+ cb.onError(new Error(p.error?.message ?? 'codex error notification'));
237
+ }
238
+ }
239
+ call(method, params) {
240
+ if (!this.ws)
241
+ return Promise.reject(new Error('codex agent: not connected'));
242
+ const id = this.nextId++;
243
+ const ws = this.ws;
244
+ return new Promise((resolve, reject) => {
245
+ this.pending.set(id, { resolve: resolve, reject });
246
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
247
+ });
248
+ }
249
+ /** Fail every in-flight turn + RPC. Used when the transport dies. */
250
+ drainPending(reason) {
251
+ const err = new Error(reason);
252
+ for (const cb of this.turnCallbacks.values()) {
253
+ try {
254
+ cb.onError(err);
255
+ }
256
+ catch (e) {
257
+ log.warn({ err: errMsg(e) }, 'codex agent: drain callback threw');
258
+ }
259
+ }
260
+ this.turnCallbacks.clear();
261
+ for (const p of this.pending.values())
262
+ p.reject(err);
263
+ this.pending.clear();
264
+ }
265
+ }
266
+ function summarizeItem(item) {
267
+ if (item.type === 'commandExecution' && 'command' in item) {
268
+ return `Running: ${truncate(item.command ?? '', 60)}`;
269
+ }
270
+ if (item.type === 'fileChange' && 'changes' in item) {
271
+ const n = item.changes?.length ?? 0;
272
+ return n > 0 ? `Editing ${n} file${n === 1 ? '' : 's'}` : 'Editing files';
273
+ }
274
+ if (item.type === 'reasoning')
275
+ return 'Thinking…';
276
+ if (item.type === 'agentMessage')
277
+ return ''; // text deltas handled separately
278
+ return item.type;
279
+ }
280
+ function truncate(s, n) {
281
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
282
+ }
@@ -0,0 +1,3 @@
1
+ // Shared interface so the orchestrator can talk to either Codex or Claude
2
+ // Code (or future agents) through the same surface.
3
+ export {};