@stage-labs/metro 0.1.0-beta.307 → 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 (60) 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 +3 -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/gateway/forward.ts +0 -1
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  32. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  33. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  34. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  35. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  40. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  41. package/runtime/runtime.json +1 -1
  42. package/runtime/sdk-runner/runner.json +1 -1
  43. package/dist/background.js +0 -141
  44. package/dist/claude.js +0 -204
  45. package/dist/mcp-config.js +0 -27
  46. package/dist/native-recovery-scan.js +0 -246
  47. package/dist/native-recovery-state.js +0 -154
  48. package/dist/native-recovery.js +0 -164
  49. package/dist/onboarding.js +0 -44
  50. package/dist/voice.js +0 -56
  51. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  52. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  53. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  54. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  57. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  58. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  59. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  60. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -1,8 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- import { log } from '@metro-labs/core/log';
3
2
  import { isRecord } from '@metro-labs/core/is-record';
4
3
  import { ApiError } from '@metro-labs/http/api-error';
5
- import { readJsonBody, type ApiSession } from '@metro-labs/http/api-http';
4
+ import { readJsonBody } from '@metro-labs/http/api-http';
6
5
  import {
7
6
  claudeSetupStatus,
8
7
  ensureClaudeSetup,
@@ -19,12 +18,10 @@ import {
19
18
  type PermissionMode,
20
19
  type SetupDeps,
21
20
  } from './setup.js';
22
- import { harnessRunner, isHarnessRunner, sdkAllowed, sdkOnLogin, SDK_NEEDS_KEY, setHarnessRunner, setSdkOnLogin, type HarnessRunner } from './runner.js';
23
- import { assertSdkDrained, assertSessionStopped, prepareSessionRunner, setAutostart, withSessionChange, type SessionDeps } from './session.js';
21
+ import { assertSessionStopped, type SessionDeps } from './session.js';
24
22
  import { tryMemoryJob } from './memory-routine.js';
25
23
 
26
24
  const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
27
- const OPERATOR_ONLY = 'only the Metro operator can allow the Agent SDK on a Claude login';
28
25
 
29
26
  export interface SetupApiDeps {
30
27
  session?: SessionDeps;
@@ -36,8 +33,6 @@ export interface SetupApiDeps {
36
33
  interface SetupChange {
37
34
  privacy?: boolean;
38
35
  permissionMode?: PermissionMode;
39
- runner?: HarnessRunner;
40
- sdkOnLogin?: boolean;
41
36
  systemPrompt?: string;
42
37
  liveEvents?: boolean;
43
38
  memoryRoutine?: boolean;
@@ -62,19 +57,11 @@ function modeChange(raw: unknown): PermissionMode | undefined {
62
57
  return raw;
63
58
  }
64
59
 
65
- function runnerChange(raw: unknown): HarnessRunner | undefined {
66
- if (raw === undefined) return undefined;
67
- if (!isHarnessRunner(raw)) throw new ApiError('runner must be cli or sdk', 400);
68
- return raw;
69
- }
70
-
71
60
  function setupChange(body: unknown): SetupChange {
72
61
  if (!isRecord(body)) throw new ApiError('a body is required', 400);
73
62
  const change: SetupChange = {
74
63
  privacy: flagChange(body.privacy, 'privacy'),
75
64
  permissionMode: modeChange(body.permissionMode),
76
- runner: runnerChange(body.runner),
77
- sdkOnLogin: flagChange(body.sdkOnLogin, 'sdkOnLogin'),
78
65
  systemPrompt: promptChange(body.systemPrompt),
79
66
  liveEvents: flagChange(body.liveEvents, 'liveEvents'),
80
67
  memoryRoutine: flagChange(body.memoryRoutine, 'memoryRoutine'),
@@ -84,44 +71,11 @@ function setupChange(body: unknown): SetupChange {
84
71
  return given;
85
72
  }
86
73
 
87
- function checkRunnerChange(change: SetupChange, session: ApiSession, agents: string | undefined): void {
88
- if (change.sdkOnLogin !== undefined && session.operator !== true) throw new ApiError(OPERATOR_ONLY, 403);
89
- if (change.runner === 'sdk' && !sdkAllowed(agents, undefined, change.sdkOnLogin ?? sdkOnLogin(agents))) throw new ApiError(SDK_NEEDS_KEY, 403);
90
- }
91
-
92
- function nextRunner(change: SetupChange, agents: string | undefined): HarnessRunner {
93
- const runner = change.runner ?? harnessRunner(agents);
94
- return runner === 'sdk' && change.sdkOnLogin === false && !sdkAllowed(agents, undefined, false) ? 'cli' : runner;
95
- }
96
-
97
74
  function changesSession(change: SetupChange, agents: string | undefined): boolean {
98
- return nextRunner(change, agents) !== harnessRunner(agents)
99
- || (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
75
+ return (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
100
76
  || (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents));
101
77
  }
102
78
 
103
- function saveSessionSettings(change: SetupChange, runner: HarnessRunner, agents: string | undefined): void {
104
- if (change.sdkOnLogin !== undefined && change.sdkOnLogin !== sdkOnLogin(agents)) {
105
- setSdkOnLogin(change.sdkOnLogin, agents);
106
- log.info({ on: change.sdkOnLogin }, 'claude-setup: the operator changed whether the Agent SDK may run on this box’s Claude login');
107
- }
108
- if (runner !== harnessRunner(agents)) {
109
- setAutostart(false, agents);
110
- setHarnessRunner(runner, agents);
111
- }
112
- if (change.permissionMode !== undefined) setPermissionMode(change.permissionMode, agents);
113
- if (change.systemPrompt !== undefined) setSystemPrompt(change.systemPrompt, agents);
114
- }
115
-
116
- function checkStoppedChange(change: SetupChange, deps: SetupApiDeps): HarnessRunner {
117
- const agents = deps.setup?.agents;
118
- const session = { ...deps.session, agents: deps.session?.agents ?? agents };
119
- const runner = nextRunner(change, agents);
120
- if (changesSession(change, agents)) assertSessionStopped(session);
121
- if (runner !== harnessRunner(agents) && harnessRunner(agents) === 'sdk') assertSdkDrained(session);
122
- return runner;
123
- }
124
-
125
79
  function applyLiveEvents(on: boolean, deps: SetupApiDeps): void {
126
80
  const agents = deps.setup?.agents;
127
81
  if (on !== liveEvents(agents)) setLiveEvents(on, agents);
@@ -133,29 +87,28 @@ function applyMemoryRoutine(on: boolean, deps: SetupApiDeps): void {
133
87
  (deps.memoryJob ?? tryMemoryJob)(on);
134
88
  }
135
89
 
136
- async function applySetupChange(change: SetupChange, session: ApiSession, deps: SetupApiDeps, req: IncomingMessage): Promise<void> {
90
+ function saveSessionSettings(change: SetupChange, deps: SetupApiDeps): void {
91
+ const agents = deps.setup?.agents;
92
+ if (changesSession(change, agents)) assertSessionStopped({ ...deps.session, agents: deps.session?.agents ?? agents });
93
+ if (change.permissionMode !== undefined) setPermissionMode(change.permissionMode, agents);
94
+ if (change.systemPrompt !== undefined) setSystemPrompt(change.systemPrompt, agents);
95
+ }
96
+
97
+ function applySetupChange(change: SetupChange, deps: SetupApiDeps): void {
137
98
  const setup = deps.setup ?? {};
138
- checkRunnerChange(change, session, setup.agents);
139
- const runner = checkStoppedChange(change, deps);
140
- if (runner !== harnessRunner(setup.agents)) {
141
- await prepareSessionRunner(runner, deps.session);
142
- checkRunnerChange(change, session, setup.agents);
143
- if (checkStoppedChange(change, deps) !== runner) throw new ApiError('The model setup changed during preparation. Choose the runner again.', 409);
144
- }
145
- if (req.socket.destroyed) throw new ApiError('The request disconnected during preparation. The runner choice was not changed.', 409);
99
+ saveSessionSettings(change, deps);
146
100
  if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
147
101
  if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
148
102
  if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
149
- saveSessionSettings(change, runner, setup.agents);
150
103
  ensureClaudeSetup(setup);
151
104
  }
152
105
 
153
- export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps, session: ApiSession): Promise<unknown> {
106
+ export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps): Promise<unknown> {
154
107
  const setup = deps.setup ?? {};
155
108
  const method = req.method ?? 'GET';
156
109
  if (method === 'GET') return claudeSetupStatus(setup);
157
110
  if (method !== 'POST') throw new ApiError('method not allowed', 405);
158
111
  const change = setupChange(await readJsonBody(req, SETUP_BODY_MAX));
159
- await withSessionChange(() => applySetupChange(change, session, deps, req));
112
+ applySetupChange(change, deps);
160
113
  return claudeSetupStatus(setup);
161
114
  }
@@ -11,8 +11,6 @@ import { claudeDir } from './files.js';
11
11
  import { memoryJobStatus, type MemoryJob } from './memory-routine.js';
12
12
  import { cliPackageDir, hooksDir, skillsDir } from './cli-files.js';
13
13
  import { hasMetroGuard, withMetroHooks } from './hooks.js';
14
- import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
15
- import { harnessRunner, runnerModel, sdkAllowed, sdkOnLogin, type HarnessRunner } from './runner.js';
16
14
  import { readSetupState, writeSetupState } from './setup-state.js';
17
15
 
18
16
  export const PRIVACY_ENV: Record<string, string> = {
@@ -69,9 +67,6 @@ export interface SetupReport {
69
67
  export interface SetupStatus {
70
68
  privacy: boolean;
71
69
  permissionMode: PermissionMode;
72
- runner: HarnessRunner;
73
- runnerAllowed: boolean;
74
- sdkOnLogin: boolean;
75
70
  systemPrompt: string;
76
71
  liveEvents: boolean;
77
72
  memoryRoutine: boolean;
@@ -218,16 +213,23 @@ interface SettingsApplied {
218
213
  guard: boolean;
219
214
  }
220
215
 
221
- function applySettings(dir: string, privacy: boolean, hooks: string | null): SettingsApplied {
216
+ const MODELS_KEYS = new Set(['availableModels', 'enforceAvailableModels']);
217
+
218
+ const withoutMetroModels = (settings: Record<string, unknown>, metroWrote: boolean): Record<string, unknown> =>
219
+ metroWrote ? Object.fromEntries(Object.entries(settings).filter(([key]) => !MODELS_KEYS.has(key))) : settings;
220
+
221
+ function applySettings(dir: string, agents: string, privacy: boolean, hooks: string | null): SettingsApplied {
222
+ const metroModels = readSetupState(agents).modelsByMetro === true;
222
223
  let guard = false;
223
224
  const settings = mergeSettings(dir, (current) => {
224
- const next = withPrivacy(current, privacy);
225
+ const next = withPrivacy(withoutMetroModels(current, metroModels), privacy);
225
226
  const hooked = hooks === null ? null : withMetroHooks(next, hooks);
226
227
  if (hooks !== null && hooked === null)
227
228
  log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: the hooks in settings.json are not in the shape Claude Code reads, so metro did not add its own');
228
229
  guard = hasMetroGuard(hooked ?? next);
229
230
  return hooked ?? next;
230
231
  });
232
+ if (metroModels && settings !== 'unreadable') writeSetupState(agents, { modelsByMetro: false });
231
233
  return { settings, guard };
232
234
  }
233
235
 
@@ -280,9 +282,8 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
280
282
  const skill = placeSkill(dir, skills, METRO_SKILL);
281
283
  const stage = placeSkill(dir, skills, STAGE_SKILL);
282
284
  const memory = placeSkill(dir, skills, MEMORY_SKILL);
283
- const applied = applySettings(dir, privacy, hooks);
285
+ const applied = applySettings(dir, agents, privacy, hooks);
284
286
  const report: SetupReport = { privacy, guard: applied.guard, worker, skill, stage, memory, settings: applied.settings };
285
- syncAvailableModelsQuietly({ ...deps, dir, agents });
286
287
  if ([report.worker, report.skill, report.stage, report.memory].some(written) || report.settings === 'written')
287
288
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
288
289
  if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings and metro hooks were not written');
@@ -299,9 +300,6 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
299
300
  return {
300
301
  privacy: privacyEnabled(agents),
301
302
  permissionMode: permissionMode(agents),
302
- runner: harnessRunner(agents),
303
- runnerAllowed: sdkAllowed(agents),
304
- sdkOnLogin: sdkOnLogin(agents),
305
303
  systemPrompt: systemPrompt(agents),
306
304
  liveEvents: liveEvents(agents),
307
305
  memoryRoutine: memoryRoutine(agents),
@@ -323,33 +321,3 @@ export function tryClaudeSetup(deps: SetupDeps = {}): void {
323
321
  log.warn({ err: errMsg(err) }, 'claude-setup: could not apply the Claude Code setup');
324
322
  }
325
323
  }
326
-
327
- const MODELS_KEY = 'availableModels';
328
- const ENFORCE_KEY = 'enforceAvailableModels';
329
-
330
- export const routeOf = (cfg: ModelConfig): string | null => (routedConnection(cfg)?.provider === 'anthropic' ? null : runnerModel(cfg));
331
-
332
- function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
333
- if (route !== null) return { ...settings, [MODELS_KEY]: [route], [ENFORCE_KEY]: true };
334
- if (!metroWrote) return settings;
335
- return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== MODELS_KEY && key !== ENFORCE_KEY));
336
- }
337
-
338
- export function syncAvailableModels(cfg: ModelConfig, deps: SetupDeps = {}): SettingsOutcome {
339
- const dir = deps.dir ?? claudeDir();
340
- const agents = deps.agents ?? agentsDir();
341
- const route = routeOf(cfg);
342
- const metroWrote = readSetupState(agents).modelsByMetro === true;
343
- return mergeSettings(dir, (current) => {
344
- writeSetupState(agents, { modelsByMetro: route !== null });
345
- return withAvailableModels(current, route, metroWrote);
346
- });
347
- }
348
-
349
- export function syncAvailableModelsQuietly(deps: SetupDeps = {}, cfg?: ModelConfig): void {
350
- try {
351
- syncAvailableModels(cfg ?? readModelConfig(deps.agents ?? agentsDir()), deps);
352
- } catch (err) {
353
- log.warn({ err: errMsg(err) }, 'claude-setup: could not sync the model allowlist');
354
- }
355
- }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
- import { agentCommand, agentUser, agentUserExpected, claudeBin } from '../agent-user/user.js';
4
+ import { agentCommand, agentUser, agentUserExpected, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
5
5
  import { runningAsRoot } from '../metro-user/privilege.js';
6
6
  import { PRIVACY_ENV } from './setup.js';
7
7
 
@@ -24,7 +24,7 @@ const ARGS = [
24
24
  const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
25
25
  const PROBE_ENV: Record<string, string> = {
26
26
  ...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
27
- DISABLE_AUTOUPDATER: '1',
27
+ ...CLAUDE_ENV,
28
28
  DISABLE_BUG_COMMAND: '1',
29
29
  ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
30
30
  };
@@ -28,7 +28,6 @@ const HOP_BY_HOP = new Set([
28
28
  'upgrade',
29
29
  'accept-encoding',
30
30
  'x-metro-key',
31
- 'x-metro-runner',
32
31
  ]);
33
32
  const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
34
33
 
@@ -2,7 +2,6 @@ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { cameThroughProxy } from '@metro-labs/http/api-http';
4
4
  import { agentIdForKey } from '../agents/keys.js';
5
- import { sdkAllowed, sdkConnectionAllowed, SDK_NEEDS_KEY } from '../claude/runner.js';
6
5
  import {
7
6
  assertBedrockReady,
8
7
  bedrockBase,
@@ -98,11 +97,6 @@ const keyOf = (req: IncomingMessage): string => {
98
97
  return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
99
98
  };
100
99
 
101
- const fromSdkRunner = (req: IncomingMessage): boolean => {
102
- const raw = req.headers['x-metro-runner'];
103
- return (Array.isArray(raw) ? raw[0] : raw)?.trim() === 'sdk';
104
- };
105
-
106
100
  const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
107
101
 
108
102
  function modelsBody(cfg: ModelConfig): Record<string, unknown> {
@@ -299,15 +293,9 @@ interface Request {
299
293
  cfg: ModelConfig;
300
294
  }
301
295
 
302
- function checkSdkRoute(req: IncomingMessage, route: Route, deps: GatewayDeps): void {
303
- if (fromSdkRunner(req) && (!sdkAllowed(undefined, deps.config()) || !sdkConnectionAllowed(route.connection)))
304
- throw new GatewayError(403, 'permission_error', SDK_NEEDS_KEY);
305
- }
306
-
307
296
  async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
308
297
  const { path, body } = ask;
309
298
  const conn = route.connection;
310
- checkSdkRoute(req, route, deps);
311
299
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
312
300
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
313
301
  if (conn.provider === 'bedrock') {
@@ -391,6 +379,6 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
391
379
  }
392
380
  if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
393
381
  else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
394
- else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
382
+ else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent');
395
383
  return true;
396
384
  }
@@ -5,9 +5,7 @@ import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { log } from '@metro-labs/core/log';
6
6
  import { openrouterModels, openrouterZdrModels } from './openrouter.js';
7
7
  import { anthropicModels, bedrockModels } from './provider-models.js';
8
- import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
9
- import { harnessRunner, runnerModel, sdkAllowed } from '../claude/runner.js';
10
- import { sessionLive, sessionRunning, stopSession } from '../claude/session.js';
8
+ import { runnerAlive } from '../claude/session.js';
11
9
  import { forgetOne, forgetReported } from './usage.js';
12
10
  import {
13
11
  addConnection,
@@ -16,6 +14,7 @@ import {
16
14
  readModelConfig,
17
15
  readModelSnapshot,
18
16
  removeConnection,
17
+ routedConnection,
19
18
  setFallbacks,
20
19
  setRoute,
21
20
  updateConnection,
@@ -45,40 +44,29 @@ const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
45
44
 
46
45
  export type { ModelApiDeps } from './model-store.js';
47
46
 
48
- function restartRunningSession(): boolean {
49
- if (!sessionRunning()) return false;
50
- stopSession();
51
- return true;
47
+ function runnerModel(cfg: ModelConfig): string | null {
48
+ const conn = routedConnection(cfg);
49
+ if (conn === null || conn.model === '') return null;
50
+ return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
52
51
  }
53
52
 
54
53
  function switchLive(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
55
54
  const [was, now] = [runnerModel(before), runnerModel(next)];
56
55
  if (was === now) return;
57
56
  if (deps.switchModel?.(now) === true) log.info({ was, now }, 'model-api: the model changed, so the Agent SDK session switches to it live, with no restart');
58
- else if (runnerIsLive(deps)) throw new ApiError('The running Agent SDK could not be told about this model change. Nothing was saved. Let its work finish, stop it, then save and start it again.', 409);
59
- }
60
-
61
- function followModel(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
62
- const restart = deps.restartSession ?? restartRunningSession;
63
- const agents = deps.setup?.agents;
64
- if (harnessRunner(agents) === 'sdk') {
65
- if (sdkAllowed(agents, next)) switchLive(before, next, deps);
66
- return;
67
- }
68
- if (routeOf(next) !== routeOf(before) && restart())
69
- log.info({ was: routeOf(before), now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
57
+ else if (runnerIsUp(deps)) throw new ApiError('The running Agent SDK could not be told about this model change. Nothing was saved. Let its work finish, stop it, then save and start it again.', 409);
58
+ else log.info({ was, now }, 'model-api: the model changed while no Agent SDK runner is up; its next start loads it');
70
59
  }
71
60
 
72
61
  function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
73
62
  const before = store.read();
74
63
  store.write(next);
75
64
  try {
76
- followModel(before, next, deps);
65
+ switchLive(before, next, deps);
77
66
  } catch (err) {
78
67
  store.write(before);
79
68
  throw err;
80
69
  }
81
- syncAvailableModelsQuietly(deps.setup ?? {}, next);
82
70
  log.info(fields, note);
83
71
  return settingsBody(next);
84
72
  }
@@ -218,14 +206,12 @@ function routeFor(path: string, method: string | undefined): Route | number {
218
206
  return codexRoute(path.slice(CODEX.length), method);
219
207
  }
220
208
 
221
- const runnerIsLive = (deps: ModelApiDeps): boolean => (deps.sessionRunning ?? (() => sessionLive({ agents: deps.setup?.agents })))();
209
+ const runnerIsUp = (deps: ModelApiDeps): boolean => (deps.runnerAlive ?? (() => runnerAlive()))();
222
210
 
223
- function checkRunnerRoute(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
224
- const agents = deps.setup?.agents;
225
- if (harnessRunner(agents) !== 'sdk' || !sdkAllowed(agents, before) || sdkAllowed(agents, next)) return;
226
- if (runnerIsLive(deps))
227
- throw new ApiError('The Agent SDK session is running, and this change would leave a model or fallback it may not use. Let it finish its work, stop it, then save the change.', 409);
228
- }
211
+ const storeFor = (path: string, deps: ModelApiDeps): Store => ({
212
+ read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
213
+ write: deps.write ?? writeModelConfig,
214
+ });
229
215
 
230
216
  export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
231
217
  const path = (req.url ?? '').split('?')[0] ?? '';
@@ -234,13 +220,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
234
220
  res.writeHead(204, cors(req)).end();
235
221
  return true;
236
222
  }
237
- const store: Store = {
238
- read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
239
- write: (cfg) => {
240
- checkRunnerRoute(store.read(), cfg, deps);
241
- (deps.write ?? writeModelConfig)(cfg);
242
- },
243
- };
223
+ const store = storeFor(path, deps);
244
224
  if (handleOpenRouterSignIn(req, res, deps, store)) return true;
245
225
  const route = routeFor(path, req.method);
246
226
  if (typeof route === 'number') {
@@ -27,8 +27,7 @@ export interface ModelApiDeps {
27
27
  anthropicBase?: string;
28
28
  bedrockControlBase?: string;
29
29
  setup?: SetupDeps;
30
- restartSession?: () => boolean;
31
- sessionRunning?: () => boolean;
30
+ runnerAlive?: () => boolean;
32
31
  switchModel?: (model: string | null) => boolean;
33
32
  claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
34
33
  claudeLoginsRoot?: string;
@@ -2,7 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import {
3
3
  BodyTooLargeError,
4
4
  readBody as readBodyBuffer,
5
- VOICE_MCP_PATH,
6
5
  } from '../routes/http.js';
7
6
  import {
8
7
  authenticate,
@@ -97,9 +96,7 @@ async function serveGet(
97
96
  if (served) session.replayMissed();
98
97
  }
99
98
 
100
- const isVoicePath = (req: IncomingMessage): boolean => (req.url ?? '').split('?')[0] === VOICE_MCP_PATH;
101
-
102
- let activeSlots: SessionSlot[] = [];
99
+ let activeSlot: SessionSlot | undefined;
103
100
  let stopToolNotices: (() => void) | undefined;
104
101
 
105
102
  const MODEL_NOTICE = 'notifications/metro/model';
@@ -118,13 +115,12 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
118
115
  setLiveEvents: (on: boolean) => void;
119
116
  switchModel: (model: string | null) => boolean;
120
117
  }> {
121
- await Promise.all(activeSlots.map((s) => s.close()));
118
+ await activeSlot?.close();
122
119
  const slot = new SessionSlot(options.liveEvents);
123
- const voiceSlot = new SessionSlot(false, 'voice');
124
- activeSlots = [slot, voiceSlot];
120
+ activeSlot = slot;
125
121
  stopToolNotices?.();
126
122
  stopToolNotices = onConnectorToolsChanged(() => {
127
- for (const each of [slot, voiceSlot]) each.current?.toolsChanged();
123
+ slot.current?.toolsChanged();
128
124
  });
129
125
  connectCallBridge(() => slot.current);
130
126
  connectServerApprovals(() => slot.current);
@@ -142,13 +138,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
142
138
  const parsed = await readOrReject(req, res);
143
139
  if (!parsed.ok) return;
144
140
  await runWithIdentity(identity, async () => {
145
- const session = await resolveSession(
146
- isVoicePath(req) ? voiceSlot : slot,
147
- req,
148
- parsed.body,
149
- identity,
150
- res,
151
- );
141
+ const session = await resolveSession(slot, req, parsed.body, identity, res);
152
142
  if (!session) return;
153
143
  if (isStandaloneGet(req)) {
154
144
  await serveGet(session, req, res);
@@ -19,14 +19,3 @@ export const MCP_INSTRUCTIONS =
19
19
  'prompts are relayed to the chat; the owner answers "yes <id>"/"no <id>". Connectors ' +
20
20
  '(third-party tools like Notion, n8n, Gmail) are reached only through `tool_search` then ' +
21
21
  '`tool_call`, from a worker.';
22
-
23
- export const VOICE_MCP_INSTRUCTIONS =
24
- "Metro's chat tools, opened for a live voice call. The caller hears what you say, so do not " +
25
- 'answer them with `send` or `reply` and do not post the call in a chat; post, react or act on ' +
26
- 'a channel only when the caller asks for it, once. `read` with a `line` (passed verbatim) ' +
27
- 'shows that chat; `group_info` and `list_members` show who is in it; `get_profile` with a ' +
28
- '`from` names a sender. The owner may block a tool on an account or make it need approval ' +
29
- '(`list_accounts` shows each `policy`). A blocked call errors. A call needing approval must ' +
30
- 'run in a background worker: on the main thread it is refused, so delegate that exact call ' +
31
- 'and keep talking; the worker waits while the owner is asked in the chat of the call, where ' +
32
- 'they answer "yes <id>"/"no <id>". Connectors are reached through `tool_search` then `tool_call`.';
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { currentBusSeq } from '@metro-labs/core/events';
3
3
  import { errMsg } from '@metro-labs/core/log';
4
4
  import { newReplayLedger } from '../channels/relay.js';
5
- import { McpSession, channelLog, type SessionKind } from './session.js';
5
+ import { McpSession, channelLog } from './session.js';
6
6
  import { allowedAgents, type RequestIdentity } from './request-identity.js';
7
7
  import { diagnosticTag, type SessionDiagnostic, type SlotDiagnostic } from './diagnostics.js';
8
8
 
@@ -25,10 +25,7 @@ export class SessionSlot {
25
25
  private changing: Promise<void> = Promise.resolve();
26
26
  private readonly replacements: SlotReplacement[] = [];
27
27
 
28
- constructor(
29
- live = true,
30
- private readonly kind: SessionKind = 'chat',
31
- ) {
28
+ constructor(live = true) {
32
29
  this.live = live;
33
30
  }
34
31
 
@@ -111,7 +108,6 @@ export class SessionSlot {
111
108
  if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
112
109
  const id = adoptId ?? randomUUID();
113
110
  const session = await McpSession.open({
114
- kind: this.kind,
115
111
  id,
116
112
  scope: allowedAgents(identity),
117
113
  adopted: adoptId !== undefined,
@@ -129,7 +125,7 @@ export class SessionSlot {
129
125
  this.session = session;
130
126
  this.recordReplacement(session, adoptId, previous);
131
127
  if (this.started && this.live) session.startChannel();
132
- channelLog('session: opened', 'id', id, 'kind', this.kind, 'adopted', adoptId !== undefined);
128
+ channelLog('session: opened', 'id', id, 'adopted', adoptId !== undefined);
133
129
  return session;
134
130
  }
135
131
 
@@ -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> {