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

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.216",
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.216",
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",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.215"
2
+ "version": "0.1.0-beta.216"
3
3
  }