@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.
- package/README.md +87 -30
- package/dist/cache.js +11 -10
- package/dist/cli/actions.js +13 -30
- package/dist/cli/config.js +10 -9
- package/dist/cli/index.js +23 -8
- package/dist/cli/webhook.js +83 -0
- package/dist/codex-rc.js +14 -5
- package/dist/dispatcher.js +43 -33
- package/dist/history.js +21 -6
- package/dist/ipc.js +2 -1
- 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/discord.js +17 -15
- package/dist/stations/index.js +68 -8
- package/dist/stations/telegram.js +5 -27
- package/dist/stations/webhook.js +100 -0
- package/dist/tunnel.js +49 -0
- package/dist/webhooks.js +40 -0
- package/docs/agents.md +67 -19
- package/docs/uri-scheme.md +68 -14
- package/package.json +1 -1
- package/skills/metro/SKILL.md +51 -22
package/dist/dispatcher.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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({
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
process.on(
|
|
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.
|
|
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?.
|
|
71
|
-
return hit.
|
|
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
|
-
/**
|
|
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 '
|
|
83
|
+
return Line.user('claude', claudeAgentId());
|
|
81
84
|
if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME)
|
|
82
|
-
return '
|
|
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
|
-
|
|
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
|
|
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. */
|
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|
package/dist/stations/discord.js
CHANGED
|
@@ -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
|
|
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
|
|
173
|
+
async handleMessage(m) {
|
|
174
174
|
if (m.author.bot)
|
|
175
175
|
return;
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|
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(', ') || '–'}`;
|
|
@@ -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
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|