@stage-labs/metro 0.1.0-beta.328 → 0.1.0-beta.329

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.328",
3
+ "version": "0.1.0-beta.329",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,7 @@ import {
25
25
  type SessionDeps,
26
26
  } from './session.js';
27
27
  import { receiveSessionFile, sessionFilePath } from './session-files.js';
28
+ import { readToolCall } from './tool-call.js';
28
29
  import { createReadStream } from '../agent-user/agent-fs.js';
29
30
  import { pipeline } from 'node:stream/promises';
30
31
  import {
@@ -50,6 +51,11 @@ export interface ClaudeApiDeps extends SetupApiDeps {
50
51
  skillSource?: GitHubSkills;
51
52
  }
52
53
 
54
+ function optionalProject(query: URLSearchParams): string | null {
55
+ const project = query.get('project');
56
+ return project === null || project === '' ? null : project;
57
+ }
58
+
53
59
  function projectOf(query: URLSearchParams): string {
54
60
  const project = query.get('project');
55
61
  if (project === null || project === '') throw new ApiError('project is required', 400);
@@ -73,7 +79,9 @@ const COLLECTIONS: Record<string, Handler> = {
73
79
  };
74
80
 
75
81
  const ITEMS: Record<string, Handler> = {
76
- sessions: (query, dir, id) => {
82
+ sessions: async (query, dir, id) => {
83
+ const tool = query.get('tool');
84
+ if (tool !== null) return { call: await readToolCall(optionalProject(query), id, tool, dir) };
77
85
  const { offset, limit } = pageOf(query);
78
86
  return readTranscript(projectOf(query), id, offset, limit, dir);
79
87
  },
@@ -15,8 +15,8 @@ const MEMORY_DEPTH = 6;
15
15
  const MEMORY_FILES_MAX = 5000;
16
16
  const EDGE_BYTES = 64 * 1024;
17
17
  const TEXT_CAP = 20_000;
18
- const RESULT_CAP = 8_000;
19
- const INPUT_CAP = 4_000;
18
+ export const RESULT_CAP = 8_000;
19
+ export const INPUT_CAP = 4_000;
20
20
  const FILE_CAP = 1_000_000;
21
21
  const MEMORY_MAX = 256 * 1024;
22
22
  const MEMORY_MODE = 0o644;
@@ -31,7 +31,7 @@ export function claudeDir(): string {
31
31
  return join(user === null ? homedir() : user.home, '.claude');
32
32
  }
33
33
 
34
- const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
34
+ export const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
35
35
 
36
36
  export function safeName(value: string, re: RegExp, what: string): string {
37
37
  if (!re.test(value) || value === '.' || value === '..')
@@ -39,13 +39,13 @@ export function safeName(value: string, re: RegExp, what: string): string {
39
39
  return value;
40
40
  }
41
41
 
42
- function projectDir(project: string, dir: string): string {
42
+ export function projectDir(project: string, dir: string): string {
43
43
  const path = join(dir, 'projects', safeName(project, PROJECT_RE, 'Claude project id'));
44
44
  if (!existsSync(path)) throw new ApiError('no such Claude project', 404);
45
45
  return path;
46
46
  }
47
47
 
48
- function parseLines(text: string): Record<string, unknown>[] {
48
+ export function parseLines(text: string): Record<string, unknown>[] {
49
49
  const out: Record<string, unknown>[] = [];
50
50
  for (const line of text.split('\n')) {
51
51
  if (line.trim() === '') continue;
@@ -187,10 +187,10 @@ export interface TranscriptPage {
187
187
  next: number | null;
188
188
  }
189
189
 
190
- const cap = (text: string, max: number): string =>
190
+ export const cap = (text: string, max: number): string =>
191
191
  text.length > max ? `${text.slice(0, max)}\n… (${String(text.length - max)} more characters)` : text;
192
192
 
193
- function resultText(content: unknown): string {
193
+ export function resultText(content: unknown): string {
194
194
  if (typeof content === 'string') return content;
195
195
  if (!Array.isArray(content)) return '';
196
196
  return content
@@ -0,0 +1,73 @@
1
+ import { createReadStream, existsSync, readdirNames } from '../agent-user/agent-fs.js';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { cap, claudeDir, INPUT_CAP, parseLines, projectDir, PROJECT_RE, RESULT_CAP, resultText, safeName, SESSION_RE, str } from './files.js';
6
+
7
+ const TOOL_USE_RE = /^[A-Za-z0-9_-]{1,128}$/;
8
+
9
+ export interface ToolCall {
10
+ name: string;
11
+ input: string;
12
+ result: string | null;
13
+ isError: boolean;
14
+ calledAt: string | null;
15
+ answeredAt: string | null;
16
+ }
17
+
18
+ function transcriptFiles(root: string, session: string): string[] {
19
+ const workers = join(root, session, 'subagents');
20
+ const extra = existsSync(workers) ? readdirNames(workers).filter((name) => name.endsWith('.jsonl')).map((name) => join(workers, name)) : [];
21
+ return [join(root, `${session}.jsonl`), ...extra].filter((path) => existsSync(path));
22
+ }
23
+
24
+ function noteBlock(found: Partial<ToolCall>, block: unknown, toolUse: string, at: string | null): void {
25
+ if (!isRecord(block)) return;
26
+ if (block.type === 'tool_use' && block.id === toolUse) {
27
+ found.name = str(block.name) ?? 'tool';
28
+ found.input = cap(JSON.stringify(block.input ?? {}, null, 1), INPUT_CAP);
29
+ found.calledAt = at;
30
+ }
31
+ if (block.type === 'tool_result' && block.tool_use_id === toolUse) {
32
+ found.result = cap(resultText(block.content), RESULT_CAP);
33
+ found.isError = block.is_error === true;
34
+ found.answeredAt = at;
35
+ }
36
+ }
37
+
38
+ async function scanForCall(path: string, toolUse: string): Promise<Partial<ToolCall>> {
39
+ const found: Partial<ToolCall> = {};
40
+ const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
41
+ for await (const line of lines) {
42
+ if (!line.includes(toolUse)) continue;
43
+ const parsed = parseLines(line)[0];
44
+ const content = parsed !== undefined && isRecord(parsed.message) ? parsed.message.content : null;
45
+ if (!Array.isArray(content)) continue;
46
+ for (const block of content) noteBlock(found, block, toolUse, str(parsed?.timestamp));
47
+ if (found.name !== undefined && found.result !== undefined) break;
48
+ }
49
+ return found;
50
+ }
51
+
52
+ function projectHolding(session: string, dir: string): string | null {
53
+ const root = join(dir, 'projects');
54
+ if (!existsSync(root)) return null;
55
+ return readdirNames(root).find((name) => PROJECT_RE.test(name) && existsSync(join(root, name, `${session}.jsonl`))) ?? null;
56
+ }
57
+
58
+ function completed(name: string, found: Partial<ToolCall>): ToolCall {
59
+ return { name, input: found.input ?? '{}', result: found.result ?? null, isError: found.isError ?? false, calledAt: found.calledAt ?? null, answeredAt: found.answeredAt ?? null };
60
+ }
61
+
62
+ export async function readToolCall(project: string | null, session: string, toolUse: string, dir = claudeDir()): Promise<ToolCall | null> {
63
+ const id = safeName(session, SESSION_RE, 'session id');
64
+ const holder = project ?? projectHolding(id, dir);
65
+ if (holder === null) return null;
66
+ const root = projectDir(holder, dir);
67
+ const wanted = safeName(toolUse, TOOL_USE_RE, 'tool call id');
68
+ for (const path of transcriptFiles(root, id)) {
69
+ const found = await scanForCall(path, wanted);
70
+ if (found.name !== undefined) return completed(found.name, found);
71
+ }
72
+ return null;
73
+ }
@@ -87,9 +87,11 @@ async function relay(req: IncomingMessage, res: ServerResponse, path: string, de
87
87
  sendError(res, 503, 'api_error', NOT_ACCEPTED);
88
88
  return;
89
89
  }
90
- await pipeResponse(upstream, res, watch);
90
+ await pipeResponse(upstream, res, watch, { site: RELAY_SITE });
91
91
  }
92
92
 
93
+ const RELAY_SITE = { connection: 'gateway.metro.box', model: 'relayed' };
94
+
93
95
  function refusal(req: IncomingMessage, path: string, deps: RelayDeps): [number, string, string] | null {
94
96
  if (cameThroughProxy(req)) return [401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address'];
95
97
  if (keyOf(req) === '' || !deps.identify(keyOf(req))) return [401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent'];
@@ -15,6 +15,7 @@ import {
15
15
  } from './helper-script.js';
16
16
  import { AGENT_NAME } from '../agent-user/user.js';
17
17
  import { hardenTailscale } from './tailscale-guard.js';
18
+ import { liftSessionSoftLimits } from './session-scopes.js';
18
19
 
19
20
  interface InstallPaths {
20
21
  helper: string;
@@ -73,7 +74,7 @@ if (import.meta.main) {
73
74
  installRootHelper();
74
75
  installImdsGuard();
75
76
  installUpgradeUnit(node);
76
- for (const note of hardenTailscale()) process.stderr.write(`metro: ${note}\n`);
77
+ for (const note of [...hardenTailscale(), ...liftSessionSoftLimits()]) process.stderr.write(`metro: ${note}\n`);
77
78
  process.stdout.write(
78
79
  `metro: wrote ${HELPER_PATH}, ${SUDOERS_PATH}, ${IMDS_UNIT_PATH} and ${UPGRADE_UNIT_PATH}; only root and metro reach the instance metadata service, and the root side follows every update\n`,
79
80
  );
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export const SYSTEM_SLICE = '/sys/fs/cgroup/system.slice';
6
+ const SESSION_SCOPE = /^metro-claude-\d+\.scope$/;
7
+
8
+ type Run = (file: string, args: string[]) => boolean;
9
+
10
+ const quiet: Run = (file, args) => spawnSync(file, args, { stdio: 'ignore' }).status === 0;
11
+
12
+ const softLimit = (dir: string): string | null => {
13
+ const path = join(dir, 'memory.high');
14
+ return existsSync(path) ? readFileSync(path, 'utf8').trim() : null;
15
+ };
16
+
17
+ export function liftSessionSoftLimits(run: Run = quiet, slice: string = SYSTEM_SLICE): string[] {
18
+ if (!existsSync(slice)) return [];
19
+ const held = readdirSync(slice).filter((name) => SESSION_SCOPE.test(name) && ![null, 'max'].includes(softLimit(join(slice, name))));
20
+ return held.map((scope) =>
21
+ run('systemctl', ['set-property', '--runtime', scope, 'MemoryHigh=infinity'])
22
+ ? `${scope}: its soft memory limit is lifted, so only the hard limit applies`
23
+ : `${scope}: its soft memory limit could not be lifted; it goes when the session starts again`,
24
+ );
25
+ }
@@ -27,6 +27,21 @@ function challengeOf(verifier: string): string {
27
27
  return createHash('sha256').update(verifier).digest('base64url');
28
28
  }
29
29
 
30
+ const REASON_MAX = 600;
31
+
32
+ function oneLine(value: unknown): string {
33
+ if (typeof value !== 'string') return '';
34
+ const printable = Array.from(value, (char) => (char < ' ' || char === '\u007f' ? ' ' : char)).join('');
35
+ const text = printable.replace(/\s+/g, ' ').trim();
36
+ return text.length > REASON_MAX ? `${text.slice(0, REASON_MAX - 1)}…` : text;
37
+ }
38
+
39
+ function registrationReason(body: unknown): string {
40
+ if (!isRecord(body)) return '.';
41
+ const reason = oneLine(body.error_description) || oneLine(body.error);
42
+ return reason === '' ? '.' : `: ${reason}`;
43
+ }
44
+
30
45
  export async function registerClient(
31
46
  server: OAuthServer,
32
47
  redirectUri: string,
@@ -58,7 +73,7 @@ export async function registerClient(
58
73
  }
59
74
  const body: unknown = await res.json().catch(() => null);
60
75
  if (!res.ok || !isRecord(body) || typeof body.client_id !== 'string')
61
- throw refused('That server refused to register Metro as a client.');
76
+ throw refused(`That server refused to register Metro as a client${registrationReason(body)}`);
62
77
  const secret = body.client_secret;
63
78
  return {
64
79
  clientId: body.client_id,
@@ -34,14 +34,27 @@ export function noteFailure(site: FailureSite, failure: Failure, what: string):
34
34
  log.warn({ connection: site.connection, model: site.model, ...failure, message: providerText(failure.message) }, what);
35
35
  }
36
36
 
37
+ const STOP_DATA = /^\{\s*"type"\s*:\s*"message_stop"/;
38
+
37
39
  export class StreamErrors {
38
40
  private readonly parser = new SseParser();
41
+ private ended = false;
39
42
 
40
43
  constructor(private readonly site: FailureSite) {}
41
44
 
45
+ get settled(): boolean {
46
+ return this.ended;
47
+ }
48
+
49
+ cut(): void {
50
+ noteFailure(this.site, { code: 'stream_cut', message: '' }, 'gateway: the provider ended its stream before the answer was complete');
51
+ }
52
+
42
53
  feed(text: string): void {
43
54
  for (const raw of this.parser.push(text)) {
55
+ if (raw.event === 'message_stop' || STOP_DATA.test(raw.data)) this.ended = true;
44
56
  if (raw.event !== 'error' && !ERROR_DATA.test(raw.data)) continue;
57
+ this.ended = true;
45
58
  const data = parseEvent(raw)?.data ?? {};
46
59
  const error = isRecord(data.error) ? data.error : {};
47
60
  noteFailure(this.site, { code: stringOf(error.type) || stringOf(error.code), message: stringOf(error.message) }, 'gateway: the provider sent an error in the middle of its stream');
@@ -171,16 +171,30 @@ function openResponse(upstream: Response, res: ServerResponse, opts: PipeOptions
171
171
 
172
172
  type Tap = (chunk: Uint8Array) => void;
173
173
 
174
- function tapOf(opts: PipeOptions, streaming: boolean): Tap | null {
174
+ interface Tapped {
175
+ tap: Tap | null;
176
+ errors: StreamErrors | null;
177
+ }
178
+
179
+ function tapOf(opts: PipeOptions, streaming: boolean): Tapped {
175
180
  const scanner = opts.scanner;
176
181
  const errors = streaming && opts.site !== undefined ? new StreamErrors(opts.site) : null;
177
- if (scanner === undefined && errors === null) return null;
182
+ if (scanner === undefined && errors === null) return { tap: null, errors };
178
183
  const decoder = new TextDecoder();
179
- return (chunk) => {
184
+ const tap: Tap = (chunk) => {
180
185
  const text = decoder.decode(chunk, { stream: true });
181
186
  scanner?.feed(text);
182
187
  errors?.feed(text);
183
188
  };
189
+ return { tap, errors };
190
+ }
191
+
192
+ const CUT_MESSAGE = 'metro gateway: the provider ended its stream before the answer was complete; try again';
193
+
194
+ function closeCut(res: ServerResponse, errors: StreamErrors | null): void {
195
+ if (errors === null || errors.settled || res.writableEnded) return;
196
+ errors.cut();
197
+ res.write(errorFrame('api_error', CUT_MESSAGE));
184
198
  }
185
199
 
186
200
  async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse, watch: Watch, tap: Tap | null): Promise<void> {
@@ -202,10 +216,15 @@ export async function pipeResponse(upstream: Response, res: ServerResponse, watc
202
216
  return;
203
217
  }
204
218
  const ping = opts.keepalive === true && streaming ? setInterval(() => res.write(PING_FRAME), PING_MS) : null;
219
+ const { tap, errors } = tapOf(opts, streaming);
205
220
  try {
206
- await pump(body, res, watch, tapOf(opts, streaming));
221
+ await pump(body, res, watch, tap);
222
+ closeCut(res, errors);
207
223
  } catch (err) {
208
- if (!watch.idle()) throw err;
224
+ if (!watch.idle()) {
225
+ closeCut(res, errors);
226
+ throw err;
227
+ }
209
228
  if (streaming) res.write(errorFrame('api_error', idleMessage(watch.ms)));
210
229
  } finally {
211
230
  if (ping !== null) clearInterval(ping);
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.328"
2
+ "version": "0.1.0-beta.329"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.328",
2
+ "version": "0.1.0-beta.329",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,8 +1,9 @@
1
1
  import { isRecord } from '@metro-labs/core/is-record';
2
+ import { log } from '@metro-labs/core/log';
2
3
  import { runnerTaskDescription } from '@metro-labs/core/runner-activity';
3
4
  import { activityName } from './activity-tasks.js';
4
5
  import type { Unanswered } from './inbox.js';
5
- import { pendingNotice, TASK_LIMIT, type SavedTask, type TaskReason } from './task-state.js';
6
+ import { exhausted, pendingNotice, TASK_LIMIT, type SavedTask, type TaskReason } from './task-state.js';
6
7
 
7
8
  interface Tool { name: string; parent: string | null }
8
9
  const failed = (text: unknown): TaskReason => typeof text === 'string' && /\b429\b|rate.?limit/i.test(text.slice(0, 4_096)) ? 'rate_limit' : 'task_error';
@@ -11,6 +12,7 @@ export class TaskLedger {
11
12
  private readonly rows = new Map<string, SavedTask>();
12
13
  private readonly tools = new Map<string, Tool>();
13
14
  private readonly errors = new Map<string, TaskReason>();
15
+ private warned = false;
14
16
 
15
17
  constructor(tasks: SavedTask[], private readonly save: (tasks: SavedTask[], handled?: ReadonlySet<string>) => void) {
16
18
  for (const task of tasks) this.rows.set(task.id, task);
@@ -102,7 +104,8 @@ export class TaskLedger {
102
104
 
103
105
  private room(): boolean {
104
106
  if (this.rows.size < TASK_LIMIT) return true;
105
- const old = this.snapshot().find((task) => task.notice === null && task.state !== 'running');
107
+ const rows = this.snapshot();
108
+ const old = rows.find((task) => task.notice === null && task.state !== 'running') ?? rows.find(exhausted);
106
109
  if (old === undefined) return false;
107
110
  this.rows.delete(old.id);
108
111
  return true;
@@ -111,12 +114,18 @@ export class TaskLedger {
111
114
  private get(id: string, now: number): SavedTask {
112
115
  const known = this.rows.get(id);
113
116
  if (known !== undefined) return known;
114
- if (!this.room()) throw new Error('The saved Agent SDK task ledger is full. Handle pending recovery notices before starting more work.');
115
117
  const task: SavedTask = { id, toolUseId: null, owner: null, attempt: 0, state: 'interrupted', events: [], updatedAt: now, notice: null };
116
- this.rows.set(id, task);
118
+ if (this.room()) this.rows.set(id, task);
119
+ else this.untracked();
117
120
  return task;
118
121
  }
119
122
 
123
+ private untracked(): void {
124
+ if (this.warned) return;
125
+ this.warned = true;
126
+ log.warn({ limit: TASK_LIMIT }, 'sdk-runner: the task ledger is full of running work and pending recovery notices, so new work runs without recovery tracking');
127
+ }
128
+
120
129
  private start(id: string, rawTool: unknown, event: string | null, now: number, description?: unknown): void {
121
130
  const task = this.get(id, now);
122
131
  if (task.state !== 'running') task.attempt += 1;
@@ -4,9 +4,9 @@ import type { Inbox, Unanswered, Uuid } from './inbox.js';
4
4
  import type { SessionStore } from './session-store.js';
5
5
  import { completedCommand, startedCommand, uuidsOf } from './session-watch.js';
6
6
  import { TaskLedger } from './task-ledger.js';
7
- import { TASK_LIMIT, type SavedTask } from './task-state.js';
7
+ import { NOTICE_DELIVERIES, TASK_LIMIT, type SavedTask } from './task-state.js';
8
8
 
9
- const DELAYS = [30_000, 120_000, 300_000];
9
+ const DELAYS = [30_000, 120_000, 300_000] as const;
10
10
  const marker = (token: string): string => `[metro-task-handled:${token}]`;
11
11
  interface Delivery { uuid: Uuid; tokens: string[]; consumed: boolean; completed?: boolean }
12
12
 
@@ -159,7 +159,7 @@ export class TaskRecovery {
159
159
 
160
160
  private eligible(task: SavedTask): boolean {
161
161
  const notice = task.notice;
162
- if (notice === null || notice.deliveries >= DELAYS.length) return false;
162
+ if (notice === null || notice.deliveries >= NOTICE_DELIVERIES) return false;
163
163
  return ![...this.deliveries.values()].some((delivery) => delivery.completed !== true && delivery.tokens.includes(notice.token));
164
164
  }
165
165
 
@@ -5,6 +5,7 @@ import { activityName } from './activity-tasks.js';
5
5
  import type { Uuid } from './inbox.js';
6
6
 
7
7
  export const TASK_LIMIT = 200;
8
+ export const NOTICE_DELIVERIES = 3;
8
9
  const STATES = ['running', 'completed', 'failed', 'stopped', 'interrupted'] as const;
9
10
  const REASONS = ['task_error', 'rate_limit', 'worker_restart', 'interrupted', 'provider_error'] as const;
10
11
  export type TaskReason = (typeof REASONS)[number];
@@ -57,6 +58,9 @@ function task(raw: unknown): raw is SavedTask {
57
58
  return integer(raw.attempt) && integer(raw.updatedAt) && STATES.some((state) => state === raw.state) && notice(raw.notice);
58
59
  }
59
60
 
61
+ export const exhausted = (task: SavedTask): boolean =>
62
+ task.state !== 'running' && task.notice !== null && task.notice.deliveries >= NOTICE_DELIVERIES && task.notice.offered === undefined;
63
+
60
64
  export const sdkTask = (task: SavedTask): boolean => !/^(?:input|turn):/.test(task.id);
61
65
 
62
66
  export function savedTasks(raw: unknown): SavedTask[] {