@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 +7 -4
- package/dist/agents/claude.js +72 -94
- package/dist/agents/codex.js +35 -110
- package/dist/agents/types.js +1 -2
- package/dist/channels/discord.js +18 -56
- package/dist/channels/telegram.js +54 -74
- package/dist/cli.js +49 -94
- package/dist/{lib → helpers}/scope-cache.js +4 -24
- 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 +120 -311
- package/dist/paths.js +53 -18
- package/package.json +1 -1
- package/dist/lib/dotenv.js +0 -31
- package/dist/lib/streaming.js +0 -207
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
|
|
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
|
|
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).
|
|
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
|
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,23 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
188
|
+
const display = (name ?? 'Tool')[0].toUpperCase() + (name ?? 'Tool').slice(1);
|
|
205
189
|
if (!input)
|
|
206
|
-
return
|
|
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
|
|
195
|
+
case 'Bash': return { name: 'Bash', detail: cmd };
|
|
213
196
|
case 'Edit':
|
|
214
197
|
case 'Write':
|
|
215
|
-
case 'NotebookEdit':
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
case '
|
|
222
|
-
case '
|
|
223
|
-
|
|
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
|
-
}
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
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();
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
131
|
+
return;
|
|
198
132
|
if (msg.method === 'item/agentMessage/delta') {
|
|
199
|
-
|
|
200
|
-
cb.onDelta(p.delta);
|
|
133
|
+
cb.onDelta(msg.params.delta);
|
|
201
134
|
}
|
|
202
135
|
else if (msg.method === 'item/started') {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
211
|
-
if (
|
|
212
|
-
cb.onToolEnd(
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
195
|
+
return { id, kind: 'commandExecution', name: 'Bash', detail: item.command || undefined };
|
|
269
196
|
}
|
|
270
197
|
if (item.type === 'fileChange' && 'changes' in item) {
|
|
271
|
-
const
|
|
272
|
-
return
|
|
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
|
|
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;
|
package/dist/agents/types.js
CHANGED