@stage-labs/metro 0.1.0-beta.294 → 0.1.0-beta.295

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.294",
3
+ "version": "0.1.0-beta.295",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.294",
3
+ "version": "0.1.0-beta.295",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -26,6 +26,9 @@ const FAILURE = {
26
26
  status_unavailable: 'The SDK status file cannot be written. Check its path and permissions before starting.',
27
27
  saved_state: 'The saved Agent SDK state cannot be read. Restore it before starting.',
28
28
  executable: 'The SDK executable could not start. Prepare the SDK runtime again.',
29
+ module: 'The SDK runtime could not load a required module. Prepare the SDK runtime again.',
30
+ configuration: 'The SDK launch configuration is missing or invalid. Check Harness and the Model page before starting.',
31
+ process_exit: 'The SDK process exited unexpectedly. Check its reported exit code or signal before retrying.',
29
32
  connection: 'The SDK connection failed. Check the server and provider connection.',
30
33
  session_ended: 'The Agent SDK session ended unexpectedly.',
31
34
  interrupted: 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.',
@@ -33,8 +36,40 @@ const FAILURE = {
33
36
  } as const;
34
37
  export type RunnerFailureCode = keyof typeof FAILURE;
35
38
 
36
- export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null): string {
37
- return code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
39
+ const ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM', 'ENOEXEC', 'ENOMEM', 'ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND', 'ERR_DLOPEN_FAILED', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'] as const;
40
+ const SIGNALS = ['SIGTERM', 'SIGKILL', 'SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGILL', 'SIGINT', 'SIGHUP', 'SIGPIPE'] as const;
41
+
42
+ export interface RunnerDiagnostic {
43
+ source: 'exception' | 'stderr';
44
+ errorCode?: (typeof ERROR_CODES)[number];
45
+ httpStatus?: number;
46
+ exitCode?: number;
47
+ signal?: (typeof SIGNALS)[number];
48
+ }
49
+
50
+ const boundedNumber = (value: unknown, min: number, max: number): number | undefined => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : undefined;
51
+
52
+ export function runnerDiagnostic(raw: unknown): RunnerDiagnostic | undefined {
53
+ if (!isRecord(raw) || (raw.source !== 'exception' && raw.source !== 'stderr')) return undefined;
54
+ const errorCode = ERROR_CODES.find((code) => code === raw.errorCode);
55
+ const signal = SIGNALS.find((value) => value === raw.signal);
56
+ const httpStatus = boundedNumber(raw.httpStatus, 400, 599);
57
+ const exitCode = boundedNumber(raw.exitCode, 0, 255);
58
+ return {
59
+ source: raw.source,
60
+ ...(errorCode === undefined ? {} : { errorCode }),
61
+ ...(httpStatus === undefined ? {} : { httpStatus }),
62
+ ...(exitCode === undefined ? {} : { exitCode }),
63
+ ...(signal === undefined ? {} : { signal }),
64
+ };
65
+ }
66
+
67
+ export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null, diagnostic?: RunnerDiagnostic): string {
68
+ const summary = code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
69
+ const safe = runnerDiagnostic(diagnostic);
70
+ if (safe === undefined) return summary;
71
+ const details = [safe.errorCode, safe.httpStatus === undefined ? null : `HTTP ${safe.httpStatus}`, safe.exitCode === undefined ? null : `exit ${safe.exitCode}`, safe.signal].filter((value) => value !== undefined && value !== null);
72
+ return details.length === 0 ? summary : `${summary} Observed: ${details.join(', ')} (${safe.source === 'stderr' ? 'startup stderr' : 'exception'}).`;
38
73
  }
39
74
 
40
75
  export interface RunnerTask {
@@ -70,6 +105,7 @@ export interface RunnerEvent {
70
105
  toolUseId?: string;
71
106
  taskId: string | null;
72
107
  code?: RunnerFailureCode;
108
+ diagnostic?: RunnerDiagnostic;
73
109
  }
74
110
 
75
111
  export interface RunnerFailure extends RunnerEvent {
@@ -146,16 +182,17 @@ function tool(raw: unknown): RunnerTool | null {
146
182
  return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
147
183
  }
148
184
 
149
- const failureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
185
+ export const runnerFailureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
150
186
  const failureKind = (value: RunnerEventKind): boolean => ['turn_failed', 'tool_failed', 'task_failed', 'compact_failed', 'session_failed'].includes(value);
151
187
 
152
188
  function event(raw: unknown): RunnerEvent | null {
153
189
  if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
190
+ const diagnostic = runnerDiagnostic(raw.diagnostic);
154
191
  return {
155
192
  at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId),
156
193
  ...(name(raw.id) === null ? {} : { id: String(raw.id) }),
157
194
  ...(name(raw.toolUseId) === null ? {} : { toolUseId: String(raw.toolUseId) }),
158
- ...(failureKind(raw.kind) && failureCode(raw.code) ? { code: raw.code } : {}),
195
+ ...(failureKind(raw.kind) && runnerFailureCode(raw.code) ? { code: raw.code, ...(diagnostic === undefined ? {} : { diagnostic }) } : {}),
159
196
  };
160
197
  }
161
198
 
@@ -184,7 +221,7 @@ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
184
221
  function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'lastError' | 'activeFailure' | 'lastFailure'> {
185
222
  const activeFailure = failure(raw.activeFailure);
186
223
  return {
187
- lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool) : safeError(raw.lastError),
224
+ lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool, activeFailure.diagnostic) : safeError(raw.lastError),
188
225
  ...(Object.hasOwn(raw, 'activeFailure') ? { activeFailure } : {}),
189
226
  ...(Object.hasOwn(raw, 'lastFailure') ? { lastFailure: failure(raw.lastFailure) } : {}),
190
227
  };
@@ -42,6 +42,10 @@ export class ChannelRelay {
42
42
  });
43
43
  }
44
44
 
45
+ whenIdle(): Promise<void> {
46
+ return this.chain;
47
+ }
48
+
45
49
  replayMissed(): void {
46
50
  const missed = bufferedSince(this.ledger.startAt).filter(
47
51
  (b) => !this.ledger.delivered.has(b.busSeq),
@@ -84,7 +84,7 @@ async function serveGet(
84
84
  scope: session.scope,
85
85
  req,
86
86
  res,
87
- previous: session.currentSink,
87
+ previous: () => session.currentSink,
88
88
  log: channelLog,
89
89
  registerSink: (sink) => {
90
90
  session.bindSink(sink);
@@ -55,6 +55,9 @@ export interface RawGetSink {
55
55
  close: () => void;
56
56
  }
57
57
 
58
+ const streamEnded = (sink: RawGetSink, res: ServerResponse): boolean =>
59
+ sink.closed || res.destroyed || res.writableEnded;
60
+
58
61
  export interface ValidateResult {
59
62
  ok: boolean;
60
63
  status?: number;
@@ -93,6 +96,7 @@ export interface ServeOpts {
93
96
  res: ServerResponse;
94
97
  log: (...a: unknown[]) => void;
95
98
  registerSink: (sink: RawGetSink | undefined) => void;
99
+ previous?: () => RawGetSink | undefined;
96
100
  }
97
101
 
98
102
  export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
@@ -111,13 +115,17 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
111
115
  res.write(COMMENT);
112
116
 
113
117
  let active = transport;
118
+ let entry: StreamEntry | undefined;
114
119
  const timers: { keepalive?: ReturnType<typeof setInterval> } = {};
115
120
  const closeStream = (): void => {
116
121
  if (sink.closed) return;
117
122
  sink.closed = true;
118
123
  if (timers.keepalive) clearInterval(timers.keepalive);
119
- web(active)?._streamMapping?.delete(STANDALONE_STREAM_ID);
120
- registerSink(undefined);
124
+ const mapping = web(active)?._streamMapping;
125
+ if (entry !== undefined && mapping?.get(STANDALONE_STREAM_ID) === entry) {
126
+ mapping.delete(STANDALONE_STREAM_ID);
127
+ registerSink(undefined);
128
+ }
121
129
  try {
122
130
  res.end();
123
131
  } catch {
@@ -128,11 +136,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
128
136
  closed: false,
129
137
  attach: (next: StreamableHTTPServerTransport): void => {
130
138
  active = next;
131
- const entry: StreamEntry = {
139
+ entry = {
132
140
  controller: {
133
141
  enqueue: (chunk: Uint8Array): void => {
134
- if (sink.closed) return;
135
- if (res.destroyed || res.writableEnded) {
142
+ if (streamEnded(sink, res)) {
136
143
  closeStream();
137
144
  return;
138
145
  }
@@ -147,6 +154,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
147
154
  close: closeStream,
148
155
  };
149
156
 
157
+ res.on('close', closeStream);
158
+ res.on('error', closeStream);
159
+ req.on('close', closeStream);
160
+
150
161
  const lastEventId = headerValue(req, 'last-event-id');
151
162
  if (lastEventId) {
152
163
  await eventStore.replayEventsAfter(lastEventId, {
@@ -168,12 +179,17 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
168
179
  });
169
180
  }
170
181
 
182
+ if (streamEnded(sink, res)) {
183
+ closeStream();
184
+ return;
185
+ }
186
+ opts.previous?.()?.close();
171
187
  sink.attach(transport);
172
188
  registerSink(sink);
189
+ if (sink.closed) return;
173
190
 
174
191
  timers.keepalive = setInterval(() => {
175
- if (sink.closed) return;
176
- if (res.destroyed || res.writableEnded) {
192
+ if (streamEnded(sink, res)) {
177
193
  closeStream();
178
194
  return;
179
195
  }
@@ -185,14 +201,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
185
201
  }
186
202
  }, KEEPALIVE_MS);
187
203
  timers.keepalive.unref?.();
188
-
189
- res.on('close', closeStream);
190
- res.on('error', closeStream);
191
- req.on('close', closeStream);
192
204
  }
193
205
 
194
206
  export interface ChannelGetOpts extends ServeOpts {
195
- previous: RawGetSink | undefined;
207
+ previous: () => RawGetSink | undefined;
196
208
  }
197
209
 
198
210
  export async function serveChannelGet(opts: ChannelGetOpts): Promise<boolean> {
@@ -201,7 +213,7 @@ export async function serveChannelGet(opts: ChannelGetOpts): Promise<boolean> {
201
213
  opts.res.writeHead(check.status ?? 400).end(check.message ?? 'bad request');
202
214
  return false;
203
215
  }
204
- if (opts.previous && !opts.previous.closed) opts.previous.close();
216
+ opts.previous()?.close();
205
217
  await serveStandaloneGet(opts);
206
218
  return true;
207
219
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { currentBusSeq } from '@metro-labs/core/events';
3
+ import { errMsg } from '@metro-labs/core/log';
3
4
  import { newReplayLedger } from '../channels/relay.js';
4
5
  import { McpSession, channelLog, type SessionKind } from './session.js';
5
6
  import { allowedAgents, type RequestIdentity } from './request-identity.js';
@@ -9,6 +10,7 @@ export class SessionSlot {
9
10
  private readonly ledger = newReplayLedger();
10
11
  private started = false;
11
12
  private live: boolean;
13
+ private changing: Promise<void> = Promise.resolve();
12
14
 
13
15
  constructor(
14
16
  live = true,
@@ -45,10 +47,20 @@ export class SessionSlot {
45
47
  if (this.started) this.session?.startChannel();
46
48
  }
47
49
 
48
- async open(
49
- identity: RequestIdentity,
50
- adoptId?: string,
51
- ): Promise<McpSession> {
50
+ open(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
51
+ return this.change(() => this.replace(identity, adoptId));
52
+ }
53
+
54
+ private change<T>(run: () => Promise<T>): Promise<T> {
55
+ const changed = this.changing.then(run);
56
+ this.changing = changed.then(() => undefined).catch((err: unknown) => {
57
+ channelLog('session: change failed', errMsg(err));
58
+ });
59
+ return changed;
60
+ }
61
+
62
+ private async replace(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
63
+ if (adoptId !== undefined && this.session?.id === adoptId) return this.session;
52
64
  await this.session?.close();
53
65
  if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
54
66
  const id = adoptId ?? randomUUID();
@@ -61,6 +73,11 @@ export class SessionSlot {
61
73
  live: () => this.live,
62
74
  onClosed: (s) => {
63
75
  if (this.session === s) this.session = undefined;
76
+ s.channel.whenIdle().then(() => {
77
+ if (this.session?.streamAttached) this.session.replayMissed();
78
+ }).catch((err: unknown) => {
79
+ channelLog('session: handoff replay failed', errMsg(err));
80
+ });
64
81
  },
65
82
  });
66
83
  this.session = session;
@@ -69,7 +86,9 @@ export class SessionSlot {
69
86
  return session;
70
87
  }
71
88
 
72
- async close(): Promise<void> {
73
- await this.session?.close();
89
+ close(): Promise<void> {
90
+ return this.change(async () => {
91
+ await this.session?.close();
92
+ });
74
93
  }
75
94
  }
@@ -177,6 +177,10 @@ export class McpSession {
177
177
  }
178
178
 
179
179
  bindSink(sink: RawGetSink | undefined): void {
180
+ if (this.closed) {
181
+ sink?.close();
182
+ return;
183
+ }
180
184
  this.sink = sink;
181
185
  if (sink !== undefined) {
182
186
  this.announceCallState();
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.294"
2
+ "version": "0.1.0-beta.295"
3
3
  }
@@ -26,6 +26,9 @@ const FAILURE = {
26
26
  status_unavailable: 'The SDK status file cannot be written. Check its path and permissions before starting.',
27
27
  saved_state: 'The saved Agent SDK state cannot be read. Restore it before starting.',
28
28
  executable: 'The SDK executable could not start. Prepare the SDK runtime again.',
29
+ module: 'The SDK runtime could not load a required module. Prepare the SDK runtime again.',
30
+ configuration: 'The SDK launch configuration is missing or invalid. Check Harness and the Model page before starting.',
31
+ process_exit: 'The SDK process exited unexpectedly. Check its reported exit code or signal before retrying.',
29
32
  connection: 'The SDK connection failed. Check the server and provider connection.',
30
33
  session_ended: 'The Agent SDK session ended unexpectedly.',
31
34
  interrupted: 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.',
@@ -33,8 +36,40 @@ const FAILURE = {
33
36
  } as const;
34
37
  export type RunnerFailureCode = keyof typeof FAILURE;
35
38
 
36
- export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null): string {
37
- return code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
39
+ const ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM', 'ENOEXEC', 'ENOMEM', 'ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND', 'ERR_DLOPEN_FAILED', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'] as const;
40
+ const SIGNALS = ['SIGTERM', 'SIGKILL', 'SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGILL', 'SIGINT', 'SIGHUP', 'SIGPIPE'] as const;
41
+
42
+ export interface RunnerDiagnostic {
43
+ source: 'exception' | 'stderr';
44
+ errorCode?: (typeof ERROR_CODES)[number];
45
+ httpStatus?: number;
46
+ exitCode?: number;
47
+ signal?: (typeof SIGNALS)[number];
48
+ }
49
+
50
+ const boundedNumber = (value: unknown, min: number, max: number): number | undefined => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : undefined;
51
+
52
+ export function runnerDiagnostic(raw: unknown): RunnerDiagnostic | undefined {
53
+ if (!isRecord(raw) || (raw.source !== 'exception' && raw.source !== 'stderr')) return undefined;
54
+ const errorCode = ERROR_CODES.find((code) => code === raw.errorCode);
55
+ const signal = SIGNALS.find((value) => value === raw.signal);
56
+ const httpStatus = boundedNumber(raw.httpStatus, 400, 599);
57
+ const exitCode = boundedNumber(raw.exitCode, 0, 255);
58
+ return {
59
+ source: raw.source,
60
+ ...(errorCode === undefined ? {} : { errorCode }),
61
+ ...(httpStatus === undefined ? {} : { httpStatus }),
62
+ ...(exitCode === undefined ? {} : { exitCode }),
63
+ ...(signal === undefined ? {} : { signal }),
64
+ };
65
+ }
66
+
67
+ export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null, diagnostic?: RunnerDiagnostic): string {
68
+ const summary = code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
69
+ const safe = runnerDiagnostic(diagnostic);
70
+ if (safe === undefined) return summary;
71
+ const details = [safe.errorCode, safe.httpStatus === undefined ? null : `HTTP ${safe.httpStatus}`, safe.exitCode === undefined ? null : `exit ${safe.exitCode}`, safe.signal].filter((value) => value !== undefined && value !== null);
72
+ return details.length === 0 ? summary : `${summary} Observed: ${details.join(', ')} (${safe.source === 'stderr' ? 'startup stderr' : 'exception'}).`;
38
73
  }
39
74
 
40
75
  export interface RunnerTask {
@@ -70,6 +105,7 @@ export interface RunnerEvent {
70
105
  toolUseId?: string;
71
106
  taskId: string | null;
72
107
  code?: RunnerFailureCode;
108
+ diagnostic?: RunnerDiagnostic;
73
109
  }
74
110
 
75
111
  export interface RunnerFailure extends RunnerEvent {
@@ -146,16 +182,17 @@ function tool(raw: unknown): RunnerTool | null {
146
182
  return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
147
183
  }
148
184
 
149
- const failureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
185
+ export const runnerFailureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
150
186
  const failureKind = (value: RunnerEventKind): boolean => ['turn_failed', 'tool_failed', 'task_failed', 'compact_failed', 'session_failed'].includes(value);
151
187
 
152
188
  function event(raw: unknown): RunnerEvent | null {
153
189
  if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
190
+ const diagnostic = runnerDiagnostic(raw.diagnostic);
154
191
  return {
155
192
  at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId),
156
193
  ...(name(raw.id) === null ? {} : { id: String(raw.id) }),
157
194
  ...(name(raw.toolUseId) === null ? {} : { toolUseId: String(raw.toolUseId) }),
158
- ...(failureKind(raw.kind) && failureCode(raw.code) ? { code: raw.code } : {}),
195
+ ...(failureKind(raw.kind) && runnerFailureCode(raw.code) ? { code: raw.code, ...(diagnostic === undefined ? {} : { diagnostic }) } : {}),
159
196
  };
160
197
  }
161
198
 
@@ -184,7 +221,7 @@ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
184
221
  function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'lastError' | 'activeFailure' | 'lastFailure'> {
185
222
  const activeFailure = failure(raw.activeFailure);
186
223
  return {
187
- lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool) : safeError(raw.lastError),
224
+ lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool, activeFailure.diagnostic) : safeError(raw.lastError),
188
225
  ...(Object.hasOwn(raw, 'activeFailure') ? { activeFailure } : {}),
189
226
  ...(Object.hasOwn(raw, 'lastFailure') ? { lastFailure: failure(raw.lastFailure) } : {}),
190
227
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.294",
2
+ "version": "0.1.0-beta.295",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -3,11 +3,13 @@ import { readFileSync } from 'node:fs';
3
3
  import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { log } from '@metro-labs/core/log';
6
- import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerInput, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
6
+ import { runnerFailureSummary, type RunnerActivity, type RunnerDiagnostic, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerInput, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
7
7
  import { writeAtomic } from '@metro-labs/core/secure-fs';
8
8
  import { ActivityTasks, activityName } from './activity-tasks.js';
9
9
  import type { ApprovalAsk } from './approvals.js';
10
10
  import type { SavedTask } from './task-state.js';
11
+ import { sessionFailure } from './errors.js';
12
+ export { failureSummary } from './errors.js';
11
13
 
12
14
  const HEARTBEAT_MS = 5_000;
13
15
  const MAX_TRACKED = 200;
@@ -18,19 +20,10 @@ type System = Extract<SDKMessage, { type: 'system' }>;
18
20
  interface TrackedTool extends RunnerTool { parent: string | null }
19
21
  interface Approval { tool: string | null; taskId: string | null; worker: boolean }
20
22
 
21
- function sessionFailureCode(err: unknown): RunnerFailureCode {
22
- const text = err instanceof Error ? err.message : String(err);
23
- for (const code of ['status_unavailable', 'session_ended', 'interrupted', 'saved_state', 'authentication', 'executable', 'connection'] as const)
24
- if (text === runnerFailureSummary(code)) return code;
25
- if (/saved Agent SDK state/i.test(text)) return 'saved_state';
26
- if (/unauthorized|authentication|invalid.*key|\b40[13]\b/i.test(text)) return 'authentication';
27
- if (/ENOENT|not found|executable|spawn/i.test(text)) return 'executable';
28
- if (/ECONN|network|fetch failed|timed? ?out/i.test(text)) return 'connection';
29
- return 'session_error';
23
+ function copyEvent<T extends RunnerEvent>(event: T): T {
24
+ return { ...event, ...(event.diagnostic === undefined ? {} : { diagnostic: { ...event.diagnostic } }) };
30
25
  }
31
26
 
32
- export function failureSummary(err: unknown): string { return runnerFailureSummary(sessionFailureCode(err)); }
33
-
34
27
  const PROVIDER_ERRORS: Record<NonNullable<Extract<SDKMessage, { type: 'assistant' }>['error']>, RunnerFailureCode> = {
35
28
  authentication_failed: 'authentication', oauth_org_not_allowed: 'authentication', cloud_credential_error: 'authentication', account_on_hold: 'authentication', verification_required: 'authentication',
36
29
  billing_error: 'billing', rate_limit: 'rate_limit', overloaded: 'provider_unavailable', server_error: 'provider_unavailable',
@@ -94,9 +87,9 @@ export class Activity {
94
87
  const s = this.state;
95
88
  return {
96
89
  ...s, tools: [...s.tools], activeTools: s.activeTools.map((tool) => ({ ...tool })),
97
- tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map((event) => ({ ...event })),
90
+ tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map(copyEvent),
98
91
  ...(s.inputs === undefined ? {} : { inputs: s.inputs.map((input) => ({ ...input })) }),
99
- activeFailure: s.activeFailure ? { ...s.activeFailure } : null, lastFailure: s.lastFailure ? { ...s.lastFailure } : null,
92
+ activeFailure: s.activeFailure ? copyEvent(s.activeFailure) : null, lastFailure: s.lastFailure ? copyEvent(s.lastFailure) : null,
100
93
  };
101
94
  }
102
95
 
@@ -154,13 +147,14 @@ export class Activity {
154
147
  this.update();
155
148
  }
156
149
 
157
- fail(message: string): void {
150
+ fail(err: unknown): void {
158
151
  this.failed = true;
159
- const failure = this.failure('session_failed', sessionFailureCode(message), true);
152
+ const safe = sessionFailure(err);
153
+ const failure = this.failure('session_failed', safe.code, true, null, null, {}, safe.diagnostic);
160
154
  if (failure.code === 'interrupted') this.interrupted = failure;
161
155
  this.state.phase = 'error';
162
156
  this.state.mainPhase = 'error';
163
- log.error({ reason: failure.code }, 'sdk-runner: activity error');
157
+ log.error({ reason: failure.code, diagnostic: failure.diagnostic, summary: this.state.lastError }, 'sdk-runner: activity error');
164
158
  this.flush();
165
159
  }
166
160
 
@@ -335,12 +329,12 @@ export class Activity {
335
329
  this.state.lastError = this.interrupted === null ? null : runnerFailureSummary(this.interrupted.code);
336
330
  }
337
331
 
338
- private failure(kind: RunnerEventKind, code: RunnerFailureCode, active: boolean, tool: string | null = null, taskId: string | null = null, identity: Pick<RunnerEvent, 'id' | 'toolUseId'> = {}): RunnerFailure {
339
- const failure: RunnerFailure = { id: activityName(identity.id) ?? randomUUID(), at: Date.now(), kind, code, tool, taskId, ...(activityName(identity.toolUseId) === null ? {} : { toolUseId: identity.toolUseId }) };
332
+ private failure(kind: RunnerEventKind, code: RunnerFailureCode, active: boolean, tool: string | null = null, taskId: string | null = null, identity: Pick<RunnerEvent, 'id' | 'toolUseId'> = {}, diagnostic?: RunnerDiagnostic): RunnerFailure {
333
+ const failure: RunnerFailure = { id: activityName(identity.id) ?? randomUUID(), at: Date.now(), kind, code, tool, taskId, ...(activityName(identity.toolUseId) === null ? {} : { toolUseId: identity.toolUseId }), ...(diagnostic === undefined ? {} : { diagnostic }) };
340
334
  this.state.lastFailure = failure;
341
335
  if (active) {
342
336
  this.state.activeFailure = failure;
343
- this.state.lastError = runnerFailureSummary(code, tool);
337
+ this.state.lastError = runnerFailureSummary(code, tool, diagnostic);
344
338
  }
345
339
  this.record(failure);
346
340
  return failure;
@@ -4,7 +4,7 @@ import { AutomationStore } from '@metro-labs/core/automation-store';
4
4
  import { errMsg, log } from '@metro-labs/core/log';
5
5
  import { skillSourceRoot } from '@metro-labs/core/skill-source';
6
6
  import { SkillsRefresh } from './skills.js';
7
- import { failureSummary, type Activity } from './activity.js';
7
+ import type { Activity } from './activity.js';
8
8
  import { approvalsThrough } from './approvals.js';
9
9
  import type { RunnerConfig } from './config.js';
10
10
  import { MetroLink } from './link.js';
@@ -79,7 +79,7 @@ export async function startAgent(cfg: RunnerConfig, hooks: AppHooks): Promise<Ru
79
79
  hooks.activity?.start();
80
80
  return await connected(cfg, hooks, release);
81
81
  } catch (err) {
82
- hooks.activity?.fail(failureSummary(err));
82
+ hooks.activity?.fail(err);
83
83
  hooks.activity?.stop();
84
84
  await release().catch(() => { log.warn('sdk-runner: releasing process ownership failed'); });
85
85
  throw err;
@@ -0,0 +1,131 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { runnerDiagnostic, runnerFailureCode, runnerFailureSummary, type RunnerDiagnostic, type RunnerFailureCode } from '@metro-labs/core/runner-activity';
3
+
4
+ const SCAN_MAX = 2_048;
5
+ const ERROR_FAILURES: Record<NonNullable<RunnerDiagnostic['errorCode']>, RunnerFailureCode> = {
6
+ ENOENT: 'executable', EACCES: 'executable', EPERM: 'executable', ENOEXEC: 'executable', ENOMEM: 'executable',
7
+ ERR_MODULE_NOT_FOUND: 'module', MODULE_NOT_FOUND: 'module', ERR_DLOPEN_FAILED: 'module',
8
+ ECONNREFUSED: 'connection', ECONNRESET: 'connection', ENOTFOUND: 'connection', EAI_AGAIN: 'connection', ETIMEDOUT: 'connection',
9
+ CERT_HAS_EXPIRED: 'connection', UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'connection',
10
+ };
11
+ const KNOWN = ['status_unavailable', 'session_ended', 'interrupted', 'saved_state', 'authentication', 'executable', 'connection', 'module', 'configuration', 'process_exit', 'session_error'] as const;
12
+
13
+ export interface SessionFailure {
14
+ code: RunnerFailureCode;
15
+ diagnostic?: RunnerDiagnostic;
16
+ }
17
+
18
+ function normalizeFailure(raw: unknown): SessionFailure {
19
+ if (!isRecord(raw) || !runnerFailureCode(raw.code)) return { code: 'session_error' };
20
+ const diagnostic = runnerDiagnostic(raw.diagnostic);
21
+ return { code: raw.code, ...(diagnostic === undefined ? {} : { diagnostic }) };
22
+ }
23
+
24
+ export class SessionError extends Error {
25
+ readonly failure: SessionFailure;
26
+
27
+ constructor(failure: SessionFailure) {
28
+ const safe = normalizeFailure(failure);
29
+ super(runnerFailureSummary(safe.code, null, safe.diagnostic));
30
+ this.failure = safe;
31
+ }
32
+ }
33
+
34
+ function statusFailure(status: number | undefined): RunnerFailureCode | null {
35
+ if (status === undefined) return null;
36
+ if (status === 401 || status === 403) return 'authentication';
37
+ if (status === 402) return 'billing';
38
+ if (status === 429) return 'rate_limit';
39
+ return status >= 500 ? 'provider_unavailable' : 'invalid_request';
40
+ }
41
+
42
+ const TEXT_FAILURES: [RunnerFailureCode, RegExp][] = [
43
+ ['authentication', /\bunauthorized\b|\bauthentication (?:failed|error)\b|\binvalid(?:[ -]api)?[ -]key\b/i],
44
+ ['module', /cannot find (?:module|package)|error (?:while )?loading shared libraries|module not found/i],
45
+ ['configuration', /METRO_(?:RUNNER_MCP_URL|AGENT_KEY) is not set|invalid (?:configuration|settings)|failed to parse (?:configuration|settings)|unknown (?:option|argument)/i],
46
+ ['executable', /executable|spawn|command not found/i],
47
+ ['connection', /network|fetch failed|timed? ?out/i],
48
+ ];
49
+
50
+ function errorText(value: unknown): string {
51
+ if (typeof value === 'string') return value.slice(0, SCAN_MAX);
52
+ return isRecord(value) && typeof value.message === 'string' ? value.message.slice(0, SCAN_MAX) : '';
53
+ }
54
+
55
+ function evidence(raw: Record<string, unknown>, message: string, source: RunnerDiagnostic['source']): RunnerDiagnostic | undefined {
56
+ const errorCodes = message.match(/\b[A-Z][A-Z_]{2,40}\b/g) ?? [];
57
+ return runnerDiagnostic({
58
+ source, errorCode: raw.code ?? errorCodes.find((code) => Object.hasOwn(ERROR_FAILURES, code)),
59
+ httpStatus: raw.status ?? raw.statusCode ?? Number(/(?:^|\bHTTP(?:\/[12](?:\.\d)?)?\s+|\bstatus(?: code)?[:= ]+)([45]\d\d)\b/i.exec(message)?.[1]),
60
+ exitCode: raw.exitCode ?? Number(/^Claude Code process exited with code (\d{1,3})\b/.exec(message)?.[1]),
61
+ signal: raw.signal ?? /^Claude Code process terminated by signal (SIG[A-Z]{1,8})\b/.exec(message)?.[1],
62
+ });
63
+ }
64
+
65
+ function diagnosticCode(message: string, diagnostic: RunnerDiagnostic | undefined): RunnerFailureCode | null {
66
+ if (/saved Agent SDK state/i.test(message)) return 'saved_state';
67
+ if (diagnostic?.errorCode !== undefined) return ERROR_FAILURES[diagnostic.errorCode];
68
+ return statusFailure(diagnostic?.httpStatus);
69
+ }
70
+
71
+ function processFailure(diagnostic: RunnerDiagnostic | undefined): RunnerFailureCode | null {
72
+ return diagnostic?.exitCode !== undefined || diagnostic?.signal !== undefined ? 'process_exit' : null;
73
+ }
74
+
75
+ function classify(value: unknown, source: RunnerDiagnostic['source']): SessionFailure {
76
+ const message = errorText(value);
77
+ const known = KNOWN.find((code) => message === runnerFailureSummary(code));
78
+ if (known !== undefined) return { code: known };
79
+ const diagnostic = evidence(isRecord(value) ? value : {}, message, source);
80
+ const code = diagnosticCode(message, diagnostic) ?? TEXT_FAILURES.find(([, pattern]) => pattern.test(message))?.[0] ?? processFailure(diagnostic) ?? 'session_error';
81
+ return code === 'session_error' ? { code } : { code, ...(diagnostic === undefined ? {} : { diagnostic }) };
82
+ }
83
+
84
+ export function sessionFailure(err: unknown): SessionFailure {
85
+ try {
86
+ if (err instanceof SessionError) return normalizeFailure(err.failure);
87
+ let current = err;
88
+ let result: SessionFailure = { code: 'session_error' };
89
+ for (let depth = 0; depth < 4; depth++) {
90
+ const next = classify(current, 'exception');
91
+ if (next.code !== 'session_error') result = next;
92
+ if (!isRecord(current) || current.cause === undefined) break;
93
+ current = current.cause;
94
+ }
95
+ return result;
96
+ } catch {
97
+ return { code: 'session_error' };
98
+ }
99
+ }
100
+
101
+ export const failureSummary = (err: unknown): string => {
102
+ const failure = sessionFailure(err);
103
+ return runnerFailureSummary(failure.code, null, failure.diagnostic);
104
+ };
105
+
106
+ export class StartupDiagnostics {
107
+ private tail = '';
108
+ private observed: SessionFailure | null = null;
109
+
110
+ stderr(text: string): void {
111
+ const head = text.slice(0, SCAN_MAX);
112
+ const candidates = [head, (this.tail + head).slice(-SCAN_MAX), text.length > SCAN_MAX ? text.slice(-SCAN_MAX) : ''];
113
+ for (const candidate of candidates) {
114
+ const failure = classify(candidate, 'stderr');
115
+ if (failure.code !== 'session_error' && (failure.code !== 'process_exit' || this.observed === null)) this.observed = failure;
116
+ }
117
+ this.tail = (this.tail + (text.length > SCAN_MAX ? text.slice(-SCAN_MAX) : text)).slice(-SCAN_MAX);
118
+ }
119
+
120
+ failure(err: unknown): SessionFailure {
121
+ const failure = sessionFailure(err);
122
+ const observed = this.observed;
123
+ if (observed === null || !['session_error', 'process_exit'].includes(failure.code)) return failure;
124
+ return { ...observed, diagnostic: runnerDiagnostic({ ...failure.diagnostic, ...observed.diagnostic }) };
125
+ }
126
+
127
+ clear(): void {
128
+ this.tail = '';
129
+ this.observed = null;
130
+ }
131
+ }
@@ -3,6 +3,7 @@ import { log } from '@metro-labs/core/log';
3
3
  import { Activity, failureSummary } from './activity.js';
4
4
  import { startAgent, type RunningAgent } from './app.js';
5
5
  import { runnerConfig } from './config.js';
6
+ import { sessionFailure } from './errors.js';
6
7
 
7
8
  let agent: RunningAgent | null = null;
8
9
  let starting: Promise<RunningAgent> | null = null;
@@ -45,9 +46,10 @@ try {
45
46
  await agent.done;
46
47
  if (!exiting) activity.fail('The Agent SDK session ended unexpectedly.');
47
48
  } catch (err) {
48
- if (!exiting) activity.fail(failureSummary(err));
49
+ if (!exiting) activity.fail(err);
49
50
  }
50
51
  } catch (err) {
51
- log.error({ reason: failureSummary(err) }, 'sdk-runner: the Agent SDK session failed to start');
52
+ const failure = sessionFailure(err);
53
+ log.error({ reason: failure.code, diagnostic: failure.diagnostic, summary: failureSummary(err) }, 'sdk-runner: the Agent SDK session failed to start');
52
54
  }
53
55
  await exit(1);
@@ -18,6 +18,7 @@ import type { SessionStore } from './session-store.js';
18
18
  import { completedCommand, SessionWatch, startedCommand, uuidsOf } from './session-watch.js';
19
19
  import { TaskRecovery } from './task-recovery.js';
20
20
  import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
21
+ import { failureSummary, SessionError, StartupDiagnostics } from './errors.js';
21
22
 
22
23
  const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
23
24
  export const COMPACT_AT = 120_000;
@@ -53,9 +54,6 @@ export function runnerOptions(cfg: RunnerConfig, tools: MetroTools, canUseTool:
53
54
  includePartialMessages: true,
54
55
  extraArgs: { 'system-prompt-snapshot': 'off' },
55
56
  ...(resume === null ? {} : { resume }),
56
- stderr: (text: string) => {
57
- log.debug({ text: text.slice(0, 500) }, 'sdk-runner: claude stderr');
58
- },
59
57
  };
60
58
  }
61
59
 
@@ -98,6 +96,7 @@ export class Runner {
98
96
  readonly automation: Automation | null;
99
97
  private readonly recovery: TaskRecovery;
100
98
  private readonly watch: SessionWatch;
99
+ private readonly diagnostics = new StartupDiagnostics();
101
100
  private readonly tasks = new ActivityTasks(() => undefined);
102
101
  private readonly skillWork = new SkillWork();
103
102
  private compactTimer: ReturnType<typeof setTimeout> | null = null;
@@ -149,7 +148,13 @@ export class Runner {
149
148
  if (this.session !== null) throw new Error('the runner was already started');
150
149
  if (options.resume === undefined) this.watch.restoreContext(0);
151
150
  else this.maybeCompact(true);
152
- this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
151
+ try {
152
+ this.session = (this.parts.open ?? query)({ prompt: this.inbox, options: { ...options, stderr: (text) => { if (this.sessionId === null) this.diagnostics.stderr(text); } } });
153
+ } catch (err) {
154
+ const failure = this.diagnostics.failure(err);
155
+ this.diagnostics.clear();
156
+ throw new SessionError(failure);
157
+ }
153
158
  this.model = options.model ?? null;
154
159
  this.queueModel(this.model);
155
160
  this.parts.skills?.start({
@@ -185,15 +190,22 @@ export class Runner {
185
190
  this.maybeCompact();
186
191
  observe?.(message);
187
192
  }
193
+ } catch (err) {
194
+ throw new SessionError(this.diagnostics.failure(err));
188
195
  } finally {
189
- this.cancelCompact();
190
- this.parts.skills?.close();
191
- this.automation?.close(false);
192
- this.recovery.close(false);
193
- this.ended = true;
196
+ this.finishRun();
194
197
  }
195
198
  }
196
199
 
200
+ private finishRun(): void {
201
+ this.diagnostics.clear();
202
+ this.cancelCompact();
203
+ this.parts.skills?.close();
204
+ this.automation?.close(false);
205
+ this.recovery.close(false);
206
+ this.ended = true;
207
+ }
208
+
197
209
  chat(event: ChannelEvent): Uuid {
198
210
  try {
199
211
  const uuid = this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
@@ -228,8 +240,9 @@ export class Runner {
228
240
  this.switching = this.switching
229
241
  .then(() => this.applyModel(model))
230
242
  .catch((err: unknown) => {
231
- this.parts.activity?.fail('The SDK could not apply the selected model. Check the Model page.');
232
- log.warn({ model, err: errMsg(err) }, 'sdk-runner: the session could not take the model the Model page picked');
243
+ const failure = new SessionError(this.diagnostics.failure(err));
244
+ this.parts.activity?.fail(failure);
245
+ log.warn({ reason: failure.failure.code, diagnostic: failure.failure.diagnostic, summary: failureSummary(failure) }, 'sdk-runner: the session could not take the model the Model page picked');
233
246
  });
234
247
  }
235
248
 
@@ -333,6 +346,7 @@ export class Runner {
333
346
  const id = typeof m.session_id === 'string' ? m.session_id : null;
334
347
  if (id === null || id === this.sessionId) return;
335
348
  this.sessionId = id;
349
+ this.diagnostics.clear();
336
350
  log.info({ session: id, model: m.model, claude: m.claude_code_version }, 'sdk-runner: session up');
337
351
  try {
338
352
  this.parts.store.save(id);