@stage-labs/metro 0.1.0-beta.6 → 0.1.0-beta.8

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.
@@ -7,17 +7,29 @@ import { fileURLToPath } from 'node:url';
7
7
  import pkg from '../package.json' with { type: 'json' };
8
8
  import { DiscordStation } from './stations/discord.js';
9
9
  import { TelegramStation } from './stations/telegram.js';
10
- import { asLine } from './stations/index.js';
10
+ import { WebhookStation } from './stations/webhook.js';
11
+ import { asLine, Line } from './stations/index.js';
11
12
  import { CodexRC } from './codex-rc.js';
12
13
  import { startIpcServer, stopIpcServer } from './ipc.js';
13
- import { agentSelf, appendHistory, mintId } from './history.js';
14
+ import { agentSelf, appendHistory, mintId, selfLine } from './history.js';
14
15
  import { noteSeen, saveBotId } from './cache.js';
15
16
  import { errMsg, log } from './log.js';
16
17
  import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
18
+ import { setCodexSessionId } from './stations/codex.js';
19
+ import { noteAgentFromLine } from './registry.js';
20
+ import { listEndpoints } from './webhooks.js';
21
+ import { loadTunnelConfig, Tunnel } from './tunnel.js';
17
22
  loadMetroEnv();
18
23
  const platforms = configuredPlatforms();
19
- requireConfiguredPlatform(platforms);
24
+ const endpoints = listEndpoints();
25
+ requireConfiguredPlatform(platforms, endpoints.length > 0);
20
26
  acquireLock(join(STATE_DIR, '.tail-lock'));
27
+ // Fail fast if launched from Claude Code without a logged-in account.
28
+ const self = agentSelf();
29
+ log.info({ self, line: selfLine() }, 'agent identity');
30
+ const seedSelf = () => { const l = selfLine(); if (l)
31
+ noteAgentFromLine(l); };
32
+ seedSelf();
21
33
  const AGENTS_MD = join(STATE_DIR, 'AGENTS.md');
22
34
  try {
23
35
  copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agents.md'), AGENTS_MD);
@@ -31,40 +43,31 @@ process.stdout.on('error', err => {
31
43
  log.warn({ err: errMsg(err) }, 'stdout error');
32
44
  });
33
45
  const codexRc = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX_RC, pkg.version) : null;
46
+ codexRc?.onThread(id => { setCodexSessionId(id); seedSelf(); });
34
47
  codexRc?.start();
35
48
  const discord = new DiscordStation();
36
49
  const telegram = new TelegramStation();
37
- function emit(event) {
38
- const json = JSON.stringify(event);
50
+ const webhook = new WebhookStation();
51
+ const tunnelCfg = loadTunnelConfig();
52
+ const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhook.port()) : null;
53
+ function emit(entry) {
54
+ const json = JSON.stringify(entry);
39
55
  process.stdout.write(json + '\n');
40
56
  codexRc?.push(json);
41
- if ('lineName' in event)
42
- noteSeen(event.line, event.lineName);
43
- else
44
- noteSeen(event.line);
45
- if (event.type === 'inbound') {
46
- appendHistory({
47
- id: event.id, ts: event.ts, kind: 'inbound', station: event.station, line: event.line,
48
- from: event.from, fromName: event.fromName, to: agentSelf(), text: event.text,
49
- platformMessageId: event.messageId, attachments: event.attachmentNames,
50
- });
51
- }
52
- else if (event.type === 'notification') {
53
- appendHistory({
54
- id: event.id, ts: event.ts, kind: 'notification',
55
- station: event.line.replace(/^metro:\/\/([^/]+)\/.*$/, '$1'),
56
- line: event.line, from: event.from ?? agentSelf(),
57
- to: event.line, text: event.text,
58
- });
59
- }
57
+ noteSeen(entry.line, entry.lineName);
58
+ for (const l of [entry.line, entry.from, entry.to])
59
+ if (l)
60
+ noteAgentFromLine(l);
61
+ appendHistory(entry);
60
62
  }
61
- const onInbound = (m) => emit({ type: 'inbound', ...m, to: agentSelf() });
63
+ const onInbound = (m) => emit({ ...m, kind: 'inbound', to: agentSelf() });
62
64
  const ipc = startIpcServer(async (req) => {
63
65
  if (req.op === 'notify') {
66
+ const line = asLine(req.line);
64
67
  emit({
65
- type: 'notification', id: mintId(), ts: new Date().toISOString(),
66
- line: asLine(req.line),
67
- from: req.from ? asLine(req.from) : agentSelf(), text: req.text,
68
+ id: mintId(), ts: new Date().toISOString(), kind: 'notification',
69
+ station: Line.station(line) ?? '?', line,
70
+ from: req.from ? asLine(req.from) : agentSelf(), to: line, text: req.text,
68
71
  });
69
72
  return { ok: true };
70
73
  }
@@ -89,7 +92,13 @@ async function main() {
89
92
  saveBotId('telegram', String(me.id));
90
93
  log.info({ bot: `@${me.username}` }, 'telegram ready');
91
94
  }
92
- log.info({ codexRc: !!codexRc }, 'dispatcher ready');
95
+ /** Start the HTTP receiver only when ≥1 endpoint is registered — no point binding a port nobody listens to. */
96
+ if (endpoints.length) {
97
+ webhook.onMessage(onInbound);
98
+ await webhook.start();
99
+ tunnel?.start();
100
+ }
101
+ log.info({ codexRc: !!codexRc, tunnel: !!tunnel }, 'dispatcher ready');
93
102
  }
94
103
  let shuttingDown = false;
95
104
  async function shutdown() {
@@ -98,15 +107,16 @@ async function shutdown() {
98
107
  shuttingDown = true;
99
108
  log.info('dispatcher shutting down');
100
109
  codexRc?.stop();
110
+ tunnel?.stop();
101
111
  await stopIpcServer(ipc).catch(() => { });
112
+ await webhook.stop().catch(() => { });
102
113
  if (platforms.discord)
103
114
  await discord.stop().catch(() => { });
104
115
  if (platforms.telegram)
105
116
  await telegram.stop().catch(() => { });
106
117
  process.exit(0);
107
118
  }
108
- process.stdin.on('end', shutdown);
109
- process.stdin.on('close', shutdown);
110
- process.on('SIGINT', shutdown);
111
- process.on('SIGTERM', shutdown);
119
+ process.stdin.on('end', shutdown).on('close', shutdown);
120
+ for (const sig of ['SIGINT', 'SIGTERM'])
121
+ process.on(sig, shutdown);
112
122
  await main();
package/dist/history.js CHANGED
@@ -4,6 +4,9 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { errMsg, log } from './log.js';
6
6
  import { STATE_DIR } from './paths.js';
7
+ import { Line } from './stations/index.js';
8
+ import { claudeAgentId, claudeSessionId } from './stations/claude.js';
9
+ import { codexAgentId, codexSessionId } from './stations/codex.js';
7
10
  const FILE = join(STATE_DIR, 'history.jsonl');
8
11
  /** Mint a universal metro message ID. Short, prefixed, URL-safe. */
9
12
  export const mintId = () => `msg_${randomBytes(6).toString('base64url')}`;
@@ -60,25 +63,37 @@ function matches(e, f) {
60
63
  /** Find an entry by universal id OR platform message id. */
61
64
  export function lookupEntry(id) {
62
65
  const entries = readHistory({ limit: 5_000 });
63
- return entries.find(e => e.id === id || e.platformMessageId === id);
66
+ return entries.find(e => e.id === id || e.messageId === id);
64
67
  }
65
68
  /** Look up the platform messageId for a universal `msg_*` id; returns the input unchanged otherwise. */
66
69
  export function resolvePlatformId(id) {
67
70
  if (!id.startsWith('msg_'))
68
71
  return id;
69
72
  const hit = lookupEntry(id);
70
- if (hit?.platformMessageId)
71
- return hit.platformMessageId;
73
+ if (hit?.messageId)
74
+ return hit.messageId;
72
75
  throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
73
76
  }
74
- /** Resolve the current agent's identity URI. Precedence: METRO_FROM > runtime env > generic. */
77
+ /** The current agent's **participant** URI for `from`/`to`. Precedence: METRO_FROM > runtime env > generic. */
75
78
  export function agentSelf() {
76
79
  const explicit = process.env.METRO_FROM;
77
80
  if (explicit)
78
81
  return explicit;
79
82
  if (process.env.CLAUDECODE)
80
- return 'metro://claude/agent';
83
+ return Line.user('claude', claudeAgentId());
81
84
  if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME)
82
- return 'metro://codex/agent';
85
+ return Line.user('codex', codexAgentId());
83
86
  return 'metro://agent';
84
87
  }
88
+ /** The current agent's **line** URI `<agent-id>/<session>`. Null until session is known (rc thread pending). */
89
+ export function selfLine() {
90
+ if (process.env.CLAUDECODE) {
91
+ const s = claudeSessionId();
92
+ return s ? Line.claude(claudeAgentId(), s) : null;
93
+ }
94
+ if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME) {
95
+ const s = codexSessionId();
96
+ return s ? Line.codex(codexAgentId(), s) : null;
97
+ }
98
+ return null;
99
+ }
package/dist/ipc.js CHANGED
@@ -12,7 +12,8 @@ export function startIpcServer(handler) {
12
12
  }
13
13
  catch { /* ignore */ }
14
14
  }
15
- const server = createServer(socket => handleConnection(socket, handler));
15
+ /** allowHalfOpen: any `await` in the handler races Node's auto-end-on-client-FIN, dropping the response. */
16
+ const server = createServer({ allowHalfOpen: true }, s => handleConnection(s, handler));
16
17
  server.on('error', err => log.warn({ err: errMsg(err) }, 'ipc server error'));
17
18
  server.listen(SOCKET_PATH, () => log.debug({ path: SOCKET_PATH }, 'ipc socket listening'));
18
19
  return server;
package/dist/paths.js CHANGED
@@ -36,10 +36,10 @@ export function loadMetroEnv() {
36
36
  export function configuredPlatforms() {
37
37
  return { telegram: !!process.env.TELEGRAM_BOT_TOKEN, discord: !!process.env.DISCORD_BOT_TOKEN };
38
38
  }
39
- export function requireConfiguredPlatform(p) {
40
- if (p.telegram || p.discord)
39
+ export function requireConfiguredPlatform(p, hasWebhooks) {
40
+ if (p.telegram || p.discord || hasWebhooks)
41
41
  return;
42
- log.fatal('no platforms configured — run `metro setup telegram <token>` or `metro setup discord <token>`');
42
+ log.fatal('no inputs configured — run `metro setup telegram <token>`, `metro setup discord <token>`, or `metro webhook add <label>`');
43
43
  process.exit(2);
44
44
  }
45
45
  /** Singleton pidfile. Exits if another instance owns it; reclaims stale locks. */
@@ -0,0 +1,48 @@
1
+ /** Append-only registry of `(station, agent-id, sessions[])` tuples metro has seen. */
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { STATE_DIR } from './paths.js';
5
+ import { Line } from './stations/index.js';
6
+ import { errMsg, log } from './log.js';
7
+ const REGISTRY_FILE = join(STATE_DIR, 'agent-registry.json');
8
+ function readRegistry() {
9
+ if (!existsSync(REGISTRY_FILE))
10
+ return {};
11
+ try {
12
+ return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
13
+ }
14
+ catch (err) {
15
+ log.warn({ err: errMsg(err) }, 'agent-registry: malformed, resetting');
16
+ return {};
17
+ }
18
+ }
19
+ function record(station, agentId, sessionId) {
20
+ const reg = readRegistry();
21
+ const rows = (reg[station] ??= []);
22
+ let row = rows.find(r => r.agentId === agentId);
23
+ if (!row) {
24
+ row = { agentId, sessions: [], lastSeen: '' };
25
+ rows.push(row);
26
+ }
27
+ if (sessionId && !row.sessions.includes(sessionId))
28
+ row.sessions.push(sessionId);
29
+ row.lastSeen = new Date().toISOString();
30
+ try {
31
+ writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
32
+ }
33
+ catch (err) {
34
+ log.warn({ err: errMsg(err) }, 'agent-registry: write failed');
35
+ }
36
+ }
37
+ /** Scan a line URI for `(station, agentId, sessionId)` and record it. No-op on non-agent or participant URIs. */
38
+ export function noteAgentFromLine(line) {
39
+ const station = Line.station(line);
40
+ if (station !== 'claude' && station !== 'codex')
41
+ return;
42
+ const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
43
+ if (p)
44
+ record(station, p.agentId, p.sessionId);
45
+ }
46
+ export function listAgents(station) {
47
+ return readRegistry()[station] ?? [];
48
+ }
@@ -0,0 +1,45 @@
1
+ /** Resolve the Claude Code agent identity (account id + session id). */
2
+ import { execFileSync } from 'node:child_process';
3
+ /** Short TTL so account switches via `claude auth login` propagate to the daemon within seconds. */
4
+ const TTL_MS = 5_000;
5
+ let cache = null;
6
+ /** Stable per-Anthropic-account UUID. Same across devices for the same login. */
7
+ export function claudeAccountId() {
8
+ if (cache && Date.now() - cache.at < TTL_MS)
9
+ return cache.id;
10
+ let raw;
11
+ try {
12
+ raw = execFileSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
13
+ }
14
+ catch (e) {
15
+ throw new Error(`metro: failed to run 'claude auth status --json' — is Claude Code installed and on PATH? (${e.message})`);
16
+ }
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ throw new Error(`metro: 'claude auth status --json' returned non-JSON: ${raw.slice(0, 200)}`);
23
+ }
24
+ if (!parsed.loggedIn || !parsed.orgId) {
25
+ throw new Error('metro: Claude Code is not logged in — run \'claude auth login\'');
26
+ }
27
+ cache = { id: parsed.orgId, at: Date.now() };
28
+ return parsed.orgId;
29
+ }
30
+ export function tryClaudeAccountId() {
31
+ try {
32
+ return claudeAccountId();
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Agent-id for the line URI: `METRO_AGENT_ID` override, else the account id. */
39
+ export function claudeAgentId() {
40
+ return process.env.METRO_AGENT_ID || claudeAccountId();
41
+ }
42
+ /** Session: `CLAUDE_CODE_SESSION_ID` (stable across `--resume`). Override: `METRO_AGENT_SESSION_ID`. */
43
+ export function claudeSessionId() {
44
+ return process.env.METRO_AGENT_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || null;
45
+ }
@@ -0,0 +1,68 @@
1
+ /** Resolve the Codex agent identity (account id + session id). */
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { STATE_DIR } from '../paths.js';
6
+ /** Short TTL so account switches via `codex login` propagate to the daemon within seconds. */
7
+ const TTL_MS = 5_000;
8
+ let cache = null;
9
+ function authPath() {
10
+ return join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'auth.json');
11
+ }
12
+ export function codexAccountId() {
13
+ if (cache && Date.now() - cache.at < TTL_MS)
14
+ return cache.id;
15
+ const path = authPath();
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(path, 'utf8');
19
+ }
20
+ catch (e) {
21
+ throw new Error(`metro: failed to read ${path} — is Codex logged in? (${e.message})`);
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ throw new Error(`metro: ${path} is not valid JSON`);
29
+ }
30
+ const id = parsed.tokens?.account_id;
31
+ if (!id) {
32
+ throw new Error(`metro: no Codex account_id in ${path} (auth_mode=${parsed.auth_mode ?? 'unknown'}) — sign in with 'codex login' (ChatGPT mode required)`);
33
+ }
34
+ cache = { id, at: Date.now() };
35
+ return id;
36
+ }
37
+ export function tryCodexAccountId() {
38
+ try {
39
+ return codexAccountId();
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Agent-id for the line URI: `METRO_AGENT_ID` override, else the account id. */
46
+ export function codexAgentId() {
47
+ return process.env.METRO_AGENT_ID || codexAccountId();
48
+ }
49
+ const SESSION_FILE = join(STATE_DIR, 'stations', 'codex', 'session-id');
50
+ /** Session: codex-rc thread id (daemon persists; CLIs read state file). Override: `METRO_AGENT_SESSION_ID`. */
51
+ export function codexSessionId() {
52
+ if (process.env.METRO_AGENT_SESSION_ID)
53
+ return process.env.METRO_AGENT_SESSION_ID;
54
+ try {
55
+ return readFileSync(SESSION_FILE, 'utf8').trim() || null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /** Daemon-side: persist the rc thread id so CLI processes can read it. Best-effort. */
62
+ export function setCodexSessionId(threadId) {
63
+ try {
64
+ mkdirSync(dirname(SESSION_FILE), { recursive: true });
65
+ writeFileSync(SESSION_FILE, threadId ?? '');
66
+ }
67
+ catch { /* CLI just won't have a session segment */ }
68
+ }
@@ -97,7 +97,7 @@ export class DiscordStation {
97
97
  }
98
98
  async start() {
99
99
  const c = this.getClient();
100
- c.on(Events.MessageCreate, m => { void this.handleMessage(m, c); });
100
+ c.on(Events.MessageCreate, m => { void this.handleMessage(m); });
101
101
  c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
102
102
  await c.login(process.env.DISCORD_BOT_TOKEN);
103
103
  await new Promise(r => c.once(Events.ClientReady, () => r()));
@@ -170,29 +170,31 @@ export class DiscordStation {
170
170
  messageId: m.id, author: m.author.username, text: m.content, timestamp: m.timestamp,
171
171
  }));
172
172
  }
173
- async handleMessage(m, c) {
173
+ async handleMessage(m) {
174
174
  if (m.author.bot)
175
175
  return;
176
- const attachmentNames = [];
177
- for (const a of m.attachments.values()) {
178
- if (a.contentType?.startsWith('image/'))
179
- attachmentNames.push('[image]');
180
- else
181
- attachmentNames.push(a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
182
- }
183
- const text = m.content.trim();
184
- if (!text && !attachmentNames.length)
176
+ const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
177
+ : a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
178
+ const text = [m.content.trim(), ...tags].filter(Boolean).join(' ');
179
+ if (!text)
185
180
  return;
186
- log.info({ from: m.author.username, bot: c.user?.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
181
+ log.info({ from: m.author.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
187
182
  const lineName = m.channel && 'name' in m.channel
188
183
  ? m.channel.name ?? undefined : undefined;
184
+ const payload = m.toJSON();
185
+ if (m.reference?.messageId) {
186
+ try {
187
+ payload.referencedMessage = (await m.fetchReference()).toJSON();
188
+ }
189
+ catch (err) {
190
+ log.debug({ err: errMsg(err) }, 'discord: fetchReference failed');
191
+ }
192
+ }
189
193
  this.messageHandler({
190
194
  id: mintId(), ts: new Date(m.createdTimestamp).toISOString(),
191
195
  station: 'discord', line: Line.discord(m.channelId), lineName,
192
196
  from: Line.user('discord', m.author.id), fromName: m.author.username,
193
- messageId: m.id, text, attachmentNames,
194
- mentionsBot: !m.guildId || (c.user ? m.mentions.has(c.user.id) : false),
195
- meta: { inGuild: !!m.guildId },
197
+ messageId: m.id, text, payload,
196
198
  });
197
199
  }
198
200
  }
@@ -1,13 +1,29 @@
1
1
  /** Line URI scheme + ChatStation interface + station listing. The whole station surface. */
2
+ import { tryClaudeAccountId } from './claude.js';
3
+ import { tryCodexAccountId } from './codex.js';
4
+ import { listAgents } from '../registry.js';
5
+ import { listEndpoints } from '../webhooks.js';
6
+ import { loadTunnelConfig } from '../tunnel.js';
2
7
  export const asLine = (s) => s;
3
8
  const PREFIX = 'metro://';
4
9
  const build = (station, ...seg) => asLine(`${PREFIX}${station}/${seg.map(String).join('/')}`);
10
+ /** Shared parser for `metro://{claude,codex}/<agentId>/<sessionId>`. Skips participant URIs (`/user/…`, `/bot/…`). */
11
+ function parseAgent(line, station) {
12
+ const p = Line.parse(line);
13
+ if (p?.station !== station || p.path[0] === 'user' || p.path[0] === 'bot' || p.path.length < 2)
14
+ return null;
15
+ return { agentId: p.path[0], sessionId: p.path[1] };
16
+ }
5
17
  /** URI helpers. Lives on a const that doubles as the `Line` type's value-side namespace. */
6
18
  export const Line = {
7
19
  discord: (channelId) => build('discord', channelId),
8
20
  telegram: (chatId, topicId) => topicId !== undefined ? build('telegram', chatId, topicId) : build('telegram', chatId),
9
- claude: (topic) => build('claude', topic),
10
- codex: (topic) => build('codex', topic),
21
+ /** `metro://claude/<orgId>/<sessionId>` orgId from `claude auth status`, session from `CLAUDE_CODE_SESSION_ID`. */
22
+ claude: (orgId, sessionId) => build('claude', orgId, sessionId),
23
+ /** `metro://codex/<accountId>/<threadId>` — accountId from auth.json, thread from codex-rc handshake. */
24
+ codex: (accountId, threadId) => build('codex', accountId, threadId),
25
+ /** `metro://webhook/<endpoint-id>` — one HTTP receive endpoint, registered via `metro webhook add`. */
26
+ webhook: (endpointId) => build('webhook', endpointId),
11
27
  /** Participant URIs — `metro://<station>/user/<id>` and `metro://<station>/bot/<id>`. */
12
28
  user: (station, id) => build(station, 'user', id),
13
29
  bot: (station, id) => build(station, 'bot', id),
@@ -38,17 +54,49 @@ export const Line = {
38
54
  const topicId = Number(p.path[1]);
39
55
  return Number.isFinite(topicId) ? { chatId, topicId } : null;
40
56
  },
57
+ parseClaude: (line) => parseAgent(line, 'claude'),
58
+ parseCodex: (line) => parseAgent(line, 'codex'),
59
+ parseWebhook(line) {
60
+ const p = Line.parse(line);
61
+ return p?.station === 'webhook' && p.path.length === 1 ? p.path[0] : null;
62
+ },
41
63
  isAgent: (line) => {
42
64
  const s = Line.station(line);
43
65
  return s === 'claude' || s === 'codex';
44
66
  },
45
67
  };
46
- const AGENT_CAPS = { in: ['text'], out: [], features: ['notify'] };
68
+ /** `out: ['text']` + `send` reflects the IPC notify path (`metro send metro://<station>/...` re-emits on stdout). */
69
+ const AGENT_CAPS = { in: ['text'], out: ['text'], features: ['send', 'notify'] };
47
70
  const CHAT_CAPS = {
48
71
  in: ['text', 'image'],
49
72
  out: ['text'],
50
73
  features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'],
51
74
  };
75
+ const WEBHOOK_CAPS = { in: ['text'], out: [], features: [] };
76
+ function seenSummary(station) {
77
+ const agents = listAgents(station);
78
+ if (!agents.length)
79
+ return '';
80
+ const sessions = agents.reduce((n, a) => n + a.sessions.length, 0);
81
+ return ` · seen ${agents.length} agent${agents.length === 1 ? '' : 's'}, ${sessions} session${sessions === 1 ? '' : 's'}`;
82
+ }
83
+ function claudeStationDetail() {
84
+ const seen = seenSummary('claude');
85
+ if (!process.env.CLAUDECODE)
86
+ return `launch metro from inside a Claude Code session${seen}`;
87
+ const orgId = tryClaudeAccountId();
88
+ return `${orgId ? `account: ${orgId}` : 'logged out — run `claude auth login`'}${seen}`;
89
+ }
90
+ function codexStationDetail() {
91
+ const rc = process.env.METRO_CODEX_RC;
92
+ const accountId = tryCodexAccountId();
93
+ const seen = seenSummary('codex');
94
+ const parts = [
95
+ accountId ? `account: ${accountId}` : (rc ? '(no Codex account — run `codex login`)' : null),
96
+ rc ? `push → ${rc}` : (!accountId ? 'set METRO_CODEX_RC=ws://… to push' : null),
97
+ ].filter(Boolean);
98
+ return `${parts.join(' · ')}${seen}`;
99
+ }
52
100
  export const listStations = () => [
53
101
  {
54
102
  name: 'discord', kind: 'chat', capabilities: CHAT_CAPS,
@@ -60,14 +108,26 @@ export const listStations = () => [
60
108
  },
61
109
  {
62
110
  name: 'claude', kind: 'agent', capabilities: AGENT_CAPS,
63
- configured: null, detail: 'stdout stream (watch via Claude Code Monitor)',
111
+ configured: !!process.env.CLAUDECODE,
112
+ detail: claudeStationDetail(),
64
113
  },
65
114
  {
66
115
  name: 'codex', kind: 'agent', capabilities: AGENT_CAPS,
67
- configured: process.env.METRO_CODEX_RC ? true : null,
68
- detail: process.env.METRO_CODEX_RC
69
- ? `push → ${process.env.METRO_CODEX_RC}`
70
- : 'set METRO_CODEX_RC=ws://… to push',
116
+ configured: !!(process.env.METRO_CODEX_RC || process.env.CODEX_HOME),
117
+ detail: codexStationDetail(),
118
+ },
119
+ {
120
+ name: 'webhook', kind: 'service', capabilities: WEBHOOK_CAPS,
121
+ configured: listEndpoints().length > 0,
122
+ detail: webhookStationDetail(),
71
123
  },
72
124
  ];
125
+ function webhookStationDetail() {
126
+ const eps = listEndpoints();
127
+ const t = loadTunnelConfig();
128
+ const base = t ? `https://${t.hostname}` : `http://127.0.0.1:${Number(process.env.METRO_WEBHOOK_PORT) || 8420}`;
129
+ if (!eps.length)
130
+ return `no endpoints (run \`metro webhook add <label>\`)${t ? ` · tunnel → ${t.hostname}` : ''}`;
131
+ return `${eps.length} endpoint${eps.length === 1 ? '' : 's'} · base ${base}${t ? '' : ' (no tunnel — run `metro tunnel setup`)'}`;
132
+ }
73
133
  export const fmtCapabilities = (c) => `in: ${c.in.join('+') || '–'} · out: ${c.out.join('+') || '–'} · features: ${c.features.join(', ') || '–'}`;
@@ -59,8 +59,6 @@ const CAPS = {
59
59
  export class TelegramStation {
60
60
  name = 'telegram';
61
61
  capabilities = CAPS;
62
- botUsername = null;
63
- botUserId = null;
64
62
  pollOffset = 0;
65
63
  pollAbort = null;
66
64
  messageHandler = () => { };
@@ -85,10 +83,7 @@ export class TelegramStation {
85
83
  }
86
84
  async stop() { this.pollAbort?.abort(); this.pollAbort = null; }
87
85
  async getMe() {
88
- const me = await tg('getMe', {});
89
- this.botUsername = me.username;
90
- this.botUserId = me.id;
91
- return me;
86
+ return tg('getMe', {});
92
87
  }
93
88
  async send(line, text, opts) {
94
89
  const { chatId, topicId } = targetOf(line);
@@ -196,15 +191,12 @@ export class TelegramStation {
196
191
  const m = u.message;
197
192
  if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
198
193
  return;
199
- const attachmentNames = attachmentTags(m);
200
- const text = m.text ?? m.caption ?? '';
201
- if (!text && !attachmentNames.length)
194
+ const text = [m.text ?? m.caption, ...attachmentTags(m)].filter(Boolean).join(' ');
195
+ if (!text)
202
196
  return;
203
197
  const topicId = m.is_topic_message ? m.message_thread_id : undefined;
204
198
  const fromName = m.from?.username ? `@${m.from.username}` : m.from?.first_name;
205
- const fromUri = Line.user('telegram', m.from?.id ?? 'unknown');
206
- const bot = this.botUsername ? `@${this.botUsername}` : undefined;
207
- log.info({ from: fromName, bot, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
199
+ log.info({ from: fromName, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
208
200
  if (this.recent.size >= 50) {
209
201
  const first = this.recent.keys().next().value;
210
202
  if (first)
@@ -215,21 +207,7 @@ export class TelegramStation {
215
207
  id: mintId(), ts: new Date((m.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
216
208
  station: 'telegram', line: Line.telegram(m.chat.id, topicId), messageId: String(m.message_id),
217
209
  lineName: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
218
- from: fromUri, fromName, text, attachmentNames, mentionsBot: this.detectMentionsBot(m),
219
- meta: { isPrivate: m.chat.type === 'private', inForum: !!m.chat.is_forum, isForumTopic: !!m.is_topic_message },
210
+ from: Line.user('telegram', m.from?.id ?? 'unknown'), fromName, text, payload: m,
220
211
  });
221
212
  }
222
- detectMentionsBot(m) {
223
- if (m.chat?.type === 'private')
224
- return true;
225
- const text = m.text ?? m.caption ?? '';
226
- for (const e of m.entities ?? m.caption_entities ?? []) {
227
- if (e.type === 'mention' && this.botUsername
228
- && text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${this.botUsername.toLowerCase()}`)
229
- return true;
230
- if (e.type === 'text_mention' && e.user?.id === this.botUserId)
231
- return true;
232
- }
233
- return false;
234
- }
235
213
  }