@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.6
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 +138 -63
- package/dist/cache.js +74 -0
- package/dist/cli/actions.js +156 -0
- package/dist/cli/config.js +184 -0
- package/dist/cli/index.js +162 -0
- package/dist/cli/skill.js +62 -0
- package/dist/cli/util.js +72 -0
- package/dist/codex-rc.js +236 -0
- package/dist/dispatcher.js +112 -0
- package/dist/history.js +84 -0
- package/dist/ipc.js +71 -0
- package/dist/log.js +7 -2
- package/dist/stations/discord.js +198 -0
- package/dist/stations/index.js +73 -0
- package/dist/{helpers/telegram-format.js → stations/telegram-md.js} +9 -14
- package/dist/stations/telegram-upload.js +113 -0
- package/dist/stations/telegram.js +235 -0
- package/docs/agents.md +168 -0
- package/docs/uri-scheme.md +71 -0
- package/package.json +6 -3
- package/skills/metro/SKILL.md +220 -0
- package/dist/agents/claude.js +0 -207
- package/dist/agents/codex.js +0 -207
- package/dist/agents/types.js +0 -2
- package/dist/channels/discord.js +0 -104
- package/dist/channels/telegram.js +0 -189
- package/dist/cli.js +0 -221
- package/dist/helpers/scope-cache.js +0 -65
- package/dist/helpers/streaming.js +0 -209
- package/dist/helpers/turn.js +0 -40
- package/dist/orchestrator.js +0 -208
package/dist/agents/claude.js
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/agents/codex.js
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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;
|
package/dist/agents/types.js
DELETED
package/dist/channels/discord.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
|
|
2
|
-
import { errMsg, log } from '../log.js';
|
|
3
|
-
/** Receive: discord.js gateway. Send: raw REST so non-orchestrator callers stay one-shot. */
|
|
4
|
-
const API_BASE = 'https://discord.com/api/v10';
|
|
5
|
-
function token() {
|
|
6
|
-
const t = process.env.DISCORD_BOT_TOKEN;
|
|
7
|
-
if (!t)
|
|
8
|
-
throw new Error('DISCORD_BOT_TOKEN is not set');
|
|
9
|
-
return t;
|
|
10
|
-
}
|
|
11
|
-
async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
|
|
12
|
-
const res = await fetch(`${API_BASE}${path}`, {
|
|
13
|
-
method,
|
|
14
|
-
headers: {
|
|
15
|
-
'Authorization': `Bot ${token()}`,
|
|
16
|
-
'User-Agent': 'metro (https://github.com/bonustrack/metro, dev)',
|
|
17
|
-
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
18
|
-
},
|
|
19
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
20
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
21
|
-
});
|
|
22
|
-
/** 429: honor `retry_after` (seconds) and retry, up to a few hops. */
|
|
23
|
-
if (res.status === 429 && retriesLeft > 0) {
|
|
24
|
-
const retryAfter = Number(res.headers.get('retry-after')) || 1;
|
|
25
|
-
log.debug({ path, retryAfter }, 'discord 429; backing off');
|
|
26
|
-
await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, 250)));
|
|
27
|
-
return rest(method, path, body, timeoutMs, retriesLeft - 1);
|
|
28
|
-
}
|
|
29
|
-
if (!res.ok) {
|
|
30
|
-
const text = await res.text().catch(() => '');
|
|
31
|
-
throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
|
|
32
|
-
}
|
|
33
|
-
if (res.status === 204)
|
|
34
|
-
return undefined;
|
|
35
|
-
return (await res.json());
|
|
36
|
-
}
|
|
37
|
-
let client = null;
|
|
38
|
-
function getClient() {
|
|
39
|
-
if (client)
|
|
40
|
-
return client;
|
|
41
|
-
client = new Client({
|
|
42
|
-
intents: [GatewayIntentBits.DirectMessages, GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
|
43
|
-
/** DM channels arrive partial; without this messageCreate never fires. */
|
|
44
|
-
partials: [Partials.Channel],
|
|
45
|
-
});
|
|
46
|
-
return client;
|
|
47
|
-
}
|
|
48
|
-
let onInboundHandler = () => { };
|
|
49
|
-
export const onInbound = (handler) => { onInboundHandler = handler; };
|
|
50
|
-
export async function shutdownGateway() {
|
|
51
|
-
if (!client)
|
|
52
|
-
return;
|
|
53
|
-
await client.destroy();
|
|
54
|
-
client = null;
|
|
55
|
-
}
|
|
56
|
-
export async function startGateway() {
|
|
57
|
-
const c = getClient();
|
|
58
|
-
c.on(Events.MessageCreate, m => {
|
|
59
|
-
if (m.author.bot)
|
|
60
|
-
return;
|
|
61
|
-
/** Forward every human message; orchestrator decides routing. @-mention stays in content. */
|
|
62
|
-
const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
|
|
63
|
-
: a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]`
|
|
64
|
-
: `[file: ${a.name}]`).join(' ');
|
|
65
|
-
const text = [m.content, tags].filter(Boolean).join(' ').trim();
|
|
66
|
-
if (!text)
|
|
67
|
-
return;
|
|
68
|
-
onInboundHandler({
|
|
69
|
-
channel_id: m.channelId,
|
|
70
|
-
message_id: m.id,
|
|
71
|
-
text,
|
|
72
|
-
in_guild: !!m.guildId,
|
|
73
|
-
mentions_bot: c.user ? m.mentions.has(c.user.id) : false,
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
|
|
77
|
-
await c.login(process.env.DISCORD_BOT_TOKEN);
|
|
78
|
-
await new Promise(r => c.once(Events.ClientReady, () => r()));
|
|
79
|
-
}
|
|
80
|
-
export async function getMe() {
|
|
81
|
-
const me = await rest('GET', '/users/@me');
|
|
82
|
-
return { username: me.username };
|
|
83
|
-
}
|
|
84
|
-
/** SUPPRESS_EMBEDS (1 << 2) — strip link unfurls from every bot message. */
|
|
85
|
-
const SUPPRESS_EMBEDS = 1 << 2;
|
|
86
|
-
export async function sendMessage(channelId, text) {
|
|
87
|
-
const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text, flags: SUPPRESS_EMBEDS });
|
|
88
|
-
return sent.id;
|
|
89
|
-
}
|
|
90
|
-
/** Public thread anchored on `messageId`. Returns the new thread channel id. */
|
|
91
|
-
export async function createThreadFromMessage(channelId, messageId, name) {
|
|
92
|
-
const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, { name, auto_archive_duration: 1440 });
|
|
93
|
-
return created.id;
|
|
94
|
-
}
|
|
95
|
-
export async function editMessage(channelId, messageId, text) {
|
|
96
|
-
const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text, flags: SUPPRESS_EMBEDS });
|
|
97
|
-
return sent.id;
|
|
98
|
-
}
|
|
99
|
-
/** Catchup-on-restart: fetch messages newer than `afterMessageId` (cap 100). */
|
|
100
|
-
export async function fetchMessagesSince(channelId, afterMessageId) {
|
|
101
|
-
const msgs = await rest('GET', `/channels/${channelId}/messages?after=${afterMessageId}&limit=100`);
|
|
102
|
-
/** Discord returns newest-first; flip to chronological for replay. */
|
|
103
|
-
return [...msgs].reverse().map(m => ({ message_id: m.id, text: m.content, author_id: m.author.id, author_is_bot: !!m.author.bot }));
|
|
104
|
-
}
|