@stage-labs/metro 0.1.0-beta.296 → 0.1.0-beta.298
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/agent-user/provision.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +56 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +16 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +18 -55
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -2
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.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.298",
|
|
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",
|
|
@@ -10,9 +10,11 @@ import { watchAgentView } from './view.js';
|
|
|
10
10
|
import { closeMetroHome } from './metro-home.js';
|
|
11
11
|
import { mustHelper } from '../metro-user/privilege.js';
|
|
12
12
|
import { stagedPluginVersion } from '../claude/plugin-install.js';
|
|
13
|
+
import { CLAUDE_CODE_VERSION } from '../claude/pinned.js';
|
|
14
|
+
import { pinClaudeCode } from '../claude/version.js';
|
|
13
15
|
|
|
14
16
|
const INSTALL_MS = 10 * 60_000;
|
|
15
|
-
const INSTALLER =
|
|
17
|
+
const INSTALLER = `curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION}`;
|
|
16
18
|
|
|
17
19
|
export type Provisioned = 'off' | 'failed' | 'ready';
|
|
18
20
|
|
|
@@ -55,6 +57,14 @@ function installClaude(user: AgentUser): Promise<void> {
|
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
function pinCheckedClaude(): void {
|
|
61
|
+
try {
|
|
62
|
+
pinClaudeCode();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.warn({ want: CLAUDE_CODE_VERSION, err: errMsg(err) }, 'agent-user: could not install the Claude Code version metro was checked with; keeping the installed one');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
|
|
59
69
|
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
60
70
|
const from = join(store, 'marketplace');
|
|
@@ -71,6 +81,7 @@ export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env):
|
|
|
71
81
|
const user = ensureUser();
|
|
72
82
|
if (user === null) throw new Error(`the user ${AGENT_NAME} could not be found after creating it`);
|
|
73
83
|
await installClaude(user);
|
|
84
|
+
pinCheckedClaude();
|
|
74
85
|
copyMarketplace(user, env);
|
|
75
86
|
watchAgentView();
|
|
76
87
|
return 'ready';
|
|
@@ -212,9 +212,9 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
|
|
|
212
212
|
function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
|
|
213
213
|
const version: VersionDeps = { ...deps.version, session: deps.version?.session ?? deps.session };
|
|
214
214
|
const method = req.method ?? 'GET';
|
|
215
|
-
if (method === 'GET') return claudeVersion(version);
|
|
215
|
+
if (method === 'GET') return Promise.resolve(claudeVersion(version));
|
|
216
216
|
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
217
|
-
return updateClaude(version);
|
|
217
|
+
return Promise.resolve(updateClaude(version));
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps, session: ApiSession) => Promise<unknown>> = {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { existsSync, readdirNames, readRange } from '../agent-user/agent-fs.js';
|
|
4
|
+
|
|
5
|
+
const HEAD_BYTES = 64 * 1024;
|
|
6
|
+
const SKIPPED_RE = /Channel notifications skipped: ([^"\\]+)/;
|
|
7
|
+
const REGISTERED = 'Channel notifications registered';
|
|
8
|
+
const NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.jsonl$/;
|
|
9
|
+
const SLACK_MS = 5_000;
|
|
10
|
+
|
|
11
|
+
export interface ChannelState {
|
|
12
|
+
registered: boolean;
|
|
13
|
+
reason: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let state: ChannelState | null = null;
|
|
17
|
+
|
|
18
|
+
export const channelState = (): ChannelState | null => state;
|
|
19
|
+
|
|
20
|
+
export function forgetChannelState(): void {
|
|
21
|
+
state = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const mcpLogDir = (home: string): string =>
|
|
25
|
+
join(home, '.cache', 'claude-cli-nodejs', home.replace(/[^a-zA-Z0-9]/g, '-'), 'mcp-logs-metro');
|
|
26
|
+
|
|
27
|
+
const openedAt = (name: string): number => {
|
|
28
|
+
const m = NAME_RE.exec(name);
|
|
29
|
+
return m === null ? Number.NaN : Date.parse(`${m[1] ?? ''}T${m[2] ?? ''}:${m[3] ?? ''}:${m[4] ?? ''}.${m[5] ?? ''}Z`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function readChannelState(home: string, since: number): ChannelState | null {
|
|
33
|
+
const dir = mcpLogDir(home);
|
|
34
|
+
if (!existsSync(dir)) return null;
|
|
35
|
+
const newest = readdirNames(dir).filter((name) => openedAt(name) >= since - SLACK_MS).sort().at(-1);
|
|
36
|
+
if (newest === undefined) return null;
|
|
37
|
+
const head = readRange(join(dir, newest), 0, HEAD_BYTES).toString('utf8');
|
|
38
|
+
const skipped = SKIPPED_RE.exec(head);
|
|
39
|
+
if (skipped !== null) return { registered: false, reason: skipped[1]?.trim() ?? null };
|
|
40
|
+
return head.includes(REGISTERED) ? { registered: true, reason: null } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function noteChannelState(home: string, since: number): boolean {
|
|
44
|
+
let found: ChannelState | null;
|
|
45
|
+
try {
|
|
46
|
+
found = readChannelState(home, since);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.debug({ err: errMsg(err) }, 'claude-session: could not read the metro MCP log of Claude Code');
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (found === null) return false;
|
|
52
|
+
state = found;
|
|
53
|
+
if (found.registered) log.info('claude-session: Claude Code registered the metro channel');
|
|
54
|
+
else log.error({ reason: found.reason }, 'claude-session: Claude Code skipped the metro channel, so chat messages do not reach the session');
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
3
|
+
import { asAgent } from '../agent-user/user.js';
|
|
4
|
+
|
|
5
|
+
export const CLAUDE_CODE_VERSION = '2.1.289';
|
|
6
|
+
|
|
7
|
+
const INSTALL_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
+
const TAIL = 400;
|
|
9
|
+
|
|
10
|
+
export function installClaudeCode(bin: string): void {
|
|
11
|
+
const run = spawnSync(...asAgent(bin, ['install', CLAUDE_CODE_VERSION]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
|
|
12
|
+
const failed = `claude install ${CLAUDE_CODE_VERSION} failed`;
|
|
13
|
+
if (run.error !== undefined) throw new Error(`${failed}: ${errMsg(run.error)}`);
|
|
14
|
+
if (run.status === 0) return;
|
|
15
|
+
throw new Error(`${failed}: ${`${run.stderr}${run.stdout}`.trim().slice(-TAIL)}`);
|
|
16
|
+
}
|
|
@@ -18,6 +18,7 @@ import { harnessRunner, isHarnessRunner, sdkAllowed, SDK_NEEDS_KEY, type Harness
|
|
|
18
18
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
19
19
|
import { trustFolder } from './onboarding.js';
|
|
20
20
|
import { inSessionScope } from './memory.js';
|
|
21
|
+
import { channelState, forgetChannelState, noteChannelState, type ChannelState } from './channel-check.js';
|
|
21
22
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
22
23
|
import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
|
|
23
24
|
|
|
@@ -59,6 +60,7 @@ export interface SessionStatus {
|
|
|
59
60
|
blocked: string | null;
|
|
60
61
|
lastStartedAt: string | null;
|
|
61
62
|
lastError: string | null;
|
|
63
|
+
channels?: ChannelState | null;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export interface SessionSnapshot {
|
|
@@ -212,12 +214,15 @@ function noteLaunchLines(pane: string, seen: Set<string>): void {
|
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
215
|
-
function confirmChannels(tmux: string,
|
|
217
|
+
function confirmChannels(tmux: string, home: string, startedAt: number): void {
|
|
218
|
+
const until = startedAt + CONFIRM_WAIT_MS;
|
|
216
219
|
const seen = new Set<string>();
|
|
217
220
|
let confirmed = false;
|
|
221
|
+
let checked = false;
|
|
218
222
|
const tick = (): void => {
|
|
219
223
|
const pane = capturePane(tmux);
|
|
220
224
|
noteLaunchLines(pane, seen);
|
|
225
|
+
checked ||= noteChannelState(home, startedAt);
|
|
221
226
|
if (!confirmed && pane.includes(WARNING) && pane.includes('server:metro')) {
|
|
222
227
|
spawnSync(...asAgent(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter']), { stdio: 'ignore' });
|
|
223
228
|
log.info('claude-session: confirmed the development channels dialog for server:metro');
|
|
@@ -230,6 +235,7 @@ function confirmChannels(tmux: string, until: number): void {
|
|
|
230
235
|
|
|
231
236
|
function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?: Error; status: number | null; stderr: string }): void {
|
|
232
237
|
memory.lastStartedAt = now;
|
|
238
|
+
forgetChannelState();
|
|
233
239
|
if (run.error !== undefined || run.status !== 0) {
|
|
234
240
|
memory.lastError = run.error === undefined ? `tmux new-session exited ${String(run.status)}: ${run.stderr.trim()}` : errMsg(run.error);
|
|
235
241
|
log.warn({ err: memory.lastError }, 'claude-session: tmux could not start');
|
|
@@ -238,7 +244,7 @@ function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?
|
|
|
238
244
|
memory.lastError = null;
|
|
239
245
|
const agents = deps.agents ?? agentsDir();
|
|
240
246
|
writeState(agents, { version: deps.version ?? METRO_VERSION, runner: harnessRunner(agents) });
|
|
241
|
-
confirmChannels(tmux, now
|
|
247
|
+
confirmChannels(tmux, sessionHome(deps), now);
|
|
242
248
|
}
|
|
243
249
|
|
|
244
250
|
function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, string[]] {
|
|
@@ -296,6 +302,7 @@ export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
|
|
|
296
302
|
autostart: autostartEnabled(deps.agents ?? agentsDir()),
|
|
297
303
|
blocked: sessionBlocked(deps),
|
|
298
304
|
lastError: memory.lastError,
|
|
305
|
+
channels: channelState(),
|
|
299
306
|
};
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -3,31 +3,23 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
6
|
import { sessionRunning, stopSession, type SessionDeps } from './session.js';
|
|
8
7
|
import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
8
|
+
import { CLAUDE_CODE_VERSION, installClaudeCode } from './pinned.js';
|
|
9
9
|
|
|
10
|
-
const LATEST_URL = 'https://registry.npmjs.org/@anthropic-ai/claude-code/latest';
|
|
11
10
|
const VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
|
|
12
|
-
const CACHE_MS = 10 * 60_000;
|
|
13
|
-
const INSTALL_TIMEOUT_MS = 5 * 60_000;
|
|
14
|
-
const TAIL = 400;
|
|
15
11
|
|
|
16
12
|
export interface VersionDeps {
|
|
17
13
|
claude?: string;
|
|
18
|
-
fetchImpl?: typeof fetch;
|
|
19
14
|
session?: SessionDeps;
|
|
20
|
-
now?: () => number;
|
|
21
15
|
}
|
|
22
16
|
|
|
23
17
|
export interface ClaudeVersion {
|
|
24
18
|
installed: string | null;
|
|
25
|
-
latest: string
|
|
19
|
+
latest: string;
|
|
26
20
|
newer: boolean;
|
|
27
21
|
}
|
|
28
22
|
|
|
29
|
-
let cached: { latest: string | null; at: number } | null = null;
|
|
30
|
-
|
|
31
23
|
function candidates(deps: VersionDeps): string[] {
|
|
32
24
|
if (deps.claude !== undefined) return [deps.claude];
|
|
33
25
|
return agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
|
|
@@ -51,48 +43,9 @@ function located(deps: VersionDeps): { bin: string; installed: string } | null {
|
|
|
51
43
|
return null;
|
|
52
44
|
}
|
|
53
45
|
|
|
54
|
-
export function
|
|
55
|
-
const left = a.split('.').map(Number);
|
|
56
|
-
const right = b.split('.').map(Number);
|
|
57
|
-
for (let i = 0; i < 3; i += 1) {
|
|
58
|
-
const d = (left[i] ?? 0) - (right[i] ?? 0);
|
|
59
|
-
if (d !== 0) return d > 0;
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function latestClaudeVersion(deps: VersionDeps = {}): Promise<string | null> {
|
|
65
|
-
const now = (deps.now ?? Date.now)();
|
|
66
|
-
if (cached !== null && now - cached.at < CACHE_MS) return cached.latest;
|
|
67
|
-
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
68
|
-
let latest: string | null = null;
|
|
69
|
-
try {
|
|
70
|
-
const res = await fetchImpl(LATEST_URL, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
71
|
-
const body: unknown = res.ok ? await res.json() : null;
|
|
72
|
-
latest = isRecord(body) && typeof body.version === 'string' ? parse(body.version) : null;
|
|
73
|
-
} catch (err) {
|
|
74
|
-
log.warn({ err: errMsg(err) }, 'claude-version: could not read the latest version from npm');
|
|
75
|
-
}
|
|
76
|
-
cached = { latest, at: now };
|
|
77
|
-
return latest;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function forgetClaudeVersion(): void {
|
|
81
|
-
cached = null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function claudeVersion(deps: VersionDeps = {}): Promise<ClaudeVersion> {
|
|
46
|
+
export function claudeVersion(deps: VersionDeps = {}): ClaudeVersion {
|
|
85
47
|
const installed = located(deps)?.installed ?? null;
|
|
86
|
-
|
|
87
|
-
return { installed, latest, newer: installed !== null && latest !== null && newerThan(latest, installed) };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function install(bin: string): void {
|
|
91
|
-
const run = spawnSync(...asAgent(bin, ['install', 'latest']), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
|
|
92
|
-
if (run.error !== undefined) throw new ApiError(`claude install latest failed: ${errMsg(run.error)}`, 502);
|
|
93
|
-
if (run.status === 0) return;
|
|
94
|
-
const said = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().slice(-TAIL);
|
|
95
|
-
throw new ApiError(`claude install latest failed: ${said}`, 502);
|
|
48
|
+
return { installed, latest: CLAUDE_CODE_VERSION, newer: installed !== null && installed !== CLAUDE_CODE_VERSION };
|
|
96
49
|
}
|
|
97
50
|
|
|
98
51
|
function restartIfChanged(changed: boolean, session: SessionDeps): boolean {
|
|
@@ -101,13 +54,23 @@ function restartIfChanged(changed: boolean, session: SessionDeps): boolean {
|
|
|
101
54
|
return true;
|
|
102
55
|
}
|
|
103
56
|
|
|
104
|
-
export
|
|
57
|
+
export function updateClaude(deps: VersionDeps = {}): ClaudeVersion & { restarted: boolean } {
|
|
105
58
|
const found = located(deps);
|
|
106
59
|
if (found === null) throw new ApiError('Claude Code is not installed on this machine', 404);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
60
|
+
try {
|
|
61
|
+
installClaudeCode(found.bin);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new ApiError(errMsg(err), 502);
|
|
64
|
+
}
|
|
65
|
+
const after = claudeVersion(deps);
|
|
110
66
|
const restarted = restartIfChanged(after.installed !== null && after.installed !== found.installed, deps.session ?? {});
|
|
111
67
|
log.info({ from: found.installed, to: after.installed, restarted }, 'claude-version: Claude Code updated');
|
|
112
68
|
return { ...after, restarted };
|
|
113
69
|
}
|
|
70
|
+
|
|
71
|
+
export function pinClaudeCode(deps: VersionDeps = {}): void {
|
|
72
|
+
const found = located(deps);
|
|
73
|
+
if (found === null || found.installed === CLAUDE_CODE_VERSION) return;
|
|
74
|
+
installClaudeCode(found.bin);
|
|
75
|
+
log.info({ from: found.installed, to: located(deps)?.installed ?? null }, 'claude-version: installed the Claude Code version metro was checked with');
|
|
76
|
+
}
|
|
@@ -8,6 +8,7 @@ import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationLis
|
|
|
8
8
|
const locked = (phase: string): boolean => phase !== 'rolled_back';
|
|
9
9
|
const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
|
|
10
10
|
const refused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization.', 409);
|
|
11
|
+
const editRefused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization. To change or remove it, open Move to organization and roll back its migration first.', 409);
|
|
11
12
|
|
|
12
13
|
export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeof migrationRecords>[number] | null {
|
|
13
14
|
const record = migrationRecord(id, dir);
|
|
@@ -27,7 +28,7 @@ export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
|
|
30
|
-
if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw
|
|
31
|
+
if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw editRefused();
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
|
|
@@ -98,8 +98,9 @@ export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Pro
|
|
|
98
98
|
try {
|
|
99
99
|
return await guardedRequest(input, init, guard, budget);
|
|
100
100
|
} catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const safe = orgRuntimeError(error);
|
|
102
|
+
if (init?.method !== 'DELETE' || safe.status !== 502) failure.error = safe;
|
|
103
|
+
throw safe;
|
|
103
104
|
}
|
|
104
105
|
};
|
|
105
106
|
const tools = await listRemoteTools(new URL(url), { kind: 'none' }, send);
|
package/runtime/runtime.json
CHANGED