@stage-labs/metro 0.1.0-beta.262 → 0.1.0-beta.263

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.262",
3
+ "version": "0.1.0-beta.263",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.262",
3
+ "version": "0.1.0-beta.263",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -85,7 +85,51 @@ export interface BufferedEvent {
85
85
  event: MetroEvent;
86
86
  }
87
87
 
88
- const BUS_BUFFER_MAX = 500;
88
+ export const BUS_BUFFER_MAX = 500;
89
+
90
+ export interface RunEventMetadata {
91
+ isPrivate?: boolean;
92
+ senderVerified?: boolean;
93
+ mentionsSelf?: boolean;
94
+ replyToSelf?: boolean;
95
+ emoji?: string;
96
+ removed?: boolean;
97
+ targetId?: string;
98
+ attachmentCount?: number;
99
+ attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
100
+ attachmentStatus?: 'saved' | 'failed';
101
+ attachmentFor?: string;
102
+ }
103
+
104
+ export interface RunEvent {
105
+ id: string;
106
+ seq: number;
107
+ ts: string;
108
+ kind: StructuredEvent['type'];
109
+ direction: 'inbound' | 'outbound' | 'system';
110
+ agentId: string;
111
+ station: string;
112
+ accountId: string | null;
113
+ line: string;
114
+ lineName?: string;
115
+ from?: string;
116
+ fromName?: string;
117
+ fromDisplayName?: string;
118
+ to?: string;
119
+ text?: string;
120
+ truncated: boolean;
121
+ messageId?: string;
122
+ replyTo?: string;
123
+ metadata: RunEventMetadata;
124
+ }
125
+
126
+ export interface RunEventsPage {
127
+ events: RunEvent[];
128
+ cursor: string;
129
+ hasMore: boolean;
130
+ reset: boolean;
131
+ retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
132
+ }
89
133
 
90
134
  const listeners = new Set<BusListener>();
91
135
  const buffer: BufferedEvent[] = [];
@@ -16,6 +16,8 @@ export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
16
16
  agentNames = names;
17
17
  }
18
18
 
19
+ export const knownAgentIds = (): string[] => Object.keys(agentNames);
20
+
19
21
  export function setAllowlistMap(map: AllowlistMap): void {
20
22
  allowlistMap = map;
21
23
  }
@@ -0,0 +1,139 @@
1
+ import { classifyEvent, userSelf, type BufferedEvent, type MetroEvent, type RunEvent, type RunEventMetadata } from '@metro-labs/core/events';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { Line } from '@metro-labs/core/lines';
4
+ import { STATIONS } from '@metro-labs/core/station-names';
5
+ import { accountFromLine, allowlistForLine, lineReceives, senderPermitted } from '../agents/map.js';
6
+ import { eventInScope } from '../agents/scope.js';
7
+
8
+ const TEXT_MAX = 8_000;
9
+ const FIELD_MAX = 512;
10
+ const LINE_MAX = 2_048;
11
+ const ACCOUNT_STATIONS = new Set<string>(STATIONS);
12
+ const KINDS = new Set(['msg', 'reply', 'react', 'edit', 'delete', 'system']);
13
+
14
+ function safeText(value: unknown, max: number): string | undefined {
15
+ if (typeof value !== 'string') return undefined;
16
+ return value.slice(0, max).replace(/https?:\/\/[^\s<>"']+|\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\/[^\s<>"']*/gi, (url) => /\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\//i.test(url) ? '[private link]' : url).slice(0, max);
17
+ }
18
+
19
+ function validLine(line: string): boolean {
20
+ return typeof line === 'string' && line.length <= LINE_MAX && /^metro:\/\/[a-z][a-z0-9-]*\/[^\s?#]+$/.test(line) && !line.includes('//', 8) && !line.endsWith('/');
21
+ }
22
+
23
+ const isSelf = (line: string): boolean => line === userSelf();
24
+ const isSynthetic = (payload: unknown): boolean => isRecord(payload) && (payload.contentType === 'attachmentSaved' || payload.contentType === 'attachmentFailed' || payload.contentType === 'transcript');
25
+
26
+ function sameAccount(line: string, other: string): boolean {
27
+ if (!validLine(other)) return false;
28
+ const a = accountFromLine(line);
29
+ const b = accountFromLine(other);
30
+ return a !== undefined && b !== undefined && a.station === b.station && a.accountId === b.accountId;
31
+ }
32
+
33
+ function permittedTraffic(event: MetroEvent): boolean {
34
+ if (!isSelf(event.to) && !sameAccount(event.line, event.to)) return false;
35
+ const allowlist = allowlistForLine(event.line);
36
+ if (isSelf(event.from)) return !isSynthetic(event.payload) || senderPermitted(allowlist, '');
37
+ return sameAccount(event.line, event.from) && senderPermitted(allowlist, event.from, event.senderVerified);
38
+ }
39
+
40
+ function visible(event: MetroEvent, allowed: Set<string>): boolean {
41
+ if (!validLine(event.line) || !eventInScope(allowed, event.line) || !lineReceives(event.line)) return false;
42
+ const station = Line.station(event.line);
43
+ if (station !== event.station) return false;
44
+ if (!ACCOUNT_STATIONS.has(station)) return station === 'claude' && event.event?.type === 'system' && event.isPrivate !== true;
45
+ return permittedTraffic(event);
46
+ }
47
+
48
+ function attachmentType(value: unknown): 'image' | 'video' | 'audio' | 'file' {
49
+ if (!isRecord(value)) return 'file';
50
+ const mime = typeof value.mime === 'string' ? value.mime : value.contentType;
51
+ for (const kind of ['image', 'video', 'audio'] as const) {
52
+ if (value.kind === kind || (typeof mime === 'string' && mime.startsWith(`${kind}/`))) return kind;
53
+ }
54
+ return 'file';
55
+ }
56
+
57
+ function attachmentMetadata(payload: unknown): RunEventMetadata {
58
+ if (!isRecord(payload)) return {};
59
+ const saved = payload.contentType === 'attachmentSaved';
60
+ const failed = payload.contentType === 'attachmentFailed';
61
+ if (saved || failed) return {
62
+ attachmentCount: 1,
63
+ attachmentTypes: [attachmentType(payload)],
64
+ attachmentStatus: saved ? 'saved' : 'failed',
65
+ attachmentFor: safeText(payload.attachmentFor, FIELD_MAX),
66
+ };
67
+ if (!Array.isArray(payload.attachments) || payload.attachments.length === 0) return {};
68
+ return {
69
+ attachmentCount: payload.attachments.length,
70
+ attachmentTypes: [...new Set(payload.attachments.slice(0, 100).map(attachmentType))],
71
+ };
72
+ }
73
+
74
+ function reactionMetadata(event: MetroEvent): RunEventMetadata {
75
+ if (event.event?.type !== 'react') return {};
76
+ return {
77
+ emoji: safeText(event.event.emoji, 32),
78
+ ...(isRecord(event.payload) && typeof event.payload.removed === 'boolean' ? { removed: event.payload.removed } : {}),
79
+ };
80
+ }
81
+
82
+ function metadata(event: MetroEvent, system: boolean): RunEventMetadata {
83
+ const attachments = attachmentMetadata(event.payload);
84
+ if (system) return attachments;
85
+ const structured = event.event;
86
+ return {
87
+ ...attachments,
88
+ ...(typeof event.isPrivate === 'boolean' ? { isPrivate: event.isPrivate } : {}),
89
+ ...(typeof event.senderVerified === 'boolean' ? { senderVerified: event.senderVerified } : {}),
90
+ ...(typeof event.mentionsSelf === 'boolean' ? { mentionsSelf: event.mentionsSelf } : {}),
91
+ ...(typeof event.replyToSelf === 'boolean' ? { replyToSelf: event.replyToSelf } : {}),
92
+ ...reactionMetadata(event),
93
+ ...(structured !== undefined && 'targetId' in structured ? { targetId: safeText(structured.targetId, FIELD_MAX) } : {}),
94
+ };
95
+ }
96
+
97
+ function messageFields(event: MetroEvent, system: boolean): Partial<RunEvent> & { truncated: boolean } {
98
+ if (system) return { truncated: false };
99
+ return {
100
+ lineName: safeText(event.lineName, FIELD_MAX),
101
+ from: safeText(event.from, LINE_MAX),
102
+ fromName: safeText(event.fromName, FIELD_MAX),
103
+ fromDisplayName: safeText(event.fromDisplayName, FIELD_MAX),
104
+ to: safeText(event.to, LINE_MAX),
105
+ text: safeText(event.text, TEXT_MAX),
106
+ truncated: typeof event.text === 'string' && event.text.length > TEXT_MAX,
107
+ };
108
+ }
109
+
110
+ function validIdentity(event: MetroEvent): boolean {
111
+ return typeof event.id === 'string' && event.id.length <= FIELD_MAX
112
+ && typeof event.ts === 'string' && event.ts.length <= 64 && Number.isFinite(Date.parse(event.ts));
113
+ }
114
+
115
+ const replyId = (event: MetroEvent): string | undefined => event.replyTo ?? (event.event?.type === 'reply' ? event.event.replyTo : undefined);
116
+ const accountId = (event: MetroEvent): string | null => ACCOUNT_STATIONS.has(event.station) ? accountFromLine(event.line)?.accountId ?? null : null;
117
+
118
+ export function runEvent(entry: BufferedEvent, agentId: string): RunEvent | null {
119
+ const { event, busSeq } = entry;
120
+ if (!visible(event, new Set([agentId])) || !validIdentity(event)) return null;
121
+ const structured = event.event ?? classifyEvent(event);
122
+ if (!KINDS.has(structured.type)) return null;
123
+ const system = structured.type === 'system' || isSynthetic(event.payload);
124
+ return {
125
+ ...messageFields(event, system),
126
+ id: safeText(event.id, FIELD_MAX) ?? '',
127
+ seq: busSeq,
128
+ ts: new Date(event.ts).toISOString(),
129
+ kind: system ? 'system' : structured.type,
130
+ direction: system ? 'system' : isSelf(event.from) ? 'outbound' : 'inbound',
131
+ agentId,
132
+ station: event.station,
133
+ accountId: accountId(event),
134
+ line: event.line,
135
+ messageId: safeText(event.messageId, FIELD_MAX),
136
+ replyTo: safeText(replyId(event), FIELD_MAX),
137
+ metadata: metadata(event, system),
138
+ };
139
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { bufferedSince, BUS_BUFFER_MAX, currentBusSeq, type BufferedEvent, type RunEvent, type RunEventsPage } from '@metro-labs/core/events';
4
+ import { ID_RE } from '@metro-labs/core/ids';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { sessionRoute } from '@metro-labs/http/api-http';
7
+ import { accountEnabled, allowlistForAccount, knownAccounts, knownAgentIds } from '../agents/map.js';
8
+ import { runEvent } from './run-event.js';
9
+
10
+ const EPOCH = randomUUID();
11
+ const DEFAULT_LIMIT = 100;
12
+ const PAGE_BYTES_MAX = 1_000_000;
13
+ const CURSOR_RE = /^([a-f0-9-]{36}):([a-f0-9]{64}):(0|[1-9]\d{0,15})$/;
14
+
15
+ interface Cursor { epoch: string; policy: string; seq: number }
16
+ interface Query { agentId: string; limit: number; cursor: Cursor | null }
17
+
18
+ function cursorOf(raw: string | null): Cursor | null {
19
+ if (raw === null) return null;
20
+ const match = CURSOR_RE.exec(raw);
21
+ if (!match || !Number.isSafeInteger(Number(match[3]))) throw new ApiError('invalid run events cursor', 400);
22
+ return { epoch: match[1] ?? '', policy: match[2] ?? '', seq: Number(match[3]) };
23
+ }
24
+
25
+ function limitOf(raw: string | null): number {
26
+ if (raw === null) return DEFAULT_LIMIT;
27
+ const limit = Number(raw);
28
+ if (!/^[1-9]\d{0,2}$/.test(raw) || limit > BUS_BUFFER_MAX) throw new ApiError('limit must be between 1 and 500', 400);
29
+ return limit;
30
+ }
31
+
32
+ function queryOf(url: string): Query {
33
+ const params = new URL(url, 'http://localhost').searchParams;
34
+ for (const key of params.keys()) {
35
+ if (!['agent', 'limit', 'cursor'].includes(key) || params.getAll(key).length !== 1) throw new ApiError('invalid run events query', 400);
36
+ }
37
+ const agentId = params.get('agent') ?? '';
38
+ if (!ID_RE.test(agentId)) throw new ApiError('agent must be an agent id', 400);
39
+ return { agentId, limit: limitOf(params.get('limit')), cursor: cursorOf(params.get('cursor')) };
40
+ }
41
+
42
+ function policyOf(agentId: string, owner: string): string {
43
+ const accounts = knownAccounts().filter((account) => account.agentId === agentId).map(({ station, id }) => [
44
+ station, id, accountEnabled(station, id), allowlistForAccount(station, id) ?? [],
45
+ ]);
46
+ return createHash('sha256').update(JSON.stringify([owner, agentId, accounts])).digest('hex');
47
+ }
48
+
49
+ function bounded(events: RunEvent[], limit: number): RunEvent[] {
50
+ const rows: RunEvent[] = [];
51
+ let bytes = 0;
52
+ for (const event of events.slice(0, limit)) {
53
+ bytes += Buffer.byteLength(JSON.stringify(event)) + 1;
54
+ if (bytes > PAGE_BYTES_MAX) break;
55
+ rows.push(event);
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ function resetNeeded(cursor: Cursor | null, policy: string, oldest: number, newest: number): boolean {
61
+ return cursor === null || cursor.epoch !== EPOCH || cursor.policy !== policy || cursor.seq < oldest - 1 || cursor.seq > newest;
62
+ }
63
+
64
+ function page(query: Query, owner: string): RunEventsPage {
65
+ const { agentId, limit, cursor } = query;
66
+ if (!knownAgentIds().includes(agentId)) throw new ApiError('no such agent', 404);
67
+ const policy = policyOf(agentId, owner);
68
+ const buffered = bufferedSince(0);
69
+ const newest = currentBusSeq();
70
+ const oldest = buffered[0]?.busSeq ?? newest + 1;
71
+ const reset = resetNeeded(cursor, policy, oldest, newest);
72
+ const visible = buffered.filter((entry) => reset || entry.busSeq > (cursor?.seq ?? newest)).flatMap((entry) => {
73
+ const event = runEvent(entry, agentId);
74
+ return event === null ? [] : [event];
75
+ });
76
+ const pending = reset ? visible.slice(-limit) : visible;
77
+ const events = bounded(pending, limit);
78
+ const hasMore = pending.length > events.length;
79
+ return {
80
+ events, cursor: `${EPOCH}:${policy}:${pageSeq(events, hasMore, newest)}`, hasMore, reset,
81
+ retention: retention(buffered, agentId),
82
+ };
83
+ }
84
+
85
+ const pageSeq = (events: RunEvent[], hasMore: boolean, newest: number): number => hasMore ? events.at(-1)?.seq ?? newest : newest;
86
+ function retention(buffered: BufferedEvent[], agentId: string): RunEventsPage['retention'] {
87
+ let oldestAt: string | null = null;
88
+ for (const entry of buffered) {
89
+ const event = runEvent(entry, agentId);
90
+ if (event === null) continue;
91
+ oldestAt = event.ts;
92
+ break;
93
+ }
94
+ return { capacity: BUS_BUFFER_MAX, oldestAt, oldestSeq: buffered[0]?.busSeq ?? null };
95
+ }
96
+
97
+ export function handleRunEventsRequest(req: IncomingMessage, res: ServerResponse): boolean {
98
+ return sessionRoute(req, res, {
99
+ methods: { '/api/run/events': ['GET'] }, admin: false, label: 'run-events',
100
+ }, (session) => Promise.resolve(page(queryOf(req.url ?? ''), session.subject)));
101
+ }
@@ -22,6 +22,7 @@ import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
22
22
  import { handleFilesRequest } from '../agent-user/files-api.js';
23
23
  import { handleVaultRequest } from '../vault/api.js';
24
24
  import { handleVoiceRequest } from '../voice/api.js';
25
+ import { handleRunEventsRequest } from '../monitor/run-events.js';
25
26
 
26
27
  export interface SessionApis {
27
28
  agentApi?: AgentApiDeps;
@@ -50,6 +51,7 @@ export function handleSessionApis(
50
51
  ): boolean {
51
52
  const routes: (() => boolean)[] = [
52
53
  () => handleSessionApiRequest(req, res),
54
+ () => handleRunEventsRequest(req, res),
53
55
  () => handleApprovalsRequest(req, res),
54
56
  ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
55
57
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.262"
2
+ "version": "0.1.0-beta.263"
3
3
  }
@@ -85,7 +85,51 @@ export interface BufferedEvent {
85
85
  event: MetroEvent;
86
86
  }
87
87
 
88
- const BUS_BUFFER_MAX = 500;
88
+ export const BUS_BUFFER_MAX = 500;
89
+
90
+ export interface RunEventMetadata {
91
+ isPrivate?: boolean;
92
+ senderVerified?: boolean;
93
+ mentionsSelf?: boolean;
94
+ replyToSelf?: boolean;
95
+ emoji?: string;
96
+ removed?: boolean;
97
+ targetId?: string;
98
+ attachmentCount?: number;
99
+ attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
100
+ attachmentStatus?: 'saved' | 'failed';
101
+ attachmentFor?: string;
102
+ }
103
+
104
+ export interface RunEvent {
105
+ id: string;
106
+ seq: number;
107
+ ts: string;
108
+ kind: StructuredEvent['type'];
109
+ direction: 'inbound' | 'outbound' | 'system';
110
+ agentId: string;
111
+ station: string;
112
+ accountId: string | null;
113
+ line: string;
114
+ lineName?: string;
115
+ from?: string;
116
+ fromName?: string;
117
+ fromDisplayName?: string;
118
+ to?: string;
119
+ text?: string;
120
+ truncated: boolean;
121
+ messageId?: string;
122
+ replyTo?: string;
123
+ metadata: RunEventMetadata;
124
+ }
125
+
126
+ export interface RunEventsPage {
127
+ events: RunEvent[];
128
+ cursor: string;
129
+ hasMore: boolean;
130
+ reset: boolean;
131
+ retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
132
+ }
89
133
 
90
134
  const listeners = new Set<BusListener>();
91
135
  const buffer: BufferedEvent[] = [];
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.262",
2
+ "version": "0.1.0-beta.263",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",