@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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 +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -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 } from '@metro-labs/http/api-http';
|
|
3
|
+
import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson, type ApiSession } 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 { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
|
|
@@ -22,25 +22,10 @@ import {
|
|
|
22
22
|
type LoginTarget,
|
|
23
23
|
} from './login.js';
|
|
24
24
|
import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
|
|
25
|
+
import { setupAnswer, type SetupApiDeps } from './setup-api.js';
|
|
25
26
|
import {
|
|
26
|
-
|
|
27
|
-
ensureClaudeSetup,
|
|
28
|
-
isPermissionMode,
|
|
29
|
-
liveEvents,
|
|
30
|
-
permissionMode,
|
|
31
|
-
setLiveEvents,
|
|
32
|
-
setMemoryRoutine,
|
|
33
|
-
setPermissionMode,
|
|
34
|
-
setPrivacy,
|
|
35
|
-
setSystemPrompt,
|
|
36
|
-
systemPrompt,
|
|
37
|
-
SYSTEM_PROMPT_MAX,
|
|
38
|
-
type PermissionMode,
|
|
39
|
-
type SetupDeps,
|
|
40
|
-
} from './setup.js';
|
|
41
|
-
import {
|
|
27
|
+
autostartEnabled,
|
|
42
28
|
ensureSession,
|
|
43
|
-
sessionRunning,
|
|
44
29
|
sessionStatus,
|
|
45
30
|
setAutostart,
|
|
46
31
|
startSession,
|
|
@@ -48,7 +33,6 @@ import {
|
|
|
48
33
|
type SessionDeps,
|
|
49
34
|
} from './session.js';
|
|
50
35
|
import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
|
|
51
|
-
import { tryMemoryJob } from './memory-routine.js';
|
|
52
36
|
import { receiveSessionFile, sessionFilePath } from './session-files.js';
|
|
53
37
|
import { createReadStream } from '../agent-user/agent-fs.js';
|
|
54
38
|
import { pipeline } from 'node:stream/promises';
|
|
@@ -70,15 +54,11 @@ const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
|
|
|
70
54
|
const PAGE = 100;
|
|
71
55
|
const PAGE_MAX = 500;
|
|
72
56
|
|
|
73
|
-
export interface ClaudeApiDeps {
|
|
57
|
+
export interface ClaudeApiDeps extends SetupApiDeps {
|
|
74
58
|
dir?: () => string;
|
|
75
59
|
login?: LoginDeps;
|
|
76
60
|
claudeLogins?: ClaudeLoginDeps;
|
|
77
|
-
session?: SessionDeps;
|
|
78
|
-
setup?: SetupDeps;
|
|
79
61
|
version?: VersionDeps;
|
|
80
|
-
liveEvents?: (on: boolean) => void;
|
|
81
|
-
memoryJob?: (on: boolean) => void;
|
|
82
62
|
}
|
|
83
63
|
|
|
84
64
|
function projectOf(query: URLSearchParams): string {
|
|
@@ -155,104 +135,22 @@ const ADMIN_ONLY = /^\/api\/claude\/(login|session|version|setup)(\/|$)/;
|
|
|
155
135
|
const LOGIN = 'login';
|
|
156
136
|
const SESSION = 'session';
|
|
157
137
|
const SETUP = 'setup';
|
|
158
|
-
const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
|
|
159
138
|
const VERSION = 'version';
|
|
160
139
|
|
|
161
|
-
interface SetupChange {
|
|
162
|
-
privacy?: boolean;
|
|
163
|
-
permissionMode?: PermissionMode;
|
|
164
|
-
systemPrompt?: string;
|
|
165
|
-
liveEvents?: boolean;
|
|
166
|
-
memoryRoutine?: boolean;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function promptChange(raw: unknown): string | undefined {
|
|
170
|
-
if (raw === undefined) return undefined;
|
|
171
|
-
if (typeof raw !== 'string') throw new ApiError('systemPrompt must be text', 400);
|
|
172
|
-
if (Buffer.byteLength(raw) > SYSTEM_PROMPT_MAX) throw new ApiError(`systemPrompt must be under ${String(SYSTEM_PROMPT_MAX / 1024)} KiB`, 400);
|
|
173
|
-
return raw;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function flagChange(raw: unknown, name: string): boolean | undefined {
|
|
177
|
-
if (raw === undefined) return undefined;
|
|
178
|
-
if (typeof raw !== 'boolean') throw new ApiError(`${name} must be true or false`, 400);
|
|
179
|
-
return raw;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function modeChange(raw: unknown): PermissionMode | undefined {
|
|
183
|
-
if (raw === undefined) return undefined;
|
|
184
|
-
if (!isPermissionMode(raw)) throw new ApiError('permissionMode must be auto or bypass', 400);
|
|
185
|
-
return raw;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function setupChange(body: unknown): SetupChange {
|
|
189
|
-
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
190
|
-
const privacy = flagChange(body.privacy, 'privacy');
|
|
191
|
-
const permissionMode = modeChange(body.permissionMode);
|
|
192
|
-
const systemPrompt = promptChange(body.systemPrompt);
|
|
193
|
-
const liveEvents = flagChange(body.liveEvents, 'liveEvents');
|
|
194
|
-
const memoryRoutine = flagChange(body.memoryRoutine, 'memoryRoutine');
|
|
195
|
-
if ([privacy, permissionMode, systemPrompt, liveEvents, memoryRoutine].every((v) => v === undefined)) throw new ApiError('nothing to change', 400);
|
|
196
|
-
return {
|
|
197
|
-
...(privacy === undefined ? {} : { privacy }),
|
|
198
|
-
...(permissionMode === undefined ? {} : { permissionMode }),
|
|
199
|
-
...(systemPrompt === undefined ? {} : { systemPrompt }),
|
|
200
|
-
...(liveEvents === undefined ? {} : { liveEvents }),
|
|
201
|
-
...(memoryRoutine === undefined ? {} : { memoryRoutine }),
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function restartsSession(change: SetupChange, agents: string | undefined): boolean {
|
|
206
|
-
let restart = false;
|
|
207
|
-
if (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents)) {
|
|
208
|
-
setPermissionMode(change.permissionMode, agents);
|
|
209
|
-
restart = true;
|
|
210
|
-
}
|
|
211
|
-
if (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents)) {
|
|
212
|
-
setSystemPrompt(change.systemPrompt, agents);
|
|
213
|
-
restart = true;
|
|
214
|
-
}
|
|
215
|
-
return restart;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function applyLiveEvents(on: boolean, deps: ClaudeApiDeps): void {
|
|
219
|
-
const agents = deps.setup?.agents;
|
|
220
|
-
if (on !== liveEvents(agents)) setLiveEvents(on, agents);
|
|
221
|
-
deps.liveEvents?.(on);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function applyMemoryRoutine(on: boolean, deps: ClaudeApiDeps): void {
|
|
225
|
-
setMemoryRoutine(on, deps.setup?.agents);
|
|
226
|
-
(deps.memoryJob ?? tryMemoryJob)(on);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function applySetupChange(change: SetupChange, deps: ClaudeApiDeps): void {
|
|
230
|
-
const setup = deps.setup ?? {};
|
|
231
|
-
if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
|
|
232
|
-
if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
|
|
233
|
-
if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
|
|
234
|
-
if (restartsSession(change, setup.agents) && sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
|
|
235
|
-
ensureClaudeSetup(setup);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async function setupAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
|
|
239
|
-
const setup = deps.setup ?? {};
|
|
240
|
-
const method = req.method ?? 'GET';
|
|
241
|
-
if (method === 'GET') return claudeSetupStatus(setup);
|
|
242
|
-
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
243
|
-
applySetupChange(setupChange(await readJsonBody(req, SETUP_BODY_MAX)), deps);
|
|
244
|
-
return claudeSetupStatus(setup);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
140
|
function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
|
|
248
141
|
if (body.action === 'start') {
|
|
249
142
|
const status = sessionStatus(session);
|
|
250
|
-
if (status.running)
|
|
251
|
-
|
|
252
|
-
|
|
143
|
+
if (!status.running && status.blocked !== null) throw new ApiError(`cannot start a Claude session: ${status.blocked}`, 409);
|
|
144
|
+
const started = status.running ? status : startSession(session);
|
|
145
|
+
setAutostart(typeof body.autostart === 'boolean' ? body.autostart : true, session.agents);
|
|
146
|
+
return { ...started, autostart: autostartEnabled(session.agents) };
|
|
147
|
+
}
|
|
148
|
+
if (body.action === 'stop') {
|
|
149
|
+
setAutostart(false, session.agents);
|
|
150
|
+
return stopSession(session);
|
|
253
151
|
}
|
|
254
|
-
if (body.action === 'stop') return stopSession(session);
|
|
255
152
|
if (body.action !== undefined) throw new ApiError('action must be start or stop', 400);
|
|
153
|
+
if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
|
|
256
154
|
if (body.autostart === true) ensureSession(session);
|
|
257
155
|
return sessionStatus(session);
|
|
258
156
|
}
|
|
@@ -264,7 +162,6 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
|
|
|
264
162
|
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
265
163
|
const body = await readJsonBody(req);
|
|
266
164
|
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
267
|
-
if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
|
|
268
165
|
return sessionCommand(body, session);
|
|
269
166
|
}
|
|
270
167
|
|
|
@@ -316,18 +213,18 @@ function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unkno
|
|
|
316
213
|
return updateClaude(version);
|
|
317
214
|
}
|
|
318
215
|
|
|
319
|
-
const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
|
|
216
|
+
const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps, session: ApiSession) => Promise<unknown>> = {
|
|
320
217
|
[SESSION]: sessionAnswer,
|
|
321
218
|
[SETUP]: setupAnswer,
|
|
322
219
|
[VERSION]: versionAnswer,
|
|
323
220
|
};
|
|
324
221
|
|
|
325
|
-
function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
|
|
222
|
+
function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps, session: ApiSession): unknown {
|
|
326
223
|
const dir = (deps.dir ?? claudeDir)();
|
|
327
224
|
const [head = '', item = ''] = parts(path);
|
|
328
225
|
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
329
226
|
const single = item === '' ? SINGLETONS[head] : undefined;
|
|
330
|
-
if (single !== undefined) return single(req, deps);
|
|
227
|
+
if (single !== undefined) return single(req, deps, session);
|
|
331
228
|
if (req.method === 'PUT') return writeAnswer(req, path, search, dir);
|
|
332
229
|
if (req.method === 'POST') return created(req, path, dir);
|
|
333
230
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
@@ -375,7 +272,7 @@ export function handleClaudeRequest(
|
|
|
375
272
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
376
273
|
if (req.method !== 'GET' && ADMIN_ONLY.test(path)) requireAdmin(session);
|
|
377
274
|
if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
|
|
378
|
-
sendJson(req, res, 200, await routed(req, path, search, deps));
|
|
275
|
+
sendJson(req, res, 200, await routed(req, path, search, deps, session));
|
|
379
276
|
})
|
|
380
277
|
.catch((err: unknown) => {
|
|
381
278
|
if (err instanceof ApiError) apiFailure(req, res, err, 'claude-api');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseRunnerActivity, type RunnerActivity } from '@metro-labs/core/runner-activity';
|
|
3
|
+
import { readRange } from '../agent-user/agent-fs.js';
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 65_536;
|
|
6
|
+
|
|
7
|
+
export function readAgentActivity(home: string): RunnerActivity | null {
|
|
8
|
+
try {
|
|
9
|
+
const path = join(home, '.metro', 'agent-status.json');
|
|
10
|
+
const bytes = readRange(path, 0, MAX_BYTES + 1);
|
|
11
|
+
if (bytes.length > MAX_BYTES) return null;
|
|
12
|
+
const raw: unknown = JSON.parse(bytes.toString('utf8'));
|
|
13
|
+
return parseRunnerActivity(raw);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import type { RunnerActivity } from '@metro-labs/core/runner-activity';
|
|
5
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
+
import { asAgent } from '../agent-user/user.js';
|
|
7
|
+
import { readAgentActivity } from './runner-activity.js';
|
|
8
|
+
|
|
9
|
+
function identity(pid: number): { started: string; zombie: boolean } | null {
|
|
10
|
+
try {
|
|
11
|
+
const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
|
|
12
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
13
|
+
return { started: fields[19] ?? '', zombie: fields[0] === 'Z' };
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidAlive(pid: number): boolean {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return isRecord(err) && err.code === 'EPERM';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sdkAlive(activity: RunnerActivity | null): boolean {
|
|
29
|
+
if (activity === null) return false;
|
|
30
|
+
const found = identity(activity.pid);
|
|
31
|
+
if (found?.zombie === true) return false;
|
|
32
|
+
if (activity.procStart !== undefined && found !== null && activity.procStart !== found.started) return false;
|
|
33
|
+
if (activity.procStart === undefined && activity.phase === 'stopped') return false;
|
|
34
|
+
return pidAlive(activity.pid);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertIdentity(activity: RunnerActivity): void {
|
|
38
|
+
const found = identity(activity.pid);
|
|
39
|
+
if (activity.pid === process.pid || activity.procStart === undefined || activity.procStart !== found?.started)
|
|
40
|
+
throw new ApiError('The Agent SDK is still running outside tmux, but its process identity cannot be verified safely. Stop that metro agent process from its terminal.', 503);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stopSdkRunner(home: string): void {
|
|
44
|
+
const activity = readAgentActivity(home);
|
|
45
|
+
if (activity === null || !sdkAlive(activity)) return;
|
|
46
|
+
assertIdentity(activity);
|
|
47
|
+
const run = spawnSync(...asAgent('kill', ['-TERM', String(activity.pid)]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
|
+
if ((run.error !== undefined || run.status !== 0) && sdkAlive(activity))
|
|
49
|
+
throw new ApiError(`The Agent SDK could not be stopped: ${run.error?.message ?? run.stderr.trim()}`, 503);
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { agentsDir } from '../agents/files.js';
|
|
2
|
+
import type { CodexMethod } from '../gateway/codex-auth.js';
|
|
3
|
+
import { connectionOf, readModelConfig, routedConnection, type Connection, type ModelConfig } from '../gateway/model-config.js';
|
|
4
|
+
import { readSetupState, writeSetupState } from './setup-state.js';
|
|
5
|
+
|
|
6
|
+
const RUNNERS = ['cli', 'sdk'] as const;
|
|
7
|
+
export type HarnessRunner = (typeof RUNNERS)[number];
|
|
8
|
+
|
|
9
|
+
export const SDK_NEEDS_KEY =
|
|
10
|
+
'The Agent SDK runner needs an API key or a ChatGPT sign-in on every model the Model page lists, the first one and each fallback: use Anthropic with an API key, Amazon Bedrock, OpenRouter, or a signed-in Codex connection (with ChatGPT or with a code). Anthropic does not allow products built on the Agent SDK to use a Claude login, so on a login it runs only where the Metro operator allows it.';
|
|
11
|
+
|
|
12
|
+
export const isHarnessRunner = (value: unknown): value is HarnessRunner => RUNNERS.some((r) => r === value);
|
|
13
|
+
|
|
14
|
+
export function harnessRunner(agents = agentsDir()): HarnessRunner {
|
|
15
|
+
const runner = readSetupState(agents).runner;
|
|
16
|
+
return isHarnessRunner(runner) ? runner : 'cli';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setHarnessRunner(runner: HarnessRunner, agents = agentsDir()): void {
|
|
20
|
+
writeSetupState(agents, { runner });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const sdkOnLogin = (agents = agentsDir()): boolean => readSetupState(agents).sdkOnLogin === true;
|
|
24
|
+
|
|
25
|
+
export function setSdkOnLogin(on: boolean, agents = agentsDir()): void {
|
|
26
|
+
writeSetupState(agents, { sdkOnLogin: on });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SDK_ON_CODEX: Record<CodexMethod, boolean> = { chatgpt: true, code: true };
|
|
30
|
+
|
|
31
|
+
const permitted = (conn: Connection | null): boolean => conn !== null && (conn.apiKey !== '' || (conn.codex !== null && SDK_ON_CODEX[conn.codex.method]));
|
|
32
|
+
|
|
33
|
+
const servingConnections = (cfg: ModelConfig): (Connection | null)[] => [routedConnection(cfg), ...(cfg.fallbacks ?? []).map((f) => connectionOf(cfg, f.connection))];
|
|
34
|
+
|
|
35
|
+
const permittedRoute = (cfg: ModelConfig): boolean => servingConnections(cfg).every(permitted);
|
|
36
|
+
|
|
37
|
+
export const sdkAllowed = (agents = agentsDir(), cfg: ModelConfig = readModelConfig(agents), loginAllowed?: boolean): boolean => permittedRoute(cfg) || (loginAllowed ?? sdkOnLogin(agents));
|
|
38
|
+
|
|
39
|
+
export const sdkConnectionAllowed = (connection: Connection, agents = agentsDir()): boolean => permitted(connection) || sdkOnLogin(agents);
|
|
40
|
+
|
|
41
|
+
export function runnerModel(cfg: ModelConfig): string | null {
|
|
42
|
+
const conn = routedConnection(cfg);
|
|
43
|
+
if (conn === null || conn.model === '') return null;
|
|
44
|
+
return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
|
|
7
|
+
import { claudeDir } from './files.js';
|
|
8
|
+
|
|
9
|
+
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
10
|
+
const sessionId = (value: unknown): string | null => typeof value === 'string' && SESSION_ID_RE.test(value) ? value : null;
|
|
11
|
+
|
|
12
|
+
function sdkSavedSession(home: string): Record<string, unknown> | null {
|
|
13
|
+
try {
|
|
14
|
+
const raw: unknown = JSON.parse(readFileSync(join(home, '.metro', 'agent-session.json'), 'utf8'));
|
|
15
|
+
return isRecord(raw) ? raw : null;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
return isRecord(err) && err.code === 'ENOENT' ? {} : null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sdkPending(home: string): number | null {
|
|
22
|
+
const state = sdkSavedSession(home);
|
|
23
|
+
if (state === null) return null;
|
|
24
|
+
if (state.sessionId !== undefined && state.sessionId !== null && sessionId(state.sessionId) === null) return null;
|
|
25
|
+
if (state.unanswered === undefined) return 0;
|
|
26
|
+
return Array.isArray(state.unanswered) ? state.unanswered.length : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function projectFolder(home: string, dir: string): string {
|
|
30
|
+
let cwd = home;
|
|
31
|
+
try {
|
|
32
|
+
cwd = realpathSync(home);
|
|
33
|
+
} catch {
|
|
34
|
+
cwd = home;
|
|
35
|
+
}
|
|
36
|
+
return join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function continueArgs(home: string, dir = claudeDir(), agents = agentsDir()): string[] {
|
|
40
|
+
const path = join(agents, 'claude-session.json');
|
|
41
|
+
const raw = readJson<unknown>(path, null);
|
|
42
|
+
const state = isRecord(raw) ? raw : {};
|
|
43
|
+
const project = projectFolder(home, dir);
|
|
44
|
+
const pinned = sessionId(state.cliSessionId);
|
|
45
|
+
const id = pinned ?? randomUUID();
|
|
46
|
+
if (pinned === null) writeJson(path, { ...state, cliSessionId: id });
|
|
47
|
+
return [existsSync(join(project, `${id}.jsonl`)) ? '--resume' : '--session-id', id];
|
|
48
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import type { RunnerActivity } from '@metro-labs/core/runner-activity';
|
|
4
|
+
import { readAgentActivity } from './runner-activity.js';
|
|
5
|
+
import { sdkAlive, stopSdkRunner } from './runner-process.js';
|
|
6
|
+
import { continueArgs, sdkPending } from './session-continuity.js';
|
|
7
|
+
import { syncAgentView } from '../agent-user/view.js';
|
|
3
8
|
import { homedir } from 'node:os';
|
|
4
9
|
import { join } from 'node:path';
|
|
5
10
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
@@ -8,7 +13,8 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
|
8
13
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
9
14
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
10
15
|
import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
|
|
11
|
-
import { claudeDir
|
|
16
|
+
import { claudeDir } from './files.js';
|
|
17
|
+
import { harnessRunner, isHarnessRunner, sdkAllowed, SDK_NEEDS_KEY, type HarnessRunner } from './runner.js';
|
|
12
18
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
13
19
|
import { trustFolder } from './onboarding.js';
|
|
14
20
|
import { inSessionScope } from './memory.js';
|
|
@@ -35,17 +41,20 @@ const PAUSE_MS = 30 * 60_000;
|
|
|
35
41
|
export interface SessionDeps {
|
|
36
42
|
tmux?: string;
|
|
37
43
|
metro?: string[];
|
|
44
|
+
runner?: string[];
|
|
38
45
|
home?: string;
|
|
39
46
|
agents?: string;
|
|
40
47
|
signedIn?: () => boolean;
|
|
41
48
|
now?: () => number;
|
|
42
49
|
version?: string;
|
|
43
|
-
|
|
50
|
+
prepare?: (runner: HarnessRunner) => Promise<void>;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
export interface SessionStatus {
|
|
47
54
|
name: string;
|
|
48
55
|
running: boolean;
|
|
56
|
+
runner?: HarnessRunner;
|
|
57
|
+
activity?: RunnerActivity | null;
|
|
49
58
|
autostart: boolean;
|
|
50
59
|
blocked: string | null;
|
|
51
60
|
lastStartedAt: string | null;
|
|
@@ -92,24 +101,6 @@ export const sessionRunning = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['has-ses
|
|
|
92
101
|
|
|
93
102
|
export const tmuxServerUp = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['list-sessions']);
|
|
94
103
|
|
|
95
|
-
function realDir(dir: string): string {
|
|
96
|
-
try {
|
|
97
|
-
return realpathSync(dir);
|
|
98
|
-
} catch {
|
|
99
|
-
return dir;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const encodedCwd = (dir: string): string => dir.replace(/[^A-Za-z0-9]/g, '-');
|
|
104
|
-
|
|
105
|
-
export function hasConversation(home: string, dir = claudeDir()): boolean {
|
|
106
|
-
const cwd = realDir(home);
|
|
107
|
-
const folder = encodedCwd(cwd);
|
|
108
|
-
return listClaudeProjects(dir).some(
|
|
109
|
-
(project) => project.sessions > 0 && (project.id === folder || (project.cwd !== null && realDir(project.cwd) === cwd)),
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
104
|
export function metroCli(args: string[], fixed?: string[]): string[] {
|
|
114
105
|
const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
|
|
115
106
|
const runtime = agentUser() === null ? process.execPath : 'node';
|
|
@@ -118,10 +109,53 @@ export function metroCli(args: string[], fixed?: string[]): string[] {
|
|
|
118
109
|
return env.length === 0 || fixed !== undefined ? base : ['env', ...env, ...base];
|
|
119
110
|
}
|
|
120
111
|
|
|
112
|
+
const sessionHome = (deps: SessionDeps): string => deps.home ?? claudeHome() ?? homedir();
|
|
113
|
+
|
|
114
|
+
export function sessionRunner(deps: SessionDeps = {}): HarnessRunner {
|
|
115
|
+
const agents = deps.agents ?? agentsDir();
|
|
116
|
+
const runner = readState(agents).runner;
|
|
117
|
+
return isHarnessRunner(runner) ? runner : harnessRunner(agents);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function assertSessionStopped(deps: SessionDeps = {}): void {
|
|
121
|
+
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);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function assertSdkDrained(deps: SessionDeps = {}): void {
|
|
125
|
+
if (sdkPending(sessionHome(deps)) !== 0)
|
|
126
|
+
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);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let changing = false;
|
|
130
|
+
|
|
131
|
+
export async function withSessionChange<T>(run: () => Promise<T>): Promise<T> {
|
|
132
|
+
if (changing) throw new ApiError('A session change is already being prepared. Try again when it finishes.', 409);
|
|
133
|
+
changing = true;
|
|
134
|
+
try {
|
|
135
|
+
return await run();
|
|
136
|
+
} finally {
|
|
137
|
+
changing = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function prepareSessionRunner(runner: HarnessRunner, deps: SessionDeps = {}): Promise<void> {
|
|
142
|
+
if (deps.prepare !== undefined) return deps.prepare(runner);
|
|
143
|
+
const [bin = 'metro', ...args] = metroCli(['agent', '--prepare', runner]);
|
|
144
|
+
await new Promise<void>((resolve, reject) => {
|
|
145
|
+
const child = spawn(...asAgent(bin, args), { stdio: ['ignore', 'ignore', 'pipe'], timeout: 16 * 60_000 });
|
|
146
|
+
let error = '';
|
|
147
|
+
child.stderr.on('data', (chunk: Buffer) => { error = (error + chunk.toString()).slice(-2000); });
|
|
148
|
+
child.on('error', (err) => { reject(new ApiError(`Runner preparation failed: ${errMsg(err)}`, 503)); });
|
|
149
|
+
child.on('close', (code) => {
|
|
150
|
+
if (code === 0) resolve();
|
|
151
|
+
else reject(new ApiError(`Runner preparation failed. The runner choice was not changed. ${error.trim()}`, 503));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
function metroCommand(deps: SessionDeps, home: string): string[] {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return continues ? [...command, '-c'] : command;
|
|
157
|
+
if (harnessRunner(deps.agents ?? agentsDir()) === 'sdk') return metroCli(['agent'], deps.runner);
|
|
158
|
+
return [...metroCli(['claude'], deps.metro), ...continueArgs(home, claudeDir(), deps.agents)];
|
|
125
159
|
}
|
|
126
160
|
|
|
127
161
|
function credentialReady(deps: SessionDeps): string | null {
|
|
@@ -130,7 +164,7 @@ function credentialReady(deps: SessionDeps): string | null {
|
|
|
130
164
|
try {
|
|
131
165
|
const cfg = readModelConfig(deps.agents ?? agentsDir());
|
|
132
166
|
const conn = routedConnection(cfg);
|
|
133
|
-
if (conn !== null && conn.provider !== 'anthropic'
|
|
167
|
+
if (conn !== null && notReady(cfg) === null && (conn.provider !== 'anthropic' || conn.apiKey !== '')) return null;
|
|
134
168
|
} catch (err) {
|
|
135
169
|
return `the Model page is not readable (${errMsg(err)})`;
|
|
136
170
|
}
|
|
@@ -145,7 +179,12 @@ const ROOT_REFUSED = 'Metro runs as root; reinstall it as the metro user';
|
|
|
145
179
|
export function sessionBlocked(deps: SessionDeps = {}): string | null {
|
|
146
180
|
if (runningAsRoot()) return ROOT_REFUSED;
|
|
147
181
|
const agents = deps.agents ?? agentsDir();
|
|
182
|
+
if (harnessRunner(agents) === 'sdk' && !sdkAllowed(agents)) return SDK_NEEDS_KEY;
|
|
148
183
|
if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
|
|
184
|
+
return launchBlocked(deps);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function launchBlocked(deps: SessionDeps): string | null {
|
|
149
188
|
if (agentUserMissing(deps))
|
|
150
189
|
return 'Claude Code runs as the user agent here, and that user is not ready yet; see the log';
|
|
151
190
|
if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
|
|
@@ -190,18 +229,29 @@ function recordStart(deps: SessionDeps, tmux: string, now: number, run: { error?
|
|
|
190
229
|
return;
|
|
191
230
|
}
|
|
192
231
|
memory.lastError = null;
|
|
193
|
-
|
|
232
|
+
const agents = deps.agents ?? agentsDir();
|
|
233
|
+
writeState(agents, { version: deps.version ?? METRO_VERSION, runner: harnessRunner(agents) });
|
|
194
234
|
confirmChannels(tmux, now + CONFIRM_WAIT_MS);
|
|
195
235
|
}
|
|
196
236
|
|
|
237
|
+
function tmuxLaunch(deps: SessionDeps, tmux: string, args: string[]): [string, string[]] {
|
|
238
|
+
const launch = asAgent(tmux, args);
|
|
239
|
+
return deps.tmux === undefined && !tmuxServerUp(tmux) ? inSessionScope(launch) : launch;
|
|
240
|
+
}
|
|
241
|
+
|
|
197
242
|
export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
243
|
+
if (changing) throw new ApiError('A runner change is being prepared. Start the session after it finishes.', 409);
|
|
244
|
+
const status = sessionStatus(deps);
|
|
245
|
+
if (status.running) return status;
|
|
246
|
+
if (status.blocked !== null) throw new ApiError(`Cannot start the session: ${status.blocked}`, 409);
|
|
247
|
+
syncAgentView(undefined, deps.agents);
|
|
198
248
|
const tmux = deps.tmux ?? 'tmux';
|
|
199
|
-
const home = deps
|
|
249
|
+
const home = sessionHome(deps);
|
|
200
250
|
const now = (deps.now ?? Date.now)();
|
|
201
251
|
const trusted = trustFolder(home);
|
|
202
252
|
const [command = 'metro', ...args] = metroCommand(deps, home);
|
|
203
253
|
const tmuxArgs = ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args];
|
|
204
|
-
const launch = deps
|
|
254
|
+
const launch = tmuxLaunch(deps, tmux, tmuxArgs);
|
|
205
255
|
const run = spawnSync(...launch, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: '/' });
|
|
206
256
|
recordStart(deps, tmux, now, run);
|
|
207
257
|
if (memory.lastError === null) log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
|
|
@@ -210,13 +260,21 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
|
210
260
|
|
|
211
261
|
export function stopSession(deps: SessionDeps = {}): SessionStatus {
|
|
212
262
|
spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
|
|
263
|
+
stopSdkRunner(sessionHome(deps));
|
|
213
264
|
return sessionStatus(deps);
|
|
214
265
|
}
|
|
215
266
|
|
|
267
|
+
export const sessionLive = (deps: SessionDeps = {}): boolean =>
|
|
268
|
+
sessionRunning(deps.tmux ?? 'tmux') || sdkAlive(readAgentActivity(sessionHome(deps)));
|
|
269
|
+
|
|
216
270
|
export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
|
|
271
|
+
const activity = readAgentActivity(sessionHome(deps));
|
|
272
|
+
const sdk = sdkAlive(activity);
|
|
217
273
|
return {
|
|
218
274
|
name: SESSION_NAME,
|
|
219
|
-
running: sessionRunning(deps.tmux ?? 'tmux'),
|
|
275
|
+
running: sessionRunning(deps.tmux ?? 'tmux') || sdk,
|
|
276
|
+
runner: sdk ? 'sdk' : sessionRunner(deps),
|
|
277
|
+
activity,
|
|
220
278
|
autostart: autostartEnabled(deps.agents ?? agentsDir()),
|
|
221
279
|
blocked: sessionBlocked(deps),
|
|
222
280
|
lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
|
|
@@ -264,8 +322,9 @@ function situation(r: Resolved): 'running' | 'stale' | 'absent' {
|
|
|
264
322
|
export function ensureSession(deps: SessionDeps = {}): Ensured {
|
|
265
323
|
const r = resolved(deps);
|
|
266
324
|
if (!autostartEnabled(r.agents)) return 'off';
|
|
325
|
+
if (changing) return 'blocked';
|
|
267
326
|
const found = situation(r);
|
|
268
|
-
if (found === 'running') return 'running';
|
|
327
|
+
if (found === 'running' || (found === 'absent' && sessionLive(deps))) return 'running';
|
|
269
328
|
const held = holdsBack(deps, r.now);
|
|
270
329
|
if (held !== null) return held;
|
|
271
330
|
if (found === 'stale') {
|