@stage-labs/metro 0.1.0-beta.327 → 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.327",
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
@@ -28,5 +28,5 @@ export function inSessionScope(command: [string, string[]], host: ScopeHost = re
28
28
  const asAgent = file === 'sudo' && args[0] === '-n' && args[1] === '-u' && args[3] === '--';
29
29
  if (!host.metro || !host.systemd || !asAgent) return command;
30
30
  const limit = sessionMemoryLimit(host.total);
31
- return helperArgv(['as-agent-scope', String(limit), String(Math.floor(limit * 0.9)), ...args.slice(4)]);
31
+ return helperArgv(['as-agent-scope', String(limit), String(limit), ...args.slice(4)]);
32
32
  }
@@ -41,7 +41,7 @@ Keep the change to what the task asks for. A pre-existing bug, a performance pro
41
41
 
42
42
  Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
43
43
 
44
- Your final message is a report to the orchestrator, not a message to a human. It gets relayed onward, so make it self-contained and concise. Lead with the outcome. Include concrete evidence, command output, file paths with line numbers, test results, for anything you claim to have verified. Report failures faithfully: if a test failed, say so and show the output; if you skipped a step, say that too.
44
+ Your final message is a report to the orchestrator, not a message to a human. It stays in the orchestrator's context for the rest of the conversation and makes every later answer slower, so keep it short: about 150 words at most, self-contained. Lead with the outcome, then the key evidence for what you claim to have verified, such as the test result or the file you changed. Put anything longer, such as full logs, tables or detailed findings, in a file under ~/work and give its path. Report failures faithfully: if a test failed, say so with its key line of output; if you skipped a step, say that too.
45
45
  `;
46
46
 
47
47
  export interface SetupDeps {
@@ -129,7 +129,10 @@ export function setSystemPrompt(text: string, agents = agentsDir()): void {
129
129
  writeAtomic(promptPath(agents), `${trimmed}\n`, 0o600);
130
130
  }
131
131
 
132
- const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
132
+ const PRIOR_WORKER: ReadonlySet<string> = new Set([
133
+ '08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef',
134
+ '9db999a7bb7cf1d6c12a679520e21c78d038999d5454d7abbc6b87a87c136c54',
135
+ ]);
133
136
  const PRIOR_METRO: ReadonlySet<string> = new Set([
134
137
  '0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
135
138
  '36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
@@ -142,6 +145,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
142
145
  '256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
143
146
  '9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
144
147
  '8b6d7c5a9bc8d02d5e3ae5e5931b36aba9f3d0eb7fd38b371294c2abaa0c94ee',
148
+ '8b8f21cb1b7608540d8b5ed9557e1644b03ce4809a7f320ba134f28129f08ddb',
145
149
  ]);
146
150
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
147
151
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -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.327"
2
+ "version": "0.1.0-beta.329"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.327",
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",
@@ -63,7 +63,7 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
63
63
  tools = metroTools(link);
64
64
  const resume = store.resumable();
65
65
  log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
66
- const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
66
+ const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); runner.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
67
67
  runner.start(runnerOptions(cfg, tools, approvals, resume, hooks.env));
68
68
  return { runner, link, done: runner.run(hooks.observe), stop };
69
69
  } catch (err) {
@@ -7,7 +7,6 @@ import { isRecord } from '@metro-labs/core/is-record';
7
7
  import type { AutomationStore } from '@metro-labs/core/automation-store';
8
8
  import type { Activity } from './activity.js';
9
9
  import { Automation } from './automation.js';
10
- import { ActivityTasks } from './activity-tasks.js';
11
10
  import { RunnerCalls } from './calls.js';
12
11
  import { channelText, type ChannelEvent } from './channel-text.js';
13
12
  import type { RunnerConfig } from './config.js';
@@ -21,7 +20,8 @@ import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
21
20
  import { debugFailure, failureSummary, SessionError, StartupDiagnostics } from './errors.js';
22
21
 
23
22
  const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
24
- export const COMPACT_AT = 120_000;
23
+ export const COMPACT_AT = 80_000;
24
+ const URGENT_FACTOR = 1.5;
25
25
  export const COMPACT_IDLE_MS = 60_000;
26
26
 
27
27
  const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
@@ -97,7 +97,6 @@ export class Runner {
97
97
  private readonly recovery: TaskRecovery;
98
98
  private readonly watch: SessionWatch;
99
99
  private readonly diagnostics = new StartupDiagnostics();
100
- private readonly tasks = new ActivityTasks(() => undefined);
101
100
  private readonly skillWork = new SkillWork();
102
101
  private compactTimer: ReturnType<typeof setTimeout> | null = null;
103
102
  private session: Query | null = null;
@@ -108,11 +107,12 @@ export class Runner {
108
107
  private model: string | null = null;
109
108
  private switching: Promise<void> = Promise.resolve();
110
109
  private ended = false;
110
+ private readonly mainAsks = new Set<string>();
111
111
  private readonly interrupted: number;
112
112
  private interruptionReported = false;
113
113
 
114
114
  constructor(private readonly parts: RunnerParts) {
115
- this.watch = new SessionWatch(parts.readOnly);
115
+ this.watch = new SessionWatch(parts.readOnly, () => this.mainAsks.size > 0);
116
116
  this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
117
117
  ready: () => { parts.skills?.check(); return this.watch.admitting && parts.skills?.busy !== true; },
118
118
  dispatch: (message) => { this.automation?.dispatched(message.uuid); this.watch.dispatched(message.uuid); },
@@ -167,6 +167,12 @@ export class Runner {
167
167
  return this.session;
168
168
  }
169
169
 
170
+ approval(id: string, ask: { worker: string | null } | null): void {
171
+ if (ask === null) this.mainAsks.delete(id);
172
+ else if (ask.worker === null) this.mainAsks.add(id);
173
+ this.inbox.notify();
174
+ }
175
+
170
176
  switchModel(model: string | null): Promise<void> {
171
177
  this.queueModel(model);
172
178
  return this.switching;
@@ -178,7 +184,6 @@ export class Runner {
178
184
  for await (const message of this.session) {
179
185
  if (this.ended) break;
180
186
  this.cancelCompact();
181
- if (message.type === 'system') this.tasks.observe(message);
182
187
  const m: Record<string, unknown> = { ...message };
183
188
  this.recovery.observe(m);
184
189
  this.automation?.observe(m);
@@ -186,8 +191,8 @@ export class Runner {
186
191
  this.observeWork(m);
187
192
  this.note(m);
188
193
  this.parts.activity?.observe(message);
189
- this.inbox.notify();
190
194
  this.maybeCompact();
195
+ this.inbox.notify();
191
196
  observe?.(message);
192
197
  }
193
198
  } catch (err) {
@@ -318,14 +323,19 @@ export class Runner {
318
323
  return this.ended || this.parts.skills?.busy === true || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting;
319
324
  }
320
325
 
326
+ private get compactUrgent(): boolean {
327
+ return this.watch.context >= (this.parts.compactAt ?? COMPACT_AT) * URGENT_FACTOR;
328
+ }
329
+
321
330
  private compactReady(resuming = false): boolean {
322
- const busy = this.maintenanceBusy || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
331
+ const waiting = this.inbox.pending > 0 && !this.compactUrgent;
332
+ const busy = this.maintenanceBusy || (!resuming && (this.calls.live || waiting));
323
333
  return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
324
334
  }
325
335
 
326
336
  private maybeCompact(resuming = false): void {
327
337
  if (!this.compactReady(resuming)) return;
328
- if (resuming) { this.askCompact(true); return; }
338
+ if (resuming || this.compactUrgent) { this.askCompact(resuming); return; }
329
339
  this.compactTimer = setTimeout(() => {
330
340
  this.compactTimer = null;
331
341
  if (this.compactReady()) this.askCompact(false);
@@ -335,7 +345,7 @@ export class Runner {
335
345
 
336
346
  private askCompact(resuming: boolean): void {
337
347
  this.compactAsked = this.inbox.compact();
338
- log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
348
+ log.info({ context: this.watch.context, resuming, urgent: this.compactUrgent, waiting: this.inbox.pending }, 'sdk-runner: compacting at a safe boundary');
339
349
  }
340
350
 
341
351
  private cancelCompact(): void {
@@ -35,7 +35,7 @@ export class SessionWatch {
35
35
  private lastContext = 0;
36
36
  private streamUsage: Record<string, unknown> = {};
37
37
 
38
- constructor(private readonly readOnly: (tool: string) => boolean) {}
38
+ constructor(private readonly readOnly: (tool: string) => boolean, private readonly mainAsking: () => boolean = () => true) {}
39
39
 
40
40
  get compacting(): boolean {
41
41
  return this.compactingNow;
@@ -52,7 +52,7 @@ export class SessionWatch {
52
52
  get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
53
53
 
54
54
  get admitting(): boolean {
55
- return this.awaitingInput === null && !this.requiresAction && !this.compacting;
55
+ return this.awaitingInput === null && !(this.requiresAction && this.mainAsking()) && !this.compacting;
56
56
  }
57
57
 
58
58
  dispatched(uuid?: string): void {
@@ -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[] {
package/skills/METRO.md CHANGED
@@ -22,7 +22,7 @@ You are the main thread of a Claude Code session that is connected to chat throu
22
22
  1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
23
23
  2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
24
24
 
25
- Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. `tool_search` may run on this thread, but never call `tool_call` here: delegate it to a worker, as all other work. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
25
+ Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. Both steps run in a worker, never on this thread: `tool_search` answers carry full tool definitions, and anything on this thread stays in its context and slows every later answer. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
26
26
 
27
27
  **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
28
28
 
@@ -30,6 +30,6 @@ The exception is a person describing a problem, asking a question or thinking ou
30
30
 
31
31
  **The whole request is the deliverable.** Do not quietly make it smaller, larger or different. Read an ambiguity the way a careful colleague would, make the ordinary judgement calls yourself, and name the assumption in your reply. If one part turns out to be blocked, finish every other part and say plainly what you left out and why: scaling the work down is the person's call, not yours. Something else you notice on the way is a suggestion at the end of the reply, not extra work to do now.
32
32
 
33
- **Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
33
+ **Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Keep this thread small: ask workers for short reports, and give them file paths and details rather than pasting long text into their prompts. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
34
34
 
35
35
  **Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.