@stage-labs/metro 0.1.0-beta.131 → 0.1.0-beta.133
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/claude.js +11 -7
- package/dist/cli.js +3 -5
- package/dist/local.js +14 -17
- package/dist/route.js +12 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +14 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agents/admin.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -35
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +24 -23
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +36 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +4 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +58 -10
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +25 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -4
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/seed.ts +0 -33
package/dist/claude.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { markOnboardingDone, seedChannels } from './onboarding.js';
|
|
3
3
|
import { writeMcpConfig } from './mcp-config.js';
|
|
4
|
-
import { currentRoute, permissionMode, routeModelEnv } from './route.js';
|
|
4
|
+
import { currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
|
|
5
5
|
import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
6
6
|
import { localAgents, pickLocalAgent } from './local.js';
|
|
7
7
|
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
@@ -10,11 +10,12 @@ const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'
|
|
|
10
10
|
const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
11
11
|
const KEY_HEADER = 'x-metro-key';
|
|
12
12
|
const PROBE_MS = 3_000;
|
|
13
|
-
export const claudeArgs = (extra, mcpConfig, mode = 'auto') => [
|
|
13
|
+
export const claudeArgs = (extra, mcpConfig, mode = 'auto', prompt = null) => [
|
|
14
14
|
...CHANNEL_FLAGS,
|
|
15
15
|
'--permission-mode',
|
|
16
16
|
PERMISSION_MODE_FLAG[mode],
|
|
17
17
|
...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
|
|
18
|
+
...(prompt === null ? [] : ['--append-system-prompt', prompt]),
|
|
18
19
|
...extra,
|
|
19
20
|
];
|
|
20
21
|
const set = (env, name) => (env[name] ?? '').trim() !== '';
|
|
@@ -82,7 +83,7 @@ export function agentKey(agents, wanted) {
|
|
|
82
83
|
catch {
|
|
83
84
|
return {
|
|
84
85
|
skip: wanted === undefined || wanted === ''
|
|
85
|
-
? '
|
|
86
|
+
? 'this box has no agent yet (or several old ones), so Claude Code talks to Anthropic directly for now'
|
|
86
87
|
: `no agent named '${wanted}' lives here, so Claude Code talks to Anthropic directly`,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
@@ -102,7 +103,7 @@ async function verdict() {
|
|
|
102
103
|
const conflicts = settingsConflicts(settingsFiles());
|
|
103
104
|
if (conflicts.length > 0)
|
|
104
105
|
return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
|
|
105
|
-
const picked = agentKey(localAgentList(),
|
|
106
|
+
const picked = agentKey(localAgentList(), undefined);
|
|
106
107
|
if ('skip' in picked)
|
|
107
108
|
return picked;
|
|
108
109
|
if (await daemonServing())
|
|
@@ -128,7 +129,7 @@ export function runClaude(args, env) {
|
|
|
128
129
|
async function servedKey(decision) {
|
|
129
130
|
if ('key' in decision)
|
|
130
131
|
return decision.key;
|
|
131
|
-
const picked = agentKey(localAgentList(),
|
|
132
|
+
const picked = agentKey(localAgentList(), undefined);
|
|
132
133
|
if ('skip' in picked)
|
|
133
134
|
return null;
|
|
134
135
|
return (await daemonServing()) ? picked.key : null;
|
|
@@ -157,6 +158,9 @@ export async function launchClaude(extra) {
|
|
|
157
158
|
const port = localPort();
|
|
158
159
|
const mcp = mcpConfigFor(await servedKey(decision), port);
|
|
159
160
|
const mode = permissionMode();
|
|
161
|
+
const prompt = systemPrompt();
|
|
162
|
+
if (prompt !== null)
|
|
163
|
+
process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
|
|
160
164
|
const sandbox = (env) => {
|
|
161
165
|
const out = bypassSandboxEnv(env, mode, process.getuid?.());
|
|
162
166
|
if (out !== env)
|
|
@@ -166,9 +170,9 @@ export async function launchClaude(extra) {
|
|
|
166
170
|
try {
|
|
167
171
|
if ('skip' in decision) {
|
|
168
172
|
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
169
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode), sandbox(process.env));
|
|
173
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), sandbox(process.env));
|
|
170
174
|
}
|
|
171
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode), sandbox(gatewayLaunchEnv(decision.key, port)));
|
|
175
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), sandbox(gatewayLaunchEnv(decision.key, port)));
|
|
172
176
|
}
|
|
173
177
|
finally {
|
|
174
178
|
mcp?.cleanup();
|
package/dist/cli.js
CHANGED
|
@@ -25,11 +25,9 @@ const USAGE = `metro — run your agent on this machine
|
|
|
25
25
|
metro stop stop metro on this machine, metro serve included
|
|
26
26
|
metro tail <agent-id>
|
|
27
27
|
follow this machine's inbound events, one JSON line each
|
|
28
|
-
metro whoami
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
print the mcpServers block of the agent's connectors, served through the
|
|
32
|
-
daemon's own relay (name the agent if several live here)
|
|
28
|
+
metro whoami print the agent this machine runs
|
|
29
|
+
metro mcp print the mcpServers block of the agent's connectors, served through the
|
|
30
|
+
daemon's own relay
|
|
33
31
|
metro plugin set up the Claude Code plugin (connector servers + /metro:refresh)
|
|
34
32
|
metro claude [args...]
|
|
35
33
|
open Claude Code with the metro channel; every argument is passed through
|
package/dist/local.js
CHANGED
|
@@ -8,20 +8,24 @@ export function agentsDir() {
|
|
|
8
8
|
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
9
9
|
return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
function agentFiles(dir) {
|
|
12
|
+
const fixed = join(dir, 'agent.json');
|
|
13
|
+
if (existsSync(fixed))
|
|
14
|
+
return [fixed];
|
|
12
15
|
if (!existsSync(dir))
|
|
13
16
|
return [];
|
|
17
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
18
|
+
.filter((entry) => entry.isDirectory())
|
|
19
|
+
.map((entry) => join(dir, entry.name, 'agent.json'))
|
|
20
|
+
.filter((path) => existsSync(path));
|
|
21
|
+
}
|
|
22
|
+
export function localAgents(dir = agentsDir()) {
|
|
14
23
|
const out = [];
|
|
15
|
-
for (const
|
|
16
|
-
if (!entry.isDirectory())
|
|
17
|
-
continue;
|
|
18
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
19
|
-
if (!existsSync(path))
|
|
20
|
-
continue;
|
|
24
|
+
for (const path of agentFiles(dir)) {
|
|
21
25
|
try {
|
|
22
26
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
-
if (typeof file.id === 'string' && typeof file.
|
|
24
|
-
out.push({ id: file.id, name: file.name, key: file.key });
|
|
27
|
+
if (typeof file.id === 'string' && typeof file.key === 'string')
|
|
28
|
+
out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
27
31
|
continue;
|
|
@@ -30,15 +34,8 @@ export function localAgents(dir = agentsDir()) {
|
|
|
30
34
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
35
|
}
|
|
32
36
|
export function localStations(dir = agentsDir()) {
|
|
33
|
-
if (!existsSync(dir))
|
|
34
|
-
return [];
|
|
35
37
|
const out = new Set();
|
|
36
|
-
for (const
|
|
37
|
-
if (!entry.isDirectory())
|
|
38
|
-
continue;
|
|
39
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
40
|
-
if (!existsSync(path))
|
|
41
|
-
continue;
|
|
38
|
+
for (const path of agentFiles(dir)) {
|
|
42
39
|
try {
|
|
43
40
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
41
|
if (!Array.isArray(file.stations))
|
package/dist/route.js
CHANGED
|
@@ -36,3 +36,15 @@ export function permissionMode(dir = agentsDir()) {
|
|
|
36
36
|
return 'auto';
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
export function systemPrompt(dir = agentsDir()) {
|
|
40
|
+
const path = join(dir, 'system-prompt.md');
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
45
|
+
return text === '' ? null : text;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.133",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.133",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -15,18 +15,23 @@ function agentsDir() {
|
|
|
15
15
|
return explicit ? explicit : join(homedir(), '.metro', 'agents');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function
|
|
19
|
-
const
|
|
18
|
+
function agentFiles(dir) {
|
|
19
|
+
const fixed = join(dir, 'agent.json');
|
|
20
|
+
if (existsSync(fixed)) return [fixed];
|
|
20
21
|
if (!existsSync(dir)) return [];
|
|
22
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
23
|
+
.filter((entry) => entry.isDirectory())
|
|
24
|
+
.map((entry) => join(dir, entry.name, 'agent.json'))
|
|
25
|
+
.filter((path) => existsSync(path));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localAgents() {
|
|
21
29
|
const out = [];
|
|
22
|
-
for (const
|
|
23
|
-
if (!entry.isDirectory()) continue;
|
|
24
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
25
|
-
if (!existsSync(path)) continue;
|
|
30
|
+
for (const path of agentFiles(agentsDir())) {
|
|
26
31
|
try {
|
|
27
32
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
-
if (typeof file.id === 'string' && typeof file.
|
|
29
|
-
out.push({ id: file.id, name: file.name, key: file.key });
|
|
33
|
+
if (typeof file.id === 'string' && typeof file.key === 'string')
|
|
34
|
+
out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
|
|
30
35
|
} catch {
|
|
31
36
|
continue;
|
|
32
37
|
}
|
|
@@ -45,7 +50,7 @@ function pickAgent() {
|
|
|
45
50
|
if (agents.length === 1) return agents[0];
|
|
46
51
|
throw new Error(
|
|
47
52
|
agents.length === 0
|
|
48
|
-
? 'no agent on this machine yet — start metro serve
|
|
53
|
+
? 'no agent on this machine yet — start metro serve, it creates one at boot'
|
|
49
54
|
: `several agents on this machine — set METRO_AGENT to one of: ${agents.map((a) => a.name).join(', ')}`,
|
|
50
55
|
);
|
|
51
56
|
}
|
|
@@ -6,20 +6,20 @@ export class AgentAdminError extends ApiError {}
|
|
|
6
6
|
|
|
7
7
|
export interface AgentSummary {
|
|
8
8
|
id: string;
|
|
9
|
-
name: string;
|
|
9
|
+
name: string | null;
|
|
10
10
|
owned: boolean;
|
|
11
11
|
key: string | null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface CreatedAgent {
|
|
15
15
|
id: string;
|
|
16
|
-
name: string;
|
|
16
|
+
name: string | null;
|
|
17
17
|
key: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface OwnedAgent {
|
|
21
21
|
id: string;
|
|
22
|
-
name: string;
|
|
22
|
+
name: string | null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export type DeletedAgent = OwnedAgent;
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
apiSession,
|
|
8
8
|
projectParam,
|
|
9
9
|
cors,
|
|
10
|
-
readJsonBody,
|
|
11
10
|
sendJson,
|
|
12
11
|
type ApiSession,
|
|
13
12
|
} from '@metro-labs/http/api-http';
|
|
@@ -19,7 +18,6 @@ import {
|
|
|
19
18
|
} from './account-routes.js';
|
|
20
19
|
import {
|
|
21
20
|
type AgentSummary,
|
|
22
|
-
type CreatedAgent,
|
|
23
21
|
type DeletedAgent,
|
|
24
22
|
type ResetAgentKey,
|
|
25
23
|
} from './admin.js';
|
|
@@ -29,11 +27,6 @@ const SERVER_NAME = 'metro';
|
|
|
29
27
|
|
|
30
28
|
export interface AgentApiDeps extends AccountApiDeps {
|
|
31
29
|
listAgents: (subject: string, project: string) => Promise<AgentSummary[]>;
|
|
32
|
-
createAgent: (
|
|
33
|
-
subject: string,
|
|
34
|
-
project: string,
|
|
35
|
-
name: string,
|
|
36
|
-
) => Promise<CreatedAgent>;
|
|
37
30
|
deleteAgent: (
|
|
38
31
|
subject: string,
|
|
39
32
|
id: string,
|
|
@@ -166,31 +159,6 @@ async function handleList(
|
|
|
166
159
|
sendJson(req, res, 200, { ...base, accounts, unavailable });
|
|
167
160
|
}
|
|
168
161
|
|
|
169
|
-
async function handleCreate(
|
|
170
|
-
req: IncomingMessage,
|
|
171
|
-
res: ServerResponse,
|
|
172
|
-
deps: AgentApiDeps,
|
|
173
|
-
session: ApiSession,
|
|
174
|
-
): Promise<void> {
|
|
175
|
-
const body = await readJsonBody(req);
|
|
176
|
-
const name = (body as { name?: unknown }).name;
|
|
177
|
-
const project = projectParam(req);
|
|
178
|
-
if (project === null) {
|
|
179
|
-
sendJson(req, res, 400, { error: 'a project is required' });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const created = await deps.createAgent(session.subject, project, name as string);
|
|
183
|
-
log.info(
|
|
184
|
-
{ agent: created.name, id: created.id, owner: session.subject },
|
|
185
|
-
'agent-api: created agent',
|
|
186
|
-
);
|
|
187
|
-
sendJson(req, res, 201, {
|
|
188
|
-
id: created.id,
|
|
189
|
-
name: created.name,
|
|
190
|
-
...credentials(created.key),
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
162
|
async function handleResetKey(
|
|
195
163
|
req: IncomingMessage,
|
|
196
164
|
res: ServerResponse,
|
|
@@ -239,15 +207,14 @@ async function routeAgent(
|
|
|
239
207
|
if (tgt.kind === 'key') await handleResetKey(req, res, deps, session, tgt.id);
|
|
240
208
|
else if (tgt.kind === 'agent')
|
|
241
209
|
await handleDelete(req, res, deps, session, tgt.id);
|
|
242
|
-
else
|
|
243
|
-
else await handleCreate(req, res, deps, session);
|
|
210
|
+
else await handleList(req, res, deps, session);
|
|
244
211
|
} catch (err) {
|
|
245
212
|
apiFailure(req, res, err);
|
|
246
213
|
}
|
|
247
214
|
}
|
|
248
215
|
|
|
249
216
|
const ALLOWED: Record<AgentTarget['kind'], string[]> = {
|
|
250
|
-
collection: ['GET'
|
|
217
|
+
collection: ['GET'],
|
|
251
218
|
agent: ['DELETE'],
|
|
252
219
|
key: ['POST'],
|
|
253
220
|
};
|
|
@@ -69,9 +69,9 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
|
69
69
|
if (!isRecord(raw)) throw bad('not a v1 agent bundle');
|
|
70
70
|
const { id, name, key, stations } = raw;
|
|
71
71
|
if (typeof id !== 'string' || parseId(id) === null) throw bad('agent id is not an id');
|
|
72
|
-
if (typeof
|
|
72
|
+
if (typeof key !== 'string' || key === '') throw bad('agent has no key');
|
|
73
73
|
if (!Array.isArray(stations)) throw bad('agent has no station list');
|
|
74
|
-
return { id, name, key, stations: stations.map(stationOf) };
|
|
74
|
+
return { id, name: typeof name === 'string' ? name : '', key, stations: stations.map(stationOf) };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function parseBundle(raw: unknown): AgentBundle {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './admin.js';
|
|
15
15
|
import type { AccountRef } from './account-attach.js';
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
agentFilePath,
|
|
18
18
|
AgentFileError,
|
|
19
19
|
agentsDir,
|
|
20
20
|
listAgentFiles,
|
|
@@ -94,7 +94,7 @@ export async function localListAgents(
|
|
|
94
94
|
return Promise.resolve(
|
|
95
95
|
storedAgents(dir)
|
|
96
96
|
.map(({ file }) => ({ id: file.id, name: file.name, owned: true, key: file.key }))
|
|
97
|
-
.sort((a, b) => a.
|
|
97
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -109,30 +109,35 @@ function freshId(taken: Set<string>): string {
|
|
|
109
109
|
export async function localCreateAgent(
|
|
110
110
|
subject: string,
|
|
111
111
|
project: string,
|
|
112
|
-
rawName
|
|
112
|
+
rawName?: string,
|
|
113
113
|
dir = agentsDir(),
|
|
114
114
|
): Promise<CreatedAgent> {
|
|
115
115
|
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
116
116
|
throw new AgentAdminError('no such project', 404);
|
|
117
|
-
const name = normalizeAgentName(rawName);
|
|
118
|
-
const folder = join(dir, name);
|
|
119
|
-
if (existsSync(join(folder, AGENT_FILE)))
|
|
120
|
-
throw new AgentAdminError(
|
|
121
|
-
`an agent named '${name}' already exists on this machine`,
|
|
122
|
-
409,
|
|
123
|
-
);
|
|
117
|
+
const name = rawName === undefined ? null : normalizeAgentName(rawName);
|
|
124
118
|
const existing = storedAgents(dir);
|
|
125
|
-
|
|
119
|
+
if (existing.length > 0) throw new AgentAdminError('this box already has its agent', 409);
|
|
120
|
+
const id = freshId(new Set());
|
|
126
121
|
const key = newApiKey();
|
|
127
|
-
ensureSecureDir(
|
|
122
|
+
ensureSecureDir(dir);
|
|
128
123
|
save({
|
|
129
|
-
path:
|
|
124
|
+
path: agentFilePath(dir),
|
|
130
125
|
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [], connectors: [] },
|
|
131
126
|
});
|
|
132
127
|
registerKey(key, id);
|
|
133
128
|
return Promise.resolve({ id, name, key });
|
|
134
129
|
}
|
|
135
130
|
|
|
131
|
+
export type Ensured = 'created' | 'present' | 'no-owner';
|
|
132
|
+
|
|
133
|
+
export async function ensureLocalAgent(dir = agentsDir()): Promise<Ensured> {
|
|
134
|
+
if (storedAgents(dir).length > 0) return 'present';
|
|
135
|
+
const owner = localOwner(dir);
|
|
136
|
+
if (owner === null) return 'no-owner';
|
|
137
|
+
await localCreateAgent(owner, LOCAL_PROJECT_ID, undefined, dir);
|
|
138
|
+
return 'created';
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
export async function localResetAgentKey(
|
|
137
142
|
subject: string,
|
|
138
143
|
id: string,
|
|
@@ -155,7 +160,7 @@ export async function localDeleteAgent(
|
|
|
155
160
|
const attached = stored.file.stations.length;
|
|
156
161
|
if (attached > 0)
|
|
157
162
|
throw new AgentAdminError(
|
|
158
|
-
`agent
|
|
163
|
+
`the agent still has ${String(attached)} station account(s) attached — detach them first`,
|
|
159
164
|
409,
|
|
160
165
|
);
|
|
161
166
|
rmSync(stored.path);
|
|
@@ -196,14 +201,10 @@ interface ImportTarget {
|
|
|
196
201
|
function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
|
|
197
202
|
const existing = storedAgents(dir);
|
|
198
203
|
const same = existing.find((s) => s.file.id === agent.id);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
throw new AgentAdminError(
|
|
202
|
-
`an agent named '${agent.name}' already exists on this machine`,
|
|
203
|
-
409,
|
|
204
|
-
);
|
|
204
|
+
if (same === undefined && existing.length > 0)
|
|
205
|
+
throw new AgentAdminError('this box already has its agent; import into it rather than bringing another', 409);
|
|
205
206
|
assertUnclaimed(existing.filter((s) => s !== same), agent);
|
|
206
|
-
return { path: same?.path ??
|
|
207
|
+
return { path: same?.path ?? agentFilePath(dir), previous: same?.file };
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
export type ImportMode = 'append' | 'overwrite';
|
|
@@ -256,7 +257,7 @@ function fileFor(
|
|
|
256
257
|
JSON.stringify({
|
|
257
258
|
version: 1,
|
|
258
259
|
id: agent.id,
|
|
259
|
-
name: previous?.name ??
|
|
260
|
+
name: previous?.name ?? null,
|
|
260
261
|
key: agent.key,
|
|
261
262
|
owner,
|
|
262
263
|
stations: mergedStations(agent, previous, mode),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, renameSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { AGENT_NAME_RE, ID_RE } from '@metro-labs/core/ids';
|
|
@@ -17,13 +18,15 @@ export class AgentFileError extends Error {}
|
|
|
17
18
|
export interface AgentFile {
|
|
18
19
|
version: 1;
|
|
19
20
|
id: string;
|
|
20
|
-
name: string;
|
|
21
|
+
name: string | null;
|
|
21
22
|
key: string | null;
|
|
22
23
|
owner: string | null;
|
|
23
24
|
stations: LoadedAccount[];
|
|
24
25
|
connectors: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export const agentFilePath = (dir = agentsDir()): string => join(dir, AGENT_FILE);
|
|
29
|
+
|
|
27
30
|
export function agentsDir(): string {
|
|
28
31
|
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
29
32
|
return explicit !== undefined && explicit !== ''
|
|
@@ -73,6 +76,12 @@ function optionalMatch(
|
|
|
73
76
|
const connectorsOf = (raw: unknown): string[] =>
|
|
74
77
|
Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && ID_RE.test(id)) : [];
|
|
75
78
|
|
|
79
|
+
function nameOf(raw: unknown, path: string): string | null {
|
|
80
|
+
if (raw === undefined || raw === null) return null;
|
|
81
|
+
if (typeof raw !== 'string' || !AGENT_NAME_RE.test(raw)) fail(path, 'name is not a valid agent name');
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
77
86
|
let parsed: unknown;
|
|
78
87
|
try {
|
|
@@ -85,8 +94,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
85
94
|
const { id, name, stations } = parsed;
|
|
86
95
|
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
87
96
|
fail(path, 'id is not an 11-character id');
|
|
88
|
-
|
|
89
|
-
fail(path, 'name is not a valid agent name');
|
|
97
|
+
const label = nameOf(name, path);
|
|
90
98
|
const key = optionalMatch(parsed.key, KEY_RE, path, 'key is not an agent key');
|
|
91
99
|
const owner = optionalMatch(
|
|
92
100
|
parsed.owner,
|
|
@@ -98,7 +106,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
98
106
|
return {
|
|
99
107
|
version: 1,
|
|
100
108
|
id,
|
|
101
|
-
name,
|
|
109
|
+
name: label,
|
|
102
110
|
key,
|
|
103
111
|
owner,
|
|
104
112
|
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
@@ -116,7 +124,7 @@ export function readAgentFile(path: string): AgentFile {
|
|
|
116
124
|
return parseAgentFile(raw, path);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
function legacyAgentFiles(dir: string): string[] {
|
|
120
128
|
if (!existsSync(dir)) return [];
|
|
121
129
|
return readdirSync(dir, { withFileTypes: true })
|
|
122
130
|
.filter((entry) => entry.isDirectory())
|
|
@@ -125,6 +133,27 @@ export function listAgentFiles(dir = agentsDir()): string[] {
|
|
|
125
133
|
.sort();
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
export function listAgentFiles(dir = agentsDir()): string[] {
|
|
137
|
+
const fixed = agentFilePath(dir);
|
|
138
|
+
return existsSync(fixed) ? [fixed] : legacyAgentFiles(dir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function migrateAgentLayout(dir = agentsDir()): 'moved' | 'kept' | 'none' {
|
|
142
|
+
const fixed = agentFilePath(dir);
|
|
143
|
+
if (existsSync(fixed)) return 'kept';
|
|
144
|
+
const [first, ...rest] = legacyAgentFiles(dir);
|
|
145
|
+
if (first === undefined) return 'none';
|
|
146
|
+
renameSync(first, fixed);
|
|
147
|
+
try {
|
|
148
|
+
rmdirSync(join(first, '..'));
|
|
149
|
+
} catch {
|
|
150
|
+
log.warn({ folder: join(first, '..') }, 'agent layout: the old agent folder is not empty and was left in place');
|
|
151
|
+
}
|
|
152
|
+
for (const extra of rest) log.warn({ path: extra }, 'agent layout: a second agent file is ignored; a box holds one agent');
|
|
153
|
+
log.info({ from: first, to: fixed }, 'agent layout: moved the agent file to its fixed place');
|
|
154
|
+
return 'moved';
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
function assertUnique(agents: AgentFile[], paths: string[]): void {
|
|
129
158
|
const ids = new Map<string, string>();
|
|
130
159
|
const keys = new Map<string, string>();
|
|
@@ -146,7 +175,7 @@ export function loadFileAgents(dir = agentsDir()): LoadedAgent[] {
|
|
|
146
175
|
assertUnique(agents, paths);
|
|
147
176
|
return agents.map((agent) => ({
|
|
148
177
|
id: agent.id,
|
|
149
|
-
name: agent.name,
|
|
178
|
+
name: agent.name ?? agent.id,
|
|
150
179
|
key: agent.key,
|
|
151
180
|
accounts: agent.stations,
|
|
152
181
|
}));
|
|
@@ -26,8 +26,8 @@ import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
|
26
26
|
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
27
27
|
import { tryClaudeSetup } from '../claude/setup.js';
|
|
28
28
|
import { applyLocalOwner } from './local-owner.js';
|
|
29
|
-
import { localOwner } from '../agents/file-admin.js';
|
|
30
|
-
import {
|
|
29
|
+
import { ensureLocalAgent, localOwner } from '../agents/file-admin.js';
|
|
30
|
+
import { migrateAgentLayout } from '../agents/files.js';
|
|
31
31
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
32
32
|
import { localSessionApis } from '../routes/local-mode.js';
|
|
33
33
|
import type { SessionApis } from '../routes/session-apis.js';
|
|
@@ -135,7 +135,8 @@ function startConnectors(): void {
|
|
|
135
135
|
|
|
136
136
|
async function main(): Promise<void> {
|
|
137
137
|
applyLocalOwner();
|
|
138
|
-
|
|
138
|
+
migrateAgentLayout();
|
|
139
|
+
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
139
140
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
140
141
|
supervisor.start();
|
|
141
142
|
const metroMcp = await createMetroMcp();
|
|
@@ -21,7 +21,19 @@ import {
|
|
|
21
21
|
startClaudeLogin,
|
|
22
22
|
type LoginDeps,
|
|
23
23
|
} from './login.js';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
claudeSetupStatus,
|
|
26
|
+
ensureClaudeSetup,
|
|
27
|
+
isPermissionMode,
|
|
28
|
+
permissionMode,
|
|
29
|
+
setPermissionMode,
|
|
30
|
+
setPrivacy,
|
|
31
|
+
setSystemPrompt,
|
|
32
|
+
systemPrompt,
|
|
33
|
+
SYSTEM_PROMPT_MAX,
|
|
34
|
+
type PermissionMode,
|
|
35
|
+
type SetupDeps,
|
|
36
|
+
} from './setup.js';
|
|
25
37
|
import {
|
|
26
38
|
ensureSession,
|
|
27
39
|
sessionRunning,
|
|
@@ -138,28 +150,64 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
|
|
|
138
150
|
const LOGIN = 'login';
|
|
139
151
|
const SESSION = 'session';
|
|
140
152
|
const SETUP = 'setup';
|
|
153
|
+
const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
|
|
141
154
|
const VERSION = 'version';
|
|
142
155
|
|
|
143
156
|
interface SetupChange {
|
|
144
157
|
privacy?: boolean;
|
|
145
158
|
permissionMode?: PermissionMode;
|
|
159
|
+
systemPrompt?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function promptChange(raw: unknown): string | undefined {
|
|
163
|
+
if (raw === undefined) return undefined;
|
|
164
|
+
if (typeof raw !== 'string') throw new ApiError('systemPrompt must be text', 400);
|
|
165
|
+
if (Buffer.byteLength(raw) > SYSTEM_PROMPT_MAX) throw new ApiError(`systemPrompt must be under ${String(SYSTEM_PROMPT_MAX / 1024)} KiB`, 400);
|
|
166
|
+
return raw;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function privacyChange(raw: unknown): boolean | undefined {
|
|
170
|
+
if (raw === undefined) return undefined;
|
|
171
|
+
if (typeof raw !== 'boolean') throw new ApiError('privacy must be true or false', 400);
|
|
172
|
+
return raw;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function modeChange(raw: unknown): PermissionMode | undefined {
|
|
176
|
+
if (raw === undefined) return undefined;
|
|
177
|
+
if (!isPermissionMode(raw)) throw new ApiError('permissionMode must be auto or bypass', 400);
|
|
178
|
+
return raw;
|
|
146
179
|
}
|
|
147
180
|
|
|
148
181
|
function setupChange(body: unknown): SetupChange {
|
|
149
182
|
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
const privacy = privacyChange(body.privacy);
|
|
184
|
+
const permissionMode = modeChange(body.permissionMode);
|
|
185
|
+
const systemPrompt = promptChange(body.systemPrompt);
|
|
186
|
+
if (privacy === undefined && permissionMode === undefined && systemPrompt === undefined) throw new ApiError('nothing to change', 400);
|
|
187
|
+
return {
|
|
188
|
+
...(privacy === undefined ? {} : { privacy }),
|
|
189
|
+
...(permissionMode === undefined ? {} : { permissionMode }),
|
|
190
|
+
...(systemPrompt === undefined ? {} : { systemPrompt }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function restartsSession(change: SetupChange, agents: string | undefined): boolean {
|
|
195
|
+
let restart = false;
|
|
196
|
+
if (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents)) {
|
|
197
|
+
setPermissionMode(change.permissionMode, agents);
|
|
198
|
+
restart = true;
|
|
199
|
+
}
|
|
200
|
+
if (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents)) {
|
|
201
|
+
setSystemPrompt(change.systemPrompt, agents);
|
|
202
|
+
restart = true;
|
|
203
|
+
}
|
|
204
|
+
return restart;
|
|
154
205
|
}
|
|
155
206
|
|
|
156
207
|
function applySetupChange(change: SetupChange, deps: ClaudeApiDeps): void {
|
|
157
208
|
const setup = deps.setup ?? {};
|
|
158
209
|
if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
|
|
159
|
-
if (change.
|
|
160
|
-
setPermissionMode(change.permissionMode, setup.agents);
|
|
161
|
-
if (sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
|
|
162
|
-
}
|
|
210
|
+
if (restartsSession(change, setup.agents) && sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
|
|
163
211
|
ensureClaudeSetup(setup);
|
|
164
212
|
}
|
|
165
213
|
|
|
@@ -168,7 +216,7 @@ async function setupAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<u
|
|
|
168
216
|
const method = req.method ?? 'GET';
|
|
169
217
|
if (method === 'GET') return claudeSetupStatus(setup);
|
|
170
218
|
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
171
|
-
applySetupChange(setupChange(await readJsonBody(req)), deps);
|
|
219
|
+
applySetupChange(setupChange(await readJsonBody(req, SETUP_BODY_MAX)), deps);
|
|
172
220
|
return claudeSetupStatus(setup);
|
|
173
221
|
}
|
|
174
222
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
@@ -19,6 +19,8 @@ export const RETENTION_DAYS = 7;
|
|
|
19
19
|
export const SKILL_NAME = 'metro-orchestrator';
|
|
20
20
|
const STATE_FILE = 'claude-setup.json';
|
|
21
21
|
const GUIDANCE = 'orchestrator.md';
|
|
22
|
+
export const SYSTEM_PROMPT_FILE = 'system-prompt.md';
|
|
23
|
+
export const SYSTEM_PROMPT_MAX = 64 * 1024;
|
|
22
24
|
|
|
23
25
|
export const WORKER_AGENT = `---
|
|
24
26
|
name: worker
|
|
@@ -56,6 +58,7 @@ export interface SetupReport {
|
|
|
56
58
|
export interface SetupStatus {
|
|
57
59
|
privacy: boolean;
|
|
58
60
|
permissionMode: PermissionMode;
|
|
61
|
+
systemPrompt: string;
|
|
59
62
|
guard: 'plugin';
|
|
60
63
|
worker: boolean;
|
|
61
64
|
skill: boolean;
|
|
@@ -95,6 +98,26 @@ export function setPermissionMode(mode: PermissionMode, agents = agentsDir()): v
|
|
|
95
98
|
writeState(agents, { permissionMode: mode });
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
const promptPath = (agents: string): string => join(agents, SYSTEM_PROMPT_FILE);
|
|
102
|
+
|
|
103
|
+
export function systemPrompt(agents = agentsDir()): string {
|
|
104
|
+
const path = promptPath(agents);
|
|
105
|
+
if (!existsSync(path)) return '';
|
|
106
|
+
return readFileSync(path, 'utf8').trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function setSystemPrompt(text: string, agents = agentsDir()): void {
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
if (trimmed === '') {
|
|
112
|
+
rmSync(promptPath(agents), { force: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
mkdirSync(agents, { recursive: true });
|
|
116
|
+
const tmp = `${promptPath(agents)}.${String(process.pid)}.tmp`;
|
|
117
|
+
writeFileSync(tmp, `${trimmed}\n`, { mode: 0o600 });
|
|
118
|
+
renameSync(tmp, promptPath(agents));
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
99
122
|
const staged = stagedMarketplaceDir(env);
|
|
100
123
|
if (staged !== null) {
|
|
@@ -178,6 +201,7 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
|
|
|
178
201
|
return {
|
|
179
202
|
privacy: privacyEnabled(deps.agents ?? agentsDir()),
|
|
180
203
|
permissionMode: permissionMode(deps.agents ?? agentsDir()),
|
|
204
|
+
systemPrompt: systemPrompt(deps.agents ?? agentsDir()),
|
|
181
205
|
guard: 'plugin',
|
|
182
206
|
worker: existsSync(workerPath(dir)),
|
|
183
207
|
skill: existsSync(skillPath(dir)),
|
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
assertLocalOwner,
|
|
34
34
|
LOCAL_PROJECT_ID,
|
|
35
35
|
localAttachAccount,
|
|
36
|
-
localCreateAgent,
|
|
37
36
|
localDeleteAgent,
|
|
38
37
|
localDetachAccount,
|
|
39
38
|
localSetAllowlist,
|
|
@@ -87,7 +86,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
87
86
|
return {
|
|
88
87
|
attachSessions: attachSessions(deps),
|
|
89
88
|
listAgents: localListAgents,
|
|
90
|
-
createAgent: localCreateAgent,
|
|
91
89
|
deleteAgent: localDeleteAgent,
|
|
92
90
|
resetKey: async (subject, id) => {
|
|
93
91
|
const reset = await localResetAgentKey(subject, id);
|
|
@@ -158,10 +156,10 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
|
158
156
|
const file = readLocalAgentFile(agentId);
|
|
159
157
|
const bundle: AgentBundle = {
|
|
160
158
|
version: 1,
|
|
161
|
-
agent: { id: file.id, name: file.name, key: file.key ?? '', stations: file.stations },
|
|
159
|
+
agent: { id: file.id, name: file.name ?? '', key: file.key ?? '', stations: file.stations },
|
|
162
160
|
connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, transport: c.transport, config: { ...c.config } })),
|
|
163
161
|
};
|
|
164
|
-
if (bundle.agent.key === '') throw new ApiError(`agent
|
|
162
|
+
if (bundle.agent.key === '') throw new ApiError(`agent ${agent.id} has no key to bundle`, 400);
|
|
165
163
|
return bundle;
|
|
166
164
|
},
|
|
167
165
|
restore: async (subject, bundle, mode) => {
|
package/runtime/runtime.json
CHANGED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { agentsDir } from './files.js';
|
|
5
|
-
import { LOCAL_PROJECT_ID, localCreateAgent, localOwner, storedAgents } from './file-admin.js';
|
|
6
|
-
|
|
7
|
-
export const SEED_FILE = '.agent';
|
|
8
|
-
|
|
9
|
-
export type Seeded = 'created' | 'present' | 'none' | 'failed';
|
|
10
|
-
|
|
11
|
-
export async function seedAgent(dir = agentsDir()): Promise<Seeded> {
|
|
12
|
-
const path = join(dir, SEED_FILE);
|
|
13
|
-
if (!existsSync(path)) return 'none';
|
|
14
|
-
const name = readFileSync(path, 'utf8').trim();
|
|
15
|
-
const owner = localOwner(dir);
|
|
16
|
-
if (owner === null) {
|
|
17
|
-
log.warn({ name }, 'agent seed: no owner yet, leaving the seed for the next boot');
|
|
18
|
-
return 'failed';
|
|
19
|
-
}
|
|
20
|
-
if (storedAgents(dir).length > 0) {
|
|
21
|
-
rmSync(path, { force: true });
|
|
22
|
-
return 'present';
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const made = await localCreateAgent(owner, LOCAL_PROJECT_ID, name, dir);
|
|
26
|
-
rmSync(path, { force: true });
|
|
27
|
-
log.info({ id: made.id, name: made.name }, 'agent seed: agent created from the launch name');
|
|
28
|
-
return 'created';
|
|
29
|
-
} catch (err) {
|
|
30
|
-
log.warn({ name, err: errMsg(err) }, 'agent seed: could not create the agent');
|
|
31
|
-
return 'failed';
|
|
32
|
-
}
|
|
33
|
-
}
|