@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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 +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { readJsonBody, type ApiSession } from '@metro-labs/http/api-http';
|
|
6
|
+
import {
|
|
7
|
+
claudeSetupStatus,
|
|
8
|
+
ensureClaudeSetup,
|
|
9
|
+
isPermissionMode,
|
|
10
|
+
liveEvents,
|
|
11
|
+
permissionMode,
|
|
12
|
+
setLiveEvents,
|
|
13
|
+
setMemoryRoutine,
|
|
14
|
+
setPermissionMode,
|
|
15
|
+
setPrivacy,
|
|
16
|
+
setSystemPrompt,
|
|
17
|
+
systemPrompt,
|
|
18
|
+
SYSTEM_PROMPT_MAX,
|
|
19
|
+
type PermissionMode,
|
|
20
|
+
type SetupDeps,
|
|
21
|
+
} from './setup.js';
|
|
22
|
+
import { harnessRunner, isHarnessRunner, sdkAllowed, sdkOnLogin, SDK_NEEDS_KEY, setHarnessRunner, setSdkOnLogin, type HarnessRunner } from './runner.js';
|
|
23
|
+
import { assertSdkDrained, assertSessionStopped, prepareSessionRunner, setAutostart, withSessionChange, type SessionDeps } from './session.js';
|
|
24
|
+
import { tryMemoryJob } from './memory-routine.js';
|
|
25
|
+
|
|
26
|
+
const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
|
|
27
|
+
const OPERATOR_ONLY = 'only the Metro operator can allow the Agent SDK on a Claude login';
|
|
28
|
+
|
|
29
|
+
export interface SetupApiDeps {
|
|
30
|
+
session?: SessionDeps;
|
|
31
|
+
setup?: SetupDeps;
|
|
32
|
+
liveEvents?: (on: boolean) => void;
|
|
33
|
+
memoryJob?: (on: boolean) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SetupChange {
|
|
37
|
+
privacy?: boolean;
|
|
38
|
+
permissionMode?: PermissionMode;
|
|
39
|
+
runner?: HarnessRunner;
|
|
40
|
+
sdkOnLogin?: boolean;
|
|
41
|
+
systemPrompt?: string;
|
|
42
|
+
liveEvents?: boolean;
|
|
43
|
+
memoryRoutine?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function promptChange(raw: unknown): string | undefined {
|
|
47
|
+
if (raw === undefined) return undefined;
|
|
48
|
+
if (typeof raw !== 'string') throw new ApiError('systemPrompt must be text', 400);
|
|
49
|
+
if (Buffer.byteLength(raw) > SYSTEM_PROMPT_MAX) throw new ApiError(`systemPrompt must be under ${String(SYSTEM_PROMPT_MAX / 1024)} KiB`, 400);
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function flagChange(raw: unknown, name: string): boolean | undefined {
|
|
54
|
+
if (raw === undefined) return undefined;
|
|
55
|
+
if (typeof raw !== 'boolean') throw new ApiError(`${name} must be true or false`, 400);
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function modeChange(raw: unknown): PermissionMode | undefined {
|
|
60
|
+
if (raw === undefined) return undefined;
|
|
61
|
+
if (!isPermissionMode(raw)) throw new ApiError('permissionMode must be auto or bypass', 400);
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runnerChange(raw: unknown): HarnessRunner | undefined {
|
|
66
|
+
if (raw === undefined) return undefined;
|
|
67
|
+
if (!isHarnessRunner(raw)) throw new ApiError('runner must be cli or sdk', 400);
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setupChange(body: unknown): SetupChange {
|
|
72
|
+
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
73
|
+
const change: SetupChange = {
|
|
74
|
+
privacy: flagChange(body.privacy, 'privacy'),
|
|
75
|
+
permissionMode: modeChange(body.permissionMode),
|
|
76
|
+
runner: runnerChange(body.runner),
|
|
77
|
+
sdkOnLogin: flagChange(body.sdkOnLogin, 'sdkOnLogin'),
|
|
78
|
+
systemPrompt: promptChange(body.systemPrompt),
|
|
79
|
+
liveEvents: flagChange(body.liveEvents, 'liveEvents'),
|
|
80
|
+
memoryRoutine: flagChange(body.memoryRoutine, 'memoryRoutine'),
|
|
81
|
+
};
|
|
82
|
+
const given = Object.fromEntries(Object.entries(change).filter(([, value]) => value !== undefined)) as SetupChange;
|
|
83
|
+
if (Object.keys(given).length === 0) throw new ApiError('nothing to change', 400);
|
|
84
|
+
return given;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkRunnerChange(change: SetupChange, session: ApiSession, agents: string | undefined): void {
|
|
88
|
+
if (change.sdkOnLogin !== undefined && session.operator !== true) throw new ApiError(OPERATOR_ONLY, 403);
|
|
89
|
+
if (change.runner === 'sdk' && !sdkAllowed(agents, undefined, change.sdkOnLogin ?? sdkOnLogin(agents))) throw new ApiError(SDK_NEEDS_KEY, 403);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nextRunner(change: SetupChange, agents: string | undefined): HarnessRunner {
|
|
93
|
+
const runner = change.runner ?? harnessRunner(agents);
|
|
94
|
+
return runner === 'sdk' && change.sdkOnLogin === false && !sdkAllowed(agents, undefined, false) ? 'cli' : runner;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function changesSession(change: SetupChange, agents: string | undefined): boolean {
|
|
98
|
+
return nextRunner(change, agents) !== harnessRunner(agents)
|
|
99
|
+
|| (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
|
|
100
|
+
|| (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function saveSessionSettings(change: SetupChange, runner: HarnessRunner, agents: string | undefined): void {
|
|
104
|
+
if (change.sdkOnLogin !== undefined && change.sdkOnLogin !== sdkOnLogin(agents)) {
|
|
105
|
+
setSdkOnLogin(change.sdkOnLogin, agents);
|
|
106
|
+
log.info({ on: change.sdkOnLogin }, 'claude-setup: the operator changed whether the Agent SDK may run on this box’s Claude login');
|
|
107
|
+
}
|
|
108
|
+
if (runner !== harnessRunner(agents)) {
|
|
109
|
+
setAutostart(false, agents);
|
|
110
|
+
setHarnessRunner(runner, agents);
|
|
111
|
+
}
|
|
112
|
+
if (change.permissionMode !== undefined) setPermissionMode(change.permissionMode, agents);
|
|
113
|
+
if (change.systemPrompt !== undefined) setSystemPrompt(change.systemPrompt, agents);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkStoppedChange(change: SetupChange, deps: SetupApiDeps): HarnessRunner {
|
|
117
|
+
const agents = deps.setup?.agents;
|
|
118
|
+
const session = { ...deps.session, agents: deps.session?.agents ?? agents };
|
|
119
|
+
const runner = nextRunner(change, agents);
|
|
120
|
+
if (changesSession(change, agents)) assertSessionStopped(session);
|
|
121
|
+
if (runner !== harnessRunner(agents) && harnessRunner(agents) === 'sdk') assertSdkDrained(session);
|
|
122
|
+
return runner;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function applyLiveEvents(on: boolean, deps: SetupApiDeps): void {
|
|
126
|
+
const agents = deps.setup?.agents;
|
|
127
|
+
if (on !== liveEvents(agents)) setLiveEvents(on, agents);
|
|
128
|
+
deps.liveEvents?.(on);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applyMemoryRoutine(on: boolean, deps: SetupApiDeps): void {
|
|
132
|
+
setMemoryRoutine(on, deps.setup?.agents);
|
|
133
|
+
(deps.memoryJob ?? tryMemoryJob)(on);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function applySetupChange(change: SetupChange, session: ApiSession, deps: SetupApiDeps, req: IncomingMessage): Promise<void> {
|
|
137
|
+
const setup = deps.setup ?? {};
|
|
138
|
+
checkRunnerChange(change, session, setup.agents);
|
|
139
|
+
const runner = checkStoppedChange(change, deps);
|
|
140
|
+
if (runner !== harnessRunner(setup.agents)) {
|
|
141
|
+
await prepareSessionRunner(runner, deps.session);
|
|
142
|
+
checkRunnerChange(change, session, setup.agents);
|
|
143
|
+
if (checkStoppedChange(change, deps) !== runner) throw new ApiError('The model setup changed during preparation. Choose the runner again.', 409);
|
|
144
|
+
}
|
|
145
|
+
if (req.socket.destroyed) throw new ApiError('The request disconnected during preparation. The runner choice was not changed.', 409);
|
|
146
|
+
if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
|
|
147
|
+
if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
|
|
148
|
+
if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
|
|
149
|
+
saveSessionSettings(change, runner, setup.agents);
|
|
150
|
+
ensureClaudeSetup(setup);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps, session: ApiSession): Promise<unknown> {
|
|
154
|
+
const setup = deps.setup ?? {};
|
|
155
|
+
const method = req.method ?? 'GET';
|
|
156
|
+
if (method === 'GET') return claudeSetupStatus(setup);
|
|
157
|
+
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
158
|
+
const change = setupChange(await readJsonBody(req, SETUP_BODY_MAX));
|
|
159
|
+
await withSessionChange(() => applySetupChange(change, session, deps, req));
|
|
160
|
+
return claudeSetupStatus(setup);
|
|
161
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
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
|
+
|
|
6
|
+
const STATE_FILE = 'claude-setup.json';
|
|
7
|
+
|
|
8
|
+
const statePath = (agents: string): string => join(agents, STATE_FILE);
|
|
9
|
+
|
|
10
|
+
export function readSetupState(agents: string): Record<string, unknown> {
|
|
11
|
+
const raw = readJson<unknown>(statePath(agents), null);
|
|
12
|
+
return isRecord(raw) ? raw : {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeSetupState(agents: string, patch: Record<string, unknown>): void {
|
|
16
|
+
mkdirSync(agents, { recursive: true });
|
|
17
|
+
writeJson(statePath(agents), { ...readSetupState(agents), ...patch });
|
|
18
|
+
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
3
|
import { existsSync, readFileSync, statSync } from '../agent-user/agent-fs.js';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
7
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
8
|
-
import {
|
|
8
|
+
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
9
9
|
import { agentsDir } from '../agents/files.js';
|
|
10
10
|
import { moveHome, removeHome, writeHomeText } from '../agent-user/home-fs.js';
|
|
11
11
|
import { claudeDir } from './files.js';
|
|
12
12
|
import { memoryJobStatus, type MemoryJob } from './memory-routine.js';
|
|
13
13
|
import { stagedMarketplaceDir } from './plugin-install.js';
|
|
14
14
|
import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
|
|
15
|
+
import { harnessRunner, runnerModel, sdkAllowed, sdkOnLogin, type HarnessRunner } from './runner.js';
|
|
16
|
+
import { readSetupState, writeSetupState } from './setup-state.js';
|
|
15
17
|
|
|
16
18
|
export const PRIVACY_ENV: Record<string, string> = {
|
|
17
19
|
DISABLE_TELEMETRY: '1',
|
|
@@ -20,7 +22,6 @@ export const PRIVACY_ENV: Record<string, string> = {
|
|
|
20
22
|
CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF: '1',
|
|
21
23
|
};
|
|
22
24
|
export const RETENTION_DAYS = 7;
|
|
23
|
-
const STATE_FILE = 'claude-setup.json';
|
|
24
25
|
const RULES_FILE = 'METRO.md';
|
|
25
26
|
const RULES_SKILL = 'metro';
|
|
26
27
|
const RENAMED_SKILL = 'metro-orchestrator';
|
|
@@ -68,6 +69,9 @@ export interface SetupReport {
|
|
|
68
69
|
export interface SetupStatus {
|
|
69
70
|
privacy: boolean;
|
|
70
71
|
permissionMode: PermissionMode;
|
|
72
|
+
runner: HarnessRunner;
|
|
73
|
+
runnerAllowed: boolean;
|
|
74
|
+
sdkOnLogin: boolean;
|
|
71
75
|
systemPrompt: string;
|
|
72
76
|
liveEvents: boolean;
|
|
73
77
|
memoryRoutine: boolean;
|
|
@@ -81,48 +85,36 @@ export interface SetupStatus {
|
|
|
81
85
|
retentionDays: number | null;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
const statePath = (agents: string): string => join(agents, STATE_FILE);
|
|
85
|
-
|
|
86
88
|
const PERMISSION_MODES = ['auto', 'bypass'] as const;
|
|
87
89
|
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
const raw = readJson<unknown>(statePath(agents), null);
|
|
91
|
-
return isRecord(raw) ? raw : {};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function writeState(agents: string, patch: Record<string, unknown>): void {
|
|
95
|
-
mkdirSync(agents, { recursive: true });
|
|
96
|
-
writeJson(statePath(agents), { ...readState(agents), ...patch });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const privacyEnabled = (agents = agentsDir()): boolean => readState(agents).privacy !== false;
|
|
91
|
+
const privacyEnabled = (agents = agentsDir()): boolean => readSetupState(agents).privacy !== false;
|
|
100
92
|
|
|
101
93
|
export function setPrivacy(enabled: boolean, agents = agentsDir()): void {
|
|
102
|
-
|
|
94
|
+
writeSetupState(agents, { privacy: enabled });
|
|
103
95
|
}
|
|
104
96
|
|
|
105
97
|
export const isPermissionMode = (value: unknown): value is PermissionMode => PERMISSION_MODES.some((m) => m === value);
|
|
106
98
|
|
|
107
99
|
export function permissionMode(agents = agentsDir()): PermissionMode {
|
|
108
|
-
const mode =
|
|
100
|
+
const mode = readSetupState(agents).permissionMode;
|
|
109
101
|
return isPermissionMode(mode) ? mode : 'auto';
|
|
110
102
|
}
|
|
111
103
|
|
|
112
104
|
export function setPermissionMode(mode: PermissionMode, agents = agentsDir()): void {
|
|
113
|
-
|
|
105
|
+
writeSetupState(agents, { permissionMode: mode });
|
|
114
106
|
}
|
|
115
107
|
|
|
116
|
-
export const liveEvents = (agents = agentsDir()): boolean =>
|
|
108
|
+
export const liveEvents = (agents = agentsDir()): boolean => readSetupState(agents).liveEvents !== false;
|
|
117
109
|
|
|
118
110
|
export function setLiveEvents(on: boolean, agents = agentsDir()): void {
|
|
119
|
-
|
|
111
|
+
writeSetupState(agents, { liveEvents: on });
|
|
120
112
|
}
|
|
121
113
|
|
|
122
|
-
export const memoryRoutine = (agents = agentsDir()): boolean =>
|
|
114
|
+
export const memoryRoutine = (agents = agentsDir()): boolean => readSetupState(agents).memoryRoutine !== false;
|
|
123
115
|
|
|
124
116
|
export function setMemoryRoutine(on: boolean, agents = agentsDir()): void {
|
|
125
|
-
|
|
117
|
+
writeSetupState(agents, { memoryRoutine: on });
|
|
126
118
|
}
|
|
127
119
|
|
|
128
120
|
const promptPath = (agents: string): string => join(agents, SYSTEM_PROMPT_FILE);
|
|
@@ -295,6 +287,9 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
|
|
|
295
287
|
return {
|
|
296
288
|
privacy: privacyEnabled(agents),
|
|
297
289
|
permissionMode: permissionMode(agents),
|
|
290
|
+
runner: harnessRunner(agents),
|
|
291
|
+
runnerAllowed: sdkAllowed(agents),
|
|
292
|
+
sdkOnLogin: sdkOnLogin(agents),
|
|
298
293
|
systemPrompt: systemPrompt(agents),
|
|
299
294
|
liveEvents: liveEvents(agents),
|
|
300
295
|
memoryRoutine: memoryRoutine(agents),
|
|
@@ -320,11 +315,7 @@ export function tryClaudeSetup(deps: SetupDeps = {}): void {
|
|
|
320
315
|
const MODELS_KEY = 'availableModels';
|
|
321
316
|
const ENFORCE_KEY = 'enforceAvailableModels';
|
|
322
317
|
|
|
323
|
-
export
|
|
324
|
-
const conn = routedConnection(cfg);
|
|
325
|
-
if (conn === null || conn.provider === 'anthropic' || conn.model === '') return null;
|
|
326
|
-
return `${conn.provider}:${conn.model}`;
|
|
327
|
-
}
|
|
318
|
+
export const routeOf = (cfg: ModelConfig): string | null => (routedConnection(cfg)?.provider === 'anthropic' ? null : runnerModel(cfg));
|
|
328
319
|
|
|
329
320
|
function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
|
|
330
321
|
if (route !== null) return { ...settings, [MODELS_KEY]: [route], [ENFORCE_KEY]: true };
|
|
@@ -336,11 +327,9 @@ export function syncAvailableModels(cfg: ModelConfig, deps: SetupDeps = {}): Set
|
|
|
336
327
|
const dir = deps.dir ?? claudeDir();
|
|
337
328
|
const agents = deps.agents ?? agentsDir();
|
|
338
329
|
const route = routeOf(cfg);
|
|
339
|
-
const
|
|
340
|
-
const metroWrote = isRecord(state) && state.modelsByMetro === true;
|
|
330
|
+
const metroWrote = readSetupState(agents).modelsByMetro === true;
|
|
341
331
|
return mergeSettings(dir, (current) => {
|
|
342
|
-
|
|
343
|
-
writeJson(statePath(agents), { ...(isRecord(state) ? state : {}), modelsByMetro: route !== null });
|
|
332
|
+
writeSetupState(agents, { modelsByMetro: route !== null });
|
|
344
333
|
return withAvailableModels(current, route, metroWrote);
|
|
345
334
|
});
|
|
346
335
|
}
|
|
@@ -2,6 +2,7 @@ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:
|
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
4
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
5
|
+
import { sdkAllowed, sdkConnectionAllowed, SDK_NEEDS_KEY } from '../claude/runner.js';
|
|
5
6
|
import {
|
|
6
7
|
assertBedrockReady,
|
|
7
8
|
bedrockBase,
|
|
@@ -97,6 +98,11 @@ const keyOf = (req: IncomingMessage): string => {
|
|
|
97
98
|
return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
|
|
98
99
|
};
|
|
99
100
|
|
|
101
|
+
const fromSdkRunner = (req: IncomingMessage): boolean => {
|
|
102
|
+
const raw = req.headers['x-metro-runner'];
|
|
103
|
+
return (Array.isArray(raw) ? raw[0] : raw)?.trim() === 'sdk';
|
|
104
|
+
};
|
|
105
|
+
|
|
100
106
|
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
101
107
|
|
|
102
108
|
function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
@@ -293,9 +299,15 @@ interface Request {
|
|
|
293
299
|
cfg: ModelConfig;
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
function checkSdkRoute(req: IncomingMessage, route: Route, deps: GatewayDeps): void {
|
|
303
|
+
if (fromSdkRunner(req) && (!sdkAllowed(undefined, deps.config()) || !sdkConnectionAllowed(route.connection)))
|
|
304
|
+
throw new GatewayError(403, 'permission_error', SDK_NEEDS_KEY);
|
|
305
|
+
}
|
|
306
|
+
|
|
296
307
|
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
|
|
297
308
|
const { path, body } = ask;
|
|
298
309
|
const conn = route.connection;
|
|
310
|
+
checkSdkRoute(req, route, deps);
|
|
299
311
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
300
312
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
301
313
|
if (conn.provider === 'bedrock') {
|
|
@@ -6,7 +6,8 @@ import { log } from '@metro-labs/core/log';
|
|
|
6
6
|
import { openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
7
7
|
import { anthropicModels, bedrockModels } from './provider-models.js';
|
|
8
8
|
import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
|
|
9
|
-
import {
|
|
9
|
+
import { harnessRunner, runnerModel, sdkAllowed } from '../claude/runner.js';
|
|
10
|
+
import { sessionLive, sessionRunning, stopSession } from '../claude/session.js';
|
|
10
11
|
import { forgetOne, forgetReported } from './usage.js';
|
|
11
12
|
import {
|
|
12
13
|
addConnection,
|
|
@@ -47,13 +48,35 @@ function restartRunningSession(): boolean {
|
|
|
47
48
|
return true;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function switchLive(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
|
|
52
|
+
const [was, now] = [runnerModel(before), runnerModel(next)];
|
|
53
|
+
if (was === now) return;
|
|
54
|
+
if (deps.switchModel?.(now) === true) log.info({ was, now }, 'model-api: the model changed, so the Agent SDK session switches to it live, with no restart');
|
|
55
|
+
else if (runnerIsLive(deps)) throw new ApiError('The running Agent SDK could not be told about this model change. Nothing was saved. Let its work finish, stop it, then save and start it again.', 409);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function followModel(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
|
|
59
|
+
const restart = deps.restartSession ?? restartRunningSession;
|
|
60
|
+
const agents = deps.setup?.agents;
|
|
61
|
+
if (harnessRunner(agents) === 'sdk') {
|
|
62
|
+
if (sdkAllowed(agents, next)) switchLive(before, next, deps);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (routeOf(next) !== routeOf(before) && restart())
|
|
66
|
+
log.info({ was: routeOf(before), now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
|
|
51
|
-
const before =
|
|
70
|
+
const before = store.read();
|
|
52
71
|
store.write(next);
|
|
72
|
+
try {
|
|
73
|
+
followModel(before, next, deps);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
store.write(before);
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
53
78
|
syncAvailableModelsQuietly(deps.setup ?? {}, next);
|
|
54
79
|
log.info(fields, note);
|
|
55
|
-
if (routeOf(next) !== before && (deps.restartSession ?? restartRunningSession)())
|
|
56
|
-
log.info({ was: before, now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
|
|
57
80
|
return settingsBody(next);
|
|
58
81
|
}
|
|
59
82
|
|
|
@@ -191,6 +214,15 @@ function routeFor(path: string, method: string | undefined): Route | number {
|
|
|
191
214
|
return codexRoute(path.slice(CODEX.length), method);
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
const runnerIsLive = (deps: ModelApiDeps): boolean => (deps.sessionRunning ?? (() => sessionLive({ agents: deps.setup?.agents })))();
|
|
218
|
+
|
|
219
|
+
function checkRunnerRoute(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
|
|
220
|
+
const agents = deps.setup?.agents;
|
|
221
|
+
if (harnessRunner(agents) !== 'sdk' || !sdkAllowed(agents, before) || sdkAllowed(agents, next)) return;
|
|
222
|
+
if (runnerIsLive(deps))
|
|
223
|
+
throw new ApiError('The Agent SDK session is running, and this change would leave a model or fallback it may not use. Let it finish its work, stop it, then save the change.', 409);
|
|
224
|
+
}
|
|
225
|
+
|
|
194
226
|
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
195
227
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
196
228
|
if (!mine(path)) return false;
|
|
@@ -203,7 +235,13 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
203
235
|
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
204
236
|
return true;
|
|
205
237
|
}
|
|
206
|
-
const store: Store = {
|
|
238
|
+
const store: Store = {
|
|
239
|
+
read: deps.read ?? readModelConfig,
|
|
240
|
+
write: (cfg) => {
|
|
241
|
+
checkRunnerRoute(store.read(), cfg, deps);
|
|
242
|
+
(deps.write ?? writeModelConfig)(cfg);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
207
245
|
apiSession(req)
|
|
208
246
|
.then(async (session) => {
|
|
209
247
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
@@ -25,6 +25,8 @@ export interface ModelApiDeps {
|
|
|
25
25
|
bedrockControlBase?: string;
|
|
26
26
|
setup?: SetupDeps;
|
|
27
27
|
restartSession?: () => boolean;
|
|
28
|
+
sessionRunning?: () => boolean;
|
|
29
|
+
switchModel?: (model: string | null) => boolean;
|
|
28
30
|
claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
|
|
29
31
|
claudeLoginsRoot?: string;
|
|
30
32
|
usageWaitMs?: number;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isStandaloneGet,
|
|
15
15
|
serveChannelGet,
|
|
16
16
|
} from './raw-get-stream.js';
|
|
17
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
17
18
|
import { channelLog, type McpSession } from './session.js';
|
|
18
19
|
import { SessionSlot } from './session-slot.js';
|
|
19
20
|
|
|
@@ -95,10 +96,21 @@ const isVoicePath = (req: IncomingMessage): boolean => (req.url ?? '').split('?'
|
|
|
95
96
|
|
|
96
97
|
let activeSlots: SessionSlot[] = [];
|
|
97
98
|
|
|
99
|
+
const MODEL_NOTICE = 'notifications/metro/model';
|
|
100
|
+
|
|
101
|
+
function noticeModel(session: McpSession | undefined, model: string | null): boolean {
|
|
102
|
+
if (session?.streamAttached !== true) return false;
|
|
103
|
+
session.server.notification({ method: MODEL_NOTICE, params: { model } }).catch((err: unknown) => {
|
|
104
|
+
channelLog('session: the model notice failed', errMsg(err));
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Promise<{
|
|
99
110
|
httpHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
100
111
|
startInbound: () => void;
|
|
101
112
|
setLiveEvents: (on: boolean) => void;
|
|
113
|
+
switchModel: (model: string | null) => boolean;
|
|
102
114
|
}> {
|
|
103
115
|
await Promise.all(activeSlots.map((s) => s.close()));
|
|
104
116
|
const slot = new SessionSlot(options.liveEvents);
|
|
@@ -146,5 +158,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
146
158
|
slot.setLive(on);
|
|
147
159
|
};
|
|
148
160
|
|
|
149
|
-
|
|
161
|
+
const switchModel = (model: string | null): boolean => noticeModel(slot.current, model);
|
|
162
|
+
|
|
163
|
+
return { httpHandler, startInbound, setLiveEvents, switchModel };
|
|
150
164
|
}
|
|
@@ -6,6 +6,9 @@ import { bearerSession, clientId, jwksUrl, SigningKeys, workosBase, type KeyStor
|
|
|
6
6
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
7
|
|
|
8
8
|
const KEYS_FILE = '.jwks';
|
|
9
|
+
const OPERATOR_USER = 'user_01M2TN3K6NRVX675M617GR4WVE';
|
|
10
|
+
|
|
11
|
+
export const isOperatorUser = (user: string): boolean => user === OPERATOR_USER;
|
|
9
12
|
|
|
10
13
|
export function jwksStore(dir: string): KeyStore {
|
|
11
14
|
const path = join(dir, KEYS_FILE);
|
|
@@ -25,7 +28,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
|
|
|
25
28
|
const held = owner();
|
|
26
29
|
if (held === null || session.organization === null || session.organization !== held)
|
|
27
30
|
throw new ApiError('this machine belongs to another organization', 403);
|
|
28
|
-
return { subject: held, role: session.role === 'admin' ? 'admin' : 'member' };
|
|
31
|
+
return { subject: held, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -58,6 +58,7 @@ export interface LocalModeDeps {
|
|
|
58
58
|
toolGroups?: AgentApiDeps['toolGroups'];
|
|
59
59
|
prepareAccount: AccountApiDeps['prepareAccount'];
|
|
60
60
|
liveEvents?: (on: boolean) => void;
|
|
61
|
+
switchModel?: (model: string | null) => boolean;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
@@ -201,7 +202,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
201
202
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
202
203
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
203
204
|
machineApi: {},
|
|
204
|
-
modelApi: {},
|
|
205
|
+
modelApi: { switchModel: deps.switchModel },
|
|
205
206
|
gateway: { config: readModelConfig },
|
|
206
207
|
terminalApi: {},
|
|
207
208
|
mode: localModeInfo,
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { approvalId } from '@metro-labs/core/ids';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { forgetPromptsOf, holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
4
4
|
import { promptBody } from '../mcp/permission-prompt.js';
|
|
5
5
|
import type { ToolAsk } from './brain.js';
|
|
6
6
|
|
|
7
|
-
const ID_LETTERS = 'abcdefghijkmnopqrstuvwxyz';
|
|
8
|
-
const ID_LENGTH = 5;
|
|
9
|
-
|
|
10
|
-
export const approvalId = (): string => Array.from({ length: ID_LENGTH }, () => ID_LETTERS[randomInt(ID_LETTERS.length)]).join('');
|
|
11
|
-
|
|
12
7
|
export class CallApprovals {
|
|
13
8
|
private closed = false;
|
|
14
9
|
|
package/runtime/runtime.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metro-labs/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "What every metro process shares: the logger, the event bus and its types, ids, secure file writes, the train protocol, station types and attachment handling. No HTTP, no policy.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./log": "./src/log.ts",
|
|
9
|
+
"./events": "./src/events.ts",
|
|
10
|
+
"./is-record": "./src/is-record.ts",
|
|
11
|
+
"./runner-activity": "./src/runner-activity.ts",
|
|
12
|
+
"./secure-fs": "./src/secure-fs.ts",
|
|
13
|
+
"./version": "./src/version.ts",
|
|
14
|
+
"./train-error": "./src/train-error.ts",
|
|
15
|
+
"./trains/protocol": "./src/protocol.ts",
|
|
16
|
+
"./ids": "./src/ids.ts",
|
|
17
|
+
"./tickets": "./src/tickets.ts",
|
|
18
|
+
"./station-names": "./src/station-names.ts",
|
|
19
|
+
"./str": "./src/str.ts",
|
|
20
|
+
"./lines": "./src/lines.ts",
|
|
21
|
+
"./endpoints": "./src/endpoints.ts",
|
|
22
|
+
"./stations/types": "./src/stations/types.ts",
|
|
23
|
+
"./stations/account-files": "./src/stations/account-files.ts",
|
|
24
|
+
"./stations/station-runtime": "./src/stations/station-runtime.ts",
|
|
25
|
+
"./stations/account-store": "./src/stations/account-store.ts",
|
|
26
|
+
"./stations/attachments": "./src/stations/attachments.ts",
|
|
27
|
+
"./stations/mail": "./src/stations/mail.ts",
|
|
28
|
+
"./stations/mail-text": "./src/stations/mail-text.ts",
|
|
29
|
+
"./stations/mail-trust": "./src/stations/mail-trust.ts",
|
|
30
|
+
"./stations/oauth": "./src/stations/oauth.ts",
|
|
31
|
+
"./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
|
|
32
|
+
"./stations/profile": "./src/stations/profile.ts",
|
|
33
|
+
"./stations/sender-profile": "./src/stations/sender-profile.ts",
|
|
34
|
+
"./stations/train-events": "./src/stations/train-events.ts",
|
|
35
|
+
"./stations/train-boot": "./src/stations/train-boot.ts"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "tsc --noEmit && METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"pino": "^9.5.0",
|
|
43
|
+
"pino-pretty": "^13.1.3"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@stage-labs/config": "0.1.0-beta.3",
|
|
47
|
+
"eslint": "^10.3.0",
|
|
48
|
+
"knip": "^6.15.0",
|
|
49
|
+
"madge": "^8.0.0",
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
}
|
|
52
|
+
}
|