@stage-labs/metro 0.1.0-beta.215 → 0.1.0-beta.217
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/agents/accounts-api.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +5 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +1 -4
- 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.217",
|
|
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",
|
|
@@ -228,7 +228,7 @@ async function applied(deps: AccountApiDeps): Promise<boolean> {
|
|
|
228
228
|
await deps.reloadAgents();
|
|
229
229
|
return true;
|
|
230
230
|
} catch (err) {
|
|
231
|
-
log.warn({ err: errMsg(err) }, 'account-api:
|
|
231
|
+
log.warn({ err: errMsg(err) }, 'account-api: reload failed, the change lands at the next boot');
|
|
232
232
|
return false;
|
|
233
233
|
}
|
|
234
234
|
}
|
|
@@ -275,7 +275,7 @@ async function handleEnabled(
|
|
|
275
275
|
station: target.station,
|
|
276
276
|
accountId: target.accountId,
|
|
277
277
|
enabled,
|
|
278
|
-
activated: await
|
|
278
|
+
activated: await applied(deps),
|
|
279
279
|
});
|
|
280
280
|
}
|
|
281
281
|
|
|
@@ -42,6 +42,11 @@ export function setDisabledAccounts(ids: Set<string>): void {
|
|
|
42
42
|
|
|
43
43
|
export const accountEnabled = (station: string, accountId: string): boolean => !disabledAccounts.has(mapKey(station, accountId));
|
|
44
44
|
|
|
45
|
+
export function lineReceives(line: string): boolean {
|
|
46
|
+
const a = accountFromLine(line);
|
|
47
|
+
return a === undefined || accountEnabled(a.station, a.accountId);
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export function accountFromLine(
|
|
46
51
|
line: string,
|
|
47
52
|
): { station: string; accountId: string } | undefined {
|
|
@@ -2,7 +2,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { InboundRelay } from '../channels/inbound.js';
|
|
4
4
|
import { holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
5
|
-
import { approversForLine } from '../agents/map.js';
|
|
5
|
+
import { approversForLine, lineReceives } from '../agents/map.js';
|
|
6
6
|
import { metroCall } from './ctx.js';
|
|
7
7
|
import { promptBody } from './permission-prompt.js';
|
|
8
8
|
|
|
@@ -46,6 +46,10 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
|
|
|
46
46
|
deps.log('permission_request: known line is outside the agent scope, held for the page only', requestId);
|
|
47
47
|
return undefined;
|
|
48
48
|
}
|
|
49
|
+
if (!lineReceives(line)) {
|
|
50
|
+
deps.log('permission_request: the known chat does not receive messages now, so no answer can arrive; held for the page only', requestId);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
49
53
|
if (approversForLine(line).length === 0) {
|
|
50
54
|
deps.log('permission_request: nobody on that chat may approve, held for the page only', requestId);
|
|
51
55
|
return undefined;
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type MetroEvent,
|
|
23
23
|
} from '@metro-labs/core/events';
|
|
24
24
|
import type { TrainEvent } from '@metro-labs/core/trains/protocol';
|
|
25
|
-
import { agentForLine, agentIdForLine } from '../agents/map.js';
|
|
25
|
+
import { agentForLine, agentIdForLine, lineReceives } from '../agents/map.js';
|
|
26
26
|
import type { Endpoint } from '@metro-labs/core/endpoints';
|
|
27
27
|
import { findEndpointByWebhookId, listEndpoints, tokenMatches } from '../stations/webhook-endpoints.js';
|
|
28
28
|
import { attachmentEventUrl, handleAttachRequest, webhookPort } from '../files/attach-serve.js';
|
|
@@ -107,6 +107,10 @@ type Emit = (entry: MetroEvent) => void;
|
|
|
107
107
|
export function makeEmit(dedupSeq?: DedupSeq): Emit {
|
|
108
108
|
const tracker = dedupSeq ?? makeDedupSeq();
|
|
109
109
|
return function emit(entry: MetroEvent): void {
|
|
110
|
+
if (!lineReceives(entry.line)) {
|
|
111
|
+
log.debug({ line: entry.line }, 'emit: dropped, this channel does not receive messages');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
110
114
|
const seq = tracker.admit(entry);
|
|
111
115
|
if (seq === null) return;
|
|
112
116
|
const enriched: MetroEvent = withAttachmentUrl({
|
|
@@ -165,10 +165,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
165
165
|
}
|
|
166
166
|
map[`${a.station}/${a.id}`] = agent.id;
|
|
167
167
|
if (a.allowlist) allow[`${a.station}/${a.id}`] = a.allowlist;
|
|
168
|
-
if (a.enabled === false) {
|
|
169
|
-
disabled.add(`${a.station}/${a.id}`);
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
168
|
+
if (a.enabled === false) disabled.add(`${a.station}/${a.id}`);
|
|
172
169
|
const cur = byStation.get(a.station);
|
|
173
170
|
if (cur) cur.push(a);
|
|
174
171
|
else byStation.set(a.station, [a]);
|
package/runtime/runtime.json
CHANGED