@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
@@ -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 } from '@metro-labs/http/api-http';
3
+ import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson, type ApiSession } 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 { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
@@ -22,25 +22,10 @@ import {
22
22
  type LoginTarget,
23
23
  } from './login.js';
24
24
  import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
25
+ import { setupAnswer, type SetupApiDeps } from './setup-api.js';
25
26
  import {
26
- claudeSetupStatus,
27
- ensureClaudeSetup,
28
- isPermissionMode,
29
- liveEvents,
30
- permissionMode,
31
- setLiveEvents,
32
- setMemoryRoutine,
33
- setPermissionMode,
34
- setPrivacy,
35
- setSystemPrompt,
36
- systemPrompt,
37
- SYSTEM_PROMPT_MAX,
38
- type PermissionMode,
39
- type SetupDeps,
40
- } from './setup.js';
41
- import {
27
+ autostartEnabled,
42
28
  ensureSession,
43
- sessionRunning,
44
29
  sessionStatus,
45
30
  setAutostart,
46
31
  startSession,
@@ -48,7 +33,6 @@ import {
48
33
  type SessionDeps,
49
34
  } from './session.js';
50
35
  import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
51
- import { tryMemoryJob } from './memory-routine.js';
52
36
  import { receiveSessionFile, sessionFilePath } from './session-files.js';
53
37
  import { createReadStream } from '../agent-user/agent-fs.js';
54
38
  import { pipeline } from 'node:stream/promises';
@@ -70,15 +54,11 @@ const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
70
54
  const PAGE = 100;
71
55
  const PAGE_MAX = 500;
72
56
 
73
- export interface ClaudeApiDeps {
57
+ export interface ClaudeApiDeps extends SetupApiDeps {
74
58
  dir?: () => string;
75
59
  login?: LoginDeps;
76
60
  claudeLogins?: ClaudeLoginDeps;
77
- session?: SessionDeps;
78
- setup?: SetupDeps;
79
61
  version?: VersionDeps;
80
- liveEvents?: (on: boolean) => void;
81
- memoryJob?: (on: boolean) => void;
82
62
  }
83
63
 
84
64
  function projectOf(query: URLSearchParams): string {
@@ -155,104 +135,22 @@ const ADMIN_ONLY = /^\/api\/claude\/(login|session|version|setup)(\/|$)/;
155
135
  const LOGIN = 'login';
156
136
  const SESSION = 'session';
157
137
  const SETUP = 'setup';
158
- const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
159
138
  const VERSION = 'version';
160
139
 
161
- interface SetupChange {
162
- privacy?: boolean;
163
- permissionMode?: PermissionMode;
164
- systemPrompt?: string;
165
- liveEvents?: boolean;
166
- memoryRoutine?: boolean;
167
- }
168
-
169
- function promptChange(raw: unknown): string | undefined {
170
- if (raw === undefined) return undefined;
171
- if (typeof raw !== 'string') throw new ApiError('systemPrompt must be text', 400);
172
- if (Buffer.byteLength(raw) > SYSTEM_PROMPT_MAX) throw new ApiError(`systemPrompt must be under ${String(SYSTEM_PROMPT_MAX / 1024)} KiB`, 400);
173
- return raw;
174
- }
175
-
176
- function flagChange(raw: unknown, name: string): boolean | undefined {
177
- if (raw === undefined) return undefined;
178
- if (typeof raw !== 'boolean') throw new ApiError(`${name} must be true or false`, 400);
179
- return raw;
180
- }
181
-
182
- function modeChange(raw: unknown): PermissionMode | undefined {
183
- if (raw === undefined) return undefined;
184
- if (!isPermissionMode(raw)) throw new ApiError('permissionMode must be auto or bypass', 400);
185
- return raw;
186
- }
187
-
188
- function setupChange(body: unknown): SetupChange {
189
- if (!isRecord(body)) throw new ApiError('a body is required', 400);
190
- const privacy = flagChange(body.privacy, 'privacy');
191
- const permissionMode = modeChange(body.permissionMode);
192
- const systemPrompt = promptChange(body.systemPrompt);
193
- const liveEvents = flagChange(body.liveEvents, 'liveEvents');
194
- const memoryRoutine = flagChange(body.memoryRoutine, 'memoryRoutine');
195
- if ([privacy, permissionMode, systemPrompt, liveEvents, memoryRoutine].every((v) => v === undefined)) throw new ApiError('nothing to change', 400);
196
- return {
197
- ...(privacy === undefined ? {} : { privacy }),
198
- ...(permissionMode === undefined ? {} : { permissionMode }),
199
- ...(systemPrompt === undefined ? {} : { systemPrompt }),
200
- ...(liveEvents === undefined ? {} : { liveEvents }),
201
- ...(memoryRoutine === undefined ? {} : { memoryRoutine }),
202
- };
203
- }
204
-
205
- function restartsSession(change: SetupChange, agents: string | undefined): boolean {
206
- let restart = false;
207
- if (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents)) {
208
- setPermissionMode(change.permissionMode, agents);
209
- restart = true;
210
- }
211
- if (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents)) {
212
- setSystemPrompt(change.systemPrompt, agents);
213
- restart = true;
214
- }
215
- return restart;
216
- }
217
-
218
- function applyLiveEvents(on: boolean, deps: ClaudeApiDeps): void {
219
- const agents = deps.setup?.agents;
220
- if (on !== liveEvents(agents)) setLiveEvents(on, agents);
221
- deps.liveEvents?.(on);
222
- }
223
-
224
- function applyMemoryRoutine(on: boolean, deps: ClaudeApiDeps): void {
225
- setMemoryRoutine(on, deps.setup?.agents);
226
- (deps.memoryJob ?? tryMemoryJob)(on);
227
- }
228
-
229
- function applySetupChange(change: SetupChange, deps: ClaudeApiDeps): void {
230
- const setup = deps.setup ?? {};
231
- if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
232
- if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
233
- if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
234
- if (restartsSession(change, setup.agents) && sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
235
- ensureClaudeSetup(setup);
236
- }
237
-
238
- async function setupAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
239
- const setup = deps.setup ?? {};
240
- const method = req.method ?? 'GET';
241
- if (method === 'GET') return claudeSetupStatus(setup);
242
- if (method !== 'POST') throw new ApiError('method not allowed', 405);
243
- applySetupChange(setupChange(await readJsonBody(req, SETUP_BODY_MAX)), deps);
244
- return claudeSetupStatus(setup);
245
- }
246
-
247
140
  function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
248
141
  if (body.action === 'start') {
249
142
  const status = sessionStatus(session);
250
- if (status.running) return status;
251
- if (status.blocked !== null) throw new ApiError(`cannot start a Claude session: ${status.blocked}`, 409);
252
- return startSession(session);
143
+ if (!status.running && status.blocked !== null) throw new ApiError(`cannot start a Claude session: ${status.blocked}`, 409);
144
+ const started = status.running ? status : startSession(session);
145
+ setAutostart(typeof body.autostart === 'boolean' ? body.autostart : true, session.agents);
146
+ return { ...started, autostart: autostartEnabled(session.agents) };
147
+ }
148
+ if (body.action === 'stop') {
149
+ setAutostart(false, session.agents);
150
+ return stopSession(session);
253
151
  }
254
- if (body.action === 'stop') return stopSession(session);
255
152
  if (body.action !== undefined) throw new ApiError('action must be start or stop', 400);
153
+ if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
256
154
  if (body.autostart === true) ensureSession(session);
257
155
  return sessionStatus(session);
258
156
  }
@@ -264,7 +162,6 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
264
162
  if (method !== 'POST') throw new ApiError('method not allowed', 405);
265
163
  const body = await readJsonBody(req);
266
164
  if (!isRecord(body)) throw new ApiError('a body is required', 400);
267
- if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
268
165
  return sessionCommand(body, session);
269
166
  }
270
167
 
@@ -316,18 +213,18 @@ function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unkno
316
213
  return updateClaude(version);
317
214
  }
318
215
 
319
- const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
216
+ const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps, session: ApiSession) => Promise<unknown>> = {
320
217
  [SESSION]: sessionAnswer,
321
218
  [SETUP]: setupAnswer,
322
219
  [VERSION]: versionAnswer,
323
220
  };
324
221
 
325
- function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
222
+ function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps, session: ApiSession): unknown {
326
223
  const dir = (deps.dir ?? claudeDir)();
327
224
  const [head = '', item = ''] = parts(path);
328
225
  if (head === LOGIN) return loginAnswer(req, item, deps);
329
226
  const single = item === '' ? SINGLETONS[head] : undefined;
330
- if (single !== undefined) return single(req, deps);
227
+ if (single !== undefined) return single(req, deps, session);
331
228
  if (req.method === 'PUT') return writeAnswer(req, path, search, dir);
332
229
  if (req.method === 'POST') return created(req, path, dir);
333
230
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
@@ -375,7 +272,7 @@ export function handleClaudeRequest(
375
272
  if (!session) throw new ApiError('unauthorized', 401);
376
273
  if (req.method !== 'GET' && ADMIN_ONLY.test(path)) requireAdmin(session);
377
274
  if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
378
- sendJson(req, res, 200, await routed(req, path, search, deps));
275
+ sendJson(req, res, 200, await routed(req, path, search, deps, session));
379
276
  })
380
277
  .catch((err: unknown) => {
381
278
  if (err instanceof ApiError) apiFailure(req, res, err, 'claude-api');
@@ -0,0 +1,17 @@
1
+ import { join } from 'node:path';
2
+ import { parseRunnerActivity, type RunnerActivity } from '@metro-labs/core/runner-activity';
3
+ import { readRange } from '../agent-user/agent-fs.js';
4
+
5
+ const MAX_BYTES = 65_536;
6
+
7
+ export function readAgentActivity(home: string): RunnerActivity | null {
8
+ try {
9
+ const path = join(home, '.metro', 'agent-status.json');
10
+ const bytes = readRange(path, 0, MAX_BYTES + 1);
11
+ if (bytes.length > MAX_BYTES) return null;
12
+ const raw: unknown = JSON.parse(bytes.toString('utf8'));
13
+ return parseRunnerActivity(raw);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import type { RunnerActivity } from '@metro-labs/core/runner-activity';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { asAgent } from '../agent-user/user.js';
7
+ import { readAgentActivity } from './runner-activity.js';
8
+
9
+ function identity(pid: number): { started: string; zombie: boolean } | null {
10
+ try {
11
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
12
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
13
+ return { started: fields[19] ?? '', zombie: fields[0] === 'Z' };
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function pidAlive(pid: number): boolean {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ } catch (err) {
24
+ return isRecord(err) && err.code === 'EPERM';
25
+ }
26
+ }
27
+
28
+ export function sdkAlive(activity: RunnerActivity | null): boolean {
29
+ if (activity === null) return false;
30
+ const found = identity(activity.pid);
31
+ if (found?.zombie === true) return false;
32
+ if (activity.procStart !== undefined && found !== null && activity.procStart !== found.started) return false;
33
+ if (activity.procStart === undefined && activity.phase === 'stopped') return false;
34
+ return pidAlive(activity.pid);
35
+ }
36
+
37
+ function assertIdentity(activity: RunnerActivity): void {
38
+ const found = identity(activity.pid);
39
+ if (activity.pid === process.pid || activity.procStart === undefined || activity.procStart !== found?.started)
40
+ throw new ApiError('The Agent SDK is still running outside tmux, but its process identity cannot be verified safely. Stop that metro agent process from its terminal.', 503);
41
+ }
42
+
43
+ export function stopSdkRunner(home: string): void {
44
+ const activity = readAgentActivity(home);
45
+ if (activity === null || !sdkAlive(activity)) return;
46
+ assertIdentity(activity);
47
+ const run = spawnSync(...asAgent('kill', ['-TERM', String(activity.pid)]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
48
+ if ((run.error !== undefined || run.status !== 0) && sdkAlive(activity))
49
+ throw new ApiError(`The Agent SDK could not be stopped: ${run.error?.message ?? run.stderr.trim()}`, 503);
50
+ }
@@ -0,0 +1,45 @@
1
+ import { agentsDir } from '../agents/files.js';
2
+ import type { CodexMethod } from '../gateway/codex-auth.js';
3
+ import { connectionOf, readModelConfig, routedConnection, type Connection, type ModelConfig } from '../gateway/model-config.js';
4
+ import { readSetupState, writeSetupState } from './setup-state.js';
5
+
6
+ const RUNNERS = ['cli', 'sdk'] as const;
7
+ export type HarnessRunner = (typeof RUNNERS)[number];
8
+
9
+ export const SDK_NEEDS_KEY =
10
+ 'The Agent SDK runner needs an API key or a ChatGPT sign-in on every model the Model page lists, the first one and each fallback: use Anthropic with an API key, Amazon Bedrock, OpenRouter, or a signed-in Codex connection (with ChatGPT or with a code). Anthropic does not allow products built on the Agent SDK to use a Claude login, so on a login it runs only where the Metro operator allows it.';
11
+
12
+ export const isHarnessRunner = (value: unknown): value is HarnessRunner => RUNNERS.some((r) => r === value);
13
+
14
+ export function harnessRunner(agents = agentsDir()): HarnessRunner {
15
+ const runner = readSetupState(agents).runner;
16
+ return isHarnessRunner(runner) ? runner : 'cli';
17
+ }
18
+
19
+ export function setHarnessRunner(runner: HarnessRunner, agents = agentsDir()): void {
20
+ writeSetupState(agents, { runner });
21
+ }
22
+
23
+ export const sdkOnLogin = (agents = agentsDir()): boolean => readSetupState(agents).sdkOnLogin === true;
24
+
25
+ export function setSdkOnLogin(on: boolean, agents = agentsDir()): void {
26
+ writeSetupState(agents, { sdkOnLogin: on });
27
+ }
28
+
29
+ const SDK_ON_CODEX: Record<CodexMethod, boolean> = { chatgpt: true, code: true };
30
+
31
+ const permitted = (conn: Connection | null): boolean => conn !== null && (conn.apiKey !== '' || (conn.codex !== null && SDK_ON_CODEX[conn.codex.method]));
32
+
33
+ const servingConnections = (cfg: ModelConfig): (Connection | null)[] => [routedConnection(cfg), ...(cfg.fallbacks ?? []).map((f) => connectionOf(cfg, f.connection))];
34
+
35
+ const permittedRoute = (cfg: ModelConfig): boolean => servingConnections(cfg).every(permitted);
36
+
37
+ export const sdkAllowed = (agents = agentsDir(), cfg: ModelConfig = readModelConfig(agents), loginAllowed?: boolean): boolean => permittedRoute(cfg) || (loginAllowed ?? sdkOnLogin(agents));
38
+
39
+ export const sdkConnectionAllowed = (connection: Connection, agents = agentsDir()): boolean => permitted(connection) || sdkOnLogin(agents);
40
+
41
+ export function runnerModel(cfg: ModelConfig): string | null {
42
+ const conn = routedConnection(cfg);
43
+ if (conn === null || conn.model === '') return null;
44
+ return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
45
+ }
@@ -0,0 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
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
+ import { agentsDir } from '../agents/files.js';
6
+ import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
7
+ import { claudeDir } from './files.js';
8
+
9
+ const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
10
+ const sessionId = (value: unknown): string | null => typeof value === 'string' && SESSION_ID_RE.test(value) ? value : null;
11
+
12
+ function sdkSavedSession(home: string): Record<string, unknown> | null {
13
+ try {
14
+ const raw: unknown = JSON.parse(readFileSync(join(home, '.metro', 'agent-session.json'), 'utf8'));
15
+ return isRecord(raw) ? raw : null;
16
+ } catch (err) {
17
+ return isRecord(err) && err.code === 'ENOENT' ? {} : null;
18
+ }
19
+ }
20
+
21
+ export function sdkPending(home: string): number | null {
22
+ const state = sdkSavedSession(home);
23
+ if (state === null) return null;
24
+ if (state.sessionId !== undefined && state.sessionId !== null && sessionId(state.sessionId) === null) return null;
25
+ if (state.unanswered === undefined) return 0;
26
+ return Array.isArray(state.unanswered) ? state.unanswered.length : null;
27
+ }
28
+
29
+ function projectFolder(home: string, dir: string): string {
30
+ let cwd = home;
31
+ try {
32
+ cwd = realpathSync(home);
33
+ } catch {
34
+ cwd = home;
35
+ }
36
+ return join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
37
+ }
38
+
39
+ export function continueArgs(home: string, dir = claudeDir(), agents = agentsDir()): string[] {
40
+ const path = join(agents, 'claude-session.json');
41
+ const raw = readJson<unknown>(path, null);
42
+ const state = isRecord(raw) ? raw : {};
43
+ const project = projectFolder(home, dir);
44
+ const pinned = sessionId(state.cliSessionId);
45
+ const id = pinned ?? randomUUID();
46
+ if (pinned === null) writeJson(path, { ...state, cliSessionId: id });
47
+ return [existsSync(join(project, `${id}.jsonl`)) ? '--resume' : '--session-id', id];
48
+ }
@@ -1,5 +1,10 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { realpathSync } from '../agent-user/agent-fs.js';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import type { RunnerActivity } from '@metro-labs/core/runner-activity';
4
+ import { readAgentActivity } from './runner-activity.js';
5
+ import { sdkAlive, stopSdkRunner } from './runner-process.js';
6
+ import { continueArgs, sdkPending } from './session-continuity.js';
7
+ import { syncAgentView } from '../agent-user/view.js';
3
8
  import { homedir } from 'node:os';
4
9
  import { join } from 'node:path';
5
10
  import { errMsg, log } from '@metro-labs/core/log';
@@ -8,7 +13,8 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
8
13
  import { METRO_VERSION } from '@metro-labs/core/version';
9
14
  import { agentsDir, listAgentFiles } from '../agents/files.js';
10
15
  import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
11
- import { claudeDir, listClaudeProjects } from './files.js';
16
+ import { claudeDir } from './files.js';
17
+ import { harnessRunner, isHarnessRunner, sdkAllowed, SDK_NEEDS_KEY, type HarnessRunner } from './runner.js';
12
18
  import { claudeAccount, claudeInstalled } from './login.js';
13
19
  import { trustFolder } from './onboarding.js';
14
20
  import { inSessionScope } from './memory.js';
@@ -35,17 +41,20 @@ const PAUSE_MS = 30 * 60_000;
35
41
  export interface SessionDeps {
36
42
  tmux?: string;
37
43
  metro?: string[];
44
+ runner?: string[];
38
45
  home?: string;
39
46
  agents?: string;
40
47
  signedIn?: () => boolean;
41
48
  now?: () => number;
42
49
  version?: string;
43
- continues?: (home: string) => boolean;
50
+ prepare?: (runner: HarnessRunner) => Promise<void>;
44
51
  }
45
52
 
46
53
  export interface SessionStatus {
47
54
  name: string;
48
55
  running: boolean;
56
+ runner?: HarnessRunner;
57
+ activity?: RunnerActivity | null;
49
58
  autostart: boolean;
50
59
  blocked: string | null;
51
60
  lastStartedAt: string | null;
@@ -92,24 +101,6 @@ export const sessionRunning = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['has-ses
92
101
 
93
102
  export const tmuxServerUp = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['list-sessions']);
94
103
 
95
- function realDir(dir: string): string {
96
- try {
97
- return realpathSync(dir);
98
- } catch {
99
- return dir;
100
- }
101
- }
102
-
103
- const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
104
-
105
- export function hasConversation(home: string, dir = claudeDir()): boolean {
106
- const cwd = realDir(home);
107
- const folder = encodedCwd(cwd);
108
- return listClaudeProjects(dir).some(
109
- (project) => project.sessions > 0 && (project.id === folder || (project.cwd !== null && realDir(project.cwd) === cwd)),
110
- );
111
- }
112
-
113
104
  export function metroCli(args: string[], fixed?: string[]): string[] {
114
105
  const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
115
106
  const runtime = agentUser() === null ? process.execPath : 'node';
@@ -118,10 +109,53 @@ export function metroCli(args: string[], fixed?: string[]): string[] {
118
109
  return env.length === 0 || fixed !== undefined ? base : ['env', ...env, ...base];
119
110
  }
120
111
 
112
+ const sessionHome = (deps: SessionDeps): string => deps.home ?? claudeHome() ?? homedir();
113
+
114
+ export function sessionRunner(deps: SessionDeps = {}): HarnessRunner {
115
+ const agents = deps.agents ?? agentsDir();
116
+ const runner = readState(agents).runner;
117
+ return isHarnessRunner(runner) ? runner : harnessRunner(agents);
118
+ }
119
+
120
+ export function assertSessionStopped(deps: SessionDeps = {}): void {
121
+ 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);
122
+ }
123
+
124
+ export function assertSdkDrained(deps: SessionDeps = {}): void {
125
+ if (sdkPending(sessionHome(deps)) !== 0)
126
+ 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);
127
+ }
128
+
129
+ let changing = false;
130
+
131
+ export async function withSessionChange<T>(run: () => Promise<T>): Promise<T> {
132
+ if (changing) throw new ApiError('A session change is already being prepared. Try again when it finishes.', 409);
133
+ changing = true;
134
+ try {
135
+ return await run();
136
+ } finally {
137
+ changing = false;
138
+ }
139
+ }
140
+
141
+ export async function prepareSessionRunner(runner: HarnessRunner, deps: SessionDeps = {}): Promise<void> {
142
+ if (deps.prepare !== undefined) return deps.prepare(runner);
143
+ const [bin = 'metro', ...args] = metroCli(['agent', '--prepare', runner]);
144
+ await new Promise<void>((resolve, reject) => {
145
+ const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], timeout: 16 * 60_000 });
146
+ let error = '';
147
+ child.stderr.on('data', (chunk: Buffer) => { error = (error + chunk.toString()).slice(-2000); });
148
+ child.on('error', (err) => { reject(new ApiError(`Runner preparation failed: ${errMsg(err)}`, 503)); });
149
+ child.on('close', (code) => {
150
+ if (code === 0) resolve();
151
+ else reject(new ApiError(`Runner preparation failed. The runner choice was not changed. ${error.trim()}`, 503));
152
+ });
153
+ });
154
+ }
155
+
121
156
  function metroCommand(deps: SessionDeps, home: string): string[] {
122
- const command = metroCli(['claude'], deps.metro);
123
- const continues = (deps.continues ?? hasConversation)(home);
124
- return continues ? [...command, '-c'] : command;
157
+ if (harnessRunner(deps.agents ?? agentsDir()) === 'sdk') return metroCli(['agent'], deps.runner);
158
+ return [...metroCli(['claude'], deps.metro), ...continueArgs(home, claudeDir(), deps.agents)];
125
159
  }
126
160
 
127
161
  function credentialReady(deps: SessionDeps): string | null {
@@ -130,7 +164,7 @@ function credentialReady(deps: SessionDeps): string | null {
130
164
  try {
131
165
  const cfg = readModelConfig(deps.agents ?? agentsDir());
132
166
  const conn = routedConnection(cfg);
133
- if (conn !== null && conn.provider !== 'anthropic' && notReady(cfg) === null) return null;
167
+ if (conn !== null && notReady(cfg) === null && (conn.provider !== 'anthropic' || conn.apiKey !== '')) return null;
134
168
  } catch (err) {
135
169
  return `the Model page is not readable (${errMsg(err)})`;
136
170
  }
@@ -145,7 +179,12 @@ const ROOT_REFUSED = 'Metro runs as root; reinstall it as the metro user';
145
179
  export function sessionBlocked(deps: SessionDeps = {}): string | null {
146
180
  if (runningAsRoot()) return ROOT_REFUSED;
147
181
  const agents = deps.agents ?? agentsDir();
182
+ if (harnessRunner(agents) === 'sdk' && !sdkAllowed(agents)) return SDK_NEEDS_KEY;
148
183
  if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
184
+ return launchBlocked(deps);
185
+ }
186
+
187
+ function launchBlocked(deps: SessionDeps): string | null {
149
188
  if (agentUserMissing(deps))
150
189
  return 'Claude Code runs as the user agent here, and that user is not ready yet; see the log';
151
190
  if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
@@ -190,18 +229,29 @@ function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?
190
229
  return;
191
230
  }
192
231
  memory.lastError = null;
193
- writeState(deps.agents ?? agentsDir(), { version: deps.version ?? METRO_VERSION });
232
+ const agents = deps.agents ?? agentsDir();
233
+ writeState(agents, { version: deps.version ?? METRO_VERSION, runner: harnessRunner(agents) });
194
234
  confirmChannels(tmux, now + CONFIRM_WAIT_MS);
195
235
  }
196
236
 
237
+ function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, string[]] {
238
+ const launch = asAgent(tmux, args);
239
+ return deps.tmux === undefined && !tmuxServerUp(tmux) ? inSessionScope(launch) : launch;
240
+ }
241
+
197
242
  export function startSession(deps: SessionDeps = {}): SessionStatus {
243
+ if (changing) throw new ApiError('A runner change is being prepared. Start the session after it finishes.', 409);
244
+ const status = sessionStatus(deps);
245
+ if (status.running) return status;
246
+ if (status.blocked !== null) throw new ApiError(`Cannot start the session: ${status.blocked}`, 409);
247
+ syncAgentView(undefined, deps.agents);
198
248
  const tmux = deps.tmux ?? 'tmux';
199
- const home = deps.home ?? claudeHome() ?? homedir();
249
+ const home = sessionHome(deps);
200
250
  const now = (deps.now ?? Date.now)();
201
251
  const trusted = trustFolder(home);
202
252
  const [command = 'metro', ...args] = metroCommand(deps, home);
203
253
  const tmuxArgs = ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args];
204
- const launch = deps.tmux === undefined && !tmuxServerUp(tmux) ? inSessionScope(asAgent(tmux, tmuxArgs)) : asAgent(tmux, tmuxArgs);
254
+ const launch = tmuxLaunch(deps, tmux, tmuxArgs);
205
255
  const run = spawnSync(...launch, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: '/' });
206
256
  recordStart(deps, tmux, now, run);
207
257
  if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
@@ -210,13 +260,21 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
210
260
 
211
261
  export function stopSession(deps: SessionDeps = {}): SessionStatus {
212
262
  spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
263
+ stopSdkRunner(sessionHome(deps));
213
264
  return sessionStatus(deps);
214
265
  }
215
266
 
267
+ export const sessionLive = (deps: SessionDeps = {}): boolean =>
268
+ sessionRunning(deps.tmux ?? 'tmux') || sdkAlive(readAgentActivity(sessionHome(deps)));
269
+
216
270
  export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
271
+ const activity = readAgentActivity(sessionHome(deps));
272
+ const sdk = sdkAlive(activity);
217
273
  return {
218
274
  name: SESSION_NAME,
219
- running: sessionRunning(deps.tmux ?? 'tmux'),
275
+ running: sessionRunning(deps.tmux ?? 'tmux') || sdk,
276
+ runner: sdk ? 'sdk' : sessionRunner(deps),
277
+ activity,
220
278
  autostart: autostartEnabled(deps.agents ?? agentsDir()),
221
279
  blocked: sessionBlocked(deps),
222
280
  lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
@@ -264,8 +322,9 @@ function situation(r: Resolved): 'running' | 'stale' | 'absent' {
264
322
  export function ensureSession(deps: SessionDeps = {}): Ensured {
265
323
  const r = resolved(deps);
266
324
  if (!autostartEnabled(r.agents)) return 'off';
325
+ if (changing) return 'blocked';
267
326
  const found = situation(r);
268
- if (found === 'running') return 'running';
327
+ if (found === 'running' || (found === 'absent' && sessionLive(deps))) return 'running';
269
328
  const held = holdsBack(deps, r.now);
270
329
  if (held !== null) return held;
271
330
  if (found === 'stale') {