@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 +67 -31
- package/dist/agents/claude.js +229 -0
- package/dist/agents/codex.js +282 -0
- package/dist/agents/types.js +3 -0
- package/dist/channels/discord.js +45 -51
- package/dist/channels/telegram.js +157 -85
- package/dist/cli.js +47 -354
- package/dist/lib/scope-cache.js +85 -0
- package/dist/lib/streaming.js +207 -0
- package/dist/orchestrator.js +399 -0
- package/dist/paths.js +2 -14
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
package/README.md
CHANGED
|
@@ -1,50 +1,70 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
13
|
-
metro setup
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
```
|
|
29
|
+
@Metro draft a release note
|
|
30
|
+
→ uses Claude (default for a new scope)
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
36
|
+
### Discord
|
|
29
37
|
|
|
30
|
-
|
|
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
|
-
|
|
43
|
+
Follow-ups in the thread route automatically — no @-mention needed.
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
# Terminal 1 — daemon (must be running first)
|
|
36
|
-
codex app-server --listen ws://127.0.0.1:8421
|
|
45
|
+
### Telegram
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
51
|
+
Regular (non-forum) groups are not routed — they have no thread boundary.
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
## How it works
|
|
46
54
|
|
|
47
|
-
|
|
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,
|
|
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
|
-
-
|
|
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
|
|
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
|
|
78
|
-
- **
|
|
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
|
+
}
|