@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.
Files changed (81) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/memory.js +2 -3
  3. package/dist/runtime-install.js +3 -11
  4. package/dist/voice.js +1 -1
  5. package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
  6. package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +1 -32
  7. package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
  8. package/package.json +4 -2
  9. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
  11. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
  12. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
  14. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  15. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
  16. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -16
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  35. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  36. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  38. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  39. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  40. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  41. package/runtime/node_modules/@metro-labs/http/package.json +3 -3
  42. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  43. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  44. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  47. package/runtime/sdk-runner/runner.json +1 -1
  48. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  49. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  50. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -17
  51. package/runtime/marketplace/plugin/.mcp.json +0 -1
  52. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
  53. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  54. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  57. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  58. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  59. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  60. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  61. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  62. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  63. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  64. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  65. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  66. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
  67. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  68. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  69. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  70. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  71. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  72. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  73. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  74. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  75. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  76. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  77. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  78. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  79. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  80. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  81. /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, plugin,
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);
@@ -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 MARKETPLACE = 'marketplace';
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 existsSync(join(store, MARKETPLACE)) ? false : copyMarketplace(sources, store);
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, MARKETPLACE), { recursive: true, force: true });
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 (ToolSearch finds 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.
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 FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
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 (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
59
- if (MCP.test(tool))
60
- return `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.`;
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 && !toolName.startsWith('mcp__')) return null;
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 === 'voice' ? '' : guidance();
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.304",
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
- "runtime"
21
+ "hooks",
22
+ "runtime",
23
+ "skills"
22
24
  ],
23
25
  "publishConfig": {
24
26
  "access": "public",
@@ -6,7 +6,6 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
- "./connector-sync": "./src/connector-sync.ts",
10
9
  "./events": "./src/events.ts",
11
10
  "./is-record": "./src/is-record.ts",
12
11
  "./runner-activity": "./src/runner-activity.ts",
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
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 { copyIntoHome } from './home-fs.js';
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 copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
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 copied = mcpServerUrl() === null ? COPIED : [...COPIED, MCP_TOKEN_FILE];
55
- for (const name of copied) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
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, connectors: Map<string, string[]>): Record<string, unknown> {
55
- return { id: agent.id, name: agent.name, connector_ids: connectors.get(agent.id) ?? [] };
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, connectors)),
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
- export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
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 { ensureMetroPlugin } from '../claude/plugin-install.js';
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
- ensureMetroPlugin()
201
- .then((outcome) => {
202
- log.info({ outcome }, 'plugin: metro plugin for Claude Code');
203
- if (outcome !== 'skipped') publishConnectors();
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({ connectorsReady: () => connectorsReady() });
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
+ }