@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +14 -5
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +25 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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
|
|
169
|
+
function flagChange(raw: unknown, name: string): boolean | undefined {
|
|
166
170
|
if (raw === undefined) return undefined;
|
|
167
|
-
if (typeof raw !== 'boolean') throw new ApiError(
|
|
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 =
|
|
183
|
+
const privacy = flagChange(body.privacy, 'privacy');
|
|
180
184
|
const permissionMode = modeChange(body.permissionMode);
|
|
181
185
|
const systemPrompt = promptChange(body.systemPrompt);
|
|
182
|
-
|
|
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(
|
|
221
|
-
permissionMode: permissionMode(
|
|
222
|
-
systemPrompt: systemPrompt(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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) },
|
package/runtime/runtime.json
CHANGED