@stage-labs/metro 0.1.0-beta.214 → 0.1.0-beta.215

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.214",
3
+ "version": "0.1.0-beta.215",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.214",
3
+ "version": "0.1.0-beta.215",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -27,7 +27,7 @@ import { runningAsRoot } from '../metro-user/privilege.js';
27
27
  import { provisionAgentUser } from '../agent-user/provision.js';
28
28
  import { applyVault } from '../vault/index.js';
29
29
  import { unwatchSession, watchSession } from '../claude/session.js';
30
- import { tryClaudeSetup } from '../claude/setup.js';
30
+ import { liveEvents, tryClaudeSetup } from '../claude/setup.js';
31
31
  import { applyLocalOwner } from './local-owner.js';
32
32
  import { installBearerSessions } from '../routes/bearer.js';
33
33
  import { ensureLocalAgent, localOwner } from '../agents/file-admin.js';
@@ -96,7 +96,7 @@ async function syncStations(station: StationName): Promise<void> {
96
96
  }
97
97
  }
98
98
 
99
- function sessionApis(): SessionApis {
99
+ function sessionApis(setLiveEvents: (on: boolean) => void): SessionApis {
100
100
  return localSessionApis({
101
101
  syncStations,
102
102
  reloadAgents: async () => {
@@ -114,6 +114,7 @@ function sessionApis(): SessionApis {
114
114
  capabilities: accountStationCapabilities,
115
115
  toolGroups: stationToolGroups,
116
116
  prepareAccount,
117
+ liveEvents: setLiveEvents,
117
118
  });
118
119
  }
119
120
 
@@ -129,10 +130,10 @@ installBearerSessions(agentsDir(), localOwner);
129
130
  await materializeFrom(fileSource);
130
131
  forgetOrphans(knownAccounts());
131
132
  supervisor.start();
132
- const metroMcp = await createMetroMcp();
133
+ const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
133
134
  webhookServer = await startWebhookServer(
134
135
  emit,
135
- sessionApis(),
136
+ sessionApis(metroMcp.setLiveEvents),
136
137
  metroMcp.httpHandler,
137
138
  true,
138
139
  );
@@ -76,6 +76,11 @@ export class InboundRelay {
76
76
  return this.lastLine;
77
77
  }
78
78
 
79
+ dropPending(): void {
80
+ for (const e of this.pendingAttachments.values()) clearTimeout(e.timer);
81
+ this.pendingAttachments.clear();
82
+ }
83
+
79
84
  noteSent(messageId: string): void {
80
85
  if (!messageId) return;
81
86
  this.sentIds.add(messageId);
@@ -24,7 +24,9 @@ import {
24
24
  claudeSetupStatus,
25
25
  ensureClaudeSetup,
26
26
  isPermissionMode,
27
+ liveEvents,
27
28
  permissionMode,
29
+ setLiveEvents,
28
30
  setPermissionMode,
29
31
  setPrivacy,
30
32
  setSystemPrompt,
@@ -70,6 +72,7 @@ export interface ClaudeApiDeps {
70
72
  session?: SessionDeps;
71
73
  setup?: SetupDeps;
72
74
  version?: VersionDeps;
75
+ liveEvents?: (on: boolean) => void;
73
76
  }
74
77
 
75
78
  function projectOf(query: URLSearchParams): string {
@@ -153,6 +156,7 @@ interface SetupChange {
153
156
  privacy?: boolean;
154
157
  permissionMode?: PermissionMode;
155
158
  systemPrompt?: string;
159
+ liveEvents?: boolean;
156
160
  }
157
161
 
158
162
  function promptChange(raw: unknown): string | undefined {
@@ -162,9 +166,9 @@ function promptChange(raw: unknown): string | undefined {
162
166
  return raw;
163
167
  }
164
168
 
165
- function privacyChange(raw: unknown): boolean | undefined {
169
+ function flagChange(raw: unknown, name: string): boolean | undefined {
166
170
  if (raw === undefined) return undefined;
167
- if (typeof raw !== 'boolean') throw new ApiError('privacy must be true or false', 400);
171
+ if (typeof raw !== 'boolean') throw new ApiError(`${name} must be true or false`, 400);
168
172
  return raw;
169
173
  }
170
174
 
@@ -176,14 +180,16 @@ function modeChange(raw: unknown): PermissionMode | undefined {
176
180
 
177
181
  function setupChange(body: unknown): SetupChange {
178
182
  if (!isRecord(body)) throw new ApiError('a body is required', 400);
179
- const privacy = privacyChange(body.privacy);
183
+ const privacy = flagChange(body.privacy, 'privacy');
180
184
  const permissionMode = modeChange(body.permissionMode);
181
185
  const systemPrompt = promptChange(body.systemPrompt);
182
- if (privacy === undefined && permissionMode === undefined && systemPrompt === undefined) throw new ApiError('nothing to change', 400);
186
+ const liveEvents = flagChange(body.liveEvents, 'liveEvents');
187
+ if (privacy === undefined && permissionMode === undefined && systemPrompt === undefined && liveEvents === undefined) throw new ApiError('nothing to change', 400);
183
188
  return {
184
189
  ...(privacy === undefined ? {} : { privacy }),
185
190
  ...(permissionMode === undefined ? {} : { permissionMode }),
186
191
  ...(systemPrompt === undefined ? {} : { systemPrompt }),
192
+ ...(liveEvents === undefined ? {} : { liveEvents }),
187
193
  };
188
194
  }
189
195
 
@@ -200,9 +206,16 @@ function restartsSession(change: SetupChange, agents: string | undefined): boole
200
206
  return restart;
201
207
  }
202
208
 
209
+ function applyLiveEvents(on: boolean, deps: ClaudeApiDeps): void {
210
+ const agents = deps.setup?.agents;
211
+ if (on !== liveEvents(agents)) setLiveEvents(on, agents);
212
+ deps.liveEvents?.(on);
213
+ }
214
+
203
215
  function applySetupChange(change: SetupChange, deps: ClaudeApiDeps): void {
204
216
  const setup = deps.setup ?? {};
205
217
  if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
218
+ if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
206
219
  if (restartsSession(change, setup.agents) && sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
207
220
  ensureClaudeSetup(setup);
208
221
  }
@@ -64,6 +64,7 @@ export interface SetupStatus {
64
64
  privacy: boolean;
65
65
  permissionMode: PermissionMode;
66
66
  systemPrompt: string;
67
+ liveEvents: boolean;
67
68
  guard: 'plugin';
68
69
  worker: boolean;
69
70
  skill: boolean;
@@ -103,6 +104,12 @@ export function setPermissionMode(mode: PermissionMode, agents = agentsDir()): v
103
104
  writeState(agents, { permissionMode: mode });
104
105
  }
105
106
 
107
+ export const liveEvents = (agents = agentsDir()): boolean => readState(agents).liveEvents !== false;
108
+
109
+ export function setLiveEvents(on: boolean, agents = agentsDir()): void {
110
+ writeState(agents, { liveEvents: on });
111
+ }
112
+
106
113
  const promptPath = (agents: string): string => join(agents, SYSTEM_PROMPT_FILE);
107
114
 
108
115
  export function systemPrompt(agents = agentsDir()): string {
@@ -213,13 +220,15 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
213
220
 
214
221
  export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
215
222
  const dir = deps.dir ?? claudeDir();
223
+ const agents = deps.agents ?? agentsDir();
216
224
  const settings = readSettings(join(dir, 'settings.json'));
217
225
  const env = settings !== null && isRecord(settings.env) ? settings.env : {};
218
226
  const days = settings?.cleanupPeriodDays;
219
227
  return {
220
- privacy: privacyEnabled(deps.agents ?? agentsDir()),
221
- permissionMode: permissionMode(deps.agents ?? agentsDir()),
222
- systemPrompt: systemPrompt(deps.agents ?? agentsDir()),
228
+ privacy: privacyEnabled(agents),
229
+ permissionMode: permissionMode(agents),
230
+ systemPrompt: systemPrompt(agents),
231
+ liveEvents: liveEvents(agents),
223
232
  guard: 'plugin',
224
233
  worker: existsSync(workerPath(dir)),
225
234
  skill: existsSync(skillPath(dir)),
@@ -87,17 +87,18 @@ async function serveGet(
87
87
  session.bindSink(sink);
88
88
  },
89
89
  });
90
- if (served) session.channel.replayMissed();
90
+ if (served) session.replayMissed();
91
91
  }
92
92
 
93
93
  let activeSlot: SessionSlot | undefined;
94
94
 
95
- export async function createMetroMcp(): Promise<{
95
+ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Promise<{
96
96
  httpHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
97
97
  startInbound: () => void;
98
+ setLiveEvents: (on: boolean) => void;
98
99
  }> {
99
100
  await activeSlot?.close();
100
- const slot = new SessionSlot();
101
+ const slot = new SessionSlot(options.liveEvents);
101
102
  activeSlot = slot;
102
103
 
103
104
  const httpHandler = async (
@@ -130,8 +131,16 @@ export async function createMetroMcp(): Promise<{
130
131
 
131
132
  const startInbound = (): void => {
132
133
  slot.startInbound();
133
- channelLog('inbound: bus subscription (bounded replay on reconnect)');
134
+ channelLog(
135
+ slot.liveEvents
136
+ ? 'inbound: bus subscription (bounded replay on reconnect)'
137
+ : 'inbound: live events are off, nothing is pushed to the session',
138
+ );
134
139
  };
135
140
 
136
- return { httpHandler, startInbound };
141
+ const setLiveEvents = (on: boolean): void => {
142
+ slot.setLive(on);
143
+ };
144
+
145
+ return { httpHandler, startInbound, setLiveEvents };
137
146
  }
@@ -28,10 +28,15 @@ export interface PermissionRelayDeps {
28
28
  mcp: Server;
29
29
  relay: InboundRelay;
30
30
  inScope: (line: string) => boolean;
31
+ live: () => boolean;
31
32
  log: (...a: unknown[]) => void;
32
33
  }
33
34
 
34
35
  function relayLine(deps: PermissionRelayDeps, requestId: string): string | undefined {
36
+ if (!deps.live()) {
37
+ deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
38
+ return undefined;
39
+ }
35
40
  const line = deps.relay.knownLine;
36
41
  if (!line) {
37
42
  deps.log('permission_request: no known line, held for the page only', requestId);
@@ -8,16 +8,38 @@ export class SessionSlot {
8
8
  private session: McpSession | undefined;
9
9
  private readonly ledger = newReplayLedger();
10
10
  private started = false;
11
+ private live: boolean;
12
+
13
+ constructor(live = true) {
14
+ this.live = live;
15
+ }
11
16
 
12
17
  get current(): McpSession | undefined {
13
18
  return this.session;
14
19
  }
15
20
 
21
+ get liveEvents(): boolean {
22
+ return this.live;
23
+ }
24
+
16
25
  startInbound(): void {
17
26
  if (this.started) return;
18
27
  this.started = true;
19
28
  if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
20
- this.session?.startChannel();
29
+ if (this.live) this.session?.startChannel();
30
+ }
31
+
32
+ setLive(on: boolean): void {
33
+ if (this.live === on) return;
34
+ this.live = on;
35
+ channelLog('inbound: live events', on ? 'on' : 'off');
36
+ if (!on) {
37
+ this.session?.stopChannel();
38
+ this.session?.relay.dropPending();
39
+ return;
40
+ }
41
+ this.ledger.startAt = currentBusSeq();
42
+ if (this.started) this.session?.startChannel();
21
43
  }
22
44
 
23
45
  async open(
@@ -32,12 +54,13 @@ export class SessionSlot {
32
54
  scope: allowedAgents(identity),
33
55
  adopted: adoptId !== undefined,
34
56
  ledger: this.ledger,
57
+ live: () => this.live,
35
58
  onClosed: (s) => {
36
59
  if (this.session === s) this.session = undefined;
37
60
  },
38
61
  });
39
62
  this.session = session;
40
- if (this.started) session.startChannel();
63
+ if (this.started && this.live) session.startChannel();
41
64
  channelLog('session: opened', 'id', id, 'adopted', adoptId !== undefined);
42
65
  return session;
43
66
  }
@@ -52,6 +52,7 @@ export interface SessionInit {
52
52
  scope: Set<string>;
53
53
  adopted: boolean;
54
54
  ledger: ReplayLedger;
55
+ live: () => boolean;
55
56
  onClosed: (session: McpSession) => void;
56
57
  }
57
58
 
@@ -63,6 +64,7 @@ export class McpSession {
63
64
  readonly server: Server;
64
65
  readonly relay: InboundRelay;
65
66
  readonly channel: ChannelRelay;
67
+ readonly live: () => boolean;
66
68
  private sink: RawGetSink | undefined;
67
69
  private unsubscribe: (() => void) | undefined;
68
70
  private closed = false;
@@ -73,6 +75,7 @@ export class McpSession {
73
75
  private constructor(init: SessionInit) {
74
76
  this.id = init.id;
75
77
  this.scope = init.scope;
78
+ this.live = init.live;
76
79
  this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
77
80
  this.onClosed = init.onClosed;
78
81
  this.eventStore = new BoundedEventStore();
@@ -114,12 +117,13 @@ export class McpSession {
114
117
  mcp: this.server,
115
118
  relay: this.relay,
116
119
  inScope: (line) => this.inScope(line),
120
+ live: this.live,
117
121
  log: channelLog,
118
122
  });
119
123
  this.channel = new ChannelRelay({
120
124
  relay: this.relay,
121
125
  log: channelLog,
122
- inScope: (line) => this.inScope(line),
126
+ inScope: (line) => this.live() && this.inScope(line),
123
127
  ledger: init.ledger,
124
128
  });
125
129
  }
@@ -151,6 +155,15 @@ export class McpSession {
151
155
  this.unsubscribe ??= this.channel.start();
152
156
  }
153
157
 
158
+ stopChannel(): void {
159
+ this.unsubscribe?.();
160
+ this.unsubscribe = undefined;
161
+ }
162
+
163
+ replayMissed(): void {
164
+ if (this.live()) this.channel.replayMissed();
165
+ }
166
+
154
167
  bindSink(sink: RawGetSink | undefined): void {
155
168
  this.sink = sink;
156
169
  if (sink !== undefined) this.announceToolSchema();
@@ -195,8 +208,7 @@ export class McpSession {
195
208
  this.onClosed(this);
196
209
  forgetPromptsOf(this.server);
197
210
  this.dropStream();
198
- this.unsubscribe?.();
199
- this.unsubscribe = undefined;
211
+ this.stopChannel();
200
212
  await this.server.close().catch(() => undefined);
201
213
  }
202
214
  }
@@ -55,6 +55,7 @@ export interface LocalModeDeps {
55
55
  capabilities: AgentApiDeps['capabilities'];
56
56
  toolGroups?: AgentApiDeps['toolGroups'];
57
57
  prepareAccount: AccountApiDeps['prepareAccount'];
58
+ liveEvents?: (on: boolean) => void;
58
59
  }
59
60
 
60
61
  function attachSessions(deps: LocalModeDeps): AttachSessions {
@@ -192,7 +193,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
192
193
  bundleApi: bundleApi(deps),
193
194
  connectorApi,
194
195
  relayApi,
195
- claudeApi: {},
196
+ claudeApi: { liveEvents: deps.liveEvents },
196
197
  updateApi: { restart: deps.restart },
197
198
  controlApi: { restart: deps.restart, stop: deps.stop },
198
199
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.214"
2
+ "version": "0.1.0-beta.215"
3
3
  }