@stage-labs/metro 0.1.0-beta.264 → 0.1.0-beta.265

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.
Files changed (51) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
  6. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
  23. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
  24. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
  25. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
  26. package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
  27. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
  28. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  37. package/runtime/sdk-runner/runner.json +1 -1
  38. package/runtime/sdk-runner/src/activity.ts +14 -2
  39. package/runtime/sdk-runner/src/app.ts +5 -4
  40. package/runtime/sdk-runner/src/approvals.ts +14 -5
  41. package/runtime/sdk-runner/src/calls.ts +133 -0
  42. package/runtime/sdk-runner/src/inbox.ts +58 -26
  43. package/runtime/sdk-runner/src/input-queue.ts +70 -0
  44. package/runtime/sdk-runner/src/input-timing.ts +68 -0
  45. package/runtime/sdk-runner/src/link.ts +27 -4
  46. package/runtime/sdk-runner/src/main.ts +0 -4
  47. package/runtime/sdk-runner/src/rules.ts +19 -5
  48. package/runtime/sdk-runner/src/runner.ts +64 -88
  49. package/runtime/sdk-runner/src/session-store.ts +14 -1
  50. package/runtime/sdk-runner/src/session-watch.ts +27 -20
  51. package/runtime/sdk-runner/src/speech.ts +0 -79
package/dist/cli.js CHANGED
@@ -34,9 +34,9 @@ const USAGE = `metro: run your agent on this machine
34
34
  open Claude Code with the metro channel and the metro MCP server, its
35
35
  inference routed through the daemon's model gateway (the Model page);
36
36
  every argument is passed through the same
37
- metro agent run chat and background workers in one Agent SDK conversation,
37
+ metro agent run chat, calls and background workers in one Agent SDK conversation,
38
38
  when the Harness runner is Agent SDK, using the Model page's model;
39
- resume it after a restart; voice calls remain separate
39
+ resume it after a restart; call audio runs independently
40
40
  metro agent --prepare [cli|sdk]
41
41
  check the runner before selecting it; install the SDK runtime if needed,
42
42
  without starting a session or connecting to chat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.264",
3
+ "version": "0.1.0-beta.265",
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.264",
3
+ "version": "0.1.0-beta.265",
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",
@@ -9,6 +9,7 @@
9
9
  "./events": "./src/events.ts",
10
10
  "./is-record": "./src/is-record.ts",
11
11
  "./runner-activity": "./src/runner-activity.ts",
12
+ "./call": "./src/call.ts",
12
13
  "./secure-fs": "./src/secure-fs.ts",
13
14
  "./version": "./src/version.ts",
14
15
  "./train-error": "./src/train-error.ts",
@@ -0,0 +1,81 @@
1
+ import { isRecord } from './is-record.js';
2
+
3
+ export const CALL_NOTICE = 'notifications/metro/call';
4
+ export const CALL_STATE = 'notifications/metro/call_state';
5
+
6
+ export interface CallState {
7
+ route: CallRoute | null;
8
+ }
9
+
10
+ export interface CallRoute {
11
+ agentId: string;
12
+ line: string;
13
+ from: string;
14
+ callId: string;
15
+ generation: string;
16
+ }
17
+
18
+ export interface SpeechTarget {
19
+ callId: string;
20
+ generation: string;
21
+ sourceId: string;
22
+ }
23
+
24
+ export interface CallSource {
25
+ route: CallRoute;
26
+ sourceId: string;
27
+ }
28
+
29
+ export type SpeechStatus = 'accepted' | 'queued' | 'started' | 'completed' | 'interrupted' | 'failed';
30
+
31
+ export type CallNotice =
32
+ | { type: 'started'; route: CallRoute; sourceId: string }
33
+ | { type: 'heard'; route: CallRoute; sourceId: string; text: string }
34
+ | { type: 'ended'; route: CallRoute }
35
+ | { type: 'speech'; route: CallRoute; sourceId: string; actionId: string; status: SpeechStatus };
36
+
37
+ const filled = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 2048;
38
+
39
+ export function callRoute(value: unknown): CallRoute | null {
40
+ if (!isRecord(value)) return null;
41
+ const { agentId, line, from, callId, generation } = value;
42
+ return filled(agentId) && filled(line) && filled(from) && filled(callId) && filled(generation)
43
+ ? { agentId, line, from, callId, generation }
44
+ : null;
45
+ }
46
+
47
+ export function callState(value: unknown): CallState | null {
48
+ if (!isRecord(value)) return null;
49
+ if (value.route === null) return { route: null };
50
+ const route = callRoute(value.route);
51
+ return route === null ? null : { route };
52
+ }
53
+
54
+ export function speechTarget(value: unknown): SpeechTarget | null {
55
+ if (!isRecord(value)) return null;
56
+ const { callId, generation, sourceId } = value;
57
+ return filled(callId) && filled(generation) && filled(sourceId) ? { callId, generation, sourceId } : null;
58
+ }
59
+
60
+ const STATUSES: readonly SpeechStatus[] = ['accepted', 'queued', 'started', 'completed', 'interrupted', 'failed'];
61
+
62
+ const speechStatus = (value: unknown): SpeechStatus | null => STATUSES.find((status) => status === value) ?? null;
63
+
64
+ function sourcedNotice(value: Record<string, unknown>, route: CallRoute, sourceId: string): CallNotice | null {
65
+ if (value.type === 'started') return { type: 'started', route, sourceId };
66
+ if (value.type === 'heard') return typeof value.text === 'string' && value.text.length <= 32_000 ? { type: 'heard', route, sourceId, text: value.text } : null;
67
+ if (value.type !== 'speech' || !filled(value.actionId)) return null;
68
+ const status = speechStatus(value.status);
69
+ return status === null ? null : { type: 'speech', route, sourceId, actionId: value.actionId, status };
70
+ }
71
+
72
+ export function callNotice(value: unknown): CallNotice | null {
73
+ if (!isRecord(value)) return null;
74
+ const route = callRoute(value.route);
75
+ if (route === null) return null;
76
+ if (value.type === 'ended') return { type: 'ended', route };
77
+ return filled(value.sourceId) ? sourcedNotice(value, route, value.sourceId) : null;
78
+ }
79
+
80
+ export const sameCall = (a: CallRoute, b: CallRoute): boolean =>
81
+ a.agentId === b.agentId && a.line === b.line && a.from === b.from && a.callId === b.callId && a.generation === b.generation;
@@ -74,6 +74,17 @@ export interface RunnerFailure extends RunnerEvent {
74
74
  code: RunnerFailureCode;
75
75
  }
76
76
 
77
+ export interface RunnerInput {
78
+ id: string;
79
+ kind: 'chat' | 'call' | 'note';
80
+ state: 'accepted' | 'consumed' | 'output' | 'completed' | 'cancelled';
81
+ acceptedAt: number;
82
+ dispatchedAt: number | null;
83
+ consumedAt: number | null;
84
+ firstOutputAt: number | null;
85
+ completedAt: number | null;
86
+ }
87
+
77
88
  export interface RunnerActivity {
78
89
  pid: number;
79
90
  procStart?: string;
@@ -84,6 +95,9 @@ export interface RunnerActivity {
84
95
  sessionId: string | null;
85
96
  updatedAt: number;
86
97
  pending: number;
98
+ queueOldestAt?: number | null;
99
+ inputs?: RunnerInput[];
100
+ callState?: 'started' | 'ended' | 'accepted' | 'queued' | 'speaking' | 'completed' | 'interrupted' | 'failed';
87
101
  workers: number;
88
102
  approvals: number;
89
103
  tools: string[];
@@ -160,6 +174,28 @@ function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'last
160
174
  };
161
175
  }
162
176
 
177
+ function input(raw: unknown): RunnerInput | null {
178
+ if (!isRecord(raw) || sessionId(raw.id) === null) return null;
179
+ const kind = (['chat', 'call', 'note'] as const).find((value) => value === raw.kind);
180
+ const state = (['accepted', 'consumed', 'output', 'completed', 'cancelled'] as const).find((value) => value === raw.state);
181
+ if (kind === undefined || state === undefined) return null;
182
+ return {
183
+ id: String(raw.id), kind, state, acceptedAt: count(raw.acceptedAt),
184
+ dispatchedAt: count(raw.dispatchedAt) || null, consumedAt: count(raw.consumedAt) || null,
185
+ firstOutputAt: count(raw.firstOutputAt) || null, completedAt: count(raw.completedAt) || null,
186
+ };
187
+ }
188
+
189
+ function inputFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'inputs' | 'queueOldestAt' | 'callState'> {
190
+ const states = ['started', 'ended', 'accepted', 'queued', 'speaking', 'completed', 'interrupted', 'failed'] as const;
191
+ const callState = states.find((state) => state === raw.callState);
192
+ return {
193
+ ...(raw.inputs === undefined ? {} : { inputs: list(raw.inputs, input, 40) }),
194
+ ...(raw.queueOldestAt === undefined ? {} : { queueOldestAt: count(raw.queueOldestAt) || null }),
195
+ ...(callState === undefined ? {} : { callState }),
196
+ };
197
+ }
198
+
163
199
  export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
164
200
  if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
165
201
  return {
@@ -168,6 +204,6 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
168
204
  pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
169
205
  tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
170
206
  tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
171
- ...failureFields(raw),
207
+ ...failureFields(raw), ...inputFields(raw),
172
208
  };
173
209
  }
@@ -1,5 +1,6 @@
1
1
  import { errMsg, log } from '@metro-labs/core/log';
2
2
  import { previewMatches } from './preview.js';
3
+ import { sameCall, speechTarget, type CallRoute, type CallSource } from '@metro-labs/core/call';
3
4
 
4
5
  export const NEEDS_APPROVAL = (where: string, tool: string): string =>
5
6
  `Needs the owner's approval for ${where} (${tool}). Make this exact call from a background worker so Claude Code asks the owner in chat, and wait for the answer. An approval given only in the terminal does not count.`;
@@ -15,6 +16,7 @@ export interface PendingPrompt {
15
16
  preview: string;
16
17
  line: string | undefined;
17
18
  at: number;
19
+ call?: CallSource;
18
20
  }
19
21
 
20
22
  interface Held extends PendingPrompt {
@@ -33,6 +35,9 @@ interface Grant {
33
35
  tool: string;
34
36
  preview: string;
35
37
  at: number;
38
+ owner: object;
39
+ requestId: string;
40
+ call?: CallSource;
36
41
  }
37
42
 
38
43
  const GRANT_TTL_MS = 10 * 60_000;
@@ -40,11 +45,22 @@ const GRANTS_MAX = 200;
40
45
  let grants: Grant[] = [];
41
46
 
42
47
  function grant(entry: Held, now = Date.now()): void {
43
- grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), { tool: entry.tool, preview: entry.preview, at: now }].slice(-GRANTS_MAX);
48
+ grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), {
49
+ tool: entry.tool, preview: entry.preview, at: now, owner: entry.owner, requestId: entry.requestId,
50
+ ...(entry.call === undefined ? {} : { call: entry.call }),
51
+ }].slice(-GRANTS_MAX);
52
+ }
53
+
54
+ function speechMatches(grant: Grant, args: Record<string, unknown>): boolean {
55
+ if (args.speech === undefined) return grant.call === undefined;
56
+ const target = speechTarget(args.speech);
57
+ const bound = grant.call;
58
+ return target !== null && bound !== undefined && bound.route.line === args.line && bound.route.callId === target.callId &&
59
+ bound.route.generation === target.generation && bound.sourceId === target.sourceId;
44
60
  }
45
61
 
46
62
  export function takeGrant(toolMatches: (tool: string) => boolean, args: Record<string, unknown>, now = Date.now()): boolean {
47
- const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && previewMatches(g.preview, args));
63
+ const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && speechMatches(g, args) && previewMatches(g.preview, args));
48
64
  if (at < 0) return false;
49
65
  grants = grants.filter((_, i) => i !== at);
50
66
  return true;
@@ -109,6 +125,23 @@ export async function answerPrompt(
109
125
 
110
126
  export function forgetPromptsOf(owner: object): void {
111
127
  for (const [id, entry] of held) if (entry.owner === owner) held.delete(id);
128
+ grants = grants.filter((entry) => entry.owner !== owner);
129
+ }
130
+
131
+ export function cancelPrompt(requestId: string, owner: object): void {
132
+ grants = grants.filter((entry) => entry.owner !== owner || entry.requestId !== requestId);
133
+ const entry = held.get(requestId);
134
+ if (entry?.owner !== owner) return;
135
+ held.delete(requestId);
136
+ entry.send('deny').catch((err: unknown) => {
137
+ log.warn({ requestId, err: errMsg(err) }, 'approvals: cancelled prompt could not be answered');
138
+ });
139
+ }
140
+
141
+ export function revokeCallPrompts(route: CallRoute): void {
142
+ grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
143
+ for (const entry of held.values())
144
+ if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
112
145
  }
113
146
 
114
147
  export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
@@ -72,7 +72,7 @@ const emit = makeEmit();
72
72
 
73
73
  supervisor.onTrainEvent((env, train) => {
74
74
  if (isCallEvent(env)) {
75
- onCallEvent(env);
75
+ onCallEvent(env, train);
76
76
  return;
77
77
  }
78
78
  const entry = trainEventToMetroEvent(env, train);
@@ -51,6 +51,7 @@ interface InboundDeps {
51
51
  getStations: () => Set<string>;
52
52
  senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
53
53
  approves?: (station: string) => boolean;
54
+ callMeta?: (event: Record<string, unknown>) => () => Record<string, string>;
54
55
  answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string, from: string) => Promise<boolean>;
55
56
  }
56
57
 
@@ -91,12 +92,7 @@ export class InboundRelay {
91
92
  return this.deps.mcp.notification({ method, params });
92
93
  }
93
94
 
94
- private isDuplicate(
95
- station: string,
96
- line: string,
97
- kind: string,
98
- messageId: string,
99
- ): boolean {
95
+ private isDuplicate(station: string, line: string, kind: string, messageId: string): boolean {
100
96
  if (!messageId) return false;
101
97
  const key = dedupeKey(station, line, kind, messageId);
102
98
  const now = Date.now();
@@ -129,6 +125,7 @@ export class InboundRelay {
129
125
  mime: p.mime ?? '',
130
126
  name: note.name,
131
127
  ...extra,
128
+ ...ctx.callMeta?.(),
132
129
  },
133
130
  });
134
131
  }
@@ -172,6 +169,7 @@ export class InboundRelay {
172
169
  from_name: e.fromName,
173
170
  ...displayNameMeta(e.fromDisplayName),
174
171
  ...e.reply,
172
+ ...e.callMeta?.(),
175
173
  },
176
174
  });
177
175
  }
@@ -201,13 +199,14 @@ export class InboundRelay {
201
199
  };
202
200
  }
203
201
 
204
- private async routeAttachment(ev: Record<string, unknown>): Promise<boolean> {
202
+ private async routeAttachment(ev: Record<string, unknown>, replay: boolean): Promise<boolean> {
205
203
  const p = ev.payload as SavedMedia | undefined;
206
204
  if (!p) return false;
207
205
  const type = p.contentType;
208
206
  if (type !== 'attachmentSaved' && type !== 'attachmentFailed') return false;
209
207
  const ctx = this.mediaCtxFor(ev, p);
210
208
  if (!ctx) return true;
209
+ if (replay) ctx.callMeta = undefined;
211
210
  if (type === 'attachmentFailed') await this.surfaceMediaFailure(ctx, p);
212
211
  else await this.surfaceMedia(ctx, p);
213
212
  return true;
@@ -215,8 +214,9 @@ export class InboundRelay {
215
214
 
216
215
  private bufferAttachments(
217
216
  ev: Record<string, unknown>,
218
- base: { line: string; from: string; station: string; text: string },
217
+ base: EventBase,
219
218
  atts: PendingAtt[],
219
+ replay: boolean,
220
220
  ): void {
221
221
  const id = str(ev.id);
222
222
  if (!id) return;
@@ -230,6 +230,7 @@ export class InboundRelay {
230
230
  fromName: str(ev.fromName),
231
231
  fromDisplayName: str(ev.fromDisplayName),
232
232
  reply: replyMeta(ev, this.sentIds),
233
+ callMeta: !replay && base.evType === 'msg' ? this.deps.callMeta?.(ev) : undefined,
233
234
  attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
234
235
  saved: new Set<number>(),
235
236
  timer: setTimeout(() => {
@@ -337,6 +338,7 @@ export class InboundRelay {
337
338
  private async emitMessage(
338
339
  ev: Record<string, unknown>,
339
340
  base: EventBase,
341
+ replay: boolean,
340
342
  ): Promise<void> {
341
343
  if (base.evType === 'msg' && base.text.trim() === '') {
342
344
  this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
@@ -358,6 +360,7 @@ export class InboundRelay {
358
360
  from_name: str(ev.fromName),
359
361
  ...displayNameMeta(ev.fromDisplayName),
360
362
  ...replyMeta(ev, this.sentIds),
363
+ ...(!replay && base.evType === 'msg' ? this.deps.callMeta?.(ev)?.() : {}),
361
364
  },
362
365
  });
363
366
  }
@@ -366,7 +369,7 @@ export class InboundRelay {
366
369
  ev: Record<string, unknown>,
367
370
  replay = false,
368
371
  ): Promise<void> {
369
- if (await this.routeAttachment(ev)) return;
372
+ if (await this.routeAttachment(ev, replay)) return;
370
373
 
371
374
  const base = this.routable(ev, replay);
372
375
  if (!base) return;
@@ -375,7 +378,7 @@ export class InboundRelay {
375
378
  const atts = (ev.payload as { attachments?: PendingAtt[] } | undefined)
376
379
  ?.attachments;
377
380
  if (Array.isArray(atts) && atts.length) {
378
- this.bufferAttachments(ev, base, atts);
381
+ this.bufferAttachments(ev, base, atts, replay);
379
382
  return;
380
383
  }
381
384
 
@@ -383,7 +386,7 @@ export class InboundRelay {
383
386
  await this.handleReact(ev, base);
384
387
  return;
385
388
  }
386
- await this.emitMessage(ev, base);
389
+ await this.emitMessage(ev, base, replay);
387
390
  }
388
391
  }
389
392
 
@@ -16,6 +16,7 @@ export interface PendingMsg {
16
16
  fromName: string;
17
17
  fromDisplayName: string;
18
18
  reply: Record<string, string>;
19
+ callMeta?: () => Record<string, string>;
19
20
  attachments: PendingAtt[];
20
21
  saved: Set<number>;
21
22
  timer: ReturnType<typeof setTimeout>;
@@ -32,6 +33,7 @@ export interface MediaCtx {
32
33
  fromName?: string;
33
34
  fromDisplayName?: string;
34
35
  reply?: Record<string, string>;
36
+ callMeta?: () => Record<string, string>;
35
37
  }
36
38
 
37
39
  export function capSet(set: Set<string>, max: number): void {
@@ -72,6 +74,7 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
72
74
  fromName: buf.fromName,
73
75
  fromDisplayName: buf.fromDisplayName,
74
76
  reply: buf.reply,
77
+ callMeta: buf.callMeta,
75
78
  };
76
79
  buf.text = '';
77
80
  return ctx;
@@ -0,0 +1,45 @@
1
+ import { CALL_NOTICE, sameCall, type CallNotice, type CallRoute } from '@metro-labs/core/call';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import { eventInScope } from '../agents/scope.js';
4
+ import { revokeCallPrompts } from '../approvals/pending.js';
5
+ import { sharedCalls } from '../voice/shared.js';
6
+ import type { McpSession } from './session.js';
7
+
8
+ export const callInScope = (scope: Set<string>, route: CallRoute): boolean =>
9
+ scope.has(route.agentId) && eventInScope(scope, route.line);
10
+
11
+ export function connectCallBridge(current: () => McpSession | undefined): void {
12
+ let owner: McpSession | undefined;
13
+ let route: CallRoute | undefined;
14
+ const available = (wanted: CallRoute): boolean => {
15
+ const session = current();
16
+ return session !== undefined && session.streamAttached && session.server.getClientVersion()?.name === 'metro-sdk-runner' &&
17
+ callInScope(session.scope, wanted) && (route === undefined || owner === session);
18
+ };
19
+ const boundTo = (session: McpSession | undefined, wanted: CallRoute): boolean => session === owner && route !== undefined && sameCall(route, wanted);
20
+ const forgetEnded = (notice: CallNotice): void => {
21
+ if (notice.type === 'ended' && route !== undefined && sameCall(route, notice.route)) {
22
+ owner = undefined;
23
+ route = undefined;
24
+ }
25
+ };
26
+ sharedCalls.connect({
27
+ available,
28
+ notify: (notice: CallNotice): boolean => {
29
+ const session = current();
30
+ const bound = notice.type === 'started' ? available(notice.route) : boundTo(session, notice.route);
31
+ forgetEnded(notice);
32
+ if (session === undefined || !session.streamAttached || !callInScope(session.scope, notice.route) || !bound) return false;
33
+ if (notice.type === 'started') {
34
+ owner = session;
35
+ route = notice.route;
36
+ }
37
+ session.server.notification({ method: CALL_NOTICE, params: { ...notice, meta: { line: notice.route.line } } }).catch((err: unknown) => {
38
+ log.warn({ err: errMsg(err) }, 'sdk-call: notification failed');
39
+ sharedCalls.end(notice.route);
40
+ });
41
+ return true;
42
+ },
43
+ revoked: (ended) => { revokeCallPrompts(ended); },
44
+ });
45
+ }
@@ -0,0 +1,25 @@
1
+ import { callRoute, speechTarget, type CallSource } from '@metro-labs/core/call';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { readPreview } from '../approvals/preview.js';
4
+ import { sharedCalls } from '../voice/shared.js';
5
+ import { agentIdForLine } from '../agents/map.js';
6
+
7
+ function boundCall(value: unknown, inScope: (line: string) => boolean): CallSource | null {
8
+ if (!isRecord(value)) return null;
9
+ const route = callRoute(value.route);
10
+ const sourceId = value.sourceId;
11
+ if (route === null || typeof sourceId !== 'string') return null;
12
+ const source = { route, sourceId };
13
+ return inScope(route.line) && sharedCalls.valid(source) ? source : null;
14
+ }
15
+
16
+ export function permissionCall(params: { call?: unknown; input_preview: string; tool_name: string }, inScope: (line: string) => boolean): CallSource | null | undefined {
17
+ if (params.call !== undefined) return boundCall(params.call, inScope);
18
+ if (params.tool_name !== 'mcp__metro__send') return undefined;
19
+ const { input } = readPreview(params.input_preview);
20
+ if (!isRecord(input) || input.speech === undefined) return undefined;
21
+ const target = speechTarget(input.speech);
22
+ if (target === null || typeof input.line !== 'string' || !inScope(input.line)) return null;
23
+ const agentId = agentIdForLine(input.line);
24
+ return agentId === undefined ? null : sharedCalls.source(input.line, target, new Set([agentId]));
25
+ }
@@ -10,6 +10,7 @@ import { errResult, makeCtx, ok, toErr } from './ctx.js';
10
10
  import { runRead } from './read-tool.js';
11
11
  import { allowedAgents, currentIdentity } from './request-identity.js';
12
12
  import { str } from '@metro-labs/core/str';
13
+ import { sendSpeech } from './speech-send.js';
13
14
 
14
15
  type Station = NonNullable<ReturnType<typeof stationForLine>>;
15
16
 
@@ -143,6 +144,7 @@ function noteSent(m: MessageArgs, messageId: string | undefined): void {
143
144
  }
144
145
 
145
146
  async function handleSend(m: MessageArgs): Promise<ToolResult> {
147
+ if (m.a.speech !== undefined) return sendSpeech(m.line, m.a);
146
148
  const text = m.a.text as string | undefined;
147
149
  const replyTo = m.a.reply_to as string | undefined;
148
150
  const requested = (m.a.attachments as CanonicalAttachment[] | undefined) ?? [];
@@ -6,6 +6,7 @@ import type {
6
6
  import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { str } from '@metro-labs/core/str';
8
8
  import { eventInScope } from '../agents/scope.js';
9
+ import { CALL_NOTICE, CALL_STATE } from '@metro-labs/core/call';
9
10
 
10
11
  const EVENT_STORE_MAX = 500;
11
12
 
@@ -14,6 +15,7 @@ interface StoredEvent {
14
15
  streamId: StreamId;
15
16
  message: JSONRPCMessage;
16
17
  line: string | undefined;
18
+ replays: boolean;
17
19
  }
18
20
 
19
21
  export interface ScopedReplay {
@@ -32,6 +34,8 @@ function frameLine(message: JSONRPCMessage): string | undefined {
32
34
  return str((meta as { line?: unknown }).line) || undefined;
33
35
  }
34
36
 
37
+ const isCallNotice = (message: JSONRPCMessage): boolean => 'method' in message && (message.method === CALL_NOTICE || message.method === CALL_STATE);
38
+
35
39
  const encodeEventId = (streamId: StreamId, seq: number): EventId =>
36
40
  `${streamId}${SEP}${seq}`;
37
41
 
@@ -52,11 +56,13 @@ export class BoundedEventStore implements EventStore {
52
56
 
53
57
  storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
54
58
  const eventId = encodeEventId(streamId, ++this.seq);
59
+ const replays = !isCallNotice(message);
55
60
  this.events.push({
56
61
  eventId,
57
62
  streamId,
58
- message,
59
- line: frameLine(message),
63
+ message: replays ? message : { jsonrpc: message.jsonrpc, method: CALL_NOTICE },
64
+ line: replays ? frameLine(message) : undefined,
65
+ replays,
60
66
  });
61
67
  if (this.events.length > this.max) this.events.shift();
62
68
  return Promise.resolve(eventId);
@@ -79,6 +85,7 @@ export class BoundedEventStore implements EventStore {
79
85
  if (e.eventId === lastEventId) seen = true;
80
86
  continue;
81
87
  }
88
+ if (!e.replays) continue;
82
89
  if (e.line !== undefined && !eventInScope(scope, e.line)) {
83
90
  onWithheld?.(e.eventId, e.line);
84
91
  continue;
@@ -17,6 +17,7 @@ import {
17
17
  import { errMsg } from '@metro-labs/core/log';
18
18
  import { channelLog, type McpSession } from './session.js';
19
19
  import { SessionSlot } from './session-slot.js';
20
+ import { connectCallBridge } from './call-bridge.js';
20
21
 
21
22
  const isInitialize = (b: unknown): boolean =>
22
23
  !!b &&
@@ -116,6 +117,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
116
117
  const slot = new SessionSlot(options.liveEvents);
117
118
  const voiceSlot = new SessionSlot(false, 'voice');
118
119
  activeSlots = [slot, voiceSlot];
120
+ connectCallBridge(() => slot.current);
119
121
 
120
122
  const httpHandler = async (
121
123
  req: IncomingMessage,
@@ -45,13 +45,14 @@ function argText(value: unknown): string | undefined {
45
45
 
46
46
  function fieldText(key: string, value: unknown): string | undefined {
47
47
  if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
48
- return key === 'frame' || key === 'wallet' ? argText(value) : valueText(value);
48
+ return key === 'frame' || key === 'wallet' || key === 'speech' ? argText(value) : valueText(value);
49
49
  }
50
50
 
51
51
  function metroLines(tool: string, input: Record<string, unknown>): string[] {
52
52
  const lines = [`Approval needed: ${tool}`];
53
53
  const channel = channelOf(input);
54
54
  if (channel !== undefined) lines.push(`Channel: ${channel}`);
55
+ if (tool === 'send' && input.speech !== undefined) lines.push('Delivery: voice call only, no chat post');
55
56
  const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
56
57
  for (const key of keys) {
57
58
  if (PLACE.has(key)) continue;
@@ -1,10 +1,11 @@
1
1
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { z } from 'zod';
3
3
  import type { InboundRelay } from '../channels/inbound.js';
4
- import { holdPrompt, type Behavior } from '../approvals/pending.js';
4
+ import { cancelPrompt, holdPrompt, type Behavior } from '../approvals/pending.js';
5
5
  import { approversForLine, lineReceives } from '../agents/map.js';
6
6
  import { metroCall } from './ctx.js';
7
7
  import { promptBody } from './permission-prompt.js';
8
+ import { permissionCall } from './call-permission.js';
8
9
 
9
10
  const PermissionRequestSchema = z.object({
10
11
  method: z.literal('notifications/claude/channel/permission_request'),
@@ -13,6 +14,7 @@ const PermissionRequestSchema = z.object({
13
14
  tool_name: z.string(),
14
15
  description: z.string(),
15
16
  input_preview: z.string(),
17
+ call: z.unknown().optional(),
16
18
  }),
17
19
  });
18
20
 
@@ -32,12 +34,12 @@ export interface PermissionRelayDeps {
32
34
  log: (...a: unknown[]) => void;
33
35
  }
34
36
 
35
- function relayLine(deps: PermissionRelayDeps, requestId: string): string | undefined {
37
+ function relayLine(deps: PermissionRelayDeps, requestId: string, callLine?: string): string | undefined {
36
38
  if (!deps.live()) {
37
39
  deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
38
40
  return undefined;
39
41
  }
40
- const line = deps.relay.knownLine;
42
+ const line = callLine ?? deps.relay.knownLine;
41
43
  if (!line) {
42
44
  deps.log('permission_request: no known line, held for the page only', requestId);
43
45
  return undefined;
@@ -59,12 +61,20 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
59
61
 
60
62
  export function registerPermissionRelay(deps: PermissionRelayDeps): void {
61
63
  const { mcp, log } = deps;
64
+ mcp.setNotificationHandler(z.object({ method: z.literal('notifications/metro/permission_cancel'), params: z.object({ request_id: z.string() }) }), (notice) => {
65
+ cancelPrompt(notice.params.request_id, mcp);
66
+ });
62
67
  const answer = (requestId: string) => async (behavior: Behavior): Promise<void> => {
63
68
  await mcp.notification({ method: 'notifications/claude/channel/permission', params: { request_id: requestId, behavior } });
64
69
  };
65
70
  mcp.setNotificationHandler(PermissionRequestSchema as never, async (n: PermissionRequest) => {
66
71
  const { params } = n;
67
- const line = relayLine(deps, params.request_id);
72
+ const call = permissionCall(params, deps.inScope);
73
+ if (call === null) {
74
+ await answer(params.request_id)('deny');
75
+ return;
76
+ }
77
+ const line = relayLine(deps, params.request_id, call?.route.line);
68
78
  holdPrompt(
69
79
  {
70
80
  requestId: params.request_id,
@@ -73,6 +83,7 @@ export function registerPermissionRelay(deps: PermissionRelayDeps): void {
73
83
  preview: params.input_preview,
74
84
  line,
75
85
  at: Date.now(),
86
+ ...(call === undefined ? {} : { call }),
76
87
  },
77
88
  mcp,
78
89
  answer(params.request_id),