@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.
- package/dist/agent.js +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- 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
|
+
}
|
package/dist/background.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
|
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
|
|
17
|
+
return model === '' ? null : { provider: text(conn.provider), model };
|
|
18
18
|
}
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
|
45
|
+
return undefined;
|
|
39
46
|
try {
|
|
40
47
|
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
-
return state
|
|
48
|
+
return state[field];
|
|
42
49
|
}
|
|
43
50
|
catch {
|
|
44
|
-
return
|
|
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))
|
package/dist/runtime-install.js
CHANGED
|
@@ -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": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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
|
);
|