@stage-labs/metro 0.1.0-beta.65 → 0.1.0-beta.67

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/cli.js CHANGED
@@ -7,6 +7,7 @@ import { bedrock } from './bedrock.js';
7
7
  import { installPlugin } from './plugin.js';
8
8
  import { update } from './update.js';
9
9
  import { serve } from './serve.js';
10
+ import { service } from './service.js';
10
11
  import { currentVersion } from './version.js';
11
12
  const USAGE = `metro — run your agent on this machine
12
13
 
@@ -16,8 +17,13 @@ const USAGE = `metro — run your agent on this machine
16
17
  permanent https://<machine>.<tailnet>.ts.net (Tailscale installed and
17
18
  signed in on this machine); the page at metro.box manages it through the
18
19
  link it prints; --owner names the one wallet that may sign in, needed on
19
- the first start only
20
- metro stop stop the metro daemon on this machine
20
+ the first start only; Stop on the page parks the daemon and keeps the
21
+ address, so Start works from the page too
22
+ metro service install [--port <n>] [--owner <address>]
23
+ run metro serve as a service (systemd on Linux, launchd on macOS): it
24
+ starts at boot and after a crash, so the page can stop, start and
25
+ restart it with no shell; metro service uninstall and status as well
26
+ metro stop stop metro on this machine, metro serve included
21
27
  metro tail <agent-id>
22
28
  follow this machine's inbound events, one JSON line each
23
29
  metro whoami [agent]
@@ -64,6 +70,7 @@ const COMMANDS = {
64
70
  return 0;
65
71
  },
66
72
  serve: () => serve(process.argv.slice(3)),
73
+ service: () => service(process.argv.slice(3)),
67
74
  stop: stopDaemon,
68
75
  tail: () => tailEvents(process.argv.slice(3)),
69
76
  mcp: async () => {
package/dist/hold.js ADDED
@@ -0,0 +1,197 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ const FUNNEL_RETRY_MS = 2_000;
5
+ const FUNNEL_EXIT_WAIT_MS = 5_000;
6
+ const LISTEN_ATTEMPTS = 10;
7
+ const LISTEN_RETRY_MS = 500;
8
+ const STOPPED = 503;
9
+ export const STOPPED_MESSAGE = 'metro is stopped on this machine. Start it from the Server page on metro.box, or run metro serve.';
10
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ function corsHeaders(req) {
12
+ return {
13
+ 'access-control-allow-origin': req.headers.origin ?? '*',
14
+ 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
15
+ 'access-control-allow-headers': 'Authorization, Content-Type',
16
+ 'access-control-allow-private-network': 'true',
17
+ 'access-control-max-age': '86400',
18
+ vary: 'Origin',
19
+ };
20
+ }
21
+ function sendJson(req, res, status, body) {
22
+ res.writeHead(status, {
23
+ 'content-type': 'application/json',
24
+ 'cache-control': 'no-store',
25
+ ...corsHeaders(req),
26
+ });
27
+ res.end(JSON.stringify(body));
28
+ }
29
+ export function holdRequest(req, res, info, onStart) {
30
+ const path = (req.url ?? '').split('?')[0] ?? '';
31
+ if (req.method === 'OPTIONS') {
32
+ res.writeHead(204, corsHeaders(req)).end();
33
+ return;
34
+ }
35
+ if (path === '/api/mode' && req.method === 'GET') {
36
+ sendJson(req, res, 200, {
37
+ mode: 'local',
38
+ owner: info.owner,
39
+ project: 'localdaemon',
40
+ version: info.version,
41
+ stopped: true,
42
+ });
43
+ return;
44
+ }
45
+ if (path === '/api/start') {
46
+ if (req.method !== 'POST') {
47
+ sendJson(req, res, 405, { error: 'method not allowed' });
48
+ return;
49
+ }
50
+ sendJson(req, res, 200, { starting: true });
51
+ onStart();
52
+ return;
53
+ }
54
+ if (path === '/health' || path === '/healthz') {
55
+ sendJson(req, res, STOPPED, { status: 'stopped', version: info.version });
56
+ return;
57
+ }
58
+ sendJson(req, res, STOPPED, { error: STOPPED_MESSAGE, stopped: true });
59
+ }
60
+ export const holdServer = (info, onStart) => createServer((req, res) => {
61
+ holdRequest(req, res, info, onStart);
62
+ });
63
+ const lastLine = (chunk) => String(chunk).trim().split('\n').at(-1) ?? '';
64
+ class HeldFunnel {
65
+ bin;
66
+ port;
67
+ log;
68
+ child = null;
69
+ closed = false;
70
+ timer = null;
71
+ exits = 0;
72
+ constructor(bin, port, log) {
73
+ this.bin = bin;
74
+ this.port = port;
75
+ this.log = log;
76
+ }
77
+ start() {
78
+ if (this.closed)
79
+ return;
80
+ const child = spawn(this.bin, ['funnel', String(this.port)], { stdio: ['ignore', 'pipe', 'pipe'] });
81
+ this.child = child;
82
+ let last = '';
83
+ const read = (chunk) => {
84
+ const line = lastLine(chunk);
85
+ if (line !== '')
86
+ last = line;
87
+ };
88
+ child.stdout?.on('data', read);
89
+ child.stderr?.on('data', read);
90
+ child.on('error', (err) => {
91
+ this.child = null;
92
+ this.closed = true;
93
+ this.log(`funnel could not start (${err.message}); the loopback address is still held`);
94
+ });
95
+ child.on('exit', () => {
96
+ this.child = null;
97
+ if (this.closed)
98
+ return;
99
+ this.exits += 1;
100
+ if (this.exits > 1)
101
+ this.log(`funnel exited (${last}); retrying in ${String(FUNNEL_RETRY_MS / 1000)}s`);
102
+ this.timer = setTimeout(() => {
103
+ this.start();
104
+ }, FUNNEL_RETRY_MS);
105
+ });
106
+ }
107
+ async stop() {
108
+ this.closed = true;
109
+ if (this.timer !== null)
110
+ clearTimeout(this.timer);
111
+ const child = this.child;
112
+ this.child = null;
113
+ if (child === null)
114
+ return;
115
+ if (child.exitCode !== null || child.signalCode !== null)
116
+ return;
117
+ await new Promise((resolve) => {
118
+ const kill = setTimeout(() => {
119
+ child.kill('SIGKILL');
120
+ }, FUNNEL_EXIT_WAIT_MS);
121
+ child.once('exit', () => {
122
+ clearTimeout(kill);
123
+ resolve();
124
+ });
125
+ child.kill('SIGINT');
126
+ });
127
+ }
128
+ }
129
+ async function listen(server, info) {
130
+ for (let attempt = 1;; attempt += 1) {
131
+ try {
132
+ await new Promise((resolve, reject) => {
133
+ server.once('error', reject);
134
+ server.listen(info.port, info.host, () => {
135
+ server.off('error', reject);
136
+ resolve();
137
+ });
138
+ });
139
+ return;
140
+ }
141
+ catch (err) {
142
+ if (attempt >= LISTEN_ATTEMPTS)
143
+ throw err;
144
+ await wait(LISTEN_RETRY_MS);
145
+ }
146
+ }
147
+ }
148
+ const close = (server) => new Promise((resolve) => {
149
+ server.close(() => {
150
+ resolve();
151
+ });
152
+ server.closeAllConnections();
153
+ });
154
+ function releaseLock(lockFile) {
155
+ try {
156
+ if (readFileSync(lockFile, 'utf8').trim() === String(process.pid))
157
+ rmSync(lockFile, { force: true });
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ }
163
+ export const holdBanner = (info) => `metro is stopped. Holding http://${info.host}:${String(info.port)}${info.funnel === null ? '' : ' and the Funnel address'} ` +
164
+ 'until Start on the Server page; Ctrl-C or metro stop ends metro serve';
165
+ export async function holdUntilStart(info, deps = {}) {
166
+ const signals = deps.signals ?? process;
167
+ const log = deps.log ??
168
+ ((line) => {
169
+ process.stderr.write(`${line}\n`);
170
+ });
171
+ let finish = () => undefined;
172
+ const ended = new Promise((resolve) => {
173
+ finish = resolve;
174
+ });
175
+ const server = holdServer(info, () => {
176
+ finish('start');
177
+ });
178
+ const onSignal = () => {
179
+ finish('exit');
180
+ };
181
+ if (info.lockFile !== null)
182
+ writeFileSync(info.lockFile, String(process.pid));
183
+ await listen(server, info);
184
+ const funnel = info.funnel === null ? null : new HeldFunnel(info.funnel, info.port, log);
185
+ funnel?.start();
186
+ signals.on('SIGINT', onSignal);
187
+ signals.on('SIGTERM', onSignal);
188
+ log(holdBanner(info));
189
+ const end = await ended;
190
+ signals.off('SIGINT', onSignal);
191
+ signals.off('SIGTERM', onSignal);
192
+ await funnel?.stop();
193
+ await close(server);
194
+ if (info.lockFile !== null)
195
+ releaseLock(info.lockFile);
196
+ return end;
197
+ }
package/dist/runtime.js CHANGED
@@ -32,6 +32,7 @@ export function findBun() {
32
32
  export const localPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
33
33
  export const localUrl = () => `http://127.0.0.1:${String(localPort())}`;
34
34
  export const RESTART_CODE = 75;
35
+ export const HOLD_CODE = 76;
35
36
  function spawnOnce(plan) {
36
37
  const child = spawn(plan.command, plan.args, {
37
38
  cwd: plan.cwd,
@@ -53,11 +54,17 @@ function spawnOnce(plan) {
53
54
  });
54
55
  });
55
56
  }
56
- export async function spawnPlan(plan) {
57
+ export async function spawnPlan(plan, hold) {
57
58
  for (;;) {
58
59
  const code = await spawnOnce(plan());
59
- if (code !== RESTART_CODE)
60
+ if (code === RESTART_CODE) {
61
+ process.stderr.write('metro was updated; restarting the daemon on the new version\n');
62
+ continue;
63
+ }
64
+ if (code !== HOLD_CODE || hold === undefined)
60
65
  return code;
61
- process.stderr.write('metro was updated; restarting the daemon on the new version\n');
66
+ if ((await hold()) === 'exit')
67
+ return 0;
68
+ process.stderr.write('starting the daemon again\n');
62
69
  }
63
70
  }
package/dist/serve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { agentsDir } from './local.js';
5
5
  import { currentVersion } from './version.js';
@@ -7,6 +7,7 @@ import { serveLockedBy, serveStateDir } from './control.js';
7
7
  import { findBun, localPort, SERVER_ENTRY, spawnPlan } from './runtime.js';
8
8
  import { prepareRuntime } from './runtime-install.js';
9
9
  import { ensureNodeName } from './node-name.js';
10
+ import { holdUntilStart } from './hold.js';
10
11
  const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
11
12
  const PORT_FLAG = /^--port=(.*)$/;
12
13
  const OWNER_FLAG = /^--owner=(.*)$/;
@@ -64,6 +65,25 @@ export function requireOwner(owner, dir = agentsDir()) {
64
65
  join(dir, '.owner') +
65
66
  ':\n metro serve --owner <address>');
66
67
  }
68
+ export function readOwner(dir = agentsDir()) {
69
+ try {
70
+ const raw = readFileSync(join(dir, '.owner'), 'utf8').trim().toLowerCase();
71
+ return ADDRESS.test(raw) ? raw : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ export function holdInfo(port, owner, tailscaleBin) {
78
+ return {
79
+ port,
80
+ host: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
81
+ owner: owner ?? readOwner(),
82
+ version: currentVersion(),
83
+ funnel: tailscaleBin,
84
+ lockFile: join(process.env.METRO_STATE_DIR ?? serveStateDir(), '.tail-lock'),
85
+ };
86
+ }
67
87
  function tailscaleStatus(bin) {
68
88
  const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
69
89
  if (run.error !== undefined || run.status !== 0)
@@ -123,5 +143,5 @@ export function serve(argv) {
123
143
  const tailscaleBin = findTailscale();
124
144
  const node = ensureNodeName(tailscaleBin);
125
145
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}, published as ${node} on your tailnet\n`);
126
- return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }));
146
+ return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, owner, tailscaleBin }), () => holdUntilStart(holdInfo(port, owner, tailscaleBin)));
127
147
  }
@@ -0,0 +1,224 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir, platform, userInfo } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { serveLockedBy } from './control.js';
6
+ import { findBun } from './runtime.js';
7
+ import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
8
+ const USAGE = 'usage: metro service install [--port <n>] [--owner <address>] | uninstall | status';
9
+ const SERVICE = 'metro';
10
+ const LABEL = 'box.metro.serve';
11
+ const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
12
+ const PLAIN_WORD = /^[A-Za-z0-9_/.:=+@,-]+$/;
13
+ const carriedEnv = (env) => Object.entries(env)
14
+ .filter((entry) => CARRIED.test(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '')
15
+ .sort(([a], [b]) => a.localeCompare(b));
16
+ const cacheDir = (host) => {
17
+ const xdg = host.env.XDG_CACHE_HOME?.trim();
18
+ return xdg === undefined || xdg === '' ? join(host.home, '.cache') : xdg;
19
+ };
20
+ function unitWord(arg) {
21
+ const escaped = arg.replaceAll('%', '%%');
22
+ if (PLAIN_WORD.test(arg))
23
+ return escaped;
24
+ return `"${escaped.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
25
+ }
26
+ export function systemdUnit(host, exec, target) {
27
+ const env = carriedEnv(host.env).map(([key, value]) => `Environment=${unitWord(`${key}=${value}`)}`);
28
+ return [
29
+ '[Unit]',
30
+ 'Description=Metro daemon (metro serve)',
31
+ 'After=network-online.target tailscaled.service',
32
+ 'Wants=network-online.target',
33
+ '',
34
+ '[Service]',
35
+ `ExecStart=${exec.map(unitWord).join(' ')}`,
36
+ 'Restart=always',
37
+ 'RestartSec=2',
38
+ `WorkingDirectory=${unitWord(host.home)}`,
39
+ ...env,
40
+ '',
41
+ '[Install]',
42
+ `WantedBy=${target}`,
43
+ '',
44
+ ].join('\n');
45
+ }
46
+ const xml = (text) => text
47
+ .replaceAll('&', '&amp;')
48
+ .replaceAll('<', '&lt;')
49
+ .replaceAll('>', '&gt;')
50
+ .replaceAll('"', '&quot;')
51
+ .replaceAll("'", '&apos;');
52
+ export function launchdPlist(host, exec, logFile) {
53
+ const env = carriedEnv(host.env).map(([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`);
54
+ return [
55
+ '<?xml version="1.0" encoding="UTF-8"?>',
56
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
57
+ '<plist version="1.0">',
58
+ '<dict>',
59
+ ` <key>Label</key><string>${LABEL}</string>`,
60
+ ' <key>ProgramArguments</key>',
61
+ ' <array>',
62
+ ...exec.map((arg) => ` <string>${xml(arg)}</string>`),
63
+ ' </array>',
64
+ ' <key>EnvironmentVariables</key>',
65
+ ' <dict>',
66
+ ...env,
67
+ ' </dict>',
68
+ ` <key>WorkingDirectory</key><string>${xml(host.home)}</string>`,
69
+ ' <key>RunAtLoad</key><true/>',
70
+ ' <key>KeepAlive</key><true/>',
71
+ ` <key>StandardOutPath</key><string>${xml(logFile)}</string>`,
72
+ ` <key>StandardErrorPath</key><string>${xml(logFile)}</string>`,
73
+ '</dict>',
74
+ '</plist>',
75
+ '',
76
+ ].join('\n');
77
+ }
78
+ function systemdPlan(host, exec) {
79
+ const system = host.uid === 0;
80
+ const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
81
+ const file = system
82
+ ? `/etc/systemd/system/${SERVICE}.service`
83
+ : join(host.home, '.config', 'systemd', 'user', `${SERVICE}.service`);
84
+ return {
85
+ kind: 'systemd',
86
+ file,
87
+ content: systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target'),
88
+ dirs: [],
89
+ install: [{ args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
90
+ uninstall: [{ args: [...ctl, 'disable', '--now', SERVICE], mayFail: true }],
91
+ reload: [{ args: [...ctl, 'daemon-reload'], mayFail: true }],
92
+ status: { args: [...ctl, 'is-active', SERVICE], mayFail: true },
93
+ hints: [
94
+ `Logs: journalctl ${system ? '' : '--user '}-u ${SERVICE} -f`,
95
+ ...(system
96
+ ? []
97
+ : [`A user service stops with your login session; keep it up without one: loginctl enable-linger ${host.user}`]),
98
+ ],
99
+ };
100
+ }
101
+ function launchdPlan(host, exec) {
102
+ const file = join(host.home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
103
+ const domain = `gui/${String(host.uid)}`;
104
+ const logFile = join(cacheDir(host), 'metro', 'serve', 'service.log');
105
+ return {
106
+ kind: 'launchd',
107
+ file,
108
+ content: launchdPlist(host, exec, logFile),
109
+ dirs: [dirname(logFile)],
110
+ install: [
111
+ { args: ['launchctl', 'bootout', `${domain}/${LABEL}`], mayFail: true },
112
+ { args: ['launchctl', 'bootstrap', domain, file] },
113
+ ],
114
+ uninstall: [{ args: ['launchctl', 'bootout', `${domain}/${LABEL}`], mayFail: true }],
115
+ reload: [],
116
+ status: { args: ['launchctl', 'print', `${domain}/${LABEL}`], mayFail: true },
117
+ hints: [`Logs: tail -f ${logFile}`],
118
+ };
119
+ }
120
+ export function servicePlan(host, serveArgs) {
121
+ const exec = [host.node, host.cli, 'serve', ...serveArgs];
122
+ if (host.platform === 'linux')
123
+ return systemdPlan(host, exec);
124
+ if (host.platform === 'darwin')
125
+ return launchdPlan(host, exec);
126
+ throw new Error(`metro service supports Linux (systemd) and macOS (launchd), not ${host.platform}`);
127
+ }
128
+ function install(serveArgs, deps) {
129
+ const { owner } = parseServeArgs(serveArgs);
130
+ deps.preflight(owner);
131
+ const pid = deps.running();
132
+ if (pid !== null)
133
+ throw new Error(`a metro serve is running on this machine (pid ${String(pid)}). Stop it first (metro stop), then install: the service takes over from there`);
134
+ const plan = servicePlan(deps.host, serveArgs);
135
+ for (const dir of plan.dirs)
136
+ deps.mkdir(dir);
137
+ deps.write(plan.file, plan.content);
138
+ for (const command of plan.install)
139
+ deps.run(command);
140
+ deps.out(`Installed ${plan.file}: metro serve now starts at boot and after a crash, and the Server page on metro.box can stop, start and restart it.`);
141
+ for (const hint of plan.hints)
142
+ deps.out(hint);
143
+ return 0;
144
+ }
145
+ function uninstall(deps) {
146
+ const plan = servicePlan(deps.host, []);
147
+ if (!deps.exists(plan.file)) {
148
+ deps.out('metro is not installed as a service on this machine');
149
+ return 1;
150
+ }
151
+ for (const command of plan.uninstall)
152
+ deps.run(command);
153
+ deps.remove(plan.file);
154
+ for (const command of plan.reload)
155
+ deps.run(command);
156
+ deps.out(`Removed ${plan.file}; a running daemon was stopped, and metro serve no longer starts on its own`);
157
+ return 0;
158
+ }
159
+ function status(deps) {
160
+ const plan = servicePlan(deps.host, []);
161
+ if (!deps.exists(plan.file)) {
162
+ deps.out(`not installed (metro service install writes ${plan.file})`);
163
+ return 1;
164
+ }
165
+ const active = deps.run(plan.status).status === 0;
166
+ deps.out(active
167
+ ? `running as a ${plan.kind} service (${plan.file})`
168
+ : `installed as a ${plan.kind} service (${plan.file}), not running`);
169
+ return active ? 0 : 1;
170
+ }
171
+ function realDeps() {
172
+ const who = userInfo();
173
+ return {
174
+ host: {
175
+ platform: platform(),
176
+ uid: who.uid,
177
+ user: who.username,
178
+ home: homedir(),
179
+ node: process.execPath,
180
+ cli: resolve(process.argv[1] ?? ''),
181
+ env: process.env,
182
+ },
183
+ run: (command) => {
184
+ const [bin = '', ...args] = command.args;
185
+ const result = spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
186
+ const code = result.status ?? 1;
187
+ if (code !== 0 && command.mayFail !== true) {
188
+ const said = [result.stderr, result.stdout, result.error?.message].find((t) => typeof t === 'string' && t.trim() !== '') ?? '';
189
+ throw new Error(`${command.args.join(' ')} failed (exit ${String(code)}): ${said.trim()}`);
190
+ }
191
+ return { status: code, output: `${result.stdout}${result.stderr}` };
192
+ },
193
+ running: serveLockedBy,
194
+ preflight: (owner) => {
195
+ requireOwner(owner);
196
+ findBun();
197
+ findTailscale();
198
+ },
199
+ mkdir: (dir) => {
200
+ mkdirSync(dir, { recursive: true });
201
+ },
202
+ write: (file, content) => {
203
+ mkdirSync(dirname(file), { recursive: true });
204
+ writeFileSync(file, content, { mode: 0o644 });
205
+ },
206
+ remove: (file) => {
207
+ rmSync(file, { force: true });
208
+ },
209
+ exists: existsSync,
210
+ out: (line) => {
211
+ process.stderr.write(`${line}\n`);
212
+ },
213
+ };
214
+ }
215
+ export function service(argv, deps = realDeps()) {
216
+ const [verb, ...rest] = argv;
217
+ if (verb === 'install')
218
+ return Promise.resolve(install(rest, deps));
219
+ if (verb === 'uninstall' && rest.length === 0)
220
+ return Promise.resolve(uninstall(deps));
221
+ if (verb === 'status' && rest.length === 0)
222
+ return Promise.resolve(status(deps));
223
+ throw new Error(USAGE);
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.65",
3
+ "version": "0.1.0-beta.67",
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": {
@@ -111,6 +111,10 @@ async function sessionApis(): Promise<SessionApis> {
111
111
  exitCode = RESTART_EXIT;
112
112
  onShutdown();
113
113
  },
114
+ stop: () => {
115
+ exitCode = HOLD_EXIT;
116
+ onShutdown();
117
+ },
114
118
  closeAgentSession,
115
119
  gatherAccounts: gatherAccountsForAgents,
116
120
  capabilities: accountStationCapabilities,
@@ -162,6 +166,7 @@ async function main(): Promise<void> {
162
166
 
163
167
  const SHUTDOWN_TIMEOUT_MS = 3_000;
164
168
  const RESTART_EXIT = 75;
169
+ const HOLD_EXIT = 76;
165
170
  let exitCode = 0;
166
171
  let shuttingDown = false;
167
172
  async function shutdown(): Promise<void> {
@@ -0,0 +1,64 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { ApiError } from './api-error.js';
3
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
4
+ import { errMsg, log } from './log.js';
5
+
6
+ const STOP = '/api/stop';
7
+ const RESTART = '/api/restart';
8
+ const EXIT_DELAY_MS = 500;
9
+
10
+ export interface ControlApiDeps {
11
+ authorize: (subject: string) => void;
12
+ restart: () => void;
13
+ stop: () => void;
14
+ served?: () => boolean;
15
+ }
16
+
17
+ interface Action {
18
+ run: () => void;
19
+ body: Record<string, boolean>;
20
+ }
21
+
22
+ const servedByCli = (): boolean => (process.env.METRO_CLI_BIN?.trim() ?? '') !== '';
23
+
24
+ function actionFor(path: string, deps: ControlApiDeps): Action | null {
25
+ if (path === STOP) return { run: deps.stop, body: { stopping: true } };
26
+ if (path === RESTART) return { run: deps.restart, body: { restarting: true } };
27
+ return null;
28
+ }
29
+
30
+ export function handleControlRequest(req: IncomingMessage, res: ServerResponse, deps: ControlApiDeps): boolean {
31
+ const path = (req.url ?? '').split('?')[0] ?? '';
32
+ const action = actionFor(path, deps);
33
+ if (action === null) return false;
34
+ if (req.method === 'OPTIONS') {
35
+ res.writeHead(204, cors(req)).end();
36
+ return true;
37
+ }
38
+ if (req.method !== 'POST') {
39
+ sendJson(req, res, 405, { error: 'method not allowed' });
40
+ return true;
41
+ }
42
+ Promise.resolve()
43
+ .then(async () => {
44
+ const session = await apiSession(req);
45
+ if (!session) throw new ApiError('unauthorized', 401);
46
+ deps.authorize(session.subject);
47
+ if (!(deps.served ?? servedByCli)())
48
+ throw new ApiError(
49
+ 'this daemon was not started by metro serve, so nothing on the machine would bring it back: use the shell instead',
50
+ 400,
51
+ );
52
+ log.info({ path, subject: session.subject }, 'control-api: requested from the page');
53
+ setTimeout(action.run, EXIT_DELAY_MS).unref();
54
+ sendJson(req, res, 200, action.body);
55
+ })
56
+ .catch((err: unknown) => {
57
+ if (err instanceof ApiError) apiFailure(req, res, err, 'control-api');
58
+ else {
59
+ log.warn({ err: errMsg(err) }, 'control-api: request failed');
60
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'control api failed' });
61
+ }
62
+ });
63
+ return true;
64
+ }
@@ -52,6 +52,7 @@ import type { StationName } from '../db/stations.js';
52
52
  export interface LocalModeDeps {
53
53
  syncStations: (station: StationName) => Promise<void>;
54
54
  restart: () => void;
55
+ stop: () => void;
55
56
  closeAgentSession: (id: string) => Promise<boolean>;
56
57
  gatherAccounts: AgentApiDeps['gatherAccounts'];
57
58
  capabilities: AgentApiDeps['capabilities'];
@@ -190,6 +191,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
190
191
  localCli,
191
192
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
192
193
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
194
+ controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
193
195
  machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
194
196
  terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
195
197
  identity: { owner: localOwner },
@@ -18,6 +18,7 @@ import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
18
18
  import { handleServersApiRequest, type ServersApiDeps } from './servers-api.js';
19
19
  import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
20
20
  import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
21
+ import { handleControlRequest, type ControlApiDeps } from './control-api.js';
21
22
  import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
22
23
  import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
23
24
 
@@ -28,6 +29,7 @@ export interface SessionApis {
28
29
  vaultApi?: VaultApiDeps;
29
30
  serversApi?: ServersApiDeps;
30
31
  updateApi?: UpdateApiDeps;
32
+ controlApi?: ControlApiDeps;
31
33
  machineApi?: MachineApiDeps;
32
34
  terminalApi?: TerminalApiDeps;
33
35
  localCli?: LocalCliDeps;
@@ -54,6 +56,7 @@ export function handleSessionApis(
54
56
  ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
55
57
  ...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
56
58
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
59
+ ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
57
60
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
58
61
  ...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
59
62
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import type { IncomingMessage, Server } from 'node:http';
2
3
  import type { Duplex } from 'node:stream';
3
4
  import { WebSocketServer, type RawData, type WebSocket } from 'ws';
@@ -19,10 +20,28 @@ export interface TerminalSocketDeps {
19
20
  const rawBytes = (data: RawData): Buffer =>
20
21
  Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
21
22
 
23
+ export const resizeWindowArgs = (session: string, cols: number, rows: number): string[] => [
24
+ 'resize-window',
25
+ '-t',
26
+ `${session}:`,
27
+ '-x',
28
+ String(cols),
29
+ '-y',
30
+ String(rows),
31
+ ];
32
+
33
+ function sizeTmuxWindow(command: string[], session: string, cols: number, rows: number): void {
34
+ if (command[0] !== 'tmux') return;
35
+ const child = spawn('tmux', resizeWindowArgs(session, cols, rows), { stdio: 'ignore' });
36
+ child.on('error', (err) => {
37
+ log.debug({ err: errMsg(err) }, 'terminal: resize-window failed');
38
+ });
39
+ }
40
+
22
41
  const dimension = (raw: unknown, fallback: number): number =>
23
42
  typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : fallback;
24
43
 
25
- function runTerminal(ws: WebSocket, command: string[], subject: string): void {
44
+ function runTerminal(ws: WebSocket, command: string[], subject: string, session: string): void {
26
45
  const terminal = new Bun.Terminal({
27
46
  cols: DEFAULT_COLS,
28
47
  rows: DEFAULT_ROWS,
@@ -43,8 +62,12 @@ function runTerminal(ws: WebSocket, command: string[], subject: string): void {
43
62
  }
44
63
  try {
45
64
  const control: unknown = JSON.parse(bytes.toString('utf8'));
46
- if (isRecord(control) && 'cols' in control)
47
- terminal.resize(dimension(control.cols, DEFAULT_COLS), dimension(control.rows, DEFAULT_ROWS));
65
+ if (isRecord(control) && 'cols' in control) {
66
+ const cols = dimension(control.cols, DEFAULT_COLS);
67
+ const rows = dimension(control.rows, DEFAULT_ROWS);
68
+ terminal.resize(cols, rows);
69
+ sizeTmuxWindow(command, session, cols, rows);
70
+ }
48
71
  } catch (err) {
49
72
  log.warn({ err: errMsg(err) }, 'terminal: bad control frame');
50
73
  }
@@ -85,7 +108,7 @@ export function attachTerminalSockets(server: Server, deps: TerminalSocketDeps):
85
108
  return;
86
109
  }
87
110
  wss.handleUpgrade(req, socket, head, (ws) => {
88
- runTerminal(ws, deps.command(grant.session), grant.subject);
111
+ runTerminal(ws, deps.command(grant.session), grant.subject, grant.session);
89
112
  });
90
113
  });
91
114
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.65"
2
+ "version": "0.1.0-beta.67"
3
3
  }