@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 +70 -31
- package/dist/agents/claude.js +207 -0
- package/dist/agents/codex.js +207 -0
- package/dist/agents/types.js +2 -0
- package/dist/channels/discord.js +36 -80
- package/dist/channels/telegram.js +136 -84
- package/dist/cli.js +68 -420
- package/dist/helpers/scope-cache.js +65 -0
- package/dist/helpers/streaming.js +209 -0
- package/dist/helpers/telegram-format.js +39 -0
- package/dist/helpers/turn.js +40 -0
- package/dist/log.js +1 -3
- package/dist/orchestrator.js +208 -0
- package/dist/paths.js +52 -29
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/lib/dotenv.js +0 -31
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
package/README.md
CHANGED
|
@@ -1,50 +1,73 @@
|
|
|
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 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
|
+
|
|
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 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
|
-
|
|
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") 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
|
-
|
|
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). 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,
|
|
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
|
-
-
|
|
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
|
|
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
|
|
78
|
-
- **
|
|
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;
|