@stage-labs/metro 0.1.0-beta.307 → 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.
- package/dist/agent.js +23 -28
- package/dist/child.js +33 -0
- package/dist/claude-env.js +104 -0
- package/dist/cli.js +7 -21
- package/dist/lock.js +62 -0
- package/dist/memory.js +12 -27
- package/dist/root-release-io.js +1 -2
- package/dist/root-release.js +1 -1
- package/dist/route.js +0 -1
- package/hooks/guard.mjs +1 -5
- package/hooks/session-start.mjs +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/dist/background.js +0 -141
- package/dist/claude.js +0 -204
- package/dist/mcp-config.js +0 -27
- package/dist/native-recovery-scan.js +0 -246
- package/dist/native-recovery-state.js +0 -154
- package/dist/native-recovery.js +0 -164
- package/dist/onboarding.js +0 -44
- package/dist/voice.js +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
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
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { approvalId } from '@metro-labs/core/ids';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { forgetPromptsOf, holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
4
|
-
import { promptBody } from '../mcp/permission-prompt.js';
|
|
5
|
-
import type { ToolAsk } from './brain.js';
|
|
6
|
-
|
|
7
|
-
export class CallApprovals {
|
|
8
|
-
private closed = false;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
private readonly line: string,
|
|
12
|
-
private readonly post: (text: string) => Promise<void>,
|
|
13
|
-
) {}
|
|
14
|
-
|
|
15
|
-
ask(ask: ToolAsk, answer: (behavior: Behavior) => void): string | null {
|
|
16
|
-
if (this.closed) return null;
|
|
17
|
-
const id = approvalId();
|
|
18
|
-
const preview = JSON.stringify(ask.input);
|
|
19
|
-
holdPrompt(
|
|
20
|
-
{ requestId: id, tool: ask.tool, description: ask.description, preview, line: this.line, at: Date.now() },
|
|
21
|
-
this,
|
|
22
|
-
(behavior) => {
|
|
23
|
-
answer(behavior);
|
|
24
|
-
return Promise.resolve();
|
|
25
|
-
},
|
|
26
|
-
this.post,
|
|
27
|
-
);
|
|
28
|
-
this.post(promptBody({ request_id: id, tool_name: ask.tool, description: ask.description, input_preview: preview })).catch((err: unknown) => {
|
|
29
|
-
log.warn({ err: errMsg(err) }, 'voice: could not ask for an approval in the chat of the call');
|
|
30
|
-
});
|
|
31
|
-
return id;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
close(): void {
|
|
35
|
-
this.closed = true;
|
|
36
|
-
forgetPromptsOf(this);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { drainLines } from '@metro-labs/core/trains/protocol';
|
|
5
|
-
import { asAgent } from '../agent-user/user.js';
|
|
6
|
-
import { metroCli } from '../claude/session.js';
|
|
7
|
-
|
|
8
|
-
const CLOSE_GRACE_MS = 10_000;
|
|
9
|
-
const DENIED = 'The owner did not approve this.';
|
|
10
|
-
|
|
11
|
-
export interface ToolAsk {
|
|
12
|
-
requestId: string;
|
|
13
|
-
tool: string;
|
|
14
|
-
input: Record<string, unknown>;
|
|
15
|
-
description: string;
|
|
16
|
-
fromWorker: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface Thinking {
|
|
20
|
-
turnStarted(): void;
|
|
21
|
-
text(delta: string): void;
|
|
22
|
-
tool(name: string): void;
|
|
23
|
-
turnEnded(): void;
|
|
24
|
-
asked(ask: ToolAsk): void;
|
|
25
|
-
exited(reason: string): void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const record = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
|
29
|
-
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
30
|
-
|
|
31
|
-
export function toolAsk(msg: Record<string, unknown>): ToolAsk | null {
|
|
32
|
-
const request = record(msg.request);
|
|
33
|
-
const requestId = str(msg.request_id);
|
|
34
|
-
if (request.subtype !== 'can_use_tool' || requestId === '' || str(request.tool_name) === '') return null;
|
|
35
|
-
return {
|
|
36
|
-
requestId,
|
|
37
|
-
tool: str(request.tool_name),
|
|
38
|
-
input: record(request.input),
|
|
39
|
-
description: str(request.description) || str(request.decision_reason),
|
|
40
|
-
fromWorker: str(request.agent_id) !== '',
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function streamEvent(msg: Record<string, unknown>, mind: Thinking): void {
|
|
45
|
-
if (msg.parent_tool_use_id !== null && msg.parent_tool_use_id !== undefined) return;
|
|
46
|
-
const event = record(msg.event);
|
|
47
|
-
const delta = record(event.delta);
|
|
48
|
-
if (event.type === 'content_block_delta' && delta.type === 'text_delta' && typeof delta.text === 'string') mind.text(delta.text);
|
|
49
|
-
const block = record(event.content_block);
|
|
50
|
-
if (event.type === 'content_block_start' && block.type === 'tool_use') mind.tool(typeof block.name === 'string' ? block.name : 'tool');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function startBrain(model: string): ChildProcessWithoutNullStreams {
|
|
54
|
-
const [command = 'metro', ...args] = metroCli(['voice', '--model', model]);
|
|
55
|
-
const [file, argv] = asAgent(command, args);
|
|
56
|
-
return spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export class Brain {
|
|
60
|
-
private readonly child: ChildProcessWithoutNullStreams;
|
|
61
|
-
private out = '';
|
|
62
|
-
private gone = false;
|
|
63
|
-
private closing = false;
|
|
64
|
-
private killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
-
|
|
66
|
-
constructor(model: string, private readonly mind: Thinking, launch = startBrain) {
|
|
67
|
-
this.child = launch(model);
|
|
68
|
-
this.child.stdout.setEncoding('utf8');
|
|
69
|
-
this.child.stdout.on('data', (chunk: string) => {
|
|
70
|
-
this.out = drainLines('voice-brain', this.out + chunk, (line) => {
|
|
71
|
-
this.read(line);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
this.child.stderr.setEncoding('utf8');
|
|
75
|
-
this.child.stderr.on('data', (chunk: string) => {
|
|
76
|
-
log.debug({ text: chunk.slice(0, 500) }, 'voice: brain stderr');
|
|
77
|
-
});
|
|
78
|
-
this.child.stdin.on('error', (err) => {
|
|
79
|
-
log.debug({ err: errMsg(err) }, 'voice: brain stdin closed');
|
|
80
|
-
});
|
|
81
|
-
this.child.on('error', (err) => {
|
|
82
|
-
this.exit(`could not start: ${errMsg(err)}`);
|
|
83
|
-
});
|
|
84
|
-
this.child.on('exit', (code, signal) => {
|
|
85
|
-
this.exit(`exited (${String(code ?? signal)})`);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
tell(text: string, now = false): void {
|
|
90
|
-
const message = { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '' };
|
|
91
|
-
this.write(now ? { ...message, priority: 'now' } : message);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
answer(ask: ToolAsk, behavior: 'allow' | 'deny'): void {
|
|
95
|
-
const response = behavior === 'allow' ? { behavior, updatedInput: ask.input } : { behavior, message: DENIED };
|
|
96
|
-
this.write({ type: 'control_response', response: { subtype: 'success', request_id: ask.requestId, response } });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
close(): void {
|
|
100
|
-
if (this.gone || this.closing) return;
|
|
101
|
-
this.closing = true;
|
|
102
|
-
this.child.stdin.end();
|
|
103
|
-
this.child.kill('SIGTERM');
|
|
104
|
-
this.killTimer = setTimeout(() => {
|
|
105
|
-
if (!this.gone) this.child.kill('SIGKILL');
|
|
106
|
-
}, CLOSE_GRACE_MS);
|
|
107
|
-
this.killTimer.unref();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private write(message: Record<string, unknown>): void {
|
|
111
|
-
if (!this.gone && !this.closing && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private read(line: string): void {
|
|
115
|
-
if (this.closing || this.gone) return;
|
|
116
|
-
let parsed: unknown;
|
|
117
|
-
try {
|
|
118
|
-
parsed = JSON.parse(line);
|
|
119
|
-
} catch {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const msg = record(parsed);
|
|
123
|
-
if (msg.type === 'system' && msg.subtype === 'init') this.mind.turnStarted();
|
|
124
|
-
else if (msg.type === 'stream_event') streamEvent(msg, this.mind);
|
|
125
|
-
else if (msg.type === 'result') this.mind.turnEnded();
|
|
126
|
-
else if (msg.type === 'control_request') this.control(msg);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private control(msg: Record<string, unknown>): void {
|
|
130
|
-
const ask = toolAsk(msg);
|
|
131
|
-
if (ask !== null) {
|
|
132
|
-
this.mind.asked(ask);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const subtype = str(record(msg.request).subtype);
|
|
136
|
-
log.warn({ subtype }, 'voice: the agent session asked for something the call does not handle');
|
|
137
|
-
this.write({ type: 'control_response', response: { subtype: 'error', request_id: str(msg.request_id), error: `not supported on a voice call: ${subtype}` } });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private exit(reason: string): void {
|
|
141
|
-
if (this.gone) return;
|
|
142
|
-
this.gone = true;
|
|
143
|
-
clearTimeout(this.killTimer);
|
|
144
|
-
if (!this.closing) this.mind.exited(reason);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
const FIRST_CLAUSE_MIN = 24;
|
|
2
|
-
const LONGEST = 220;
|
|
3
|
-
const SENTENCE_END = /[.!?…]+["')\]]*\s+|\n+/g;
|
|
4
|
-
const CLAUSE_END = /[,;:]\s+/g;
|
|
5
|
-
|
|
6
|
-
function speakable(text: string): string {
|
|
7
|
-
return text
|
|
8
|
-
.replace(/https?:\/\/\S+/g, 'the link')
|
|
9
|
-
.replace(/`{1,3}[^`]*`{1,3}/g, (code) => code.replace(/`/g, ''))
|
|
10
|
-
.replace(/^\s*(?:#+|[-*•]|\d+\.)\s+/gm, '')
|
|
11
|
-
.replace(/[*_~#|<>[\]{}]/g, '')
|
|
12
|
-
.replace(/\s+/g, ' ')
|
|
13
|
-
.trim();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function lastEnd(text: string, pattern: RegExp): number {
|
|
17
|
-
let end = -1;
|
|
18
|
-
for (const match of text.matchAll(pattern)) end = match.index + match[0].length;
|
|
19
|
-
return end;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function cutAt(text: string, first: boolean): number {
|
|
23
|
-
const sentence = lastEnd(text, SENTENCE_END);
|
|
24
|
-
if (sentence > 0) return sentence;
|
|
25
|
-
if (first && text.length >= FIRST_CLAUSE_MIN) {
|
|
26
|
-
const clause = lastEnd(text, CLAUSE_END);
|
|
27
|
-
if (clause > 0) return clause;
|
|
28
|
-
}
|
|
29
|
-
if (text.length < LONGEST) return -1;
|
|
30
|
-
const space = text.lastIndexOf(' ');
|
|
31
|
-
return space > 0 ? space + 1 : text.length;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class Chunker {
|
|
35
|
-
private buffer = '';
|
|
36
|
-
private first = true;
|
|
37
|
-
|
|
38
|
-
add(delta: string): string[] {
|
|
39
|
-
this.buffer += delta;
|
|
40
|
-
const at = cutAt(this.buffer, this.first);
|
|
41
|
-
if (at < 0) return [];
|
|
42
|
-
const ready = speakable(this.buffer.slice(0, at));
|
|
43
|
-
this.buffer = this.buffer.slice(at);
|
|
44
|
-
if (ready === '') return [];
|
|
45
|
-
this.first = false;
|
|
46
|
-
return [ready];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
flush(): string[] {
|
|
50
|
-
const rest = speakable(this.buffer);
|
|
51
|
-
this.buffer = '';
|
|
52
|
-
return rest === '' ? [] : [rest];
|
|
53
|
-
}
|
|
54
|
-
}
|