@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
package/dist/agent.js ADDED
@@ -0,0 +1,128 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { headlessEnv, runClaude } from './claude.js';
6
+ import { localAgent } from './local.js';
7
+ import { currentModel, harnessRunner, permissionMode, systemPrompt } from './route.js';
8
+ import { installDependencies, ranges, readOrNull } from './runtime-install.js';
9
+ import { findBun, localPort, runtimeDir } from './runtime.js';
10
+ export const RUNNER_SOURCES = 'sdk-runner';
11
+ const STAMP = 'runner.json';
12
+ const ENTRY = join('src', 'main.ts');
13
+ const METRO_MODULES = join('node_modules', '@metro-labs');
14
+ const BUNFIG = '[install]\noptional = false\npeer = false\n';
15
+ const SDK = '@anthropic-ai/claude-agent-sdk';
16
+ const NOT_THE_RUNNER = 'the Harness runs this agent as a Claude Code session, so metro agent does not start: it would take the metro chat away from it. Choose Agent SDK as the Harness runner on metro.box first';
17
+ export function runnerStore(env = process.env) {
18
+ const explicit = env.METRO_RUNNER_STORE?.trim() ?? '';
19
+ return explicit === '' ? join(homedir(), '.metro', 'sdk-runner') : explicit;
20
+ }
21
+ export function readRunnerManifest(text) {
22
+ const raw = JSON.parse(text);
23
+ return {
24
+ version: typeof raw.version === 'string' ? raw.version : '',
25
+ dependencies: ranges(raw.dependencies),
26
+ };
27
+ }
28
+ function glibcMissing() {
29
+ const report = process.report.getReport();
30
+ return report.header?.glibcVersionRuntime === undefined;
31
+ }
32
+ export function claudePackage(platform = process.platform, arch = process.arch, musl = platform === 'linux' && glibcMissing()) {
33
+ return { name: `${SDK}-${platform}-${arch}${musl ? '-musl' : ''}`, binary: platform === 'win32' ? 'claude.exe' : 'claude' };
34
+ }
35
+ export function runnerDependencies(manifest, pkg) {
36
+ const sdk = manifest.dependencies[SDK];
37
+ return sdk === undefined ? manifest.dependencies : { ...manifest.dependencies, [pkg.name]: sdk };
38
+ }
39
+ function stageSources(sources, store, stamp) {
40
+ mkdirSync(store, { recursive: true });
41
+ if (readOrNull(join(store, STAMP)) !== stamp || !existsSync(join(store, ENTRY))) {
42
+ rmSync(join(store, 'src'), { recursive: true, force: true });
43
+ rmSync(join(store, METRO_MODULES), { recursive: true, force: true });
44
+ cpSync(join(sources, 'src'), join(store, 'src'), { recursive: true });
45
+ cpSync(join(sources, METRO_MODULES), join(store, METRO_MODULES), { recursive: true });
46
+ writeFileSync(join(store, STAMP), stamp);
47
+ }
48
+ if (readOrNull(join(store, 'bunfig.toml')) !== BUNFIG)
49
+ writeFileSync(join(store, 'bunfig.toml'), BUNFIG);
50
+ }
51
+ export function prepareRunner(opts = {}) {
52
+ const sources = opts.sources ?? join(runtimeDir(), RUNNER_SOURCES);
53
+ const stamp = readFileSync(join(sources, STAMP), 'utf8');
54
+ const store = opts.store ?? runnerStore();
55
+ stageSources(sources, store, stamp);
56
+ const log = opts.log ??
57
+ ((line) => {
58
+ process.stderr.write(`metro agent: ${line}\n`);
59
+ });
60
+ const pkg = opts.claude ?? claudePackage();
61
+ const claude = join(store, 'node_modules', ...pkg.name.split('/'), pkg.binary);
62
+ const marker = join(store, 'node_modules', '.metro-installed');
63
+ if (!existsSync(claude))
64
+ rmSync(marker, { force: true });
65
+ installDependencies(store, runnerDependencies(readRunnerManifest(stamp), pkg), opts.bun ?? findBun(), log);
66
+ if (!existsSync(claude)) {
67
+ rmSync(marker, { force: true });
68
+ throw new Error(`The Claude Code binary bundled with the Agent SDK is missing at ${claude}. Try preparing the runner again.`);
69
+ }
70
+ return { entry: join(store, ENTRY), claude };
71
+ }
72
+ export function runnerEnv(key, port, model, mode, prompt, claude) {
73
+ return {
74
+ METRO_RUNNER_MCP_URL: `http://127.0.0.1:${String(port)}/mcp`,
75
+ METRO_RUNNER_CLAUDE: claude,
76
+ METRO_AGENT_KEY: key,
77
+ METRO_RUNNER_PERMISSION_MODE: mode,
78
+ ...(model === null ? {} : { METRO_RUNNER_MODEL: model }),
79
+ ...(prompt === null ? {} : { METRO_RUNNER_PROMPT: prompt }),
80
+ };
81
+ }
82
+ export function sdkGatewayEnv(env) {
83
+ const own = (env.ANTHROPIC_CUSTOM_HEADERS ?? '').trim();
84
+ const header = 'x-metro-runner: sdk';
85
+ return { ...env, ANTHROPIC_CUSTOM_HEADERS: own === '' ? header : `${own}\n${header}` };
86
+ }
87
+ function checkBinary(bin) {
88
+ const run = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 30_000 });
89
+ if (run.error !== undefined || run.status !== 0)
90
+ throw new Error(`Runner preflight failed: ${bin} cannot run (${run.error?.message ?? `exit ${String(run.status)}`}).`);
91
+ }
92
+ function checkPreparedRunner(opts) {
93
+ const prepared = prepareRunner(opts);
94
+ try {
95
+ checkBinary(prepared.claude);
96
+ }
97
+ catch (err) {
98
+ rmSync(join(opts.store ?? runnerStore(), 'node_modules', '.metro-installed'), { force: true });
99
+ throw err;
100
+ }
101
+ }
102
+ export function prepareAgentRunner(runner, opts = {}) {
103
+ if (runner === 'cli')
104
+ checkBinary('claude');
105
+ else if (runner === 'sdk')
106
+ checkPreparedRunner(opts);
107
+ else
108
+ throw new Error('metro agent --prepare needs cli or sdk');
109
+ }
110
+ export async function agentSession(args = []) {
111
+ if (args[0] === '--prepare' && args.length <= 2) {
112
+ prepareAgentRunner(args[1] ?? 'sdk');
113
+ return 0;
114
+ }
115
+ if (args.length > 0)
116
+ throw new Error('metro agent accepts only --prepare [cli|sdk]');
117
+ process.chdir(homedir());
118
+ if (harnessRunner() !== 'sdk')
119
+ throw new Error(NOT_THE_RUNNER);
120
+ const agent = localAgent();
121
+ if (agent === null)
122
+ throw new Error('metro agent needs the agent of this machine, and there is none yet');
123
+ const bun = findBun();
124
+ const runner = prepareRunner({ bun });
125
+ process.stderr.write('metro agent: this agent runs as one Agent SDK session: a light front talks, background workers do the work\n');
126
+ const env = { ...sdkGatewayEnv(await headlessEnv()), ...runnerEnv(agent.key, localPort(), currentModel(), permissionMode(), systemPrompt(), runner.claude) };
127
+ return runClaude([runner.entry], env, undefined, bun);
128
+ }
@@ -96,6 +96,20 @@ function stopBackground(id, env = process.env) {
96
96
  const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
97
97
  return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
98
98
  }
99
+ export function resumeSessionId(args) {
100
+ const index = args.findLastIndex((arg) => arg === '--resume' || arg === '-r');
101
+ const id = index === -1 ? undefined : args[index + 1];
102
+ return id !== undefined && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(id) ? id : undefined;
103
+ }
104
+ function pinnedHeld(dir, live, id) {
105
+ if (live.has(id))
106
+ return id;
107
+ const path = join(dir, `${id}.jsonl`);
108
+ if (!existsSync(path))
109
+ return null;
110
+ const moved = continuedIn(readFileSync(path, 'utf8'));
111
+ return moved !== null && live.has(moved) ? moved : null;
112
+ }
99
113
  const sleep = (ms) => new Promise((resolve) => {
100
114
  setTimeout(resolve, ms);
101
115
  });
@@ -107,12 +121,16 @@ async function settled(sessions, held, alive, ms) {
107
121
  await sleep(POLL_MS);
108
122
  return settled(sessions, held, alive, ms - POLL_MS);
109
123
  }
124
+ function heldFor(deps, dir, live) {
125
+ const project = projectDir(dir, deps.cwd ?? process.cwd());
126
+ return deps.sessionId === undefined ? heldConversation(project, live) : pinnedHeld(project, live, deps.sessionId);
127
+ }
110
128
  export async function takeBackConversation(deps = {}) {
111
129
  const env = deps.env ?? process.env;
112
130
  const alive = deps.alive ?? processAlive;
113
131
  const dir = claudeDir(env);
114
132
  const sessions = join(dir, 'sessions');
115
- const held = heldConversation(projectDir(dir, deps.cwd ?? process.cwd()), liveBackground(sessions, alive));
133
+ const held = heldFor(deps, dir, liveBackground(sessions, alive));
116
134
  if (held === null)
117
135
  return null;
118
136
  const id = held.slice(0, 8);
package/dist/claude.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { markOnboardingDone, seedChannels } from './onboarding.js';
3
3
  import { writeMcpConfig } from './mcp-config.js';
4
- import { currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
4
+ import { currentRoute, harnessRunner, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
5
  import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
6
  import { localAgent } from './local.js';
7
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
8
8
  import { localPort, localUrl } from './runtime.js';
9
- import { keepInSession, takeBackConversation } from './background.js';
9
+ import { keepInSession, resumeSessionId, takeBackConversation } from './background.js';
10
10
  const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
11
11
  const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
12
12
  export const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
@@ -108,16 +108,16 @@ function deadline(child, timeoutMs) {
108
108
  clearTimeout(kill);
109
109
  };
110
110
  }
111
- export function runClaude(args, env, headless) {
111
+ export function runClaude(args, env, headless, command = 'claude') {
112
112
  return new Promise((resolve, reject) => {
113
113
  const leaveToChild = () => undefined;
114
114
  process.on('SIGINT', leaveToChild);
115
115
  process.on('SIGTERM', leaveToChild);
116
- const child = spawn('claude', args, { stdio: [headless === undefined ? 'inherit' : 'ignore', 'inherit', 'inherit'], env });
116
+ const child = spawn(command, args, { stdio: [headless === undefined ? 'inherit' : 'ignore', 'inherit', 'inherit'], env });
117
117
  const stop = deadline(child, headless?.timeoutMs);
118
118
  child.on('error', (err) => {
119
119
  reject(new Error(err.code === 'ENOENT'
120
- ? 'the `claude` command is not on PATH — install Claude Code first'
120
+ ? `the \`${command}\` command is not on PATH${command === 'claude' ? ' — install Claude Code first' : ''}`
121
121
  : err.message));
122
122
  });
123
123
  child.on('exit', (code) => {
@@ -167,10 +167,11 @@ export async function headlessEnv() {
167
167
  }
168
168
  const continues = (args) => args.includes('-c') || args.includes('--continue');
169
169
  async function reclaimConversation(extra) {
170
- if (!continues(extra))
170
+ const sessionId = resumeSessionId(extra);
171
+ if (sessionId === undefined && !continues(extra))
171
172
  return;
172
173
  try {
173
- const note = await takeBackConversation();
174
+ const note = await takeBackConversation({ sessionId });
174
175
  if (note !== null)
175
176
  process.stderr.write(`metro claude: ${note}\n`);
176
177
  }
@@ -179,7 +180,10 @@ async function reclaimConversation(extra) {
179
180
  process.stderr.write(`metro claude: could not check for a Claude Code background session holding the conversation (${message})\n`);
180
181
  }
181
182
  }
183
+ const SDK_HOLDS_CHAT = 'the Harness runs this agent as an Agent SDK session (metro agent), which holds the metro chat; a metro claude session would take it away. Switch the Harness runner back to the Claude Code session first, or open the conversation with plain claude --resume <session id>';
182
184
  export async function launchClaude(extra) {
185
+ if (harnessRunner() === 'sdk')
186
+ throw new Error(SDK_HOLDS_CHAT);
183
187
  await reclaimConversation(extra);
184
188
  const decision = await verdict();
185
189
  const port = localPort();
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { whoisAuthorized } from './api.js';
3
3
  import { stopAll } from './control.js';
4
4
  import { tailEvents } from './tail.js';
5
+ import { agentSession } from './agent.js';
5
6
  import { launchClaude } from './claude.js';
6
7
  import { memoryPaths, memoryRoutine } from './memory.js';
7
8
  import { update } from './update.js';
@@ -33,6 +34,12 @@ const USAGE = `metro: run your agent on this machine
33
34
  open Claude Code with the metro channel and the metro MCP server, its
34
35
  inference routed through the daemon's model gateway (the Model page);
35
36
  every argument is passed through the same
37
+ metro agent run chat and background workers in one Agent SDK conversation,
38
+ when the Harness runner is Agent SDK, using the Model page's model;
39
+ resume it after a restart; voice calls remain separate
40
+ metro agent --prepare [cli|sdk]
41
+ check the runner before selecting it; install the SDK runtime if needed,
42
+ without starting a session or connecting to chat
36
43
  metro memory [skill file]
37
44
  run the memory routine once: when Claude Code transcripts changed since
38
45
  the last run, Claude Code reads the memory skill (or the file given) and
@@ -77,6 +84,7 @@ const COMMANDS = {
77
84
  stop: stopDaemon,
78
85
  tail: () => tailEvents(process.argv.slice(3)),
79
86
  claude: () => launchClaude(process.argv.slice(3)),
87
+ agent: () => agentSession(process.argv.slice(3)),
80
88
  memory: () => memoryRoutine(memoryPaths(process.env, process.argv[3])),
81
89
  voice: () => voiceSession(process.argv.slice(3)),
82
90
  update: () => update(process.argv.slice(3)),
package/dist/route.js CHANGED
@@ -2,8 +2,9 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { agentsDir } from './local.js';
4
4
  const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
5
+ const ANTHROPIC = 'anthropic';
5
6
  const text = (value) => (typeof value === 'string' ? value : '');
6
- function routeIn(cfg) {
7
+ function routedIn(cfg) {
7
8
  if (typeof cfg !== 'object' || cfg === null)
8
9
  return null;
9
10
  const { route, connections } = cfg;
@@ -12,38 +13,45 @@ function routeIn(cfg) {
12
13
  const conn = connections.find((c) => typeof c === 'object' && c !== null && c.id === route);
13
14
  if (typeof conn !== 'object' || conn === null)
14
15
  return null;
15
- const provider = text(conn.provider);
16
16
  const model = text(conn.model);
17
- return PROVIDERS.has(provider) && model !== '' ? `${provider}:${model}` : null;
17
+ return model === '' ? null : { provider: text(conn.provider), model };
18
18
  }
19
- export function currentRoute(dir = agentsDir()) {
19
+ function routed(dir) {
20
20
  const path = join(dir, 'model.json');
21
21
  if (!existsSync(path))
22
22
  return null;
23
23
  try {
24
- return routeIn(JSON.parse(readFileSync(path, 'utf8')));
24
+ return routedIn(JSON.parse(readFileSync(path, 'utf8')));
25
25
  }
26
26
  catch {
27
27
  return null;
28
28
  }
29
29
  }
30
+ const prefixed = (found) => (found !== null && PROVIDERS.has(found.provider) ? `${found.provider}:${found.model}` : null);
31
+ export const currentRoute = (dir = agentsDir()) => prefixed(routed(dir));
32
+ export function currentModel(dir = agentsDir()) {
33
+ const found = routed(dir);
34
+ return found?.provider === ANTHROPIC ? found.model : prefixed(found);
35
+ }
30
36
  export function routeModelEnv(env, route) {
31
37
  if (route === null || (env.ANTHROPIC_MODEL ?? '').trim() !== '')
32
38
  return env;
33
39
  return { ...env, ANTHROPIC_MODEL: route };
34
40
  }
35
- export function permissionMode(dir = agentsDir()) {
41
+ export const permissionMode = (dir = agentsDir()) => (setupField(dir, 'permissionMode') === 'bypass' ? 'bypass' : 'auto');
42
+ function setupField(dir, field) {
36
43
  const path = join(dir, 'claude-setup.json');
37
44
  if (!existsSync(path))
38
- return 'auto';
45
+ return undefined;
39
46
  try {
40
47
  const state = JSON.parse(readFileSync(path, 'utf8'));
41
- return state.permissionMode === 'bypass' ? 'bypass' : 'auto';
48
+ return state[field];
42
49
  }
43
50
  catch {
44
- return 'auto';
51
+ return undefined;
45
52
  }
46
53
  }
54
+ export const harnessRunner = (dir = agentsDir()) => (setupField(dir, 'runner') === 'sdk' ? 'sdk' : 'cli');
47
55
  export function systemPrompt(dir = agentsDir()) {
48
56
  const path = join(dir, 'system-prompt.md');
49
57
  if (!existsSync(path))
@@ -13,7 +13,7 @@ export function runtimeStore() {
13
13
  return env === '' ? join(homedir(), '.metro', 'runtime') : env;
14
14
  }
15
15
  const isRecord = (v) => typeof v === 'object' && v !== null;
16
- const ranges = (raw) => isRecord(raw)
16
+ export const ranges = (raw) => isRecord(raw)
17
17
  ? Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === 'string'))
18
18
  : {};
19
19
  export function readManifest(path) {
@@ -33,7 +33,7 @@ export function dependenciesFor(manifest, stations) {
33
33
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
34
34
  }
35
35
  const packageText = (deps) => `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
36
- function readOrNull(path) {
36
+ export function readOrNull(path) {
37
37
  try {
38
38
  return readFileSync(path, 'utf8');
39
39
  }
@@ -68,6 +68,7 @@ export function installDependencies(store, deps, bun, log) {
68
68
  const marker = join(store, 'node_modules', '.metro-installed');
69
69
  if (existsSync(marker) && readOrNull(join(store, 'package.json')) === wanted)
70
70
  return false;
71
+ rmSync(marker, { force: true });
71
72
  writeFileSync(join(store, 'package.json'), wanted);
72
73
  log(`Installing the channel SDKs this machine needs into ${store}`);
73
74
  const run = spawnSync(bun, ['install', '--no-summary', '--no-progress'], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.259",
3
+ "version": "0.1.0-beta.260",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.259",
3
+ "version": "0.1.0-beta.260",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -8,6 +8,7 @@
8
8
  "./log": "./src/log.ts",
9
9
  "./events": "./src/events.ts",
10
10
  "./is-record": "./src/is-record.ts",
11
+ "./runner-activity": "./src/runner-activity.ts",
11
12
  "./secure-fs": "./src/secure-fs.ts",
12
13
  "./version": "./src/version.ts",
13
14
  "./train-error": "./src/train-error.ts",
@@ -1,7 +1,11 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomInt } from 'node:crypto';
2
2
 
3
3
  const ID_BYTES = 8;
4
4
  const REROLLS = 16;
5
+ const APPROVAL_LETTERS = 'abcdefghijkmnopqrstuvwxyz';
6
+ const APPROVAL_LENGTH = 5;
7
+
8
+ export const approvalId = (): string => Array.from({ length: APPROVAL_LENGTH }, () => APPROVAL_LETTERS[randomInt(APPROVAL_LETTERS.length)]).join('');
5
9
 
6
10
  export const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
7
11
 
@@ -0,0 +1,103 @@
1
+ import { isRecord } from './is-record.js';
2
+
3
+ const PHASES = ['starting', 'idle', 'working', 'approval', 'compacting', 'stopped', 'error'] as const;
4
+ const TASK_STATES = ['pending', 'running', 'completed', 'failed', 'stopped', 'paused', 'unknown'] as const;
5
+ const EVENTS = ['turn_started', 'turn_finished', 'turn_failed', 'tool_started', 'tool_finished', 'tool_failed', 'task_started', 'task_completed', 'task_failed', 'task_stopped', 'task_updated', 'compacting', 'compacted', 'compact_failed', 'api_retry', 'approval_waiting', 'approval_ended', 'permission_denied', 'session_failed'] as const;
6
+ export type RunnerPhase = (typeof PHASES)[number];
7
+ export type RunnerTaskState = (typeof TASK_STATES)[number];
8
+ export type RunnerEventKind = (typeof EVENTS)[number];
9
+
10
+ export interface RunnerTask {
11
+ id: string;
12
+ kind: string | null;
13
+ agent: string | null;
14
+ status: RunnerTaskState;
15
+ background: boolean;
16
+ startedAt: number;
17
+ updatedAt: number;
18
+ endedAt: number | null;
19
+ lastTool: string | null;
20
+ toolUses: number;
21
+ durationMs: number;
22
+ }
23
+
24
+ export interface RunnerTool {
25
+ id: string;
26
+ name: string;
27
+ taskId: string | null;
28
+ worker?: boolean;
29
+ startedAt: number;
30
+ }
31
+
32
+ export interface RunnerEvent {
33
+ at: number;
34
+ kind: RunnerEventKind;
35
+ tool: string | null;
36
+ taskId: string | null;
37
+ }
38
+
39
+ export interface RunnerActivity {
40
+ pid: number;
41
+ procStart?: string;
42
+ runner: 'sdk';
43
+ phase: RunnerPhase;
44
+ mainPhase: RunnerPhase;
45
+ mainStartedAt: number | null;
46
+ sessionId: string | null;
47
+ updatedAt: number;
48
+ pending: number;
49
+ workers: number;
50
+ approvals: number;
51
+ tools: string[];
52
+ activeTools: RunnerTool[];
53
+ tasks: RunnerTask[];
54
+ events: RunnerEvent[];
55
+ lastError: string | null;
56
+ }
57
+
58
+ const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
59
+ const phase = (value: unknown): value is RunnerPhase => PHASES.some((p) => p === value);
60
+ const taskState = (value: unknown): value is RunnerTaskState => TASK_STATES.some((s) => s === value);
61
+ const eventKind = (value: unknown): value is RunnerEventKind => EVENTS.some((e) => e === value);
62
+ const name = (value: unknown): string | null => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : null;
63
+ const sessionId = (value: unknown): string | null => typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value) ? value : null;
64
+
65
+ function task(raw: unknown): RunnerTask | null {
66
+ if (!isRecord(raw) || !name(raw.id) || !taskState(raw.status)) return null;
67
+ return {
68
+ id: String(raw.id), kind: name(raw.kind), agent: name(raw.agent), status: raw.status, background: raw.background === true,
69
+ startedAt: count(raw.startedAt), updatedAt: count(raw.updatedAt), endedAt: count(raw.endedAt) || null,
70
+ lastTool: name(raw.lastTool), toolUses: count(raw.toolUses), durationMs: count(raw.durationMs),
71
+ };
72
+ }
73
+
74
+ function tool(raw: unknown): RunnerTool | null {
75
+ if (!isRecord(raw) || !name(raw.id) || !name(raw.name)) return null;
76
+ return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
77
+ }
78
+
79
+ function event(raw: unknown): RunnerEvent | null {
80
+ if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
81
+ return { at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId) };
82
+ }
83
+
84
+ function list<T>(raw: unknown, parse: (item: unknown) => T | null, max: number): T[] {
85
+ if (!Array.isArray(raw)) return [];
86
+ return raw.map(parse).filter((item): item is T => item !== null).slice(0, max);
87
+ }
88
+
89
+ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
90
+ return typeof value === 'string' && /^\d{1,32}$/.test(value) ? { procStart: value } : {};
91
+ }
92
+
93
+ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
94
+ if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
95
+ return {
96
+ runner: 'sdk', pid: count(raw.pid), ...processIdentity(raw.procStart), phase: raw.phase, mainPhase: phase(raw.mainPhase) ? raw.mainPhase : raw.phase,
97
+ mainStartedAt: count(raw.mainStartedAt) || null, sessionId: sessionId(raw.sessionId), updatedAt: count(raw.updatedAt),
98
+ pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
99
+ tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
100
+ tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
101
+ lastError: typeof raw.lastError === 'string' ? raw.lastError.slice(0, 300) : null,
102
+ };
103
+ }
@@ -90,13 +90,13 @@ function openGuarded(path: string): number {
90
90
  }
91
91
  }
92
92
 
93
- function asAgentSh(script: string, args: string[]): Buffer {
93
+ function asAgentSh(script: string, args: string[], failure = 'ENOENT'): Buffer {
94
94
  const [file, argv] = asUser(agentUser(), 'sh', ['-c', script, 'metro', ...args]);
95
95
  const run = spawnSync(file, argv, { maxBuffer: MAX_OUTPUT, stdio: ['ignore', 'pipe', 'pipe'] });
96
96
  if (run.error !== undefined) throw run.error;
97
97
  if (run.status !== 0) {
98
98
  const err = new Error(run.stderr.toString('utf8').trim() || `no such file or directory, ${args[0] ?? ''}`) as Error & { code: string };
99
- err.code = 'ENOENT';
99
+ err.code = run.status === 66 ? 'ENOENT' : failure;
100
100
  throw err;
101
101
  }
102
102
  return run.stdout;
@@ -169,7 +169,7 @@ export function readFileSync(path: string, encoding: 'utf8'): string {
169
169
  fs.closeSync(fd);
170
170
  }
171
171
  },
172
- () => asAgentSh('cat -- "$1"', [path]).toString('utf8'),
172
+ () => asAgentSh('if test -x "${1%/*}" && ! test -e "$1" && ! test -L "$1"; then exit 66; fi; cat -- "$1"', [path], 'EIO').toString('utf8'),
173
173
  );
174
174
  }
175
175
 
@@ -55,7 +55,7 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
55
55
 
56
56
  const written = new Map<string, string | null>();
57
57
 
58
- function syncAgentView(user: AgentUser | null = agentUser(), dir = agentsDir()): number {
58
+ export function syncAgentView(user: AgentUser | null = agentUser(), dir = agentsDir()): number {
59
59
  if (user === null) return 0;
60
60
  const target = agentViewDir(user);
61
61
  let changed = 0;
@@ -106,7 +106,7 @@ async function syncStations(station: StationName): Promise<void> {
106
106
  }
107
107
  }
108
108
 
109
- function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
109
+ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model: string | null) => boolean): SessionApis {
110
110
  return localSessionApis({
111
111
  syncStations,
112
112
  reloadAgents: async () => {
@@ -125,6 +125,7 @@ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
125
125
  toolGroups: stationToolGroups,
126
126
  prepareAccount,
127
127
  liveEvents: setLiveEvents,
128
+ switchModel,
128
129
  });
129
130
  }
130
131
 
@@ -144,7 +145,7 @@ installBearerSessions(agentsDir(), localOwner);
144
145
  const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
145
146
  webhookServer = await startWebhookServer(
146
147
  emit,
147
- sessionApis(metroMcp.setLiveEvents),
148
+ sessionApis(metroMcp.setLiveEvents, metroMcp.switchModel),
148
149
  metroMcp.httpHandler,
149
150
  true,
150
151
  );