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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
  6. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
  23. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
  24. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
  25. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
  26. package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
  27. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
  28. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  37. package/runtime/sdk-runner/runner.json +1 -1
  38. package/runtime/sdk-runner/src/activity.ts +14 -2
  39. package/runtime/sdk-runner/src/app.ts +5 -4
  40. package/runtime/sdk-runner/src/approvals.ts +14 -5
  41. package/runtime/sdk-runner/src/calls.ts +133 -0
  42. package/runtime/sdk-runner/src/inbox.ts +58 -26
  43. package/runtime/sdk-runner/src/input-queue.ts +75 -0
  44. package/runtime/sdk-runner/src/input-timing.ts +68 -0
  45. package/runtime/sdk-runner/src/link.ts +27 -4
  46. package/runtime/sdk-runner/src/main.ts +0 -4
  47. package/runtime/sdk-runner/src/rules.ts +19 -5
  48. package/runtime/sdk-runner/src/runner.ts +67 -89
  49. package/runtime/sdk-runner/src/session-store.ts +14 -1
  50. package/runtime/sdk-runner/src/session-watch.ts +51 -20
  51. package/runtime/sdk-runner/src/speech.ts +0 -79
@@ -1,26 +1,27 @@
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']);
20
19
 
21
20
  export function runnerEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
22
21
  const unpinned = Object.fromEntries(Object.entries(env).filter(([name]) => !MODEL_PINS.has(name)));
23
- return { [STARTUP_WAIT]: '0', ...unpinned, DISABLE_AUTOUPDATER: '1' };
22
+ const terminals = new Set((env.CLAUDE_CODE_TERMINAL_MCP_TOOLS ?? '').split(',').map((name) => name.trim()).filter(Boolean));
23
+ terminals.add('mcp__metro__send');
24
+ return { [STARTUP_WAIT]: '0', ...unpinned, DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_TERMINAL_MCP_TOOLS: [...terminals].join(',') };
24
25
  }
25
26
 
26
27
  export type ModelSettings = Parameters<Query['applyFlagSettings']>[0];
@@ -62,22 +63,33 @@ export type OpenSession = (params: { prompt: AsyncIterable<SDKUserMessage>; opti
62
63
 
63
64
  export interface RunnerParts {
64
65
  store: SessionStore;
65
- sink: SpeechSink;
66
66
  readOnly: (tool: string) => boolean;
67
67
  compactAt?: number;
68
68
  open?: OpenSession;
69
69
  activity?: Activity;
70
70
  }
71
71
 
72
+ function streamOutput(event: Record<string, unknown>): boolean {
73
+ if (event.type === 'content_block_start' && isRecord(event.content_block)) return event.content_block.type === 'tool_use';
74
+ return event.type === 'content_block_delta' && isRecord(event.delta) && event.delta.type === 'text_delta' && typeof event.delta.text === 'string' && event.delta.text.length > 0;
75
+ }
76
+
77
+ function visibleOutput(m: Record<string, unknown>): boolean {
78
+ if (m.type === 'assistant' && isRecord(m.message) && Array.isArray(m.message.content)) {
79
+ return m.message.content.some((block: unknown) => isRecord(block) && (block.type === 'tool_use' || (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0)));
80
+ }
81
+ return m.type === 'stream_event' && isRecord(m.event) && streamOutput(m.event);
82
+ }
83
+
72
84
  export class Runner {
73
85
  readonly inbox: Inbox;
74
- private readonly speech: SpeechRouter;
86
+ readonly calls: RunnerCalls;
75
87
  private readonly watch: SessionWatch;
76
88
  private session: Query | null = null;
77
89
  private sessionId: string | null = null;
78
- private compactAsked = false;
90
+ private compactAsked: Uuid | null = null;
79
91
  private compactFloor: number | null = null;
80
- private stall: ReturnType<typeof setTimeout> | null = null;
92
+ private compacted = false;
81
93
  private model: string | null = null;
82
94
  private switching: Promise<void> = Promise.resolve();
83
95
  private ended = false;
@@ -85,31 +97,27 @@ export class Runner {
85
97
  private interruptionReported = false;
86
98
 
87
99
  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
100
  this.watch = new SessionWatch(parts.readOnly);
101
- const { unanswered: again, interrupted } = parts.store.recover();
101
+ this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
102
+ ready: () => this.watch.admitting,
103
+ dispatch: (message) => { this.watch.dispatched(message.uuid); },
104
+ input: (input) => { parts.activity?.input(input); },
105
+ queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
106
+ });
107
+ this.calls = new RunnerCalls(this.inbox, parts.activity);
108
+ const { unanswered: again, interrupted, context } = parts.store.recover();
109
+ this.watch.restoreContext(context ?? 0);
102
110
  this.interrupted = interrupted.length;
103
111
  this.inbox.again(again);
104
112
  if (again.length > 0) log.info({ count: again.length }, 'sdk-runner: chat messages the last session never read go in again');
105
113
  }
106
114
 
107
- get id(): string | null {
108
- return this.sessionId;
109
- }
115
+ get id(): string | null { return this.sessionId; }
110
116
 
111
117
  start(options: Options): Query {
112
118
  if (this.session !== null) throw new Error('the runner was already started');
119
+ if (options.resume === undefined) this.watch.restoreContext(0);
120
+ else this.maybeCompact(true);
113
121
  this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
114
122
  this.model = options.model ?? null;
115
123
  this.queueModel(this.model);
@@ -128,9 +136,9 @@ export class Runner {
128
136
  const m: Record<string, unknown> = { ...message };
129
137
  this.trackInput(m);
130
138
  this.watch.observe(m);
131
- this.speech.observe(message);
132
139
  this.note(m);
133
140
  this.parts.activity?.observe(message);
141
+ this.inbox.notify();
134
142
  observe?.(message);
135
143
  }
136
144
  } finally {
@@ -140,7 +148,9 @@ export class Runner {
140
148
 
141
149
  chat(event: ChannelEvent): Uuid {
142
150
  try {
143
- return this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
151
+ const uuid = this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
152
+ this.calls.chat(event);
153
+ return uuid;
144
154
  } catch (err) {
145
155
  log.error('sdk-runner: could not save incoming chat; stopping before further work');
146
156
  try {
@@ -152,23 +162,8 @@ export class Runner {
152
162
  }
153
163
  }
154
164
 
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
165
  close(cancelActive = true): void {
171
- this.clearStall();
166
+ this.calls.close();
172
167
  try {
173
168
  if (cancelActive && !this.ended) this.inbox.finished();
174
169
  } finally {
@@ -177,36 +172,6 @@ export class Runner {
177
172
  }
178
173
  }
179
174
 
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
175
  private queueModel(model: string | null): void {
211
176
  this.switching = this.switching
212
177
  .then(() => this.applyModel(model))
@@ -238,37 +203,50 @@ export class Runner {
238
203
  if (m.type === 'result') {
239
204
  const uuids = uuidsOf(m);
240
205
  if (uuids !== null) this.inbox.finished(uuids);
206
+ this.inbox.boundary();
241
207
  return;
242
208
  }
243
209
  const started = startedCommand(m);
244
210
  this.inbox.started([...(uuidsOf(m) ?? []), ...(started === null ? [] : [started])]);
211
+ if (visibleOutput(m)) this.inbox.output();
245
212
  }
246
213
 
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
- }
214
+ private keep(unanswered: Unanswered[]): void { this.parts.store.saveUnanswered(unanswered); }
251
215
 
252
216
  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;
217
+ if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
218
+ if (m.type === 'result') this.turnDone(m);
219
+ if (m.type !== 'system') return;
220
+ if (m.subtype === 'init') this.started(m);
221
+ else if (m.subtype === 'session_state_changed' && m.state === 'idle') this.maybeCompact();
222
+ else if (m.subtype === 'compact_boundary') {
223
+ this.compacted = true;
258
224
  this.compactFloor = null;
225
+ this.parts.store.saveContext(0);
259
226
  log.info({ compact: m.compact_metadata }, 'sdk-runner: the conversation was compacted');
260
227
  }
261
228
  }
262
229
 
263
230
  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
231
  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');
232
+ log.info({ turnMs: m.duration_ms, context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
233
+ this.parts.store.saveContext(context);
234
+ if (this.compactAsked !== null && uuidsOf(m)?.includes(this.compactAsked)) {
235
+ this.compactAsked = null;
236
+ if (!this.compacted) this.compactFloor = context;
237
+ }
238
+ if (this.compacted && context > 0) {
239
+ this.compactFloor = context;
240
+ this.compacted = false;
241
+ }
242
+ this.maybeCompact();
243
+ }
244
+
245
+ private maybeCompact(resuming = false): void {
246
+ const busy = this.compactAsked !== null || !this.watch.safe || !this.watch.admitting || (!resuming && (this.calls.live || this.inbox.pending > 0));
247
+ if (!compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy })) return;
248
+ this.compactAsked = this.inbox.compact();
249
+ log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
272
250
  }
273
251
 
274
252
  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,15 @@ 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
-
37
25
  export class SessionWatch {
38
26
  private readonly writes = new Set<string>();
39
27
  private readonly idle: (() => void)[] = [];
40
28
  private readonly compacted: (() => void)[] = [];
41
29
  private compactingNow = false;
30
+ private busy = false;
31
+ private sessionStateSeen = false;
32
+ private requiresAction = false;
33
+ private awaitingInput: string | null = null;
42
34
  private lastContext = 0;
43
35
  private streamUsage: Record<string, unknown> = {};
44
36
 
@@ -56,25 +48,52 @@ export class SessionWatch {
56
48
  return this.writes.size > 0;
57
49
  }
58
50
 
51
+ get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
52
+
53
+ get admitting(): boolean {
54
+ return this.awaitingInput === null && !this.requiresAction && !this.compacting;
55
+ }
56
+
57
+ dispatched(uuid?: string): void {
58
+ this.busy = true;
59
+ this.awaitingInput = uuid ?? null;
60
+ }
61
+
62
+ restoreContext(context: number): void { this.lastContext = context; }
63
+
59
64
  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;
65
+ if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
66
+ this.acknowledge(m);
67
+ this.lifecycle(m);
63
68
  if (m.type === 'assistant' && isRecord(m.message)) this.assistant(m.message);
64
69
  else if (m.type === 'stream_event' && isRecord(m.event)) this.stream(m.event);
65
70
  else if (m.type === 'user') for (const id of toolResultIds(m.message)) this.done(id);
66
71
  }
67
72
 
68
- whenNotWriting(capMs = GATE_CAP_MS): Promise<void> {
69
- return this.writing ? capped(this.idle, capMs) : Promise.resolve();
73
+ whenNotWriting(): Promise<void> {
74
+ return this.writing ? new Promise((resolve) => { this.idle.push(resolve); }) : Promise.resolve();
70
75
  }
71
76
 
72
- whenNotCompacting(capMs = COMPACT_CAP_MS): Promise<void> {
73
- return this.compactingNow ? capped(this.compacted, capMs) : Promise.resolve();
77
+ whenNotCompacting(): Promise<void> {
78
+ return this.compactingNow ? new Promise((resolve) => { this.compacted.push(resolve); }) : Promise.resolve();
79
+ }
80
+
81
+ private acknowledge(m: Record<string, unknown>): void {
82
+ if (this.awaitingInput === null) return;
83
+ if (startedCommand(m) === this.awaitingInput || uuidsOf(m)?.includes(this.awaitingInput)) this.awaitingInput = null;
84
+ }
85
+
86
+ private lifecycle(m: Record<string, unknown>): void {
87
+ if (m.type === 'system') this.system(m);
88
+ else if (m.type === 'result' && !this.sessionStateSeen) {
89
+ this.busy = typeof m.queued_turn_count === 'number' && m.queued_turn_count > 0;
90
+ this.settle();
91
+ }
92
+ if (m.type === 'assistant' || m.type === 'stream_event' || startedCommand(m) !== null) this.busy = true;
74
93
  }
75
94
 
76
95
  private system(m: Record<string, unknown>): void {
77
- if (m.subtype === 'init') this.settle();
96
+ if (m.subtype === 'session_state_changed') this.sessionState(m.state);
78
97
  else if (m.subtype === 'status') this.setCompacting(m.status === 'compacting');
79
98
  else if (m.subtype === 'compact_boundary') {
80
99
  this.setCompacting(false);
@@ -83,6 +102,18 @@ export class SessionWatch {
83
102
  }
84
103
  }
85
104
 
105
+ private sessionState(state: unknown): void {
106
+ this.sessionStateSeen = true;
107
+ if (state === 'running' || state === 'requires_action') {
108
+ this.busy = true;
109
+ this.requiresAction = state === 'requires_action';
110
+ } else if (state === 'idle') {
111
+ this.busy = false;
112
+ this.requiresAction = false;
113
+ this.settle();
114
+ }
115
+ }
116
+
86
117
  private stream(event: Record<string, unknown>): void {
87
118
  if (event.type === 'message_start' && isRecord(event.message)) {
88
119
  this.streamUsage = isRecord(event.message.usage) ? event.message.usage : {};
@@ -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
- }