@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 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
- ? 'several agents live here; set METRO_AGENT=<name> to route through the daemon, Claude Code talks to Anthropic directly for now'
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(), process.env.METRO_AGENT);
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(), process.env.METRO_AGENT);
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 [agent]
29
- print the agent this machine runs
30
- metro mcp [agent]
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
- export function localAgents(dir = agentsDir()) {
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 entry of readdirSync(dir, { withFileTypes: true })) {
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.name === 'string' && typeof file.key === 'string')
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 entry of readdirSync(dir, { withFileTypes: true })) {
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.131",
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.131",
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 localAgents() {
19
- const dir = agentsDir();
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 entry of readdirSync(dir, { withFileTypes: true })) {
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.name === 'string' && typeof file.key === 'string')
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 and create or restore one in the web UI'
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 if (req.method === 'GET') await handleList(req, res, deps, session);
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', 'POST'],
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 name !== 'string' || typeof key !== 'string' || key === '') throw bad('agent has no name or key');
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 { existsSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
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
- AGENT_FILE,
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.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
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: string,
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
- const id = freshId(new Set(existing.map((s) => s.file.id)));
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(folder);
122
+ ensureSecureDir(dir);
128
123
  save({
129
- path: join(folder, AGENT_FILE),
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 '${stored.file.name}' still has ${String(attached)} station account(s) attached — detach them first`,
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
- const fresh = join(dir, agent.name, AGENT_FILE);
200
- if (same === undefined && existsSync(fresh))
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 ?? fresh, previous: same?.file };
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 ?? agent.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
- if (typeof name !== 'string' || !AGENT_NAME_RE.test(name))
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
- export function listAgentFiles(dir = agentsDir()): string[] {
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 { seedAgent } from '../agents/seed.js';
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
- await seedAgent();
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 { claudeSetupStatus, ensureClaudeSetup, isPermissionMode, permissionMode, setPermissionMode, setPrivacy, type PermissionMode, type SetupDeps } from './setup.js';
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
- if (body.privacy !== undefined && typeof body.privacy !== 'boolean') throw new ApiError('privacy must be true or false', 400);
151
- if (body.permissionMode !== undefined && !isPermissionMode(body.permissionMode)) throw new ApiError('permissionMode must be auto or bypass', 400);
152
- if (body.privacy === undefined && body.permissionMode === undefined) throw new ApiError('nothing to change', 400);
153
- return { ...(typeof body.privacy === 'boolean' ? { privacy: body.privacy } : {}), ...(isPermissionMode(body.permissionMode) ? { permissionMode: body.permissionMode } : {}) };
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.permissionMode !== undefined && change.permissionMode !== permissionMode(setup.agents)) {
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 '${agent.name}' has no key to bundle`, 400);
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) => {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.131"
2
+ "version": "0.1.0-beta.133"
3
3
  }
@@ -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
- }