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

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,73 @@
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 inline, persistent tool-call traces. 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 each tool call as its own block: plain header `🛠 **Read**` followed by two fenced code blocks — input (`src/foo.ts`) above, output (file contents) below. Outputs are capped at 50 lines / 1500 chars per tool with a `_(N more lines)_` note if truncated. `Thinking…` shows as a transient status that vanishes once real content arrives.
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") and posts a deep link back in General so the new topic is one tap away. 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). Long replies split past ~1900 chars onto a follow-up message.
66
+ - **Tool-call visibility.** Each tool call is rendered as a plain `🛠 **<tool>**` header plus two fenced code blocks — input then output — paired by tool id so parallel calls don't collide. Both blocks are fully visible (no collapse). Outputs are capped at 50 lines / 1500 chars per tool.
67
+ - **Telegram formatting.** Agent markdown (`**bold**`, `*italic*`, `` `code` ``, fenced blocks, `[link](url)`, blockquotes) is converted to Telegram's HTML parse mode on the way out, so it renders as formatted text instead of literal characters.
68
+ - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and the `SUPPRESS_EMBEDS` flag on Discord, so URLs in agent replies don't unfurl into giant auto-embeds.
69
+ - **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
70
+ - **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
71
 
49
72
  ## Config
50
73
 
@@ -52,27 +75,43 @@ Bare `codex` (no `--remote`) can't work with metro — the agent has no daemon t
52
75
  |---|---|---|
53
76
  | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
54
77
  | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
55
- | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, attachment cache, default download dir. |
78
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, scope cache, codex socket, telegram offset, claude session set. |
56
79
  | `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
80
 
59
81
  Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
60
82
 
83
+ ## Develop locally
84
+
85
+ ```bash
86
+ git clone https://github.com/bonustrack/metro && cd metro
87
+ bun install && bun run build
88
+ bun link # makes `metro` resolve to this checkout
89
+ METRO_LOG_LEVEL=debug metro
90
+ ```
91
+
61
92
  ## Reference
62
93
 
63
94
  - `metro --help` — command surface
64
- - `metro doctor` — health check
65
- - [SKILL.md](skills/metro/SKILL.md) — agent-facing flow
95
+ - `metro doctor` — health check (tokens + gateway/poller reachability + orchestrator status)
96
+ - State files (`$METRO_STATE_DIR`, defaults to `~/.cache/metro/`):
97
+ - `scopes.json` — Discord-thread / Telegram-topic ↔ agent-session map
98
+ - `.tail-lock` — orchestrator pid
99
+ - `codex-app-server.sock` — codex's UDS
100
+ - `telegram-offset.json` — last processed update id (used for catchup on restart)
101
+ - `claude-sessions.json` — set of started Claude session uuids (so restarts use `--resume` instead of `--session-id`)
66
102
 
67
103
  ## Uninstall
68
104
 
69
105
  ```bash
70
- metro setup clear; metro setup skill --clear
106
+ metro setup clear
71
107
  rm -rf ~/.cache/metro/
72
108
  npm uninstall -g @stage-labs/metro
73
109
  ```
74
110
 
75
111
  ## Caveats
76
112
 
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.
113
+ - **No allowlist.** Anyone who can DM/@-mention your bot can spawn an agent session. Run against bots you own.
114
+ - **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.
115
+ - **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.
116
+ - **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.
117
+ - **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,207 @@
1
+ /** Claude Code adapter: per-turn `claude -p` subprocess; --session-id then --resume (persisted across restarts). */
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { errMsg, log } from '../log.js';
7
+ import { STATE_DIR } from '../paths.js';
8
+ const STARTED_FILE = join(STATE_DIR, 'claude-sessions.json');
9
+ function loadStarted() {
10
+ if (!existsSync(STARTED_FILE))
11
+ return new Set();
12
+ try {
13
+ return new Set(JSON.parse(readFileSync(STARTED_FILE, 'utf8')));
14
+ }
15
+ catch (err) {
16
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude: started cache read failed');
17
+ return new Set();
18
+ }
19
+ }
20
+ export class ClaudeAgent {
21
+ started = loadStarted();
22
+ children = new Set();
23
+ persistStarted() {
24
+ try {
25
+ writeFileSync(STARTED_FILE, JSON.stringify([...this.started]));
26
+ }
27
+ catch (err) {
28
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude: started cache write failed');
29
+ }
30
+ }
31
+ async start() {
32
+ await new Promise((resolve, reject) => {
33
+ const c = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
34
+ let out = '';
35
+ c.stdout.on('data', d => { out += String(d); });
36
+ c.on('error', reject);
37
+ c.on('exit', code => {
38
+ if (code === 0) {
39
+ log.info({ version: out.trim() }, 'claude agent: ready');
40
+ resolve();
41
+ }
42
+ else
43
+ reject(new Error(`claude --version exited with ${code}`));
44
+ });
45
+ });
46
+ }
47
+ async stop() {
48
+ for (const c of this.children) {
49
+ try {
50
+ c.kill('SIGTERM');
51
+ }
52
+ catch { /* ignore */ }
53
+ }
54
+ this.children.clear();
55
+ }
56
+ async createThread() {
57
+ const id = randomUUID();
58
+ log.info({ thread: id }, 'claude agent: thread allocated');
59
+ return id;
60
+ }
61
+ async sendTurn(threadId, text, callbacks) {
62
+ const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
63
+ args.push(this.started.has(threadId) ? '--resume' : '--session-id', threadId, text);
64
+ const child = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
65
+ this.children.add(child);
66
+ log.debug({ thread: threadId }, 'claude agent: turn started');
67
+ const session = new TurnSession(callbacks);
68
+ let buffer = '';
69
+ child.stdout?.on('data', d => {
70
+ buffer += String(d);
71
+ let nl;
72
+ while ((nl = buffer.indexOf('\n')) !== -1) {
73
+ const line = buffer.slice(0, nl).trim();
74
+ buffer = buffer.slice(nl + 1);
75
+ if (!line)
76
+ continue;
77
+ try {
78
+ session.handle(JSON.parse(line));
79
+ }
80
+ catch (err) {
81
+ log.warn({ err: errMsg(err) }, 'claude agent: malformed event');
82
+ }
83
+ }
84
+ });
85
+ child.stderr?.on('data', d => log.trace({ src: 'claude-stderr' }, String(d).trim()));
86
+ child.on('exit', code => {
87
+ this.children.delete(child);
88
+ if (!this.started.has(threadId)) {
89
+ this.started.add(threadId);
90
+ this.persistStarted();
91
+ }
92
+ if (!session.done) {
93
+ if (code === 0)
94
+ session.fireComplete();
95
+ else
96
+ session.fireError(new Error(`claude exited with code ${code}`));
97
+ }
98
+ });
99
+ child.on('error', err => { this.children.delete(child); if (!session.done)
100
+ session.fireError(err); });
101
+ }
102
+ }
103
+ class TurnSession {
104
+ cb;
105
+ done = false;
106
+ thinking = true;
107
+ /** tool_use_id → kind; survives until the matching tool_result lands. */
108
+ byUseId = new Map();
109
+ /** Pending tool_use blocks: input JSON streams in over multiple `input_json_delta` events before the input is complete. */
110
+ pendingTools = new Map();
111
+ constructor(cb) {
112
+ this.cb = cb;
113
+ cb.onToolStart({ id: 'thinking', kind: 'thinking', name: 'Thinking…', transient: true });
114
+ }
115
+ fireComplete() { if (this.done)
116
+ return; this.done = true; this.clearThinking(); this.cb.onComplete(); }
117
+ fireError(err) { if (this.done)
118
+ return; this.done = true; this.clearThinking(); this.cb.onError(err); }
119
+ clearThinking() { if (!this.thinking)
120
+ return; this.thinking = false; this.cb.onToolEnd('thinking'); }
121
+ handle(ev) {
122
+ if (ev.type === 'result') {
123
+ if (ev.is_error)
124
+ this.fireError(new Error(typeof ev.result === 'string' ? ev.result : 'claude error'));
125
+ else
126
+ this.fireComplete();
127
+ return;
128
+ }
129
+ if (ev.type === 'user') {
130
+ this.handleToolResults(ev);
131
+ return;
132
+ }
133
+ if (ev.type !== 'stream_event' || !ev.event)
134
+ return;
135
+ const e = ev.event;
136
+ if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use') {
137
+ this.clearThinking();
138
+ const idx = e.index ?? -1;
139
+ const kind = e.content_block.name ?? 'tool';
140
+ const id = e.content_block.id ?? `${kind}:${idx}`;
141
+ this.byUseId.set(id, kind);
142
+ this.pendingTools.set(idx, { id, kind, toolName: e.content_block.name, inputJson: '' });
143
+ }
144
+ else if (e.type === 'content_block_delta' && e.delta?.type === 'input_json_delta') {
145
+ const tool = this.pendingTools.get(e.index ?? -1);
146
+ if (tool)
147
+ tool.inputJson += e.delta.partial_json ?? '';
148
+ }
149
+ else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') {
150
+ this.clearThinking();
151
+ this.cb.onDelta(e.delta.text ?? '');
152
+ }
153
+ else if (e.type === 'content_block_stop') {
154
+ const tool = this.pendingTools.get(e.index ?? -1);
155
+ if (!tool)
156
+ return;
157
+ this.pendingTools.delete(e.index ?? -1);
158
+ let input;
159
+ try {
160
+ input = tool.inputJson ? JSON.parse(tool.inputJson) : undefined;
161
+ }
162
+ catch { /* malformed mid-stream — show name only */ }
163
+ const { name, detail } = summarizeTool(tool.toolName, input);
164
+ this.cb.onToolStart({ id: tool.id, kind: tool.kind, name, detail });
165
+ }
166
+ }
167
+ /** Claude emits `tool_result` blocks inside a follow-up `user` message after the tool runs. */
168
+ handleToolResults(ev) {
169
+ for (const block of ev.message?.content ?? []) {
170
+ if (block.type !== 'tool_result' || !block.tool_use_id)
171
+ continue;
172
+ if (!this.byUseId.has(block.tool_use_id))
173
+ continue;
174
+ this.byUseId.delete(block.tool_use_id);
175
+ this.cb.onToolEnd(block.tool_use_id, extractResultText(block.content));
176
+ }
177
+ }
178
+ }
179
+ function extractResultText(content) {
180
+ if (typeof content === 'string')
181
+ return content.trim() || undefined;
182
+ if (!Array.isArray(content))
183
+ return undefined;
184
+ const text = content.filter(b => b?.type === 'text').map(b => b.text ?? '').join('\n').trim();
185
+ return text || undefined;
186
+ }
187
+ function summarizeTool(name, input) {
188
+ const display = (name ?? 'Tool')[0].toUpperCase() + (name ?? 'Tool').slice(1);
189
+ if (!input)
190
+ return { name: display };
191
+ const path = (input.file_path ?? input.path);
192
+ const cmd = input.command;
193
+ const pattern = input.pattern;
194
+ switch (name) {
195
+ case 'Bash': return { name: 'Bash', detail: cmd };
196
+ case 'Edit':
197
+ case 'Write':
198
+ case 'NotebookEdit': return { name: display, detail: path };
199
+ case 'Read': return { name: 'Read', detail: path };
200
+ case 'Grep':
201
+ case 'Glob': return { name: display, detail: pattern };
202
+ case 'WebFetch': return { name: 'WebFetch', detail: input.url };
203
+ case 'WebSearch': return { name: 'WebSearch', detail: input.query };
204
+ case 'Task': return { name: 'Task', detail: (input.description ?? input.subagent_type) };
205
+ default: return { name: display };
206
+ }
207
+ }
@@ -0,0 +1,207 @@
1
+ /** Codex adapter: spawns `codex app-server` over UDS WebSocket; one thread/scope, streams turn events. */
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, unlinkSync } from 'node:fs';
4
+ import { createConnection } from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { WebSocket } from 'ws';
7
+ import { errMsg, log } from '../log.js';
8
+ import { STATE_DIR } from '../paths.js';
9
+ const SOCKET_PATH = join(STATE_DIR, 'codex-app-server.sock');
10
+ const READY_TIMEOUT_MS = 15_000;
11
+ const READY_POLL_MS = 100;
12
+ export class CodexAgent {
13
+ clientVersion;
14
+ ws = null;
15
+ daemon = null;
16
+ nextId = 1;
17
+ pending = new Map();
18
+ turnCallbacks = new Map();
19
+ constructor(clientVersion) {
20
+ this.clientVersion = clientVersion;
21
+ }
22
+ async start() {
23
+ await this.checkCodexInstalled();
24
+ try {
25
+ unlinkSync(SOCKET_PATH);
26
+ }
27
+ catch { /* missing is fine */ }
28
+ log.info({ socket: SOCKET_PATH }, 'codex agent: starting app-server');
29
+ this.daemon = spawn('codex', ['app-server', '--listen', `unix://${SOCKET_PATH}`], { stdio: ['ignore', 'pipe', 'pipe'] });
30
+ let bootStderr = '';
31
+ let daemonExited = false;
32
+ let daemonExitCode = null;
33
+ this.daemon.stdout?.on('data', d => log.trace({ src: 'codex-stdout' }, String(d).trim()));
34
+ this.daemon.stderr?.on('data', (d) => { bootStderr += String(d); log.trace({ src: 'codex-stderr' }, String(d).trim()); });
35
+ this.daemon.on('exit', code => { daemonExited = true; daemonExitCode = code; log.warn({ code }, 'codex daemon exited'); });
36
+ try {
37
+ await this.waitForSocket(() => daemonExited, () => bootStderr.trim() || `exit code ${daemonExitCode}`);
38
+ await this.connect();
39
+ }
40
+ catch (err) {
41
+ const detail = bootStderr.trim() ? ` (codex stderr: ${bootStderr.trim().slice(0, 200)})` : '';
42
+ throw new Error(`${errMsg(err)}${detail}`);
43
+ }
44
+ log.info('codex agent: ready');
45
+ }
46
+ async checkCodexInstalled() {
47
+ await new Promise((resolve, reject) => {
48
+ const c = spawn('codex', ['--version'], { stdio: ['ignore', 'ignore', 'pipe'] });
49
+ let stderr = '';
50
+ c.stderr?.on('data', d => { stderr += String(d); });
51
+ c.on('error', err => reject(new Error(`codex CLI not found on PATH: ${errMsg(err)}`)));
52
+ c.on('exit', code => code === 0 ? resolve() : reject(new Error(`codex --version exited ${code}: ${stderr.trim()}`)));
53
+ });
54
+ }
55
+ async stop() {
56
+ this.ws?.close();
57
+ this.ws = null;
58
+ this.daemon?.kill('SIGTERM');
59
+ this.daemon = null;
60
+ }
61
+ async createThread() {
62
+ const result = await this.call('thread/start', {});
63
+ log.info({ thread: result.thread.id }, 'codex agent: thread created');
64
+ return result.thread.id;
65
+ }
66
+ async sendTurn(threadId, text, callbacks) {
67
+ this.turnCallbacks.set(threadId, callbacks);
68
+ try {
69
+ /** `text_elements` is snake_case per codex's generated bindings. */
70
+ await this.call('turn/start', { threadId, input: [{ type: 'text', text, text_elements: [] }] });
71
+ }
72
+ catch (err) {
73
+ this.turnCallbacks.delete(threadId);
74
+ callbacks.onError(err instanceof Error ? err : new Error(String(err)));
75
+ }
76
+ }
77
+ async waitForSocket(exited, exitReason) {
78
+ const deadline = Date.now() + READY_TIMEOUT_MS;
79
+ while (Date.now() < deadline) {
80
+ if (exited())
81
+ throw new Error(`codex app-server exited before listening: ${exitReason()}`);
82
+ if (existsSync(SOCKET_PATH))
83
+ return;
84
+ await new Promise(r => setTimeout(r, READY_POLL_MS));
85
+ }
86
+ throw new Error(`codex app-server didn't appear at ${SOCKET_PATH} within ${READY_TIMEOUT_MS}ms`);
87
+ }
88
+ /** perMessageDeflate disabled: codex 0.130 closes connections offering it. */
89
+ async connect() {
90
+ const ws = new WebSocket('ws://localhost/', {
91
+ perMessageDeflate: false,
92
+ createConnection: () => {
93
+ const sock = createConnection({ path: SOCKET_PATH });
94
+ sock.on('error', err => log.warn({ err: errMsg(err) }, 'codex agent: socket error'));
95
+ return sock;
96
+ },
97
+ });
98
+ this.ws = ws;
99
+ ws.on('error', err => log.warn({ err: errMsg(err) }, 'codex agent: websocket error'));
100
+ await new Promise((resolve, reject) => { ws.once('open', () => resolve()); ws.once('error', reject); });
101
+ ws.on('message', data => this.onMessage(data));
102
+ ws.on('close', () => { log.warn('codex agent: websocket closed'); this.ws = null; this.drainPending('codex websocket closed'); });
103
+ await this.call('initialize', { clientInfo: { name: 'metro', version: this.clientVersion, title: null } });
104
+ }
105
+ onMessage(raw) {
106
+ let msg;
107
+ try {
108
+ msg = JSON.parse(raw.toString());
109
+ }
110
+ catch (err) {
111
+ log.warn({ err: errMsg(err) }, 'codex agent: malformed message');
112
+ return;
113
+ }
114
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
115
+ const p = this.pending.get(msg.id);
116
+ this.pending.delete(msg.id);
117
+ if (msg.error)
118
+ p.reject(new Error(msg.error.message ?? 'rpc error'));
119
+ else
120
+ p.resolve(msg.result);
121
+ return;
122
+ }
123
+ if (!msg.method)
124
+ return;
125
+ log.trace({ method: msg.method }, 'codex agent: notification');
126
+ const threadId = msg.params?.threadId;
127
+ if (!threadId)
128
+ return;
129
+ const cb = this.turnCallbacks.get(threadId);
130
+ if (!cb)
131
+ return;
132
+ if (msg.method === 'item/agentMessage/delta') {
133
+ cb.onDelta(msg.params.delta);
134
+ }
135
+ else if (msg.method === 'item/started') {
136
+ const a = summarizeItem(msg.params.item);
137
+ if (a)
138
+ cb.onToolStart(a);
139
+ }
140
+ else if (msg.method === 'item/completed') {
141
+ const item = msg.params.item;
142
+ if (item.type !== 'agentMessage' && item.type !== 'userMessage')
143
+ cb.onToolEnd(item.id, itemOutput(item));
144
+ }
145
+ else if (msg.method === 'thread/status/changed') {
146
+ /** codex 0.130: `thread/status=idle` is the dependable completion signal. */
147
+ const status = msg.params.status?.type;
148
+ if (status === 'idle') {
149
+ this.turnCallbacks.delete(threadId);
150
+ cb.onComplete();
151
+ }
152
+ else if (status === 'systemError') {
153
+ this.turnCallbacks.delete(threadId);
154
+ cb.onError(new Error('codex thread entered systemError'));
155
+ }
156
+ }
157
+ else if (msg.method === 'turn/completed') {
158
+ this.turnCallbacks.delete(threadId);
159
+ cb.onComplete();
160
+ }
161
+ else if (msg.method === 'error') {
162
+ this.turnCallbacks.delete(threadId);
163
+ cb.onError(new Error(msg.params.error?.message ?? 'codex error notification'));
164
+ }
165
+ }
166
+ call(method, params) {
167
+ if (!this.ws)
168
+ return Promise.reject(new Error('codex agent: not connected'));
169
+ const id = this.nextId++;
170
+ const ws = this.ws;
171
+ return new Promise((resolve, reject) => {
172
+ this.pending.set(id, { resolve: resolve, reject });
173
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
174
+ });
175
+ }
176
+ drainPending(reason) {
177
+ const err = new Error(reason);
178
+ for (const cb of this.turnCallbacks.values()) {
179
+ try {
180
+ cb.onError(err);
181
+ }
182
+ catch (e) {
183
+ log.warn({ err: errMsg(e) }, 'codex agent: drain callback threw');
184
+ }
185
+ }
186
+ this.turnCallbacks.clear();
187
+ for (const p of this.pending.values())
188
+ p.reject(err);
189
+ this.pending.clear();
190
+ }
191
+ }
192
+ function summarizeItem(item) {
193
+ const id = item.id;
194
+ if (item.type === 'commandExecution' && 'command' in item) {
195
+ return { id, kind: 'commandExecution', name: 'Bash', detail: item.command || undefined };
196
+ }
197
+ if (item.type === 'fileChange' && 'changes' in item) {
198
+ const paths = (item.changes ?? []).map(c => c.path).filter((p) => !!p);
199
+ return { id, kind: 'fileChange', name: 'Edit', detail: paths.length === 1 ? paths[0] : paths.length ? `${paths.length} files` : undefined };
200
+ }
201
+ if (item.type === 'reasoning')
202
+ return { id, kind: 'reasoning', name: 'Thinking…', transient: true };
203
+ if (item.type === 'agentMessage' || item.type === 'userMessage')
204
+ return null;
205
+ return { id, kind: item.type, name: item.type };
206
+ }
207
+ const itemOutput = (item) => item.type === 'commandExecution' && 'output' in item ? item.output?.trim() || undefined : undefined;
@@ -0,0 +1,2 @@
1
+ /** Shared interface across agent backends (Codex, Claude Code). */
2
+ export {};