@stage-labs/metro 0.1.0-beta.261 → 0.1.0-beta.263

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.261",
3
+ "version": "0.1.0-beta.263",
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.261",
3
+ "version": "0.1.0-beta.263",
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",
@@ -85,7 +85,51 @@ export interface BufferedEvent {
85
85
  event: MetroEvent;
86
86
  }
87
87
 
88
- const BUS_BUFFER_MAX = 500;
88
+ export const BUS_BUFFER_MAX = 500;
89
+
90
+ export interface RunEventMetadata {
91
+ isPrivate?: boolean;
92
+ senderVerified?: boolean;
93
+ mentionsSelf?: boolean;
94
+ replyToSelf?: boolean;
95
+ emoji?: string;
96
+ removed?: boolean;
97
+ targetId?: string;
98
+ attachmentCount?: number;
99
+ attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
100
+ attachmentStatus?: 'saved' | 'failed';
101
+ attachmentFor?: string;
102
+ }
103
+
104
+ export interface RunEvent {
105
+ id: string;
106
+ seq: number;
107
+ ts: string;
108
+ kind: StructuredEvent['type'];
109
+ direction: 'inbound' | 'outbound' | 'system';
110
+ agentId: string;
111
+ station: string;
112
+ accountId: string | null;
113
+ line: string;
114
+ lineName?: string;
115
+ from?: string;
116
+ fromName?: string;
117
+ fromDisplayName?: string;
118
+ to?: string;
119
+ text?: string;
120
+ truncated: boolean;
121
+ messageId?: string;
122
+ replyTo?: string;
123
+ metadata: RunEventMetadata;
124
+ }
125
+
126
+ export interface RunEventsPage {
127
+ events: RunEvent[];
128
+ cursor: string;
129
+ hasMore: boolean;
130
+ reset: boolean;
131
+ retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
132
+ }
89
133
 
90
134
  const listeners = new Set<BusListener>();
91
135
  const buffer: BufferedEvent[] = [];
@@ -7,6 +7,36 @@ export type RunnerPhase = (typeof PHASES)[number];
7
7
  export type RunnerTaskState = (typeof TASK_STATES)[number];
8
8
  export type RunnerEventKind = (typeof EVENTS)[number];
9
9
 
10
+ const FAILURE = {
11
+ authentication: 'The provider refused authentication. Check the Model page.',
12
+ billing: 'The provider refused billing. Check the Model page.',
13
+ rate_limit: 'The provider rate limit was reached. Retry later or check the Model page.',
14
+ provider_unavailable: 'The provider is unavailable. Retry later or check the Model page.',
15
+ invalid_request: 'The provider rejected the request. Check Conversations and the Model page.',
16
+ output_limit: 'The model reached its output token limit. Check Conversations.',
17
+ provider_error: 'SDK turn failed (provider_error). Check Conversations and the Model page.',
18
+ error_during_execution: 'SDK turn failed (error_during_execution). Check Conversations and the Model page.',
19
+ error_max_turns: 'SDK turn failed (error_max_turns). Check Conversations and the Model page.',
20
+ error_max_budget_usd: 'SDK turn failed (error_max_budget_usd). Check Conversations and the Model page.',
21
+ error_max_structured_output_retries: 'SDK turn failed (error_max_structured_output_retries). Check Conversations and the Model page.',
22
+ read_token_limit: 'Read exceeded its token limit. Read a smaller range with offset and limit, or search the file.',
23
+ tool_error: 'A tool failed. See its result in Conversations.',
24
+ task_error: 'A background task failed. See Conversations and Terminal.',
25
+ compact_error: 'Conversation compaction failed.',
26
+ status_unavailable: 'The SDK status file cannot be written. Check its path and permissions before starting.',
27
+ saved_state: 'The saved Agent SDK state cannot be read. Restore it before starting.',
28
+ executable: 'The SDK executable could not start. Prepare the SDK runtime again.',
29
+ connection: 'The SDK connection failed. Check the server and provider connection.',
30
+ session_ended: 'The Agent SDK session ended unexpectedly.',
31
+ interrupted: 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.',
32
+ session_error: 'The Agent SDK session failed. Check Terminal and the Model page.',
33
+ } as const;
34
+ export type RunnerFailureCode = keyof typeof FAILURE;
35
+
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];
38
+ }
39
+
10
40
  export interface RunnerTask {
11
41
  id: string;
12
42
  kind: string | null;
@@ -30,10 +60,18 @@ export interface RunnerTool {
30
60
  }
31
61
 
32
62
  export interface RunnerEvent {
63
+ id?: string;
33
64
  at: number;
34
65
  kind: RunnerEventKind;
35
66
  tool: string | null;
67
+ toolUseId?: string;
36
68
  taskId: string | null;
69
+ code?: RunnerFailureCode;
70
+ }
71
+
72
+ export interface RunnerFailure extends RunnerEvent {
73
+ id: string;
74
+ code: RunnerFailureCode;
37
75
  }
38
76
 
39
77
  export interface RunnerActivity {
@@ -53,6 +91,8 @@ export interface RunnerActivity {
53
91
  tasks: RunnerTask[];
54
92
  events: RunnerEvent[];
55
93
  lastError: string | null;
94
+ activeFailure?: RunnerFailure | null;
95
+ lastFailure?: RunnerFailure | null;
56
96
  }
57
97
 
58
98
  const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
@@ -76,9 +116,30 @@ function tool(raw: unknown): RunnerTool | null {
76
116
  return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
77
117
  }
78
118
 
119
+ const failureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
120
+ const failureKind = (value: RunnerEventKind): boolean => ['turn_failed', 'tool_failed', 'task_failed', 'compact_failed', 'session_failed'].includes(value);
121
+
79
122
  function event(raw: unknown): RunnerEvent | null {
80
123
  if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
81
- return { at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId) };
124
+ return {
125
+ at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId),
126
+ ...(name(raw.id) === null ? {} : { id: String(raw.id) }),
127
+ ...(name(raw.toolUseId) === null ? {} : { toolUseId: String(raw.toolUseId) }),
128
+ ...(failureKind(raw.kind) && failureCode(raw.code) ? { code: raw.code } : {}),
129
+ };
130
+ }
131
+
132
+ function failure(raw: unknown): RunnerFailure | null {
133
+ const parsed = event(raw);
134
+ return parsed?.id !== undefined && parsed.code !== undefined ? { ...parsed, id: parsed.id, code: parsed.code } : null;
135
+ }
136
+
137
+ function safeError(raw: unknown): string | null {
138
+ if (typeof raw !== 'string' || raw.length === 0) return null;
139
+ const known = Object.values(FAILURE).find((text) => text === raw);
140
+ if (known !== undefined) return known;
141
+ const tool = /^([A-Za-z0-9_.:-]{1,128}) failed\. See its result in Conversations\.$/.exec(raw)?.[1];
142
+ return tool === undefined ? FAILURE.session_error : runnerFailureSummary('tool_error', tool);
82
143
  }
83
144
 
84
145
  function list<T>(raw: unknown, parse: (item: unknown) => T | null, max: number): T[] {
@@ -90,6 +151,15 @@ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
90
151
  return typeof value === 'string' && /^\d{1,32}$/.test(value) ? { procStart: value } : {};
91
152
  }
92
153
 
154
+ function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'lastError' | 'activeFailure' | 'lastFailure'> {
155
+ const activeFailure = failure(raw.activeFailure);
156
+ return {
157
+ lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool) : safeError(raw.lastError),
158
+ ...(Object.hasOwn(raw, 'activeFailure') ? { activeFailure } : {}),
159
+ ...(Object.hasOwn(raw, 'lastFailure') ? { lastFailure: failure(raw.lastFailure) } : {}),
160
+ };
161
+ }
162
+
93
163
  export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
94
164
  if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
95
165
  return {
@@ -98,6 +168,6 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
98
168
  pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
99
169
  tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
100
170
  tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
101
- lastError: typeof raw.lastError === 'string' ? raw.lastError.slice(0, 300) : null,
171
+ ...failureFields(raw),
102
172
  };
103
173
  }
@@ -16,6 +16,8 @@ export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
16
16
  agentNames = names;
17
17
  }
18
18
 
19
+ export const knownAgentIds = (): string[] => Object.keys(agentNames);
20
+
19
21
  export function setAllowlistMap(map: AllowlistMap): void {
20
22
  allowlistMap = map;
21
23
  }
@@ -18,12 +18,21 @@ function sdkSavedSession(home: string): Record<string, unknown> | null {
18
18
  }
19
19
  }
20
20
 
21
+ const inputState = (value: unknown): boolean => value === undefined || value === 'queued' || value === 'started';
22
+
23
+ function sdkInput(value: unknown): boolean {
24
+ if (!isRecord(value)) return false;
25
+ return typeof value.text === 'string' && typeof value.at === 'number' && Number.isFinite(value.at) && (value.uuid === undefined || sessionId(value.uuid) !== null) && inputState(value.state);
26
+ }
27
+
28
+ const sdkInputs = (value: unknown): value is unknown[] | undefined => value === undefined || (Array.isArray(value) && value.every(sdkInput));
29
+
21
30
  export function sdkPending(home: string): number | null {
22
31
  const state = sdkSavedSession(home);
23
32
  if (state === null) return null;
24
33
  if (state.sessionId !== undefined && state.sessionId !== null && sessionId(state.sessionId) === null) return null;
25
- if (state.unanswered === undefined) return 0;
26
- return Array.isArray(state.unanswered) ? state.unanswered.length : null;
34
+ if (!sdkInputs(state.unanswered) || !sdkInputs(state.interrupted)) return null;
35
+ return state.unanswered?.length ?? 0;
27
36
  }
28
37
 
29
38
  function projectFolder(home: string, dir: string): string {
@@ -0,0 +1,139 @@
1
+ import { classifyEvent, userSelf, type BufferedEvent, type MetroEvent, type RunEvent, type RunEventMetadata } from '@metro-labs/core/events';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { Line } from '@metro-labs/core/lines';
4
+ import { STATIONS } from '@metro-labs/core/station-names';
5
+ import { accountFromLine, allowlistForLine, lineReceives, senderPermitted } from '../agents/map.js';
6
+ import { eventInScope } from '../agents/scope.js';
7
+
8
+ const TEXT_MAX = 8_000;
9
+ const FIELD_MAX = 512;
10
+ const LINE_MAX = 2_048;
11
+ const ACCOUNT_STATIONS = new Set<string>(STATIONS);
12
+ const KINDS = new Set(['msg', 'reply', 'react', 'edit', 'delete', 'system']);
13
+
14
+ function safeText(value: unknown, max: number): string | undefined {
15
+ if (typeof value !== 'string') return undefined;
16
+ return value.slice(0, max).replace(/https?:\/\/[^\s<>"']+|\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\/[^\s<>"']*/gi, (url) => /\/(?:attach|api\/uploads|api\/webhooks|api\/threema)\//i.test(url) ? '[private link]' : url).slice(0, max);
17
+ }
18
+
19
+ function validLine(line: string): boolean {
20
+ return typeof line === 'string' && line.length <= LINE_MAX && /^metro:\/\/[a-z][a-z0-9-]*\/[^\s?#]+$/.test(line) && !line.includes('//', 8) && !line.endsWith('/');
21
+ }
22
+
23
+ const isSelf = (line: string): boolean => line === userSelf();
24
+ const isSynthetic = (payload: unknown): boolean => isRecord(payload) && (payload.contentType === 'attachmentSaved' || payload.contentType === 'attachmentFailed' || payload.contentType === 'transcript');
25
+
26
+ function sameAccount(line: string, other: string): boolean {
27
+ if (!validLine(other)) return false;
28
+ const a = accountFromLine(line);
29
+ const b = accountFromLine(other);
30
+ return a !== undefined && b !== undefined && a.station === b.station && a.accountId === b.accountId;
31
+ }
32
+
33
+ function permittedTraffic(event: MetroEvent): boolean {
34
+ if (!isSelf(event.to) && !sameAccount(event.line, event.to)) return false;
35
+ const allowlist = allowlistForLine(event.line);
36
+ if (isSelf(event.from)) return !isSynthetic(event.payload) || senderPermitted(allowlist, '');
37
+ return sameAccount(event.line, event.from) && senderPermitted(allowlist, event.from, event.senderVerified);
38
+ }
39
+
40
+ function visible(event: MetroEvent, allowed: Set<string>): boolean {
41
+ if (!validLine(event.line) || !eventInScope(allowed, event.line) || !lineReceives(event.line)) return false;
42
+ const station = Line.station(event.line);
43
+ if (station !== event.station) return false;
44
+ if (!ACCOUNT_STATIONS.has(station)) return station === 'claude' && event.event?.type === 'system' && event.isPrivate !== true;
45
+ return permittedTraffic(event);
46
+ }
47
+
48
+ function attachmentType(value: unknown): 'image' | 'video' | 'audio' | 'file' {
49
+ if (!isRecord(value)) return 'file';
50
+ const mime = typeof value.mime === 'string' ? value.mime : value.contentType;
51
+ for (const kind of ['image', 'video', 'audio'] as const) {
52
+ if (value.kind === kind || (typeof mime === 'string' && mime.startsWith(`${kind}/`))) return kind;
53
+ }
54
+ return 'file';
55
+ }
56
+
57
+ function attachmentMetadata(payload: unknown): RunEventMetadata {
58
+ if (!isRecord(payload)) return {};
59
+ const saved = payload.contentType === 'attachmentSaved';
60
+ const failed = payload.contentType === 'attachmentFailed';
61
+ if (saved || failed) return {
62
+ attachmentCount: 1,
63
+ attachmentTypes: [attachmentType(payload)],
64
+ attachmentStatus: saved ? 'saved' : 'failed',
65
+ attachmentFor: safeText(payload.attachmentFor, FIELD_MAX),
66
+ };
67
+ if (!Array.isArray(payload.attachments) || payload.attachments.length === 0) return {};
68
+ return {
69
+ attachmentCount: payload.attachments.length,
70
+ attachmentTypes: [...new Set(payload.attachments.slice(0, 100).map(attachmentType))],
71
+ };
72
+ }
73
+
74
+ function reactionMetadata(event: MetroEvent): RunEventMetadata {
75
+ if (event.event?.type !== 'react') return {};
76
+ return {
77
+ emoji: safeText(event.event.emoji, 32),
78
+ ...(isRecord(event.payload) && typeof event.payload.removed === 'boolean' ? { removed: event.payload.removed } : {}),
79
+ };
80
+ }
81
+
82
+ function metadata(event: MetroEvent, system: boolean): RunEventMetadata {
83
+ const attachments = attachmentMetadata(event.payload);
84
+ if (system) return attachments;
85
+ const structured = event.event;
86
+ return {
87
+ ...attachments,
88
+ ...(typeof event.isPrivate === 'boolean' ? { isPrivate: event.isPrivate } : {}),
89
+ ...(typeof event.senderVerified === 'boolean' ? { senderVerified: event.senderVerified } : {}),
90
+ ...(typeof event.mentionsSelf === 'boolean' ? { mentionsSelf: event.mentionsSelf } : {}),
91
+ ...(typeof event.replyToSelf === 'boolean' ? { replyToSelf: event.replyToSelf } : {}),
92
+ ...reactionMetadata(event),
93
+ ...(structured !== undefined && 'targetId' in structured ? { targetId: safeText(structured.targetId, FIELD_MAX) } : {}),
94
+ };
95
+ }
96
+
97
+ function messageFields(event: MetroEvent, system: boolean): Partial<RunEvent> & { truncated: boolean } {
98
+ if (system) return { truncated: false };
99
+ return {
100
+ lineName: safeText(event.lineName, FIELD_MAX),
101
+ from: safeText(event.from, LINE_MAX),
102
+ fromName: safeText(event.fromName, FIELD_MAX),
103
+ fromDisplayName: safeText(event.fromDisplayName, FIELD_MAX),
104
+ to: safeText(event.to, LINE_MAX),
105
+ text: safeText(event.text, TEXT_MAX),
106
+ truncated: typeof event.text === 'string' && event.text.length > TEXT_MAX,
107
+ };
108
+ }
109
+
110
+ function validIdentity(event: MetroEvent): boolean {
111
+ return typeof event.id === 'string' && event.id.length <= FIELD_MAX
112
+ && typeof event.ts === 'string' && event.ts.length <= 64 && Number.isFinite(Date.parse(event.ts));
113
+ }
114
+
115
+ const replyId = (event: MetroEvent): string | undefined => event.replyTo ?? (event.event?.type === 'reply' ? event.event.replyTo : undefined);
116
+ const accountId = (event: MetroEvent): string | null => ACCOUNT_STATIONS.has(event.station) ? accountFromLine(event.line)?.accountId ?? null : null;
117
+
118
+ export function runEvent(entry: BufferedEvent, agentId: string): RunEvent | null {
119
+ const { event, busSeq } = entry;
120
+ if (!visible(event, new Set([agentId])) || !validIdentity(event)) return null;
121
+ const structured = event.event ?? classifyEvent(event);
122
+ if (!KINDS.has(structured.type)) return null;
123
+ const system = structured.type === 'system' || isSynthetic(event.payload);
124
+ return {
125
+ ...messageFields(event, system),
126
+ id: safeText(event.id, FIELD_MAX) ?? '',
127
+ seq: busSeq,
128
+ ts: new Date(event.ts).toISOString(),
129
+ kind: system ? 'system' : structured.type,
130
+ direction: system ? 'system' : isSelf(event.from) ? 'outbound' : 'inbound',
131
+ agentId,
132
+ station: event.station,
133
+ accountId: accountId(event),
134
+ line: event.line,
135
+ messageId: safeText(event.messageId, FIELD_MAX),
136
+ replyTo: safeText(replyId(event), FIELD_MAX),
137
+ metadata: metadata(event, system),
138
+ };
139
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { bufferedSince, BUS_BUFFER_MAX, currentBusSeq, type BufferedEvent, type RunEvent, type RunEventsPage } from '@metro-labs/core/events';
4
+ import { ID_RE } from '@metro-labs/core/ids';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { sessionRoute } from '@metro-labs/http/api-http';
7
+ import { accountEnabled, allowlistForAccount, knownAccounts, knownAgentIds } from '../agents/map.js';
8
+ import { runEvent } from './run-event.js';
9
+
10
+ const EPOCH = randomUUID();
11
+ const DEFAULT_LIMIT = 100;
12
+ const PAGE_BYTES_MAX = 1_000_000;
13
+ const CURSOR_RE = /^([a-f0-9-]{36}):([a-f0-9]{64}):(0|[1-9]\d{0,15})$/;
14
+
15
+ interface Cursor { epoch: string; policy: string; seq: number }
16
+ interface Query { agentId: string; limit: number; cursor: Cursor | null }
17
+
18
+ function cursorOf(raw: string | null): Cursor | null {
19
+ if (raw === null) return null;
20
+ const match = CURSOR_RE.exec(raw);
21
+ if (!match || !Number.isSafeInteger(Number(match[3]))) throw new ApiError('invalid run events cursor', 400);
22
+ return { epoch: match[1] ?? '', policy: match[2] ?? '', seq: Number(match[3]) };
23
+ }
24
+
25
+ function limitOf(raw: string | null): number {
26
+ if (raw === null) return DEFAULT_LIMIT;
27
+ const limit = Number(raw);
28
+ if (!/^[1-9]\d{0,2}$/.test(raw) || limit > BUS_BUFFER_MAX) throw new ApiError('limit must be between 1 and 500', 400);
29
+ return limit;
30
+ }
31
+
32
+ function queryOf(url: string): Query {
33
+ const params = new URL(url, 'http://localhost').searchParams;
34
+ for (const key of params.keys()) {
35
+ if (!['agent', 'limit', 'cursor'].includes(key) || params.getAll(key).length !== 1) throw new ApiError('invalid run events query', 400);
36
+ }
37
+ const agentId = params.get('agent') ?? '';
38
+ if (!ID_RE.test(agentId)) throw new ApiError('agent must be an agent id', 400);
39
+ return { agentId, limit: limitOf(params.get('limit')), cursor: cursorOf(params.get('cursor')) };
40
+ }
41
+
42
+ function policyOf(agentId: string, owner: string): string {
43
+ const accounts = knownAccounts().filter((account) => account.agentId === agentId).map(({ station, id }) => [
44
+ station, id, accountEnabled(station, id), allowlistForAccount(station, id) ?? [],
45
+ ]);
46
+ return createHash('sha256').update(JSON.stringify([owner, agentId, accounts])).digest('hex');
47
+ }
48
+
49
+ function bounded(events: RunEvent[], limit: number): RunEvent[] {
50
+ const rows: RunEvent[] = [];
51
+ let bytes = 0;
52
+ for (const event of events.slice(0, limit)) {
53
+ bytes += Buffer.byteLength(JSON.stringify(event)) + 1;
54
+ if (bytes > PAGE_BYTES_MAX) break;
55
+ rows.push(event);
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ function resetNeeded(cursor: Cursor | null, policy: string, oldest: number, newest: number): boolean {
61
+ return cursor === null || cursor.epoch !== EPOCH || cursor.policy !== policy || cursor.seq < oldest - 1 || cursor.seq > newest;
62
+ }
63
+
64
+ function page(query: Query, owner: string): RunEventsPage {
65
+ const { agentId, limit, cursor } = query;
66
+ if (!knownAgentIds().includes(agentId)) throw new ApiError('no such agent', 404);
67
+ const policy = policyOf(agentId, owner);
68
+ const buffered = bufferedSince(0);
69
+ const newest = currentBusSeq();
70
+ const oldest = buffered[0]?.busSeq ?? newest + 1;
71
+ const reset = resetNeeded(cursor, policy, oldest, newest);
72
+ const visible = buffered.filter((entry) => reset || entry.busSeq > (cursor?.seq ?? newest)).flatMap((entry) => {
73
+ const event = runEvent(entry, agentId);
74
+ return event === null ? [] : [event];
75
+ });
76
+ const pending = reset ? visible.slice(-limit) : visible;
77
+ const events = bounded(pending, limit);
78
+ const hasMore = pending.length > events.length;
79
+ return {
80
+ events, cursor: `${EPOCH}:${policy}:${pageSeq(events, hasMore, newest)}`, hasMore, reset,
81
+ retention: retention(buffered, agentId),
82
+ };
83
+ }
84
+
85
+ const pageSeq = (events: RunEvent[], hasMore: boolean, newest: number): number => hasMore ? events.at(-1)?.seq ?? newest : newest;
86
+ function retention(buffered: BufferedEvent[], agentId: string): RunEventsPage['retention'] {
87
+ let oldestAt: string | null = null;
88
+ for (const entry of buffered) {
89
+ const event = runEvent(entry, agentId);
90
+ if (event === null) continue;
91
+ oldestAt = event.ts;
92
+ break;
93
+ }
94
+ return { capacity: BUS_BUFFER_MAX, oldestAt, oldestSeq: buffered[0]?.busSeq ?? null };
95
+ }
96
+
97
+ export function handleRunEventsRequest(req: IncomingMessage, res: ServerResponse): boolean {
98
+ return sessionRoute(req, res, {
99
+ methods: { '/api/run/events': ['GET'] }, admin: false, label: 'run-events',
100
+ }, (session) => Promise.resolve(page(queryOf(req.url ?? ''), session.subject)));
101
+ }
@@ -22,6 +22,7 @@ import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
22
22
  import { handleFilesRequest } from '../agent-user/files-api.js';
23
23
  import { handleVaultRequest } from '../vault/api.js';
24
24
  import { handleVoiceRequest } from '../voice/api.js';
25
+ import { handleRunEventsRequest } from '../monitor/run-events.js';
25
26
 
26
27
  export interface SessionApis {
27
28
  agentApi?: AgentApiDeps;
@@ -50,6 +51,7 @@ export function handleSessionApis(
50
51
  ): boolean {
51
52
  const routes: (() => boolean)[] = [
52
53
  () => handleSessionApiRequest(req, res),
54
+ () => handleRunEventsRequest(req, res),
53
55
  () => handleApprovalsRequest(req, res),
54
56
  ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
55
57
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.261"
2
+ "version": "0.1.0-beta.263"
3
3
  }
@@ -85,7 +85,51 @@ export interface BufferedEvent {
85
85
  event: MetroEvent;
86
86
  }
87
87
 
88
- const BUS_BUFFER_MAX = 500;
88
+ export const BUS_BUFFER_MAX = 500;
89
+
90
+ export interface RunEventMetadata {
91
+ isPrivate?: boolean;
92
+ senderVerified?: boolean;
93
+ mentionsSelf?: boolean;
94
+ replyToSelf?: boolean;
95
+ emoji?: string;
96
+ removed?: boolean;
97
+ targetId?: string;
98
+ attachmentCount?: number;
99
+ attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
100
+ attachmentStatus?: 'saved' | 'failed';
101
+ attachmentFor?: string;
102
+ }
103
+
104
+ export interface RunEvent {
105
+ id: string;
106
+ seq: number;
107
+ ts: string;
108
+ kind: StructuredEvent['type'];
109
+ direction: 'inbound' | 'outbound' | 'system';
110
+ agentId: string;
111
+ station: string;
112
+ accountId: string | null;
113
+ line: string;
114
+ lineName?: string;
115
+ from?: string;
116
+ fromName?: string;
117
+ fromDisplayName?: string;
118
+ to?: string;
119
+ text?: string;
120
+ truncated: boolean;
121
+ messageId?: string;
122
+ replyTo?: string;
123
+ metadata: RunEventMetadata;
124
+ }
125
+
126
+ export interface RunEventsPage {
127
+ events: RunEvent[];
128
+ cursor: string;
129
+ hasMore: boolean;
130
+ reset: boolean;
131
+ retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
132
+ }
89
133
 
90
134
  const listeners = new Set<BusListener>();
91
135
  const buffer: BufferedEvent[] = [];
@@ -7,6 +7,36 @@ export type RunnerPhase = (typeof PHASES)[number];
7
7
  export type RunnerTaskState = (typeof TASK_STATES)[number];
8
8
  export type RunnerEventKind = (typeof EVENTS)[number];
9
9
 
10
+ const FAILURE = {
11
+ authentication: 'The provider refused authentication. Check the Model page.',
12
+ billing: 'The provider refused billing. Check the Model page.',
13
+ rate_limit: 'The provider rate limit was reached. Retry later or check the Model page.',
14
+ provider_unavailable: 'The provider is unavailable. Retry later or check the Model page.',
15
+ invalid_request: 'The provider rejected the request. Check Conversations and the Model page.',
16
+ output_limit: 'The model reached its output token limit. Check Conversations.',
17
+ provider_error: 'SDK turn failed (provider_error). Check Conversations and the Model page.',
18
+ error_during_execution: 'SDK turn failed (error_during_execution). Check Conversations and the Model page.',
19
+ error_max_turns: 'SDK turn failed (error_max_turns). Check Conversations and the Model page.',
20
+ error_max_budget_usd: 'SDK turn failed (error_max_budget_usd). Check Conversations and the Model page.',
21
+ error_max_structured_output_retries: 'SDK turn failed (error_max_structured_output_retries). Check Conversations and the Model page.',
22
+ read_token_limit: 'Read exceeded its token limit. Read a smaller range with offset and limit, or search the file.',
23
+ tool_error: 'A tool failed. See its result in Conversations.',
24
+ task_error: 'A background task failed. See Conversations and Terminal.',
25
+ compact_error: 'Conversation compaction failed.',
26
+ status_unavailable: 'The SDK status file cannot be written. Check its path and permissions before starting.',
27
+ saved_state: 'The saved Agent SDK state cannot be read. Restore it before starting.',
28
+ executable: 'The SDK executable could not start. Prepare the SDK runtime again.',
29
+ connection: 'The SDK connection failed. Check the server and provider connection.',
30
+ session_ended: 'The Agent SDK session ended unexpectedly.',
31
+ interrupted: 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.',
32
+ session_error: 'The Agent SDK session failed. Check Terminal and the Model page.',
33
+ } as const;
34
+ export type RunnerFailureCode = keyof typeof FAILURE;
35
+
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];
38
+ }
39
+
10
40
  export interface RunnerTask {
11
41
  id: string;
12
42
  kind: string | null;
@@ -30,10 +60,18 @@ export interface RunnerTool {
30
60
  }
31
61
 
32
62
  export interface RunnerEvent {
63
+ id?: string;
33
64
  at: number;
34
65
  kind: RunnerEventKind;
35
66
  tool: string | null;
67
+ toolUseId?: string;
36
68
  taskId: string | null;
69
+ code?: RunnerFailureCode;
70
+ }
71
+
72
+ export interface RunnerFailure extends RunnerEvent {
73
+ id: string;
74
+ code: RunnerFailureCode;
37
75
  }
38
76
 
39
77
  export interface RunnerActivity {
@@ -53,6 +91,8 @@ export interface RunnerActivity {
53
91
  tasks: RunnerTask[];
54
92
  events: RunnerEvent[];
55
93
  lastError: string | null;
94
+ activeFailure?: RunnerFailure | null;
95
+ lastFailure?: RunnerFailure | null;
56
96
  }
57
97
 
58
98
  const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
@@ -76,9 +116,30 @@ function tool(raw: unknown): RunnerTool | null {
76
116
  return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
77
117
  }
78
118
 
119
+ const failureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
120
+ const failureKind = (value: RunnerEventKind): boolean => ['turn_failed', 'tool_failed', 'task_failed', 'compact_failed', 'session_failed'].includes(value);
121
+
79
122
  function event(raw: unknown): RunnerEvent | null {
80
123
  if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
81
- return { at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId) };
124
+ return {
125
+ at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId),
126
+ ...(name(raw.id) === null ? {} : { id: String(raw.id) }),
127
+ ...(name(raw.toolUseId) === null ? {} : { toolUseId: String(raw.toolUseId) }),
128
+ ...(failureKind(raw.kind) && failureCode(raw.code) ? { code: raw.code } : {}),
129
+ };
130
+ }
131
+
132
+ function failure(raw: unknown): RunnerFailure | null {
133
+ const parsed = event(raw);
134
+ return parsed?.id !== undefined && parsed.code !== undefined ? { ...parsed, id: parsed.id, code: parsed.code } : null;
135
+ }
136
+
137
+ function safeError(raw: unknown): string | null {
138
+ if (typeof raw !== 'string' || raw.length === 0) return null;
139
+ const known = Object.values(FAILURE).find((text) => text === raw);
140
+ if (known !== undefined) return known;
141
+ const tool = /^([A-Za-z0-9_.:-]{1,128}) failed\. See its result in Conversations\.$/.exec(raw)?.[1];
142
+ return tool === undefined ? FAILURE.session_error : runnerFailureSummary('tool_error', tool);
82
143
  }
83
144
 
84
145
  function list<T>(raw: unknown, parse: (item: unknown) => T | null, max: number): T[] {
@@ -90,6 +151,15 @@ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
90
151
  return typeof value === 'string' && /^\d{1,32}$/.test(value) ? { procStart: value } : {};
91
152
  }
92
153
 
154
+ function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'lastError' | 'activeFailure' | 'lastFailure'> {
155
+ const activeFailure = failure(raw.activeFailure);
156
+ return {
157
+ lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool) : safeError(raw.lastError),
158
+ ...(Object.hasOwn(raw, 'activeFailure') ? { activeFailure } : {}),
159
+ ...(Object.hasOwn(raw, 'lastFailure') ? { lastFailure: failure(raw.lastFailure) } : {}),
160
+ };
161
+ }
162
+
93
163
  export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
94
164
  if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
95
165
  return {
@@ -98,6 +168,6 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
98
168
  pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
99
169
  tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
100
170
  tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
101
- lastError: typeof raw.lastError === 'string' ? raw.lastError.slice(0, 300) : null,
171
+ ...failureFields(raw),
102
172
  };
103
173
  }