@stage-labs/metro 0.1.0-beta.215 → 0.1.0-beta.217

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.
@@ -0,0 +1,123 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
6
+ const STOP_MS = 30_000;
7
+ const SETTLE_MS = 5_000;
8
+ const POLL_MS = 250;
9
+ const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
10
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
11
+ export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
12
+ const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
13
+ export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
14
+ function startTime(pid) {
15
+ try {
16
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
17
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function processAlive(pid, procStart) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ }
27
+ catch (err) {
28
+ if (err.code !== 'EPERM')
29
+ return false;
30
+ }
31
+ const started = procStart === undefined ? null : startTime(pid);
32
+ return started === null || started === procStart;
33
+ }
34
+ function backgroundEntry(file, alive) {
35
+ try {
36
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
37
+ if (!isRecord(raw) || typeof raw.pid !== 'number' || typeof raw.sessionId !== 'string')
38
+ return null;
39
+ if (raw.kind === 'interactive' || typeof raw.kind !== 'string')
40
+ return null;
41
+ return alive(raw.pid, typeof raw.procStart === 'string' ? raw.procStart : undefined) ? raw.sessionId : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export function liveBackground(sessions, alive = processAlive) {
48
+ if (!existsSync(sessions))
49
+ return new Set();
50
+ const ids = readdirSync(sessions)
51
+ .filter((name) => name.endsWith('.json'))
52
+ .map((name) => backgroundEntry(join(sessions, name), alive));
53
+ return new Set(ids.filter((id) => id !== null));
54
+ }
55
+ function newestFirst(dir) {
56
+ if (!existsSync(dir))
57
+ return [];
58
+ return readdirSync(dir)
59
+ .filter((name) => name.endsWith('.jsonl'))
60
+ .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs }))
61
+ .sort((a, b) => b.at - a.at)
62
+ .map((file) => file.path);
63
+ }
64
+ function continuedIn(text) {
65
+ const last = text.split('\n').findLast((line) => line.includes('"type":"continued-in"'));
66
+ if (last === undefined)
67
+ return null;
68
+ try {
69
+ const record = JSON.parse(last);
70
+ return isRecord(record) && typeof record.continuedInSessionId === 'string' ? record.continuedInSessionId : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ const HAS_TURN = /"type":"(user|assistant)"/;
77
+ export function heldConversation(dir, live) {
78
+ if (live.size === 0)
79
+ return null;
80
+ for (const file of newestFirst(dir)) {
81
+ const text = readFileSync(file, 'utf8');
82
+ if (!HAS_TURN.test(text))
83
+ continue;
84
+ const moved = continuedIn(text);
85
+ if (moved !== null && live.has(moved))
86
+ return moved;
87
+ if (!live.has(basename(file, '.jsonl')))
88
+ return null;
89
+ }
90
+ return null;
91
+ }
92
+ function stopBackground(id, env = process.env) {
93
+ const run = spawnSync('claude', ['stop', id], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: STOP_MS });
94
+ if (run.error !== undefined)
95
+ return { ok: false, detail: run.error.message };
96
+ const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
97
+ return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
98
+ }
99
+ const sleep = (ms) => new Promise((resolve) => {
100
+ setTimeout(resolve, ms);
101
+ });
102
+ async function settled(sessions, held, alive, ms) {
103
+ if (!liveBackground(sessions, alive).has(held))
104
+ return true;
105
+ if (ms <= 0)
106
+ return false;
107
+ await sleep(POLL_MS);
108
+ return settled(sessions, held, alive, ms - POLL_MS);
109
+ }
110
+ export async function takeBackConversation(deps = {}) {
111
+ const env = deps.env ?? process.env;
112
+ const alive = deps.alive ?? processAlive;
113
+ const dir = claudeDir(env);
114
+ const sessions = join(dir, 'sessions');
115
+ const held = heldConversation(projectDir(dir, deps.cwd ?? process.cwd()), liveBackground(sessions, alive));
116
+ if (held === null)
117
+ return null;
118
+ const id = held.slice(0, 8);
119
+ const stopped = (deps.stop ?? stopBackground)(id, env);
120
+ if (!stopped.ok || !(await settled(sessions, held, alive, deps.settleMs ?? SETTLE_MS)))
121
+ return `the conversation is held by the Claude Code background session ${id} and it could not be stopped (${stopped.detail}), so Claude Code will refuse to continue it: run claude stop ${id}`;
122
+ return `the conversation had moved to the Claude Code background session ${id}, where no chat reaches it; stopped that session (its conversation is kept) and continuing it here`;
123
+ }
package/dist/claude.js CHANGED
@@ -6,6 +6,7 @@ import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
6
  import { localAgent } from './local.js';
7
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
8
8
  import { localPort, localUrl } from './runtime.js';
9
+ import { keepInSession, takeBackConversation } from './background.js';
9
10
  const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
10
11
  const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
11
12
  const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
@@ -132,7 +133,22 @@ function gatewayLaunchEnv(key, port) {
132
133
  process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
133
134
  return env;
134
135
  }
136
+ const continues = (args) => args.includes('-c') || args.includes('--continue');
137
+ async function reclaimConversation(extra) {
138
+ if (!continues(extra))
139
+ return;
140
+ try {
141
+ const note = await takeBackConversation();
142
+ if (note !== null)
143
+ process.stderr.write(`metro claude: ${note}\n`);
144
+ }
145
+ catch (err) {
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ process.stderr.write(`metro claude: could not check for a Claude Code background session holding the conversation (${message})\n`);
148
+ }
149
+ }
135
150
  export async function launchClaude(extra) {
151
+ await reclaimConversation(extra);
136
152
  const decision = await verdict();
137
153
  const port = localPort();
138
154
  const mcp = mcpConfigFor(await servedKey(decision), port);
@@ -143,9 +159,9 @@ export async function launchClaude(extra) {
143
159
  try {
144
160
  if ('skip' in decision) {
145
161
  process.stderr.write(`metro claude: ${decision.skip}\n`);
146
- return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), channelEnv(process.env));
162
+ return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), keepInSession(channelEnv(process.env)));
147
163
  }
148
- return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), channelEnv(gatewayLaunchEnv(decision.key, port)));
164
+ return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), keepInSession(channelEnv(gatewayLaunchEnv(decision.key, port))));
149
165
  }
150
166
  finally {
151
167
  mcp?.cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.215",
3
+ "version": "0.1.0-beta.217",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.215",
3
+ "version": "0.1.0-beta.217",
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, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -228,7 +228,7 @@ async function applied(deps: AccountApiDeps): Promise<boolean> {
228
228
  await deps.reloadAgents();
229
229
  return true;
230
230
  } catch (err) {
231
- log.warn({ err: errMsg(err) }, 'account-api: allowlist reload failed, the change lands at the next boot');
231
+ log.warn({ err: errMsg(err) }, 'account-api: reload failed, the change lands at the next boot');
232
232
  return false;
233
233
  }
234
234
  }
@@ -275,7 +275,7 @@ async function handleEnabled(
275
275
  station: target.station,
276
276
  accountId: target.accountId,
277
277
  enabled,
278
- activated: await activate(deps, target.station),
278
+ activated: await applied(deps),
279
279
  });
280
280
  }
281
281
 
@@ -42,6 +42,11 @@ export function setDisabledAccounts(ids: Set<string>): void {
42
42
 
43
43
  export const accountEnabled = (station: string, accountId: string): boolean => !disabledAccounts.has(mapKey(station, accountId));
44
44
 
45
+ export function lineReceives(line: string): boolean {
46
+ const a = accountFromLine(line);
47
+ return a === undefined || accountEnabled(a.station, a.accountId);
48
+ }
49
+
45
50
  export function accountFromLine(
46
51
  line: string,
47
52
  ): { station: string; accountId: string } | undefined {
@@ -2,7 +2,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { z } from 'zod';
3
3
  import type { InboundRelay } from '../channels/inbound.js';
4
4
  import { holdPrompt, type Behavior } from '../approvals/pending.js';
5
- import { approversForLine } from '../agents/map.js';
5
+ import { approversForLine, lineReceives } from '../agents/map.js';
6
6
  import { metroCall } from './ctx.js';
7
7
  import { promptBody } from './permission-prompt.js';
8
8
 
@@ -46,6 +46,10 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
46
46
  deps.log('permission_request: known line is outside the agent scope, held for the page only', requestId);
47
47
  return undefined;
48
48
  }
49
+ if (!lineReceives(line)) {
50
+ deps.log('permission_request: the known chat does not receive messages now, so no answer can arrive; held for the page only', requestId);
51
+ return undefined;
52
+ }
49
53
  if (approversForLine(line).length === 0) {
50
54
  deps.log('permission_request: nobody on that chat may approve, held for the page only', requestId);
51
55
  return undefined;
@@ -22,7 +22,7 @@ import {
22
22
  type MetroEvent,
23
23
  } from '@metro-labs/core/events';
24
24
  import type { TrainEvent } from '@metro-labs/core/trains/protocol';
25
- import { agentForLine, agentIdForLine } from '../agents/map.js';
25
+ import { agentForLine, agentIdForLine, lineReceives } from '../agents/map.js';
26
26
  import type { Endpoint } from '@metro-labs/core/endpoints';
27
27
  import { findEndpointByWebhookId, listEndpoints, tokenMatches } from '../stations/webhook-endpoints.js';
28
28
  import { attachmentEventUrl, handleAttachRequest, webhookPort } from '../files/attach-serve.js';
@@ -107,6 +107,10 @@ type Emit = (entry: MetroEvent) => void;
107
107
  export function makeEmit(dedupSeq?: DedupSeq): Emit {
108
108
  const tracker = dedupSeq ?? makeDedupSeq();
109
109
  return function emit(entry: MetroEvent): void {
110
+ if (!lineReceives(entry.line)) {
111
+ log.debug({ line: entry.line }, 'emit: dropped, this channel does not receive messages');
112
+ return;
113
+ }
110
114
  const seq = tracker.admit(entry);
111
115
  if (seq === null) return;
112
116
  const enriched: MetroEvent = withAttachmentUrl({
@@ -165,10 +165,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
165
165
  }
166
166
  map[`${a.station}/${a.id}`] = agent.id;
167
167
  if (a.allowlist) allow[`${a.station}/${a.id}`] = a.allowlist;
168
- if (a.enabled === false) {
169
- disabled.add(`${a.station}/${a.id}`);
170
- continue;
171
- }
168
+ if (a.enabled === false) disabled.add(`${a.station}/${a.id}`);
172
169
  const cur = byStation.get(a.station);
173
170
  if (cur) cur.push(a);
174
171
  else byStation.set(a.station, [a]);
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.215"
2
+ "version": "0.1.0-beta.217"
3
3
  }