@stage-labs/metro 0.1.0-beta.307 → 0.1.0-beta.309

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 (91) 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/agent-user/view.ts +3 -9
  16. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +14 -12
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +39 -118
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  24. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +37 -77
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +86 -220
  35. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
  36. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
  38. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
  39. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
  40. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
  41. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
  42. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
  43. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
  44. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
  45. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  46. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  47. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  48. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  49. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  50. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  51. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
  52. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  53. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  54. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  55. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  56. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  57. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  58. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  59. package/runtime/runtime.json +1 -1
  60. package/runtime/sdk-runner/runner.json +1 -1
  61. package/dist/background.js +0 -141
  62. package/dist/claude.js +0 -204
  63. package/dist/mcp-config.js +0 -27
  64. package/dist/native-recovery-scan.js +0 -246
  65. package/dist/native-recovery-state.js +0 -154
  66. package/dist/native-recovery.js +0 -164
  67. package/dist/onboarding.js +0 -44
  68. package/dist/voice.js +0 -56
  69. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  70. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  71. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  72. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  73. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  74. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  75. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
  76. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
  77. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
  78. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
  79. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
  80. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
  81. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
  82. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
  83. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
  84. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -83
  85. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
  86. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
  87. package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
  88. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  89. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  90. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  91. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -4,6 +4,7 @@ import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { agentsDir } from '../agents/files.js';
6
6
  import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
7
+ import { effectiveSetup, modelViewText } from '../gateway/effective.js';
7
8
  import { removeHome, writeHomeText } from './home-fs.js';
8
9
  import { closeMetroHome } from './metro-home.js';
9
10
  import { agentUser, agentViewDir, type AgentUser } from './user.js';
@@ -39,17 +40,10 @@ function agentFile(dir: string): string | null {
39
40
  return json({ version: 1, id: file.id, key: file.key });
40
41
  }
41
42
 
42
- function modelFile(dir: string): string | null {
43
- const cfg = readRecord(join(dir, 'model.json'));
44
- if (cfg === null || !Array.isArray(cfg.connections)) return null;
45
- const connections = cfg.connections.filter(isRecord).map((c) => ({ id: c.id, provider: c.provider, model: c.model }));
46
- return json({ version: 2, route: cfg.route, connections });
47
- }
48
-
49
- export function viewFiles(dir = agentsDir()): Map<string, string | null> {
43
+ export function viewFiles(dir = agentsDir(), model: () => string | null = () => modelViewText(effectiveSetup(dir))): Map<string, string | null> {
50
44
  const files = new Map<string, string | null>([
51
45
  ['agent.json', agentFile(dir)],
52
- ['model.json', modelFile(dir)],
46
+ ['model.json', model()],
53
47
  ]);
54
48
  for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
55
49
  for (const name of NEVER_COPIED) files.set(name, null);
@@ -26,9 +26,8 @@ import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connec
26
26
  import { setAnnouncedFile } from '../mcp-server/announced.js';
27
27
  import { holdServerApproval } from '../mcp/server-approvals.js';
28
28
  import { removeConnectorLeftovers } from '../connectors/leftovers.js';
29
- import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
30
29
  import { runningAsRoot } from '../metro-user/privilege.js';
31
- import { provisionAgentUser } from '../agent-user/provision.js';
30
+ import { prepareAgentRunner, provisionAgentUser } from '../agent-user/provision.js';
32
31
  import { applyVault } from '../vault/index.js';
33
32
  import { blockImds } from '../metro-user/imds.js';
34
33
  import { followRelease } from '../metro-user/root-follow.js';
@@ -61,6 +60,11 @@ import { startUploadReaper } from '../files/upload-store.js';
61
60
  import { startAttachReaper } from '../files/attach-reaper.js';
62
61
  import { startCloudWatchPublisher } from '../server/cloudwatch.js';
63
62
  import { watchFallbackUsage } from '../gateway/usage-refresh.js';
63
+ import { startOrgModels, stopOrgModels } from '../gateway/org-models.js';
64
+ import { effectiveSetup, watchModelChanges } from '../gateway/effective.js';
65
+ import { readClaudeLogins } from '../gateway/claude-store.js';
66
+ import { readAgentKey } from '../connectors/agent-key.js';
67
+ import { METRO_API } from '../connectors/metro-api.js';
64
68
  import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
65
69
 
66
70
  installCrashGuard();
@@ -115,7 +119,7 @@ async function syncStations(station: StationName): Promise<void> {
115
119
  }
116
120
  }
117
121
 
118
- function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model: string | null) => boolean): SessionApis {
122
+ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
119
123
  return localSessionApis({
120
124
  syncStations,
121
125
  reloadAgents: async () => {
@@ -134,7 +138,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
134
138
  toolGroups: stationToolGroups,
135
139
  prepareAccount,
136
140
  liveEvents: setLiveEvents,
137
- switchModel,
138
141
  });
139
142
  }
140
143
 
@@ -151,10 +154,12 @@ installBearerSessions(agentsDir(), localOwner);
151
154
  syncGmailHost();
152
155
  supervisor.start();
153
156
  sweepLeftoverCalls();
157
+ const models = startOrgModels({ owner: localOwner, key: () => readAgentKey(), api: METRO_API, dir: agentsDir });
154
158
  const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
159
+ watchModelChanges(models, metroMcp.switchModel);
155
160
  webhookServer = await startWebhookServer(
156
161
  emit,
157
- sessionApis(metroMcp.setLiveEvents, metroMcp.switchModel),
162
+ sessionApis(metroMcp.setLiveEvents),
158
163
  metroMcp.httpHandler,
159
164
  true,
160
165
  );
@@ -175,7 +180,7 @@ installBearerSessions(agentsDir(), localOwner);
175
180
  });
176
181
  watchConnectorTools();
177
182
  startCloudWatchPublisher();
178
- watchFallbackUsage();
183
+ watchFallbackUsage(() => effectiveSetup(), () => readClaudeLogins());
179
184
  announceLocalEndpoint();
180
185
  tunnel?.start();
181
186
  log.info(
@@ -193,7 +198,7 @@ async function startClaude(): Promise<void> {
193
198
  log.error('claude: Metro runs as root, so Claude Code never starts here; reinstall Metro as the metro user');
194
199
  return;
195
200
  }
196
- const agentUser = await provisionAgentUser();
201
+ const agentUser = provisionAgentUser();
197
202
  if (agentUser !== 'off') log.info({ agentUser }, 'agent-user: Claude Code runs as its own user');
198
203
  if (agentUser === 'failed') return;
199
204
  if (agentUser === 'ready') {
@@ -202,14 +207,10 @@ async function startClaude(): Promise<void> {
202
207
  const vault = (await applyVault()).status;
203
208
  if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
204
209
  }
205
- const plugin = await removeMetroPlugin().catch((err: unknown) => {
206
- log.warn({ err: errMsg(err) }, 'claude-plugin: could not check for the old metro plugin');
207
- return 'failed';
208
- });
209
- if (plugin !== 'done') log.info({ outcome: plugin }, 'claude-plugin: checked for the old metro Claude Code plugin');
210
210
  tryClaudeSetup();
211
211
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
212
212
  tryMemoryJob(memoryRoutine());
213
+ if (agentUser === 'ready') await prepareAgentRunner();
213
214
  watchSession();
214
215
  }
215
216
 
@@ -226,6 +227,7 @@ async function shutdown(): Promise<void> {
226
227
  log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
227
228
  });
228
229
  const leaving = leaveCallsForShutdown();
230
+ stopOrgModels();
229
231
  unwatchSession();
230
232
  tunnel?.stop();
231
233
  if (webhookServer) {
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
- import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson, type ApiSession } from '@metro-labs/http/api-http';
3
+ import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { githubSkillsAnswer } from './github-api.js';
@@ -35,7 +35,6 @@ import {
35
35
  stopSession,
36
36
  type SessionDeps,
37
37
  } from './session.js';
38
- import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
39
38
  import { receiveSessionFile, sessionFilePath } from './session-files.js';
40
39
  import { createReadStream } from '../agent-user/agent-fs.js';
41
40
  import { pipeline } from 'node:stream/promises';
@@ -61,7 +60,6 @@ export interface ClaudeApiDeps extends SetupApiDeps {
61
60
  dir?: () => string;
62
61
  login?: LoginDeps;
63
62
  claudeLogins?: ClaudeLoginDeps;
64
- version?: VersionDeps;
65
63
  skillSource?: GitHubSkills;
66
64
  }
67
65
 
@@ -135,11 +133,10 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
135
133
  return createClaudeSkill(body.name, typeof body.text === 'string' ? body.text : undefined, dir);
136
134
  }
137
135
 
138
- const ADMIN_ONLY = /^\/api\/claude\/(login|session|version|setup|skill-source)(\/|$)/;
136
+ const ADMIN_ONLY = /^\/api\/claude\/(login|session|setup|skill-source)(\/|$)/;
139
137
  const LOGIN = 'login';
140
138
  const SESSION = 'session';
141
139
  const SETUP = 'setup';
142
- const VERSION = 'version';
143
140
 
144
141
  function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
145
142
  if (body.action === 'start') {
@@ -209,18 +206,9 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
209
206
  return handler(query, dir, item);
210
207
  }
211
208
 
212
- function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
213
- const version: VersionDeps = { ...deps.version, session: deps.version?.session ?? deps.session };
214
- const method = req.method ?? 'GET';
215
- if (method === 'GET') return Promise.resolve(claudeVersion(version));
216
- if (method !== 'POST') throw new ApiError('method not allowed', 405);
217
- return Promise.resolve(updateClaude(version));
218
- }
219
-
220
- const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps, session: ApiSession) => Promise<unknown>> = {
209
+ const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
221
210
  [SESSION]: sessionAnswer,
222
211
  [SETUP]: setupAnswer,
223
- [VERSION]: versionAnswer,
224
212
  };
225
213
 
226
214
  function skillsAnswer(req: IncomingMessage, path: string, search: string, dir: string, source: GitHubSkills): unknown {
@@ -245,14 +233,14 @@ function fileAnswer(req: IncomingMessage, path: string, search: string, dir: str
245
233
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
246
234
  }
247
235
 
248
- function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps, session: ApiSession): unknown {
236
+ function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
249
237
  const dir = (deps.dir ?? claudeDir)();
250
238
  const [head = '', item = ''] = parts(path);
251
239
  if (head === LOGIN) return loginAnswer(req, item, deps);
252
240
  if (head === 'skill-source' && parts(path).length === 1) return githubSkillsAnswer(req, deps.skillSource);
253
241
  if (head === 'skills') return skillsAnswer(req, path, search, dir, sourceOf(deps));
254
242
  const single = item === '' ? SINGLETONS[head] : undefined;
255
- if (single !== undefined) return single(req, deps, session);
243
+ if (single !== undefined) return single(req, deps);
256
244
  return fileAnswer(req, path, search, dir);
257
245
  }
258
246
 
@@ -303,7 +291,7 @@ export function handleClaudeRequest(
303
291
  }
304
292
  if (req.method !== 'GET' && ADMIN_ONLY.test(path.replace(/\/{2,}/g, '/'))) requireAdmin(session);
305
293
  if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
306
- sendJson(req, res, 200, await routed(req, path, search, deps, session));
294
+ sendJson(req, res, 200, await routed(req, path, search, deps));
307
295
  })
308
296
  .catch((err: unknown) => {
309
297
  if (err instanceof ApiError) apiFailure(req, res, err, 'claude-api');
@@ -10,7 +10,6 @@ import { readFileSync, statSync } from '../agent-user/agent-fs.js';
10
10
  import { receiveHomeFile, writeHomeText } from '../agent-user/home-fs.js';
11
11
  import { agentsDir } from '../agents/files.js';
12
12
  import { claudeDir } from './files.js';
13
- import { readSetupState } from './setup-state.js';
14
13
  import { GitHubFailure, GitHubReader } from './github-fetch.js';
15
14
  import { githubSource, validatedGeneration } from './github-validate.js';
16
15
  import type { ClaudeSkill } from './skills.js';
@@ -29,7 +28,6 @@ interface Stored {
29
28
  export interface GitHubSkillsDeps {
30
29
  agents: string;
31
30
  claude: string;
32
- sdk(): boolean;
33
31
  request?: (url: string, init: RequestInit) => Promise<Response>;
34
32
  }
35
33
  const EMPTY: Stored = { source: null, prepared: null, etag: null, checkedAt: null, retryAt: 0, problem: null, rateLimit: 0, repositoryId: null };
@@ -107,7 +105,7 @@ export class GitHubSkills {
107
105
  view(activation = this.activation(), loaded = this.loaded(activation)): object {
108
106
  const { source, prepared, checkedAt, problem } = this.stored;
109
107
  return {
110
- supported: this.deps.sdk(),
108
+ supported: true,
111
109
  source: source === null ? null : { repository: source.repository, ref: source.ref, folder: source.folder, hasToken: true },
112
110
  checking: this.busy, checkedAt, problem: this.unreadable ? 'The saved GitHub source is unreadable. Restore its configuration before changing it. Local skills are still available.' : problem,
113
111
  prepared: prepared === null ? null : { generation: prepared.id, source: prepared.source, skillCount: prepared.skills.length },
@@ -142,7 +140,6 @@ export class GitHubSkills {
142
140
  }
143
141
 
144
142
  async configure(raw: unknown): Promise<object> {
145
- if (!this.deps.sdk()) throw new ApiError('GitHub skills require the Agent SDK runner. Local skills are unchanged.', 409);
146
143
  if (this.busy) throw new ApiError('A skills sync is already running. Try again when it finishes.', 409);
147
144
  const source = githubSource(raw);
148
145
  this.readable();
@@ -193,11 +190,10 @@ export class GitHubSkills {
193
190
  }
194
191
 
195
192
  async sync(force = false): Promise<void> {
196
- if (force && !this.deps.sdk()) throw new ApiError('GitHub skills require the Agent SDK runner. Local skills are unchanged.', 409);
197
193
  this.readable();
198
194
  this.offer();
199
195
  const source = this.stored.source;
200
- if (source === null || this.busy || !this.deps.sdk() || this.waiting(force)) return;
196
+ if (source === null || this.busy || this.waiting(force)) return;
201
197
  const epoch = this.epoch;
202
198
  this.busy = true;
203
199
  try { await this.fetch(source, epoch, force); }
@@ -254,6 +250,6 @@ export class GitHubSkills {
254
250
 
255
251
  let current: GitHubSkills | null = null;
256
252
  export function githubSkills(): GitHubSkills {
257
- current ??= new GitHubSkills({ agents: agentsDir(), claude: claudeDir(), sdk: () => readSetupState(agentsDir()).runner === 'sdk' });
253
+ current ??= new GitHubSkills({ agents: agentsDir(), claude: claudeDir() });
258
254
  return current;
259
255
  }
@@ -4,7 +4,7 @@ import { markOnboardingDone } from './onboarding.js';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
- import { agentCommand, asAgent, claudeBin } from '../agent-user/user.js';
7
+ import { agentCommand, asAgent, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
8
8
 
9
9
  const LOGIN_ARGS = ['auth', 'login', '--claudeai'];
10
10
  const STATUS_COMMAND = ['auth', 'status', '--json'];
@@ -95,14 +95,14 @@ function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Ch
95
95
  return value;
96
96
  }
97
97
 
98
- function forgetClaudeChecks(): void {
98
+ export function forgetClaudeChecks(): void {
99
99
  delete checks.installed;
100
100
  delete checks.account;
101
101
  }
102
102
 
103
103
  export function claudeInstalled(): boolean {
104
104
  return remembered(checks.installed, () => {
105
- const run = spawnSync(...asAgent(claudeBin(), ['--version']), { stdio: 'ignore' });
105
+ const run = spawnSync(...asAgent(claudeBin(), ['--version'], CLAUDE_ENV), { stdio: 'ignore', env: { ...process.env, ...CLAUDE_ENV } });
106
106
  return run.error === undefined && run.status === 0;
107
107
  }, (c) => {
108
108
  checks.installed = c;
@@ -125,7 +125,8 @@ export function claudeAccount(): ClaudeAccount {
125
125
 
126
126
  const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
127
127
 
128
- export function claudeAccountIn(env: Record<string, string>): ClaudeAccount {
128
+ export function claudeAccountIn(extra: Record<string, string>): ClaudeAccount {
129
+ const env = { ...CLAUDE_ENV, ...extra };
129
130
  const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
130
131
  if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
131
132
  try {
@@ -165,7 +166,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target:
165
166
  sweep(now);
166
167
  const running = runningFor(target);
167
168
  if (running !== null) return view(running);
168
- const env = { TERM: 'xterm-256color', ...target?.env };
169
+ const env = { TERM: 'xterm-256color', ...CLAUDE_ENV, ...target?.env };
169
170
  const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
170
171
  const session: Live = {
171
172
  id: randomBytes(9).toString('base64url'),
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
1
+ import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
@@ -36,25 +36,3 @@ export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
36
36
  writeHomeText(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, 0o600);
37
37
  return 'marked';
38
38
  }
39
-
40
- const TRUST = 'hasTrustDialogAccepted';
41
-
42
- function realDir(dir: string): string {
43
- try {
44
- return realpathSync(dir);
45
- } catch {
46
- return dir;
47
- }
48
- }
49
-
50
- export function trustFolder(dir: string, path = claudeConfigPath()): OnboardingMark {
51
- const config = readConfig(path);
52
- if (config === null) return 'unreadable';
53
- const key = realDir(dir);
54
- const projects = isRecord(config.projects) ? config.projects : {};
55
- const project = isRecord(projects[key]) ? projects[key] : {};
56
- if (project[TRUST] === true) return 'already';
57
- const next = { ...config, projects: { ...projects, [key]: { ...project, [TRUST]: true } } };
58
- writeHomeText(path, `${JSON.stringify(next, null, 2)}\n`, 0o600);
59
- return 'marked';
60
- }
@@ -3,7 +3,6 @@ import { ApiError } from '@metro-labs/http/api-error';
3
3
  import type { RunnerActivity } from '@metro-labs/core/runner-activity';
4
4
  import { readAgentActivity } from './runner-activity.js';
5
5
  import { sdkAlive, stopSdkRunner } from './runner-process.js';
6
- import { continueArgs, sdkPending } from './session-continuity.js';
7
6
  import { syncAgentView } from '../agent-user/view.js';
8
7
  import { homedir } from 'node:os';
9
8
  import { join } from 'node:path';
@@ -12,13 +11,10 @@ import { isRecord } from '@metro-labs/core/is-record';
12
11
  import { readJson, writeJson } from '@metro-labs/core/secure-fs';
13
12
  import { METRO_VERSION } from '@metro-labs/core/version';
14
13
  import { agentsDir, listAgentFiles } from '../agents/files.js';
15
- import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
16
- import { claudeDir } from './files.js';
17
- import { harnessRunner, isHarnessRunner, sdkAllowed, SDK_NEEDS_KEY, type HarnessRunner } from './runner.js';
18
- import { claudeAccount, claudeInstalled } from './login.js';
19
- import { trustFolder } from './onboarding.js';
14
+ import { notReady, primaryRoute, type ModelSetup } from '../gateway/model-config.js';
15
+ import { effectiveSetup } from '../gateway/effective.js';
16
+ import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
20
17
  import { inSessionScope } from './memory.js';
21
- import { channelState, forgetChannelState, noteChannelState, type ChannelState } from './channel-check.js';
22
18
  import { runningAsRoot } from '../metro-user/privilege.js';
23
19
  import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
24
20
 
@@ -31,41 +27,35 @@ function sessionEnv(): Record<string, string> {
31
27
 
32
28
  const SESSION_NAME = 'metro';
33
29
  const STATE_FILE = 'claude-session.json';
34
- const WARNING = 'WARNING: Loading development channels';
35
- const CONFIRM_POLL_MS = 500;
36
- const CONFIRM_WAIT_MS = 60_000;
37
30
  const WATCH_MS = 15_000;
38
31
  const STRIKE_WINDOW_MS = 60_000;
39
32
  const MAX_STRIKES = 5;
40
33
  const PAUSE_MS = 30 * 60_000;
34
+ const PREPARE_MS = 16 * 60_000;
41
35
 
42
36
  export interface SessionDeps {
43
37
  tmux?: string;
44
38
  metro?: string[];
45
- runner?: string[];
46
39
  home?: string;
47
40
  agents?: string;
48
41
  signedIn?: () => boolean;
42
+ model?: () => ModelSetup;
49
43
  now?: () => number;
50
44
  version?: string;
51
- prepare?: (runner: HarnessRunner) => Promise<void>;
52
45
  }
53
46
 
54
47
  export interface SessionStatus {
55
48
  name: string;
56
49
  running: boolean;
57
- runner?: HarnessRunner;
58
50
  activity?: RunnerActivity | null;
59
51
  autostart: boolean;
60
52
  blocked: string | null;
61
53
  lastStartedAt: string | null;
62
54
  lastError: string | null;
63
- channels?: ChannelState | null;
64
55
  }
65
56
 
66
57
  export interface SessionSnapshot {
67
58
  running: boolean;
68
- runner: HarnessRunner | null;
69
59
  activity: RunnerActivity | null;
70
60
  lastStartedAt: string | null;
71
61
  }
@@ -120,64 +110,42 @@ export function metroCli(args: string[], fixed?: string[]): string[] {
120
110
 
121
111
  const sessionHome = (deps: SessionDeps): string => deps.home ?? claudeHome() ?? homedir();
122
112
 
123
- export function sessionRunner(deps: SessionDeps = {}): HarnessRunner {
124
- const agents = deps.agents ?? agentsDir();
125
- const runner = readState(agents).runner;
126
- return isHarnessRunner(runner) ? runner : harnessRunner(agents);
127
- }
128
-
129
113
  export function assertSessionStopped(deps: SessionDeps = {}): void {
130
- if (sessionLive(deps)) throw new ApiError('The session is running. Let it finish its work, stop it, then change its runner, permissions or prompt and start it again.', 409);
131
- }
132
-
133
- export function assertSdkDrained(deps: SessionDeps = {}): void {
134
- if (sdkPending(sessionHome(deps)) !== 0)
135
- throw new ApiError('The Agent SDK still has chat messages it has not answered, or its saved state cannot be read. Start it so it answers them (fix the Model page first if it cannot start), stop it, then switch runners.', 409);
136
- }
137
-
138
- let changing = false;
139
-
140
- export async function withSessionChange<T>(run: () => Promise<T>): Promise<T> {
141
- if (changing) throw new ApiError('A session change is already being prepared. Try again when it finishes.', 409);
142
- changing = true;
143
- try {
144
- return await run();
145
- } finally {
146
- changing = false;
147
- }
114
+ if (sessionLive(deps)) throw new ApiError('The session is running. Let it finish its work, stop it, then change its permissions or prompt and start it again.', 409);
148
115
  }
149
116
 
150
- export async function prepareSessionRunner(runner: HarnessRunner, deps: SessionDeps = {}): Promise<void> {
151
- if (deps.prepare !== undefined) return deps.prepare(runner);
152
- const [bin = 'metro', ...args] = metroCli(['agent', '--prepare', runner]);
153
- await new Promise<void>((resolve, reject) => {
154
- const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], timeout: 16 * 60_000 });
155
- let error = '';
156
- child.stderr.on('data', (chunk: Buffer) => { error = (error + chunk.toString()).slice(-2000); });
157
- child.on('error', (err) => { reject(new ApiError(`Runner preparation failed: ${errMsg(err)}`, 503)); });
117
+ export function prepareRunnerStore(): Promise<void> {
118
+ const [bin = 'metro', ...args] = metroCli(['agent', '--prepare']);
119
+ return new Promise<void>((resolve, reject) => {
120
+ const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], cwd: '/', timeout: PREPARE_MS });
121
+ let said = '';
122
+ child.stderr.on('data', (chunk: Buffer) => {
123
+ said = (said + chunk.toString('utf8')).slice(-2000);
124
+ });
125
+ child.on('error', (err) => {
126
+ forgetClaudeChecks();
127
+ reject(err);
128
+ });
158
129
  child.on('close', (code) => {
130
+ forgetClaudeChecks();
159
131
  if (code === 0) resolve();
160
- else reject(new ApiError(`Runner preparation failed. The runner choice was not changed. ${error.trim()}`, 503));
132
+ else reject(new Error(`metro agent --prepare failed: ${said.trim() || `exit ${String(code)}`}`));
161
133
  });
162
134
  });
163
135
  }
164
136
 
165
- function metroCommand(deps: SessionDeps, home: string): string[] {
166
- if (harnessRunner(deps.agents ?? agentsDir()) === 'sdk') return metroCli(['agent'], deps.runner);
167
- return [...metroCli(['claude'], deps.metro), ...continueArgs(home, claudeDir(), deps.agents)];
168
- }
137
+ const loginNotRuledOut = (): boolean => claudeAccount().signedIn || !claudeInstalled();
169
138
 
170
139
  function credentialReady(deps: SessionDeps): string | null {
171
- const signedIn = deps.signedIn ?? (() => claudeAccount().signedIn);
140
+ const signedIn = deps.signedIn ?? loginNotRuledOut;
172
141
  if (signedIn()) return null;
173
142
  try {
174
- const cfg = readModelConfig(deps.agents ?? agentsDir());
175
- const conn = routedConnection(cfg);
176
- if (conn !== null && notReady(cfg) === null && (conn.provider !== 'anthropic' || conn.apiKey !== '')) return null;
143
+ const primary = primaryRoute((deps.model ?? (() => effectiveSetup(deps.agents ?? agentsDir())))());
144
+ if (primary !== null && notReady(primary) === null && (primary.connection.provider !== 'anthropic' || primary.connection.apiKey !== '')) return null;
177
145
  } catch (err) {
178
- return `the Model page is not readable (${errMsg(err)})`;
146
+ return `the model setup is not readable (${errMsg(err)})`;
179
147
  }
180
- return 'Claude Code is not signed in and the Model page routes nowhere yet';
148
+ return 'Claude Code is not signed in and the organization routes this agent to no model yet';
181
149
  }
182
150
 
183
151
  const agentUserMissing = (deps: SessionDeps): boolean =>
@@ -187,64 +155,26 @@ const ROOT_REFUSED = 'Metro runs as root; reinstall it as the metro user';
187
155
 
188
156
  export function sessionBlocked(deps: SessionDeps = {}): string | null {
189
157
  if (runningAsRoot()) return ROOT_REFUSED;
190
- const agents = deps.agents ?? agentsDir();
191
- if (harnessRunner(agents) === 'sdk' && !sdkAllowed(agents)) return SDK_NEEDS_KEY;
192
- if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
158
+ if (listAgentFiles(deps.agents ?? agentsDir()).length === 0) return 'no agent on this machine yet';
193
159
  return launchBlocked(deps);
194
160
  }
195
161
 
196
162
  function launchBlocked(deps: SessionDeps): string | null {
197
163
  if (agentUserMissing(deps))
198
164
  return 'Claude Code runs as the user agent here, and that user is not ready yet; see the log';
199
- if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
200
165
  if (!tmuxOk(deps.tmux ?? 'tmux', ['-V'])) return 'tmux is not installed on this machine';
201
166
  return credentialReady(deps);
202
167
  }
203
168
 
204
- function capturePane(tmux: string): string {
205
- const run = spawnSync(...asAgent(tmux, ['capture-pane', '-p', '-t', SESSION_NAME]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
206
- return run.error === undefined && run.status === 0 ? run.stdout : '';
207
- }
208
-
209
- function noteLaunchLines(pane: string, seen: Set<string>): void {
210
- for (const line of pane.split('\n').map((l) => l.trim()))
211
- if (line.startsWith('metro claude:') && !seen.has(line)) {
212
- seen.add(line);
213
- log.info({ line }, 'claude-session: metro claude said');
214
- }
215
- }
216
-
217
- function confirmChannels(tmux: string, home: string, startedAt: number): void {
218
- const until = startedAt + CONFIRM_WAIT_MS;
219
- const seen = new Set<string>();
220
- let confirmed = false;
221
- let checked = false;
222
- const tick = (): void => {
223
- const pane = capturePane(tmux);
224
- noteLaunchLines(pane, seen);
225
- checked ||= noteChannelState(home, startedAt);
226
- if (!confirmed && pane.includes(WARNING) && pane.includes('server:metro')) {
227
- spawnSync(...asAgent(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter']), { stdio: 'ignore' });
228
- log.info('claude-session: confirmed the development channels dialog for server:metro');
229
- confirmed = true;
230
- }
231
- if (Date.now() < until && sessionRunning(tmux)) setTimeout(tick, CONFIRM_POLL_MS).unref();
232
- };
233
- setTimeout(tick, CONFIRM_POLL_MS).unref();
234
- }
235
-
236
- function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?: Error; status: number | null; stderr: string }): void {
169
+ function recordStart(deps: SessionDeps, now: number, run: { error?: Error; status: number | null; stderr: string }): void {
237
170
  memory.lastStartedAt = now;
238
- forgetChannelState();
239
171
  if (run.error !== undefined || run.status !== 0) {
240
172
  memory.lastError = run.error === undefined ? `tmux new-session exited ${String(run.status)}: ${run.stderr.trim()}` : errMsg(run.error);
241
173
  log.warn({ err: memory.lastError }, 'claude-session: tmux could not start');
242
174
  return;
243
175
  }
244
176
  memory.lastError = null;
245
- const agents = deps.agents ?? agentsDir();
246
- writeState(agents, { version: deps.version ?? METRO_VERSION, runner: harnessRunner(agents) });
247
- confirmChannels(tmux, sessionHome(deps), now);
177
+ writeState(deps.agents ?? agentsDir(), { version: deps.version ?? METRO_VERSION });
248
178
  }
249
179
 
250
180
  function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, string[]] {
@@ -253,7 +183,6 @@ function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, s
253
183
  }
254
184
 
255
185
  export function startSession(deps: SessionDeps = {}): SessionStatus {
256
- if (changing) throw new ApiError('A runner change is being prepared. Start the session after it finishes.', 409);
257
186
  const status = sessionStatus(deps);
258
187
  if (status.running) return status;
259
188
  if (status.blocked !== null) throw new ApiError(`Cannot start the session: ${status.blocked}`, 409);
@@ -261,13 +190,12 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
261
190
  const tmux = deps.tmux ?? 'tmux';
262
191
  const home = sessionHome(deps);
263
192
  const now = (deps.now ?? Date.now)();
264
- const trusted = trustFolder(home);
265
- const [command = 'metro', ...args] = metroCommand(deps, home);
193
+ const [command = 'metro', ...args] = metroCli(['agent'], deps.metro);
266
194
  const tmuxArgs = ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args];
267
195
  const launch = tmuxLaunch(deps, tmux, tmuxArgs);
268
196
  const run = spawnSync(...launch, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: '/' });
269
- recordStart(deps, tmux, now, run);
270
- if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
197
+ recordStart(deps, now, run);
198
+ if (memory.lastError === null) log.info({ home, command: [command, ...args].join(' ') }, 'claude-session: started the Agent SDK runner in tmux');
271
199
  return sessionStatus(deps);
272
200
  }
273
201
 
@@ -277,32 +205,26 @@ export function stopSession(deps: SessionDeps = {}, cancelActive = false): Sessi
277
205
  return sessionStatus(deps);
278
206
  }
279
207
 
280
- export const sessionLive = (deps: SessionDeps = {}): boolean =>
281
- sessionRunning(deps.tmux ?? 'tmux') || sdkAlive(readAgentActivity(sessionHome(deps)));
208
+ export const runnerAlive = (deps: SessionDeps = {}): boolean => sdkAlive(readAgentActivity(sessionHome(deps)));
209
+
210
+ export const sessionLive = (deps: SessionDeps = {}): boolean => sessionRunning(deps.tmux ?? 'tmux') || runnerAlive(deps);
282
211
 
283
212
  export function sessionSnapshot(deps: SessionDeps = {}): SessionSnapshot {
284
213
  const activity = readAgentActivity(sessionHome(deps));
285
- const sdk = sdkAlive(activity);
286
- const tmux = sessionRunning(deps.tmux ?? 'tmux');
287
- const runner = sdk ? 'sdk' : tmux ? readState(deps.agents ?? agentsDir()).runner : null;
288
214
  return {
289
- running: tmux || sdk,
290
- runner: isHarnessRunner(runner) ? runner : null,
215
+ running: sdkAlive(activity) || sessionRunning(deps.tmux ?? 'tmux'),
291
216
  activity,
292
217
  lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
293
218
  };
294
219
  }
295
220
 
296
221
  export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
297
- const snapshot = sessionSnapshot(deps);
298
222
  return {
299
223
  name: SESSION_NAME,
300
- ...snapshot,
301
- runner: snapshot.runner ?? sessionRunner(deps),
224
+ ...sessionSnapshot(deps),
302
225
  autostart: autostartEnabled(deps.agents ?? agentsDir()),
303
226
  blocked: sessionBlocked(deps),
304
227
  lastError: memory.lastError,
305
- channels: channelState(),
306
228
  };
307
229
  }
308
230
 
@@ -312,7 +234,7 @@ function strike(now: number): boolean {
312
234
  if (memory.strikes < MAX_STRIKES) return false;
313
235
  memory.pausedUntil = now + PAUSE_MS;
314
236
  memory.strikes = 0;
315
- log.warn({ pauseMinutes: PAUSE_MS / 60_000 }, 'claude-session: Claude Code keeps exiting right after starting; not restarting it for a while');
237
+ log.warn({ pauseMinutes: PAUSE_MS / 60_000 }, 'claude-session: the session keeps exiting right after starting; not restarting it for a while');
316
238
  return true;
317
239
  }
318
240
 
@@ -346,7 +268,6 @@ function situation(r: Resolved): 'running' | 'stale' | 'absent' {
346
268
  export function ensureSession(deps: SessionDeps = {}): Ensured {
347
269
  const r = resolved(deps);
348
270
  if (!autostartEnabled(r.agents)) return 'off';
349
- if (changing) return 'blocked';
350
271
  const found = situation(r);
351
272
  if (found === 'running' || (found === 'absent' && sessionLive(deps))) return 'running';
352
273
  const held = holdsBack(deps, r.now);
@@ -366,7 +287,7 @@ export function watchSession(deps: SessionDeps = {}, everyMs = WATCH_MS): void {
366
287
  const tick = (): void => {
367
288
  try {
368
289
  const outcome = ensureSession(deps);
369
- if (outcome === 'started' || outcome === 'restarted') log.info({ outcome }, 'claude-session: Claude Code is up');
290
+ if (outcome === 'started' || outcome === 'restarted') log.info({ outcome }, 'claude-session: the Agent SDK runner is up');
370
291
  } catch (err) {
371
292
  log.warn({ err: errMsg(err) }, 'claude-session: check failed');
372
293
  }