@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15

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.
Files changed (45) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +46 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +51 -64
  6. package/dist/cli/messenger-api.js +214 -0
  7. package/dist/cli/messenger-transcribe.js +43 -0
  8. package/dist/cli/messenger-uploads.js +116 -0
  9. package/dist/cli/monitor-api.js +205 -0
  10. package/dist/cli/tail.js +49 -118
  11. package/dist/cli/webhook.js +103 -3
  12. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  13. package/dist/codex-rc/protocol.js +38 -0
  14. package/dist/dispatcher/server.js +122 -0
  15. package/dist/dispatcher.js +52 -83
  16. package/dist/history.js +49 -27
  17. package/dist/ipc.js +28 -10
  18. package/dist/lines.js +54 -0
  19. package/dist/local-identity.js +80 -0
  20. package/dist/paths.js +58 -12
  21. package/dist/trains/protocol.js +99 -0
  22. package/dist/trains/supervisor.js +210 -0
  23. package/dist/tunnel.js +39 -1
  24. package/docs/broker.md +88 -136
  25. package/docs/monitor.md +88 -10
  26. package/docs/uri-scheme.md +10 -7
  27. package/examples/README.md +32 -0
  28. package/examples/telegram.ts +121 -0
  29. package/package.json +6 -5
  30. package/skills/metro/SKILL.md +67 -213
  31. package/dist/cache.js +0 -69
  32. package/dist/cli/actions.js +0 -206
  33. package/dist/cli/skill.js +0 -62
  34. package/dist/monitor.js +0 -194
  35. package/dist/registry.js +0 -48
  36. package/dist/stations/claude.js +0 -45
  37. package/dist/stations/codex.js +0 -68
  38. package/dist/stations/discord.js +0 -216
  39. package/dist/stations/index.js +0 -129
  40. package/dist/stations/telegram-md.js +0 -34
  41. package/dist/stations/telegram-upload.js +0 -113
  42. package/dist/stations/telegram.js +0 -234
  43. package/dist/stations/webhook.js +0 -103
  44. package/dist/webhooks.js +0 -41
  45. package/docs/users.md +0 -226
package/dist/cli/tail.js CHANGED
@@ -1,128 +1,64 @@
1
- /** CLI subcommands: `metro tail` (claim-aware log subscriber) + `metro claim|release|claims`. */
2
- import { existsSync, watch } from 'node:fs';
3
- import { CLAIMS_FILE, HISTORY_FILE, claimLine, cursorKey, historySize, passesMode, readClaims, readCursor, readEntriesFrom, releaseLine, writeCursor, } from '../broker.js';
1
+ /** CLI: `metro tail / claim / release / claims`. HTTP monitor endpoints live in monitor-api.ts. */
2
+ import { existsSync } from 'node:fs';
3
+ import { CLAIMS_FILE, claimLine, readClaims, releaseLine } from '../broker/claims.js';
4
+ import { cursorKey, drainTail, followTail, historySize, readCursor, writeCursor, } from '../broker/history-stream.js';
4
5
  import { userSelf } from '../history.js';
5
- import { asLine } from '../stations/index.js';
6
+ import { asLine } from '../lines.js';
6
7
  import { loadMetroEnv } from '../paths.js';
8
+ import { pickMode } from './monitor-api.js';
7
9
  import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
8
- function resolveMode(f, self) {
9
- const strict = f.strict === true, unclaimed = f.unclaimed === true, all = f.all === true;
10
- if ([strict, unclaimed, all].filter(Boolean).length > 1) {
11
- throw exitErr('--strict, --unclaimed, --all are mutually exclusive', 1);
12
- }
13
- if (strict) {
14
- if (!self)
15
- throw exitErr('--strict requires --as <user-uri>', 1);
16
- return 'mine-only';
17
- }
18
- if (unclaimed)
19
- return 'unclaimed';
20
- if (all || !self)
21
- return 'all';
22
- return 'mine-or-unclaimed';
23
- }
24
- /** Generic fallback returned by `userSelf()` when there's no Claude/Codex env — treat as "no identity". */
25
- const GENERIC_USER = 'metro://user';
26
- function resolveSelf(f) {
27
- const raw = flagOne(f, 'as');
28
- if (raw !== undefined)
29
- return asLine(raw);
30
- const auto = userSelf();
31
- return auto === GENERIC_USER ? null : auto;
32
- }
33
- function resolveStartOffset(f, key) {
34
- const since = flagOne(f, 'since');
35
- if (since === 'tail')
36
- return historySize();
37
- if (since !== undefined) {
38
- const n = Number(since);
39
- if (!Number.isFinite(n) || n < 0)
40
- throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
41
- return n;
42
- }
43
- return key ? readCursor(key) : 0;
44
- }
10
+ export { handleMonitorRequest } from './monitor-api.js';
45
11
  export async function cmdTail(_, f) {
46
12
  loadMetroEnv();
47
- const self = resolveSelf(f);
48
- const mode = resolveMode(f, self);
13
+ const raw = flagOne(f, 'as');
14
+ const auto = userSelf();
15
+ const self = raw !== undefined ? asLine(raw) : auto === 'metro://user' ? null : auto;
16
+ const mode = pickMode(f.strict === true, f.unclaimed === true, f.all === true, self, msg => { throw exitErr(`--${msg}`, 1); });
17
+ const excludeFromFlag = flagOne(f, 'exclude-from');
18
+ const tail = {
19
+ mode, self, chatFilter: flagOne(f, 'chat'), stationFilter: flagOne(f, 'station'),
20
+ includeWebhooks: f['include-webhooks'] === true,
21
+ excludeFrom: excludeFromFlag
22
+ ? excludeFromFlag.split(',').map(s => s.trim()).filter(Boolean)
23
+ : undefined,
24
+ };
49
25
  const follow = f.follow === true;
50
- const chatFilter = flagOne(f, 'chat');
51
- const stationFilter = flagOne(f, 'station');
52
26
  const limit = Number(flagOne(f, 'limit')) || 0;
53
27
  const json = isJson(f);
54
- const includeWebhooks = f['include-webhooks'] === true;
55
- /** Cursor key derives from the effective mode (not userSelf), so `--all` / `--unclaimed` */
56
- /** don't trample the personal `--as=<me>` cursor in a CLAUDECODE shell. */
57
- const key = cursorKey(mode, self, { includeWebhooks });
58
- const startOffset = resolveStartOffset(f, key);
28
+ /** Cursor key derives from effective mode (not userSelf), so --all/--unclaimed don't trample --as. */
29
+ const key = cursorKey(mode, self, { includeWebhooks: tail.includeWebhooks });
30
+ const since = flagOne(f, 'since');
31
+ const sN = since !== undefined && since !== 'tail' ? Number(since) : NaN;
32
+ if (since !== undefined && since !== 'tail' && (!Number.isFinite(sN) || sN < 0)) {
33
+ throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
34
+ }
35
+ let offset = since === 'tail' ? historySize() : Number.isFinite(sN) ? sN : key ? readCursor(key) : 0;
59
36
  let emitted = 0;
60
- let offset = startOffset;
61
- const drain = () => {
62
- /** read claims once per drain so a burst of events shares a snapshot */
63
- const claims = readClaims();
64
- for (const { entry, offset: next } of readEntriesFrom(offset)) {
65
- offset = next;
66
- if (chatFilter && entry.line !== chatFilter)
67
- continue;
68
- if (stationFilter && entry.station !== stationFilter)
69
- continue;
70
- if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
71
- continue;
72
- if (json)
73
- process.stdout.write(JSON.stringify(entry) + '\n');
74
- else
75
- process.stdout.write(fmtRow(entry) + '\n');
76
- if (key)
77
- writeCursor(key, offset);
78
- emitted++;
79
- if (limit && emitted >= limit)
80
- return true;
81
- }
82
- return false;
37
+ const onEntry = (entry) => {
38
+ process.stdout.write((json ? JSON.stringify(entry) : fmtRow(entry)) + '\n');
39
+ if (key)
40
+ writeCursor(key, offset);
41
+ if (limit && ++emitted >= limit)
42
+ return true;
83
43
  };
84
- if (drain() && !follow)
44
+ offset = drainTail(offset, tail, onEntry);
45
+ if ((limit && emitted >= limit) || !follow)
85
46
  return;
86
- if (!follow)
87
- return;
88
- /** fs.watch on macOS sometimes coalesces or drops events — poll every 500ms as a backstop. */
89
47
  await new Promise(resolve => {
90
- let watcher = null;
91
- const trigger = () => { if (drain())
92
- cleanup(); };
93
- const cleanup = () => {
94
- if (watcher) {
95
- try {
96
- watcher.close();
97
- }
98
- catch { /* ignore */ }
99
- watcher = null;
100
- }
101
- clearInterval(poll);
102
- resolve();
103
- };
104
- const poll = setInterval(trigger, 500);
105
- const startWatcher = () => {
106
- if (!existsSync(HISTORY_FILE))
107
- return;
108
- try {
109
- watcher = watch(HISTORY_FILE, () => trigger());
110
- }
111
- catch { /* ignore — poll will catch */ }
112
- };
113
- startWatcher();
114
- process.on('SIGINT', cleanup);
115
- process.on('SIGTERM', cleanup);
116
- process.stdin.on('end', cleanup).on('close', cleanup);
48
+ const stop = followTail(offset, tail, e => { if (onEntry(e) === true)
49
+ finish(); }, 500);
50
+ const finish = () => { stop(); resolve(); };
51
+ process.on('SIGINT', finish);
52
+ process.on('SIGTERM', finish);
53
+ process.stdin.on('end', finish).on('close', finish);
117
54
  });
118
55
  }
119
56
  function fmtRow(e) {
120
- const ts = e.ts.slice(11, 19);
121
- const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
57
+ const body = e.text ?? '';
122
58
  const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
123
59
  const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
124
60
  const where = e.line.padEnd(40).slice(0, 40);
125
- return `${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
61
+ return `${e.ts.slice(11, 19)} ${e.id.padEnd(12)} ${who} ${where} ${text}`;
126
62
  }
127
63
  export async function cmdClaim(p, f) {
128
64
  loadMetroEnv();
@@ -138,11 +74,7 @@ export async function cmdRelease(p, f) {
138
74
  need(p, 1, 'metro release <line>');
139
75
  const line = asLine(p[0]);
140
76
  const { released, claims } = releaseLine(line);
141
- if (!released) {
142
- emit(f, `${line} was not claimed`, { ok: true, released: false, line, claims });
143
- return;
144
- }
145
- emit(f, `released ${line}`, { ok: true, released: true, line, claims });
77
+ emit(f, released ? `released ${line}` : `${line} was not claimed`, { ok: true, released, line, claims });
146
78
  }
147
79
  export async function cmdClaims(_, f) {
148
80
  loadMetroEnv();
@@ -151,14 +83,13 @@ export async function cmdClaims(_, f) {
151
83
  if (isJson(f))
152
84
  return writeJson({ claims });
153
85
  if (!entries.length) {
154
- process.stdout.write('(no claims — every tail with matching filters receives every event)\n');
155
- process.stdout.write(`file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
86
+ process.stdout.write('(no claims — every tail with matching filters receives every event)\n'
87
+ + `file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
156
88
  return;
157
89
  }
158
- const widest = Math.max(...entries.map(([l]) => l.length));
90
+ const w = Math.max(...entries.map(([l]) => l.length));
159
91
  process.stdout.write('metro claims\n\n');
160
- for (const [line, owner] of entries) {
161
- process.stdout.write(` ${line.padEnd(widest)} → ${owner}\n`);
162
- }
92
+ for (const [l, o] of entries)
93
+ process.stdout.write(` ${l.padEnd(w)} → ${o}\n`);
163
94
  process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
164
95
  }
@@ -1,7 +1,13 @@
1
- /** CLI subcommands: `metro webhook add|list|remove` + `metro tunnel setup`. */
1
+ /** CLI subcommands: `metro call`, `metro trains list`, `metro webhook ...`, `metro tunnel ...`. */
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
2
6
  import { spawnSync } from 'node:child_process';
3
- import { addEndpoint, listEndpoints, removeEndpoint, webhookPort } from '../webhooks.js';
4
- import { loadTunnelConfig, saveTunnelConfig } from '../tunnel.js';
7
+ import { ipcCall } from '../ipc.js';
8
+ import { loadMetroEnv } from '../paths.js';
9
+ import { TRAINS_DIR } from '../trains/supervisor.js';
10
+ import { addEndpoint, listEndpoints, loadTunnelConfig, removeEndpoint, saveTunnelConfig, webhookPort, } from '../tunnel.js';
5
11
  import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
6
12
  function urlFor(endpointId) {
7
13
  const t = loadTunnelConfig();
@@ -79,3 +85,97 @@ function run(cmd, args, opts = {}) {
79
85
  if (r.status !== 0 && !opts.allowFail)
80
86
  throw exitErr(`${cmd} ${args.join(' ')} exited ${r.status}`, 2);
81
87
  }
88
+ /* ──────────── metro call <train> <action> [args] + metro trains list ──────────── */
89
+ async function readArgs(raw) {
90
+ if (raw === undefined)
91
+ return {};
92
+ if (raw === '-') {
93
+ const chunks = [];
94
+ for await (const c of process.stdin)
95
+ chunks.push(c);
96
+ const s = Buffer.concat(chunks).toString('utf8').trim();
97
+ return s ? JSON.parse(s) : {};
98
+ }
99
+ if (raw.startsWith('@'))
100
+ return JSON.parse(readFileSync(raw.slice(1), 'utf8'));
101
+ /** Bare string allowed (handed to the train as-is). */
102
+ try {
103
+ return JSON.parse(raw);
104
+ }
105
+ catch {
106
+ return raw;
107
+ }
108
+ }
109
+ export async function cmdCall(p, f) {
110
+ need(p, 2, 'metro call <train> <action> [args-json | @file | -]');
111
+ loadMetroEnv();
112
+ const [train, action, rawArgs] = p;
113
+ const resp = await ipcCall({ op: 'forward-call', train, action, args: await readArgs(rawArgs) });
114
+ if (!resp.ok)
115
+ throw new Error(resp.error);
116
+ if (!('response' in resp))
117
+ throw new Error('daemon returned malformed forward-call response');
118
+ if (resp.response.error)
119
+ throw new Error(`train '${train}': ${resp.response.error}`);
120
+ if (isJson(f))
121
+ writeJson(resp.response.result ?? null);
122
+ else
123
+ process.stdout.write(JSON.stringify(resp.response.result ?? null) + '\n');
124
+ }
125
+ export async function cmdTrains(p, f) {
126
+ const sub = p[0] ?? 'list';
127
+ if (sub === 'restart')
128
+ return cmdTrainsRestart(p.slice(1), f);
129
+ if (sub === 'new')
130
+ return cmdTrainsNew(p.slice(1), f);
131
+ if (sub !== 'list')
132
+ throw new Error(`metro trains <list|restart|new> (got '${sub}')`);
133
+ loadMetroEnv();
134
+ const resp = await ipcCall({ op: 'trains-list' });
135
+ if (!resp.ok)
136
+ throw new Error(resp.error);
137
+ if (!('trains' in resp))
138
+ throw new Error('daemon returned malformed trains-list response');
139
+ if (isJson(f))
140
+ return writeJson({ trains: resp.trains });
141
+ if (!resp.trains.length) {
142
+ process.stdout.write('metro trains\n\n (no trains in ~/.metro/trains/)\n');
143
+ return;
144
+ }
145
+ process.stdout.write('metro trains\n\n');
146
+ for (const t of resp.trains) {
147
+ const mark = t.running ? '●' : '○';
148
+ const pid = t.pid ? ` pid ${t.pid}` : '';
149
+ const started = t.startedAt ? ` since ${t.startedAt.slice(11, 19)}` : '';
150
+ const fails = t.failCount ? ` · ${t.failCount} fail${t.failCount === 1 ? '' : 's'}` : '';
151
+ process.stdout.write(` ${mark} ${t.name.padEnd(16)}${pid}${started}${fails}\n ${t.path}\n`);
152
+ }
153
+ process.stdout.write('\n');
154
+ }
155
+ async function cmdTrainsRestart(p, f) {
156
+ need(p, 1, 'metro trains restart <name>');
157
+ loadMetroEnv();
158
+ const resp = await ipcCall({ op: 'train-restart', name: p[0] });
159
+ if (!resp.ok)
160
+ throw new Error(resp.error);
161
+ emit(f, `restarted train '${p[0]}'`, { ok: true, name: p[0] });
162
+ }
163
+ /** dist/cli/webhook.js → <package-root>/examples/telegram.ts */
164
+ const bundledExample = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'examples', 'telegram.ts');
165
+ async function cmdTrainsNew(p, f) {
166
+ need(p, 1, 'metro trains new <name>');
167
+ const name = p[0];
168
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
169
+ throw exitErr(`bad train name '${name}' (use [A-Za-z0-9_-])`, 1);
170
+ const src = bundledExample();
171
+ if (!existsSync(src))
172
+ throw exitErr(`bundled example missing at ${src} (broken install?)`, 2);
173
+ mkdirSync(TRAINS_DIR, { recursive: true });
174
+ const dest = join(TRAINS_DIR, `${name}.ts`);
175
+ if (existsSync(dest))
176
+ throw exitErr(`train already exists: ${dest}`, 1);
177
+ copyFileSync(src, dest);
178
+ const metroPkg = join(homedir(), '.metro', 'package.json');
179
+ const pkgHint = existsSync(metroPkg) ? '' : '\n (run `cd ~/.metro && bun init` first if your train needs deps)';
180
+ emit(f, `created ${dest}${pkgHint}\n next: edit the file, then \`metro trains restart ${name}\``, { ok: true, path: dest, pkgInitialized: existsSync(metroPkg) });
181
+ }
@@ -1,30 +1,10 @@
1
- /**
2
- * JSON-RPC/WS client for codex app-server: each event → `turn/start` (Codex's Monitor equivalent).
3
- */
4
- import { createConnection } from 'node:net';
5
- import { WebSocket } from 'ws';
6
- import { errMsg, log } from './log.js';
1
+ /** JSON-RPC/WS client for codex app-server: each event → `turn/start`. */
2
+ import { errMsg, log } from '../log.js';
3
+ import { buildTurnInput, encodeRpc, extractThreadStartedId, isStatusActive, openSocket, parseUrl, } from './protocol.js';
7
4
  const RECONNECT_DELAY_MS = 2_000;
8
5
  const MAX_QUEUE = 100;
9
6
  /** Backstop in case `turn/completed` never arrives — unstick the single-flight gate. */
10
7
  const TURN_TIMEOUT_MS = 120_000;
11
- /** Accept ws://, wss://, unix:///abs/path, or /abs/path (shorthand for unix). */
12
- function parseUrl(input) {
13
- if (input.startsWith('ws://') || input.startsWith('wss://'))
14
- return { kind: 'tcp', url: input };
15
- if (input.startsWith('unix://'))
16
- return { kind: 'unix', path: input.replace(/^unix:\/+/, '/') };
17
- if (input.startsWith('/'))
18
- return { kind: 'unix', path: input };
19
- throw new Error(`unsupported METRO_CODEX_RC: ${input} (expected ws://, wss://, unix://, or abs path)`);
20
- }
21
- function openSocket(endpoint) {
22
- if (endpoint.kind === 'tcp')
23
- return new WebSocket(endpoint.url);
24
- return new WebSocket('ws://localhost/', {
25
- createConnection: () => createConnection({ path: endpoint.path }),
26
- });
27
- }
28
8
  export class CodexRC {
29
9
  url;
30
10
  clientVersion;
@@ -115,9 +95,12 @@ export class CodexRC {
115
95
  p.resolve(msg.result);
116
96
  return;
117
97
  }
98
+ this.handleNotification(msg);
99
+ }
100
+ handleNotification(msg) {
118
101
  switch (msg.method) {
119
102
  case 'thread/started': {
120
- const id = msg.params?.thread?.id;
103
+ const id = extractThreadStartedId(msg.params);
121
104
  if (id) {
122
105
  this.setThreadId(id);
123
106
  log.info({ thread: id }, 'codex-rc thread started');
@@ -126,12 +109,10 @@ export class CodexRC {
126
109
  break;
127
110
  }
128
111
  case 'thread/status/changed': {
129
- /* Codex 0.130+: status `{active}` means in-flight; anything else is idle. */
130
- const p = msg.params;
131
- if (p?.threadId !== this.threadId)
112
+ const s = isStatusActive(msg.params, this.threadId);
113
+ if (!s.match)
132
114
  break;
133
- const active = typeof p.status === 'object' && p.status !== null && 'active' in p.status;
134
- if (active)
115
+ if (s.active)
135
116
  this.turnInFlight = true;
136
117
  else {
137
118
  this.clearTurnTimeout();
@@ -203,8 +184,7 @@ export class CodexRC {
203
184
  this.turnInFlight = true;
204
185
  this.armTurnTimeout();
205
186
  try {
206
- const input = [{ type: 'text', text: line, textElements: [] }];
207
- await this.call('turn/start', { threadId: this.threadId, input });
187
+ await this.call('turn/start', { threadId: this.threadId, input: buildTurnInput(line) });
208
188
  this.queue.shift();
209
189
  }
210
190
  catch (err) {
@@ -239,7 +219,7 @@ export class CodexRC {
239
219
  const ws = this.ws;
240
220
  return new Promise((resolve, reject) => {
241
221
  this.pending.set(id, { resolve: resolve, reject });
242
- ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
222
+ ws.send(encodeRpc(id, method, params));
243
223
  });
244
224
  }
245
225
  }
@@ -0,0 +1,38 @@
1
+ /** Codex JSON-RPC over WS: endpoint parsing, socket open, message encode/decode. */
2
+ import { createConnection } from 'node:net';
3
+ import { WebSocket } from 'ws';
4
+ /** Accept ws://, wss://, unix:///abs/path, or /abs/path (shorthand for unix). */
5
+ export function parseUrl(input) {
6
+ if (input.startsWith('ws://') || input.startsWith('wss://'))
7
+ return { kind: 'tcp', url: input };
8
+ if (input.startsWith('unix://'))
9
+ return { kind: 'unix', path: input.replace(/^unix:\/+/, '/') };
10
+ if (input.startsWith('/'))
11
+ return { kind: 'unix', path: input };
12
+ throw new Error(`unsupported METRO_CODEX_RC: ${input} (expected ws://, wss://, unix://, or abs path)`);
13
+ }
14
+ export function openSocket(endpoint) {
15
+ if (endpoint.kind === 'tcp')
16
+ return new WebSocket(endpoint.url);
17
+ return new WebSocket('ws://localhost/', {
18
+ createConnection: () => createConnection({ path: endpoint.path }),
19
+ });
20
+ }
21
+ export function encodeRpc(id, method, params) {
22
+ return JSON.stringify({ jsonrpc: '2.0', id, method, params });
23
+ }
24
+ /** Extract the active-thread state from a `thread/status/changed` params payload. */
25
+ /** Codex 0.130+: `status: {active}` ⇒ in-flight; any other shape ⇒ idle. */
26
+ export function isStatusActive(params, expectedThreadId) {
27
+ const p = params;
28
+ if (p?.threadId !== expectedThreadId)
29
+ return { match: false, active: false };
30
+ const active = typeof p.status === 'object' && p.status !== null && 'active' in p.status;
31
+ return { match: true, active };
32
+ }
33
+ export function extractThreadStartedId(params) {
34
+ return params?.thread?.id;
35
+ }
36
+ export function buildTurnInput(line) {
37
+ return [{ type: 'text', text: line, textElements: [] }];
38
+ }
@@ -0,0 +1,122 @@
1
+ /** Dispatcher's plumbing: outbound event emission + train-envelope translation + HTTP receiver. */
2
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { createServer, } from 'node:http';
4
+ import { Line } from '../lines.js';
5
+ import { errMsg, log } from '../log.js';
6
+ import { noteSeen } from '../paths.js';
7
+ import { appendHistory, formatDisplay, mintId, noteUserFromLine, userSelf, } from '../history.js';
8
+ import { handleMonitorRequest } from '../cli/tail.js';
9
+ import { findEndpoint, listEndpoints, webhookPort } from '../tunnel.js';
10
+ export function makeEmit(codexRc) {
11
+ return function emit(entry) {
12
+ /** `display` first so it survives Monitor's body truncation — the user must see it to echo it. */
13
+ const enriched = { display: formatDisplay(entry), ...entry };
14
+ const json = JSON.stringify(enriched);
15
+ process.stdout.write(json + '\n');
16
+ codexRc?.push(json);
17
+ noteSeen(entry.line, entry.lineName);
18
+ for (const l of [entry.line, entry.from, entry.to])
19
+ if (l)
20
+ noteUserFromLine(l);
21
+ appendHistory(enriched);
22
+ };
23
+ }
24
+ /** Translate the snake_case train wire envelope to a camelCase `HistoryEntry`. */
25
+ /** Trains can omit `id`/`station`/`to`; metro fills sensible defaults. */
26
+ export function trainEventToHistoryEntry(env, trainName) {
27
+ const line = env.line;
28
+ if (typeof line !== 'string') {
29
+ log.warn({ train: trainName }, 'train: dropped event without `line`');
30
+ return null;
31
+ }
32
+ const station = env.station ?? Line.station(line) ?? trainName;
33
+ const isPrivate = env.is_private === true;
34
+ /** Trains may still emit `emoji` for reactions — fold it into text so the new envelope stays minimal. */
35
+ const text = env.text ?? (env.emoji ? `[react ${env.emoji}]` : undefined);
36
+ return {
37
+ id: env.id ?? mintId(),
38
+ ts: env.ts ?? new Date().toISOString(),
39
+ station,
40
+ line: line,
41
+ lineName: env.line_name,
42
+ from: (env.from ?? `metro://${station}`),
43
+ fromName: env.from_name,
44
+ to: (env.to ?? (isPrivate ? userSelf() : line)),
45
+ text,
46
+ messageId: env.message_id,
47
+ replyTo: env.reply_to,
48
+ payload: env.payload,
49
+ };
50
+ }
51
+ export async function startWebhookServer(emit) {
52
+ const port = webhookPort();
53
+ const server = createServer((req, res) => {
54
+ handleRequest(req, res, emit).catch(err => {
55
+ log.warn({ err: errMsg(err) }, 'webhook handler error');
56
+ if (!res.headersSent)
57
+ res.writeHead(500).end();
58
+ });
59
+ });
60
+ await new Promise((resolve, reject) => {
61
+ server.once('error', reject);
62
+ server.listen(port, '127.0.0.1', () => {
63
+ log.info({ port, endpoints: listEndpoints().length }, 'webhook + monitor ready');
64
+ resolve();
65
+ });
66
+ });
67
+ return server;
68
+ }
69
+ async function handleRequest(req, res, emit) {
70
+ if (handleMonitorRequest(req, res, emit))
71
+ return;
72
+ const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
73
+ if (!m) {
74
+ res.writeHead(404).end();
75
+ return;
76
+ }
77
+ const endpointId = m[1];
78
+ const endpoint = findEndpoint(endpointId);
79
+ if (!endpoint) {
80
+ res.writeHead(404).end();
81
+ return;
82
+ }
83
+ if (req.method === 'GET') {
84
+ res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
85
+ return;
86
+ }
87
+ if (req.method !== 'POST') {
88
+ res.writeHead(405).end();
89
+ return;
90
+ }
91
+ const chunks = [];
92
+ for await (const c of req)
93
+ chunks.push(c);
94
+ const raw = Buffer.concat(chunks);
95
+ const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
96
+ if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
97
+ log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
98
+ res.writeHead(401).end('signature mismatch');
99
+ return;
100
+ }
101
+ let body = raw.toString('utf8');
102
+ try {
103
+ body = JSON.parse(body);
104
+ }
105
+ catch { /* keep as string */ }
106
+ const line = Line.webhook(endpointId);
107
+ emit({
108
+ id: mintId(), ts: new Date().toISOString(), station: 'webhook',
109
+ line, lineName: endpoint.label, from: line, to: line,
110
+ messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
111
+ text: `${headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event'} ${req.method} ${req.url}`,
112
+ payload: { headers, body },
113
+ });
114
+ res.writeHead(200).end('ok');
115
+ }
116
+ function verifySig(secret, raw, header) {
117
+ if (!header?.startsWith('sha256='))
118
+ return false;
119
+ const given = Buffer.from(header.slice(7), 'hex');
120
+ const want = createHmac('sha256', secret).update(raw).digest();
121
+ return given.length === want.length && timingSafeEqual(given, want);
122
+ }