@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308

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.
Files changed (64) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  35. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  40. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  42. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  43. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  44. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/runner.json +1 -1
  47. package/dist/background.js +0 -141
  48. package/dist/claude.js +0 -204
  49. package/dist/mcp-config.js +0 -27
  50. package/dist/native-recovery-scan.js +0 -246
  51. package/dist/native-recovery-state.js +0 -154
  52. package/dist/native-recovery.js +0 -164
  53. package/dist/onboarding.js +0 -44
  54. package/dist/voice.js +0 -56
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  57. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  58. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  59. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  60. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  61. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  62. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  63. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  64. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -1,164 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { homedir } from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { claudeDir, projectDir, resumeSessionId } from './background.js';
5
- import { scanNative } from './native-recovery-scan.js';
6
- import { nativeToken, NATIVE_NOTICE_MAX, NATIVE_TASK_MAX, readNativeState, saveNativeState, withNativeStateLock } from './native-recovery-state.js';
7
- const hash = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
8
- const taskId = (owner, source, tool) => hash(owner, source ?? '', tool);
9
- const RETRY_MS = [0, 60_000, 10 * 60_000];
10
- const PROMPT_NOTICES = 8;
11
- const taskReason = (task) => task.state === 'started' ? 'interrupted' : task.state === 'failed' ? 'failed' : task.state === 'restarted' ? 'restarted' : null;
12
- function agentResult(task, outcome) {
13
- if (nativeToken(outcome.agentId))
14
- task.agent = outcome.agentId;
15
- if (outcome.status === 'completed')
16
- task.state = 'completed';
17
- else if (outcome.status === 'async_launched')
18
- task.state = 'started';
19
- }
20
- function messageResult(task, outcome) {
21
- if (outcome.success === true && nativeToken(outcome.resumedAgentId)) {
22
- task.agent = outcome.resumedAgentId;
23
- task.state = 'started';
24
- }
25
- else if (outcome.success === true && outcome.resumedAgentId === undefined)
26
- task.state = 'completed';
27
- else if (outcome.success === false)
28
- task.state = 'failed';
29
- }
30
- function result(task, event) {
31
- if (event.at < task.updatedAt || event.receipt === task.receipt)
32
- return;
33
- if (event.failed)
34
- task.state = 'failed';
35
- else if (event.outcome !== null) {
36
- if (task.kind === 'Agent')
37
- agentResult(task, event.outcome);
38
- else
39
- messageResult(task, event.outcome);
40
- }
41
- task.receipt = event.receipt;
42
- task.updatedAt = event.at;
43
- }
44
- function acknowledge(state, event) {
45
- const notice = state.notices.find((row) => row.id === event.id);
46
- if (notice !== undefined && notice.deliveredAt !== null && event.at >= notice.deliveredAt)
47
- notice.acknowledgedAt = event.at;
48
- }
49
- function notification(state, event) {
50
- const candidates = state.tasks.filter((row) => row.source === event.source && row.at <= event.at
51
- && (event.tool === null ? row.agent === event.agent : row.tool === event.tool && (row.agent === null || row.agent === event.agent)));
52
- if (candidates.length > 1)
53
- return false;
54
- const task = candidates[0];
55
- if (task !== undefined && event.at >= task.updatedAt) {
56
- task.agent = event.agent;
57
- task.state = event.state;
58
- task.receipt = event.receipt;
59
- task.updatedAt = event.at;
60
- }
61
- return true;
62
- }
63
- function applyEvent(state, event) {
64
- if (event.type === 'ack') {
65
- acknowledge(state, event);
66
- return true;
67
- }
68
- if (event.type === 'notification')
69
- return notification(state, event);
70
- const id = taskId(state.owner, event.source, event.tool);
71
- const task = state.tasks.find((row) => row.id === id);
72
- if (event.type === 'result') {
73
- if (task !== undefined)
74
- result(task, event);
75
- return true;
76
- }
77
- if (task !== undefined)
78
- return true;
79
- if (state.tasks.length >= NATIVE_TASK_MAX)
80
- prune(state);
81
- if (state.tasks.length >= NATIVE_TASK_MAX)
82
- return false;
83
- state.tasks.push({ id, source: event.source, tool: event.tool, kind: event.kind, agent: null, at: event.at, updatedAt: event.at, state: 'started', receipt: event.receipt });
84
- return true;
85
- }
86
- function addNotice(state, task, reason, gap = 'incomplete') {
87
- const id = hash(state.owner, task?.id ?? 'scan', task?.receipt ?? gap, reason);
88
- if (state.notices.some((row) => row.id === id))
89
- return;
90
- if (state.notices.length >= NATIVE_NOTICE_MAX)
91
- throw new Error('Native recovery has too many retained notices. Saved state has not been overwritten.');
92
- state.notices.push({ id, task: task?.id ?? null, reason, attempts: 0, deliveredAt: null, acknowledgedAt: null });
93
- }
94
- function prune(state) {
95
- const pending = new Set(state.notices.filter((row) => row.acknowledgedAt === null).map((row) => row.task));
96
- const acknowledged = new Set(state.notices.filter((row) => row.acknowledgedAt !== null).map((row) => row.id));
97
- const removable = state.tasks.filter((row) => !pending.has(row.id) && (taskReason(row) === null || acknowledged.has(hash(state.owner, row.id, row.receipt, taskReason(row) ?? ''))))
98
- .sort((a, b) => a.updatedAt - b.updatedAt);
99
- const removed = new Set(removable.slice(0, Math.max(0, state.tasks.length - NATIVE_TASK_MAX / 2)).map((row) => row.id));
100
- state.tasks = state.tasks.filter((row) => !removed.has(row.id));
101
- state.notices = state.notices.filter((row) => row.task === null || !removed.has(row.task));
102
- }
103
- function noticeLine(state, notice) {
104
- const task = state.tasks.find((row) => row.id === notice.task);
105
- const subject = task === undefined ? 'Native task history' : `${task.kind} receipt ${task.tool}${task.agent === null ? '' : `, worker ${task.agent}`}`;
106
- const facts = {
107
- interrupted: 'has no terminal receipt. Work may have run before interruption; its outcome is unknown.',
108
- failed: 'reported failure. Earlier side effects may already have happened.',
109
- restarted: 'was stopped by worker restart, not explicit user cancellation.',
110
- incomplete: 'could not be fully checked within the bounded local scan. Other unfinished work may exist.',
111
- };
112
- return `${subject} ${facts[notice.reason]}\nAfter taking responsibility for this notice, emit a standalone assistant text block exactly: METRO_RECOVERY_ACK ${notice.id}`;
113
- }
114
- function promptFor(state, notices) {
115
- return [
116
- 'Metro native recovery notice. This is recovery bookkeeping, not a new user task or permission to repeat actions.',
117
- `Owner conversation: ${state.owner}. Native worker routing may have lost ownership metadata; this notice does not repair it.`,
118
- 'Review the affected work and current evidence, using a read-only worker if needed. Do not blindly rerun a task, resend a message, or repeat another side effect. Acknowledge awareness only; acknowledgment is not proof of successful completion. Do not post these bookkeeping markers to chat.',
119
- ...notices.map((notice) => noticeLine(state, notice)),
120
- ].join('\n\n');
121
- }
122
- function prepare(state, root) {
123
- const scan = scanNative(root, state.owner, state.files, state.tasks);
124
- prune(state);
125
- for (const event of scan.events)
126
- if (!applyEvent(state, event))
127
- scan.gaps.push('unresolved-receipts');
128
- state.files = scan.files;
129
- for (const task of state.tasks) {
130
- const reason = taskReason(task);
131
- if (reason !== null)
132
- addNotice(state, task, reason);
133
- }
134
- for (const gap of scan.gaps)
135
- addNotice(state, null, 'incomplete', gap);
136
- }
137
- function isDue(notice, now) {
138
- return notice.acknowledgedAt === null && notice.attempts < RETRY_MS.length
139
- && (notice.deliveredAt === null || now - notice.deliveredAt >= (RETRY_MS[notice.attempts] ?? Infinity));
140
- }
141
- function locations(owner, deps) {
142
- const env = deps.env ?? process.env;
143
- const given = env.HOME?.trim();
144
- const home = resolve(given === '' ? homedir() : given ?? homedir());
145
- return { path: join(home, '.metro', 'native-recovery', `${owner}.json`), root: projectDir(claudeDir(env), deps.cwd ?? process.cwd()) };
146
- }
147
- export function nativeRecoveryArgs(extra, deps = {}) {
148
- const owner = resumeSessionId(extra);
149
- if (extra.length !== 2 || owner === undefined || (extra[0] !== '--resume' && extra[0] !== '-r'))
150
- return extra;
151
- const { path, root } = locations(owner, deps);
152
- return withNativeStateLock(path, () => {
153
- const state = readNativeState(path, owner);
154
- prepare(state, root);
155
- const now = deps.now ?? Date.now();
156
- const due = state.notices.filter((row) => isDue(row, now)).slice(0, PROMPT_NOTICES);
157
- for (const notice of due) {
158
- notice.attempts += 1;
159
- notice.deliveredAt = now;
160
- }
161
- saveNativeState(path, state);
162
- return due.length === 0 ? extra : [...extra, promptFor(state, due)];
163
- });
164
- }
@@ -1,44 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- const FILE = '.claude.json';
5
- const FLAG = 'hasCompletedOnboarding';
6
- const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
7
- export function claudeConfigPath(env = process.env) {
8
- return join(given(env.CLAUDE_CONFIG_DIR) ?? given(env.HOME) ?? homedir(), FILE);
9
- }
10
- function readConfig(path) {
11
- if (!existsSync(path))
12
- return {};
13
- try {
14
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
15
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
16
- }
17
- catch {
18
- return null;
19
- }
20
- }
21
- export function markOnboardingDone(path = claudeConfigPath()) {
22
- const config = readConfig(path);
23
- if (config === null)
24
- return 'unreadable';
25
- if (config[FLAG] === true)
26
- return 'already';
27
- writeFileSync(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, { mode: 0o600 });
28
- return 'marked';
29
- }
30
- const FEATURES = 'cachedGrowthBookFeatures';
31
- const CHANNEL_FLAGS = ['tengu_harbor', 'tengu_harbor_permissions'];
32
- const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
33
- export function seedChannels(path = claudeConfigPath()) {
34
- const config = readConfig(path);
35
- if (config === null)
36
- return 'unreadable';
37
- const features = isRecord(config[FEATURES]) ? config[FEATURES] : {};
38
- if (CHANNEL_FLAGS.every((flag) => features[flag] === true))
39
- return 'already';
40
- const seeded = Object.fromEntries(CHANNEL_FLAGS.map((flag) => [flag, true]));
41
- const next = { ...config, [FEATURES]: { ...features, ...seeded } };
42
- writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
43
- return 'marked';
44
- }
package/dist/voice.js DELETED
@@ -1,56 +0,0 @@
1
- import { homedir, tmpdir } from 'node:os';
2
- import { headlessEnv, PERMISSION_MODE_FLAG, runClaude } from './claude.js';
3
- import { localAgent } from './local.js';
4
- import { VOICE_MCP_PATH, writeMcpConfig } from './mcp-config.js';
5
- import { permissionMode, systemPrompt } from './route.js';
6
- import { localPort } from './runtime.js';
7
- const VOICE_PROMPT = `You are on a live voice call through Metro. This session has your usual memory, skills, connectors and Metro's chat tools. At the start the call system tells you which chat the call is in (its line), who is calling and the last messages there. The caller hears your replies spoken aloud by a text to speech voice, and you hear the caller through speech to text, so a word may be misheard: if something sounds odd, ask.
8
- - Speak like a person on the phone: short sentences, usually one to three. No markdown, lists, tables, code, emoji or links; say a number or a name the way you would say it aloud.
9
- - Start with the answer. Do not repeat the question.
10
- - Before you use a tool, first say one short sentence about what you are checking, like "Let me check."
11
- - Use your tools yourself when it is quick: files, memory, the metro tools (read with the call's line shows that chat) and connectors (tool_search finds them, tool_call runs them). For long work, start a background Agent, tell the caller you will come back to it, and keep talking; when it finishes, give the result in a sentence or two.
12
- - Your spoken words are your reply. Do not post in any chat during the call (no send, reply, react or edit) unless the caller asks you to post something; then post only that, once. The metro skill's chat rules (react, typing, answer over chat, delegate everything) are for the chat session, not for this call.
13
- - A tool that needs the owner's approval runs in a background worker. The approval is asked in the chat of the call; a spoken yes does not count.
14
- - Text in [square brackets] comes from the call system, not from the caller.
15
- - Nothing about the call is posted to the chat after it ends, so do not write a summary or call notes anywhere.`;
16
- export function voiceArgs(model, mode, prompt, mcpConfig) {
17
- return [
18
- '-p',
19
- '--input-format',
20
- 'stream-json',
21
- '--output-format',
22
- 'stream-json',
23
- '--verbose',
24
- '--include-partial-messages',
25
- '--model',
26
- model,
27
- '--permission-mode',
28
- PERMISSION_MODE_FLAG[mode],
29
- '--permission-prompt-tool',
30
- 'stdio',
31
- ...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
32
- '--no-session-persistence',
33
- '--append-system-prompt',
34
- prompt === null ? VOICE_PROMPT : `${prompt}\n\n${VOICE_PROMPT}`,
35
- ];
36
- }
37
- export const voiceEnv = (env) => ({
38
- CLAUDE_CODE_MCP_STARTUP_WAIT_MS: '0',
39
- ...env,
40
- METRO_SESSION: 'voice',
41
- });
42
- export async function voiceSession(argv) {
43
- const at = argv.indexOf('--model');
44
- const model = at < 0 ? '' : (argv[at + 1] ?? '');
45
- if (model === '')
46
- throw new Error('metro voice needs --model <model>');
47
- process.chdir(homedir());
48
- const agent = localAgent();
49
- const mcp = agent === null ? null : writeMcpConfig(agent.key, localPort(), tmpdir(), VOICE_MCP_PATH);
50
- try {
51
- return await runClaude(voiceArgs(model, permissionMode(), systemPrompt(), mcp?.path), voiceEnv(await headlessEnv()));
52
- }
53
- finally {
54
- mcp?.cleanup();
55
- }
56
- }
@@ -1,56 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
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
- }
@@ -1,121 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
- import { errMsg, log } from '@metro-labs/core/log';
6
- import { existsSync } from '../agent-user/agent-fs.js';
7
- import { removeHome } from '../agent-user/home-fs.js';
8
- import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
9
- import { agentsDir } from '../agents/files.js';
10
- import { readSetupState, writeSetupState } from './setup-state.js';
11
-
12
- const PLUGIN = 'metro@metro';
13
- const MARKETPLACE = 'metro';
14
- const DONE = 'pluginRemoved';
15
- const STEP_MS = 60_000;
16
- const SAID_MAX = 300;
17
-
18
- export interface Answer {
19
- status: number;
20
- stdout: string;
21
- stderr: string;
22
- }
23
-
24
- export type Run = (args: string[]) => Promise<Answer>;
25
-
26
- export interface CleanupDeps {
27
- run?: Run | null;
28
- agents?: string;
29
- leftover?: string | null;
30
- }
31
-
32
- export type CleanupOutcome = 'done' | 'clean' | 'removed' | 'skipped' | 'failed';
33
-
34
- export const claudeAt =
35
- (bin: string): Run =>
36
- (args) =>
37
- new Promise((resolve) => {
38
- execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
39
- const code = (err as { code?: unknown } | null)?.code;
40
- resolve({ status: err === null ? 0 : typeof code === 'number' ? code : 1, stdout, stderr });
41
- });
42
- });
43
-
44
- async function findClaude(): Promise<Run | null> {
45
- const candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
46
- for (const bin of candidates) {
47
- const run = claudeAt(bin);
48
- if ((await run(['--version'])).status === 0) return run;
49
- }
50
- return null;
51
- }
52
-
53
- function listOf(stdout: string): Record<string, unknown>[] {
54
- try {
55
- const parsed: unknown = JSON.parse(stdout);
56
- return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
57
- } catch {
58
- return [];
59
- }
60
- }
61
-
62
- async function listed(run: Run, args: string[], key: string, wanted: string): Promise<boolean | null> {
63
- const answer = await run(args);
64
- if (answer.status !== 0) {
65
- log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude could not list');
66
- return null;
67
- }
68
- return listOf(answer.stdout).some((entry) => entry[key] === wanted);
69
- }
70
-
71
- async function step(run: Run, args: string[]): Promise<boolean> {
72
- const answer = await run(args);
73
- if (answer.status === 0) {
74
- log.info({ args }, 'claude-plugin: removed what the old metro plugin left');
75
- return true;
76
- }
77
- log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude refused');
78
- return false;
79
- }
80
-
81
- async function removeIfListed(run: Run, list: string[], key: string, wanted: string, remove: string[]): Promise<'gone' | 'removed' | 'failed'> {
82
- const present = await listed(run, list, key, wanted);
83
- if (present === null) return 'failed';
84
- if (!present) return 'gone';
85
- return (await step(run, remove)) ? 'removed' : 'failed';
86
- }
87
-
88
- function leftoverDir(): string | null {
89
- const user = agentUser();
90
- return user === null ? null : join(user.home, '.metro', 'marketplace');
91
- }
92
-
93
- function removeLeftover(dir: string | null): boolean {
94
- if (dir === null || !existsSync(dir)) return true;
95
- try {
96
- removeHome(dir, true);
97
- log.info({ dir }, 'claude-plugin: removed the old plugin marketplace copy');
98
- return true;
99
- } catch (err) {
100
- log.warn({ dir, err: errMsg(err) }, 'claude-plugin: could not remove the old plugin marketplace copy');
101
- return false;
102
- }
103
- }
104
-
105
- async function cleanUp(run: Run, leftover: string | null): Promise<'removed' | 'clean' | 'failed'> {
106
- const plugin = await removeIfListed(run, ['plugin', 'list', '--json'], 'id', PLUGIN, ['plugin', 'uninstall', PLUGIN]);
107
- const market = await removeIfListed(run, ['plugin', 'marketplace', 'list', '--json'], 'name', MARKETPLACE, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
108
- const folder = removeLeftover(leftover);
109
- if (plugin === 'failed' || market === 'failed' || !folder) return 'failed';
110
- return plugin === 'removed' || market === 'removed' ? 'removed' : 'clean';
111
- }
112
-
113
- export async function removeMetroPlugin(deps: CleanupDeps = {}): Promise<CleanupOutcome> {
114
- const agents = deps.agents ?? agentsDir();
115
- if (readSetupState(agents)[DONE] === true) return 'done';
116
- const run = deps.run === undefined ? await findClaude() : deps.run;
117
- if (run === null) return 'skipped';
118
- const outcome = await cleanUp(run, deps.leftover === undefined ? leftoverDir() : deps.leftover);
119
- if (outcome !== 'failed') writeSetupState(agents, { [DONE]: true });
120
- return outcome;
121
- }
@@ -1,45 +0,0 @@
1
- import { agentsDir } from '../agents/files.js';
2
- import type { CodexMethod } from '../gateway/codex-auth.js';
3
- import { connectionOf, readModelConfig, routedConnection, type Connection, type ModelConfig } from '../gateway/model-config.js';
4
- import { readSetupState, writeSetupState } from './setup-state.js';
5
-
6
- const RUNNERS = ['cli', 'sdk'] as const;
7
- export type HarnessRunner = (typeof RUNNERS)[number];
8
-
9
- export const SDK_NEEDS_KEY =
10
- 'The Agent SDK runner needs an API key or a ChatGPT sign-in on every model the Model page lists, the first one and each fallback: use Anthropic with an API key, Amazon Bedrock, OpenRouter, or a signed-in Codex connection (with ChatGPT or with a code). Anthropic does not allow products built on the Agent SDK to use a Claude login, so on a login it runs only where the Metro operator allows it.';
11
-
12
- export const isHarnessRunner = (value: unknown): value is HarnessRunner => RUNNERS.some((r) => r === value);
13
-
14
- export function harnessRunner(agents = agentsDir()): HarnessRunner {
15
- const runner = readSetupState(agents).runner;
16
- return isHarnessRunner(runner) ? runner : 'cli';
17
- }
18
-
19
- export function setHarnessRunner(runner: HarnessRunner, agents = agentsDir()): void {
20
- writeSetupState(agents, { runner });
21
- }
22
-
23
- export const sdkOnLogin = (agents = agentsDir()): boolean => readSetupState(agents).sdkOnLogin === true;
24
-
25
- export function setSdkOnLogin(on: boolean, agents = agentsDir()): void {
26
- writeSetupState(agents, { sdkOnLogin: on });
27
- }
28
-
29
- const SDK_ON_CODEX: Record<CodexMethod, boolean> = { chatgpt: true, code: true };
30
-
31
- const permitted = (conn: Connection | null): boolean => conn !== null && (conn.apiKey !== '' || (conn.codex !== null && SDK_ON_CODEX[conn.codex.method]));
32
-
33
- const servingConnections = (cfg: ModelConfig): (Connection | null)[] => [routedConnection(cfg), ...(cfg.fallbacks ?? []).map((f) => connectionOf(cfg, f.connection))];
34
-
35
- const permittedRoute = (cfg: ModelConfig): boolean => servingConnections(cfg).every(permitted);
36
-
37
- export const sdkAllowed = (agents = agentsDir(), cfg: ModelConfig = readModelConfig(agents), loginAllowed?: boolean): boolean => permittedRoute(cfg) || (loginAllowed ?? sdkOnLogin(agents));
38
-
39
- export const sdkConnectionAllowed = (connection: Connection, agents = agentsDir()): boolean => permitted(connection) || sdkOnLogin(agents);
40
-
41
- export function runnerModel(cfg: ModelConfig): string | null {
42
- const conn = routedConnection(cfg);
43
- if (conn === null || conn.model === '') return null;
44
- return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
45
- }
@@ -1,57 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { join } from 'node:path';
3
- import { isRecord } from '@metro-labs/core/is-record';
4
- import { readJson, writeJson } from '@metro-labs/core/secure-fs';
5
- import { agentsDir } from '../agents/files.js';
6
- import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
7
- import { claudeDir } from './files.js';
8
-
9
- const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
10
- const sessionId = (value: unknown): string | null => typeof value === 'string' && SESSION_ID_RE.test(value) ? value : null;
11
-
12
- function sdkSavedSession(home: string): Record<string, unknown> | null {
13
- try {
14
- const raw: unknown = JSON.parse(readFileSync(join(home, '.metro', 'agent-session.json'), 'utf8'));
15
- return isRecord(raw) ? raw : null;
16
- } catch (err) {
17
- return isRecord(err) && err.code === 'ENOENT' ? {} : null;
18
- }
19
- }
20
-
21
- const inputState = (value: unknown): boolean => value === undefined || value === 'queued' || value === 'started';
22
-
23
- function sdkInput(value: unknown): boolean {
24
- if (!isRecord(value)) return false;
25
- return typeof value.text === 'string' && typeof value.at === 'number' && Number.isFinite(value.at) && (value.uuid === undefined || sessionId(value.uuid) !== null) && inputState(value.state);
26
- }
27
-
28
- const sdkInputs = (value: unknown): value is unknown[] | undefined => value === undefined || (Array.isArray(value) && value.every(sdkInput));
29
-
30
- export function sdkPending(home: string): number | null {
31
- const state = sdkSavedSession(home);
32
- if (state === null) return null;
33
- if (state.sessionId !== undefined && state.sessionId !== null && sessionId(state.sessionId) === null) return null;
34
- if (!sdkInputs(state.unanswered) || !sdkInputs(state.interrupted)) return null;
35
- return state.unanswered?.length ?? 0;
36
- }
37
-
38
- function projectFolder(home: string, dir: string): string {
39
- let cwd = home;
40
- try {
41
- cwd = realpathSync(home);
42
- } catch {
43
- cwd = home;
44
- }
45
- return join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
46
- }
47
-
48
- export function continueArgs(home: string, dir = claudeDir(), agents = agentsDir()): string[] {
49
- const path = join(agents, 'claude-session.json');
50
- const raw = readJson<unknown>(path, null);
51
- const state = isRecord(raw) ? raw : {};
52
- const project = projectFolder(home, dir);
53
- const pinned = sessionId(state.cliSessionId);
54
- const id = pinned ?? randomUUID();
55
- if (pinned === null) writeJson(path, { ...state, cliSessionId: id });
56
- return [existsSync(join(project, `${id}.jsonl`)) ? '--resume' : '--session-id', id];
57
- }
@@ -1,76 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { ApiError } from '@metro-labs/http/api-error';
5
- import { errMsg, log } from '@metro-labs/core/log';
6
- import { sessionRunning, stopSession, type SessionDeps } from './session.js';
7
- import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
8
- import { CLAUDE_CODE_VERSION, installClaudeCode } from './pinned.js';
9
-
10
- const VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
11
-
12
- export interface VersionDeps {
13
- claude?: string;
14
- session?: SessionDeps;
15
- }
16
-
17
- export interface ClaudeVersion {
18
- installed: string | null;
19
- latest: string;
20
- newer: boolean;
21
- }
22
-
23
- function candidates(deps: VersionDeps): string[] {
24
- if (deps.claude !== undefined) return [deps.claude];
25
- return agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
26
- }
27
-
28
- const parse = (text: string): string | null => {
29
- const m = VERSION_RE.exec(text);
30
- return m === null ? null : `${m[1] ?? ''}.${m[2] ?? ''}.${m[3] ?? ''}`;
31
- };
32
-
33
- function probe(bin: string): string | null {
34
- const run = spawnSync(...asAgent(bin, ['--version']), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
35
- return run.error === undefined && run.status === 0 && typeof run.stdout === 'string' ? parse(run.stdout) : null;
36
- }
37
-
38
- function located(deps: VersionDeps): { bin: string; installed: string } | null {
39
- for (const bin of candidates(deps)) {
40
- const installed = probe(bin);
41
- if (installed !== null) return { bin, installed };
42
- }
43
- return null;
44
- }
45
-
46
- export function claudeVersion(deps: VersionDeps = {}): ClaudeVersion {
47
- const installed = located(deps)?.installed ?? null;
48
- return { installed, latest: CLAUDE_CODE_VERSION, newer: installed !== null && installed !== CLAUDE_CODE_VERSION };
49
- }
50
-
51
- function restartIfChanged(changed: boolean, session: SessionDeps): boolean {
52
- if (!changed || !sessionRunning(session.tmux ?? 'tmux')) return false;
53
- stopSession(session);
54
- return true;
55
- }
56
-
57
- export function updateClaude(deps: VersionDeps = {}): ClaudeVersion & { restarted: boolean } {
58
- const found = located(deps);
59
- if (found === null) throw new ApiError('Claude Code is not installed on this machine', 404);
60
- try {
61
- installClaudeCode(found.bin);
62
- } catch (err) {
63
- throw new ApiError(errMsg(err), 502);
64
- }
65
- const after = claudeVersion(deps);
66
- const restarted = restartIfChanged(after.installed !== null && after.installed !== found.installed, deps.session ?? {});
67
- log.info({ from: found.installed, to: after.installed, restarted }, 'claude-version: Claude Code updated');
68
- return { ...after, restarted };
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
- }