@stage-labs/metro 0.1.0-beta.7 → 0.1.0-beta.9
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 +83 -27
- package/dist/cli/index.js +19 -1
- package/dist/cli/webhook.js +83 -0
- package/dist/codex-rc.js +14 -5
- package/dist/dispatcher.js +30 -3
- package/dist/history.js +18 -3
- package/dist/paths.js +3 -3
- package/dist/registry.js +48 -0
- package/dist/stations/claude.js +45 -0
- package/dist/stations/codex.js +68 -0
- package/dist/stations/index.js +68 -8
- package/dist/stations/webhook.js +100 -0
- package/dist/tunnel.js +66 -0
- package/dist/webhooks.js +40 -0
- package/docs/agents.md +47 -16
- package/docs/uri-scheme.md +68 -14
- package/package.json +1 -1
- package/skills/metro/SKILL.md +24 -16
|
@@ -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
|
+
}
|
package/dist/stations/index.js
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
68
|
-
detail:
|
|
69
|
-
|
|
70
|
-
|
|
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(', ') || '–'}`;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Receive-only HTTP station. Each registered endpoint = one path `/wh/<id>` → InboundMessage on stdout. */
|
|
2
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { errMsg, log } from '../log.js';
|
|
5
|
+
import { mintId } from '../history.js';
|
|
6
|
+
import { findEndpoint, listEndpoints } from '../webhooks.js';
|
|
7
|
+
import { Line } from './index.js';
|
|
8
|
+
const DEFAULT_PORT = 8420;
|
|
9
|
+
/** Synthesize an `event` tag from common provider-specific headers (GitHub, Intercom). */
|
|
10
|
+
function pickEvent(headers) {
|
|
11
|
+
return headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event';
|
|
12
|
+
}
|
|
13
|
+
/** Constant-time signature check against `X-Hub-Signature-256: sha256=<hex>` (GitHub/Intercom style). */
|
|
14
|
+
function verifySig(secret, raw, header) {
|
|
15
|
+
if (!header?.startsWith('sha256='))
|
|
16
|
+
return false;
|
|
17
|
+
const given = Buffer.from(header.slice(7), 'hex');
|
|
18
|
+
const want = createHmac('sha256', secret).update(raw).digest();
|
|
19
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
20
|
+
}
|
|
21
|
+
async function readBody(req) {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const c of req)
|
|
24
|
+
chunks.push(c);
|
|
25
|
+
return Buffer.concat(chunks);
|
|
26
|
+
}
|
|
27
|
+
export class WebhookStation {
|
|
28
|
+
name = 'webhook';
|
|
29
|
+
server = null;
|
|
30
|
+
handler = null;
|
|
31
|
+
port() { return Number(process.env.METRO_WEBHOOK_PORT) || DEFAULT_PORT; }
|
|
32
|
+
onMessage(h) { this.handler = h; }
|
|
33
|
+
start() {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
this.server = createServer((req, res) => this.handle(req, res).catch(err => {
|
|
36
|
+
log.warn({ err: errMsg(err) }, 'webhook handler error');
|
|
37
|
+
if (!res.headersSent)
|
|
38
|
+
res.writeHead(500).end();
|
|
39
|
+
}));
|
|
40
|
+
this.server.on('error', reject);
|
|
41
|
+
this.server.listen(this.port(), '127.0.0.1', () => {
|
|
42
|
+
log.info({ port: this.port(), endpoints: listEndpoints().length }, 'webhook station ready');
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async stop() {
|
|
48
|
+
const srv = this.server;
|
|
49
|
+
if (!srv)
|
|
50
|
+
return;
|
|
51
|
+
await new Promise(resolve => srv.close(() => resolve()));
|
|
52
|
+
this.server = null;
|
|
53
|
+
}
|
|
54
|
+
async handle(req, res) {
|
|
55
|
+
const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
|
|
56
|
+
if (!m) {
|
|
57
|
+
res.writeHead(404).end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const endpointId = m[1];
|
|
61
|
+
const endpoint = findEndpoint(endpointId);
|
|
62
|
+
if (!endpoint) {
|
|
63
|
+
res.writeHead(404).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (req.method === 'GET') {
|
|
67
|
+
res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method !== 'POST') {
|
|
71
|
+
res.writeHead(405).end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const raw = await readBody(req);
|
|
75
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
|
|
76
|
+
if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
|
|
77
|
+
log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
|
|
78
|
+
res.writeHead(401).end('signature mismatch');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let body = raw.toString('utf8');
|
|
82
|
+
try {
|
|
83
|
+
body = JSON.parse(body);
|
|
84
|
+
}
|
|
85
|
+
catch { /* keep as string */ }
|
|
86
|
+
const line = Line.webhook(endpointId);
|
|
87
|
+
this.handler?.({
|
|
88
|
+
id: mintId(),
|
|
89
|
+
ts: new Date().toISOString(),
|
|
90
|
+
station: 'webhook',
|
|
91
|
+
line,
|
|
92
|
+
lineName: endpoint.label,
|
|
93
|
+
from: line,
|
|
94
|
+
messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
|
|
95
|
+
text: `${pickEvent(headers)} ${req.method} ${req.url}`,
|
|
96
|
+
payload: { headers, body },
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(200).end('ok');
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Cloudflared tunnel manager. Prefers token-from-env so a missing local credentials JSON does not block startup. */
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { STATE_DIR } from './paths.js';
|
|
6
|
+
import { errMsg, log } from './log.js';
|
|
7
|
+
const FILE = join(STATE_DIR, 'tunnel.json');
|
|
8
|
+
const RESTART_DELAY_MS = 2_000;
|
|
9
|
+
export const loadTunnelConfig = () => existsSync(FILE)
|
|
10
|
+
? JSON.parse(readFileSync(FILE, 'utf8'))
|
|
11
|
+
: null;
|
|
12
|
+
export function saveTunnelConfig(c) { writeFileSync(FILE, JSON.stringify(c, null, 2)); }
|
|
13
|
+
/** Fetch the tunnel's auth token. Null when CLI is unavailable, not logged in, or no such tunnel. */
|
|
14
|
+
function fetchTunnelToken(name) {
|
|
15
|
+
const r = spawnSync('cloudflared', ['tunnel', 'token', name], { encoding: 'utf8' });
|
|
16
|
+
if (r.status !== 0)
|
|
17
|
+
return null;
|
|
18
|
+
const token = r.stdout.trim();
|
|
19
|
+
return token.length > 0 ? token : null;
|
|
20
|
+
}
|
|
21
|
+
export class Tunnel {
|
|
22
|
+
cfg;
|
|
23
|
+
port;
|
|
24
|
+
child = null;
|
|
25
|
+
closed = false;
|
|
26
|
+
token = null;
|
|
27
|
+
tokenResolved = false;
|
|
28
|
+
constructor(cfg, port) {
|
|
29
|
+
this.cfg = cfg;
|
|
30
|
+
this.port = port;
|
|
31
|
+
}
|
|
32
|
+
get hostname() { return this.cfg.hostname; }
|
|
33
|
+
start() {
|
|
34
|
+
if (this.closed)
|
|
35
|
+
return;
|
|
36
|
+
if (!this.tokenResolved) {
|
|
37
|
+
this.token = fetchTunnelToken(this.cfg.name);
|
|
38
|
+
this.tokenResolved = true;
|
|
39
|
+
}
|
|
40
|
+
const mode = this.token ? 'token' : 'named';
|
|
41
|
+
log.info({ name: this.cfg.name, hostname: this.cfg.hostname, port: this.port, mode }, 'cloudflared tunnel starting');
|
|
42
|
+
/** `--no-autoupdate` is a global cloudflared flag — must come before the `tunnel` subcommand. */
|
|
43
|
+
const args = ['--no-autoupdate', 'tunnel', 'run', '--url', `http://127.0.0.1:${this.port}`];
|
|
44
|
+
/** Token form resolves the tunnel from TUNNEL_TOKEN so the trailing name arg must be omitted. */
|
|
45
|
+
if (!this.token)
|
|
46
|
+
args.push(this.cfg.name);
|
|
47
|
+
const env = this.token
|
|
48
|
+
? { ...process.env, TUNNEL_TOKEN: this.token }
|
|
49
|
+
: process.env;
|
|
50
|
+
this.child = spawn('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
51
|
+
this.child.stderr?.on('data', d => log.debug({ cloudflared: d.toString().trim() }, 'cloudflared'));
|
|
52
|
+
this.child.on('exit', code => {
|
|
53
|
+
this.child = null;
|
|
54
|
+
if (this.closed)
|
|
55
|
+
return;
|
|
56
|
+
log.warn({ code }, 'cloudflared exited; restarting');
|
|
57
|
+
setTimeout(() => this.start(), RESTART_DELAY_MS);
|
|
58
|
+
});
|
|
59
|
+
this.child.on('error', err => log.warn({ err: errMsg(err) }, 'cloudflared spawn error'));
|
|
60
|
+
}
|
|
61
|
+
stop() {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
this.child?.kill();
|
|
64
|
+
this.child = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Webhook endpoint registry: persists `(id, label, secret?)` for each receive endpoint. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { STATE_DIR } from './paths.js';
|
|
6
|
+
const FILE = join(STATE_DIR, 'webhooks.json');
|
|
7
|
+
function read() {
|
|
8
|
+
if (!existsSync(FILE))
|
|
9
|
+
return { endpoints: [] };
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(FILE, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { endpoints: [] };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function write(s) { writeFileSync(FILE, JSON.stringify(s, null, 2)); }
|
|
18
|
+
export const listEndpoints = () => read().endpoints;
|
|
19
|
+
export const findEndpoint = (id) => read().endpoints.find(e => e.id === id);
|
|
20
|
+
/** Mint a 16-char URL-safe random id (~96 bits of entropy — collision-proof for any reasonable count). */
|
|
21
|
+
const mintEndpointId = () => randomBytes(12).toString('base64url');
|
|
22
|
+
export function addEndpoint(label, secret) {
|
|
23
|
+
const s = read();
|
|
24
|
+
const ep = {
|
|
25
|
+
id: mintEndpointId(), label, createdAt: new Date().toISOString(),
|
|
26
|
+
...(secret ? { secret } : {}),
|
|
27
|
+
};
|
|
28
|
+
s.endpoints.push(ep);
|
|
29
|
+
write(s);
|
|
30
|
+
return ep;
|
|
31
|
+
}
|
|
32
|
+
export function removeEndpoint(id) {
|
|
33
|
+
const s = read();
|
|
34
|
+
const before = s.endpoints.length;
|
|
35
|
+
s.endpoints = s.endpoints.filter(e => e.id !== id);
|
|
36
|
+
if (s.endpoints.length === before)
|
|
37
|
+
return false;
|
|
38
|
+
write(s);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
package/docs/agents.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Metro: a guide for coding agents
|
|
2
2
|
|
|
3
|
-
You are running inside a session that has **launched `metro`** in the background. Metro emits a live stream of JSON events from Discord, Telegram, and other agents on its stdout. Your job is to consume that stream and post replies back via subcommands.
|
|
3
|
+
You are running inside a session that has **launched `metro`** in the background. Metro emits a live stream of JSON events from Discord, Telegram, third-party webhooks (GitHub, Intercom, …), and other agents on its stdout. Your job is to consume that stream and post replies back via subcommands.
|
|
4
4
|
|
|
5
5
|
## Starting the bridge
|
|
6
6
|
|
|
@@ -34,11 +34,11 @@ Run `metro doctor` if anything seems off.
|
|
|
34
34
|
Every event is a **history entry** — the same record that's appended to `history.jsonl`. Fields: `kind` (`inbound`/`notification`/`outbound`/`edit`/`react`), `id` (`msg_…`), `ts`, `station`, `line` (conversation), `lineName?`, `from` (participant URI), `fromName?`, `to`, `text`, `messageId?` (platform-side id; inbound/outbound only), `payload?` (raw platform message; inbound only).
|
|
35
35
|
|
|
36
36
|
```json
|
|
37
|
-
{"kind":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","lineName":"infra","from":"metro://telegram/user/12345","fromName":"@alice","to":"metro://claude/
|
|
37
|
+
{"kind":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","lineName":"infra","from":"metro://telegram/user/12345","fromName":"@alice","to":"metro://claude/user/9bfc7af0-…","messageId":"4567","text":"hello [image]","payload":{"message_id":4567,"chat":{"id":-100,"type":"supergroup","is_forum":true},"from":{"id":12345,"username":"alice"},"text":"hello","entities":[{"type":"mention","offset":0,"length":6}],"photo":[{"file_id":"…"}],"reply_to_message":{"message_id":4500,"text":"earlier","from":{"id":99,"username":"bob"}}}}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
```json
|
|
41
|
-
{"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/
|
|
41
|
+
{"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/9bfc7af0-…/50b00d11-…","from":"metro://codex/user/8119ecb1-…","to":"metro://claude/9bfc7af0-…/50b00d11-…","text":"deploy succeeded"}
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
### `payload` by station
|
|
@@ -47,14 +47,15 @@ Every event is a **history entry** — the same record that's appended to `histo
|
|
|
47
47
|
|
|
48
48
|
- **`discord`** — discord.js `Message.toJSON()`: camelCase fields (`channelId`, `guildId`, `content`, `author`, `mentions: { users[], roles[], everyone }`, `attachments[]`, `reference`, …). Collections come back as **arrays of IDs**. `referencedMessage` (also `toJSON()`-shaped) is added inline on replies (auto-fetched).
|
|
49
49
|
- **`telegram`** — raw Bot API `Message` (snake_case): `{ message_id, chat, from, text, caption, entities[], photo[], document, voice, audio, reply_to_message, … }`. `reply_to_message` is inline on replies.
|
|
50
|
+
- **`webhook`** — `{ headers: Record<string,string>, body: <parsed JSON | raw string> }`. Narrow further on the provider — GitHub sets `headers['x-github-event']` (`push`, `pull_request`, `issues`, …) and includes a `repository`/`sender` in body; Intercom sets `x-intercom-topic` etc. `text` is a short summary; full event is always in `payload.body`.
|
|
50
51
|
|
|
51
52
|
Use `payload` for anything the envelope doesn't surface — mentions, reply chains, embeds, stickers, entities.
|
|
52
53
|
|
|
53
|
-
Both `from` and `to` are **participant URIs** (the conversation lives in `line`): `metro://<station>/user/<id>` for a person, `metro://claude/<
|
|
54
|
+
Both `from` and `to` are **participant URIs** (the conversation lives in `line`): `metro://<station>/user/<id>` for a person, `metro://claude/user/<orgId>` for a Claude Code agent (orgId = stable Anthropic-account UUID), `metro://codex/user/<accountId>` for a Codex agent (accountId = stable ChatGPT-account UUID), `metro://<station>/<channelId>` as a fallback `to` when sending to a group with no single recipient.
|
|
54
55
|
|
|
55
|
-
When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from` to your runtime — `metro://claude/
|
|
56
|
+
When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from` to your runtime — `metro://claude/user/<orgId>` (when `$CLAUDECODE` is set; orgId comes from `claude auth status --json`) or `metro://codex/user/<accountId>` (when `$METRO_CODEX_RC`/`$CODEX_HOME` is set; accountId comes from `$CODEX_HOME/auth.json`, `tokens.account_id`). Both identities are account-scoped, not install-scoped: switch accounts with `claude auth login` / `codex login` and the next event uses the new id (within ~5 s for the daemon, immediately for one-shot CLI calls). Override with `--from=<uri>` or `$METRO_FROM`. When replying/reacting, `to` is auto-set to the original sender (history lookup).
|
|
56
57
|
|
|
57
|
-
- `kind: "inbound"` — a human (or another bot) posted on a chat platform.
|
|
58
|
+
- `kind: "inbound"` — a human (or another bot) posted on a chat platform **or a third-party service POSTed to a registered webhook endpoint** (`station: "webhook"`, `payload: { headers, body }`).
|
|
58
59
|
- `kind: "notification"` — another agent called `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
|
|
59
60
|
|
|
60
61
|
`text` may include `[image]` / `[voice]` / `[audio]` / `[file: <name>]` placeholders alongside the real text — non-image attachments are opaque markers, images can be materialized via `metro download`.
|
|
@@ -70,8 +71,9 @@ Derive from `payload`. Bot id per station is in `$METRO_STATE_DIR/bot-ids.json`
|
|
|
70
71
|
|
|
71
72
|
- **`discord`** — DM if `payload.guildId == null`; otherwise pinged if `payload.mentions.users.includes(<bot-id>)`.
|
|
72
73
|
- **`telegram`** — DM if `payload.chat.type === 'private'`; otherwise pinged if any entity in `payload.entities` (or `caption_entities`) is `{type:"mention"}` matching `@<bot-username>`, or `{type:"text_mention", user:{id:<bot-id>}}`.
|
|
74
|
+
- **`webhook`** — every POST is for you (you registered the endpoint). Route on `payload.headers['x-github-event']` / `x-intercom-topic` etc. to know which provider event it is.
|
|
73
75
|
|
|
74
|
-
Default: only reply on DM or ping; otherwise stay silent or `metro react` to ack.
|
|
76
|
+
Default for chat: only reply on DM or ping; otherwise stay silent or `metro react` to ack. Webhooks just consume — no ack mechanism.
|
|
75
77
|
|
|
76
78
|
## Subcommands
|
|
77
79
|
|
|
@@ -84,8 +86,11 @@ Default: only reply on DM or ping; otherwise stay silent or `metro react` to ack
|
|
|
84
86
|
| Download `[image]` attachments | `metro download <line> <messageId> [--out=<dir>]` |
|
|
85
87
|
| Recent-message lookback (Discord only) | `metro fetch <line> [--limit=20]` |
|
|
86
88
|
| Cross-agent ping | `metro send <agent-line> <text> [--from=<line>]` |
|
|
89
|
+
| Register webhook endpoint | `metro webhook add <label> [--secret=<hmac-secret>]` |
|
|
90
|
+
| List / remove webhook endpoints | `metro webhook list` · `metro webhook remove <id>` |
|
|
91
|
+
| Configure Cloudflare named tunnel | `metro tunnel setup <tunnel-name> <hostname>` |
|
|
87
92
|
|
|
88
|
-
`reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
|
|
93
|
+
`reply` / `send` / `edit` accept multi-line text via stdin (heredoc). Webhooks are receive-only — there's no `reply` for them, just consume the event.
|
|
89
94
|
|
|
90
95
|
### Rich content flags
|
|
91
96
|
|
|
@@ -146,13 +151,39 @@ Lines sorted by recency. Use when the user says "the Telegram channel" or "that
|
|
|
146
151
|
|
|
147
152
|
```
|
|
148
153
|
$ metro stations
|
|
149
|
-
✓ discord chat
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
✓ discord chat in: text+image · out: text · features: reply, send, edit, react, download, fetch
|
|
155
|
+
DISCORD_BOT_TOKEN
|
|
156
|
+
✓ telegram chat in: text+image · out: text · features: reply, send, edit, react, download, fetch
|
|
157
|
+
TELEGRAM_BOT_TOKEN
|
|
158
|
+
✓ claude agent in: text · out: text · features: send, notify
|
|
159
|
+
account: 9bfc7af0-… · seen 1 agent, 2 sessions
|
|
160
|
+
seen: 9bfc7af0-… · sessions: 2
|
|
161
|
+
✗ codex agent in: text · out: text · features: send, notify
|
|
162
|
+
set METRO_CODEX_RC=ws://… to push
|
|
163
|
+
✓ webhook service in: text · out: – · features: –
|
|
164
|
+
2 endpoints · base https://webhook.example.com
|
|
153
165
|
```
|
|
154
166
|
|
|
155
|
-
`✓` = ready, `✗` = configured-but-broken, `·` = informational
|
|
167
|
+
`✓` = ready (env/runtime detected), `✗` = configured-but-broken or runtime not detected, `·` = informational. The detail line under each agent row shows the resolved account id plus the per-agent count of sessions metro has observed — pull addressable agent lines from those.
|
|
168
|
+
|
|
169
|
+
## Webhooks (receiving HTTP events)
|
|
170
|
+
|
|
171
|
+
When the user wants metro to receive events from a third-party service (GitHub PRs, Intercom conversations, Fireflies meetings, …):
|
|
172
|
+
|
|
173
|
+
1. **One-time tunnel setup** (only needed once per machine): `metro tunnel setup <tunnel-name> <hostname>`. Requires `cloudflared` on PATH (`brew install cloudflared`) and a Cloudflare account + domain on Cloudflare DNS. Run `cloudflared tunnel login` first if you haven't.
|
|
174
|
+
2. **Register an endpoint**: `metro webhook add <label> [--secret=<shared-secret>]`. Prints the public URL — paste it into the provider's webhook settings. For GitHub specifically, set **Content type: `application/json`** (form-encoded won't parse into `payload.body`).
|
|
175
|
+
3. **Run the daemon**: `metro`. With at least one endpoint registered, metro auto-binds the HTTP listener (port 8420, override `METRO_WEBHOOK_PORT`) and spawns `cloudflared tunnel run` if `tunnel.json` exists.
|
|
176
|
+
|
|
177
|
+
Each POST becomes an inbound event:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{"kind":"inbound","station":"webhook","line":"metro://webhook/<id>","lineName":"github",
|
|
181
|
+
"from":"metro://webhook/<id>","to":"metro://claude/user/<orgId>",
|
|
182
|
+
"messageId":"<x-github-delivery>","text":"push POST /wh/<id>",
|
|
183
|
+
"payload":{"headers":{"x-github-event":"push",…},"body":{"ref":"refs/heads/main",…}}}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`text` is a short summary; the real event lives in `payload.body`. Use `payload.headers['x-github-event']` (or `x-intercom-topic` etc.) to narrow on provider event type. If you set `--secret`, metro verifies `X-Hub-Signature-256` and rejects bad signatures with 401 — agents see only authenticated events.
|
|
156
187
|
|
|
157
188
|
## Image attachments
|
|
158
189
|
|
|
@@ -164,11 +195,11 @@ When an inbound has an `[image]` tag in `text`:
|
|
|
164
195
|
|
|
165
196
|
## Cross-agent notification
|
|
166
197
|
|
|
167
|
-
Both agents can post to each other's
|
|
198
|
+
Both agents can post to each other's **agent line** — `metro://claude/<agent-id>/<session-id>` or `metro://codex/<agent-id>/<session-id>`. `<agent-id>` is the peer's stable account id (cross-device); `<session-id>` is one conversation. Discover both by running `metro stations` (which lists every agent + session metro has seen), or by reading `$METRO_STATE_DIR/agent-registry.json` directly. The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer agent sees it as a notification:
|
|
168
199
|
|
|
169
200
|
```bash
|
|
170
|
-
metro send metro://claude/
|
|
171
|
-
metro send metro://claude/
|
|
201
|
+
metro send metro://claude/9bfc7af0-…/50b00d11-… "build green, ready to ship"
|
|
202
|
+
metro send metro://claude/9bfc7af0-…/50b00d11-… "build green" --from=metro://codex/user/8119ecb1-… # override sender
|
|
172
203
|
```
|
|
173
204
|
|
|
174
205
|
This requires the metro daemon to be running on the machine. Without a daemon, agent-line sends error with a clear message.
|
package/docs/uri-scheme.md
CHANGED
|
@@ -6,7 +6,7 @@ Universal identifier for every conversational scope and notification sink in met
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
line = "metro://" station "/" path
|
|
9
|
-
station = lowercase identifier (claude | codex | discord | telegram | …)
|
|
9
|
+
station = lowercase identifier (claude | codex | discord | telegram | webhook | …)
|
|
10
10
|
path = station-specific, "/"-separated segments
|
|
11
11
|
```
|
|
12
12
|
|
|
@@ -14,27 +14,73 @@ The URI parses cleanly with the WHATWG `URL` parser: `new URL(line)` gives `prot
|
|
|
14
14
|
|
|
15
15
|
## Registered stations
|
|
16
16
|
|
|
17
|
-
| Station | Kind
|
|
18
|
-
|
|
19
|
-
| `discord` | chat
|
|
20
|
-
| `telegram` | chat
|
|
21
|
-
| `claude` | agent
|
|
22
|
-
| `codex` | agent
|
|
17
|
+
| Station | Kind | Pattern | Example |
|
|
18
|
+
|------------|---------|----------------------------------------------|------------------------------------------------------------------------|
|
|
19
|
+
| `discord` | chat | `metro://discord/<channel-id>` | `metro://discord/1234567890123456789` |
|
|
20
|
+
| `telegram` | chat | `metro://telegram/<chat-id>[/<topic-id>]` | `metro://telegram/-1001234567890/42` |
|
|
21
|
+
| `claude` | agent | `metro://claude/<agent-id>/<session-id>` | `metro://claude/9bfc7af0-…/50b00d11-…` |
|
|
22
|
+
| `codex` | agent | `metro://codex/<agent-id>/<session-id>` | `metro://codex/8119ecb1-…/01997d4b-…` |
|
|
23
|
+
| `webhook` | service | `metro://webhook/<endpoint-id>` | `metro://webhook/fwaCgTKJuLAjS2K0` |
|
|
24
|
+
|
|
25
|
+
Agent lines mirror the `<root>/<sub>` structure of `metro://telegram/<chat-id>/<topic-id>`: `<agent-id>` (the user's stable account id — same across devices) plays the role of `<chat-id>`, and `<session-id>` (one conversation) plays the role of `<topic-id>`. Both segments are derived per station (see [participants](#participants) below).
|
|
23
26
|
|
|
24
27
|
## Participants
|
|
25
28
|
|
|
26
29
|
Every chat station also exposes participant URIs — used as `from` on inbound/outbound events and history rows.
|
|
27
30
|
|
|
28
|
-
| Kind
|
|
29
|
-
|
|
30
|
-
| user
|
|
31
|
-
|
|
|
31
|
+
| Kind | Pattern | Example |
|
|
32
|
+
|--------|----------------------------------|---------------------------------------------------------------|
|
|
33
|
+
| user | `metro://<station>/user/<id>` | `metro://discord/user/87654321` |
|
|
34
|
+
| claude | `metro://claude/user/<orgId>` | `metro://claude/user/9bfc7af0-2117-44c5-baf2-d22ba382d065` |
|
|
35
|
+
| codex | `metro://codex/user/<accountId>` | `metro://codex/user/8119ecb1-b05e-48db-aa80-434584439df9` |
|
|
36
|
+
| webhook | `metro://webhook/<endpointId>` | `metro://webhook/fwaCgTKJuLAjS2K0` (line + `from` are the same — no HTTP-side user identity) |
|
|
37
|
+
|
|
38
|
+
`from` and `to` on history entries are always participant URIs. Discord/Telegram inbounds set `from` to the user URI; the daemon sets `to` to the agent identity:
|
|
39
|
+
|
|
40
|
+
- **Claude Code** (`$CLAUDECODE` set) — `metro://claude/user/<orgId>`. `<orgId>` is the stable Anthropic-account UUID, resolved by shelling out to `claude auth status --json`.
|
|
41
|
+
- **Codex** (`$METRO_CODEX_RC` or `$CODEX_HOME` set) — `metro://codex/user/<accountId>`. `<accountId>` is the ChatGPT-account UUID, read from `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`) at the `tokens.account_id` field. Requires `auth_mode=chatgpt`; API-key-only Codex sessions have no account id and metro will error.
|
|
42
|
+
- **Neither** — `to` is the generic `metro://agent`.
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
Same account on any machine yields the same URI. Switching accounts via `claude auth login` / `codex login` flips the URI within ~5 s for the long-lived daemon (5 s TTL cache); one-shot CLI invocations re-resolve every run. On outbound, `from` = the same agent identity; `to` = the original sender for replies/reacts (looked up from history), or the channel `line` for fresh group sends. A `fromName` field carries the display name (`@alice`, `bonustrack_`).
|
|
34
45
|
|
|
35
46
|
Override with `--from=<uri>` on any write command, or set `$METRO_FROM` to pin a custom identity for the whole session.
|
|
36
47
|
|
|
37
|
-
Chat lines identify a Discord channel / Telegram chat (with optional forum topic). Agent lines
|
|
48
|
+
Chat lines identify a Discord channel / Telegram chat (with optional forum topic). Agent lines now identify a *specific session* of a specific agent (`<agent-id>/<session-id>`) — posting to one re-emits the message on the daemon's stdout stream and (if configured) pushes it to the Codex app-server. They have no inherent "messages"; only events.
|
|
49
|
+
|
|
50
|
+
### Session derivation per station
|
|
51
|
+
|
|
52
|
+
| Station | `<agent-id>` | `<session-id>` |
|
|
53
|
+
|----------|----------------------------|-----------------------------------------------------------------|
|
|
54
|
+
| `claude` | `orgId` from `claude auth status --json` | `$CLAUDE_CODE_SESSION_ID` (set by Claude Code; stable across `--resume`)|
|
|
55
|
+
| `codex` | `tokens.account_id` from `$CODEX_HOME/auth.json` | codex-rc thread id from the JSON-RPC handshake (`thread/loaded/list` → `thread/start`) |
|
|
56
|
+
|
|
57
|
+
Override either segment with `METRO_AGENT_ID` / `METRO_AGENT_SESSION_ID` env vars.
|
|
58
|
+
|
|
59
|
+
### Agent registry
|
|
60
|
+
|
|
61
|
+
The daemon persists every `(station, agent-id, session)` tuple it sees to `$METRO_STATE_DIR/agent-registry.json`. `metro stations` prints the count of seen agents and sessions per station. Run it to discover what's reachable rather than guessing topic names.
|
|
62
|
+
|
|
63
|
+
## Webhook station
|
|
64
|
+
|
|
65
|
+
Receive-only HTTP endpoint for third-party services (GitHub, Intercom, Fireflies, …). Each registered endpoint is one `metro://webhook/<endpoint-id>` line.
|
|
66
|
+
|
|
67
|
+
- **Register:** `metro webhook add <label> [--secret=<shared-secret>]` mints a 16-char endpoint id (96 bits of entropy, persisted to `$METRO_STATE_DIR/webhooks.json`) and prints the receiving URL. `metro webhook list` / `remove <id>` for the obvious.
|
|
68
|
+
- **Listener:** the dispatcher binds `127.0.0.1:8420` (override with `METRO_WEBHOOK_PORT`) when ≥1 endpoint is registered. Routes `POST /wh/<endpoint-id>` to an inbound event with `payload: { headers, body }` — `body` is parsed JSON when the request `Content-Type` is JSON, raw string otherwise. `GET /wh/<endpoint-id>` returns 200 (for provider ping checks).
|
|
69
|
+
- **Envelope:** `messageId` falls back to `X-GitHub-Delivery` / `X-Request-ID` / a generated UUID for idempotency tracking. `text` is synthesized from `X-GitHub-Event` / `X-Intercom-Topic` plus method + path for at-a-glance routing; agents narrow on `payload.body` for full event details.
|
|
70
|
+
- **HMAC verification:** if `--secret` was set on `metro webhook add`, requests must include a matching `X-Hub-Signature-256: sha256=<hex>` (GitHub/Intercom format) — mismatches are rejected with 401 before reaching the stream.
|
|
71
|
+
- **Public reachability:** provided by a Cloudflare named tunnel — see [Tunneling](#tunneling) below. Without one, the listener stays loopback-only (useful for `curl` testing).
|
|
72
|
+
|
|
73
|
+
## Tunneling
|
|
74
|
+
|
|
75
|
+
Webhook providers need a public URL. Metro integrates with **Cloudflare named tunnels** (free, stable, account-scoped):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cloudflared tunnel login # one-time OAuth (browser)
|
|
79
|
+
metro tunnel setup metro webhook.yourdomain.com # creates the tunnel + DNS route
|
|
80
|
+
metro # daemon spawns `cloudflared tunnel run`
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
After setup, `metro webhook list` prints `https://webhook.yourdomain.com/wh/<id>` for each endpoint. The URL is stable across restarts (bound to the tunnel UUID in `~/.cloudflared/<uuid>.json`, not the cloudflared process). Tunnel config persists at `$METRO_STATE_DIR/tunnel.json`. Without setup, endpoints fall back to `http://127.0.0.1:8420/wh/<id>` (local-only, useful for curl testing).
|
|
38
84
|
|
|
39
85
|
## Message addressing
|
|
40
86
|
|
|
@@ -61,7 +107,15 @@ import { Line } from './stations/index.js'; // value namespace + type
|
|
|
61
107
|
const l: Line = Line.discord('1234567890'); // typed Line
|
|
62
108
|
Line.parse(l); // { station: 'discord', path: ['1234567890'] } | null
|
|
63
109
|
Line.station(l); // 'discord'
|
|
64
|
-
Line.
|
|
110
|
+
Line.claude(orgId, sessionId); // metro://claude/<orgId>/<sessionId>
|
|
111
|
+
Line.codex(accountId, threadId); // metro://codex/<accountId>/<threadId>
|
|
112
|
+
Line.parseClaude(l); // { agentId, sessionId } | null
|
|
113
|
+
Line.parseCodex(l); // { agentId, sessionId } | null
|
|
114
|
+
Line.webhook(endpointId); // metro://webhook/<endpointId>
|
|
115
|
+
Line.parseWebhook(l); // string | null (the endpoint id)
|
|
116
|
+
Line.user(station, id); // metro://<station>/user/<id>
|
|
117
|
+
Line.bot(station, id); // metro://<station>/bot/<id>
|
|
118
|
+
Line.isAgent(l); // true for any metro://{claude,codex}/...
|
|
65
119
|
```
|
|
66
120
|
|
|
67
121
|
## Adding a new station
|