@stage-labs/metro 0.1.0-beta.214 → 0.1.0-beta.216
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/background.js +123 -0
- package/dist/claude.js +18 -2
- 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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
|
|
6
|
+
const STOP_MS = 30_000;
|
|
7
|
+
const SETTLE_MS = 5_000;
|
|
8
|
+
const POLL_MS = 250;
|
|
9
|
+
const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
10
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
11
|
+
export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
|
|
12
|
+
const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
|
|
13
|
+
export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
|
|
14
|
+
function startTime(pid) {
|
|
15
|
+
try {
|
|
16
|
+
const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
|
|
17
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function processAlive(pid, procStart) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.code !== 'EPERM')
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const started = procStart === undefined ? null : startTime(pid);
|
|
32
|
+
return started === null || started === procStart;
|
|
33
|
+
}
|
|
34
|
+
function backgroundEntry(file, alive) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
37
|
+
if (!isRecord(raw) || typeof raw.pid !== 'number' || typeof raw.sessionId !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
if (raw.kind === 'interactive' || typeof raw.kind !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
return alive(raw.pid, typeof raw.procStart === 'string' ? raw.procStart : undefined) ? raw.sessionId : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function liveBackground(sessions, alive = processAlive) {
|
|
48
|
+
if (!existsSync(sessions))
|
|
49
|
+
return new Set();
|
|
50
|
+
const ids = readdirSync(sessions)
|
|
51
|
+
.filter((name) => name.endsWith('.json'))
|
|
52
|
+
.map((name) => backgroundEntry(join(sessions, name), alive));
|
|
53
|
+
return new Set(ids.filter((id) => id !== null));
|
|
54
|
+
}
|
|
55
|
+
function newestFirst(dir) {
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
return [];
|
|
58
|
+
return readdirSync(dir)
|
|
59
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
60
|
+
.map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs }))
|
|
61
|
+
.sort((a, b) => b.at - a.at)
|
|
62
|
+
.map((file) => file.path);
|
|
63
|
+
}
|
|
64
|
+
function continuedIn(text) {
|
|
65
|
+
const last = text.split('\n').findLast((line) => line.includes('"type":"continued-in"'));
|
|
66
|
+
if (last === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const record = JSON.parse(last);
|
|
70
|
+
return isRecord(record) && typeof record.continuedInSessionId === 'string' ? record.continuedInSessionId : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const HAS_TURN = /"type":"(user|assistant)"/;
|
|
77
|
+
export function heldConversation(dir, live) {
|
|
78
|
+
if (live.size === 0)
|
|
79
|
+
return null;
|
|
80
|
+
for (const file of newestFirst(dir)) {
|
|
81
|
+
const text = readFileSync(file, 'utf8');
|
|
82
|
+
if (!HAS_TURN.test(text))
|
|
83
|
+
continue;
|
|
84
|
+
const moved = continuedIn(text);
|
|
85
|
+
if (moved !== null && live.has(moved))
|
|
86
|
+
return moved;
|
|
87
|
+
if (!live.has(basename(file, '.jsonl')))
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function stopBackground(id, env = process.env) {
|
|
93
|
+
const run = spawnSync('claude', ['stop', id], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: STOP_MS });
|
|
94
|
+
if (run.error !== undefined)
|
|
95
|
+
return { ok: false, detail: run.error.message };
|
|
96
|
+
const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
|
|
97
|
+
return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
|
|
98
|
+
}
|
|
99
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
100
|
+
setTimeout(resolve, ms);
|
|
101
|
+
});
|
|
102
|
+
async function settled(sessions, held, alive, ms) {
|
|
103
|
+
if (!liveBackground(sessions, alive).has(held))
|
|
104
|
+
return true;
|
|
105
|
+
if (ms <= 0)
|
|
106
|
+
return false;
|
|
107
|
+
await sleep(POLL_MS);
|
|
108
|
+
return settled(sessions, held, alive, ms - POLL_MS);
|
|
109
|
+
}
|
|
110
|
+
export async function takeBackConversation(deps = {}) {
|
|
111
|
+
const env = deps.env ?? process.env;
|
|
112
|
+
const alive = deps.alive ?? processAlive;
|
|
113
|
+
const dir = claudeDir(env);
|
|
114
|
+
const sessions = join(dir, 'sessions');
|
|
115
|
+
const held = heldConversation(projectDir(dir, deps.cwd ?? process.cwd()), liveBackground(sessions, alive));
|
|
116
|
+
if (held === null)
|
|
117
|
+
return null;
|
|
118
|
+
const id = held.slice(0, 8);
|
|
119
|
+
const stopped = (deps.stop ?? stopBackground)(id, env);
|
|
120
|
+
if (!stopped.ok || !(await settled(sessions, held, alive, deps.settleMs ?? SETTLE_MS)))
|
|
121
|
+
return `the conversation is held by the Claude Code background session ${id} and it could not be stopped (${stopped.detail}), so Claude Code will refuse to continue it: run claude stop ${id}`;
|
|
122
|
+
return `the conversation had moved to the Claude Code background session ${id}, where no chat reaches it; stopped that session (its conversation is kept) and continuing it here`;
|
|
123
|
+
}
|
package/dist/claude.js
CHANGED
|
@@ -6,6 +6,7 @@ import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
|
6
6
|
import { localAgent } from './local.js';
|
|
7
7
|
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
8
8
|
import { localPort, localUrl } from './runtime.js';
|
|
9
|
+
import { keepInSession, takeBackConversation } from './background.js';
|
|
9
10
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
10
11
|
const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
|
|
11
12
|
const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
@@ -132,7 +133,22 @@ function gatewayLaunchEnv(key, port) {
|
|
|
132
133
|
process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
|
|
133
134
|
return env;
|
|
134
135
|
}
|
|
136
|
+
const continues = (args) => args.includes('-c') || args.includes('--continue');
|
|
137
|
+
async function reclaimConversation(extra) {
|
|
138
|
+
if (!continues(extra))
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
const note = await takeBackConversation();
|
|
142
|
+
if (note !== null)
|
|
143
|
+
process.stderr.write(`metro claude: ${note}\n`);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147
|
+
process.stderr.write(`metro claude: could not check for a Claude Code background session holding the conversation (${message})\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
135
150
|
export async function launchClaude(extra) {
|
|
151
|
+
await reclaimConversation(extra);
|
|
136
152
|
const decision = await verdict();
|
|
137
153
|
const port = localPort();
|
|
138
154
|
const mcp = mcpConfigFor(await servedKey(decision), port);
|
|
@@ -143,9 +159,9 @@ export async function launchClaude(extra) {
|
|
|
143
159
|
try {
|
|
144
160
|
if ('skip' in decision) {
|
|
145
161
|
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
146
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), channelEnv(process.env));
|
|
162
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), keepInSession(channelEnv(process.env)));
|
|
147
163
|
}
|
|
148
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), channelEnv(gatewayLaunchEnv(decision.key, port)));
|
|
164
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), keepInSession(channelEnv(gatewayLaunchEnv(decision.key, port))));
|
|
149
165
|
}
|
|
150
166
|
finally {
|
|
151
167
|
mcp?.cleanup();
|
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.216",
|
|
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