@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308

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 (64) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  35. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  40. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  42. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  43. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  44. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/runner.json +1 -1
  47. package/dist/background.js +0 -141
  48. package/dist/claude.js +0 -204
  49. package/dist/mcp-config.js +0 -27
  50. package/dist/native-recovery-scan.js +0 -246
  51. package/dist/native-recovery-state.js +0 -154
  52. package/dist/native-recovery.js +0 -164
  53. package/dist/onboarding.js +0 -44
  54. package/dist/voice.js +0 -56
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  57. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  58. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  59. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  60. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  61. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  62. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  63. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  64. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -8,7 +8,7 @@ import { errMsg } from '@metro-labs/core/log';
8
8
  import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
9
9
  import { accountStationNames, stationByName } from '../stations/registry.js';
10
10
  import { eventInScope } from '../agents/scope.js';
11
- import { MCP_INSTRUCTIONS, VOICE_MCP_INSTRUCTIONS } from './instructions.js';
11
+ import { MCP_INSTRUCTIONS } from './instructions.js';
12
12
  import { BoundedEventStore } from './event-store.js';
13
13
  import { registerPermissionRelay } from './permission-relay.js';
14
14
  import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
@@ -55,12 +55,7 @@ function makeTransport(
55
55
  return t;
56
56
  }
57
57
 
58
- export type SessionKind = 'chat' | 'voice';
59
-
60
- const CHANNEL_CAPABILITIES = { experimental: { 'claude/channel': {}, 'claude/channel/permission': {} } };
61
-
62
58
  export interface SessionInit {
63
- kind?: SessionKind;
64
59
  id: string;
65
60
  scope: Set<string>;
66
61
  adopted: boolean;
@@ -84,7 +79,6 @@ export class McpSession {
84
79
  private issuedSchema: string | undefined;
85
80
  private announcing = false;
86
81
  private readonly onClosed: (session: McpSession) => void;
87
- private readonly kind: SessionKind;
88
82
  private readonly openedAt = new Date().toISOString();
89
83
  private clientInitializedAt: string | null = null;
90
84
  private lastStreamAttachedAt: string | null = null;
@@ -92,20 +86,15 @@ export class McpSession {
92
86
 
93
87
  private constructor(init: SessionInit) {
94
88
  this.id = init.id;
95
- this.kind = init.kind ?? 'chat';
96
89
  this.scope = init.scope;
97
90
  this.live = init.live;
98
91
  this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
99
92
  this.onClosed = init.onClosed;
100
93
  this.eventStore = new BoundedEventStore();
101
94
  this.transport = makeTransport(init.id, this.eventStore, init.adopted);
102
- const voice = init.kind === 'voice';
103
95
  this.server = new Server(
104
96
  { name: 'metro', version: '0.1.0' },
105
- {
106
- capabilities: { ...(voice ? {} : CHANNEL_CAPABILITIES), tools: { listChanged: true } },
107
- instructions: voice ? VOICE_MCP_INSTRUCTIONS : MCP_INSTRUCTIONS,
108
- },
97
+ { capabilities: { tools: { listChanged: true } }, instructions: MCP_INSTRUCTIONS },
109
98
  );
110
99
  this.server.oninitialized = (): void => {
111
100
  this.clientInitializedAt = new Date().toISOString();
@@ -130,16 +119,15 @@ export class McpSession {
130
119
  approves,
131
120
  noteDirect,
132
121
  answerPermission,
133
- callMeta: (event) => voice ? () => ({}) : sharedCalls.bindChat(event, this.scope),
122
+ callMeta: (event) => sharedCalls.bindChat(event, this.scope),
123
+ });
124
+ registerPermissionRelay({
125
+ mcp: this.server,
126
+ relay: this.relay,
127
+ inScope: (line) => this.inScope(line),
128
+ live: this.live,
129
+ log: channelLog,
134
130
  });
135
- if (!voice)
136
- registerPermissionRelay({
137
- mcp: this.server,
138
- relay: this.relay,
139
- inScope: (line) => this.inScope(line),
140
- live: this.live,
141
- log: channelLog,
142
- });
143
131
  this.channel = new ChannelRelay({
144
132
  relay: this.relay,
145
133
  log: channelLog,
@@ -214,7 +202,7 @@ export class McpSession {
214
202
  }
215
203
 
216
204
  private announceCallState(): void {
217
- if (this.kind !== 'chat' || !this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
205
+ if (!this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
218
206
  const active = sharedCalls.snapshot();
219
207
  const route = active !== null && this.scope.has(active.agentId) && eventInScope(this.scope, active.line) ? active : null;
220
208
  this.server.notification({ method: CALL_STATE, params: { route, ...(route === null ? {} : { meta: { line: route.line } }) } }).catch((err: unknown) => {
@@ -264,7 +252,7 @@ export class McpSession {
264
252
  async close(): Promise<void> {
265
253
  if (this.closed) return;
266
254
  this.closed = true;
267
- if (this.kind === 'chat') sharedCalls.revoke();
255
+ sharedCalls.revoke();
268
256
  this.onClosed(this);
269
257
  forgetPromptsOf(this.server);
270
258
  this.dropStream();
@@ -6,9 +6,6 @@ import { bearerSession, clientId, jwksUrl, SigningKeys, workosBase, type KeyStor
6
6
  import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
7
7
 
8
8
  const KEYS_FILE = '.jwks';
9
- const OPERATOR_USER = 'user_01M2TN3K6NRVX675M617GR4WVE';
10
-
11
- export const isOperatorUser = (user: string): boolean => user === OPERATOR_USER;
12
9
 
13
10
  export function jwksStore(dir: string): KeyStore {
14
11
  const path = join(dir, KEYS_FILE);
@@ -28,7 +25,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
28
25
  const held = owner();
29
26
  if (held === null || session.organization === null || session.organization !== held)
30
27
  throw new ApiError('this machine belongs to another organization', 403);
31
- return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
28
+ return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member' };
32
29
  };
33
30
  }
34
31
 
@@ -38,7 +38,6 @@ import { applyMcpCors, handleMcpPreflight } from '@metro-labs/http/cors';
38
38
  import { METRO_VERSION } from '@metro-labs/core/version';
39
39
 
40
40
  const LRU_CAP = 2_000;
41
- export const VOICE_MCP_PATH = '/mcp/voice';
42
41
 
43
42
  function dedupKey(
44
43
  e: Pick<MetroEvent, 'station' | 'line' | 'messageId'>,
@@ -214,7 +213,7 @@ export async function startWebhookServer(
214
213
 
215
214
  function isMcpPath(req: IncomingMessage): boolean {
216
215
  const path = (req.url ?? '').split('?')[0];
217
- return path === '/' || path === '/mcp' || path === VOICE_MCP_PATH || path === MCP_STATUS_PATH;
216
+ return path === '/' || path === '/mcp' || path === MCP_STATUS_PATH;
218
217
  }
219
218
 
220
219
  function flatHeaders(req: IncomingMessage): Record<string, string> {
@@ -1,20 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { CallRoute } from '@metro-labs/core/call';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
4
  import { forwardTrainCall } from '../stations/train-call.js';
6
- import { CallApprovals } from './approvals.js';
7
5
  import type { Playback } from './audio.js';
8
6
  import { Peer, type PeerEvents } from './peer.js';
9
7
  import { SharedTalk } from './shared-talk.js';
10
- import { endSharedCall, hearSharedCall, openSharedCall, sharedCallsSelected, type SharedCallTransport } from './shared.js';
11
- import { Talk } from './talk.js';
8
+ import { endSharedCall, hearSharedCall, openSharedCall, type SharedCallTransport } from './shared.js';
12
9
  import type { VoiceConfig } from './store.js';
13
10
 
14
11
  const OFFER_WAIT_MS = 45_000;
15
12
  const LONGEST_CALL_MS = 60 * 60_000;
16
- const CONTEXT_MESSAGES = 12;
17
- const CONTEXT_CHARS = 400;
18
13
  const LEAVE_RETRY_MS = 3_000;
19
14
  const SIGNAL_WAIT_MS = 2_000;
20
15
 
@@ -22,24 +17,18 @@ export interface CallStart {
22
17
  agentId: string;
23
18
  sourceId: string;
24
19
  line: string;
25
- lineName: string;
26
- direct: boolean;
27
20
  from: string;
28
- callerName: string;
29
21
  callId: string;
30
22
  callerPeer: string;
31
23
  }
32
24
 
33
- type CallAudio = Pick<Talk, 'connect' | 'hear' | 'finish'>;
25
+ type CallAudio = Pick<SharedTalk, 'connect' | 'hear' | 'finish'> & SharedCallTransport;
34
26
  type CallPeer = Pick<Peer, 'answer' | 'sendOpus' | 'close'>;
35
- type ContextStart = Omit<CallStart, 'agentId' | 'sourceId'>;
36
27
 
37
28
  export interface CallDependencies {
38
29
  train: typeof trainCall;
39
30
  peer(events: PeerEvents): CallPeer;
40
- cli(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, approvals: CallApprovals): CallAudio & Pick<Talk, 'prime'>;
41
- shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio & SharedCallTransport;
42
- selected: typeof sharedCallsSelected;
31
+ shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio;
43
32
  open: typeof openSharedCall;
44
33
  heard: typeof hearSharedCall;
45
34
  ended: typeof endSharedCall;
@@ -47,8 +36,6 @@ export interface CallDependencies {
47
36
  leaveRetryMs: number;
48
37
  }
49
38
 
50
- const str = (value: unknown): string => (typeof value === 'string' ? value : '');
51
-
52
39
  export async function trainCall(action: string, args: Record<string, unknown>): Promise<unknown> {
53
40
  const answer = await forwardTrainCall('xmtp', action, args);
54
41
  if (answer.error !== undefined) throw new Error(answer.error);
@@ -58,9 +45,7 @@ export async function trainCall(action: string, args: Record<string, unknown>):
58
45
  const dependencies: CallDependencies = {
59
46
  train: trainCall,
60
47
  peer: (events) => new Peer(events),
61
- cli: (cfg, out, ended, approvals) => new Talk(cfg, out, ended, approvals),
62
48
  shared: (cfg, out, ended, heard) => new SharedTalk(cfg, out, ended, heard),
63
- selected: sharedCallsSelected,
64
49
  open: openSharedCall,
65
50
  heard: hearSharedCall,
66
51
  ended: endSharedCall,
@@ -68,51 +53,11 @@ const dependencies: CallDependencies = {
68
53
  leaveRetryMs: LEAVE_RETRY_MS,
69
54
  };
70
55
 
71
- function speaker(row: Record<string, unknown>, start: ContextStart): string {
72
- if (row.self === true) return 'you';
73
- return row.from === start.from ? start.callerName : `another member (${str(row.from)})`;
74
- }
75
-
76
- function contextLine(row: unknown, start: ContextStart): string | null {
77
- if (!isRecord(row) || typeof row.text !== 'string' || row.text.startsWith('[call')) return null;
78
- const who = speaker(row, start);
79
- return `${str(row.ts).slice(11, 16)} ${who}: ${row.text.replace(/\s+/g, ' ').slice(0, CONTEXT_CHARS)}`;
80
- }
81
-
82
- async function recentChat(start: CallStart, train: typeof trainCall): Promise<string> {
83
- try {
84
- const result = await train('read', { line: start.line, limit: CONTEXT_MESSAGES });
85
- const rows = isRecord(result) && Array.isArray(result.messages) ? result.messages : [];
86
- return rows.map((row) => contextLine(row, start)).filter((l): l is string => l !== null).join('\n');
87
- } catch (err) {
88
- log.warn({ err: errMsg(err) }, 'voice: could not read the chat before the call');
89
- return '';
90
- }
91
- }
92
-
93
- function where(start: ContextStart): string {
94
- if (start.lineName !== '') return `the group chat "${start.lineName}"`;
95
- return start.direct ? `your direct chat with ${start.callerName}` : 'a group chat with no name';
96
- }
97
-
98
- export function opening(start: ContextStart, chat: string): string {
99
- const recent = chat === '' ? 'No earlier messages were found there.' : `Recent messages there before the call, oldest first (times in UTC):\n${chat}`;
100
- return [
101
- `[A voice call with ${start.callerName} is starting on Stage, in ${where(start)}.`,
102
- `Line of this chat: ${start.line} (pass it verbatim to the metro tools, e.g. read, to look at or act on this chat).`,
103
- `Caller: ${start.callerName} (${start.from}).`,
104
- recent,
105
- `Greet ${start.callerName} in a few words.]`,
106
- ].join('\n');
107
- }
108
-
109
56
  export class Call {
110
57
  readonly route: CallRoute;
111
58
  private readonly selfPeer = randomUUID();
112
59
  private readonly startedAt = Date.now();
113
60
  private readonly talk: CallAudio;
114
- private readonly shared: SharedCallTransport | null;
115
- private readonly prime: ((context: string) => void) | null;
116
61
  private readonly deps: CallDependencies;
117
62
  private peer: CallPeer | null = null;
118
63
  private timer: ReturnType<typeof setTimeout> | null = null;
@@ -136,24 +81,9 @@ export class Call {
136
81
  const ended = (reason: string): void => {
137
82
  this.hangUp(reason);
138
83
  };
139
- if (this.deps.selected()) {
140
- const shared = this.deps.shared(cfg, out, ended, (text, sourceId) => {
141
- if (!this.over) this.deps.heard(this.route, text, sourceId);
142
- });
143
- this.talk = shared;
144
- this.shared = shared;
145
- this.prime = null;
146
- } else {
147
- const approvals = new CallApprovals(start.line, async (text) => {
148
- await this.deps.train('send', { line: start.line, text });
149
- });
150
- const cli = this.deps.cli(cfg, out, ended, approvals);
151
- this.talk = cli;
152
- this.shared = null;
153
- this.prime = (context) => {
154
- cli.prime(context);
155
- };
156
- }
84
+ this.talk = this.deps.shared(cfg, out, ended, (text, sourceId) => {
85
+ if (!this.over) this.deps.heard(this.route, text, sourceId);
86
+ });
157
87
  this.longest = setTimeout(() => {
158
88
  this.hangUp('the call reached one hour');
159
89
  }, LONGEST_CALL_MS);
@@ -163,15 +93,7 @@ export class Call {
163
93
  if (this.over || this.begun) return;
164
94
  this.begun = true;
165
95
  log.info({ line: this.start.line, callId: this.start.callId }, 'voice: answering a call');
166
- if (this.shared !== null) {
167
- if (!this.deps.open(this.route, this.start.sourceId, this.shared)) throw new Error('the shared conversation is unavailable');
168
- } else {
169
- recentChat(this.start, this.deps.train).then((chat) => {
170
- if (!this.over) this.prime?.(opening(this.start, chat));
171
- }).catch((err: unknown) => {
172
- log.warn({ err: errMsg(err) }, 'voice: could not start the agent on the call');
173
- });
174
- }
96
+ if (!this.deps.open(this.route, this.start.sourceId, this.talk)) throw new Error('the shared conversation is unavailable');
175
97
  await this.signal({ kind: 'join', callId: this.start.callId, from: this.selfPeer });
176
98
  if (this.over) {
177
99
  await this.sendLeave();
@@ -235,14 +157,9 @@ export class Call {
235
157
  log.info({ reason, seconds: Math.round((Date.now() - this.startedAt) / 1000) }, 'voice: call over');
236
158
  this.talk.finish();
237
159
  this.peer?.close();
238
- if (this.shared !== null) this.deps.ended(this.route);
160
+ this.deps.ended(this.route);
239
161
  this.onOver();
240
- this.left = this.sendLeave().then(async () => {
241
- if (this.shared !== null || reason.startsWith('the caller') || reason === 'no offer arrived') return;
242
- await this.deps.train('send', { line: this.start.line, text: `The voice call ended: ${reason}.` });
243
- }).catch((err: unknown) => {
244
- log.warn({ err: errMsg(err) }, 'voice: wrapping up the call failed');
245
- });
162
+ this.left = this.sendLeave();
246
163
  }
247
164
 
248
165
  private async sendLeave(): Promise<void> {
@@ -26,7 +26,6 @@ const str = (value: unknown): string => (typeof value === 'string' ? value : '')
26
26
 
27
27
  const contentTypeOf = (env: TrainEvent): string => str(record(env.payload).contentType);
28
28
  const sourceIdOf = (env: TrainEvent): string => str(env.message_id) || str(env.id) || randomUUID();
29
- const callerNameOf = (env: TrainEvent): string => str(env.from_display_name) || str(env.from_name) || 'the caller';
30
29
 
31
30
  export const isCallEvent = (env: TrainEvent): boolean => CALL_TYPES.has(contentTypeOf(env));
32
31
 
@@ -74,9 +73,7 @@ function invite(env: TrainEvent, body: Record<string, unknown>, create: MakeCall
74
73
  invites.add(key);
75
74
  if (invites.size > INVITES_MAX) invites.delete(invites.values().next().value ?? '');
76
75
  current?.hangUp('the caller started a new call');
77
- const callerName = callerNameOf(env);
78
- const sourceId = sourceIdOf(env);
79
- const start = { agentId, sourceId, line, lineName: str(env.line_name), direct: env.is_private === true, from, callerName, callId, callerPeer };
76
+ const start = { agentId, sourceId: sourceIdOf(env), line, from, callId, callerPeer };
80
77
  const call = create(start, readVoice(), () => {
81
78
  if (current === call) current = null;
82
79
  });
@@ -7,7 +7,7 @@ export const elevenLabs = (): string => process.env.METRO_ELEVENLABS_URL ?? 'wss
7
7
  const STT_RATE = 16_000;
8
8
  const CHUNK_SAMPLES = STT_RATE / 10;
9
9
  const HELD_SAMPLES = STT_RATE * 2;
10
- export const VAD_SILENCE_SECS = 0.5;
10
+ const VAD_SILENCE_SECS = 0.5;
11
11
 
12
12
  export interface Heard {
13
13
  partial(text: string): void;
@@ -4,7 +4,6 @@ import { str } from '@metro-labs/core/str';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { agentIdForLine, allowlistForLine, lineReceives, mayApprove, senderPermitted, accountFromLine } from '../agents/map.js';
6
6
  import { eventInScope } from '../agents/scope.js';
7
- import { harnessRunner } from '../claude/runner.js';
8
7
  import { policyFor, type Access } from '../policy/policy.js';
9
8
 
10
9
  export interface SharedSpeechAction {
@@ -194,7 +193,6 @@ export class SharedCalls {
194
193
  }
195
194
 
196
195
  export const sharedCalls = new SharedCalls();
197
- export const sharedCallsSelected = (): boolean => harnessRunner() === 'sdk';
198
196
  export const openSharedCall = (route: CallRoute, sourceId: string, transport: SharedCallTransport): boolean => sharedCalls.open(route, sourceId, transport);
199
197
  export const hearSharedCall = (route: CallRoute, text: string, sourceId: string): void => { sharedCalls.heard(route, text, sourceId); };
200
198
  export const endSharedCall = (route: CallRoute): void => { sharedCalls.end(route); };
@@ -61,10 +61,6 @@ export class Utterance {
61
61
  });
62
62
  }
63
63
 
64
- get usable(): boolean {
65
- return !this.closed && this.listener === null && this.ws.readyState <= WebSocket.OPEN;
66
- }
67
-
68
64
  attach(listener: Voiced): void {
69
65
  if (!this.aborted && !this.finished) this.listener = listener;
70
66
  }
@@ -3,7 +3,6 @@ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
3
3
  import { isRecord } from '@metro-labs/core/is-record';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { agentsDir } from '../agents/files.js';
6
- import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
7
6
 
8
7
  const VOICE_PROVIDERS = ['elevenlabs'] as const;
9
8
  type VoiceProvider = (typeof VOICE_PROVIDERS)[number];
@@ -12,7 +11,6 @@ export interface VoiceConfig {
12
11
  provider: VoiceProvider;
13
12
  apiKey: string;
14
13
  voiceId: string;
15
- model: string;
16
14
  language: string;
17
15
  enabled: boolean;
18
16
  }
@@ -22,7 +20,6 @@ const AUTO_LANGUAGE = 'auto';
22
20
  const LANGUAGE_RE = /^(?:[a-z]{2,3}|auto)$/;
23
21
 
24
22
  const DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL';
25
- const DEFAULT_VOICE_MODEL = 'anthropic:claude-sonnet-5-5';
26
23
 
27
24
  const VOICE_FILE = 'voice.json';
28
25
  const MAX_FIELD = 512;
@@ -38,7 +35,6 @@ export function parseVoice(raw: unknown): VoiceConfig {
38
35
  provider: isProvider(r.provider) ? r.provider : 'elevenlabs',
39
36
  apiKey: text(r.apiKey),
40
37
  voiceId: text(r.voiceId),
41
- model: text(r.model),
42
38
  language: LANGUAGE_RE.test(text(r.language)) ? text(r.language) : '',
43
39
  enabled: r.enabled !== false,
44
40
  };
@@ -52,13 +48,6 @@ export function writeVoice(cfg: VoiceConfig, dir = agentsDir()): void {
52
48
  }
53
49
 
54
50
  export const voiceIdOf = (cfg: VoiceConfig): string => (cfg.voiceId === '' ? DEFAULT_VOICE_ID : cfg.voiceId);
55
- const voiceModelOf = (cfg: VoiceConfig): string => (cfg.model === '' ? DEFAULT_VOICE_MODEL : cfg.model);
56
- export function brainModel(cfg: VoiceConfig, models: ModelConfig = readModelConfig()): string {
57
- const model = voiceModelOf(cfg);
58
- const anthropic = /^anthropic:(.+)$/.exec(model)?.[1];
59
- if (anthropic === undefined) return model;
60
- return models.connections.some((c) => c.provider === 'anthropic') ? model : anthropic;
61
- }
62
51
 
63
52
  export function languageOf(cfg: VoiceConfig): string | null {
64
53
  const language = cfg.language === '' ? DEFAULT_LANGUAGE : cfg.language;
@@ -73,10 +62,9 @@ export function publicVoice(cfg: VoiceConfig): Record<string, unknown> {
73
62
  providers: VOICE_PROVIDERS,
74
63
  hasKey: cfg.apiKey !== '',
75
64
  voiceId: cfg.voiceId,
76
- model: cfg.model,
77
65
  language: cfg.language,
78
66
  enabled: cfg.enabled,
79
- defaults: { voiceId: DEFAULT_VOICE_ID, model: DEFAULT_VOICE_MODEL, language: DEFAULT_LANGUAGE },
67
+ defaults: { voiceId: DEFAULT_VOICE_ID, language: DEFAULT_LANGUAGE },
80
68
  };
81
69
  }
82
70
 
@@ -109,7 +97,6 @@ export function patchVoice(cfg: VoiceConfig, body: unknown): VoiceConfig {
109
97
  provider: isProvider(body.provider) ? body.provider : cfg.provider,
110
98
  apiKey: keyOf(body, cfg.apiKey),
111
99
  voiceId: field(body, 'voiceId', cfg.voiceId),
112
- model: field(body, 'model', cfg.model),
113
100
  language: languageField(body, cfg.language),
114
101
  enabled: typeof body.enabled === 'boolean' ? body.enabled : cfg.enabled,
115
102
  };
@@ -1,4 +1,4 @@
1
- import { createHash, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes, sign, verify, type KeyObject } from 'node:crypto';
1
+ import { createHash, createPublicKey, randomBytes, sign, verify, type KeyObject } from 'node:crypto';
2
2
  import { strongEd25519 } from './ed25519.js';
3
3
 
4
4
  export const AGENT_SCHEME = 'MetroAgent';
@@ -25,40 +25,25 @@ export interface AgentProof {
25
25
  signature: string;
26
26
  }
27
27
 
28
- type Curve = 'Ed25519' | 'X25519';
29
-
30
28
  const digest = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('base64url');
31
29
 
32
30
  export const agentKeyId = (signingKey: string): string => digest(Buffer.from(signingKey, 'base64url'));
33
31
 
34
- function publicKeyOf(raw: string, curve: Curve): KeyObject | null {
32
+ function publicKeyOf(raw: string): KeyObject | null {
35
33
  if (!RAW_KEY_RE.test(raw) || Buffer.from(raw, 'base64url').toString('base64url') !== raw) return null;
36
34
  try {
37
- const key = createPublicKey({ key: { kty: 'OKP', crv: curve, x: raw }, format: 'jwk' });
38
- return key.asymmetricKeyType === curve.toLowerCase() ? key : null;
35
+ const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw }, format: 'jwk' });
36
+ return key.asymmetricKeyType === 'ed25519' ? key : null;
39
37
  } catch {
40
38
  return null;
41
39
  }
42
40
  }
43
41
 
44
42
  export function signingKeyOf(raw: string): KeyObject | null {
45
- const key = publicKeyOf(raw, 'Ed25519');
43
+ const key = publicKeyOf(raw);
46
44
  return key !== null && strongEd25519(Buffer.from(raw, 'base64url')) ? key : null;
47
45
  }
48
46
 
49
- function contributes(key: KeyObject): boolean {
50
- try {
51
- return diffieHellman({ privateKey: generateKeyPairSync('x25519').privateKey, publicKey: key }).some((byte) => byte !== 0);
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- export function sealingKeyOf(raw: string): KeyObject | null {
58
- const key = publicKeyOf(raw, 'X25519');
59
- return key !== null && contributes(key) ? key : null;
60
- }
61
-
62
47
  export function rawPublicKey(key: KeyObject): string {
63
48
  const { x } = key.export({ format: 'jwk' });
64
49
  if (typeof x !== 'string') throw new Error('not an OKP public key');
@@ -11,7 +11,6 @@ export interface ApiSession {
11
11
  userId?: string;
12
12
  sessionId?: string;
13
13
  role: Role;
14
- operator?: true;
15
14
  }
16
15
 
17
16
  export type BearerSessions = (req: IncomingMessage) => Promise<ApiSession | null>;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.306"
2
+ "version": "0.1.0-beta.308"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.306",
2
+ "version": "0.1.0-beta.308",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,141 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { basename, join } from 'node:path';
5
- export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
6
- const STOP_MS = 30_000;
7
- const SETTLE_MS = 5_000;
8
- const POLL_MS = 250;
9
- const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
10
- export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
11
- export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
12
- export const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
13
- export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
14
- function startTime(pid) {
15
- try {
16
- const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
17
- return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
18
- }
19
- catch {
20
- return null;
21
- }
22
- }
23
- export function processAlive(pid, procStart) {
24
- try {
25
- process.kill(pid, 0);
26
- }
27
- catch (err) {
28
- if (err.code !== 'EPERM')
29
- return false;
30
- }
31
- const started = procStart === undefined ? null : startTime(pid);
32
- return started === null || started === procStart;
33
- }
34
- function backgroundEntry(file, alive) {
35
- try {
36
- const raw = JSON.parse(readFileSync(file, 'utf8'));
37
- if (!isRecord(raw) || typeof raw.pid !== 'number' || typeof raw.sessionId !== 'string')
38
- return null;
39
- if (raw.kind === 'interactive' || typeof raw.kind !== 'string')
40
- return null;
41
- return alive(raw.pid, typeof raw.procStart === 'string' ? raw.procStart : undefined) ? raw.sessionId : null;
42
- }
43
- catch {
44
- return null;
45
- }
46
- }
47
- export function liveBackground(sessions, alive = processAlive) {
48
- if (!existsSync(sessions))
49
- return new Set();
50
- const ids = readdirSync(sessions)
51
- .filter((name) => name.endsWith('.json'))
52
- .map((name) => backgroundEntry(join(sessions, name), alive));
53
- return new Set(ids.filter((id) => id !== null));
54
- }
55
- function newestFirst(dir) {
56
- if (!existsSync(dir))
57
- return [];
58
- return readdirSync(dir)
59
- .filter((name) => name.endsWith('.jsonl'))
60
- .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs }))
61
- .sort((a, b) => b.at - a.at)
62
- .map((file) => file.path);
63
- }
64
- function continuedIn(text) {
65
- const last = text.split('\n').findLast((line) => line.includes('"type":"continued-in"'));
66
- if (last === undefined)
67
- return null;
68
- try {
69
- const record = JSON.parse(last);
70
- return isRecord(record) && typeof record.continuedInSessionId === 'string' ? record.continuedInSessionId : null;
71
- }
72
- catch {
73
- return null;
74
- }
75
- }
76
- const HAS_TURN = /"type":"(user|assistant)"/;
77
- export function heldConversation(dir, live) {
78
- if (live.size === 0)
79
- return null;
80
- for (const file of newestFirst(dir)) {
81
- const text = readFileSync(file, 'utf8');
82
- if (!HAS_TURN.test(text))
83
- continue;
84
- const moved = continuedIn(text);
85
- if (moved !== null && live.has(moved))
86
- return moved;
87
- if (!live.has(basename(file, '.jsonl')))
88
- return null;
89
- }
90
- return null;
91
- }
92
- function stopBackground(id, env = process.env) {
93
- const run = spawnSync('claude', ['stop', id], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: STOP_MS });
94
- if (run.error !== undefined)
95
- return { ok: false, detail: run.error.message };
96
- const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
97
- return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
98
- }
99
- export function resumeSessionId(args) {
100
- const index = args.findLastIndex((arg) => arg === '--resume' || arg === '-r');
101
- const id = index === -1 ? undefined : args[index + 1];
102
- return id !== undefined && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(id) ? id : undefined;
103
- }
104
- function pinnedHeld(dir, live, id) {
105
- if (live.has(id))
106
- return id;
107
- const path = join(dir, `${id}.jsonl`);
108
- if (!existsSync(path))
109
- return null;
110
- const moved = continuedIn(readFileSync(path, 'utf8'));
111
- return moved !== null && live.has(moved) ? moved : null;
112
- }
113
- const sleep = (ms) => new Promise((resolve) => {
114
- setTimeout(resolve, ms);
115
- });
116
- async function settled(sessions, held, alive, ms) {
117
- if (!liveBackground(sessions, alive).has(held))
118
- return true;
119
- if (ms <= 0)
120
- return false;
121
- await sleep(POLL_MS);
122
- return settled(sessions, held, alive, ms - POLL_MS);
123
- }
124
- function heldFor(deps, dir, live) {
125
- const project = projectDir(dir, deps.cwd ?? process.cwd());
126
- return deps.sessionId === undefined ? heldConversation(project, live) : pinnedHeld(project, live, deps.sessionId);
127
- }
128
- export async function takeBackConversation(deps = {}) {
129
- const env = deps.env ?? process.env;
130
- const alive = deps.alive ?? processAlive;
131
- const dir = claudeDir(env);
132
- const sessions = join(dir, 'sessions');
133
- const held = heldFor(deps, dir, liveBackground(sessions, alive));
134
- if (held === null)
135
- return null;
136
- const id = held.slice(0, 8);
137
- const stopped = (deps.stop ?? stopBackground)(id, env);
138
- if (!stopped.ok || !(await settled(sessions, held, alive, deps.settleMs ?? SETTLE_MS)))
139
- return `the conversation is held by the Claude Code background session ${id} and it could not be stopped (${stopped.detail}), so Claude Code will refuse to continue it: run claude stop ${id}`;
140
- return `the conversation had moved to the Claude Code background session ${id}, where no chat reaches it; stopped that session (its conversation is kept) and continuing it here`;
141
- }