@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
@@ -6,7 +6,7 @@ import { elevenLabs, parsed, wsText } from './scribe.js';
6
6
 
7
7
  const TTS_MODEL = 'eleven_flash_v2_5';
8
8
 
9
- interface Voiced {
9
+ export interface Voiced {
10
10
  audio(pcm48k: Int16Array, chars: number): void;
11
11
  done(): void;
12
12
  }
@@ -37,10 +37,12 @@ export class Utterance {
37
37
  private aborted = false;
38
38
  private voiced = false;
39
39
  private refusal: string | null = null;
40
+ private finished = false;
40
41
 
41
- constructor(apiKey: string, voiceId: string, failed: (reason: string) => void) {
42
+ constructor(apiKey: string, voiceId: string, private readonly failed: (reason: string) => void) {
42
43
  this.ws = new WebSocket(ttsUrl(voiceId), { headers: { 'xi-api-key': apiKey } });
43
44
  this.ws.on('open', () => {
45
+ if (this.aborted) return;
44
46
  this.ws.send(JSON.stringify({ text: ' ' }));
45
47
  for (const line of this.outbox.splice(0)) this.ws.send(line);
46
48
  });
@@ -48,15 +50,14 @@ export class Utterance {
48
50
  this.onMessage(wsText(data));
49
51
  });
50
52
  this.ws.on('error', (err) => {
53
+ if (this.aborted || this.finished) return;
51
54
  this.refusal ??= errMsg(err);
52
55
  log.warn({ err: errMsg(err) }, 'voice: text to speech connection error');
53
56
  });
54
57
  this.ws.on('close', (code, why) => {
55
58
  this.closed = true;
56
- this.listener?.done();
57
- this.listener = null;
58
59
  const reason = this.refusal ?? (code === 1000 ? null : (why.toString() || `closed (${String(code)})`));
59
- if (!this.aborted && !this.voiced && reason !== null) failed(reason.slice(0, MAX_REASON));
60
+ this.complete(reason);
60
61
  });
61
62
  }
62
63
 
@@ -65,7 +66,7 @@ export class Utterance {
65
66
  }
66
67
 
67
68
  attach(listener: Voiced): void {
68
- this.listener = listener;
69
+ if (!this.aborted && !this.finished) this.listener = listener;
69
70
  }
70
71
 
71
72
  say(text: string): void {
@@ -77,28 +78,52 @@ export class Utterance {
77
78
  }
78
79
 
79
80
  abort(): void {
81
+ if (this.aborted) return;
80
82
  this.aborted = true;
81
83
  this.listener = null;
84
+ this.outbox.length = 0;
82
85
  this.ws.terminate();
83
86
  }
84
87
 
88
+ private complete(reason: string | null): void {
89
+ if (this.aborted || this.finished) return;
90
+ this.finished = true;
91
+ const listener = this.listener;
92
+ this.listener = null;
93
+ this.outbox.length = 0;
94
+ const failure = reason ?? (listener !== null && !this.voiced ? 'no audio received' : null);
95
+ if (failure !== null) this.failed(failure.slice(0, MAX_REASON));
96
+ else listener?.done();
97
+ }
98
+
85
99
  private post(msg: Record<string, unknown>): void {
100
+ if (this.aborted || this.closed || this.finished) return;
86
101
  const line = JSON.stringify(msg);
87
102
  if (this.ws.readyState === WebSocket.OPEN) this.ws.send(line);
88
103
  else this.outbox.push(line);
89
104
  }
90
105
 
106
+ private refused(msg: Record<string, unknown>, raw: string): void {
107
+ if (typeof msg.message === 'string' && msg.message !== '') this.refusal ??= msg.message;
108
+ log.warn({ msg: raw.slice(0, MAX_REASON) }, 'voice: text to speech said');
109
+ }
110
+
111
+ private audio(encoded: string, chars: number): void {
112
+ this.voiced = true;
113
+ const pcm = pcmOf(Buffer.from(encoded, 'base64'));
114
+ const up = upsample2(pcm, this.carry);
115
+ this.carry = pcm.at(-1) ?? this.carry;
116
+ this.listener?.audio(up, chars);
117
+ }
118
+
91
119
  private onMessage(raw: string): void {
120
+ if (this.aborted || this.finished) return;
92
121
  const msg = parsed(raw);
93
- if (typeof msg.audio === 'string' && msg.audio !== '') {
94
- this.voiced = true;
95
- const pcm = pcmOf(Buffer.from(msg.audio, 'base64'));
96
- const up = upsample2(pcm, this.carry);
97
- this.carry = pcm.at(-1) ?? this.carry;
98
- this.listener?.audio(up, charsOf(msg));
99
- } else if (msg.isFinal !== true) {
100
- if (typeof msg.message === 'string' && msg.message !== '') this.refusal ??= msg.message;
101
- log.warn({ msg: raw.slice(0, MAX_REASON) }, 'voice: text to speech said');
122
+ if (typeof msg.audio === 'string' && msg.audio !== '') this.audio(msg.audio, charsOf(msg));
123
+ else if (msg.isFinal !== true) this.refused(msg, raw);
124
+ if (this.refusal !== null || msg.isFinal === true) {
125
+ this.complete(this.refusal);
126
+ this.ws.close(1000);
102
127
  }
103
128
  }
104
129
  }
@@ -58,7 +58,7 @@ export class Talk implements Thinking {
58
58
 
59
59
  constructor(
60
60
  private readonly cfg: VoiceConfig,
61
- out: (opus: Buffer, timestamp: number, marker: boolean) => void,
61
+ out: Playback['send'],
62
62
  private readonly ended: (reason: string) => void,
63
63
  private readonly approvals: CallApprovals,
64
64
  ) {
@@ -72,6 +72,9 @@ export class Talk implements Thinking {
72
72
  this.player.whenFirstPlayed(() => {
73
73
  this.firstPlayed();
74
74
  });
75
+ this.player.whenFailed((reason) => {
76
+ if (!this.finished) this.ended(`audio playback failed: ${reason}`);
77
+ });
75
78
  this.brain = new Brain(brainModel(cfg), this);
76
79
  this.scribe = new Scribe(cfg.apiKey, languageOf(cfg), {
77
80
  partial: (text) => {
@@ -88,10 +91,11 @@ export class Talk implements Thinking {
88
91
  }
89
92
 
90
93
  prime(context: string): void {
91
- this.brain.tell(context);
94
+ if (!this.finished) this.brain.tell(context);
92
95
  }
93
96
 
94
97
  connect(): void {
98
+ if (this.finished || this.live) return;
95
99
  this.live = true;
96
100
  this.spare ??= this.utterance();
97
101
  this.player.start();
@@ -118,8 +122,8 @@ export class Talk implements Thinking {
118
122
  }
119
123
 
120
124
  turnStarted(): void {
121
- this.muted = false;
122
125
  if (this.finished) return;
126
+ this.muted = false;
123
127
  this.reply = {
124
128
  utterance: this.takeUtterance(),
125
129
  said: '',
@@ -137,19 +141,20 @@ export class Talk implements Thinking {
137
141
 
138
142
  text(delta: string): void {
139
143
  const reply = this.reply;
140
- if (this.muted || reply === null) return;
144
+ if (this.finished || this.muted || reply === null) return;
141
145
  reply.firstText ??= performance.now();
142
146
  this.speak(reply, reply.chunker.add(delta));
143
147
  }
144
148
 
145
149
  tool(name: string): void {
150
+ if (this.finished) return;
146
151
  log.info({ tool: name }, 'voice: the agent uses a tool during the call');
147
152
  if (this.reply !== null && !this.muted) this.speak(this.reply, this.reply.chunker.flush());
148
153
  }
149
154
 
150
155
  turnEnded(): void {
151
156
  const reply = this.reply;
152
- if (reply === null || this.muted) return;
157
+ if (this.finished || reply === null || this.muted) return;
153
158
  this.speak(reply, reply.chunker.flush());
154
159
  reply.utterance.end();
155
160
  log.info(
@@ -171,6 +176,7 @@ export class Talk implements Thinking {
171
176
  }
172
177
 
173
178
  exited(reason: string): void {
179
+ if (this.finished) return;
174
180
  log.warn({ reason }, 'voice: the agent session for the call ended');
175
181
  this.ended(`agent session ${reason}`);
176
182
  }
@@ -183,13 +189,13 @@ export class Talk implements Thinking {
183
189
  ready.attach({
184
190
  audio: (pcm, chars) => {
185
191
  const current = reply();
186
- if (current?.utterance !== ready) return;
192
+ if (this.finished || this.muted || current?.utterance !== ready) return;
187
193
  this.spoke = true;
188
194
  current.firstAudio ??= performance.now();
189
195
  this.player.push(pcm, chars);
190
196
  },
191
197
  done: () => {
192
- this.player.finish();
198
+ if (!this.finished && !this.muted && this.reply?.utterance === ready) this.player.finish();
193
199
  },
194
200
  });
195
201
  return ready;
@@ -239,11 +245,13 @@ export class Talk implements Thinking {
239
245
  }
240
246
 
241
247
  private partial(text: string): void {
248
+ if (this.finished) return;
242
249
  if (text !== '') this.lastHeardAt = performance.now();
243
250
  if (this.interrupts(text)) this.cutOff();
244
251
  }
245
252
 
246
253
  private committed(text: string): void {
254
+ if (this.finished) return;
247
255
  if (this.interrupts(text)) this.cutOff();
248
256
  const note = this.cut;
249
257
  const now = this.muted;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.264"
2
+ "version": "0.1.0-beta.265"
3
3
  }
@@ -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,5 @@
1
1
  {
2
- "version": "0.1.0-beta.264",
2
+ "version": "0.1.0-beta.265",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { log } from '@metro-labs/core/log';
6
- import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
6
+ import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerInput, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
7
7
  import { writeAtomic } from '@metro-labs/core/secure-fs';
8
8
  import { ActivityTasks, activityName } from './activity-tasks.js';
9
9
  import type { ApprovalAsk } from './approvals.js';
@@ -91,12 +91,24 @@ export class Activity {
91
91
  return {
92
92
  ...s, tools: [...s.tools], activeTools: s.activeTools.map((tool) => ({ ...tool })),
93
93
  tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map((event) => ({ ...event })),
94
+ ...(s.inputs === undefined ? {} : { inputs: s.inputs.map((input) => ({ ...input })) }),
94
95
  activeFailure: s.activeFailure ? { ...s.activeFailure } : null, lastFailure: s.lastFailure ? { ...s.lastFailure } : null,
95
96
  };
96
97
  }
97
98
 
98
- pending(count: number): void {
99
+ pending(count: number, oldest?: number | null): void {
99
100
  this.state.pending = count;
101
+ if (oldest !== undefined) this.state.queueOldestAt = oldest;
102
+ this.update();
103
+ }
104
+
105
+ input(input: RunnerInput): void {
106
+ this.state.inputs = [input, ...(this.state.inputs ?? []).filter((entry) => entry.id !== input.id)].slice(0, MAX_EVENTS);
107
+ this.update();
108
+ }
109
+
110
+ call(state: NonNullable<RunnerActivity['callState']>): void {
111
+ this.state.callState = state;
100
112
  this.update();
101
113
  }
102
114
 
@@ -7,7 +7,6 @@ import { MetroLink } from './link.js';
7
7
  import { Runner, runnerOptions } from './runner.js';
8
8
  import { SessionStore } from './session-store.js';
9
9
  import { claimRunner } from './singleton.js';
10
- import type { SpeechSink } from './speech.js';
11
10
  import { metroTools, type MetroTools } from './tool-proxy.js';
12
11
 
13
12
  export interface RunningAgent {
@@ -18,7 +17,6 @@ export interface RunningAgent {
18
17
  }
19
18
 
20
19
  export interface AppHooks {
21
- speech: SpeechSink;
22
20
  lost(reason: string): void;
23
21
  observe?: (message: SDKMessage) => void;
24
22
  env?: NodeJS.ProcessEnv;
@@ -29,9 +27,11 @@ export interface AppHooks {
29
27
  async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Promise<void>): Promise<RunningAgent> {
30
28
  const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
31
29
  let tools: MetroTools | null = null;
32
- const runner = new Runner({ store, sink: hooks.speech, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
30
+ const runner = new Runner({ store, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
33
31
  const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
34
32
  channel: (event) => { runner.chat(event); },
33
+ call: (notice) => { runner.calls.notice(notice); },
34
+ callState: (state) => { runner.calls.reconcile(state.route); },
35
35
  toolsChanged: () => { tools?.changed(); },
36
36
  model: (model) => {
37
37
  log.info({ model }, 'sdk-runner: the Model page picked a model');
@@ -54,7 +54,8 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
54
54
  tools = metroTools(link);
55
55
  const resume = store.resumable();
56
56
  log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
57
- runner.start(runnerOptions(cfg, tools, approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }), resume, hooks.env));
57
+ const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
58
+ runner.start(runnerOptions(cfg, tools, approvals, resume, hooks.env));
58
59
  return { runner, link, done: runner.run(hooks.observe), stop };
59
60
  } catch (err) {
60
61
  runner.close(false);
@@ -1,4 +1,5 @@
1
1
  import { approvalId } from '@metro-labs/core/ids';
2
+ import type { CallSource } from '@metro-labs/core/call';
2
3
  import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';
3
4
  import { log } from '@metro-labs/core/log';
4
5
  import type { Asker } from './link.js';
@@ -8,21 +9,29 @@ const DENIED = 'The owner did not approve this.';
8
9
  export interface ApprovalAsk { tool: string; worker: string | null }
9
10
  export type ApprovalWatch = (id: string, ask: ApprovalAsk | null) => void;
10
11
 
11
- export function approvalsThrough(asker: Asker, waiting: ApprovalWatch = () => undefined): CanUseTool {
12
+ export interface ApprovalBinding { call: CallSource; signal: AbortSignal }
13
+ export type BindApproval = (tool: string, input: Record<string, unknown>) => ApprovalBinding | null | undefined;
14
+
15
+ export function approvalsThrough(asker: Asker, waiting: ApprovalWatch = () => undefined, bind: BindApproval = () => undefined): CanUseTool {
12
16
  return async (tool, input, options) => {
17
+ const binding = bind(tool, input);
18
+ if (binding === null) return { behavior: 'deny', message: 'This speech target is no longer authorized for the active call.' };
19
+ const signal = binding === undefined ? options.signal : AbortSignal.any([options.signal, binding.signal]);
13
20
  const id = approvalId();
14
- log.info({ tool, id, worker: options.agentID ?? null }, 'sdk-runner: a tool waits for the owner’s approval');
15
- waiting(id, { tool, worker: options.agentID ?? null });
21
+ const worker = options.agentID ?? null;
22
+ log.info({ tool, id, worker }, 'sdk-runner: a tool waits for the owner’s approval');
23
+ waiting(id, { tool, worker });
16
24
  const behavior = await asker.ask(
17
25
  {
18
26
  request_id: id,
19
27
  tool_name: tool,
20
28
  description: options.description ?? options.title ?? options.decisionReason ?? '',
21
29
  input_preview: JSON.stringify(input),
30
+ ...(binding === undefined ? {} : { call: binding.call }),
22
31
  },
23
- options.signal,
32
+ signal,
24
33
  ).finally(() => { waiting(id, null); });
25
34
  log.info({ tool, id, behavior }, 'sdk-runner: the owner answered');
26
- return behavior === 'allow' ? { behavior, updatedInput: input } : { behavior, message: DENIED };
35
+ return behavior === 'allow' && !signal.aborted ? { behavior, updatedInput: input } : { behavior: 'deny', message: DENIED };
27
36
  };
28
37
  }
@@ -0,0 +1,133 @@
1
+ import { sameCall, speechTarget, type CallNotice, type CallRoute, type CallSource } from '@metro-labs/core/call';
2
+ import { log } from '@metro-labs/core/log';
3
+ import type { Activity } from './activity.js';
4
+ import type { ApprovalBinding } from './approvals.js';
5
+ import type { ChannelEvent } from './channel-text.js';
6
+ import type { Inbox } from './inbox.js';
7
+ import { callEnded, callStarted, callWords, speechFailed } from './rules.js';
8
+
9
+ const GENERATIONS_MAX = 512;
10
+ const SOURCES_MAX = 2_048;
11
+ interface ActiveCall {
12
+ route: CallRoute;
13
+ sources: Set<string>;
14
+ failures: Set<string>;
15
+ inputs: string[];
16
+ controller: AbortController;
17
+ }
18
+
19
+ const routeKey = (route: CallRoute): string => JSON.stringify([route.agentId, route.line, route.from, route.callId, route.generation]);
20
+
21
+ export class RunnerCalls {
22
+ private active: ActiveCall | null = null;
23
+ private readonly seen = new Set<string>();
24
+
25
+ constructor(private readonly inbox: Inbox, private readonly activity?: Activity) {}
26
+
27
+ get live(): boolean { return this.active !== null; }
28
+
29
+ notice(notice: CallNotice): void {
30
+ if (notice.type === 'started') this.start(notice);
31
+ else if (this.active !== null && sameCall(notice.route, this.active.route)) {
32
+ if (notice.type === 'ended') this.end();
33
+ else if (notice.type === 'heard') this.heard(notice);
34
+ else this.speech(notice);
35
+ }
36
+ }
37
+
38
+ reconcile(route: CallRoute | null): void {
39
+ if (route !== null && this.active !== null && sameCall(route, this.active.route)) return;
40
+ this.end();
41
+ if (route !== null) this.activate(route);
42
+ }
43
+
44
+ chat(event: ChannelEvent): void {
45
+ const active = this.active;
46
+ if (active === null) return;
47
+ const { meta } = event;
48
+ if (meta.line !== active.route.line || meta.from !== active.route.from || meta.call_id !== active.route.callId || meta.call_generation !== active.route.generation) return;
49
+ if (typeof meta.call_source_id !== 'string' || meta.call_source_id.length === 0 || meta.call_source_id.length > 2048) return;
50
+ if (active.sources.size < SOURCES_MAX) active.sources.add(meta.call_source_id);
51
+ }
52
+
53
+ approval(tool: string, input: Record<string, unknown>): ApprovalBinding | null | undefined {
54
+ if (tool !== 'mcp__metro__send' || input.speech === undefined) return undefined;
55
+ const target = speechTarget(input.speech);
56
+ const active = this.active;
57
+ if (active === null || target === null || input.line !== active.route.line || target.callId !== active.route.callId || target.generation !== active.route.generation || !active.sources.has(target.sourceId)) return null;
58
+ return { call: { route: active.route, sourceId: target.sourceId }, signal: active.controller.signal };
59
+ }
60
+
61
+ close(): void {
62
+ this.active?.controller.abort();
63
+ this.active = null;
64
+ }
65
+
66
+ private activate(route: CallRoute): boolean {
67
+ const key = routeKey(route);
68
+ if (this.seen.has(key)) return false;
69
+ this.seen.add(key);
70
+ if (this.seen.size > GENERATIONS_MAX) {
71
+ const oldest = this.seen.values().next().value;
72
+ if (oldest !== undefined) this.seen.delete(oldest);
73
+ }
74
+ this.end();
75
+ this.active = { route, sources: new Set(), failures: new Set(), inputs: [], controller: new AbortController() };
76
+ this.activity?.call('started');
77
+ return true;
78
+ }
79
+
80
+ private start(notice: Extract<CallNotice, { type: 'started' }>): void {
81
+ if (this.activate(notice.route)) this.push(notice, callStarted(notice));
82
+ }
83
+
84
+ private heard(notice: Extract<CallNotice, { type: 'heard' }>): void {
85
+ if (notice.text.trim() === '') return;
86
+ this.push(notice, callWords(notice, notice.text));
87
+ }
88
+
89
+ private speech(notice: Extract<CallNotice, { type: 'speech' }>): void {
90
+ this.activity?.call(notice.status === 'started' ? 'speaking' : notice.status);
91
+ const active = this.active;
92
+ if (notice.status !== 'failed' || active === null || !active.sources.has(notice.sourceId)) return;
93
+ if (active.failures.has(notice.actionId) || active.failures.size >= SOURCES_MAX) return;
94
+ active.failures.add(notice.actionId);
95
+ try {
96
+ this.inbox.push('note', speechFailed(active.route));
97
+ } catch {
98
+ log.warn('sdk-runner: speech failure context could not be queued');
99
+ }
100
+ }
101
+
102
+ private push(source: CallSource, text: string): void {
103
+ const active = this.active;
104
+ if (active === null || active.sources.has(source.sourceId) || active.sources.size >= SOURCES_MAX) return;
105
+ active.sources.add(source.sourceId);
106
+ this.enqueue(text);
107
+ }
108
+
109
+ private enqueue(text: string): void {
110
+ const active = this.active;
111
+ if (active === null) return;
112
+ try {
113
+ active.inputs.push(this.inbox.push('call', text));
114
+ } catch {
115
+ this.activity?.call('failed');
116
+ log.warn('sdk-runner: call input queue refused a message');
117
+ }
118
+ }
119
+
120
+ private end(): void {
121
+ const active = this.active;
122
+ if (active === null) return;
123
+ this.active = null;
124
+ active.controller.abort();
125
+ this.inbox.cancel(active.inputs);
126
+ this.activity?.call('ended');
127
+ try {
128
+ this.inbox.push('note', callEnded(active.route));
129
+ } catch {
130
+ log.warn('sdk-runner: call end context could not be queued');
131
+ }
132
+ }
133
+ }