@stage-labs/metro 0.1.0-beta.3 → 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,6 +1,6 @@
1
1
  # Metro
2
2
 
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.
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
5
  ## Prereqs
6
6
 
@@ -38,14 +38,14 @@ How would Codex have done this? with codex
38
38
  @-mention the bot in any channel:
39
39
  1. Metro creates a thread anchored on your message (named after the message).
40
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…`).
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.
42
42
 
43
43
  Follow-ups in the thread route automatically — no @-mention needed.
44
44
 
45
45
  ### Telegram
46
46
 
47
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.
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
49
  - **Inside an existing custom topic** — routes to that topic's scope on every message.
50
50
 
51
51
  Regular (non-forum) groups are not routed — they have no thread boundary.
@@ -62,7 +62,10 @@ Telegram poller ──┘ └─▶ claude -p ... (pe
62
62
 
63
63
  - **One metro = one daemon.** Lockfile at `$METRO_STATE_DIR/.tail-lock` keeps things singleton.
64
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.
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.
66
69
  - **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
67
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`).
68
71
 
@@ -1,23 +1,10 @@
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.
1
+ /** Claude Code adapter: per-turn `claude -p` subprocess; --session-id then --resume (persisted across restarts). */
9
2
  import { spawn } from 'node:child_process';
10
3
  import { randomUUID } from 'node:crypto';
11
4
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
5
  import { join } from 'node:path';
13
6
  import { errMsg, log } from '../log.js';
14
7
  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
8
  const STARTED_FILE = join(STATE_DIR, 'claude-sessions.json');
22
9
  function loadStarted() {
23
10
  if (!existsSync(STARTED_FILE))
@@ -26,13 +13,11 @@ function loadStarted() {
26
13
  return new Set(JSON.parse(readFileSync(STARTED_FILE, 'utf8')));
27
14
  }
28
15
  catch (err) {
29
- log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude agent: started cache read failed; treating as empty');
16
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude: started cache read failed');
30
17
  return new Set();
31
18
  }
32
19
  }
33
20
  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
21
  started = loadStarted();
37
22
  children = new Set();
38
23
  persistStarted() {
@@ -40,12 +25,10 @@ export class ClaudeAgent {
40
25
  writeFileSync(STARTED_FILE, JSON.stringify([...this.started]));
41
26
  }
42
27
  catch (err) {
43
- log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude agent: started cache write failed');
28
+ log.warn({ err: errMsg(err), path: STARTED_FILE }, 'claude: started cache write failed');
44
29
  }
45
30
  }
46
31
  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
32
  await new Promise((resolve, reject) => {
50
33
  const c = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
51
34
  let out = '';
@@ -71,28 +54,16 @@ export class ClaudeAgent {
71
54
  this.children.clear();
72
55
  }
73
56
  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
57
  const id = randomUUID();
78
58
  log.info({ thread: id }, 'claude agent: thread allocated');
79
59
  return id;
80
60
  }
81
61
  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);
62
+ const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
63
+ args.push(this.started.has(threadId) ? '--resume' : '--session-id', threadId, text);
93
64
  const child = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
94
65
  this.children.add(child);
95
- log.debug({ thread: threadId, args: args.slice(0, -1) }, 'claude agent: turn started');
66
+ log.debug({ thread: threadId }, 'claude agent: turn started');
96
67
  const session = new TurnSession(callbacks);
97
68
  let buffer = '';
98
69
  child.stdout?.on('data', d => {
@@ -118,8 +89,6 @@ export class ClaudeAgent {
118
89
  this.started.add(threadId);
119
90
  this.persistStarted();
120
91
  }
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
92
  if (!session.done) {
124
93
  if (code === 0)
125
94
  session.fireComplete();
@@ -127,50 +96,28 @@ export class ClaudeAgent {
127
96
  session.fireError(new Error(`claude exited with code ${code}`));
128
97
  }
129
98
  });
130
- child.on('error', err => {
131
- this.children.delete(child);
132
- if (!session.done)
133
- session.fireError(err);
134
- });
99
+ child.on('error', err => { this.children.delete(child); if (!session.done)
100
+ session.fireError(err); });
135
101
  }
136
102
  }
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
103
  class TurnSession {
140
104
  cb;
141
105
  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
106
  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();
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();
150
111
  constructor(cb) {
151
112
  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');
113
+ cb.onToolStart({ id: 'thinking', kind: 'thinking', name: 'Thinking…', transient: true });
173
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'); }
174
121
  handle(ev) {
175
122
  if (ev.type === 'result') {
176
123
  if (ev.is_error)
@@ -179,51 +126,82 @@ class TurnSession {
179
126
  this.fireComplete();
180
127
  return;
181
128
  }
129
+ if (ev.type === 'user') {
130
+ this.handleToolResults(ev);
131
+ return;
132
+ }
182
133
  if (ev.type !== 'stream_event' || !ev.event)
183
134
  return;
184
135
  const e = ev.event;
185
136
  if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use') {
186
137
  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));
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 ?? '';
189
148
  }
190
149
  else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') {
191
150
  this.clearThinking();
192
151
  this.cb.onDelta(e.delta.text ?? '');
193
152
  }
194
153
  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);
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;
199
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));
200
176
  }
201
177
  }
202
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
+ }
203
187
  function summarizeTool(name, input) {
204
- const n = (name ?? 'Tool')[0].toUpperCase() + (name ?? 'Tool').slice(1);
188
+ const display = (name ?? 'Tool')[0].toUpperCase() + (name ?? 'Tool').slice(1);
205
189
  if (!input)
206
- return n;
190
+ return { name: display };
207
191
  const path = (input.file_path ?? input.path);
208
192
  const cmd = input.command;
209
193
  const pattern = input.pattern;
210
- const url = input.url;
211
194
  switch (name) {
212
- case 'Bash': return cmd ? `Running: ${truncate(cmd, 60)}` : n;
195
+ case 'Bash': return { name: 'Bash', detail: cmd };
213
196
  case 'Edit':
214
197
  case 'Write':
215
- case 'NotebookEdit':
216
- return path ? `Editing ${path}` : n;
217
- case 'Read': return path ? `Reading ${path}` : n;
198
+ case 'NotebookEdit': return { name: display, detail: path };
199
+ case 'Read': return { name: 'Read', detail: path };
218
200
  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;
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 };
225
206
  }
226
207
  }
227
- function truncate(s, n) {
228
- return s.length > n ? s.slice(0, n - 1) + '…' : s;
229
- }
@@ -1,10 +1,4 @@
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)`.
1
+ /** Codex adapter: spawns `codex app-server` over UDS WebSocket; one thread/scope, streams turn events. */
8
2
  import { spawn } from 'node:child_process';
9
3
  import { existsSync, unlinkSync } from 'node:fs';
10
4
  import { createConnection } from 'node:net';
@@ -21,49 +15,29 @@ export class CodexAgent {
21
15
  daemon = null;
22
16
  nextId = 1;
23
17
  pending = new Map();
24
- turnCallbacks = new Map(); // keyed by thread_id
18
+ turnCallbacks = new Map();
25
19
  constructor(clientVersion) {
26
20
  this.clientVersion = clientVersion;
27
21
  }
28
22
  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
23
  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
24
  try {
35
25
  unlinkSync(SOCKET_PATH);
36
26
  }
37
27
  catch { /* missing is fine */ }
38
28
  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).
29
+ this.daemon = spawn('codex', ['app-server', '--listen', `unix://${SOCKET_PATH}`], { stdio: ['ignore', 'pipe', 'pipe'] });
47
30
  let bootStderr = '';
48
31
  let daemonExited = false;
49
32
  let daemonExitCode = null;
50
- const onBootStderr = (d) => {
51
- bootStderr += String(d);
52
- log.trace({ src: 'codex-stderr' }, String(d).trim());
53
- };
54
33
  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
- });
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'); });
61
36
  try {
62
37
  await this.waitForSocket(() => daemonExited, () => bootStderr.trim() || `exit code ${daemonExitCode}`);
63
38
  await this.connect();
64
39
  }
65
40
  catch (err) {
66
- // Make the failure self-explanatory in the orchestrator's catch log.
67
41
  const detail = bootStderr.trim() ? ` (codex stderr: ${bootStderr.trim().slice(0, 200)})` : '';
68
42
  throw new Error(`${errMsg(err)}${detail}`);
69
43
  }
@@ -75,9 +49,7 @@ export class CodexAgent {
75
49
  let stderr = '';
76
50
  c.stderr?.on('data', d => { stderr += String(d); });
77
51
  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()}`)));
52
+ c.on('exit', code => code === 0 ? resolve() : reject(new Error(`codex --version exited ${code}: ${stderr.trim()}`)));
81
53
  });
82
54
  }
83
55
  async stop() {
@@ -86,39 +58,22 @@ export class CodexAgent {
86
58
  this.daemon?.kill('SIGTERM');
87
59
  this.daemon = null;
88
60
  }
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
61
  async createThread() {
95
62
  const result = await this.call('thread/start', {});
96
63
  log.info({ thread: result.thread.id }, 'codex agent: thread created');
97
64
  return result.thread.id;
98
65
  }
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
66
  async sendTurn(threadId, text, callbacks) {
106
67
  this.turnCallbacks.set(threadId, callbacks);
107
68
  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
- });
69
+ /** `text_elements` is snake_case per codex's generated bindings. */
70
+ await this.call('turn/start', { threadId, input: [{ type: 'text', text, text_elements: [] }] });
115
71
  }
116
72
  catch (err) {
117
73
  this.turnCallbacks.delete(threadId);
118
74
  callbacks.onError(err instanceof Error ? err : new Error(String(err)));
119
75
  }
120
76
  }
121
- // --- transport ---
122
77
  async waitForSocket(exited, exitReason) {
123
78
  const deadline = Date.now() + READY_TIMEOUT_MS;
124
79
  while (Date.now() < deadline) {
@@ -130,40 +85,22 @@ export class CodexAgent {
130
85
  }
131
86
  throw new Error(`codex app-server didn't appear at ${SOCKET_PATH} within ${READY_TIMEOUT_MS}ms`);
132
87
  }
88
+ /** perMessageDeflate disabled: codex 0.130 closes connections offering it. */
133
89
  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
90
  const ws = new WebSocket('ws://localhost/', {
139
91
  perMessageDeflate: false,
140
92
  createConnection: () => {
141
93
  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
94
  sock.on('error', err => log.warn({ err: errMsg(err) }, 'codex agent: socket error'));
146
95
  return sock;
147
96
  },
148
97
  });
149
98
  this.ws = ws;
150
99
  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
- });
100
+ await new Promise((resolve, reject) => { ws.once('open', () => resolve()); ws.once('error', reject); });
155
101
  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
- });
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 } });
167
104
  }
168
105
  onMessage(raw) {
169
106
  let msg;
@@ -174,7 +111,6 @@ export class CodexAgent {
174
111
  log.warn({ err: errMsg(err) }, 'codex agent: malformed message');
175
112
  return;
176
113
  }
177
- // RPC response
178
114
  if (msg.id !== undefined && this.pending.has(msg.id)) {
179
115
  const p = this.pending.get(msg.id);
180
116
  this.pending.delete(msg.id);
@@ -184,56 +120,47 @@ export class CodexAgent {
184
120
  p.resolve(msg.result);
185
121
  return;
186
122
  }
187
- // Notifications.
188
123
  if (!msg.method)
189
124
  return;
190
125
  log.trace({ method: msg.method }, 'codex agent: notification');
191
- const params = msg.params;
192
- const threadId = params?.threadId;
126
+ const threadId = msg.params?.threadId;
193
127
  if (!threadId)
194
128
  return;
195
129
  const cb = this.turnCallbacks.get(threadId);
196
130
  if (!cb)
197
- return; // no active turn for this thread (yet or anymore)
131
+ return;
198
132
  if (msg.method === 'item/agentMessage/delta') {
199
- const p = msg.params;
200
- cb.onDelta(p.delta);
133
+ cb.onDelta(msg.params.delta);
201
134
  }
202
135
  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
- }
136
+ const a = summarizeItem(msg.params.item);
137
+ if (a)
138
+ cb.onToolStart(a);
208
139
  }
209
140
  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);
141
+ const item = msg.params.item;
142
+ if (item.type !== 'agentMessage' && item.type !== 'userMessage')
143
+ cb.onToolEnd(item.id, itemOutput(item));
213
144
  }
214
145
  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') {
146
+ /** codex 0.130: `thread/status=idle` is the dependable completion signal. */
147
+ const status = msg.params.status?.type;
148
+ if (status === 'idle') {
220
149
  this.turnCallbacks.delete(threadId);
221
150
  cb.onComplete();
222
151
  }
223
- else if (p.status?.type === 'systemError') {
152
+ else if (status === 'systemError') {
224
153
  this.turnCallbacks.delete(threadId);
225
154
  cb.onError(new Error('codex thread entered systemError'));
226
155
  }
227
156
  }
228
157
  else if (msg.method === 'turn/completed') {
229
- // Belt-and-braces: if codex does emit it, take it.
230
158
  this.turnCallbacks.delete(threadId);
231
159
  cb.onComplete();
232
160
  }
233
161
  else if (msg.method === 'error') {
234
- const p = msg.params;
235
162
  this.turnCallbacks.delete(threadId);
236
- cb.onError(new Error(p.error?.message ?? 'codex error notification'));
163
+ cb.onError(new Error(msg.params.error?.message ?? 'codex error notification'));
237
164
  }
238
165
  }
239
166
  call(method, params) {
@@ -246,7 +173,6 @@ export class CodexAgent {
246
173
  ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
247
174
  });
248
175
  }
249
- /** Fail every in-flight turn + RPC. Used when the transport dies. */
250
176
  drainPending(reason) {
251
177
  const err = new Error(reason);
252
178
  for (const cb of this.turnCallbacks.values()) {
@@ -264,19 +190,18 @@ export class CodexAgent {
264
190
  }
265
191
  }
266
192
  function summarizeItem(item) {
193
+ const id = item.id;
267
194
  if (item.type === 'commandExecution' && 'command' in item) {
268
- return `Running: ${truncate(item.command ?? '', 60)}`;
195
+ return { id, kind: 'commandExecution', name: 'Bash', detail: item.command || undefined };
269
196
  }
270
197
  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';
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 };
273
200
  }
274
201
  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;
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 };
282
206
  }
207
+ const itemOutput = (item) => item.type === 'commandExecution' && 'output' in item ? item.output?.trim() || undefined : undefined;
@@ -1,3 +1,2 @@
1
- // Shared interface so the orchestrator can talk to either Codex or Claude
2
- // Code (or future agents) through the same surface.
1
+ /** Shared interface across agent backends (Codex, Claude Code). */
3
2
  export {};