@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308

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.
Files changed (64) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  35. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  40. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  42. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  43. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  44. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/runner.json +1 -1
  47. package/dist/background.js +0 -141
  48. package/dist/claude.js +0 -204
  49. package/dist/mcp-config.js +0 -27
  50. package/dist/native-recovery-scan.js +0 -246
  51. package/dist/native-recovery-state.js +0 -154
  52. package/dist/native-recovery.js +0 -164
  53. package/dist/onboarding.js +0 -44
  54. package/dist/voice.js +0 -56
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  57. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  58. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  59. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  60. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  61. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  62. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  63. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  64. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -25,9 +25,9 @@ import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
25
25
  import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connector-tools.js';
26
26
  import { setAnnouncedFile } from '../mcp-server/announced.js';
27
27
  import { holdServerApproval } from '../mcp/server-approvals.js';
28
- import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
28
+ import { removeConnectorLeftovers } from '../connectors/leftovers.js';
29
29
  import { runningAsRoot } from '../metro-user/privilege.js';
30
- import { provisionAgentUser } from '../agent-user/provision.js';
30
+ import { prepareAgentRunner, provisionAgentUser } from '../agent-user/provision.js';
31
31
  import { applyVault } from '../vault/index.js';
32
32
  import { blockImds } from '../metro-user/imds.js';
33
33
  import { followRelease } from '../metro-user/root-follow.js';
@@ -139,6 +139,7 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
139
139
 
140
140
 
141
141
  async function main(): Promise<void> {
142
+ removeConnectorLeftovers();
142
143
  applyLocalOwner();
143
144
  installBearerSessions(agentsDir(), localOwner);
144
145
  watchPolicySnapshot();
@@ -191,7 +192,7 @@ async function startClaude(): Promise<void> {
191
192
  log.error('claude: Metro runs as root, so Claude Code never starts here; reinstall Metro as the metro user');
192
193
  return;
193
194
  }
194
- const agentUser = await provisionAgentUser();
195
+ const agentUser = provisionAgentUser();
195
196
  if (agentUser !== 'off') log.info({ agentUser }, 'agent-user: Claude Code runs as its own user');
196
197
  if (agentUser === 'failed') return;
197
198
  if (agentUser === 'ready') {
@@ -200,14 +201,10 @@ async function startClaude(): Promise<void> {
200
201
  const vault = (await applyVault()).status;
201
202
  if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
202
203
  }
203
- const plugin = await removeMetroPlugin().catch((err: unknown) => {
204
- log.warn({ err: errMsg(err) }, 'claude-plugin: could not check for the old metro plugin');
205
- return 'failed';
206
- });
207
- if (plugin !== 'done') log.info({ outcome: plugin }, 'claude-plugin: checked for the old metro Claude Code plugin');
208
204
  tryClaudeSetup();
209
205
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
210
206
  tryMemoryJob(memoryRoutine());
207
+ if (agentUser === 'ready') await prepareAgentRunner();
211
208
  watchSession();
212
209
  }
213
210
 
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
- import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson, type ApiSession } from '@metro-labs/http/api-http';
3
+ import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { githubSkillsAnswer } from './github-api.js';
@@ -35,7 +35,6 @@ import {
35
35
  stopSession,
36
36
  type SessionDeps,
37
37
  } from './session.js';
38
- import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
39
38
  import { receiveSessionFile, sessionFilePath } from './session-files.js';
40
39
  import { createReadStream } from '../agent-user/agent-fs.js';
41
40
  import { pipeline } from 'node:stream/promises';
@@ -61,7 +60,6 @@ export interface ClaudeApiDeps extends SetupApiDeps {
61
60
  dir?: () => string;
62
61
  login?: LoginDeps;
63
62
  claudeLogins?: ClaudeLoginDeps;
64
- version?: VersionDeps;
65
63
  skillSource?: GitHubSkills;
66
64
  }
67
65
 
@@ -135,11 +133,10 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
135
133
  return createClaudeSkill(body.name, typeof body.text === 'string' ? body.text : undefined, dir);
136
134
  }
137
135
 
138
- const ADMIN_ONLY = /^\/api\/claude\/(login|session|version|setup|skill-source)(\/|$)/;
136
+ const ADMIN_ONLY = /^\/api\/claude\/(login|session|setup|skill-source)(\/|$)/;
139
137
  const LOGIN = 'login';
140
138
  const SESSION = 'session';
141
139
  const SETUP = 'setup';
142
- const VERSION = 'version';
143
140
 
144
141
  function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
145
142
  if (body.action === 'start') {
@@ -209,18 +206,9 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
209
206
  return handler(query, dir, item);
210
207
  }
211
208
 
212
- function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
213
- const version: VersionDeps = { ...deps.version, session: deps.version?.session ?? deps.session };
214
- const method = req.method ?? 'GET';
215
- if (method === 'GET') return Promise.resolve(claudeVersion(version));
216
- if (method !== 'POST') throw new ApiError('method not allowed', 405);
217
- return Promise.resolve(updateClaude(version));
218
- }
219
-
220
- const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps, session: ApiSession) => Promise<unknown>> = {
209
+ const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
221
210
  [SESSION]: sessionAnswer,
222
211
  [SETUP]: setupAnswer,
223
- [VERSION]: versionAnswer,
224
212
  };
225
213
 
226
214
  function skillsAnswer(req: IncomingMessage, path: string, search: string, dir: string, source: GitHubSkills): unknown {
@@ -245,14 +233,14 @@ function fileAnswer(req: IncomingMessage, path: string, search: string, dir: str
245
233
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
246
234
  }
247
235
 
248
- function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps, session: ApiSession): unknown {
236
+ function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
249
237
  const dir = (deps.dir ?? claudeDir)();
250
238
  const [head = '', item = ''] = parts(path);
251
239
  if (head === LOGIN) return loginAnswer(req, item, deps);
252
240
  if (head === 'skill-source' && parts(path).length === 1) return githubSkillsAnswer(req, deps.skillSource);
253
241
  if (head === 'skills') return skillsAnswer(req, path, search, dir, sourceOf(deps));
254
242
  const single = item === '' ? SINGLETONS[head] : undefined;
255
- if (single !== undefined) return single(req, deps, session);
243
+ if (single !== undefined) return single(req, deps);
256
244
  return fileAnswer(req, path, search, dir);
257
245
  }
258
246
 
@@ -303,7 +291,7 @@ export function handleClaudeRequest(
303
291
  }
304
292
  if (req.method !== 'GET' && ADMIN_ONLY.test(path.replace(/\/{2,}/g, '/'))) requireAdmin(session);
305
293
  if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
306
- sendJson(req, res, 200, await routed(req, path, search, deps, session));
294
+ sendJson(req, res, 200, await routed(req, path, search, deps));
307
295
  })
308
296
  .catch((err: unknown) => {
309
297
  if (err instanceof ApiError) apiFailure(req, res, err, 'claude-api');
@@ -10,7 +10,6 @@ import { readFileSync, statSync } from '../agent-user/agent-fs.js';
10
10
  import { receiveHomeFile, writeHomeText } from '../agent-user/home-fs.js';
11
11
  import { agentsDir } from '../agents/files.js';
12
12
  import { claudeDir } from './files.js';
13
- import { readSetupState } from './setup-state.js';
14
13
  import { GitHubFailure, GitHubReader } from './github-fetch.js';
15
14
  import { githubSource, validatedGeneration } from './github-validate.js';
16
15
  import type { ClaudeSkill } from './skills.js';
@@ -29,7 +28,6 @@ interface Stored {
29
28
  export interface GitHubSkillsDeps {
30
29
  agents: string;
31
30
  claude: string;
32
- sdk(): boolean;
33
31
  request?: (url: string, init: RequestInit) => Promise<Response>;
34
32
  }
35
33
  const EMPTY: Stored = { source: null, prepared: null, etag: null, checkedAt: null, retryAt: 0, problem: null, rateLimit: 0, repositoryId: null };
@@ -107,7 +105,7 @@ export class GitHubSkills {
107
105
  view(activation = this.activation(), loaded = this.loaded(activation)): object {
108
106
  const { source, prepared, checkedAt, problem } = this.stored;
109
107
  return {
110
- supported: this.deps.sdk(),
108
+ supported: true,
111
109
  source: source === null ? null : { repository: source.repository, ref: source.ref, folder: source.folder, hasToken: true },
112
110
  checking: this.busy, checkedAt, problem: this.unreadable ? 'The saved GitHub source is unreadable. Restore its configuration before changing it. Local skills are still available.' : problem,
113
111
  prepared: prepared === null ? null : { generation: prepared.id, source: prepared.source, skillCount: prepared.skills.length },
@@ -142,7 +140,6 @@ export class GitHubSkills {
142
140
  }
143
141
 
144
142
  async configure(raw: unknown): Promise<object> {
145
- if (!this.deps.sdk()) throw new ApiError('GitHub skills require the Agent SDK runner. Local skills are unchanged.', 409);
146
143
  if (this.busy) throw new ApiError('A skills sync is already running. Try again when it finishes.', 409);
147
144
  const source = githubSource(raw);
148
145
  this.readable();
@@ -193,11 +190,10 @@ export class GitHubSkills {
193
190
  }
194
191
 
195
192
  async sync(force = false): Promise<void> {
196
- if (force && !this.deps.sdk()) throw new ApiError('GitHub skills require the Agent SDK runner. Local skills are unchanged.', 409);
197
193
  this.readable();
198
194
  this.offer();
199
195
  const source = this.stored.source;
200
- if (source === null || this.busy || !this.deps.sdk() || this.waiting(force)) return;
196
+ if (source === null || this.busy || this.waiting(force)) return;
201
197
  const epoch = this.epoch;
202
198
  this.busy = true;
203
199
  try { await this.fetch(source, epoch, force); }
@@ -254,6 +250,6 @@ export class GitHubSkills {
254
250
 
255
251
  let current: GitHubSkills | null = null;
256
252
  export function githubSkills(): GitHubSkills {
257
- current ??= new GitHubSkills({ agents: agentsDir(), claude: claudeDir(), sdk: () => readSetupState(agentsDir()).runner === 'sdk' });
253
+ current ??= new GitHubSkills({ agents: agentsDir(), claude: claudeDir() });
258
254
  return current;
259
255
  }
@@ -4,7 +4,7 @@ import { markOnboardingDone } from './onboarding.js';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
- import { agentCommand, asAgent, claudeBin } from '../agent-user/user.js';
7
+ import { agentCommand, asAgent, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
8
8
 
9
9
  const LOGIN_ARGS = ['auth', 'login', '--claudeai'];
10
10
  const STATUS_COMMAND = ['auth', 'status', '--json'];
@@ -95,14 +95,14 @@ function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Ch
95
95
  return value;
96
96
  }
97
97
 
98
- function forgetClaudeChecks(): void {
98
+ export function forgetClaudeChecks(): void {
99
99
  delete checks.installed;
100
100
  delete checks.account;
101
101
  }
102
102
 
103
103
  export function claudeInstalled(): boolean {
104
104
  return remembered(checks.installed, () => {
105
- const run = spawnSync(...asAgent(claudeBin(), ['--version']), { stdio: 'ignore' });
105
+ const run = spawnSync(...asAgent(claudeBin(), ['--version'], CLAUDE_ENV), { stdio: 'ignore', env: { ...process.env, ...CLAUDE_ENV } });
106
106
  return run.error === undefined && run.status === 0;
107
107
  }, (c) => {
108
108
  checks.installed = c;
@@ -125,7 +125,8 @@ export function claudeAccount(): ClaudeAccount {
125
125
 
126
126
  const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
127
127
 
128
- export function claudeAccountIn(env: Record<string, string>): ClaudeAccount {
128
+ export function claudeAccountIn(extra: Record<string, string>): ClaudeAccount {
129
+ const env = { ...CLAUDE_ENV, ...extra };
129
130
  const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
130
131
  if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
131
132
  try {
@@ -165,7 +166,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target:
165
166
  sweep(now);
166
167
  const running = runningFor(target);
167
168
  if (running !== null) return view(running);
168
- const env = { TERM: 'xterm-256color', ...target?.env };
169
+ const env = { TERM: 'xterm-256color', ...CLAUDE_ENV, ...target?.env };
169
170
  const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
170
171
  const session: Live = {
171
172
  id: randomBytes(9).toString('base64url'),
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
1
+ import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
@@ -36,25 +36,3 @@ export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
36
36
  writeHomeText(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, 0o600);
37
37
  return 'marked';
38
38
  }
39
-
40
- const TRUST = 'hasTrustDialogAccepted';
41
-
42
- function realDir(dir: string): string {
43
- try {
44
- return realpathSync(dir);
45
- } catch {
46
- return dir;
47
- }
48
- }
49
-
50
- export function trustFolder(dir: string, path = claudeConfigPath()): OnboardingMark {
51
- const config = readConfig(path);
52
- if (config === null) return 'unreadable';
53
- const key = realDir(dir);
54
- const projects = isRecord(config.projects) ? config.projects : {};
55
- const project = isRecord(projects[key]) ? projects[key] : {};
56
- if (project[TRUST] === true) return 'already';
57
- const next = { ...config, projects: { ...projects, [key]: { ...project, [TRUST]: true } } };
58
- writeHomeText(path, `${JSON.stringify(next, null, 2)}\n`, 0o600);
59
- return 'marked';
60
- }
@@ -3,7 +3,6 @@ import { ApiError } from '@metro-labs/http/api-error';
3
3
  import type { RunnerActivity } from '@metro-labs/core/runner-activity';
4
4
  import { readAgentActivity } from './runner-activity.js';
5
5
  import { sdkAlive, stopSdkRunner } from './runner-process.js';
6
- import { continueArgs, sdkPending } from './session-continuity.js';
7
6
  import { syncAgentView } from '../agent-user/view.js';
8
7
  import { homedir } from 'node:os';
9
8
  import { join } from 'node:path';
@@ -13,12 +12,8 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
13
12
  import { METRO_VERSION } from '@metro-labs/core/version';
14
13
  import { agentsDir, listAgentFiles } from '../agents/files.js';
15
14
  import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
16
- import { claudeDir } from './files.js';
17
- import { harnessRunner, isHarnessRunner, sdkAllowed, SDK_NEEDS_KEY, type HarnessRunner } from './runner.js';
18
- import { claudeAccount, claudeInstalled } from './login.js';
19
- import { trustFolder } from './onboarding.js';
15
+ import { claudeAccount, claudeInstalled, forgetClaudeChecks } from './login.js';
20
16
  import { inSessionScope } from './memory.js';
21
- import { channelState, forgetChannelState, noteChannelState, type ChannelState } from './channel-check.js';
22
17
  import { runningAsRoot } from '../metro-user/privilege.js';
23
18
  import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
24
19
 
@@ -31,41 +26,34 @@ function sessionEnv(): Record<string, string> {
31
26
 
32
27
  const SESSION_NAME = 'metro';
33
28
  const STATE_FILE = 'claude-session.json';
34
- const WARNING = 'WARNING: Loading development channels';
35
- const CONFIRM_POLL_MS = 500;
36
- const CONFIRM_WAIT_MS = 60_000;
37
29
  const WATCH_MS = 15_000;
38
30
  const STRIKE_WINDOW_MS = 60_000;
39
31
  const MAX_STRIKES = 5;
40
32
  const PAUSE_MS = 30 * 60_000;
33
+ const PREPARE_MS = 16 * 60_000;
41
34
 
42
35
  export interface SessionDeps {
43
36
  tmux?: string;
44
37
  metro?: string[];
45
- runner?: string[];
46
38
  home?: string;
47
39
  agents?: string;
48
40
  signedIn?: () => boolean;
49
41
  now?: () => number;
50
42
  version?: string;
51
- prepare?: (runner: HarnessRunner) => Promise<void>;
52
43
  }
53
44
 
54
45
  export interface SessionStatus {
55
46
  name: string;
56
47
  running: boolean;
57
- runner?: HarnessRunner;
58
48
  activity?: RunnerActivity | null;
59
49
  autostart: boolean;
60
50
  blocked: string | null;
61
51
  lastStartedAt: string | null;
62
52
  lastError: string | null;
63
- channels?: ChannelState | null;
64
53
  }
65
54
 
66
55
  export interface SessionSnapshot {
67
56
  running: boolean;
68
- runner: HarnessRunner | null;
69
57
  activity: RunnerActivity | null;
70
58
  lastStartedAt: string | null;
71
59
  }
@@ -120,55 +108,34 @@ export function metroCli(args: string[], fixed?: string[]): string[] {
120
108
 
121
109
  const sessionHome = (deps: SessionDeps): string => deps.home ?? claudeHome() ?? homedir();
122
110
 
123
- export function sessionRunner(deps: SessionDeps = {}): HarnessRunner {
124
- const agents = deps.agents ?? agentsDir();
125
- const runner = readState(agents).runner;
126
- return isHarnessRunner(runner) ? runner : harnessRunner(agents);
127
- }
128
-
129
111
  export function assertSessionStopped(deps: SessionDeps = {}): void {
130
- if (sessionLive(deps)) throw new ApiError('The session is running. Let it finish its work, stop it, then change its runner, permissions or prompt and start it again.', 409);
131
- }
132
-
133
- export function assertSdkDrained(deps: SessionDeps = {}): void {
134
- if (sdkPending(sessionHome(deps)) !== 0)
135
- throw new ApiError('The Agent SDK still has chat messages it has not answered, or its saved state cannot be read. Start it so it answers them (fix the Model page first if it cannot start), stop it, then switch runners.', 409);
136
- }
137
-
138
- let changing = false;
139
-
140
- export async function withSessionChange<T>(run: () => Promise<T>): Promise<T> {
141
- if (changing) throw new ApiError('A session change is already being prepared. Try again when it finishes.', 409);
142
- changing = true;
143
- try {
144
- return await run();
145
- } finally {
146
- changing = false;
147
- }
112
+ if (sessionLive(deps)) throw new ApiError('The session is running. Let it finish its work, stop it, then change its permissions or prompt and start it again.', 409);
148
113
  }
149
114
 
150
- export async function prepareSessionRunner(runner: HarnessRunner, deps: SessionDeps = {}): Promise<void> {
151
- if (deps.prepare !== undefined) return deps.prepare(runner);
152
- const [bin = 'metro', ...args] = metroCli(['agent', '--prepare', runner]);
153
- await new Promise<void>((resolve, reject) => {
154
- const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], timeout: 16 * 60_000 });
155
- let error = '';
156
- child.stderr.on('data', (chunk: Buffer) => { error = (error + chunk.toString()).slice(-2000); });
157
- child.on('error', (err) => { reject(new ApiError(`Runner preparation failed: ${errMsg(err)}`, 503)); });
115
+ export function prepareRunnerStore(): Promise<void> {
116
+ const [bin = 'metro', ...args] = metroCli(['agent', '--prepare']);
117
+ return new Promise<void>((resolve, reject) => {
118
+ const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], cwd: '/', timeout: PREPARE_MS });
119
+ let said = '';
120
+ child.stderr.on('data', (chunk: Buffer) => {
121
+ said = (said + chunk.toString('utf8')).slice(-2000);
122
+ });
123
+ child.on('error', (err) => {
124
+ forgetClaudeChecks();
125
+ reject(err);
126
+ });
158
127
  child.on('close', (code) => {
128
+ forgetClaudeChecks();
159
129
  if (code === 0) resolve();
160
- else reject(new ApiError(`Runner preparation failed. The runner choice was not changed. ${error.trim()}`, 503));
130
+ else reject(new Error(`metro agent --prepare failed: ${said.trim() || `exit ${String(code)}`}`));
161
131
  });
162
132
  });
163
133
  }
164
134
 
165
- function metroCommand(deps: SessionDeps, home: string): string[] {
166
- if (harnessRunner(deps.agents ?? agentsDir()) === 'sdk') return metroCli(['agent'], deps.runner);
167
- return [...metroCli(['claude'], deps.metro), ...continueArgs(home, claudeDir(), deps.agents)];
168
- }
135
+ const loginNotRuledOut = (): boolean => claudeAccount().signedIn || !claudeInstalled();
169
136
 
170
137
  function credentialReady(deps: SessionDeps): string | null {
171
- const signedIn = deps.signedIn ?? (() => claudeAccount().signedIn);
138
+ const signedIn = deps.signedIn ?? loginNotRuledOut;
172
139
  if (signedIn()) return null;
173
140
  try {
174
141
  const cfg = readModelConfig(deps.agents ?? agentsDir());
@@ -187,64 +154,26 @@ const ROOT_REFUSED = 'Metro runs as root; reinstall it as the metro user';
187
154
 
188
155
  export function sessionBlocked(deps: SessionDeps = {}): string | null {
189
156
  if (runningAsRoot()) return ROOT_REFUSED;
190
- const agents = deps.agents ?? agentsDir();
191
- if (harnessRunner(agents) === 'sdk' && !sdkAllowed(agents)) return SDK_NEEDS_KEY;
192
- if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
157
+ if (listAgentFiles(deps.agents ?? agentsDir()).length === 0) return 'no agent on this machine yet';
193
158
  return launchBlocked(deps);
194
159
  }
195
160
 
196
161
  function launchBlocked(deps: SessionDeps): string | null {
197
162
  if (agentUserMissing(deps))
198
163
  return 'Claude Code runs as the user agent here, and that user is not ready yet; see the log';
199
- if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
200
164
  if (!tmuxOk(deps.tmux ?? 'tmux', ['-V'])) return 'tmux is not installed on this machine';
201
165
  return credentialReady(deps);
202
166
  }
203
167
 
204
- function capturePane(tmux: string): string {
205
- const run = spawnSync(...asAgent(tmux, ['capture-pane', '-p', '-t', SESSION_NAME]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
206
- return run.error === undefined && run.status === 0 ? run.stdout : '';
207
- }
208
-
209
- function noteLaunchLines(pane: string, seen: Set<string>): void {
210
- for (const line of pane.split('\n').map((l) => l.trim()))
211
- if (line.startsWith('metro claude:') && !seen.has(line)) {
212
- seen.add(line);
213
- log.info({ line }, 'claude-session: metro claude said');
214
- }
215
- }
216
-
217
- function confirmChannels(tmux: string, home: string, startedAt: number): void {
218
- const until = startedAt + CONFIRM_WAIT_MS;
219
- const seen = new Set<string>();
220
- let confirmed = false;
221
- let checked = false;
222
- const tick = (): void => {
223
- const pane = capturePane(tmux);
224
- noteLaunchLines(pane, seen);
225
- checked ||= noteChannelState(home, startedAt);
226
- if (!confirmed && pane.includes(WARNING) && pane.includes('server:metro')) {
227
- spawnSync(...asAgent(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter']), { stdio: 'ignore' });
228
- log.info('claude-session: confirmed the development channels dialog for server:metro');
229
- confirmed = true;
230
- }
231
- if (Date.now() < until && sessionRunning(tmux)) setTimeout(tick, CONFIRM_POLL_MS).unref();
232
- };
233
- setTimeout(tick, CONFIRM_POLL_MS).unref();
234
- }
235
-
236
- function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?: Error; status: number | null; stderr: string }): void {
168
+ function recordStart(deps: SessionDeps, now: number, run: { error?: Error; status: number | null; stderr: string }): void {
237
169
  memory.lastStartedAt = now;
238
- forgetChannelState();
239
170
  if (run.error !== undefined || run.status !== 0) {
240
171
  memory.lastError = run.error === undefined ? `tmux new-session exited ${String(run.status)}: ${run.stderr.trim()}` : errMsg(run.error);
241
172
  log.warn({ err: memory.lastError }, 'claude-session: tmux could not start');
242
173
  return;
243
174
  }
244
175
  memory.lastError = null;
245
- const agents = deps.agents ?? agentsDir();
246
- writeState(agents, { version: deps.version ?? METRO_VERSION, runner: harnessRunner(agents) });
247
- confirmChannels(tmux, sessionHome(deps), now);
176
+ writeState(deps.agents ?? agentsDir(), { version: deps.version ?? METRO_VERSION });
248
177
  }
249
178
 
250
179
  function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, string[]] {
@@ -253,7 +182,6 @@ function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, s
253
182
  }
254
183
 
255
184
  export function startSession(deps: SessionDeps = {}): SessionStatus {
256
- if (changing) throw new ApiError('A runner change is being prepared. Start the session after it finishes.', 409);
257
185
  const status = sessionStatus(deps);
258
186
  if (status.running) return status;
259
187
  if (status.blocked !== null) throw new ApiError(`Cannot start the session: ${status.blocked}`, 409);
@@ -261,13 +189,12 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
261
189
  const tmux = deps.tmux ?? 'tmux';
262
190
  const home = sessionHome(deps);
263
191
  const now = (deps.now ?? Date.now)();
264
- const trusted = trustFolder(home);
265
- const [command = 'metro', ...args] = metroCommand(deps, home);
192
+ const [command = 'metro', ...args] = metroCli(['agent'], deps.metro);
266
193
  const tmuxArgs = ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args];
267
194
  const launch = tmuxLaunch(deps, tmux, tmuxArgs);
268
195
  const run = spawnSync(...launch, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: '/' });
269
- recordStart(deps, tmux, now, run);
270
- if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
196
+ recordStart(deps, now, run);
197
+ if (memory.lastError === null) log.info({ home, command: [command, ...args].join(' ') }, 'claude-session: started the Agent SDK runner in tmux');
271
198
  return sessionStatus(deps);
272
199
  }
273
200
 
@@ -277,32 +204,26 @@ export function stopSession(deps: SessionDeps = {}, cancelActive = false): Sessi
277
204
  return sessionStatus(deps);
278
205
  }
279
206
 
280
- export const sessionLive = (deps: SessionDeps = {}): boolean =>
281
- sessionRunning(deps.tmux ?? 'tmux') || sdkAlive(readAgentActivity(sessionHome(deps)));
207
+ export const runnerAlive = (deps: SessionDeps = {}): boolean => sdkAlive(readAgentActivity(sessionHome(deps)));
208
+
209
+ export const sessionLive = (deps: SessionDeps = {}): boolean => sessionRunning(deps.tmux ?? 'tmux') || runnerAlive(deps);
282
210
 
283
211
  export function sessionSnapshot(deps: SessionDeps = {}): SessionSnapshot {
284
212
  const activity = readAgentActivity(sessionHome(deps));
285
- const sdk = sdkAlive(activity);
286
- const tmux = sessionRunning(deps.tmux ?? 'tmux');
287
- const runner = sdk ? 'sdk' : tmux ? readState(deps.agents ?? agentsDir()).runner : null;
288
213
  return {
289
- running: tmux || sdk,
290
- runner: isHarnessRunner(runner) ? runner : null,
214
+ running: sdkAlive(activity) || sessionRunning(deps.tmux ?? 'tmux'),
291
215
  activity,
292
216
  lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
293
217
  };
294
218
  }
295
219
 
296
220
  export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
297
- const snapshot = sessionSnapshot(deps);
298
221
  return {
299
222
  name: SESSION_NAME,
300
- ...snapshot,
301
- runner: snapshot.runner ?? sessionRunner(deps),
223
+ ...sessionSnapshot(deps),
302
224
  autostart: autostartEnabled(deps.agents ?? agentsDir()),
303
225
  blocked: sessionBlocked(deps),
304
226
  lastError: memory.lastError,
305
- channels: channelState(),
306
227
  };
307
228
  }
308
229
 
@@ -312,7 +233,7 @@ function strike(now: number): boolean {
312
233
  if (memory.strikes < MAX_STRIKES) return false;
313
234
  memory.pausedUntil = now + PAUSE_MS;
314
235
  memory.strikes = 0;
315
- log.warn({ pauseMinutes: PAUSE_MS / 60_000 }, 'claude-session: Claude Code keeps exiting right after starting; not restarting it for a while');
236
+ log.warn({ pauseMinutes: PAUSE_MS / 60_000 }, 'claude-session: the session keeps exiting right after starting; not restarting it for a while');
316
237
  return true;
317
238
  }
318
239
 
@@ -346,7 +267,6 @@ function situation(r: Resolved): 'running' | 'stale' | 'absent' {
346
267
  export function ensureSession(deps: SessionDeps = {}): Ensured {
347
268
  const r = resolved(deps);
348
269
  if (!autostartEnabled(r.agents)) return 'off';
349
- if (changing) return 'blocked';
350
270
  const found = situation(r);
351
271
  if (found === 'running' || (found === 'absent' && sessionLive(deps))) return 'running';
352
272
  const held = holdsBack(deps, r.now);
@@ -366,7 +286,7 @@ export function watchSession(deps: SessionDeps = {}, everyMs = WATCH_MS): void {
366
286
  const tick = (): void => {
367
287
  try {
368
288
  const outcome = ensureSession(deps);
369
- if (outcome === 'started' || outcome === 'restarted') log.info({ outcome }, 'claude-session: Claude Code is up');
289
+ if (outcome === 'started' || outcome === 'restarted') log.info({ outcome }, 'claude-session: the Agent SDK runner is up');
370
290
  } catch (err) {
371
291
  log.warn({ err: errMsg(err) }, 'claude-session: check failed');
372
292
  }