@stage-labs/metro 0.1.0-beta.263 → 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 (76) hide show
  1. package/dist/cli.js +2 -2
  2. package/dist/runtime-install.js +22 -5
  3. package/package.json +1 -1
  4. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  5. package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
  6. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  7. package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
  8. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  9. package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
  11. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
  13. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
  27. package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
  28. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
  29. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
  30. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
  32. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
  33. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
  34. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
  35. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
  36. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
  38. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
  39. package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
  40. package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
  41. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
  42. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
  43. package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
  44. package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
  45. package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
  46. package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
  47. package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
  48. package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
  49. package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
  50. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
  51. package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
  52. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
  53. package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
  54. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  55. package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
  56. package/runtime/runtime.json +1 -1
  57. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  58. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
  59. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  60. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  61. package/runtime/sdk-runner/runner.json +1 -1
  62. package/runtime/sdk-runner/src/activity.ts +14 -2
  63. package/runtime/sdk-runner/src/app.ts +5 -4
  64. package/runtime/sdk-runner/src/approvals.ts +14 -5
  65. package/runtime/sdk-runner/src/calls.ts +133 -0
  66. package/runtime/sdk-runner/src/inbox.ts +58 -26
  67. package/runtime/sdk-runner/src/input-queue.ts +70 -0
  68. package/runtime/sdk-runner/src/input-timing.ts +68 -0
  69. package/runtime/sdk-runner/src/link.ts +27 -4
  70. package/runtime/sdk-runner/src/main.ts +0 -4
  71. package/runtime/sdk-runner/src/rules.ts +19 -5
  72. package/runtime/sdk-runner/src/runner.ts +64 -88
  73. package/runtime/sdk-runner/src/session-store.ts +14 -1
  74. package/runtime/sdk-runner/src/session-watch.ts +27 -20
  75. package/runtime/stations.json +3 -0
  76. package/runtime/sdk-runner/src/speech.ts +0 -79
@@ -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),
@@ -13,9 +13,9 @@ type ChannelTarget = Extract<PolicyTarget, { kind: 'channel' }>;
13
13
 
14
14
  export const UNGATED = new Set<string>(['list_accounts', 'create_upload']);
15
15
 
16
- function fromLine(line: string, override: string | undefined): ChannelTarget[] {
16
+ function fromLine(line: string, override = ''): ChannelTarget[] {
17
17
  const acct = accountFromLine(line);
18
- return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override ?? acct.accountId }];
18
+ return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override || acct.accountId }];
19
19
  }
20
20
 
21
21
  function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
@@ -26,7 +26,7 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
26
26
  }
27
27
  const account = str(a.account);
28
28
  const line = str(a.line);
29
- if (line) return fromLine(line, account || undefined);
29
+ if (line) return fromLine(line, name === 'list_members' ? undefined : account);
30
30
  const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
31
31
  if (station === undefined) return [];
32
32
  if (account) return [{ kind: 'channel', station, account }];
@@ -12,6 +12,8 @@ import { BoundedEventStore } from './event-store.js';
12
12
  import { registerPermissionRelay } from './permission-relay.js';
13
13
  import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
14
14
  import { web, type RawGetSink } from './raw-get-stream.js';
15
+ import { sharedCalls } from '../voice/shared.js';
16
+ import { CALL_STATE } from '@metro-labs/core/call';
15
17
 
16
18
  export const channelLog = (...a: unknown[]): void => {
17
19
  console.error('[metro-mcp]', ...a);
@@ -76,9 +78,11 @@ export class McpSession {
76
78
  private issuedSchema: string | undefined;
77
79
  private announcing = false;
78
80
  private readonly onClosed: (session: McpSession) => void;
81
+ private readonly kind: SessionKind;
79
82
 
80
83
  private constructor(init: SessionInit) {
81
84
  this.id = init.id;
85
+ this.kind = init.kind ?? 'chat';
82
86
  this.scope = init.scope;
83
87
  this.live = init.live;
84
88
  this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
@@ -112,6 +116,7 @@ export class McpSession {
112
116
  senderAllowed,
113
117
  approves,
114
118
  answerPermission,
119
+ callMeta: (event) => voice ? () => ({}) : sharedCalls.bindChat(event, this.scope),
115
120
  });
116
121
  if (!voice)
117
122
  registerPermissionRelay({
@@ -167,7 +172,20 @@ export class McpSession {
167
172
 
168
173
  bindSink(sink: RawGetSink | undefined): void {
169
174
  this.sink = sink;
170
- if (sink !== undefined) this.announceToolSchema();
175
+ if (sink !== undefined) {
176
+ this.announceCallState();
177
+ this.announceToolSchema();
178
+ }
179
+ }
180
+
181
+ private announceCallState(): void {
182
+ if (this.kind !== 'chat' || !this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
183
+ const active = sharedCalls.snapshot();
184
+ const route = active !== null && this.scope.has(active.agentId) && eventInScope(this.scope, active.line) ? active : null;
185
+ this.server.notification({ method: CALL_STATE, params: { route, ...(route === null ? {} : { meta: { line: route.line } }) } }).catch((err: unknown) => {
186
+ channelLog('session: call state notification failed', errMsg(err));
187
+ if (route !== null) sharedCalls.end(route);
188
+ });
171
189
  }
172
190
 
173
191
  private get schemaNoticeDue(): boolean {
@@ -206,6 +224,7 @@ export class McpSession {
206
224
  async close(): Promise<void> {
207
225
  if (this.closed) return;
208
226
  this.closed = true;
227
+ if (this.kind === 'chat') sharedCalls.revoke();
209
228
  this.onClosed(this);
210
229
  forgetPromptsOf(this.server);
211
230
  this.dropStream();
@@ -0,0 +1,11 @@
1
+ export const speechSchema = {
2
+ type: 'object',
3
+ description: 'Speak text only in the exact active Stage call, without posting it in chat. Copy callId, generation and sourceId from its authenticated call input or call_id, call_generation and call_source_id from the requesting channel message. Use only for a call reply or an explicit owner request to speak in that call. Normal chat and worker results stay silent. One action per source; retries do not repeat speech. Accepted or queued is not heard. Completed means audio transport drained, not proof the caller heard it.',
4
+ properties: {
5
+ callId: { type: 'string' },
6
+ generation: { type: 'string' },
7
+ sourceId: { type: 'string' },
8
+ },
9
+ required: ['callId', 'generation', 'sourceId'],
10
+ additionalProperties: false,
11
+ } as const;
@@ -0,0 +1,15 @@
1
+ import { speechTarget } from '@metro-labs/core/call';
2
+ import type { ToolResult } from '@metro-labs/core/stations/types';
3
+ import { sharedCalls } from '../voice/shared.js';
4
+ import { allowedAgents, currentIdentity } from './request-identity.js';
5
+ import { errResult, okJson } from './ctx.js';
6
+
7
+ export function sendSpeech(line: string, args: Record<string, unknown>): ToolResult {
8
+ const target = speechTarget(args.speech);
9
+ if (target === null) return errResult('Call speech needs callId, generation and sourceId from its authenticated source.');
10
+ if (['reply_to', 'subject', 'attachments', 'frame', 'wallet', 'account'].some((key) => args[key] !== undefined))
11
+ return errResult('Call speech cannot include chat content, media or an account override. Nothing was sent.');
12
+ if (typeof args.text !== 'string') return errResult('Call speech needs text.');
13
+ const receipt = sharedCalls.speak(line, target, args.text, allowedAgents(currentIdentity()));
14
+ return okJson({ speech: receipt, delivery: 'Audio transport status only. No proof the caller heard it. No chat message was posted.' });
15
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolDef } from './tool-def.js';
2
2
  import { attachmentItem, CREATE_UPLOAD_TOOL } from './attachment-schema.js';
3
+ import { speechSchema } from './speech-schema.js';
3
4
 
4
5
  const lineProp = {
5
6
  type: 'string',
@@ -34,7 +35,7 @@ export const COMMON_TOOLS: ToolDef[] = [
34
35
  group: 'write',
35
36
  description:
36
37
  'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
37
- 'subject?, attachments?, frame?, wallet?. The station is derived from the line. Outlook and Gmail: send to ' +
38
+ 'subject?, attachments?, frame?, wallet?, speech?. `speech` speaks text only in an authorized active call, never posts it in chat. The station is derived from the line. Outlook and Gmail: send to ' +
38
39
  'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
39
40
  'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
40
41
  'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
@@ -54,6 +55,7 @@ export const COMMON_TOOLS: ToolDef[] = [
54
55
  type: 'string',
55
56
  description: 'The message text (optional if sending only media).',
56
57
  },
58
+ speech: speechSchema,
57
59
  reply_to: {
58
60
  type: 'string',
59
61
  description: 'Optional message_id to quote/reply to.',
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -11,6 +11,7 @@ const INSTALL_TIMEOUT_MS = 15 * 60_000;
11
11
  export interface RuntimeManifest {
12
12
  core: Record<string, string>;
13
13
  stations: Record<string, Record<string, string>>;
14
+ patchedDependencies?: Record<string, string>;
14
15
  }
15
16
 
16
17
  export interface RuntimeStore {
@@ -30,6 +31,7 @@ export function readManifest(path: string): RuntimeManifest {
30
31
  return {
31
32
  core: ranges(raw.core),
32
33
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
34
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
33
35
  };
34
36
  }
35
37
 
@@ -46,8 +48,21 @@ export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<st
46
48
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
47
49
  }
48
50
 
49
- const packageText = (deps: Record<string, string>): string =>
50
- `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
51
+ function packageText(deps: Record<string, string>, patches: Record<string, string>): string {
52
+ const patchedDependencies = Object.fromEntries(
53
+ Object.entries(deps).flatMap(([name, version]) => {
54
+ const key = `${name}@${version}`;
55
+ const path = patches[key];
56
+ return path === undefined ? [] : [[key, path]];
57
+ }),
58
+ );
59
+ return `${JSON.stringify({
60
+ name: 'metro-runtime',
61
+ private: true,
62
+ dependencies: deps,
63
+ ...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
64
+ }, null, 2)}\n`;
65
+ }
51
66
 
52
67
  function current(dir: string): string | null {
53
68
  try {
@@ -58,9 +73,10 @@ function current(dir: string): string | null {
58
73
  }
59
74
 
60
75
  export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
61
- const wanted = packageText(dependenciesFor(store.manifest, stations));
62
- const installed = existsSync(join(store.dir, 'node_modules', '.metro-installed'));
63
- if (installed && current(store.dir) === wanted) return false;
76
+ const wanted = packageText(dependenciesFor(store.manifest, stations), store.manifest.patchedDependencies ?? {});
77
+ const marker = join(store.dir, 'node_modules', '.metro-installed');
78
+ if (existsSync(marker) && current(store.dir) === wanted) return false;
79
+ rmSync(marker, { force: true });
64
80
  mkdirSync(store.dir, { recursive: true });
65
81
  writeFileSync(join(store.dir, 'package.json'), wanted);
66
82
  log.info({ dir: store.dir }, 'runtime: installing the channel SDKs this machine needs');
@@ -71,7 +87,7 @@ export function installRuntime(store: RuntimeStore, stations: Iterable<string>):
71
87
  });
72
88
  if (run.error !== undefined || run.status !== 0)
73
89
  throw new Error(`bun install failed in ${store.dir}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
74
- writeFileSync(join(store.dir, 'node_modules', '.metro-installed'), `${new Date().toISOString()}\n`);
90
+ writeFileSync(marker, `${new Date().toISOString()}\n`);
75
91
  return true;
76
92
  }
77
93
 
@@ -5,6 +5,7 @@ const RATE = 48_000;
5
5
  const FRAME = 960;
6
6
  const FRAME_MS = 20;
7
7
  const MAX_LATE_FRAMES = 5;
8
+ const MAX_QUEUED_FRAMES = 3_000;
8
9
 
9
10
  interface Frame {
10
11
  pcm: Int16Array;
@@ -55,7 +56,7 @@ export class OpusIn {
55
56
  }
56
57
 
57
58
  export interface Playback {
58
- send(opus: Buffer, timestamp: number, marker: boolean): void;
59
+ send(opus: Buffer, timestamp: number, marker: boolean): void | Promise<void>;
59
60
  played(chars: number): void;
60
61
  }
61
62
 
@@ -70,11 +71,15 @@ export class Player {
70
71
  private speaking = false;
71
72
  private onIdle: (() => void) | null = null;
72
73
  private onFirst: (() => void) | null = null;
74
+ private onFailed: ((reason: string) => void) | null = null;
75
+ private generation = 0;
76
+ private inFlight = 0;
77
+ private closed = false;
73
78
 
74
79
  constructor(private readonly out: Playback) {}
75
80
 
76
81
  get busy(): boolean {
77
- return this.queue.length > 0 || this.pending.length > 0;
82
+ return this.queue.length > 0 || this.pending.length > 0 || this.inFlight > 0;
78
83
  }
79
84
 
80
85
  whenFirstPlayed(fn: () => void): void {
@@ -85,7 +90,16 @@ export class Player {
85
90
  this.onIdle = fn;
86
91
  }
87
92
 
93
+ whenFailed(fn: (reason: string) => void): void {
94
+ this.onFailed = fn;
95
+ }
96
+
88
97
  push(pcm: Int16Array, chars: number): void {
98
+ if (this.closed) return;
99
+ if (this.queue.length + this.inFlight + Math.ceil((this.pending.length + pcm.length) / FRAME) > MAX_QUEUED_FRAMES) {
100
+ this.failed('audio queue is full');
101
+ return;
102
+ }
89
103
  const joined = new Int16Array(this.pending.length + pcm.length);
90
104
  joined.set(this.pending);
91
105
  joined.set(pcm, this.pending.length);
@@ -101,7 +115,7 @@ export class Player {
101
115
  }
102
116
 
103
117
  finish(): void {
104
- if (this.pending.length === 0) return;
118
+ if (this.closed || this.pending.length === 0) return;
105
119
  const pcm = new Int16Array(FRAME);
106
120
  pcm.set(this.pending);
107
121
  this.queue.push({ pcm, chars: this.pendingChars });
@@ -110,28 +124,36 @@ export class Player {
110
124
  }
111
125
 
112
126
  clear(): void {
127
+ this.generation += 1;
128
+ this.inFlight = 0;
129
+ this.speaking = false;
113
130
  this.queue.length = 0;
114
131
  this.pending = new Int16Array(0);
115
132
  this.pendingChars = 0;
116
133
  }
117
134
 
118
135
  start(): void {
119
- if (this.timer !== null) return;
136
+ if (this.closed || this.timer !== null) return;
120
137
  this.started = performance.now();
121
138
  this.sent = 0;
122
139
  this.tick();
123
140
  }
124
141
 
125
142
  stop(): void {
143
+ if (this.closed) return;
144
+ this.closed = true;
126
145
  if (this.timer !== null) clearTimeout(this.timer);
127
146
  this.timer = null;
147
+ this.clear();
128
148
  this.encoder.delete();
129
149
  }
130
150
 
131
151
  private tick(): void {
152
+ if (this.closed) return;
132
153
  const due = Math.floor((performance.now() - this.started) / FRAME_MS) + 1;
133
154
  if (due - this.sent > MAX_LATE_FRAMES) this.sent = due - 1;
134
- while (this.sent < due) this.sendOne();
155
+ while (!this.closed && this.sent < due) this.sendOne();
156
+ if (this.closed) return;
135
157
  const next = this.started + this.sent * FRAME_MS;
136
158
  this.timer = setTimeout(() => {
137
159
  this.tick();
@@ -143,11 +165,29 @@ export class Player {
143
165
  const marker = frame !== undefined && !this.speaking;
144
166
  this.speaking = frame !== undefined;
145
167
  const pcm = frame?.pcm ?? new Int16Array(FRAME);
146
- this.out.send(this.encoder.encode(bytesOf(pcm), FRAME), (this.sent * FRAME) >>> 0, marker);
168
+ const timestamp = (this.sent * FRAME) >>> 0;
147
169
  this.sent += 1;
148
- if (frame === undefined) return;
149
- if (marker) this.onFirst?.();
150
- if (frame.chars > 0) this.out.played(frame.chars);
151
- if (!this.busy) this.onIdle?.();
170
+ const generation = this.generation;
171
+ if (frame !== undefined) this.inFlight += 1;
172
+ try {
173
+ const sent = this.out.send(this.encoder.encode(bytesOf(pcm), FRAME), timestamp, marker);
174
+ Promise.resolve(sent).then(() => {
175
+ if (this.closed || generation !== this.generation || frame === undefined) return;
176
+ this.inFlight -= 1;
177
+ if (marker) this.onFirst?.();
178
+ if (frame.chars > 0) this.out.played(frame.chars);
179
+ if (!this.busy) this.onIdle?.();
180
+ }).catch((err: unknown) => {
181
+ if (!this.closed && generation === this.generation) this.failed(errMsg(err));
182
+ });
183
+ } catch (err) {
184
+ if (!this.closed && generation === this.generation) this.failed(errMsg(err));
185
+ }
186
+ }
187
+
188
+ private failed(reason: string): void {
189
+ this.clear();
190
+ log.warn({ reason }, 'voice: audio playback failed');
191
+ this.onFailed?.(reason);
152
192
  }
153
193
  }
@@ -50,15 +50,21 @@ function streamEvent(msg: Record<string, unknown>, mind: Thinking): void {
50
50
  if (event.type === 'content_block_start' && block.type === 'tool_use') mind.tool(typeof block.name === 'string' ? block.name : 'tool');
51
51
  }
52
52
 
53
+ function startBrain(model: string): ChildProcessWithoutNullStreams {
54
+ const [command = 'metro', ...args] = metroCli(['voice', '--model', model]);
55
+ const [file, argv] = asAgent(command, args);
56
+ return spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
57
+ }
58
+
53
59
  export class Brain {
54
60
  private readonly child: ChildProcessWithoutNullStreams;
55
61
  private out = '';
56
62
  private gone = false;
63
+ private closing = false;
64
+ private killTimer: ReturnType<typeof setTimeout> | undefined;
57
65
 
58
- constructor(model: string, private readonly mind: Thinking) {
59
- const [command = 'metro', ...args] = metroCli(['voice', '--model', model]);
60
- const [file, argv] = asAgent(command, args);
61
- this.child = spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
66
+ constructor(model: string, private readonly mind: Thinking, launch = startBrain) {
67
+ this.child = launch(model);
62
68
  this.child.stdout.setEncoding('utf8');
63
69
  this.child.stdout.on('data', (chunk: string) => {
64
70
  this.out = drainLines('voice-brain', this.out + chunk, (line) => {
@@ -91,18 +97,22 @@ export class Brain {
91
97
  }
92
98
 
93
99
  close(): void {
94
- if (this.gone) return;
100
+ if (this.gone || this.closing) return;
101
+ this.closing = true;
95
102
  this.child.stdin.end();
96
- setTimeout(() => {
103
+ this.child.kill('SIGTERM');
104
+ this.killTimer = setTimeout(() => {
97
105
  if (!this.gone) this.child.kill('SIGKILL');
98
- }, CLOSE_GRACE_MS).unref();
106
+ }, CLOSE_GRACE_MS);
107
+ this.killTimer.unref();
99
108
  }
100
109
 
101
110
  private write(message: Record<string, unknown>): void {
102
- if (!this.gone && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
111
+ if (!this.gone && !this.closing && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
103
112
  }
104
113
 
105
114
  private read(line: string): void {
115
+ if (this.closing || this.gone) return;
106
116
  let parsed: unknown;
107
117
  try {
108
118
  parsed = JSON.parse(line);
@@ -130,6 +140,7 @@ export class Brain {
130
140
  private exit(reason: string): void {
131
141
  if (this.gone) return;
132
142
  this.gone = true;
133
- this.mind.exited(reason);
143
+ clearTimeout(this.killTimer);
144
+ if (!this.closing) this.mind.exited(reason);
134
145
  }
135
146
  }