@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260

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 (79) hide show
  1. package/dist/agent.js +128 -0
  2. package/dist/background.js +19 -1
  3. package/dist/claude.js +11 -7
  4. package/dist/cli.js +8 -0
  5. package/dist/route.js +17 -9
  6. package/dist/runtime-install.js +3 -2
  7. package/package.json +1 -1
  8. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  9. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  10. package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
  11. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
  24. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
  29. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
  30. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
  32. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
  37. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
  38. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
  39. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
  40. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
  41. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
  42. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
  43. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
  44. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
  45. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
  46. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
  47. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
  48. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
  49. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
  50. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
  51. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
  52. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
  53. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
  54. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
  55. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
  56. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
  57. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
  58. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
  59. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
  60. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
  61. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
  62. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
  63. package/runtime/sdk-runner/runner.json +10 -0
  64. package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
  65. package/runtime/sdk-runner/src/activity.ts +293 -0
  66. package/runtime/sdk-runner/src/app.ts +77 -0
  67. package/runtime/sdk-runner/src/approvals.ts +28 -0
  68. package/runtime/sdk-runner/src/channel-text.ts +26 -0
  69. package/runtime/sdk-runner/src/config.ts +50 -0
  70. package/runtime/sdk-runner/src/inbox.ts +94 -0
  71. package/runtime/sdk-runner/src/link.ts +130 -0
  72. package/runtime/sdk-runner/src/main.ts +44 -0
  73. package/runtime/sdk-runner/src/rules.ts +11 -0
  74. package/runtime/sdk-runner/src/runner.ts +251 -0
  75. package/runtime/sdk-runner/src/session-store.ts +71 -0
  76. package/runtime/sdk-runner/src/session-watch.ts +111 -0
  77. package/runtime/sdk-runner/src/singleton.ts +15 -0
  78. package/runtime/sdk-runner/src/speech.ts +79 -0
  79. package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
@@ -0,0 +1,161 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { log } from '@metro-labs/core/log';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { readJsonBody, type ApiSession } from '@metro-labs/http/api-http';
6
+ import {
7
+ claudeSetupStatus,
8
+ ensureClaudeSetup,
9
+ isPermissionMode,
10
+ liveEvents,
11
+ permissionMode,
12
+ setLiveEvents,
13
+ setMemoryRoutine,
14
+ setPermissionMode,
15
+ setPrivacy,
16
+ setSystemPrompt,
17
+ systemPrompt,
18
+ SYSTEM_PROMPT_MAX,
19
+ type PermissionMode,
20
+ type SetupDeps,
21
+ } 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';
24
+ import { tryMemoryJob } from './memory-routine.js';
25
+
26
+ 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
+
29
+ export interface SetupApiDeps {
30
+ session?: SessionDeps;
31
+ setup?: SetupDeps;
32
+ liveEvents?: (on: boolean) => void;
33
+ memoryJob?: (on: boolean) => void;
34
+ }
35
+
36
+ interface SetupChange {
37
+ privacy?: boolean;
38
+ permissionMode?: PermissionMode;
39
+ runner?: HarnessRunner;
40
+ sdkOnLogin?: boolean;
41
+ systemPrompt?: string;
42
+ liveEvents?: boolean;
43
+ memoryRoutine?: boolean;
44
+ }
45
+
46
+ function promptChange(raw: unknown): string | undefined {
47
+ if (raw === undefined) return undefined;
48
+ if (typeof raw !== 'string') throw new ApiError('systemPrompt must be text', 400);
49
+ if (Buffer.byteLength(raw) > SYSTEM_PROMPT_MAX) throw new ApiError(`systemPrompt must be under ${String(SYSTEM_PROMPT_MAX / 1024)} KiB`, 400);
50
+ return raw;
51
+ }
52
+
53
+ function flagChange(raw: unknown, name: string): boolean | undefined {
54
+ if (raw === undefined) return undefined;
55
+ if (typeof raw !== 'boolean') throw new ApiError(`${name} must be true or false`, 400);
56
+ return raw;
57
+ }
58
+
59
+ function modeChange(raw: unknown): PermissionMode | undefined {
60
+ if (raw === undefined) return undefined;
61
+ if (!isPermissionMode(raw)) throw new ApiError('permissionMode must be auto or bypass', 400);
62
+ return raw;
63
+ }
64
+
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
+ function setupChange(body: unknown): SetupChange {
72
+ if (!isRecord(body)) throw new ApiError('a body is required', 400);
73
+ const change: SetupChange = {
74
+ privacy: flagChange(body.privacy, 'privacy'),
75
+ permissionMode: modeChange(body.permissionMode),
76
+ runner: runnerChange(body.runner),
77
+ sdkOnLogin: flagChange(body.sdkOnLogin, 'sdkOnLogin'),
78
+ systemPrompt: promptChange(body.systemPrompt),
79
+ liveEvents: flagChange(body.liveEvents, 'liveEvents'),
80
+ memoryRoutine: flagChange(body.memoryRoutine, 'memoryRoutine'),
81
+ };
82
+ const given = Object.fromEntries(Object.entries(change).filter(([, value]) => value !== undefined)) as SetupChange;
83
+ if (Object.keys(given).length === 0) throw new ApiError('nothing to change', 400);
84
+ return given;
85
+ }
86
+
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
+ function changesSession(change: SetupChange, agents: string | undefined): boolean {
98
+ return nextRunner(change, agents) !== harnessRunner(agents)
99
+ || (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
100
+ || (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents));
101
+ }
102
+
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
+ function applyLiveEvents(on: boolean, deps: SetupApiDeps): void {
126
+ const agents = deps.setup?.agents;
127
+ if (on !== liveEvents(agents)) setLiveEvents(on, agents);
128
+ deps.liveEvents?.(on);
129
+ }
130
+
131
+ function applyMemoryRoutine(on: boolean, deps: SetupApiDeps): void {
132
+ setMemoryRoutine(on, deps.setup?.agents);
133
+ (deps.memoryJob ?? tryMemoryJob)(on);
134
+ }
135
+
136
+ async function applySetupChange(change: SetupChange, session: ApiSession, deps: SetupApiDeps, req: IncomingMessage): Promise<void> {
137
+ 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);
146
+ if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
147
+ if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
148
+ if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
149
+ saveSessionSettings(change, runner, setup.agents);
150
+ ensureClaudeSetup(setup);
151
+ }
152
+
153
+ export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps, session: ApiSession): Promise<unknown> {
154
+ const setup = deps.setup ?? {};
155
+ const method = req.method ?? 'GET';
156
+ if (method === 'GET') return claudeSetupStatus(setup);
157
+ if (method !== 'POST') throw new ApiError('method not allowed', 405);
158
+ const change = setupChange(await readJsonBody(req, SETUP_BODY_MAX));
159
+ await withSessionChange(() => applySetupChange(change, session, deps, req));
160
+ return claudeSetupStatus(setup);
161
+ }
@@ -0,0 +1,18 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
5
+
6
+ const STATE_FILE = 'claude-setup.json';
7
+
8
+ const statePath = (agents: string): string => join(agents, STATE_FILE);
9
+
10
+ export function readSetupState(agents: string): Record<string, unknown> {
11
+ const raw = readJson<unknown>(statePath(agents), null);
12
+ return isRecord(raw) ? raw : {};
13
+ }
14
+
15
+ export function writeSetupState(agents: string, patch: Record<string, unknown>): void {
16
+ mkdirSync(agents, { recursive: true });
17
+ writeJson(statePath(agents), { ...readSetupState(agents), ...patch });
18
+ }
@@ -1,17 +1,19 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdirSync, rmSync } from 'node:fs';
2
+ import { rmSync } from 'node:fs';
3
3
  import { existsSync, readFileSync, statSync } from '../agent-user/agent-fs.js';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
7
  import { isRecord } from '@metro-labs/core/is-record';
8
- import { readJson, writeAtomic, writeJson } from '@metro-labs/core/secure-fs';
8
+ import { writeAtomic } from '@metro-labs/core/secure-fs';
9
9
  import { agentsDir } from '../agents/files.js';
10
10
  import { moveHome, removeHome, writeHomeText } from '../agent-user/home-fs.js';
11
11
  import { claudeDir } from './files.js';
12
12
  import { memoryJobStatus, type MemoryJob } from './memory-routine.js';
13
13
  import { stagedMarketplaceDir } from './plugin-install.js';
14
14
  import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
15
+ import { harnessRunner, runnerModel, sdkAllowed, sdkOnLogin, type HarnessRunner } from './runner.js';
16
+ import { readSetupState, writeSetupState } from './setup-state.js';
15
17
 
16
18
  export const PRIVACY_ENV: Record<string, string> = {
17
19
  DISABLE_TELEMETRY: '1',
@@ -20,7 +22,6 @@ export const PRIVACY_ENV: Record<string, string> = {
20
22
  CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF: '1',
21
23
  };
22
24
  export const RETENTION_DAYS = 7;
23
- const STATE_FILE = 'claude-setup.json';
24
25
  const RULES_FILE = 'METRO.md';
25
26
  const RULES_SKILL = 'metro';
26
27
  const RENAMED_SKILL = 'metro-orchestrator';
@@ -68,6 +69,9 @@ export interface SetupReport {
68
69
  export interface SetupStatus {
69
70
  privacy: boolean;
70
71
  permissionMode: PermissionMode;
72
+ runner: HarnessRunner;
73
+ runnerAllowed: boolean;
74
+ sdkOnLogin: boolean;
71
75
  systemPrompt: string;
72
76
  liveEvents: boolean;
73
77
  memoryRoutine: boolean;
@@ -81,48 +85,36 @@ export interface SetupStatus {
81
85
  retentionDays: number | null;
82
86
  }
83
87
 
84
- const statePath = (agents: string): string => join(agents, STATE_FILE);
85
-
86
88
  const PERMISSION_MODES = ['auto', 'bypass'] as const;
87
89
  export type PermissionMode = (typeof PERMISSION_MODES)[number];
88
90
 
89
- function readState(agents: string): Record<string, unknown> {
90
- const raw = readJson<unknown>(statePath(agents), null);
91
- return isRecord(raw) ? raw : {};
92
- }
93
-
94
- function writeState(agents: string, patch: Record<string, unknown>): void {
95
- mkdirSync(agents, { recursive: true });
96
- writeJson(statePath(agents), { ...readState(agents), ...patch });
97
- }
98
-
99
- const privacyEnabled = (agents = agentsDir()): boolean => readState(agents).privacy !== false;
91
+ const privacyEnabled = (agents = agentsDir()): boolean => readSetupState(agents).privacy !== false;
100
92
 
101
93
  export function setPrivacy(enabled: boolean, agents = agentsDir()): void {
102
- writeState(agents, { privacy: enabled });
94
+ writeSetupState(agents, { privacy: enabled });
103
95
  }
104
96
 
105
97
  export const isPermissionMode = (value: unknown): value is PermissionMode => PERMISSION_MODES.some((m) => m === value);
106
98
 
107
99
  export function permissionMode(agents = agentsDir()): PermissionMode {
108
- const mode = readState(agents).permissionMode;
100
+ const mode = readSetupState(agents).permissionMode;
109
101
  return isPermissionMode(mode) ? mode : 'auto';
110
102
  }
111
103
 
112
104
  export function setPermissionMode(mode: PermissionMode, agents = agentsDir()): void {
113
- writeState(agents, { permissionMode: mode });
105
+ writeSetupState(agents, { permissionMode: mode });
114
106
  }
115
107
 
116
- export const liveEvents = (agents = agentsDir()): boolean => readState(agents).liveEvents !== false;
108
+ export const liveEvents = (agents = agentsDir()): boolean => readSetupState(agents).liveEvents !== false;
117
109
 
118
110
  export function setLiveEvents(on: boolean, agents = agentsDir()): void {
119
- writeState(agents, { liveEvents: on });
111
+ writeSetupState(agents, { liveEvents: on });
120
112
  }
121
113
 
122
- export const memoryRoutine = (agents = agentsDir()): boolean => readState(agents).memoryRoutine !== false;
114
+ export const memoryRoutine = (agents = agentsDir()): boolean => readSetupState(agents).memoryRoutine !== false;
123
115
 
124
116
  export function setMemoryRoutine(on: boolean, agents = agentsDir()): void {
125
- writeState(agents, { memoryRoutine: on });
117
+ writeSetupState(agents, { memoryRoutine: on });
126
118
  }
127
119
 
128
120
  const promptPath = (agents: string): string => join(agents, SYSTEM_PROMPT_FILE);
@@ -295,6 +287,9 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
295
287
  return {
296
288
  privacy: privacyEnabled(agents),
297
289
  permissionMode: permissionMode(agents),
290
+ runner: harnessRunner(agents),
291
+ runnerAllowed: sdkAllowed(agents),
292
+ sdkOnLogin: sdkOnLogin(agents),
298
293
  systemPrompt: systemPrompt(agents),
299
294
  liveEvents: liveEvents(agents),
300
295
  memoryRoutine: memoryRoutine(agents),
@@ -320,11 +315,7 @@ export function tryClaudeSetup(deps: SetupDeps = {}): void {
320
315
  const MODELS_KEY = 'availableModels';
321
316
  const ENFORCE_KEY = 'enforceAvailableModels';
322
317
 
323
- export function routeOf(cfg: ModelConfig): string | null {
324
- const conn = routedConnection(cfg);
325
- if (conn === null || conn.provider === 'anthropic' || conn.model === '') return null;
326
- return `${conn.provider}:${conn.model}`;
327
- }
318
+ export const routeOf = (cfg: ModelConfig): string | null => (routedConnection(cfg)?.provider === 'anthropic' ? null : runnerModel(cfg));
328
319
 
329
320
  function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
330
321
  if (route !== null) return { ...settings, [MODELS_KEY]: [route], [ENFORCE_KEY]: true };
@@ -336,11 +327,9 @@ export function syncAvailableModels(cfg: ModelConfig, deps: SetupDeps = {}): Set
336
327
  const dir = deps.dir ?? claudeDir();
337
328
  const agents = deps.agents ?? agentsDir();
338
329
  const route = routeOf(cfg);
339
- const state = readJson<unknown>(statePath(agents), null);
340
- const metroWrote = isRecord(state) && state.modelsByMetro === true;
330
+ const metroWrote = readSetupState(agents).modelsByMetro === true;
341
331
  return mergeSettings(dir, (current) => {
342
- mkdirSync(agents, { recursive: true });
343
- writeJson(statePath(agents), { ...(isRecord(state) ? state : {}), modelsByMetro: route !== null });
332
+ writeSetupState(agents, { modelsByMetro: route !== null });
344
333
  return withAvailableModels(current, route, metroWrote);
345
334
  });
346
335
  }
@@ -28,6 +28,7 @@ const HOP_BY_HOP = new Set([
28
28
  'upgrade',
29
29
  'accept-encoding',
30
30
  'x-metro-key',
31
+ 'x-metro-runner',
31
32
  ]);
32
33
  const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
33
34
 
@@ -2,6 +2,7 @@ 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';
5
6
  import {
6
7
  assertBedrockReady,
7
8
  bedrockBase,
@@ -97,6 +98,11 @@ const keyOf = (req: IncomingMessage): string => {
97
98
  return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
98
99
  };
99
100
 
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
+
100
106
  const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
101
107
 
102
108
  function modelsBody(cfg: ModelConfig): Record<string, unknown> {
@@ -293,9 +299,15 @@ interface Request {
293
299
  cfg: ModelConfig;
294
300
  }
295
301
 
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
+
296
307
  async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
297
308
  const { path, body } = ask;
298
309
  const conn = route.connection;
310
+ checkSdkRoute(req, route, deps);
299
311
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
300
312
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
301
313
  if (conn.provider === 'bedrock') {
@@ -6,7 +6,8 @@ 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
8
  import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
9
- import { sessionRunning, stopSession } from '../claude/session.js';
9
+ import { harnessRunner, runnerModel, sdkAllowed } from '../claude/runner.js';
10
+ import { sessionLive, sessionRunning, stopSession } from '../claude/session.js';
10
11
  import { forgetOne, forgetReported } from './usage.js';
11
12
  import {
12
13
  addConnection,
@@ -47,13 +48,35 @@ function restartRunningSession(): boolean {
47
48
  return true;
48
49
  }
49
50
 
51
+ function switchLive(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
52
+ const [was, now] = [runnerModel(before), runnerModel(next)];
53
+ if (was === now) return;
54
+ 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');
55
+ 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);
56
+ }
57
+
58
+ function followModel(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
59
+ const restart = deps.restartSession ?? restartRunningSession;
60
+ const agents = deps.setup?.agents;
61
+ if (harnessRunner(agents) === 'sdk') {
62
+ if (sdkAllowed(agents, next)) switchLive(before, next, deps);
63
+ return;
64
+ }
65
+ if (routeOf(next) !== routeOf(before) && restart())
66
+ log.info({ was: routeOf(before), now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
67
+ }
68
+
50
69
  function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
51
- const before = routeOf(store.read());
70
+ const before = store.read();
52
71
  store.write(next);
72
+ try {
73
+ followModel(before, next, deps);
74
+ } catch (err) {
75
+ store.write(before);
76
+ throw err;
77
+ }
53
78
  syncAvailableModelsQuietly(deps.setup ?? {}, next);
54
79
  log.info(fields, note);
55
- if (routeOf(next) !== before && (deps.restartSession ?? restartRunningSession)())
56
- log.info({ was: before, now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
57
80
  return settingsBody(next);
58
81
  }
59
82
 
@@ -191,6 +214,15 @@ function routeFor(path: string, method: string | undefined): Route | number {
191
214
  return codexRoute(path.slice(CODEX.length), method);
192
215
  }
193
216
 
217
+ const runnerIsLive = (deps: ModelApiDeps): boolean => (deps.sessionRunning ?? (() => sessionLive({ agents: deps.setup?.agents })))();
218
+
219
+ function checkRunnerRoute(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
220
+ const agents = deps.setup?.agents;
221
+ if (harnessRunner(agents) !== 'sdk' || !sdkAllowed(agents, before) || sdkAllowed(agents, next)) return;
222
+ if (runnerIsLive(deps))
223
+ 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);
224
+ }
225
+
194
226
  export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
195
227
  const path = (req.url ?? '').split('?')[0] ?? '';
196
228
  if (!mine(path)) return false;
@@ -203,7 +235,13 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
203
235
  sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
204
236
  return true;
205
237
  }
206
- const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
238
+ const store: Store = {
239
+ read: deps.read ?? readModelConfig,
240
+ write: (cfg) => {
241
+ checkRunnerRoute(store.read(), cfg, deps);
242
+ (deps.write ?? writeModelConfig)(cfg);
243
+ },
244
+ };
207
245
  apiSession(req)
208
246
  .then(async (session) => {
209
247
  if (!session) throw new ApiError('unauthorized', 401);
@@ -25,6 +25,8 @@ export interface ModelApiDeps {
25
25
  bedrockControlBase?: string;
26
26
  setup?: SetupDeps;
27
27
  restartSession?: () => boolean;
28
+ sessionRunning?: () => boolean;
29
+ switchModel?: (model: string | null) => boolean;
28
30
  claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
29
31
  claudeLoginsRoot?: string;
30
32
  usageWaitMs?: number;
@@ -14,6 +14,7 @@ import {
14
14
  isStandaloneGet,
15
15
  serveChannelGet,
16
16
  } from './raw-get-stream.js';
17
+ import { errMsg } from '@metro-labs/core/log';
17
18
  import { channelLog, type McpSession } from './session.js';
18
19
  import { SessionSlot } from './session-slot.js';
19
20
 
@@ -95,10 +96,21 @@ const isVoicePath = (req: IncomingMessage): boolean => (req.url ?? '').split('?'
95
96
 
96
97
  let activeSlots: SessionSlot[] = [];
97
98
 
99
+ const MODEL_NOTICE = 'notifications/metro/model';
100
+
101
+ function noticeModel(session: McpSession | undefined, model: string | null): boolean {
102
+ if (session?.streamAttached !== true) return false;
103
+ session.server.notification({ method: MODEL_NOTICE, params: { model } }).catch((err: unknown) => {
104
+ channelLog('session: the model notice failed', errMsg(err));
105
+ });
106
+ return true;
107
+ }
108
+
98
109
  export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Promise<{
99
110
  httpHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
100
111
  startInbound: () => void;
101
112
  setLiveEvents: (on: boolean) => void;
113
+ switchModel: (model: string | null) => boolean;
102
114
  }> {
103
115
  await Promise.all(activeSlots.map((s) => s.close()));
104
116
  const slot = new SessionSlot(options.liveEvents);
@@ -146,5 +158,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
146
158
  slot.setLive(on);
147
159
  };
148
160
 
149
- return { httpHandler, startInbound, setLiveEvents };
161
+ const switchModel = (model: string | null): boolean => noticeModel(slot.current, model);
162
+
163
+ return { httpHandler, startInbound, setLiveEvents, switchModel };
150
164
  }
@@ -6,6 +6,9 @@ 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;
9
12
 
10
13
  export function jwksStore(dir: string): KeyStore {
11
14
  const path = join(dir, KEYS_FILE);
@@ -25,7 +28,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
25
28
  const held = owner();
26
29
  if (held === null || session.organization === null || session.organization !== held)
27
30
  throw new ApiError('this machine belongs to another organization', 403);
28
- return { subject: held, role: session.role === 'admin' ? 'admin' : 'member' };
31
+ return { subject: held, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
29
32
  };
30
33
  }
31
34
 
@@ -58,6 +58,7 @@ export interface LocalModeDeps {
58
58
  toolGroups?: AgentApiDeps['toolGroups'];
59
59
  prepareAccount: AccountApiDeps['prepareAccount'];
60
60
  liveEvents?: (on: boolean) => void;
61
+ switchModel?: (model: string | null) => boolean;
61
62
  }
62
63
 
63
64
  function attachSessions(deps: LocalModeDeps): AttachSessions {
@@ -201,7 +202,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
201
202
  controlApi: { restart: deps.restart, stop: deps.stop },
202
203
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
203
204
  machineApi: {},
204
- modelApi: {},
205
+ modelApi: { switchModel: deps.switchModel },
205
206
  gateway: { config: readModelConfig },
206
207
  terminalApi: {},
207
208
  mode: localModeInfo,
@@ -1,14 +1,9 @@
1
- import { randomInt } from 'node:crypto';
1
+ import { approvalId } from '@metro-labs/core/ids';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { forgetPromptsOf, holdPrompt, type Behavior } from '../approvals/pending.js';
4
4
  import { promptBody } from '../mcp/permission-prompt.js';
5
5
  import type { ToolAsk } from './brain.js';
6
6
 
7
- const ID_LETTERS = 'abcdefghijkmnopqrstuvwxyz';
8
- const ID_LENGTH = 5;
9
-
10
- export const approvalId = (): string => Array.from({ length: ID_LENGTH }, () => ID_LETTERS[randomInt(ID_LETTERS.length)]).join('');
11
-
12
7
  export class CallApprovals {
13
8
  private closed = false;
14
9
 
@@ -9,6 +9,7 @@ export type Role = 'admin' | 'member';
9
9
  export interface ApiSession {
10
10
  subject: string;
11
11
  role: Role;
12
+ operator?: true;
12
13
  }
13
14
 
14
15
  export type BearerSessions = (req: IncomingMessage) => Promise<ApiSession | null>;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.259"
2
+ "version": "0.1.0-beta.260"
3
3
  }
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@metro-labs/core",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "What every metro process shares: the logger, the event bus and its types, ids, secure file writes, the train protocol, station types and attachment handling. No HTTP, no policy.",
6
+ "type": "module",
7
+ "exports": {
8
+ "./log": "./src/log.ts",
9
+ "./events": "./src/events.ts",
10
+ "./is-record": "./src/is-record.ts",
11
+ "./runner-activity": "./src/runner-activity.ts",
12
+ "./secure-fs": "./src/secure-fs.ts",
13
+ "./version": "./src/version.ts",
14
+ "./train-error": "./src/train-error.ts",
15
+ "./trains/protocol": "./src/protocol.ts",
16
+ "./ids": "./src/ids.ts",
17
+ "./tickets": "./src/tickets.ts",
18
+ "./station-names": "./src/station-names.ts",
19
+ "./str": "./src/str.ts",
20
+ "./lines": "./src/lines.ts",
21
+ "./endpoints": "./src/endpoints.ts",
22
+ "./stations/types": "./src/stations/types.ts",
23
+ "./stations/account-files": "./src/stations/account-files.ts",
24
+ "./stations/station-runtime": "./src/stations/station-runtime.ts",
25
+ "./stations/account-store": "./src/stations/account-store.ts",
26
+ "./stations/attachments": "./src/stations/attachments.ts",
27
+ "./stations/mail": "./src/stations/mail.ts",
28
+ "./stations/mail-text": "./src/stations/mail-text.ts",
29
+ "./stations/mail-trust": "./src/stations/mail-trust.ts",
30
+ "./stations/oauth": "./src/stations/oauth.ts",
31
+ "./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
32
+ "./stations/profile": "./src/stations/profile.ts",
33
+ "./stations/sender-profile": "./src/stations/sender-profile.ts",
34
+ "./stations/train-events": "./src/stations/train-events.ts",
35
+ "./stations/train-boot": "./src/stations/train-boot.ts"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "test": "tsc --noEmit && METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
40
+ },
41
+ "dependencies": {
42
+ "pino": "^9.5.0",
43
+ "pino-pretty": "^13.1.3"
44
+ },
45
+ "devDependencies": {
46
+ "@stage-labs/config": "0.1.0-beta.3",
47
+ "eslint": "^10.3.0",
48
+ "knip": "^6.15.0",
49
+ "madge": "^8.0.0",
50
+ "typescript": "^5"
51
+ }
52
+ }
@@ -0,0 +1,7 @@
1
+ export interface Endpoint {
2
+ id: string;
3
+ webhookId?: string;
4
+ label: string;
5
+ secret?: string;
6
+ createdAt: string;
7
+ }