@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.304

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 (56) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
  3. package/runtime/marketplace/plugin/METRO.md +2 -0
  4. package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
  5. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +20 -2
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
  8. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
  32. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
  33. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
  34. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  35. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
  36. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
  37. package/runtime/runtime.json +1 -1
  38. package/runtime/sdk-runner/runner.json +1 -1
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
  47. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
  48. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.302",
3
+ "version": "0.1.0-beta.304",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.302",
4
- "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
3
+ "version": "0.1.0-beta.304",
4
+ "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon (or one `mcp` server on mcp.metro.box), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
7
7
  "url": "https://metro.box"
@@ -15,6 +15,8 @@ You are the main thread of a Claude Code session that is connected to chat throu
15
15
 
16
16
  **Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
17
17
 
18
+ **Connectors through the `mcp` server.** When the `mcp` server is present, it holds every connector tool behind two tools. Find what you need with `find_tools`: `select:notion.search,linear.create_issue` for exact names, keywords (`notion page update`) to rank, or `+slack send` to require `slack` in the tool name. Have a worker run them with `call_tool`, up to 10 independent calls in one go. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker call `call_tool` again with the same tool and arguments and `approval: "<id>"`.
19
+
18
20
  **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
19
21
 
20
22
  The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
@@ -23,6 +23,7 @@ const ORCHESTRATION = new Set([
23
23
  const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
24
24
  const MCP = /^mcp__/;
25
25
  const METRO_MCP = /^mcp__metro__/;
26
+ const FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
26
27
  const VOICE = process.env.METRO_SESSION === 'voice';
27
28
 
28
29
  function answer(decision, reason) {
@@ -54,7 +55,7 @@ function verdict(payload, input) {
54
55
  : '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.';
55
56
  if (VOICE) return null;
56
57
  if (ORCHESTRATION.has(tool)) return null;
57
- if (METRO_MCP.test(tool)) return null;
58
+ if (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
58
59
  if (MCP.test(tool))
59
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.`;
60
61
  if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
7
+ const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
7
8
 
8
9
  function readAgent() {
9
10
  const path = join(agentsDir(), 'agent.json');
@@ -17,8 +18,25 @@ function readAgent() {
17
18
  return parsed;
18
19
  }
19
20
 
20
- if (process.argv[2] !== 'headers') {
21
- process.stderr.write('usage: metro-plugin.mjs headers\n');
21
+ function readMcpToken() {
22
+ try {
23
+ const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
24
+ return MCP_TOKEN.test(token) ? token : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ const command = process.argv[2];
31
+
32
+ if (command === 'mcp-headers') {
33
+ const token = readMcpToken();
34
+ if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
35
+ process.exit(0);
36
+ }
37
+
38
+ if (command !== 'headers') {
39
+ process.stderr.write('usage: metro-plugin.mjs headers | mcp-headers\n');
22
40
  process.exit(1);
23
41
  }
24
42
 
@@ -3,6 +3,8 @@ 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
+ import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
6
8
  import { removeHome, writeHomeText } from './home-fs.js';
7
9
  import { closeMetroHome } from './metro-home.js';
8
10
  import { agentUser, agentViewDir, type AgentUser } from './user.js';
@@ -49,7 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
49
51
  ['agent.json', agentFile(dir)],
50
52
  ['model.json', modelFile(dir)],
51
53
  ]);
52
- for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
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);
53
56
  return files;
54
57
  }
55
58
 
@@ -5,7 +5,7 @@ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } fr
5
5
  import { ApiError } from '@metro-labs/http/api-error';
6
6
  import { isRecord } from '@metro-labs/core/is-record';
7
7
  import { parseId } from '@metro-labs/core/ids';
8
- import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../stations/materialize.js';
8
+ import type { LoadedAccount, LoadedAgent } from '../stations/materialize.js';
9
9
  import { STATIONS } from '@metro-labs/core/station-names';
10
10
  import { approversOf } from './files.js';
11
11
 
@@ -17,7 +17,7 @@ const STATION_NAMES = new Set<string>(STATIONS);
17
17
  export interface AgentBundle {
18
18
  version: 1;
19
19
  agent: { id: string; name: string; stations: LoadedAccount[] };
20
- connectors: LoadedConnector[];
20
+ connectors: [];
21
21
  }
22
22
 
23
23
  export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
@@ -68,13 +68,6 @@ function stationOf(raw: unknown): LoadedAccount {
68
68
  };
69
69
  }
70
70
 
71
- function connectorOf(raw: unknown): LoadedConnector {
72
- if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
73
- throw bad('a connector is missing its id, name or url');
74
- if (!isRecord(raw.config)) throw bad('a connector has no config');
75
- return { id: raw.id, name: raw.name, url: raw.url, config: raw.config };
76
- }
77
-
78
71
  function agentOf(raw: unknown): AgentBundle['agent'] {
79
72
  if (!isRecord(raw)) throw bad('not a v1 agent bundle');
80
73
  const { id, name, stations } = raw;
@@ -85,8 +78,7 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
85
78
 
86
79
  export function parseBundle(raw: unknown): AgentBundle {
87
80
  if (!isRecord(raw) || raw.version !== 1) throw bad('not a v1 agent bundle');
88
- const connectors = Array.isArray(raw.connectors) ? raw.connectors : [];
89
- return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
81
+ return { version: 1, agent: agentOf(raw.agent), connectors: [] };
90
82
  }
91
83
 
92
84
  async function answer(req: IncomingMessage, deps: BundleApiDeps, path: string): Promise<unknown> {
@@ -17,6 +17,7 @@ export interface PendingPrompt {
17
17
  line: string | undefined;
18
18
  at: number;
19
19
  call?: CallSource;
20
+ origin?: 'mcp-server';
20
21
  }
21
22
 
22
23
  interface Held extends PendingPrompt {
@@ -93,6 +94,8 @@ export const pendingPrompts = (): PendingPrompt[] => [...held.values()].map(show
93
94
 
94
95
  export const promptLine = (requestId: string): string | undefined => held.get(requestId)?.line;
95
96
 
97
+ export const isPromptHeld = (requestId: string): boolean => held.has(requestId);
98
+
96
99
  function chatNotice(via: AnsweredVia, behavior: Behavior): string | undefined {
97
100
  if (via === 'expiry') return 'Expired, denied.';
98
101
  if (via === 'page') return behavior === 'allow' ? 'Approved on the page.' : 'Denied on the page.';
@@ -116,8 +119,12 @@ export async function answerPrompt(
116
119
  const entry = held.get(requestId);
117
120
  if (entry === undefined) return undefined;
118
121
  held.delete(requestId);
119
- if (behavior === 'allow' && via !== 'expiry') grant(entry);
120
- log.info({ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` }, 'approvals: a Claude Code permission prompt answered');
122
+ const server = entry.origin === 'mcp-server';
123
+ if (behavior === 'allow' && via !== 'expiry' && !server) grant(entry);
124
+ log.info(
125
+ { requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` },
126
+ server ? 'approvals: an mcp.metro.box approval answered' : 'approvals: a Claude Code permission prompt answered',
127
+ );
121
128
  await entry.send(behavior);
122
129
  await tellChat(entry, via, behavior);
123
130
  return shown(entry);
@@ -152,7 +159,7 @@ export function revokeCallPrompts(route: CallRoute): void {
152
159
 
153
160
  export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
154
161
  for (const [id, entry] of held)
155
- if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
162
+ if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
156
163
  held.delete(id);
157
164
  log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
158
165
  }
@@ -21,10 +21,12 @@ import {
21
21
  trainEventToMetroEvent,
22
22
  } from '../routes/http.js';
23
23
  import { agentsDir, fileSource } from '../agents/files.js';
24
- import { publishLocalConnectors } from '../connectors/publication.js';
24
+ import { connectorsReady, publishConnectors } from '../connectors/publication.js';
25
+ import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
25
26
  import { readAgentKey } from '../connectors/agent-key.js';
26
27
  import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
27
- import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
28
+ import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
29
+ import { holdServerApproval } from '../mcp/server-approvals.js';
28
30
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
29
31
  import { runningAsRoot } from '../metro-user/privilege.js';
30
32
  import { provisionAgentUser } from '../agent-user/provision.js';
@@ -140,10 +142,8 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
140
142
  async function main(): Promise<void> {
141
143
  applyLocalOwner();
142
144
  installBearerSessions(agentsDir(), localOwner);
145
+ adoptOrgServerKeys();
143
146
  watchPolicySnapshot();
144
- loadConnectorPolicies().catch((err: unknown) => {
145
- log.warn({ err: errMsg(err) }, 'connector policy: loading the policies failed');
146
- });
147
147
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
148
148
  await materializeFrom(fileSource);
149
149
  ensureStationDeps();
@@ -160,10 +160,15 @@ installBearerSessions(agentsDir(), localOwner);
160
160
  );
161
161
  metroMcp.startInbound();
162
162
  startPromptExpiry();
163
- publishLocalConnectors(readLocalConnectors());
164
163
  startUploadReaper();
165
164
  startAttachReaper();
166
165
  watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
166
+ watchMcpServer({
167
+ owner: localOwner,
168
+ hold: (approval, answer) => {
169
+ holdServerApproval(approval, answer, emit);
170
+ },
171
+ });
167
172
  startCloudWatchPublisher();
168
173
  watchFallbackUsage();
169
174
  announceLocalEndpoint();
@@ -195,7 +200,7 @@ async function startClaude(): Promise<void> {
195
200
  ensureMetroPlugin()
196
201
  .then((outcome) => {
197
202
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
198
- if (outcome !== 'skipped') publishLocalConnectors(readLocalConnectors());
203
+ if (outcome !== 'skipped') publishConnectors();
199
204
  })
200
205
  .catch((err: unknown) => {
201
206
  log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
@@ -203,7 +208,7 @@ async function startClaude(): Promise<void> {
203
208
  tryClaudeSetup();
204
209
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
205
210
  tryMemoryJob(memoryRoutine());
206
- watchSession();
211
+ watchSession({ connectorsReady: () => connectorsReady() });
207
212
  }
208
213
 
209
214
  const SHUTDOWN_TIMEOUT_MS = 3_000;
@@ -216,6 +221,9 @@ async function shutdown(): Promise<void> {
216
221
  shuttingDown = true;
217
222
  log.info('dispatcher shutting down');
218
223
  stopOrgConnectorSync(true);
224
+ stopMcpServer().catch((err: unknown) => {
225
+ log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
226
+ });
219
227
  const leaving = leaveCallsForShutdown();
220
228
  unwatchSession();
221
229
  tunnel?.stop();
@@ -7,6 +7,7 @@ import {
7
7
  type SavedMedia,
8
8
  } from './media-note.js';
9
9
  import { buildWebhookNote } from './webhook-note.js';
10
+ import { isMetroNotice } from './notice.js';
10
11
  import { replyMeta } from './addressed.js';
11
12
  import { reactContent, reactionEmoji } from './react-note.js';
12
13
  import {
@@ -285,7 +286,9 @@ export class InboundRelay {
285
286
  capSet(this.allowedLines, ALLOWED_LINES_MAX);
286
287
  }
287
288
 
288
- private droppedSender(from: string, line: string, verified: unknown): boolean {
289
+ private droppedSender(ev: Record<string, unknown>, from: string, line: string): boolean {
290
+ if (isMetroNotice(ev)) return false;
291
+ const verified = ev.senderVerified;
289
292
  if (
290
293
  from.startsWith('metro://claude') ||
291
294
  from === 'metro://user' ||
@@ -311,7 +314,7 @@ export class InboundRelay {
311
314
  if (!this.deps.getStations().has(station)) return null;
312
315
  const from = str(ev.from);
313
316
  const line = str(ev.line);
314
- if (this.droppedSender(from, line, ev.senderVerified)) return null;
317
+ if (this.droppedSender(ev, from, line)) return null;
315
318
  const text = str(ev.text);
316
319
  if (!replay && this.isDuplicate(station, line, evType, str(ev.messageId))) {
317
320
  this.deps.log(
@@ -337,7 +340,7 @@ export class InboundRelay {
337
340
  if (await this.handlePermissionReply(ev, base)) return;
338
341
  await this.notify('notifications/claude/channel', {
339
342
  content:
340
- base.evType === 'system'
343
+ base.evType === 'system' && !isMetroNotice(ev)
341
344
  ? buildWebhookNote(base.text, str(ev.lineName), ev.payload)
342
345
  : base.text,
343
346
  meta: {
@@ -0,0 +1,22 @@
1
+ import { mintId, type MetroEvent } from '@metro-labs/core/events';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { asLine, Line } from '@metro-labs/core/lines';
4
+
5
+ const NOTICE_FROM = 'metro://metro';
6
+ const NOTICE_SOURCE = 'metro';
7
+
8
+ export function metroNotice(line: string, text: string): MetroEvent {
9
+ return {
10
+ id: mintId(),
11
+ ts: new Date().toISOString(),
12
+ station: Line.station(line) ?? '',
13
+ line: asLine(line),
14
+ from: asLine(NOTICE_FROM),
15
+ to: asLine(line),
16
+ text,
17
+ event: { type: 'system', source: NOTICE_SOURCE },
18
+ };
19
+ }
20
+
21
+ export const isMetroNotice = (ev: Record<string, unknown>): boolean =>
22
+ ev.from === NOTICE_FROM && isRecord(ev.event) && ev.event.type === 'system' && ev.event.source === NOTICE_SOURCE;
@@ -49,6 +49,7 @@ export interface SessionDeps {
49
49
  now?: () => number;
50
50
  version?: string;
51
51
  prepare?: (runner: HarnessRunner) => Promise<void>;
52
+ connectorsReady?: () => boolean;
52
53
  }
53
54
 
54
55
  export interface SessionStatus {
@@ -316,9 +317,10 @@ function strike(now: number): boolean {
316
317
  return true;
317
318
  }
318
319
 
319
- export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
320
+ export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused' | 'waiting';
320
321
 
321
322
  function holdsBack(deps: SessionDeps, now: number): Ensured | null {
323
+ if (deps.connectorsReady?.() === false) return 'waiting';
322
324
  if (now < memory.pausedUntil) return 'paused';
323
325
  if (sessionBlocked(deps) !== null) return 'blocked';
324
326
  return strike(now) ? 'paused' : null;
@@ -151,6 +151,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
151
151
  'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
152
152
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
153
153
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
+ 'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
154
155
  ]);
155
156
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
156
157
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -2,7 +2,6 @@ import type { ToolGroup } from '@metro-labs/core/stations/types';
2
2
  import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
3
  import { serverKeysOf } from './plugin-sync.js';
4
4
  import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
5
- import { isOrgRelayId } from './relay-id.js';
6
5
  import type { BlockedReason } from './relay-policy.js';
7
6
 
8
7
  export interface GateRow {
@@ -101,14 +100,5 @@ export function connectorToolOf(toolName: string, entries: readonly ConnectorGat
101
100
  for (const gate of entries)
102
101
  for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
103
102
  if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
104
- return legacyToolOf(toolName, entries);
105
- }
106
-
107
- function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
108
- if (!toolName.startsWith('mcp__metro_box_')) return undefined;
109
- const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
110
- const gate = legacy.length === 1 ? legacy[0] : undefined;
111
- if (gate === undefined) return undefined;
112
- const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
113
- return called === '' ? undefined : resolvedTool(gate, called);
103
+ return undefined;
114
104
  }
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { errMsg, log } from '@metro-labs/core/log';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { isOrgRelayId, orgRelayId } from './relay-id.js';
7
+ import { readServerKeys, writeServerKeys } from './server-keys.js';
8
+
9
+ interface Alias {
10
+ sourceId: string;
11
+ relayId: string;
12
+ }
13
+
14
+ function recordsOf(dir: string): unknown[] {
15
+ let raw: unknown;
16
+ try {
17
+ raw = JSON.parse(readFileSync(join(dir, 'connector-migrations.json'), 'utf8'));
18
+ } catch (err) {
19
+ if (isRecord(err) && err.code === 'ENOENT') return [];
20
+ log.warn({ err: errMsg(err) }, 'connectors: the old migration records could not be read; server keys left as they are');
21
+ return [];
22
+ }
23
+ if (isRecord(raw) && raw.version === 1 && Array.isArray(raw.records)) return raw.records;
24
+ log.warn('connectors: the old migration records are not valid; server keys left as they are');
25
+ return [];
26
+ }
27
+
28
+ function aliasOf(record: unknown): Alias[] {
29
+ if (!isRecord(record) || record.phase !== 'active' || typeof record.sourceId !== 'string' || typeof record.destinationId !== 'string') return [];
30
+ const relayId = orgRelayId(record.destinationId);
31
+ return isOrgRelayId(relayId) && record.sourceId !== relayId ? [{ sourceId: record.sourceId, relayId }] : [];
32
+ }
33
+
34
+ export function adoptOrgServerKeys(dir = agentsDir()): number {
35
+ try {
36
+ const aliases = recordsOf(dir).flatMap(aliasOf);
37
+ if (aliases.length === 0) return 0;
38
+ const keys = readServerKeys(dir);
39
+ if (keys === undefined) return 0;
40
+ let moved = 0;
41
+ for (const { sourceId, relayId } of aliases) {
42
+ const key = keys.get(sourceId);
43
+ if (key === undefined || keys.has(relayId)) continue;
44
+ keys.delete(sourceId);
45
+ keys.set(relayId, key);
46
+ moved += 1;
47
+ }
48
+ if (moved > 0) {
49
+ writeServerKeys(keys, dir);
50
+ log.info({ moved }, 'connectors: server keys moved to organization connector ids, tool names unchanged');
51
+ }
52
+ return moved;
53
+ } catch (err) {
54
+ log.warn({ err: errMsg(err) }, 'connectors: the server keys could not be moved to organization connector ids');
55
+ return 0;
56
+ }
57
+ }