@stage-labs/metro 0.1.0-beta.305 → 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}/session-start.mjs +2 -2
- package/package.json +4 -2
- 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/boot/boot.ts +13 -10
- 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 +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
- 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/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
- 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/config.ts +0 -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/http/package.json +2 -1
- 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/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 -16
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
- /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
- /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
|
}
|
|
@@ -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
|
|
|
@@ -21,11 +21,11 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
25
24
|
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
25
|
+
import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connector-tools.js';
|
|
26
26
|
import { setAnnouncedFile } from '../mcp-server/announced.js';
|
|
27
27
|
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
28
|
-
import {
|
|
28
|
+
import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
|
|
29
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
30
30
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
31
31
|
import { applyVault } from '../vault/index.js';
|
|
@@ -165,7 +165,13 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
165
165
|
hold: (approval, answer) => {
|
|
166
166
|
holdServerApproval(approval, answer, emit);
|
|
167
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
|
+
},
|
|
168
173
|
});
|
|
174
|
+
watchConnectorTools();
|
|
169
175
|
startCloudWatchPublisher();
|
|
170
176
|
watchFallbackUsage();
|
|
171
177
|
announceLocalEndpoint();
|
|
@@ -194,14 +200,11 @@ async function startClaude(): Promise<void> {
|
|
|
194
200
|
const vault = (await applyVault()).status;
|
|
195
201
|
if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
|
|
196
202
|
}
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
.catch((err: unknown) => {
|
|
203
|
-
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
204
|
-
});
|
|
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');
|
|
205
208
|
tryClaudeSetup();
|
|
206
209
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
207
210
|
tryMemoryJob(memoryRoutine());
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
+
import { existsSync } from '../agent-user/agent-fs.js';
|
|
7
|
+
import { removeHome } from '../agent-user/home-fs.js';
|
|
8
|
+
import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
9
|
+
import { agentsDir } from '../agents/files.js';
|
|
10
|
+
import { readSetupState, writeSetupState } from './setup-state.js';
|
|
11
|
+
|
|
12
|
+
const PLUGIN = 'metro@metro';
|
|
13
|
+
const MARKETPLACE = 'metro';
|
|
14
|
+
const DONE = 'pluginRemoved';
|
|
15
|
+
const STEP_MS = 60_000;
|
|
16
|
+
const SAID_MAX = 300;
|
|
17
|
+
|
|
18
|
+
export interface Answer {
|
|
19
|
+
status: number;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Run = (args: string[]) => Promise<Answer>;
|
|
25
|
+
|
|
26
|
+
export interface CleanupDeps {
|
|
27
|
+
run?: Run | null;
|
|
28
|
+
agents?: string;
|
|
29
|
+
leftover?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CleanupOutcome = 'done' | 'clean' | 'removed' | 'skipped' | 'failed';
|
|
33
|
+
|
|
34
|
+
export const claudeAt =
|
|
35
|
+
(bin: string): Run =>
|
|
36
|
+
(args) =>
|
|
37
|
+
new Promise((resolve) => {
|
|
38
|
+
execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
39
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
40
|
+
resolve({ status: err === null ? 0 : typeof code === 'number' ? code : 1, stdout, stderr });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function findClaude(): Promise<Run | null> {
|
|
45
|
+
const candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
|
|
46
|
+
for (const bin of candidates) {
|
|
47
|
+
const run = claudeAt(bin);
|
|
48
|
+
if ((await run(['--version'])).status === 0) return run;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listOf(stdout: string): Record<string, unknown>[] {
|
|
54
|
+
try {
|
|
55
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
56
|
+
return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function listed(run: Run, args: string[], key: string, wanted: string): Promise<boolean | null> {
|
|
63
|
+
const answer = await run(args);
|
|
64
|
+
if (answer.status !== 0) {
|
|
65
|
+
log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude could not list');
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return listOf(answer.stdout).some((entry) => entry[key] === wanted);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function step(run: Run, args: string[]): Promise<boolean> {
|
|
72
|
+
const answer = await run(args);
|
|
73
|
+
if (answer.status === 0) {
|
|
74
|
+
log.info({ args }, 'claude-plugin: removed what the old metro plugin left');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude refused');
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function removeIfListed(run: Run, list: string[], key: string, wanted: string, remove: string[]): Promise<'gone' | 'removed' | 'failed'> {
|
|
82
|
+
const present = await listed(run, list, key, wanted);
|
|
83
|
+
if (present === null) return 'failed';
|
|
84
|
+
if (!present) return 'gone';
|
|
85
|
+
return (await step(run, remove)) ? 'removed' : 'failed';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function leftoverDir(): string | null {
|
|
89
|
+
const user = agentUser();
|
|
90
|
+
return user === null ? null : join(user.home, '.metro', 'marketplace');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function removeLeftover(dir: string | null): boolean {
|
|
94
|
+
if (dir === null || !existsSync(dir)) return true;
|
|
95
|
+
try {
|
|
96
|
+
removeHome(dir, true);
|
|
97
|
+
log.info({ dir }, 'claude-plugin: removed the old plugin marketplace copy');
|
|
98
|
+
return true;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn({ dir, err: errMsg(err) }, 'claude-plugin: could not remove the old plugin marketplace copy');
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function cleanUp(run: Run, leftover: string | null): Promise<'removed' | 'clean' | 'failed'> {
|
|
106
|
+
const plugin = await removeIfListed(run, ['plugin', 'list', '--json'], 'id', PLUGIN, ['plugin', 'uninstall', PLUGIN]);
|
|
107
|
+
const market = await removeIfListed(run, ['plugin', 'marketplace', 'list', '--json'], 'name', MARKETPLACE, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
108
|
+
const folder = removeLeftover(leftover);
|
|
109
|
+
if (plugin === 'failed' || market === 'failed' || !folder) return 'failed';
|
|
110
|
+
return plugin === 'removed' || market === 'removed' ? 'removed' : 'clean';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function removeMetroPlugin(deps: CleanupDeps = {}): Promise<CleanupOutcome> {
|
|
114
|
+
const agents = deps.agents ?? agentsDir();
|
|
115
|
+
if (readSetupState(agents)[DONE] === true) return 'done';
|
|
116
|
+
const run = deps.run === undefined ? await findClaude() : deps.run;
|
|
117
|
+
if (run === null) return 'skipped';
|
|
118
|
+
const outcome = await cleanUp(run, deps.leftover === undefined ? leftoverDir() : deps.leftover);
|
|
119
|
+
if (outcome !== 'failed') writeSetupState(agents, { [DONE]: true });
|
|
120
|
+
return outcome;
|
|
121
|
+
}
|
|
@@ -352,7 +352,7 @@ export function ensureSession(deps: SessionDeps = {}): Ensured {
|
|
|
352
352
|
const held = holdsBack(deps, r.now);
|
|
353
353
|
if (held !== null) return held;
|
|
354
354
|
if (found === 'stale') {
|
|
355
|
-
log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new
|
|
355
|
+
log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new hooks and flags load');
|
|
356
356
|
stopSession(deps);
|
|
357
357
|
}
|
|
358
358
|
startSession(deps);
|