@stage-labs/metro 0.1.0-beta.307 → 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.
- package/dist/agent.js +23 -28
- package/dist/child.js +33 -0
- package/dist/claude-env.js +104 -0
- package/dist/cli.js +7 -21
- package/dist/lock.js +62 -0
- package/dist/memory.js +12 -27
- package/dist/root-release-io.js +1 -2
- package/dist/root-release.js +1 -1
- package/dist/route.js +0 -1
- package/hooks/guard.mjs +1 -5
- package/hooks/session-start.mjs +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/dist/background.js +0 -141
- package/dist/claude.js +0 -204
- package/dist/mcp-config.js +0 -27
- package/dist/native-recovery-scan.js +0 -246
- package/dist/native-recovery-state.js +0 -154
- package/dist/native-recovery.js +0 -164
- package/dist/onboarding.js +0 -44
- package/dist/voice.js +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
|
@@ -26,9 +26,8 @@ import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connec
|
|
|
26
26
|
import { setAnnouncedFile } from '../mcp-server/announced.js';
|
|
27
27
|
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
28
28
|
import { removeConnectorLeftovers } from '../connectors/leftovers.js';
|
|
29
|
-
import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
|
|
30
29
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
31
|
-
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
30
|
+
import { prepareAgentRunner, provisionAgentUser } from '../agent-user/provision.js';
|
|
32
31
|
import { applyVault } from '../vault/index.js';
|
|
33
32
|
import { blockImds } from '../metro-user/imds.js';
|
|
34
33
|
import { followRelease } from '../metro-user/root-follow.js';
|
|
@@ -193,7 +192,7 @@ async function startClaude(): Promise<void> {
|
|
|
193
192
|
log.error('claude: Metro runs as root, so Claude Code never starts here; reinstall Metro as the metro user');
|
|
194
193
|
return;
|
|
195
194
|
}
|
|
196
|
-
const agentUser =
|
|
195
|
+
const agentUser = provisionAgentUser();
|
|
197
196
|
if (agentUser !== 'off') log.info({ agentUser }, 'agent-user: Claude Code runs as its own user');
|
|
198
197
|
if (agentUser === 'failed') return;
|
|
199
198
|
if (agentUser === 'ready') {
|
|
@@ -202,14 +201,10 @@ async function startClaude(): Promise<void> {
|
|
|
202
201
|
const vault = (await applyVault()).status;
|
|
203
202
|
if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
|
|
204
203
|
}
|
|
205
|
-
const plugin = await removeMetroPlugin().catch((err: unknown) => {
|
|
206
|
-
log.warn({ err: errMsg(err) }, 'claude-plugin: could not check for the old metro plugin');
|
|
207
|
-
return 'failed';
|
|
208
|
-
});
|
|
209
|
-
if (plugin !== 'done') log.info({ outcome: plugin }, 'claude-plugin: checked for the old metro Claude Code plugin');
|
|
210
204
|
tryClaudeSetup();
|
|
211
205
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
212
206
|
tryMemoryJob(memoryRoutine());
|
|
207
|
+
if (agentUser === 'ready') await prepareAgentRunner();
|
|
213
208
|
watchSession();
|
|
214
209
|
}
|
|
215
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
|
|
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|
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 ||
|
|
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()
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
130
|
+
else reject(new Error(`metro agent --prepare failed: ${said.trim() || `exit ${String(code)}`}`));
|
|
161
131
|
});
|
|
162
132
|
});
|
|
163
133
|
}
|
|
164
134
|
|
|
165
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
270
|
-
if (memory.lastError === null) log.info({ home,
|
|
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
|
|
281
|
-
|
|
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:
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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
|
}
|