@stage-labs/metro 0.1.0-beta.295 → 0.1.0-beta.296

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.295",
3
+ "version": "0.1.0-beta.296",
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.295",
3
+ "version": "0.1.0-beta.296",
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",
@@ -0,0 +1,102 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { bufferedSince } from '@metro-labs/core/events';
4
+ import { STATIONS } from '@metro-labs/core/station-names';
5
+ import { accountEnabled, accountFromLine, knownAccounts } from '../agents/map.js';
6
+ import { eventInScope } from '../agents/scope.js';
7
+ import type { GetStreamDiagnostic } from './raw-get-stream.js';
8
+
9
+ export const MCP_STATUS_PATH = '/mcp/status';
10
+
11
+ export const diagnosticTag = (value: string): string =>
12
+ createHash('sha256').update(value).digest('hex').slice(0, 16);
13
+
14
+ export interface SessionDiagnostic {
15
+ tag: string;
16
+ openedAt: string;
17
+ transportInitialized: boolean;
18
+ clientInitializedAt: string | null;
19
+ client: { kind: 'claude-code' | 'metro-sdk-runner' | 'other'; tag: string } | null;
20
+ subscribed: boolean;
21
+ streamAttached: boolean;
22
+ lastStreamAttachedAt: string | null;
23
+ lastStreamDetachedAt: string | null;
24
+ stream: GetStreamDiagnostic | null;
25
+ }
26
+
27
+ export interface SlotDiagnostic {
28
+ started: boolean;
29
+ liveEvents: boolean;
30
+ current: SessionDiagnostic | null;
31
+ replacements: { at: string; reason: 'initialize' | 'adopt'; tag: string; previous: SessionDiagnostic | null }[];
32
+ }
33
+
34
+ interface DiagnosticSlot {
35
+ diagnostics: (scope: Set<string>) => SlotDiagnostic;
36
+ }
37
+
38
+ export interface IncomingDiagnostic extends SlotDiagnostic {
39
+ version: 1;
40
+ observedAt: string;
41
+ accounts: {
42
+ station: string;
43
+ tag: string;
44
+ receiveEnabled: boolean;
45
+ bufferedEvents: number;
46
+ latestEventAt: string | null;
47
+ latestEventTag: string | null;
48
+ }[];
49
+ }
50
+
51
+ export function clientDiagnostic(client: { name: string; version: string } | undefined): SessionDiagnostic['client'] {
52
+ if (client === undefined) return null;
53
+ const kind = client.name === 'claude-code' || client.name === 'metro-sdk-runner' ? client.name : 'other';
54
+ return { kind, tag: diagnosticTag(JSON.stringify([client.name, client.version])) };
55
+ }
56
+
57
+ const eventDate = (raw: unknown): string | null => {
58
+ if (typeof raw !== 'string') return null;
59
+ const time = Date.parse(raw);
60
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
61
+ };
62
+
63
+ const eventTag = (messageId: unknown, id: unknown): string | null => {
64
+ const value = typeof messageId === 'string' && messageId !== '' ? messageId : id;
65
+ return typeof value === 'string' && value !== '' ? diagnosticTag(value) : null;
66
+ };
67
+
68
+ export function incomingDiagnostic(slot: DiagnosticSlot, scope: Set<string>): IncomingDiagnostic {
69
+ const events = bufferedSince(-1).filter(({ event }) => typeof event.line === 'string' && eventInScope(scope, event.line));
70
+ const accounts = knownAccounts().filter((account) => scope.has(account.agentId) && STATIONS.some((station) => station === account.station));
71
+ return {
72
+ version: 1,
73
+ observedAt: new Date().toISOString(),
74
+ ...slot.diagnostics(scope),
75
+ accounts: accounts.map((account) => {
76
+ const buffered = events.filter(({ event }) => {
77
+ const target = accountFromLine(event.line);
78
+ return target?.station === account.station && target.accountId === account.id;
79
+ });
80
+ const latest = buffered.at(-1)?.event;
81
+ return {
82
+ station: account.station,
83
+ tag: diagnosticTag(JSON.stringify([account.station, account.id])),
84
+ receiveEnabled: accountEnabled(account.station, account.id),
85
+ bufferedEvents: buffered.length,
86
+ latestEventAt: latest === undefined ? null : eventDate(latest.ts),
87
+ latestEventTag: latest === undefined ? null : eventTag(latest.messageId, latest.id),
88
+ };
89
+ }),
90
+ };
91
+ }
92
+
93
+ export function handleIncomingDiagnostic(req: IncomingMessage, res: ServerResponse, slot: DiagnosticSlot, scope: Set<string>): boolean {
94
+ if ((req.url ?? '').split('?')[0] !== MCP_STATUS_PATH) return false;
95
+ if (req.method !== 'GET') {
96
+ res.writeHead(405, { allow: 'GET' }).end();
97
+ return true;
98
+ }
99
+ const body = JSON.stringify(incomingDiagnostic(slot, scope));
100
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(body);
101
+ return true;
102
+ }
@@ -8,6 +8,7 @@ import {
8
8
  authenticate,
9
9
  runWithIdentity,
10
10
  type RequestIdentity,
11
+ allowedAgents,
11
12
  } from './request-identity.js';
12
13
  import {
13
14
  headerValue,
@@ -18,6 +19,7 @@ import { errMsg } from '@metro-labs/core/log';
18
19
  import { channelLog, type McpSession } from './session.js';
19
20
  import { SessionSlot } from './session-slot.js';
20
21
  import { connectCallBridge } from './call-bridge.js';
22
+ import { handleIncomingDiagnostic } from './diagnostics.js';
21
23
 
22
24
  const isInitialize = (b: unknown): boolean =>
23
25
  !!b &&
@@ -128,6 +130,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
128
130
  res.writeHead(401).end('unauthorized');
129
131
  return;
130
132
  }
133
+ if (handleIncomingDiagnostic(req, res, slot, allowedAgents(identity))) return;
131
134
  const parsed = await readOrReject(req, res);
132
135
  if (!parsed.ok) return;
133
136
  await runWithIdentity(identity, async () => {
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import type {
3
4
  StreamableHTTPServerTransport,
4
5
  EventId,
@@ -49,10 +50,19 @@ export const headerValue = (
49
50
  return typeof raw === 'string' ? raw : undefined;
50
51
  };
51
52
 
53
+ export interface GetStreamDiagnostic {
54
+ tag: string;
55
+ openedAt: string;
56
+ attachedAt: string | null;
57
+ closedAt: string | null;
58
+ ownsMapping: boolean;
59
+ }
60
+
52
61
  export interface RawGetSink {
53
62
  closed: boolean;
54
63
  attach: (transport: StreamableHTTPServerTransport) => void;
55
64
  close: () => void;
65
+ diagnostics: () => GetStreamDiagnostic;
56
66
  }
57
67
 
58
68
  const streamEnded = (sink: RawGetSink, res: ServerResponse): boolean =>
@@ -99,10 +109,8 @@ export interface ServeOpts {
99
109
  previous?: () => RawGetSink | undefined;
100
110
  }
101
111
 
102
- export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
103
- const { transport, eventStore, scope, req, res, log, registerSink } = opts;
112
+ function primeStream(transport: StreamableHTTPServerTransport, res: ServerResponse): void {
104
113
  const sessionId = web(transport)?.sessionId;
105
-
106
114
  res.writeHead(200, {
107
115
  'content-type': 'text/event-stream',
108
116
  'cache-control': 'no-cache, no-transform',
@@ -113,13 +121,23 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
113
121
  res.flushHeaders?.();
114
122
  res.write(RETRY_DIRECTIVE);
115
123
  res.write(COMMENT);
124
+ }
125
+
126
+ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
127
+ const { transport, eventStore, scope, req, res, log, registerSink } = opts;
128
+ primeStream(transport, res);
116
129
 
117
130
  let active = transport;
118
131
  let entry: StreamEntry | undefined;
132
+ const tag = randomUUID();
133
+ const openedAt = new Date().toISOString();
134
+ let attachedAt: string | null = null;
135
+ let closedAt: string | null = null;
119
136
  const timers: { keepalive?: ReturnType<typeof setInterval> } = {};
120
137
  const closeStream = (): void => {
121
138
  if (sink.closed) return;
122
139
  sink.closed = true;
140
+ closedAt = new Date().toISOString();
123
141
  if (timers.keepalive) clearInterval(timers.keepalive);
124
142
  const mapping = web(active)?._streamMapping;
125
143
  if (entry !== undefined && mapping?.get(STANDALONE_STREAM_ID) === entry) {
@@ -150,8 +168,16 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
150
168
  cleanup: closeStream,
151
169
  };
152
170
  web(next)?._streamMapping?.set(STANDALONE_STREAM_ID, entry);
171
+ attachedAt = new Date().toISOString();
153
172
  },
154
173
  close: closeStream,
174
+ diagnostics: () => ({
175
+ tag,
176
+ openedAt,
177
+ attachedAt,
178
+ closedAt,
179
+ ownsMapping: entry !== undefined && web(active)?._streamMapping?.get(STANDALONE_STREAM_ID) === entry,
180
+ }),
155
181
  };
156
182
 
157
183
  res.on('close', closeStream);
@@ -4,6 +4,18 @@ import { errMsg } from '@metro-labs/core/log';
4
4
  import { newReplayLedger } from '../channels/relay.js';
5
5
  import { McpSession, channelLog, type SessionKind } from './session.js';
6
6
  import { allowedAgents, type RequestIdentity } from './request-identity.js';
7
+ import { diagnosticTag, type SessionDiagnostic, type SlotDiagnostic } from './diagnostics.js';
8
+
9
+ interface SlotReplacement {
10
+ at: string;
11
+ reason: 'initialize' | 'adopt';
12
+ tag: string;
13
+ scope: Set<string>;
14
+ previousScope: Set<string>;
15
+ previous: SessionDiagnostic | null;
16
+ }
17
+
18
+ const sharesScope = (left: Set<string>, right: Set<string>): boolean => [...left].some((id) => right.has(id));
7
19
 
8
20
  export class SessionSlot {
9
21
  private session: McpSession | undefined;
@@ -11,6 +23,7 @@ export class SessionSlot {
11
23
  private started = false;
12
24
  private live: boolean;
13
25
  private changing: Promise<void> = Promise.resolve();
26
+ private readonly replacements: SlotReplacement[] = [];
14
27
 
15
28
  constructor(
16
29
  live = true,
@@ -27,6 +40,20 @@ export class SessionSlot {
27
40
  return this.live;
28
41
  }
29
42
 
43
+ diagnostics(scope: Set<string>): SlotDiagnostic {
44
+ return {
45
+ started: this.started,
46
+ liveEvents: this.live,
47
+ current: this.session !== undefined && sharesScope(scope, this.session.scope) ? this.session.diagnostics() : null,
48
+ replacements: this.replacements.filter((replacement) => sharesScope(scope, replacement.scope)).map((replacement) => ({
49
+ at: replacement.at,
50
+ reason: replacement.reason,
51
+ tag: replacement.tag,
52
+ previous: sharesScope(scope, replacement.previousScope) ? replacement.previous : null,
53
+ })),
54
+ };
55
+ }
56
+
30
57
  startInbound(): void {
31
58
  if (this.started) return;
32
59
  this.started = true;
@@ -59,8 +86,27 @@ export class SessionSlot {
59
86
  return changed;
60
87
  }
61
88
 
89
+ private previousSlot(): Pick<SlotReplacement, 'previous' | 'previousScope'> {
90
+ return {
91
+ previous: this.session?.diagnostics() ?? null,
92
+ previousScope: this.session?.scope ?? new Set<string>(),
93
+ };
94
+ }
95
+
96
+ private recordReplacement(session: McpSession, adoptId: string | undefined, previous: Pick<SlotReplacement, 'previous' | 'previousScope'>): void {
97
+ this.replacements.push({
98
+ at: new Date().toISOString(),
99
+ reason: adoptId === undefined ? 'initialize' : 'adopt',
100
+ tag: diagnosticTag(session.id),
101
+ scope: session.scope,
102
+ ...previous,
103
+ });
104
+ if (this.replacements.length > 8) this.replacements.shift();
105
+ }
106
+
62
107
  private async replace(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
63
108
  if (adoptId !== undefined && this.session?.id === adoptId) return this.session;
109
+ const previous = this.previousSlot();
64
110
  await this.session?.close();
65
111
  if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
66
112
  const id = adoptId ?? randomUUID();
@@ -81,6 +127,7 @@ export class SessionSlot {
81
127
  },
82
128
  });
83
129
  this.session = session;
130
+ this.recordReplacement(session, adoptId, previous);
84
131
  if (this.started && this.live) session.startChannel();
85
132
  channelLog('session: opened', 'id', id, 'kind', this.kind, 'adopted', adoptId !== undefined);
86
133
  return session;
@@ -15,6 +15,7 @@ import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
15
15
  import { web, type RawGetSink } from './raw-get-stream.js';
16
16
  import { sharedCalls } from '../voice/shared.js';
17
17
  import { CALL_STATE } from '@metro-labs/core/call';
18
+ import { clientDiagnostic, diagnosticTag, type SessionDiagnostic } from './diagnostics.js';
18
19
 
19
20
  export const channelLog = (...a: unknown[]): void => {
20
21
  console.error('[metro-mcp]', ...a);
@@ -84,6 +85,10 @@ export class McpSession {
84
85
  private announcing = false;
85
86
  private readonly onClosed: (session: McpSession) => void;
86
87
  private readonly kind: SessionKind;
88
+ private readonly openedAt = new Date().toISOString();
89
+ private clientInitializedAt: string | null = null;
90
+ private lastStreamAttachedAt: string | null = null;
91
+ private lastStreamDetachedAt: string | null = null;
87
92
 
88
93
  private constructor(init: SessionInit) {
89
94
  this.id = init.id;
@@ -102,6 +107,9 @@ export class McpSession {
102
107
  instructions: voice ? VOICE_MCP_INSTRUCTIONS : MCP_INSTRUCTIONS,
103
108
  },
104
109
  );
110
+ this.server.oninitialized = (): void => {
111
+ this.clientInitializedAt = new Date().toISOString();
112
+ };
105
113
  registerToolHandlers(this.server, {
106
114
  markCurrent: (): void => {
107
115
  this.issuedSchema = toolSchemaSignature();
@@ -159,6 +167,21 @@ export class McpSession {
159
167
  return this.sink;
160
168
  }
161
169
 
170
+ diagnostics(): SessionDiagnostic {
171
+ return {
172
+ tag: diagnosticTag(this.id),
173
+ openedAt: this.openedAt,
174
+ transportInitialized: web(this.transport)?._initialized === true,
175
+ clientInitializedAt: this.clientInitializedAt,
176
+ client: clientDiagnostic(this.server.getClientVersion()),
177
+ subscribed: this.unsubscribe !== undefined,
178
+ streamAttached: this.streamAttached,
179
+ lastStreamAttachedAt: this.lastStreamAttachedAt,
180
+ lastStreamDetachedAt: this.lastStreamDetachedAt,
181
+ stream: this.sink?.diagnostics() ?? null,
182
+ };
183
+ }
184
+
162
185
  inScope(line: string): boolean {
163
186
  return this.streamAttached && eventInScope(this.scope, line);
164
187
  }
@@ -181,8 +204,10 @@ export class McpSession {
181
204
  sink?.close();
182
205
  return;
183
206
  }
207
+ if (sink === undefined && this.sink !== undefined) this.lastStreamDetachedAt = new Date().toISOString();
184
208
  this.sink = sink;
185
209
  if (sink !== undefined) {
210
+ this.lastStreamAttachedAt = new Date().toISOString();
186
211
  this.announceCallState();
187
212
  this.announceToolSchema();
188
213
  }
@@ -227,6 +252,7 @@ export class McpSession {
227
252
 
228
253
  dropStream(): void {
229
254
  const sink = this.sink;
255
+ if (sink !== undefined) this.lastStreamDetachedAt = new Date().toISOString();
230
256
  this.sink = undefined;
231
257
  sink?.close();
232
258
  }
@@ -12,6 +12,7 @@ import {
12
12
  type ServerResponse,
13
13
  } from 'node:http';
14
14
  import { Line } from '@metro-labs/core/lines';
15
+ import { MCP_STATUS_PATH } from '../mcp/diagnostics.js';
15
16
  import { errMsg, log } from '@metro-labs/core/log';
16
17
  import {
17
18
  classifyEvent,
@@ -214,7 +215,7 @@ export async function startWebhookServer(
214
215
 
215
216
  function isMcpPath(req: IncomingMessage): boolean {
216
217
  const path = (req.url ?? '').split('?')[0];
217
- return path === '/' || path === '/mcp' || path === VOICE_MCP_PATH;
218
+ return path === '/' || path === '/mcp' || path === VOICE_MCP_PATH || path === MCP_STATUS_PATH;
218
219
  }
219
220
 
220
221
  function flatHeaders(req: IncomingMessage): Record<string, string> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.295"
2
+ "version": "0.1.0-beta.296"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.295",
2
+ "version": "0.1.0-beta.296",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",