@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.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -2
- package/runtime/sdk-runner/src/app.ts +5 -4
- package/runtime/sdk-runner/src/approvals.ts +14 -5
- package/runtime/sdk-runner/src/calls.ts +133 -0
- package/runtime/sdk-runner/src/inbox.ts +58 -26
- package/runtime/sdk-runner/src/input-queue.ts +75 -0
- package/runtime/sdk-runner/src/input-timing.ts +68 -0
- package/runtime/sdk-runner/src/link.ts +27 -4
- package/runtime/sdk-runner/src/main.ts +0 -4
- package/runtime/sdk-runner/src/rules.ts +19 -5
- package/runtime/sdk-runner/src/runner.ts +67 -89
- package/runtime/sdk-runner/src/session-store.ts +14 -1
- package/runtime/sdk-runner/src/session-watch.ts +51 -20
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
90
|
+
private compactAsked: Uuid | null = null;
|
|
79
91
|
private compactFloor: number | null = null;
|
|
80
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
this.compactAsked
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
69
|
-
return this.writing ?
|
|
73
|
+
whenNotWriting(): Promise<void> {
|
|
74
|
+
return this.writing ? new Promise((resolve) => { this.idle.push(resolve); }) : Promise.resolve();
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
whenNotCompacting(
|
|
73
|
-
return this.compactingNow ?
|
|
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 === '
|
|
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
|
-
}
|