@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,11 +1,25 @@
1
- export const FRONT_RULES = `You are the orchestrator of this agent's Agent SDK chat session. Chat messages (<channel> blocks) and the reports of your background workers arrive in this conversation. Voice calls still use a separate session. Do not claim that chat and calls share context.
1
+ import type { CallRoute, CallSource } from '@metro-labs/core/call';
2
+
3
+ export const FRONT_RULES = `You are the orchestrator of this agent's persistent Agent SDK session. Typed chat, live call words (<call> blocks), and background worker reports share this conversation.
2
4
  - Be fast. When you can answer a chat message at once, make the reaction and the answer one response of parallel tool calls (react, then send), with no typing signal. Use typing only when the answer will take a while.
3
5
  - Stay light: talk, decide and delegate. Real work goes to background workers (Agent tool, subagent_type "worker", run_in_background: true); keep answering while they run, and relay what they report.
6
+ - Plain assistant text is never spoken. Reply to a live <call> with mcp__metro__send using its exact line, text, and speech:{callId,generation,sourceId}. This sends speech only, not a chat post. Keep it short. Never paste a spoken reply or transcript into chat unless the owner explicitly requests a chat post.
7
+ - Ordinary typed chat and worker results are never automatically spoken. For an explicit typed owner request to speak, use only that channel message's call_id, call_generation and call_source_id with its exact line as send speech:{callId:call_id,generation:call_generation,sourceId:call_source_id}. Without all three authorized live-call values, do not speak or guess a target. Do not reuse an earlier sourceId for a new reply.
8
+ - A call ending invalidates its speech targets. Keep the chat session and background work running. Nothing is posted about a hangup unless requested. Speech status notifications are status only, never a request to speak.
4
9
  - Text in [square brackets] comes from Metro, not from a person.`;
5
10
 
6
- export const callStarted = (where: string): string =>
7
- `[A voice call started: ${where}. Until it ends, the caller's words arrive as <call> blocks, and your plain text answer to a <call> block is spoken aloud: one to three short sentences, no markdown, lists, links or emoji. A <call> block comes first: start your response with the words for the caller, before any tool call, then finish other work, and write no more text in that response unless the caller needs to hear it. Never answer a <call> block with send. Chat messages that arrive during the call keep their usual handling with the metro tools; your text about them is not spoken. When a worker finishes during the call, tell the caller in a sentence or two. Greet the caller now in one short sentence.]`;
11
+ const attr = (text: string): string => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
12
+
13
+ export function callWords(source: CallSource, text: string): string {
14
+ const { route, sourceId } = source;
15
+ return `<call line="${attr(route.line)}" from="${attr(route.from)}" callId="${attr(route.callId)}" generation="${attr(route.generation)}" sourceId="${attr(sourceId)}">\n${attr(text)}\n</call>`;
16
+ }
17
+
18
+ export const callStarted = (source: CallSource): string =>
19
+ `[A voice call started. Greet the caller briefly using send with this block's exact line and speech:{callId,generation,sourceId}. Do not post the greeting in chat.]\n${callWords(source, '')}`;
8
20
 
9
- export const CALL_ENDED = '[The voice call ended. Nothing about it is posted anywhere unless someone asks.]';
21
+ export const callEnded = (route: CallRoute): string =>
22
+ `[The voice call ${attr(route.callId)} generation ${attr(route.generation)} ended. Its speech targets are invalid. Keep all other work running. Nothing about it is posted unless requested.]`;
10
23
 
11
- export const callWords = (text: string): string => `<call>${text}</call>`;
24
+ export const speechFailed = (route: CallRoute): string =>
25
+ `[Speech delivery failed for voice call ${attr(route.callId)} generation ${attr(route.generation)} on ${attr(route.line)}. The full reply was not confirmed. Voice delivery needs attention before another attempt. This is status only: do not retry, speak, or post to chat in response. Keep the chat session and other work running.]`;
@@ -1,19 +1,18 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { query, type CanUseTool, type Options, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
3
2
  import { errMsg, log } from '@metro-labs/core/log';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
4
  import type { Activity } from './activity.js';
5
+ import { RunnerCalls } from './calls.js';
5
6
  import { channelText, type ChannelEvent } from './channel-text.js';
6
7
  import type { RunnerConfig } from './config.js';
7
8
  import { Inbox, type Unanswered, type Uuid } from './inbox.js';
8
- import { CALL_ENDED, callStarted, callWords, FRONT_RULES } from './rules.js';
9
+ import { FRONT_RULES } from './rules.js';
9
10
  import { INTERRUPTED_NOTICE } from './recovery.js';
10
11
  import type { SessionStore } from './session-store.js';
11
12
  import { SessionWatch, startedCommand, uuidsOf } from './session-watch.js';
12
- import { SpeechRouter, type SpeechSink } from './speech.js';
13
13
  import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
14
14
 
15
15
  const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
16
- const STALL_MS = 2_000;
17
16
  export const COMPACT_AT = 120_000;
18
17
 
19
18
  const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
@@ -62,22 +61,33 @@ export type OpenSession = (params: { prompt: AsyncIterable<SDKUserMessage>; opti
62
61
 
63
62
  export interface RunnerParts {
64
63
  store: SessionStore;
65
- sink: SpeechSink;
66
64
  readOnly: (tool: string) => boolean;
67
65
  compactAt?: number;
68
66
  open?: OpenSession;
69
67
  activity?: Activity;
70
68
  }
71
69
 
70
+ function streamOutput(event: Record<string, unknown>): boolean {
71
+ if (event.type === 'content_block_start' && isRecord(event.content_block)) return event.content_block.type === 'tool_use';
72
+ return event.type === 'content_block_delta' && isRecord(event.delta) && event.delta.type === 'text_delta' && typeof event.delta.text === 'string' && event.delta.text.length > 0;
73
+ }
74
+
75
+ function visibleOutput(m: Record<string, unknown>): boolean {
76
+ if (m.type === 'assistant' && isRecord(m.message) && Array.isArray(m.message.content)) {
77
+ return m.message.content.some((block: unknown) => isRecord(block) && (block.type === 'tool_use' || (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0)));
78
+ }
79
+ return m.type === 'stream_event' && isRecord(m.event) && streamOutput(m.event);
80
+ }
81
+
72
82
  export class Runner {
73
83
  readonly inbox: Inbox;
74
- private readonly speech: SpeechRouter;
84
+ readonly calls: RunnerCalls;
75
85
  private readonly watch: SessionWatch;
76
86
  private session: Query | null = null;
77
87
  private sessionId: string | null = null;
78
- private compactAsked = false;
88
+ private compactAsked: Uuid | null = null;
79
89
  private compactFloor: number | null = null;
80
- private stall: ReturnType<typeof setTimeout> | null = null;
90
+ private compacted = false;
81
91
  private model: string | null = null;
82
92
  private switching: Promise<void> = Promise.resolve();
83
93
  private ended = false;
@@ -85,31 +95,27 @@ export class Runner {
85
95
  private interruptionReported = false;
86
96
 
87
97
  constructor(private readonly parts: RunnerParts) {
88
- this.inbox = new Inbox((unanswered) => {
89
- this.keep(unanswered);
90
- });
91
- this.speech = new SpeechRouter((uuid) => this.inbox.kindOf(uuid), {
92
- say: (text) => {
93
- this.clearStall();
94
- parts.sink.say(text);
95
- },
96
- done: () => {
97
- parts.sink.done();
98
- },
99
- });
100
98
  this.watch = new SessionWatch(parts.readOnly);
101
- const { unanswered: again, interrupted } = parts.store.recover();
99
+ this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
100
+ ready: () => this.watch.safe,
101
+ dispatch: () => { this.watch.dispatched(); },
102
+ input: (input) => { parts.activity?.input(input); },
103
+ queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
104
+ });
105
+ this.calls = new RunnerCalls(this.inbox, parts.activity);
106
+ const { unanswered: again, interrupted, context } = parts.store.recover();
107
+ this.watch.restoreContext(context ?? 0);
102
108
  this.interrupted = interrupted.length;
103
109
  this.inbox.again(again);
104
110
  if (again.length > 0) log.info({ count: again.length }, 'sdk-runner: chat messages the last session never read go in again');
105
111
  }
106
112
 
107
- get id(): string | null {
108
- return this.sessionId;
109
- }
113
+ get id(): string | null { return this.sessionId; }
110
114
 
111
115
  start(options: Options): Query {
112
116
  if (this.session !== null) throw new Error('the runner was already started');
117
+ if (options.resume === undefined) this.watch.restoreContext(0);
118
+ else this.maybeCompact(true);
113
119
  this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
114
120
  this.model = options.model ?? null;
115
121
  this.queueModel(this.model);
@@ -128,9 +134,9 @@ export class Runner {
128
134
  const m: Record<string, unknown> = { ...message };
129
135
  this.trackInput(m);
130
136
  this.watch.observe(m);
131
- this.speech.observe(message);
132
137
  this.note(m);
133
138
  this.parts.activity?.observe(message);
139
+ this.inbox.notify();
134
140
  observe?.(message);
135
141
  }
136
142
  } finally {
@@ -140,7 +146,9 @@ export class Runner {
140
146
 
141
147
  chat(event: ChannelEvent): Uuid {
142
148
  try {
143
- return this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
149
+ const uuid = this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
150
+ this.calls.chat(event);
151
+ return uuid;
144
152
  } catch (err) {
145
153
  log.error('sdk-runner: could not save incoming chat; stopping before further work');
146
154
  try {
@@ -152,23 +160,8 @@ export class Runner {
152
160
  }
153
161
  }
154
162
 
155
- callStarted(where: string): Uuid {
156
- this.speech.setCallLive(true);
157
- return this.callPush(callStarted(where));
158
- }
159
-
160
- heard(text: string): Uuid {
161
- return this.callPush(callWords(text));
162
- }
163
-
164
- callEnded(): Uuid {
165
- this.speech.setCallLive(false);
166
- this.clearStall();
167
- return this.inbox.push('note', CALL_ENDED);
168
- }
169
-
170
163
  close(cancelActive = true): void {
171
- this.clearStall();
164
+ this.calls.close();
172
165
  try {
173
166
  if (cancelActive && !this.ended) this.inbox.finished();
174
167
  } finally {
@@ -177,36 +170,6 @@ export class Runner {
177
170
  }
178
171
  }
179
172
 
180
- private callPush(text: string): Uuid {
181
- const uuid = randomUUID();
182
- this.inbox.mark(uuid, 'call');
183
- this.armStall();
184
- if (this.watch.compacting) log.info('sdk-runner: a call message waits for the compaction to finish');
185
- this.watch
186
- .whenNotCompacting()
187
- .then(() => this.watch.whenNotWriting())
188
- .then(() => this.inbox.push('call', text, 'now', uuid))
189
- .catch((err: unknown) => {
190
- log.warn({ err: errMsg(err) }, 'sdk-runner: could not hand a call message to the session');
191
- });
192
- return uuid;
193
- }
194
-
195
- private armStall(): void {
196
- this.clearStall();
197
- if (this.parts.sink.stalled === undefined) return;
198
- this.stall = setTimeout(() => {
199
- this.stall = null;
200
- if (this.speech.callLive && !this.speech.talking) this.parts.sink.stalled?.();
201
- }, STALL_MS);
202
- this.stall.unref();
203
- }
204
-
205
- private clearStall(): void {
206
- if (this.stall !== null) clearTimeout(this.stall);
207
- this.stall = null;
208
- }
209
-
210
173
  private queueModel(model: string | null): void {
211
174
  this.switching = this.switching
212
175
  .then(() => this.applyModel(model))
@@ -238,37 +201,50 @@ export class Runner {
238
201
  if (m.type === 'result') {
239
202
  const uuids = uuidsOf(m);
240
203
  if (uuids !== null) this.inbox.finished(uuids);
204
+ this.inbox.boundary();
241
205
  return;
242
206
  }
243
207
  const started = startedCommand(m);
244
208
  this.inbox.started([...(uuidsOf(m) ?? []), ...(started === null ? [] : [started])]);
209
+ if (visibleOutput(m)) this.inbox.output();
245
210
  }
246
211
 
247
- private keep(unanswered: Unanswered[]): void {
248
- this.parts.store.saveUnanswered(unanswered);
249
- this.parts.activity?.pending(unanswered.filter((entry) => entry.state !== 'started').length);
250
- }
212
+ private keep(unanswered: Unanswered[]): void { this.parts.store.saveUnanswered(unanswered); }
251
213
 
252
214
  private note(m: Record<string, unknown>): void {
253
- if (m.type === 'system' && m.subtype === 'init') this.started(m);
254
- else if (m.type === 'result') this.turnDone(m);
255
- else if (m.type === 'system' && (m.subtype === 'task_started' || m.subtype === 'task_notification')) log.info({ task: m.task_id, status: m.status ?? 'started' }, `sdk-runner: worker ${m.subtype}`);
256
- else if (m.type === 'system' && m.subtype === 'compact_boundary') {
257
- this.compactAsked = false;
215
+ if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
216
+ if (m.type === 'result') this.turnDone(m);
217
+ if (m.type !== 'system') return;
218
+ if (m.subtype === 'init') this.started(m);
219
+ else if (m.subtype === 'session_state_changed' && m.state === 'idle') this.maybeCompact();
220
+ else if (m.subtype === 'compact_boundary') {
221
+ this.compacted = true;
258
222
  this.compactFloor = null;
223
+ this.parts.store.saveContext(0);
259
224
  log.info({ compact: m.compact_metadata }, 'sdk-runner: the conversation was compacted');
260
225
  }
261
226
  }
262
227
 
263
228
  private turnDone(m: Record<string, unknown>): void {
264
- log.info({ turnMs: m.duration_ms, context: this.watch.context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
265
- const limit = this.parts.compactAt ?? COMPACT_AT;
266
229
  const context = this.watch.context;
267
- if (context > 0) this.compactFloor ??= context;
268
- if (!compactDue({ context, floor: this.compactFloor, limit, busy: this.compactAsked || this.speech.callLive || this.watch.compacting })) return;
269
- this.compactAsked = true;
270
- log.info({ context: this.watch.context }, 'sdk-runner: compacting while no call is live');
271
- this.inbox.push('note', '/compact');
230
+ log.info({ turnMs: m.duration_ms, context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
231
+ this.parts.store.saveContext(context);
232
+ if (this.compactAsked !== null && uuidsOf(m)?.includes(this.compactAsked)) {
233
+ this.compactAsked = null;
234
+ if (!this.compacted) this.compactFloor = context;
235
+ }
236
+ if (this.compacted && context > 0) {
237
+ this.compactFloor = context;
238
+ this.compacted = false;
239
+ }
240
+ this.maybeCompact();
241
+ }
242
+
243
+ private maybeCompact(resuming = false): void {
244
+ const busy = this.compactAsked !== null || !this.watch.safe || (!resuming && (this.calls.live || this.inbox.pending > 0));
245
+ if (!compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy })) return;
246
+ this.compactAsked = this.inbox.compact();
247
+ log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
272
248
  }
273
249
 
274
250
  private started(m: Record<string, unknown>): void {
@@ -13,6 +13,7 @@ interface Stored {
13
13
  sessionId: string | null;
14
14
  unanswered: Unanswered[];
15
15
  interrupted: Unanswered[];
16
+ context?: number;
16
17
  }
17
18
 
18
19
  function inputId(raw: unknown): Uuid | undefined {
@@ -35,11 +36,18 @@ function entry(raw: unknown): Unanswered | null {
35
36
 
36
37
  const UNREADABLE = 'The saved Agent SDK state cannot be read. Restore it before starting.';
37
38
 
39
+ function contextOf(raw: unknown): number | undefined {
40
+ if (raw === undefined) return undefined;
41
+ if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) throw new Error(UNREADABLE);
42
+ return raw;
43
+ }
44
+
38
45
  function stored(raw: unknown): Stored {
39
46
  if (!isRecord(raw)) throw new Error(UNREADABLE);
40
47
  const id = raw.sessionId ?? null;
41
48
  if (id !== null && (typeof id !== 'string' || !SESSION_ID.test(id))) throw new Error(UNREADABLE);
42
- return { sessionId: id, unanswered: entries(raw.unanswered), interrupted: entries(raw.interrupted) };
49
+ const context = contextOf(raw.context);
50
+ return { sessionId: id, unanswered: entries(raw.unanswered), interrupted: entries(raw.interrupted), ...(context === undefined ? {} : { context }) };
43
51
  }
44
52
 
45
53
  function entries(raw: unknown): Unanswered[] {
@@ -86,6 +94,11 @@ export class SessionStore {
86
94
  this.write({ ...this.read(), unanswered });
87
95
  }
88
96
 
97
+ saveContext(context: number): void {
98
+ const stored = this.read();
99
+ if (stored.context !== context) this.write({ ...stored, context });
100
+ }
101
+
89
102
  private read(): Stored {
90
103
  try {
91
104
  return stored(JSON.parse(readFileSync(this.path, 'utf8')));
@@ -1,8 +1,6 @@
1
1
  import { isRecord } from '@metro-labs/core/is-record';
2
2
 
3
3
  const METRO_TOOL = /^mcp__metro__(.+)$/;
4
- const GATE_CAP_MS = 5_000;
5
- const COMPACT_CAP_MS = 20_000;
6
4
 
7
5
  export const uuidsOf = (m: Record<string, unknown>): string[] | null => {
8
6
  if (Array.isArray(m.user_message_uuids)) return m.user_message_uuids.filter((u): u is string => typeof u === 'string');
@@ -24,21 +22,14 @@ function toolResultIds(message: unknown): string[] {
24
22
  return message.content.filter(isRecord).filter((b) => b.type === 'tool_result' && typeof b.tool_use_id === 'string').map((b) => String(b.tool_use_id));
25
23
  }
26
24
 
27
- function capped(waiters: (() => void)[], capMs: number): Promise<void> {
28
- return new Promise((resolve) => {
29
- const timer = setTimeout(resolve, capMs);
30
- waiters.push(() => {
31
- clearTimeout(timer);
32
- resolve();
33
- });
34
- });
35
- }
36
25
 
37
26
  export class SessionWatch {
38
27
  private readonly writes = new Set<string>();
39
28
  private readonly idle: (() => void)[] = [];
40
29
  private readonly compacted: (() => void)[] = [];
41
30
  private compactingNow = false;
31
+ private busy = false;
32
+ private sessionStateSeen = false;
42
33
  private lastContext = 0;
43
34
  private streamUsage: Record<string, unknown> = {};
44
35
 
@@ -56,26 +47,42 @@ export class SessionWatch {
56
47
  return this.writes.size > 0;
57
48
  }
58
49
 
50
+ get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
51
+
52
+ dispatched(): void { this.busy = true; }
53
+ restoreContext(context: number): void { this.lastContext = context; }
54
+
59
55
  observe(m: Record<string, unknown>): void {
60
- if (m.type === 'system') this.system(m);
61
- else if (m.type === 'result') this.settle();
62
- if (m.parent_tool_use_id !== null) return;
56
+ if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
57
+ this.lifecycle(m);
63
58
  if (m.type === 'assistant' && isRecord(m.message)) this.assistant(m.message);
64
59
  else if (m.type === 'stream_event' && isRecord(m.event)) this.stream(m.event);
65
60
  else if (m.type === 'user') for (const id of toolResultIds(m.message)) this.done(id);
66
61
  }
67
62
 
68
- whenNotWriting(capMs = GATE_CAP_MS): Promise<void> {
69
- return this.writing ? capped(this.idle, capMs) : Promise.resolve();
63
+ whenNotWriting(): Promise<void> {
64
+ return this.writing ? new Promise((resolve) => { this.idle.push(resolve); }) : Promise.resolve();
70
65
  }
71
66
 
72
- whenNotCompacting(capMs = COMPACT_CAP_MS): Promise<void> {
73
- return this.compactingNow ? capped(this.compacted, capMs) : Promise.resolve();
67
+ whenNotCompacting(): Promise<void> {
68
+ return this.compactingNow ? new Promise((resolve) => { this.compacted.push(resolve); }) : Promise.resolve();
69
+ }
70
+
71
+ private lifecycle(m: Record<string, unknown>): void {
72
+ if (m.type === 'system') this.system(m);
73
+ else if (m.type === 'result' && !this.sessionStateSeen) {
74
+ this.busy = typeof m.queued_turn_count === 'number' && m.queued_turn_count > 0;
75
+ this.settle();
76
+ }
77
+ if (m.type === 'assistant' || m.type === 'stream_event' || startedCommand(m) !== null) this.busy = true;
74
78
  }
75
79
 
76
80
  private system(m: Record<string, unknown>): void {
77
- if (m.subtype === 'init') this.settle();
78
- else if (m.subtype === 'status') this.setCompacting(m.status === 'compacting');
81
+ if (m.subtype === 'session_state_changed') {
82
+ this.sessionStateSeen = true;
83
+ if (m.state === 'running' || m.state === 'requires_action') this.busy = true;
84
+ else if (m.state === 'idle') { this.busy = false; this.settle(); }
85
+ } else if (m.subtype === 'status') this.setCompacting(m.status === 'compacting');
79
86
  else if (m.subtype === 'compact_boundary') {
80
87
  this.setCompacting(false);
81
88
  this.lastContext = 0;
@@ -36,5 +36,8 @@
36
36
  "outlook": {},
37
37
  "gmail": {},
38
38
  "webhook": {}
39
+ },
40
+ "patchedDependencies": {
41
+ "baileys@7.0.0-rc14": "patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch"
39
42
  }
40
43
  }
@@ -1,79 +0,0 @@
1
- import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
2
- import { isRecord } from '@metro-labs/core/is-record';
3
- import type { InputKind } from './inbox.js';
4
- import { startedCommand, uuidsOf } from './session-watch.js';
5
-
6
- export interface SpeechSink {
7
- say(text: string): void;
8
- done(): void;
9
- stalled?(): void;
10
- }
11
-
12
-
13
- function textDelta(event: unknown): string | null {
14
- if (!isRecord(event) || event.type !== 'content_block_delta' || !isRecord(event.delta)) return null;
15
- return event.delta.type === 'text_delta' && typeof event.delta.text === 'string' ? event.delta.text : null;
16
- }
17
-
18
- export class SpeechRouter {
19
- private answering: string[] = [];
20
- private speaking = false;
21
- private live = false;
22
-
23
- constructor(
24
- private readonly kindOf: (uuid: string) => InputKind | undefined,
25
- private readonly sink: SpeechSink,
26
- ) {}
27
-
28
- get callLive(): boolean {
29
- return this.live;
30
- }
31
-
32
- get talking(): boolean {
33
- return this.speaking;
34
- }
35
-
36
- setCallLive(on: boolean): void {
37
- this.live = on;
38
- if (!on) this.endTurn();
39
- }
40
-
41
- observe(message: SDKMessage): void {
42
- const m: Record<string, unknown> = { ...message };
43
- if (m.type === 'result') {
44
- this.endTurn();
45
- this.answering = [];
46
- return;
47
- }
48
- if (m.type === 'system' && m.subtype === 'init') this.endTurn();
49
- const started = startedCommand(m);
50
- if (started !== null) this.answer([started]);
51
- if (m.parent_tool_use_id === null) this.mainThread(m);
52
- }
53
-
54
- private answer(uuids: readonly string[]): void {
55
- this.answering = [...new Set([...this.answering, ...uuids])];
56
- }
57
-
58
- private mainThread(m: Record<string, unknown>): void {
59
- if (m.type !== 'stream_event' && m.type !== 'assistant') return;
60
- const uuids = uuidsOf(m);
61
- if (uuids !== null) this.answer(uuids);
62
- const text = m.type === 'stream_event' ? textDelta(m.event) : null;
63
- if (text === null || text === '' || !this.speaks()) return;
64
- this.speaking = true;
65
- this.sink.say(text);
66
- }
67
-
68
- private speaks(): boolean {
69
- if (!this.live) return false;
70
- if (this.answering.length === 0) return true;
71
- return this.answering.some((uuid) => this.kindOf(uuid) === 'call');
72
- }
73
-
74
- private endTurn(): void {
75
- if (!this.speaking) return;
76
- this.speaking = false;
77
- this.sink.done();
78
- }
79
- }