@stage-labs/metro 0.1.0-beta.304 → 0.1.0-beta.306
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/cli.js +1 -1
- package/dist/memory.js +2 -3
- package/dist/runtime-install.js +3 -11
- package/dist/voice.js +1 -1
- package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
- package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +1 -32
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
- package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -17
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
package/dist/cli.js
CHANGED
|
@@ -56,7 +56,7 @@ const USAGE = `metro: run your agent on this machine
|
|
|
56
56
|
metro voice --model <model>
|
|
57
57
|
the agent's side of a voice call, which the daemon starts when a call
|
|
58
58
|
comes in: Claude Code reads the call's words as stream-json on stdin and
|
|
59
|
-
streams its replies on stdout, with the agent's memory, skills,
|
|
59
|
+
streams its replies on stdout, with the agent's memory, skills, hooks,
|
|
60
60
|
connectors and metro tools (on /mcp/voice, so the chat session keeps its
|
|
61
61
|
slot) but no metro channel; tool approvals come back on stdout
|
|
62
62
|
metro update update to the newest published version (--check only reports)
|
package/dist/memory.js
CHANGED
|
@@ -116,13 +116,12 @@ export function routineArgs(prompt, memory, appended) {
|
|
|
116
116
|
'Glob',
|
|
117
117
|
'Grep',
|
|
118
118
|
`Edit(/${memory}/**)`,
|
|
119
|
-
'--settings',
|
|
120
|
-
JSON.stringify({ enabledPlugins: { 'metro@metro': false } }),
|
|
121
119
|
'--strict-mcp-config',
|
|
122
120
|
'--no-session-persistence',
|
|
123
121
|
...(appended === null ? [] : ['--append-system-prompt', appended]),
|
|
124
122
|
];
|
|
125
123
|
}
|
|
124
|
+
export const routineEnv = (env) => ({ ...env, METRO_SESSION: 'memory' });
|
|
126
125
|
function lockHeld(path, now) {
|
|
127
126
|
const pid = Number(readFileSync(path, 'utf8').trim());
|
|
128
127
|
return now - statSync(path).mtimeMs < LOCK_STALE_MS && Number.isInteger(pid) && pid > 0 && processAlive(pid);
|
|
@@ -194,7 +193,7 @@ async function digest(paths, now) {
|
|
|
194
193
|
for (const folder of MEMORY_FOLDERS)
|
|
195
194
|
mkdirSync(join(paths.memory, folder), { recursive: true });
|
|
196
195
|
const prompt = routinePrompt(now, since, paths, files);
|
|
197
|
-
const code = await runClaude(routineArgs(prompt, paths.memory, systemPrompt()), await headlessEnv(), { timeoutMs: TIMEOUT_MS });
|
|
196
|
+
const code = await runClaude(routineArgs(prompt, paths.memory, systemPrompt()), routineEnv(await headlessEnv()), { timeoutMs: TIMEOUT_MS });
|
|
198
197
|
if (code === 0) {
|
|
199
198
|
writeFileSync(paths.state, `${JSON.stringify({ lastRun: now.toISOString() })}\n`);
|
|
200
199
|
const moved = settleWorkstreams(paths.memory);
|
package/dist/runtime-install.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { localStations } from './local.js';
|
|
6
6
|
import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
|
|
7
7
|
const MANIFEST_FILE = 'stations.json';
|
|
8
|
-
const
|
|
8
|
+
const OLD_MARKETPLACE = 'marketplace';
|
|
9
9
|
const METRO_SOURCES = join('node_modules', '@metro-labs');
|
|
10
10
|
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
11
11
|
export function runtimeStore() {
|
|
@@ -54,24 +54,16 @@ export function readOrNull(path) {
|
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
function copyMarketplace(sources, store) {
|
|
58
|
-
const staged = join(sources, MARKETPLACE);
|
|
59
|
-
if (!existsSync(staged))
|
|
60
|
-
return false;
|
|
61
|
-
cpSync(staged, join(store, MARKETPLACE), { recursive: true });
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
57
|
function syncSources(sources, store) {
|
|
65
58
|
const shim = join(sources, STORE_ENTRY);
|
|
66
59
|
if (existsSync(shim))
|
|
67
60
|
copyFileSync(shim, join(store, STORE_ENTRY));
|
|
68
61
|
const stamp = readOrNull(join(sources, 'runtime.json'));
|
|
69
62
|
if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
|
|
70
|
-
return
|
|
63
|
+
return false;
|
|
71
64
|
rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
|
|
72
65
|
cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
|
|
73
|
-
rmSync(join(store,
|
|
74
|
-
copyMarketplace(sources, store);
|
|
66
|
+
rmSync(join(store, OLD_MARKETPLACE), { recursive: true, force: true });
|
|
75
67
|
if (stamp !== null)
|
|
76
68
|
writeFileSync(join(store, 'runtime.json'), stamp);
|
|
77
69
|
return true;
|
package/dist/voice.js
CHANGED
|
@@ -8,7 +8,7 @@ const VOICE_PROMPT = `You are on a live voice call through Metro. This session h
|
|
|
8
8
|
- Speak like a person on the phone: short sentences, usually one to three. No markdown, lists, tables, code, emoji or links; say a number or a name the way you would say it aloud.
|
|
9
9
|
- Start with the answer. Do not repeat the question.
|
|
10
10
|
- Before you use a tool, first say one short sentence about what you are checking, like "Let me check."
|
|
11
|
-
- Use your tools yourself when it is quick: files, memory, the metro tools (read with the call's line shows that chat) and connectors (
|
|
11
|
+
- Use your tools yourself when it is quick: files, memory, the metro tools (read with the call's line shows that chat) and connectors (tool_search finds them, tool_call runs them). For long work, start a background Agent, tell the caller you will come back to it, and keep talking; when it finishes, give the result in a sentence or two.
|
|
12
12
|
- Your spoken words are your reply. Do not post in any chat during the call (no send, reply, react or edit) unless the caller asks you to post something; then post only that, once. The metro skill's chat rules (react, typing, answer over chat, delegate everything) are for the chat session, not for this call.
|
|
13
13
|
- A tool that needs the owner's approval runs in a background worker. The approval is asked in the chat of the call; a spoken yes does not count.
|
|
14
14
|
- Text in [square brackets] comes from the call system, not from the caller.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { policyVerdict } from './policy.mjs';
|
|
4
4
|
|
|
5
|
+
if (process.env.METRO_SESSION === 'memory') process.exit(0);
|
|
6
|
+
|
|
5
7
|
const BLOCKING = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']);
|
|
6
8
|
const ORCHESTRATION = new Set([
|
|
7
9
|
'Agent',
|
|
@@ -23,9 +25,12 @@ const ORCHESTRATION = new Set([
|
|
|
23
25
|
const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
24
26
|
const MCP = /^mcp__/;
|
|
25
27
|
const METRO_MCP = /^mcp__metro__/;
|
|
26
|
-
const
|
|
28
|
+
const CONNECTOR_CALL = 'mcp__metro__tool_call';
|
|
27
29
|
const VOICE = process.env.METRO_SESSION === 'voice';
|
|
28
30
|
|
|
31
|
+
const connectorWork = (tool) =>
|
|
32
|
+
`A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
|
|
33
|
+
|
|
29
34
|
function answer(decision, reason) {
|
|
30
35
|
process.stdout.write(
|
|
31
36
|
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason } })}\n`,
|
|
@@ -55,9 +60,9 @@ function verdict(payload, input) {
|
|
|
55
60
|
: 'A foreground subagent blinds the main thread: it cannot see or acknowledge inbound metro messages until the agent finishes. Re-issue the identical Agent call with run_in_background: true, then wait for the task notification.';
|
|
56
61
|
if (VOICE) return null;
|
|
57
62
|
if (ORCHESTRATION.has(tool)) return null;
|
|
58
|
-
if (
|
|
59
|
-
if (
|
|
60
|
-
|
|
63
|
+
if (tool === CONNECTOR_CALL) return connectorWork(tool);
|
|
64
|
+
if (METRO_MCP.test(tool)) return null;
|
|
65
|
+
if (MCP.test(tool)) return connectorWork(tool);
|
|
61
66
|
if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
|
|
62
67
|
return `The main thread is orchestrator-only, so ${tool === '' ? 'this tool' : tool} is not available here. Delegate the work to a subagent with the Agent tool (run_in_background: true; the worker agent has full tool access) or to a Workflow, and report the result over metro.`;
|
|
63
68
|
}
|
|
@@ -56,42 +56,11 @@ function decide(policy, name, group) {
|
|
|
56
56
|
return access in RANK ? access : 'allow';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
60
|
-
|
|
61
|
-
function connectorCall(snap, toolName) {
|
|
62
|
-
const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
|
|
63
|
-
const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
|
|
64
|
-
if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
|
|
65
|
-
for (const [server, entry] of entries) {
|
|
66
|
-
if (!isRecord(entry)) continue;
|
|
67
|
-
const name = text(entry.name);
|
|
68
|
-
const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
|
|
69
|
-
if (!/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id))) prefixes.push(`mcp__${normalized(`metro.box ${name}`)}__`);
|
|
70
|
-
const prefix = prefixes.find((p) => toolName.startsWith(p) && toolName.length > p.length);
|
|
71
|
-
if (prefix === undefined) continue;
|
|
72
|
-
const called = toolName.slice(prefix.length);
|
|
73
|
-
const tools = isRecord(entry.tools) ? entry.tools : {};
|
|
74
|
-
const policy = isRecord(entry.policy) ? entry.policy : {};
|
|
75
|
-
const known = [...Object.keys(tools), ...Object.keys(isRecord(policy.tools) ? policy.tools : {})];
|
|
76
|
-
const tool = known.find((k) => k === called) ?? known.find((k) => normalized(k) === called) ?? called;
|
|
77
|
-
return { name: name || server, tool, group: tools[tool] === 'read' ? 'read' : 'write', policy };
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function connectorVerdict(snap, toolName) {
|
|
83
|
-
const call = connectorCall(snap, toolName);
|
|
84
|
-
if (call === null) return null;
|
|
85
|
-
const access = decide(call.policy, call.tool, call.group);
|
|
86
|
-
return access === 'allow' ? null : { access, tool: call.tool, owner: call.name, where: `${call.tool} on ${call.name}` };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
59
|
export function policyVerdict(toolName, input) {
|
|
90
60
|
const name = METRO_TOOL.exec(toolName)?.[1];
|
|
91
|
-
if (name === undefined
|
|
61
|
+
if (name === undefined) return null;
|
|
92
62
|
const snap = readSnapshot();
|
|
93
63
|
if (snap === null) return null;
|
|
94
|
-
if (name === undefined) return connectorVerdict(snap, toolName);
|
|
95
64
|
const tools = isRecord(snap.tools) ? snap.tools : {};
|
|
96
65
|
const group = tools[name] === 'read' ? 'read' : 'write';
|
|
97
66
|
const accounts = isRecord(snap.accounts) ? snap.accounts : {};
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
const given = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
|
|
8
8
|
const claudeDir = given(process.env.CLAUDE_CONFIG_DIR) ?? join(given(process.env.HOME) ?? homedir(), '.claude');
|
|
9
9
|
const SKILL = join(claudeDir, 'skills', 'metro', 'SKILL.md');
|
|
10
|
-
const FALLBACK = join(dirname(dirname(fileURLToPath(import.meta.url))), 'METRO.md');
|
|
10
|
+
const FALLBACK = join(dirname(dirname(fileURLToPath(import.meta.url))), 'skills', 'METRO.md');
|
|
11
11
|
|
|
12
12
|
const body = (text) => text.replace(/^---[\s\S]*?\n---\n/, '').trim();
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ function guidance() {
|
|
|
24
24
|
return '';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const text = process.env.METRO_SESSION
|
|
27
|
+
const text = ['voice', 'memory'].includes(process.env.METRO_SESSION) ? '' : guidance();
|
|
28
28
|
if (text !== '')
|
|
29
29
|
process.stdout.write(
|
|
30
30
|
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `This machine runs as a metro agent. Standing rules, kept in the metro skill:\n\n${text}` } })}\n`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.306",
|
|
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": {
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
|
-
"
|
|
21
|
+
"hooks",
|
|
22
|
+
"runtime",
|
|
23
|
+
"skills"
|
|
22
24
|
],
|
|
23
25
|
"publishConfig": {
|
|
24
26
|
"access": "public",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { createWriteStream, mkdirSync,
|
|
3
|
-
import { dirname
|
|
2
|
+
import { createWriteStream, mkdirSync, renameSync, rmSync, utimesSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
4
|
import type { Readable } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
@@ -37,33 +37,6 @@ export function writeHomeText(path: string, text: string, mode = 0o644, mtime?:
|
|
|
37
37
|
if (mtime !== undefined) utimesSync(path, mtime, mtime);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function writeHomeInPlace(path: string, text: string, mode = 0o644, user = agentUser()): void {
|
|
41
|
-
if (user !== null) {
|
|
42
|
-
runAs(user, 'mkdir -p -- "$(dirname -- "$1")"; cat > "$1"; chmod -- "$2" "$1"', [path, octal(mode)], text);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
-
writeFileSync(path, text, { mode });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function filesUnder(root: string, rel = ''): string[] {
|
|
50
|
-
return readdirSync(join(root, rel), { withFileTypes: true }).flatMap((entry) => {
|
|
51
|
-
const inner = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
52
|
-
if (entry.isDirectory()) return filesUnder(root, inner);
|
|
53
|
-
return entry.isFile() ? [inner] : [];
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function copyIntoHome(from: string, to: string, user = agentUser()): number {
|
|
58
|
-
removeHome(to, true, user);
|
|
59
|
-
const files = filesUnder(from);
|
|
60
|
-
for (const rel of files) {
|
|
61
|
-
const source = join(from, rel);
|
|
62
|
-
writeHomeText(join(to, rel), readFileSync(source, 'utf8'), statSync(source).mode & 0o777, undefined, user);
|
|
63
|
-
}
|
|
64
|
-
return files.length;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
40
|
export function removeHome(path: string, recursive = false, user = agentUser()): void {
|
|
68
41
|
if (user !== null) {
|
|
69
42
|
runAs(user, recursive ? 'rm -rf -- "$1"' : 'rm -f -- "$1"', [path]);
|
|
@@ -4,12 +4,10 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { chmodSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
-
import {
|
|
8
|
-
import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
|
|
7
|
+
import { AGENT_NAME, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
|
|
9
8
|
import { watchAgentView } from './view.js';
|
|
10
9
|
import { closeMetroHome } from './metro-home.js';
|
|
11
10
|
import { mustHelper } from '../metro-user/privilege.js';
|
|
12
|
-
import { stagedPluginVersion } from '../claude/plugin-install.js';
|
|
13
11
|
import { CLAUDE_CODE_VERSION } from '../claude/pinned.js';
|
|
14
12
|
import { pinClaudeCode } from '../claude/version.js';
|
|
15
13
|
|
|
@@ -68,24 +66,13 @@ function pinCheckedClaude(): void {
|
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
|
|
71
|
-
function
|
|
72
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
73
|
-
const from = join(store, 'marketplace');
|
|
74
|
-
if (store === '' || !existsSync(join(from, '.claude-plugin', 'marketplace.json'))) return;
|
|
75
|
-
const to = agentMarketplaceDir(user);
|
|
76
|
-
if (stagedPluginVersion(from) !== null && stagedPluginVersion(from) === stagedPluginVersion(to)) return;
|
|
77
|
-
const files = copyIntoHome(from, to, user);
|
|
78
|
-
log.info({ files, version: stagedPluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
|
|
69
|
+
export async function provisionAgentUser(): Promise<Provisioned> {
|
|
82
70
|
if (!agentUserExpected()) return 'off';
|
|
83
71
|
try {
|
|
84
72
|
const user = ensureUser();
|
|
85
73
|
if (user === null) throw new Error(`the user ${AGENT_NAME} could not be found after creating it`);
|
|
86
74
|
await installClaude(user);
|
|
87
75
|
pinCheckedClaude();
|
|
88
|
-
copyMarketplace(user, env);
|
|
89
76
|
watchAgentView();
|
|
90
77
|
return 'ready';
|
|
91
78
|
} catch (err) {
|
|
@@ -87,5 +87,4 @@ export function claudeBin(user = agentUser()): string {
|
|
|
87
87
|
|
|
88
88
|
export const claudeHome = (user = agentUser()): string | null => (user === null ? null : user.home);
|
|
89
89
|
|
|
90
|
-
export const agentMarketplaceDir = (user: AgentUser): string => join(user.home, '.metro', 'marketplace');
|
|
91
90
|
export const agentViewDir = (user: AgentUser): string => join(user.home, '.metro', 'agents');
|
|
@@ -3,13 +3,13 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
|
-
import { mcpServerUrl } from '../mcp-server/config.js';
|
|
7
6
|
import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
|
|
8
7
|
import { removeHome, writeHomeText } from './home-fs.js';
|
|
9
8
|
import { closeMetroHome } from './metro-home.js';
|
|
10
9
|
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
11
10
|
|
|
12
11
|
const COPIED = ['policy.json', 'claude-setup.json', 'system-prompt.md'] as const;
|
|
12
|
+
const NEVER_COPIED = [MCP_TOKEN_FILE] as const;
|
|
13
13
|
const SYNC_MS = 5_000;
|
|
14
14
|
|
|
15
15
|
const readText = (path: string): string | null => {
|
|
@@ -51,8 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
|
|
|
51
51
|
['agent.json', agentFile(dir)],
|
|
52
52
|
['model.json', modelFile(dir)],
|
|
53
53
|
]);
|
|
54
|
-
const
|
|
55
|
-
for (const name of
|
|
54
|
+
for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
|
|
55
|
+
for (const name of NEVER_COPIED) files.set(name, null);
|
|
56
56
|
return files;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -26,7 +26,6 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
26
26
|
toolGroups?: () => Record<string, { name: string; group: string }[]>;
|
|
27
27
|
attachable?: string[];
|
|
28
28
|
features?: string[];
|
|
29
|
-
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
type Routable =
|
|
@@ -51,8 +50,8 @@ export function target(path: string): Target {
|
|
|
51
50
|
return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
function agentPayload(agent: AgentSummary
|
|
55
|
-
return { id: agent.id, name: agent.name
|
|
53
|
+
function agentPayload(agent: AgentSummary): Record<string, unknown> {
|
|
54
|
+
return { id: agent.id, name: agent.name };
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
function wantsAccounts(req: IncomingMessage): boolean {
|
|
@@ -66,9 +65,8 @@ async function handleList(
|
|
|
66
65
|
deps: AgentApiDeps,
|
|
67
66
|
): Promise<void> {
|
|
68
67
|
const list = await deps.listAgents();
|
|
69
|
-
const connectors = await deps.connectorIds(list.map((a) => a.id));
|
|
70
68
|
const base = {
|
|
71
|
-
agents: list.map((a) => agentPayload(a
|
|
69
|
+
agents: list.map((a) => agentPayload(a)),
|
|
72
70
|
capabilities: deps.capabilities(),
|
|
73
71
|
features: deps.features ?? [],
|
|
74
72
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
@@ -145,19 +145,13 @@ export function cancelPrompt(requestId: string, owner: object): void {
|
|
|
145
145
|
});
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export function revokeToolPrompts(matches: (tool: string) => boolean): void {
|
|
149
|
-
grants = grants.filter((entry) => !matches(entry.tool));
|
|
150
|
-
for (const entry of held.values())
|
|
151
|
-
if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
148
|
export function revokeCallPrompts(route: CallRoute): void {
|
|
155
149
|
grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
|
|
156
150
|
for (const entry of held.values())
|
|
157
151
|
if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
|
|
154
|
+
function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
161
155
|
for (const [id, entry] of held)
|
|
162
156
|
if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
163
157
|
held.delete(id);
|
|
@@ -21,13 +21,11 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import { connectorsReady, publishConnectors } from '../connectors/publication.js';
|
|
25
|
-
import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
|
|
26
|
-
import { readAgentKey } from '../connectors/agent-key.js';
|
|
27
|
-
import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
28
24
|
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
25
|
+
import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connector-tools.js';
|
|
26
|
+
import { setAnnouncedFile } from '../mcp-server/announced.js';
|
|
29
27
|
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
30
|
-
import {
|
|
28
|
+
import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
|
|
31
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
32
30
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
33
31
|
import { applyVault } from '../vault/index.js';
|
|
@@ -67,6 +65,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
|
|
|
67
65
|
installCrashGuard();
|
|
68
66
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
69
67
|
setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
|
|
68
|
+
setAnnouncedFile(join(agentsDir(), 'mcp-approvals.json'));
|
|
70
69
|
|
|
71
70
|
log.info({ self: userSelf() }, 'user identity');
|
|
72
71
|
|
|
@@ -142,7 +141,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
142
141
|
async function main(): Promise<void> {
|
|
143
142
|
applyLocalOwner();
|
|
144
143
|
installBearerSessions(agentsDir(), localOwner);
|
|
145
|
-
adoptOrgServerKeys();
|
|
146
144
|
watchPolicySnapshot();
|
|
147
145
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
148
146
|
await materializeFrom(fileSource);
|
|
@@ -162,13 +160,18 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
162
160
|
startPromptExpiry();
|
|
163
161
|
startUploadReaper();
|
|
164
162
|
startAttachReaper();
|
|
165
|
-
watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
|
|
166
163
|
watchMcpServer({
|
|
167
164
|
owner: localOwner,
|
|
168
165
|
hold: (approval, answer) => {
|
|
169
166
|
holdServerApproval(approval, answer, emit);
|
|
170
167
|
},
|
|
168
|
+
registered: () => {
|
|
169
|
+
refreshConnectorTools().catch((err: unknown) => {
|
|
170
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: the connector tools could not be read after the token was registered');
|
|
171
|
+
});
|
|
172
|
+
},
|
|
171
173
|
});
|
|
174
|
+
watchConnectorTools();
|
|
172
175
|
startCloudWatchPublisher();
|
|
173
176
|
watchFallbackUsage();
|
|
174
177
|
announceLocalEndpoint();
|
|
@@ -197,18 +200,15 @@ async function startClaude(): Promise<void> {
|
|
|
197
200
|
const vault = (await applyVault()).status;
|
|
198
201
|
if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
|
|
199
202
|
}
|
|
200
|
-
|
|
201
|
-
.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
.catch((err: unknown) => {
|
|
206
|
-
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
207
|
-
});
|
|
203
|
+
const plugin = await removeMetroPlugin().catch((err: unknown) => {
|
|
204
|
+
log.warn({ err: errMsg(err) }, 'claude-plugin: could not check for the old metro plugin');
|
|
205
|
+
return 'failed';
|
|
206
|
+
});
|
|
207
|
+
if (plugin !== 'done') log.info({ outcome: plugin }, 'claude-plugin: checked for the old metro Claude Code plugin');
|
|
208
208
|
tryClaudeSetup();
|
|
209
209
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
210
210
|
tryMemoryJob(memoryRoutine());
|
|
211
|
-
watchSession(
|
|
211
|
+
watchSession();
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -220,7 +220,6 @@ async function shutdown(): Promise<void> {
|
|
|
220
220
|
if (shuttingDown) return;
|
|
221
221
|
shuttingDown = true;
|
|
222
222
|
log.info('dispatcher shutting down');
|
|
223
|
-
stopOrgConnectorSync(true);
|
|
224
223
|
stopMcpServer().catch((err: unknown) => {
|
|
225
224
|
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
226
225
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const CHECKOUT = fileURLToPath(new URL('../../../../packages/cli', import.meta.url));
|
|
6
|
+
const HOOKS = 'hooks';
|
|
7
|
+
const SKILLS = 'skills';
|
|
8
|
+
export const GUARD_HOOK = 'guard.mjs';
|
|
9
|
+
export const SESSION_START_HOOK = 'session-start.mjs';
|
|
10
|
+
const UNSAFE_PATH = /["$`\\\n]/;
|
|
11
|
+
|
|
12
|
+
export function cliPackageDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
13
|
+
const bin = env.METRO_CLI_BIN?.trim() ?? '';
|
|
14
|
+
if (bin === '') return CHECKOUT;
|
|
15
|
+
try {
|
|
16
|
+
return dirname(dirname(realpathSync(bin)));
|
|
17
|
+
} catch {
|
|
18
|
+
return CHECKOUT;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const skillsDir = (cli: string): string => join(cli, SKILLS);
|
|
23
|
+
|
|
24
|
+
export function hooksDir(cli: string): string | null {
|
|
25
|
+
const dir = join(cli, HOOKS);
|
|
26
|
+
if (UNSAFE_PATH.test(dir)) return null;
|
|
27
|
+
return existsSync(join(dir, GUARD_HOOK)) && existsSync(join(dir, SESSION_START_HOOK)) ? dir : null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { GUARD_HOOK, SESSION_START_HOOK } from './cli-files.js';
|
|
4
|
+
|
|
5
|
+
interface HookCommand {
|
|
6
|
+
type: 'command';
|
|
7
|
+
command: string;
|
|
8
|
+
statusMessage?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const METRO_COMMAND = /^node "[^"]*\/hooks\/(guard|session-start)\.mjs"$/;
|
|
12
|
+
const METRO_GUARD = /^node "[^"]*\/hooks\/guard\.mjs"$/;
|
|
13
|
+
|
|
14
|
+
const commandOf = (hook: unknown): string => (isRecord(hook) && typeof hook.command === 'string' ? hook.command : '');
|
|
15
|
+
const metroHook = (hook: unknown): boolean => METRO_COMMAND.test(commandOf(hook));
|
|
16
|
+
const run = (dir: string, file: string): string => `node "${join(dir, file)}"`;
|
|
17
|
+
|
|
18
|
+
function metroHooks(dir: string): Record<string, HookCommand> {
|
|
19
|
+
return {
|
|
20
|
+
PreToolUse: { type: 'command', command: run(dir, GUARD_HOOK), statusMessage: 'Checking the orchestrator boundary' },
|
|
21
|
+
SessionStart: { type: 'command', command: run(dir, SESSION_START_HOOK) },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withHook(groups: unknown[], wanted: HookCommand): unknown[] {
|
|
26
|
+
let placed = false;
|
|
27
|
+
const out: unknown[] = [];
|
|
28
|
+
for (const group of groups) {
|
|
29
|
+
if (!isRecord(group) || !Array.isArray(group.hooks) || !group.hooks.some(metroHook)) {
|
|
30
|
+
out.push(group);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const kept = group.hooks.flatMap((hook: unknown) => {
|
|
34
|
+
if (!metroHook(hook)) return [hook];
|
|
35
|
+
if (placed) return [];
|
|
36
|
+
placed = true;
|
|
37
|
+
return [wanted];
|
|
38
|
+
});
|
|
39
|
+
if (kept.length > 0) out.push({ ...group, hooks: kept });
|
|
40
|
+
}
|
|
41
|
+
return placed ? out : [...out, { hooks: [wanted] }];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function withMetroHooks(settings: Record<string, unknown>, dir: string): Record<string, unknown> | null {
|
|
45
|
+
const hooks = settings.hooks ?? {};
|
|
46
|
+
if (!isRecord(hooks)) return null;
|
|
47
|
+
const next: Record<string, unknown> = { ...hooks };
|
|
48
|
+
for (const [event, wanted] of Object.entries(metroHooks(dir))) {
|
|
49
|
+
const groups = hooks[event] ?? [];
|
|
50
|
+
if (!Array.isArray(groups)) return null;
|
|
51
|
+
next[event] = withHook(groups, wanted);
|
|
52
|
+
}
|
|
53
|
+
return { ...settings, hooks: next };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasMetroGuard(settings: Record<string, unknown> | null): boolean {
|
|
57
|
+
const groups = settings !== null && isRecord(settings.hooks) ? settings.hooks.PreToolUse : undefined;
|
|
58
|
+
if (!Array.isArray(groups)) return false;
|
|
59
|
+
return groups.some((group) => isRecord(group) && Array.isArray(group.hooks) && group.hooks.some((hook) => METRO_GUARD.test(commandOf(hook))));
|
|
60
|
+
}
|