@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
@@ -1,4 +1,4 @@
1
- import { errMsg } from '../log.js';
1
+ import { errMsg, logFatalSync } from '../log.js';
2
2
  import { readCalls } from '../protocol.js';
3
3
  import { TrainError } from '../train-error.js';
4
4
  import type { CallMsg } from './station-runtime.js';
@@ -18,12 +18,33 @@ export interface ClientTrain<A extends { id: string }, C> {
18
18
  loadAccounts: () => A[];
19
19
  createClient: (account: A) => C;
20
20
  startInbound: (client: C) => Promise<void>;
21
+ disconnectClient?: (client: C) => Promise<void>;
21
22
  makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
22
23
  }
23
24
 
24
25
  export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
25
26
  const clients = new Map<string, C>();
27
+ let stopping = false;
28
+ if (train.disconnectClient) {
29
+ const disconnect = train.disconnectClient;
30
+ const shutdown = (): void => {
31
+ if (stopping) return;
32
+ stopping = true;
33
+ process.stdin.pause();
34
+ Promise.allSettled([...clients.values()].map(async (client) => { await disconnect(client); })).then((results) => {
35
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [errMsg(result.reason)] : []);
36
+ if (errors.length) logFatalSync({ station: train.station, errors }, 'train: shutdown failed');
37
+ process.exit(errors.length ? 1 : 0);
38
+ }).catch((err: unknown) => {
39
+ logFatalSync({ station: train.station, err: errMsg(err) }, 'train: shutdown failed');
40
+ process.exit(1);
41
+ });
42
+ };
43
+ process.on('SIGTERM', shutdown);
44
+ process.on('SIGINT', shutdown);
45
+ }
26
46
  const clientFor = (accountId: string): C => {
47
+ if (stopping) throw new TrainError('train_stopping', `${train.station} is shutting down`);
27
48
  const known = clients.get(accountId);
28
49
  if (known !== undefined) return known;
29
50
  const account = train.accounts.get(accountId);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.263",
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
+ }
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
3
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
4
+ import { InputQueue } from './input-queue.js';
5
+ import { InputTiming, type InputWatch } from './input-timing.js';
3
6
 
4
7
  export type Uuid = ReturnType<typeof randomUUID>;
5
- export type InputKind = 'chat' | 'call' | 'note';
8
+ export type InputKind = RunnerInput['kind'];
6
9
  export type Priority = 'now' | 'next' | 'later';
7
10
 
8
11
  export interface Unanswered {
@@ -12,32 +15,49 @@ export interface Unanswered {
12
15
  state?: 'queued' | 'started';
13
16
  }
14
17
 
18
+ interface InboxHooks {
19
+ ready?(): boolean;
20
+ dispatch?(): void;
21
+ queue?(count: number, oldest: number | null): void;
22
+ input?: InputWatch;
23
+ }
24
+
15
25
  const KINDS_MAX = 2_000;
16
26
  const LEDGER_MAX = 100;
17
27
  const LEDGER_AGE_MS = 6 * 60 * 60_000;
18
28
 
19
29
  export class Inbox implements AsyncIterable<SDKUserMessage> {
20
- private readonly queue: SDKUserMessage[] = [];
30
+ private readonly queue = new InputQueue();
21
31
  private readonly kinds = new Map<string, InputKind>();
22
32
  private readonly ledger = new Map<string, Unanswered>();
33
+ private readonly timing: InputTiming;
23
34
  private wake: (() => void) | null = null;
24
35
  private closed = false;
25
36
  private restoring = false;
26
37
 
27
- constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined) {}
38
+ constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined, private readonly hooks: InboxHooks = {}) {
39
+ this.timing = new InputTiming(hooks.input ?? (() => undefined));
40
+ }
28
41
 
29
- push(kind: InputKind, text: string, priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
30
- this.mark(uuid, kind);
42
+ get pending(): number { return this.queue.size; }
43
+
44
+ compact(): Uuid {
45
+ const uuid = this.push('note', '/compact');
46
+ this.queue.prefer(uuid);
47
+ return uuid;
48
+ }
49
+
50
+ push(kind: InputKind, text: string, _priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
51
+ if (this.closed) throw new Error('The SDK input queue is closed.');
52
+ this.queue.check(kind, text);
31
53
  if (kind === 'chat') this.remember(uuid, text, at);
32
- this.queue.push({
33
- type: 'user',
34
- message: { role: 'user', content: text },
35
- parent_tool_use_id: null,
36
- uuid,
37
- origin: { kind: 'channel', server: 'metro' },
38
- ...(priority === undefined ? {} : { priority }),
39
- });
40
- this.wake?.();
54
+ this.mark(uuid, kind);
55
+ this.queue.push(kind, {
56
+ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null,
57
+ uuid, origin: { kind: 'channel', server: 'metro' },
58
+ }, text, at);
59
+ this.timing.accept(uuid, kind, at);
60
+ this.notify();
41
61
  return uuid;
42
62
  }
43
63
 
@@ -48,9 +68,7 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
48
68
  if (oldest !== undefined) this.kinds.delete(oldest);
49
69
  }
50
70
 
51
- kindOf(uuid: string): InputKind | undefined {
52
- return this.kinds.get(uuid);
53
- }
71
+ kindOf(uuid: string): InputKind | undefined { return this.kinds.get(uuid); }
54
72
 
55
73
  started(uuids: readonly string[]): void {
56
74
  let changed = false;
@@ -61,12 +79,24 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
61
79
  changed = true;
62
80
  }
63
81
  if (changed) this.changed(this.unanswered());
82
+ this.timing.consume(uuids);
64
83
  }
65
84
 
85
+ output(): void { this.timing.output(); }
86
+ boundary(): void { this.timing.boundary(); }
87
+
66
88
  finished(uuids = this.startedUuids()): void {
67
89
  let changed = false;
68
90
  for (const uuid of uuids) changed = this.ledger.delete(uuid) || changed;
69
91
  if (changed) this.changed(this.unanswered());
92
+ this.timing.finish(uuids);
93
+ }
94
+
95
+ cancel(uuids: readonly string[]): void {
96
+ const ephemeral = uuids.filter((uuid) => this.kindOf(uuid) !== 'chat');
97
+ this.queue.cancel(new Set(ephemeral));
98
+ this.timing.finish(ephemeral, true);
99
+ this.notify();
70
100
  }
71
101
 
72
102
  startedUuids(): string[] {
@@ -82,16 +112,22 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
82
112
  this.wake?.();
83
113
  }
84
114
 
115
+ notify(): void {
116
+ this.hooks.queue?.(this.queue.size, this.queue.oldest);
117
+ this.wake?.();
118
+ }
119
+
85
120
  async *[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
86
121
  while (!this.closed) {
87
- const next = this.queue.shift();
122
+ const next = (this.hooks.ready?.() ?? true) ? this.queue.take() : undefined;
88
123
  if (next !== undefined) {
124
+ this.hooks.dispatch?.();
125
+ if (next.uuid !== undefined) this.timing.dispatch(next.uuid);
126
+ this.notify();
89
127
  yield next;
90
128
  continue;
91
129
  }
92
- await new Promise<void>((resolve) => {
93
- this.wake = resolve;
94
- });
130
+ await new Promise<void>((resolve) => { this.wake = resolve; });
95
131
  this.wake = null;
96
132
  }
97
133
  }
@@ -108,12 +144,8 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
108
144
  }
109
145
 
110
146
  private remember(uuid: Uuid, text: string, at: number): void {
147
+ if (!this.ledger.has(uuid) && this.ledger.size >= LEDGER_MAX) throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
111
148
  this.ledger.set(uuid, { text, at, uuid, state: 'queued' });
112
- while (this.ledger.size > LEDGER_MAX) {
113
- const oldest = [...this.ledger].find(([, entry]) => entry.state !== 'started')?.[0];
114
- if (oldest === undefined) break;
115
- this.ledger.delete(oldest);
116
- }
117
149
  if (!this.restoring) this.changed(this.unanswered());
118
150
  }
119
151
  }
@@ -0,0 +1,70 @@
1
+ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
3
+
4
+ type InputKind = RunnerInput['kind'];
5
+
6
+ const LANES: readonly InputKind[] = ['chat', 'call', 'note'];
7
+ const CAPS: Record<InputKind, number> = { chat: 100, call: 32, note: 16 };
8
+ const BYTES_MAX = 4 * 1024 * 1024;
9
+ const INPUT_MAX = 256 * 1024;
10
+
11
+ interface Queued {
12
+ kind: InputKind;
13
+ message: SDKUserMessage;
14
+ bytes: number;
15
+ at: number;
16
+ }
17
+
18
+ export class InputQueue {
19
+ private readonly items: Queued[] = [];
20
+ private bytes = 0;
21
+ private lane = 0;
22
+ private first: string | null = null;
23
+
24
+ prefer(uuid: string): void { this.first = uuid; }
25
+
26
+ get size(): number { return this.items.length; }
27
+ get oldest(): number | null { return this.items.reduce<number | null>((at, item) => at === null ? item.at : Math.min(at, item.at), null); }
28
+
29
+ check(kind: InputKind, text: string): void {
30
+ const bytes = Buffer.byteLength(text);
31
+ if (bytes > INPUT_MAX || this.bytes + bytes > BYTES_MAX || this.items.filter((item) => item.kind === kind).length >= CAPS[kind]) {
32
+ throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
33
+ }
34
+ }
35
+
36
+ push(kind: InputKind, message: SDKUserMessage, text: string, at: number): void {
37
+ const bytes = Buffer.byteLength(text);
38
+ this.items.push({ kind, message, bytes, at });
39
+ this.bytes += bytes;
40
+ }
41
+
42
+ take(): SDKUserMessage | undefined {
43
+ if (this.first !== null) {
44
+ const index = this.items.findIndex((item) => item.message.uuid === this.first);
45
+ this.first = null;
46
+ if (index >= 0) return this.remove(index)?.message;
47
+ }
48
+ for (let i = 0; i < LANES.length; i += 1) {
49
+ const lane = (this.lane + i) % LANES.length;
50
+ const index = this.items.findIndex((item) => item.kind === LANES[lane]);
51
+ if (index < 0) continue;
52
+ this.lane = (lane + 1) % LANES.length;
53
+ return this.remove(index)?.message;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ cancel(uuids: ReadonlySet<string>): void {
59
+ for (let index = this.items.length - 1; index >= 0; index -= 1) {
60
+ const uuid = this.items[index]?.message.uuid;
61
+ if (uuid !== undefined && uuids.has(uuid)) this.remove(index);
62
+ }
63
+ }
64
+
65
+ private remove(index: number): Queued | undefined {
66
+ const [item] = this.items.splice(index, 1);
67
+ if (item !== undefined) this.bytes -= item.bytes;
68
+ return item;
69
+ }
70
+ }
@@ -0,0 +1,68 @@
1
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
2
+ import { log } from '@metro-labs/core/log';
3
+
4
+ export type InputWatch = (input: RunnerInput) => void;
5
+
6
+ export class InputTiming {
7
+ private readonly inputs = new Map<string, RunnerInput>();
8
+ private readonly active = new Set<string>();
9
+
10
+ constructor(private readonly changed: InputWatch) {}
11
+
12
+ accept(id: string, kind: RunnerInput['kind'], at: number): void {
13
+ const input: RunnerInput = { id, kind, state: 'accepted', acceptedAt: at, dispatchedAt: null, consumedAt: null, firstOutputAt: null, completedAt: null };
14
+ this.inputs.set(id, input);
15
+ if (this.inputs.size > 2_000) {
16
+ const oldest = this.inputs.keys().next().value;
17
+ if (oldest !== undefined) { this.inputs.delete(oldest); this.active.delete(oldest); }
18
+ }
19
+ this.note(input);
20
+ }
21
+
22
+ dispatch(id: string): void {
23
+ const input = this.inputs.get(id);
24
+ if (input === undefined) return;
25
+ input.dispatchedAt = Date.now();
26
+ this.note(input);
27
+ }
28
+
29
+ consume(uuids: readonly string[]): void {
30
+ for (const id of uuids) {
31
+ const input = this.inputs.get(id);
32
+ if (input === undefined || input.consumedAt !== null) continue;
33
+ input.consumedAt = Date.now();
34
+ input.state = 'consumed';
35
+ this.active.add(id);
36
+ this.note(input);
37
+ }
38
+ }
39
+
40
+ output(): void {
41
+ for (const id of this.active) {
42
+ const input = this.inputs.get(id);
43
+ if (input === undefined || input.firstOutputAt !== null) continue;
44
+ input.firstOutputAt = Date.now();
45
+ input.state = 'output';
46
+ this.note(input);
47
+ }
48
+ }
49
+
50
+ finish(uuids: readonly string[], cancelled = false): void {
51
+ for (const id of uuids) {
52
+ const input = this.inputs.get(id);
53
+ if (input === undefined) continue;
54
+ input.completedAt = Date.now();
55
+ input.state = cancelled ? 'cancelled' : 'completed';
56
+ this.inputs.delete(id);
57
+ this.active.delete(id);
58
+ this.note(input);
59
+ }
60
+ }
61
+
62
+ boundary(): void { this.active.clear(); }
63
+
64
+ private note(input: RunnerInput): void {
65
+ this.changed({ ...input });
66
+ log.info({ input: input.id, kind: input.kind, state: input.state, queueAgeMs: (input.consumedAt ?? Date.now()) - input.acceptedAt }, 'sdk-runner: input lifecycle');
67
+ }
68
+ }
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
3
3
  import { CallToolResultSchema, type CallToolRequest, type CallToolResult, type ListToolsResult, type Notification } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { errMsg, log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
+ import { CALL_NOTICE, CALL_STATE, callNotice, callState, type CallNotice, type CallSource, type CallState } from '@metro-labs/core/call';
6
7
  import { channelEvent, type ChannelEvent } from './channel-text.js';
7
8
 
8
9
  const CHANNEL = 'notifications/claude/channel';
@@ -23,10 +24,13 @@ export interface PermissionAsk {
23
24
  tool_name: string;
24
25
  description: string;
25
26
  input_preview: string;
27
+ call?: CallSource;
26
28
  }
27
29
 
28
30
  export interface LinkEvents {
29
31
  channel(event: ChannelEvent): void;
32
+ call?(notice: CallNotice): void;
33
+ callState?(state: CallState): void;
30
34
  toolsChanged(): void;
31
35
  model(model: string | null): void;
32
36
  lost(reason: string): void;
@@ -81,14 +85,19 @@ export class MetroLink implements Asker {
81
85
  ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior> {
82
86
  if (signal.aborted) return Promise.resolve('deny');
83
87
  return new Promise((resolve) => {
88
+ const abort = (): void => {
89
+ settle('deny');
90
+ this.client.notification({ method: 'notifications/metro/permission_cancel', params: { request_id: ask.request_id } }).catch(() => {
91
+ log.warn('sdk-runner: could not cancel a pending approval with metro');
92
+ });
93
+ };
84
94
  const settle = (behavior: Behavior): void => {
85
- this.waiting.delete(ask.request_id);
95
+ signal.removeEventListener('abort', abort);
96
+ if (!this.waiting.delete(ask.request_id)) return;
86
97
  resolve(behavior);
87
98
  };
88
99
  this.waiting.set(ask.request_id, settle);
89
- signal.addEventListener('abort', () => {
90
- settle('deny');
91
- }, { once: true });
100
+ signal.addEventListener('abort', abort, { once: true });
92
101
  this.client.notification({ method: ASK, params: { ...ask } }).catch((err: unknown) => {
93
102
  log.warn({ err: errMsg(err), id: ask.request_id }, 'sdk-runner: could not hand an approval to metro');
94
103
  settle('deny');
@@ -106,6 +115,10 @@ export class MetroLink implements Asker {
106
115
  if (notification.method === CHANNEL) {
107
116
  const event = channelEvent(params);
108
117
  if (event !== null) this.events.channel(event);
118
+ } else if (notification.method === CALL_NOTICE) {
119
+ this.callNotified(params);
120
+ } else if (notification.method === CALL_STATE) {
121
+ this.reconciled(params);
109
122
  } else if (notification.method === ANSWER) {
110
123
  this.answered(params);
111
124
  } else if (notification.method === TOOLS_CHANGED) {
@@ -115,6 +128,16 @@ export class MetroLink implements Asker {
115
128
  }
116
129
  }
117
130
 
131
+ private callNotified(params: Record<string, unknown>): void {
132
+ const notice = callNotice(params);
133
+ if (notice !== null) this.events.call?.(notice);
134
+ }
135
+
136
+ private reconciled(params: Record<string, unknown>): void {
137
+ const state = callState(params);
138
+ if (state !== null) this.events.callState?.(state);
139
+ }
140
+
118
141
  private answered(params: Record<string, unknown>): void {
119
142
  const id = typeof params.request_id === 'string' ? params.request_id : '';
120
143
  this.waiting.get(id)?.(params.behavior === 'allow' ? 'allow' : 'deny');
@@ -23,10 +23,6 @@ try {
23
23
  activity = new Activity(join(dirname(cfg.statePath), 'agent-status.json'));
24
24
  agent = await startAgent(cfg, {
25
25
  activity,
26
- speech: {
27
- say: (text) => { log.debug({ chars: text.length }, 'sdk-runner: speech'); },
28
- done: () => { log.debug('sdk-runner: speech done'); },
29
- },
30
26
  lost: () => {
31
27
  activity?.fail('The SDK lost its Metro connection. The session will restart.');
32
28
  exitWith(1)();