@stage-labs/metro 0.1.0-beta.260 → 0.1.0-beta.262
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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +72 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +11 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +7 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +192 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +64 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +72 -2
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +81 -25
- package/runtime/sdk-runner/src/app.ts +4 -4
- package/runtime/sdk-runner/src/inbox.ts +32 -7
- package/runtime/sdk-runner/src/link.ts +1 -0
- package/runtime/sdk-runner/src/main.ts +1 -1
- package/runtime/sdk-runner/src/recovery.ts +46 -0
- package/runtime/sdk-runner/src/runner.ts +54 -20
- package/runtime/sdk-runner/src/session-store.ts +39 -9
- package/runtime/sdk-runner/src/session-watch.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.262",
|
|
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",
|
|
@@ -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 {
|
|
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
|
-
|
|
171
|
+
...failureFields(raw),
|
|
102
172
|
};
|
|
103
173
|
}
|
|
@@ -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
|
|
26
|
-
return
|
|
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 {
|
|
@@ -25,6 +25,7 @@ import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, typ
|
|
|
25
25
|
import { dropUnusedClaudeLogins } from './claude-logins.js';
|
|
26
26
|
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
27
27
|
import { refreshUsage } from './usage-refresh.js';
|
|
28
|
+
import { handleOpenRouterSignIn } from './openrouter-signin.js';
|
|
28
29
|
|
|
29
30
|
const PATH = '/api/model';
|
|
30
31
|
const CONNECTIONS = '/api/model/connections';
|
|
@@ -230,11 +231,6 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
230
231
|
res.writeHead(204, cors(req)).end();
|
|
231
232
|
return true;
|
|
232
233
|
}
|
|
233
|
-
const route = routeFor(path, req.method);
|
|
234
|
-
if (typeof route === 'number') {
|
|
235
|
-
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
234
|
const store: Store = {
|
|
239
235
|
read: deps.read ?? readModelConfig,
|
|
240
236
|
write: (cfg) => {
|
|
@@ -242,6 +238,12 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
242
238
|
(deps.write ?? writeModelConfig)(cfg);
|
|
243
239
|
},
|
|
244
240
|
};
|
|
241
|
+
if (handleOpenRouterSignIn(req, res, deps, store)) return true;
|
|
242
|
+
const route = routeFor(path, req.method);
|
|
243
|
+
if (typeof route === 'number') {
|
|
244
|
+
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
245
247
|
apiSession(req)
|
|
246
248
|
.then(async (session) => {
|
|
247
249
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
@@ -21,6 +21,9 @@ export interface ModelApiDeps {
|
|
|
21
21
|
geminiUserBase?: string;
|
|
22
22
|
geminiBase?: string;
|
|
23
23
|
openrouterBase?: string;
|
|
24
|
+
owner?: () => string | null;
|
|
25
|
+
openrouterPublicBase?: () => string | null;
|
|
26
|
+
openrouterNow?: () => number;
|
|
24
27
|
anthropicBase?: string;
|
|
25
28
|
bedrockControlBase?: string;
|
|
26
29
|
setup?: SetupDeps;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { pkcePair, newState } from '@metro-labs/core/stations/oauth';
|
|
3
|
+
import { ticketStore } from '@metro-labs/core/tickets';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
|
+
import { addConnection, connectionOf, updateConnection } from './model-config.js';
|
|
8
|
+
import { asApiError, type Store } from './model-store.js';
|
|
9
|
+
import { forgetReported } from './usage.js';
|
|
10
|
+
|
|
11
|
+
export const OPENROUTER_CALLBACK = '/api/model/openrouter/callback';
|
|
12
|
+
const TTL = 10 * 60_000;
|
|
13
|
+
const MAX_ATTEMPTS = 100;
|
|
14
|
+
const STALE = 'This OpenRouter sign-in expired or was cancelled. Start again.';
|
|
15
|
+
const FAILED = 'OpenRouter sign-in did not finish. Try again or use an API key. Manage any unused key in OpenRouter.';
|
|
16
|
+
|
|
17
|
+
type Result = { status: 'pending' } | { status: 'done'; connection: string } | { status: 'failed'; error: string };
|
|
18
|
+
|
|
19
|
+
interface Attempt {
|
|
20
|
+
owner: string;
|
|
21
|
+
connection: string;
|
|
22
|
+
keyHash: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
state: string;
|
|
25
|
+
verifier: string;
|
|
26
|
+
result: Result;
|
|
27
|
+
abort: AbortController;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LoginDeps {
|
|
31
|
+
store: Store;
|
|
32
|
+
owner: () => string | null;
|
|
33
|
+
publicBase: () => string | null;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
39
|
+
|
|
40
|
+
function callbackUrl(base: string | null): string {
|
|
41
|
+
let url: URL;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(base ?? '');
|
|
44
|
+
} catch {
|
|
45
|
+
throw new ApiError('This box needs a public HTTPS address for OpenRouter sign-in. Use an API key instead.', 409);
|
|
46
|
+
}
|
|
47
|
+
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '')
|
|
48
|
+
throw new ApiError('This box needs a public HTTPS origin for OpenRouter sign-in. Use an API key instead.', 409);
|
|
49
|
+
return `${url.origin}${OPENROUTER_CALLBACK}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function exchange(code: string, verifier: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<string> {
|
|
53
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/auth/keys', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
56
|
+
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: 'S256' }),
|
|
57
|
+
redirect: 'manual',
|
|
58
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
await res.body?.cancel();
|
|
62
|
+
throw new ApiError(FAILED, 502);
|
|
63
|
+
}
|
|
64
|
+
const body: unknown = await res.json();
|
|
65
|
+
if (!isRecord(body) || typeof body.key !== 'string' || body.key.trim() === '' || body.key.length > 512)
|
|
66
|
+
throw new ApiError(FAILED, 502);
|
|
67
|
+
return body.key.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function authorizationCode(params: URLSearchParams): string {
|
|
71
|
+
const code = params.get('code') ?? '';
|
|
72
|
+
if (params.has('error') || code === '' || code.length > 4096 || params.getAll('code').length !== 1 || params.getAll('state').length !== 1)
|
|
73
|
+
throw new ApiError('OpenRouter did not approve this sign-in. Try again or use an API key.', 400);
|
|
74
|
+
return code;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class OpenRouterLogins {
|
|
78
|
+
private readonly states = ticketStore<Attempt>(TTL, MAX_ATTEMPTS);
|
|
79
|
+
private readonly attempts = new Map<string, Attempt>();
|
|
80
|
+
|
|
81
|
+
constructor(private readonly deps: LoginDeps) {}
|
|
82
|
+
|
|
83
|
+
private now(): number {
|
|
84
|
+
return (this.deps.now ?? Date.now)();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private discard(attempt: Attempt): void {
|
|
88
|
+
this.states.take(attempt.state, this.now());
|
|
89
|
+
attempt.verifier = '';
|
|
90
|
+
attempt.abort.abort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private prune(): void {
|
|
94
|
+
for (const [id, attempt] of this.attempts) {
|
|
95
|
+
if (attempt.expiresAt > this.now()) continue;
|
|
96
|
+
this.discard(attempt);
|
|
97
|
+
this.attempts.delete(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private checkOwner(owner: string): void {
|
|
102
|
+
if (this.deps.owner() !== owner) throw new ApiError('This sign-in belongs to another organization.', 403);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private target(connection: string): string {
|
|
106
|
+
const cfg = this.deps.store.read();
|
|
107
|
+
if (connection === '') {
|
|
108
|
+
try {
|
|
109
|
+
addConnection(cfg, { provider: 'openrouter' });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
asApiError(err);
|
|
112
|
+
}
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
const found = connectionOf(cfg, connection);
|
|
116
|
+
if (found?.provider !== 'openrouter') throw new ApiError('This OpenRouter connection no longer exists.', 404);
|
|
117
|
+
return hash(found.apiKey);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
begin(owner: string, connection: string): { id: string; url: string; expiresAt: number } {
|
|
121
|
+
this.checkOwner(owner);
|
|
122
|
+
const callback = callbackUrl(this.deps.publicBase());
|
|
123
|
+
const keyHash = this.target(connection);
|
|
124
|
+
this.prune();
|
|
125
|
+
if (this.attempts.size >= MAX_ATTEMPTS) throw new ApiError('Too many OpenRouter sign-ins. Try again later.', 429);
|
|
126
|
+
const { verifier, challenge } = pkcePair();
|
|
127
|
+
const attempt: Attempt = { owner, connection, keyHash, verifier, state: '', expiresAt: this.now() + TTL, result: { status: 'pending' }, abort: new AbortController() };
|
|
128
|
+
attempt.state = this.states.mint(attempt, this.now()).ticket;
|
|
129
|
+
const id = newState();
|
|
130
|
+
this.attempts.set(id, attempt);
|
|
131
|
+
const url = new URL('https://openrouter.ai/auth');
|
|
132
|
+
url.search = new URLSearchParams({ callback_url: callback, code_challenge: challenge, code_challenge_method: 'S256', state: attempt.state, key_label: 'Metro' }).toString();
|
|
133
|
+
return { id, url: url.href, expiresAt: attempt.expiresAt };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private find(owner: string, id: string): Attempt {
|
|
137
|
+
this.checkOwner(owner);
|
|
138
|
+
this.prune();
|
|
139
|
+
const attempt = this.attempts.get(id);
|
|
140
|
+
if (attempt?.owner !== owner) throw new ApiError(STALE, 410);
|
|
141
|
+
return attempt;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
status(owner: string, id: string): Result {
|
|
145
|
+
return this.find(owner, id).result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
cancel(owner: string, id: string): Result {
|
|
149
|
+
const attempt = this.find(owner, id);
|
|
150
|
+
if (attempt.result.status === 'pending') {
|
|
151
|
+
attempt.result = { status: 'failed', error: 'OpenRouter sign-in cancelled.' };
|
|
152
|
+
this.discard(attempt);
|
|
153
|
+
}
|
|
154
|
+
return attempt.result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private checkPending(attempt: Attempt): void {
|
|
158
|
+
if (attempt.expiresAt <= this.now() || attempt.result.status !== 'pending') throw new ApiError(STALE, 410);
|
|
159
|
+
this.checkOwner(attempt.owner);
|
|
160
|
+
if (this.target(attempt.connection) !== attempt.keyHash)
|
|
161
|
+
throw new ApiError('This connection changed during sign-in. Start again.', 409);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private save(attempt: Attempt, key: string): void {
|
|
165
|
+
this.checkPending(attempt);
|
|
166
|
+
const current = this.deps.store.read();
|
|
167
|
+
const next = attempt.connection === '' ? addConnection(current, { provider: 'openrouter', apiKey: key }) : updateConnection(current, attempt.connection, { apiKey: key });
|
|
168
|
+
const id = attempt.connection === '' ? (next.connections.at(-1)?.id ?? '') : attempt.connection;
|
|
169
|
+
this.deps.store.write(next);
|
|
170
|
+
forgetReported(id);
|
|
171
|
+
attempt.result = { status: 'done', connection: id };
|
|
172
|
+
log.info({ connection: id }, 'model-api: OpenRouter connected');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async complete(params: URLSearchParams): Promise<void> {
|
|
176
|
+
const state = params.get('state') ?? '';
|
|
177
|
+
const attempt = this.states.take(state, this.now());
|
|
178
|
+
if (attempt === undefined) throw new ApiError(STALE, 410);
|
|
179
|
+
const verifier = attempt.verifier;
|
|
180
|
+
attempt.verifier = '';
|
|
181
|
+
try {
|
|
182
|
+
this.checkPending(attempt);
|
|
183
|
+
const key = await exchange(authorizationCode(params), verifier, attempt.abort.signal, this.deps.fetchImpl ?? fetch);
|
|
184
|
+
this.save(attempt, key);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (attempt.result.status === 'pending') attempt.result = { status: 'failed', error: err instanceof ApiError ? err.message : FAILED };
|
|
187
|
+
throw new ApiError(attempt.result.status === 'failed' ? attempt.result.error : FAILED, 400);
|
|
188
|
+
} finally {
|
|
189
|
+
this.discard(attempt);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { sendJson, sessionRoute } from '@metro-labs/http/api-http';
|
|
3
|
+
import { log } from '@metro-labs/core/log';
|
|
4
|
+
import { publicBaseUrl } from '../files/attach-serve.js';
|
|
5
|
+
import { askedConnection, type ModelApiDeps, type Store } from './model-store.js';
|
|
6
|
+
import { OPENROUTER_CALLBACK, OpenRouterLogins } from './openrouter-login.js';
|
|
7
|
+
|
|
8
|
+
const LOGIN = '/api/model/openrouter/login';
|
|
9
|
+
const ID_RE = /^[A-Za-z0-9_-]{32}$/;
|
|
10
|
+
const logins = new WeakMap<ModelApiDeps, OpenRouterLogins>();
|
|
11
|
+
const CALLBACK_HEADERS = {
|
|
12
|
+
'cache-control': 'no-store',
|
|
13
|
+
'referrer-policy': 'no-referrer',
|
|
14
|
+
'content-security-policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
15
|
+
'x-content-type-options': 'nosniff',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function loginFor(deps: ModelApiDeps, store: Store): OpenRouterLogins {
|
|
19
|
+
let login = logins.get(deps);
|
|
20
|
+
if (login === undefined) {
|
|
21
|
+
login = new OpenRouterLogins({
|
|
22
|
+
store,
|
|
23
|
+
owner: () => deps.owner?.() ?? null,
|
|
24
|
+
publicBase: deps.openrouterPublicBase ?? publicBaseUrl,
|
|
25
|
+
fetchImpl: deps.fetchImpl,
|
|
26
|
+
now: deps.openrouterNow,
|
|
27
|
+
});
|
|
28
|
+
logins.set(deps, login);
|
|
29
|
+
}
|
|
30
|
+
return login;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function callback(req: IncomingMessage, res: ServerResponse, login: OpenRouterLogins): void {
|
|
34
|
+
if (req.method !== 'GET') {
|
|
35
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const url = new URL(req.url ?? '', 'https://metro');
|
|
39
|
+
if (url.search === '') {
|
|
40
|
+
res.writeHead(200, { ...CALLBACK_HEADERS, 'content-type': 'text/html; charset=utf-8' });
|
|
41
|
+
res.end('<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenRouter sign-in</title><h1>Return to Metro</h1><p>Check your connection in Metro. You can close this tab.</p><p>If you declined access, cancel the sign-in in Metro or use an API key.</p></html>');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const finish = (): void => { res.writeHead(303, { ...CALLBACK_HEADERS, location: OPENROUTER_CALLBACK }).end(); };
|
|
45
|
+
login.complete(url.searchParams).then(finish, finish).catch(() => {
|
|
46
|
+
log.warn('openrouter-signin: callback response closed');
|
|
47
|
+
if (!res.writableEnded) res.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function handleOpenRouterSignIn(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps, store: Store): boolean {
|
|
52
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
53
|
+
if (path === OPENROUTER_CALLBACK) {
|
|
54
|
+
callback(req, res, loginFor(deps, store));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
const id = path.startsWith(`${LOGIN}/`) ? path.slice(LOGIN.length + 1) : '';
|
|
58
|
+
if (path !== LOGIN && !ID_RE.test(id)) return false;
|
|
59
|
+
return sessionRoute(req, res, { methods: { [path]: path === LOGIN ? ['POST'] : ['GET', 'DELETE'] }, admin: false, label: 'openrouter-signin' }, (session) => {
|
|
60
|
+
const login = loginFor(deps, store);
|
|
61
|
+
if (path === LOGIN) return Promise.resolve(login.begin(session.subject, askedConnection(req)));
|
|
62
|
+
return Promise.resolve(req.method === 'DELETE' ? login.cancel(session.subject, id) : login.status(session.subject, id));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -202,7 +202,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
202
202
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
203
203
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
204
204
|
machineApi: {},
|
|
205
|
-
modelApi: { switchModel: deps.switchModel },
|
|
205
|
+
modelApi: { switchModel: deps.switchModel, owner: localOwner },
|
|
206
206
|
gateway: { config: readModelConfig },
|
|
207
207
|
terminalApi: {},
|
|
208
208
|
mode: localModeInfo,
|
package/runtime/runtime.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
171
|
+
...failureFields(raw),
|
|
102
172
|
};
|
|
103
173
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
3
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
5
|
import { log } from '@metro-labs/core/log';
|
|
5
|
-
import type
|
|
6
|
+
import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
|
|
6
7
|
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
7
8
|
import { ActivityTasks, activityName } from './activity-tasks.js';
|
|
8
9
|
import type { ApprovalAsk } from './approvals.js';
|
|
@@ -16,14 +17,34 @@ type System = Extract<SDKMessage, { type: 'system' }>;
|
|
|
16
17
|
interface TrackedTool extends RunnerTool { parent: string | null }
|
|
17
18
|
interface Approval { tool: string | null; taskId: string | null; worker: boolean }
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
function sessionFailureCode(err: unknown): RunnerFailureCode {
|
|
20
21
|
const text = err instanceof Error ? err.message : String(err);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (/
|
|
24
|
-
if (/
|
|
25
|
-
if (/
|
|
26
|
-
|
|
22
|
+
for (const code of ['status_unavailable', 'session_ended', 'interrupted', 'saved_state', 'authentication', 'executable', 'connection'] as const)
|
|
23
|
+
if (text === runnerFailureSummary(code)) return code;
|
|
24
|
+
if (/saved Agent SDK state/i.test(text)) return 'saved_state';
|
|
25
|
+
if (/unauthorized|authentication|invalid.*key|\b40[13]\b/i.test(text)) return 'authentication';
|
|
26
|
+
if (/ENOENT|not found|executable|spawn/i.test(text)) return 'executable';
|
|
27
|
+
if (/ECONN|network|fetch failed|timed? ?out/i.test(text)) return 'connection';
|
|
28
|
+
return 'session_error';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function failureSummary(err: unknown): string { return runnerFailureSummary(sessionFailureCode(err)); }
|
|
32
|
+
|
|
33
|
+
const PROVIDER_ERRORS: Record<NonNullable<Extract<SDKMessage, { type: 'assistant' }>['error']>, RunnerFailureCode> = {
|
|
34
|
+
authentication_failed: 'authentication', oauth_org_not_allowed: 'authentication', cloud_credential_error: 'authentication', account_on_hold: 'authentication', verification_required: 'authentication',
|
|
35
|
+
billing_error: 'billing', rate_limit: 'rate_limit', overloaded: 'provider_unavailable', server_error: 'provider_unavailable',
|
|
36
|
+
max_output_tokens: 'output_limit', invalid_request: 'invalid_request', model_not_found: 'invalid_request', unknown: 'provider_error',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function providerFailure(status: number | null | undefined): RunnerFailureCode {
|
|
40
|
+
const codes: Record<number, RunnerFailureCode | undefined> = { 400: 'invalid_request', 401: 'authentication', 402: 'billing', 403: 'authentication', 404: 'invalid_request', 429: 'rate_limit' };
|
|
41
|
+
if (status === null || status === undefined) return 'provider_error';
|
|
42
|
+
return codes[status] ?? (status >= 500 && status <= 599 ? 'provider_unavailable' : 'provider_error');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readTokenLimit(content: unknown): boolean {
|
|
46
|
+
if (typeof content === 'string') return /File content \([\d,]+ tokens\) exceeds maximum allowed tokens \([\d,]+\)/.test(content.slice(0, 2_048));
|
|
47
|
+
return Array.isArray(content) && content.slice(0, 8).some((block: unknown) => isRecord(block) && block.type === 'text' && typeof block.text === 'string' && readTokenLimit(block.text));
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
function processIdentity(): Pick<RunnerActivity, 'procStart'> {
|
|
@@ -44,6 +65,8 @@ export class Activity {
|
|
|
44
65
|
private readonly approvals = new Map<string, Approval>();
|
|
45
66
|
private busy = false;
|
|
46
67
|
private failed = false;
|
|
68
|
+
private providerCode: RunnerFailureCode | null = null;
|
|
69
|
+
private interrupted: RunnerFailure | null = null;
|
|
47
70
|
private compacting = false;
|
|
48
71
|
private ready = false;
|
|
49
72
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -51,7 +74,7 @@ export class Activity {
|
|
|
51
74
|
private writeFailed = false;
|
|
52
75
|
private readonly state: RunnerActivity = {
|
|
53
76
|
pid: process.pid, ...processIdentity(), runner: 'sdk', phase: 'starting', mainPhase: 'starting', mainStartedAt: null, sessionId: null, updatedAt: Date.now(),
|
|
54
|
-
pending: 0, workers: 0, approvals: 0, tools: [], activeTools: [], tasks: [], events: [], lastError: null,
|
|
77
|
+
pending: 0, workers: 0, approvals: 0, tools: [], activeTools: [], tasks: [], events: [], lastError: null, activeFailure: null, lastFailure: null,
|
|
55
78
|
};
|
|
56
79
|
|
|
57
80
|
constructor(private readonly path: string) {}
|
|
@@ -68,6 +91,7 @@ export class Activity {
|
|
|
68
91
|
return {
|
|
69
92
|
...s, tools: [...s.tools], activeTools: s.activeTools.map((tool) => ({ ...tool })),
|
|
70
93
|
tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map((event) => ({ ...event })),
|
|
94
|
+
activeFailure: s.activeFailure ? { ...s.activeFailure } : null, lastFailure: s.lastFailure ? { ...s.lastFailure } : null,
|
|
71
95
|
};
|
|
72
96
|
}
|
|
73
97
|
|
|
@@ -97,11 +121,11 @@ export class Activity {
|
|
|
97
121
|
|
|
98
122
|
fail(message: string): void {
|
|
99
123
|
this.failed = true;
|
|
100
|
-
this.
|
|
101
|
-
|
|
124
|
+
const failure = this.failure('session_failed', sessionFailureCode(message), true);
|
|
125
|
+
if (failure.code === 'interrupted') this.interrupted = failure;
|
|
102
126
|
this.state.phase = 'error';
|
|
103
127
|
this.state.mainPhase = 'error';
|
|
104
|
-
log.error({ reason:
|
|
128
|
+
log.error({ reason: failure.code }, 'sdk-runner: activity error');
|
|
105
129
|
this.flush();
|
|
106
130
|
}
|
|
107
131
|
|
|
@@ -155,8 +179,7 @@ export class Activity {
|
|
|
155
179
|
this.compacting = compacting;
|
|
156
180
|
if (message.compact_result === 'success') this.event('compacted');
|
|
157
181
|
else if (message.compact_result === 'failed') {
|
|
158
|
-
this.
|
|
159
|
-
this.event('compact_failed');
|
|
182
|
+
this.failure('compact_failed', 'compact_error', false, null, null, { id: message.uuid });
|
|
160
183
|
}
|
|
161
184
|
}
|
|
162
185
|
|
|
@@ -171,6 +194,8 @@ export class Activity {
|
|
|
171
194
|
private turn(): void {
|
|
172
195
|
this.busy = true;
|
|
173
196
|
if (this.state.mainStartedAt !== null) return;
|
|
197
|
+
this.clearFailure();
|
|
198
|
+
this.providerCode = null;
|
|
174
199
|
this.state.mainStartedAt = Date.now();
|
|
175
200
|
this.event('turn_started');
|
|
176
201
|
}
|
|
@@ -189,8 +214,8 @@ export class Activity {
|
|
|
189
214
|
}
|
|
190
215
|
|
|
191
216
|
private taskEvent(kind: RunnerEventKind, tool: string | null, taskId: string | null): void {
|
|
192
|
-
if (kind === 'task_failed') this.
|
|
193
|
-
this.event(kind, tool, taskId);
|
|
217
|
+
if (kind === 'task_failed') this.failure(kind, 'task_error', false, tool, taskId);
|
|
218
|
+
else this.event(kind, tool, taskId);
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
private result(message: Extract<SDKMessage, { type: 'result' }>): void {
|
|
@@ -200,25 +225,30 @@ export class Activity {
|
|
|
200
225
|
for (const [id, tool] of this.tools) if (tool.parent === null) this.tools.delete(id);
|
|
201
226
|
this.failed = message.is_error || message.subtype !== 'success';
|
|
202
227
|
if (!this.failed) {
|
|
228
|
+
this.clearFailure();
|
|
229
|
+
this.providerCode = null;
|
|
203
230
|
this.event('turn_finished');
|
|
204
231
|
return;
|
|
205
232
|
}
|
|
206
|
-
const code = message.subtype === 'success' ?
|
|
207
|
-
this.
|
|
208
|
-
this.
|
|
233
|
+
const code = message.subtype === 'success' ? this.providerCode ?? providerFailure(message.api_error_status) : message.subtype;
|
|
234
|
+
this.providerCode = null;
|
|
235
|
+
this.failure('turn_failed', code, true, null, null, { id: message.uuid });
|
|
209
236
|
log.error({ reason: code }, 'sdk-runner: turn failed');
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
private content(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): void {
|
|
213
240
|
const content = message.message.content;
|
|
214
241
|
const parent = message.parent_tool_use_id ?? null;
|
|
242
|
+
if (parent === null && message.type === 'assistant') {
|
|
243
|
+
this.turn();
|
|
244
|
+
if (message.error !== undefined) this.providerCode = message.error === 'unknown' ? null : PROVIDER_ERRORS[message.error];
|
|
245
|
+
}
|
|
215
246
|
if (Array.isArray(content)) for (const block of content) if (isRecord(block)) this.block(block, parent);
|
|
216
|
-
if (parent === null && message.type === 'assistant') this.turn();
|
|
217
247
|
}
|
|
218
248
|
|
|
219
249
|
private block(block: Record<string, unknown>, parent: string | null): void {
|
|
220
250
|
if (block.type === 'tool_use' && typeof block.id === 'string') this.tool(block.id, block.name, parent);
|
|
221
|
-
else if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') this.finish(block.tool_use_id, block.is_error === true, parent);
|
|
251
|
+
else if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') this.finish(block.tool_use_id, block.is_error === true, parent, block.content);
|
|
222
252
|
}
|
|
223
253
|
|
|
224
254
|
private tool(id: string, rawName: unknown, parent: string | null): void {
|
|
@@ -231,14 +261,13 @@ export class Activity {
|
|
|
231
261
|
log.info({ tool: name, worker: taskId }, 'sdk-runner: tool started');
|
|
232
262
|
}
|
|
233
263
|
|
|
234
|
-
private finish(id: string, failed: boolean, parent: string | null): void {
|
|
264
|
+
private finish(id: string, failed: boolean, parent: string | null, content: unknown): void {
|
|
235
265
|
const tool = this.tools.get(id);
|
|
236
266
|
if (tool === undefined && this.tasks.isHidden(parent)) return;
|
|
237
267
|
this.tools.delete(id);
|
|
238
268
|
const name = tool?.name ?? 'tool';
|
|
239
269
|
if (failed) {
|
|
240
|
-
this.
|
|
241
|
-
this.event('tool_failed', name, tool?.taskId ?? null);
|
|
270
|
+
this.toolFailure(id, tool, parent, content);
|
|
242
271
|
log.warn({ tool: name }, 'sdk-runner: tool failed');
|
|
243
272
|
return;
|
|
244
273
|
}
|
|
@@ -246,8 +275,35 @@ export class Activity {
|
|
|
246
275
|
log.info({ tool: name }, 'sdk-runner: tool finished');
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
private toolFailure(id: string, tool: TrackedTool | undefined, parent: string | null, content: unknown): void {
|
|
279
|
+
const name = tool?.name ?? 'tool';
|
|
280
|
+
const code = name === 'Read' && readTokenLimit(content) ? 'read_token_limit' : 'tool_error';
|
|
281
|
+
this.failure('tool_failed', code, false, name, tool?.taskId ?? this.tasks.taskFor(parent), { toolUseId: id });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private clearFailure(): void {
|
|
285
|
+
this.failed = false;
|
|
286
|
+
this.state.activeFailure = this.interrupted;
|
|
287
|
+
this.state.lastError = this.interrupted === null ? null : runnerFailureSummary(this.interrupted.code);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private failure(kind: RunnerEventKind, code: RunnerFailureCode, active: boolean, tool: string | null = null, taskId: string | null = null, identity: Pick<RunnerEvent, 'id' | 'toolUseId'> = {}): RunnerFailure {
|
|
291
|
+
const failure: RunnerFailure = { id: activityName(identity.id) ?? randomUUID(), at: Date.now(), kind, code, tool, taskId, ...(activityName(identity.toolUseId) === null ? {} : { toolUseId: identity.toolUseId }) };
|
|
292
|
+
this.state.lastFailure = failure;
|
|
293
|
+
if (active) {
|
|
294
|
+
this.state.activeFailure = failure;
|
|
295
|
+
this.state.lastError = runnerFailureSummary(code, tool);
|
|
296
|
+
}
|
|
297
|
+
this.record(failure);
|
|
298
|
+
return failure;
|
|
299
|
+
}
|
|
300
|
+
|
|
249
301
|
private event(kind: RunnerEventKind, tool: string | null = null, taskId: string | null = null): void {
|
|
250
|
-
this.
|
|
302
|
+
this.record({ id: randomUUID(), at: Date.now(), kind, tool, taskId });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private record(event: RunnerEvent): void {
|
|
306
|
+
this.state.events.unshift(event);
|
|
251
307
|
if (this.state.events.length > MAX_EVENTS) this.state.events.length = MAX_EVENTS;
|
|
252
308
|
}
|
|
253
309
|
|
|
@@ -14,7 +14,7 @@ export interface RunningAgent {
|
|
|
14
14
|
runner: Runner;
|
|
15
15
|
link: MetroLink;
|
|
16
16
|
done: Promise<void>;
|
|
17
|
-
stop(): Promise<void>;
|
|
17
|
+
stop(cancelActive?: boolean): Promise<void>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface AppHooks {
|
|
@@ -42,10 +42,10 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
|
|
|
42
42
|
lost: (reason) => { hooks.lost(reason); },
|
|
43
43
|
});
|
|
44
44
|
let stopped = false;
|
|
45
|
-
const stop = async (): Promise<void> => {
|
|
45
|
+
const stop = async (cancelActive = true): Promise<void> => {
|
|
46
46
|
if (stopped) return;
|
|
47
47
|
stopped = true;
|
|
48
|
-
runner.close();
|
|
48
|
+
runner.close(cancelActive);
|
|
49
49
|
await link.close().catch(() => { log.debug('sdk-runner: closing the metro link failed'); });
|
|
50
50
|
hooks.activity?.stop();
|
|
51
51
|
await release();
|
|
@@ -57,7 +57,7 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
|
|
|
57
57
|
runner.start(runnerOptions(cfg, tools, approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }), resume, hooks.env));
|
|
58
58
|
return { runner, link, done: runner.run(hooks.observe), stop };
|
|
59
59
|
} catch (err) {
|
|
60
|
-
runner.close();
|
|
60
|
+
runner.close(false);
|
|
61
61
|
await link.close().catch(() => { log.debug('sdk-runner: closing the metro link failed'); });
|
|
62
62
|
throw err;
|
|
63
63
|
}
|
|
@@ -8,6 +8,8 @@ export type Priority = 'now' | 'next' | 'later';
|
|
|
8
8
|
export interface Unanswered {
|
|
9
9
|
text: string;
|
|
10
10
|
at: number;
|
|
11
|
+
uuid?: Uuid;
|
|
12
|
+
state?: 'queued' | 'started';
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const KINDS_MAX = 2_000;
|
|
@@ -20,6 +22,7 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
20
22
|
private readonly ledger = new Map<string, Unanswered>();
|
|
21
23
|
private wake: (() => void) | null = null;
|
|
22
24
|
private closed = false;
|
|
25
|
+
private restoring = false;
|
|
23
26
|
|
|
24
27
|
constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined) {}
|
|
25
28
|
|
|
@@ -49,14 +52,29 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
49
52
|
return this.kinds.get(uuid);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
started(uuids: readonly string[]): void {
|
|
56
|
+
let changed = false;
|
|
57
|
+
for (const uuid of uuids) {
|
|
58
|
+
const entry = this.ledger.get(uuid);
|
|
59
|
+
if (entry === undefined || entry.state === 'started') continue;
|
|
60
|
+
this.ledger.set(uuid, { ...entry, state: 'started' });
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
if (changed) this.changed(this.unanswered());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
finished(uuids = this.startedUuids()): void {
|
|
53
67
|
let changed = false;
|
|
54
68
|
for (const uuid of uuids) changed = this.ledger.delete(uuid) || changed;
|
|
55
69
|
if (changed) this.changed(this.unanswered());
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
startedUuids(): string[] {
|
|
73
|
+
return [...this.ledger].filter(([, entry]) => entry.state === 'started').map(([uuid]) => uuid);
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
unanswered(now = Date.now()): Unanswered[] {
|
|
59
|
-
return [...this.ledger.values()].filter((entry) => now - entry.at < LEDGER_AGE_MS);
|
|
77
|
+
return [...this.ledger.values()].filter((entry) => entry.state === 'started' || now - entry.at < LEDGER_AGE_MS);
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
close(): void {
|
|
@@ -79,16 +97,23 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
again(left: readonly Unanswered[]): void {
|
|
82
|
-
|
|
100
|
+
if (left.length === 0) return;
|
|
101
|
+
this.restoring = true;
|
|
102
|
+
try {
|
|
103
|
+
for (const entry of left) this.push('chat', entry.text, undefined, entry.uuid ?? randomUUID(), entry.at);
|
|
104
|
+
} finally {
|
|
105
|
+
this.restoring = false;
|
|
106
|
+
}
|
|
107
|
+
this.changed(this.unanswered());
|
|
83
108
|
}
|
|
84
109
|
|
|
85
|
-
private remember(uuid:
|
|
86
|
-
this.ledger.set(uuid, { text, at });
|
|
110
|
+
private remember(uuid: Uuid, text: string, at: number): void {
|
|
111
|
+
this.ledger.set(uuid, { text, at, uuid, state: 'queued' });
|
|
87
112
|
while (this.ledger.size > LEDGER_MAX) {
|
|
88
|
-
const oldest = this.ledger.
|
|
113
|
+
const oldest = [...this.ledger].find(([, entry]) => entry.state !== 'started')?.[0];
|
|
89
114
|
if (oldest === undefined) break;
|
|
90
115
|
this.ledger.delete(oldest);
|
|
91
116
|
}
|
|
92
|
-
this.changed(this.unanswered());
|
|
117
|
+
if (!this.restoring) this.changed(this.unanswered());
|
|
93
118
|
}
|
|
94
119
|
}
|
|
@@ -79,6 +79,7 @@ export class MetroLink implements Asker {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior> {
|
|
82
|
+
if (signal.aborted) return Promise.resolve('deny');
|
|
82
83
|
return new Promise((resolve) => {
|
|
83
84
|
const settle = (behavior: Behavior): void => {
|
|
84
85
|
this.waiting.delete(ask.request_id);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import type { Unanswered } from './inbox.js';
|
|
5
|
+
|
|
6
|
+
export const INTERRUPTED_NOTICE = 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.';
|
|
7
|
+
|
|
8
|
+
function scanTranscript(fd: number, wanted: ReadonlySet<string>): Set<string> {
|
|
9
|
+
const found = new Set<string>();
|
|
10
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
11
|
+
let overlap = '';
|
|
12
|
+
for (let size = readSync(fd, chunk); size > 0; size = readSync(fd, chunk)) {
|
|
13
|
+
const text = overlap + chunk.toString('latin1', 0, size);
|
|
14
|
+
for (const uuid of wanted) if (text.includes(`"${uuid}"`)) found.add(uuid);
|
|
15
|
+
if (found.size === wanted.size) break;
|
|
16
|
+
overlap = text.slice(-37);
|
|
17
|
+
}
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function transcriptUuids(path: string | null, inputs: readonly Unanswered[]): Set<string> {
|
|
22
|
+
const wanted = new Set(inputs.flatMap((input) => input.state !== 'started' && input.uuid !== undefined ? [input.uuid] : []));
|
|
23
|
+
if (path === null || wanted.size === 0) return new Set();
|
|
24
|
+
let fd: number | undefined;
|
|
25
|
+
try {
|
|
26
|
+
fd = openSync(path, 'r');
|
|
27
|
+
return scanTranscript(fd, wanted);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (isRecord(err) && err.code === 'ENOENT') return new Set();
|
|
30
|
+
throw new Error('The saved Agent SDK transcript cannot be checked. Restore it before starting.');
|
|
31
|
+
} finally {
|
|
32
|
+
if (fd !== undefined) closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function recoverInputs(inputs: readonly Unanswered[], transcript: string | null): { unanswered: Unanswered[]; interrupted: Unanswered[] } {
|
|
37
|
+
const seen = transcriptUuids(transcript, inputs);
|
|
38
|
+
const unanswered: Unanswered[] = [];
|
|
39
|
+
const interrupted: Unanswered[] = [];
|
|
40
|
+
for (const input of inputs) {
|
|
41
|
+
const entry = { ...input, uuid: input.uuid ?? randomUUID(), state: input.state ?? 'queued' };
|
|
42
|
+
if (entry.state === 'started' || seen.has(entry.uuid)) interrupted.push({ ...entry, state: 'started' });
|
|
43
|
+
else unanswered.push(entry);
|
|
44
|
+
}
|
|
45
|
+
return { unanswered, interrupted };
|
|
46
|
+
}
|
|
@@ -6,6 +6,7 @@ import { channelText, type ChannelEvent } from './channel-text.js';
|
|
|
6
6
|
import type { RunnerConfig } from './config.js';
|
|
7
7
|
import { Inbox, type Unanswered, type Uuid } from './inbox.js';
|
|
8
8
|
import { CALL_ENDED, callStarted, callWords, FRONT_RULES } from './rules.js';
|
|
9
|
+
import { INTERRUPTED_NOTICE } from './recovery.js';
|
|
9
10
|
import type { SessionStore } from './session-store.js';
|
|
10
11
|
import { SessionWatch, startedCommand, uuidsOf } from './session-watch.js';
|
|
11
12
|
import { SpeechRouter, type SpeechSink } from './speech.js';
|
|
@@ -79,6 +80,9 @@ export class Runner {
|
|
|
79
80
|
private stall: ReturnType<typeof setTimeout> | null = null;
|
|
80
81
|
private model: string | null = null;
|
|
81
82
|
private switching: Promise<void> = Promise.resolve();
|
|
83
|
+
private ended = false;
|
|
84
|
+
private readonly interrupted: number;
|
|
85
|
+
private interruptionReported = false;
|
|
82
86
|
|
|
83
87
|
constructor(private readonly parts: RunnerParts) {
|
|
84
88
|
this.inbox = new Inbox((unanswered) => {
|
|
@@ -94,7 +98,8 @@ export class Runner {
|
|
|
94
98
|
},
|
|
95
99
|
});
|
|
96
100
|
this.watch = new SessionWatch(parts.readOnly);
|
|
97
|
-
const again = parts.store.
|
|
101
|
+
const { unanswered: again, interrupted } = parts.store.recover();
|
|
102
|
+
this.interrupted = interrupted.length;
|
|
98
103
|
this.inbox.again(again);
|
|
99
104
|
if (again.length > 0) log.info({ count: again.length }, 'sdk-runner: chat messages the last session never read go in again');
|
|
100
105
|
}
|
|
@@ -118,20 +123,33 @@ export class Runner {
|
|
|
118
123
|
|
|
119
124
|
async run(observe?: (message: SDKMessage) => void): Promise<void> {
|
|
120
125
|
if (this.session === null) throw new Error('the runner was not started');
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
try {
|
|
127
|
+
for await (const message of this.session) {
|
|
128
|
+
const m: Record<string, unknown> = { ...message };
|
|
129
|
+
this.trackInput(m);
|
|
130
|
+
this.watch.observe(m);
|
|
131
|
+
this.speech.observe(message);
|
|
132
|
+
this.note(m);
|
|
133
|
+
this.parts.activity?.observe(message);
|
|
134
|
+
observe?.(message);
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
this.ended = true;
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
chat(event: ChannelEvent): Uuid {
|
|
134
|
-
|
|
142
|
+
try {
|
|
143
|
+
return this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log.error('sdk-runner: could not save incoming chat; stopping before further work');
|
|
146
|
+
try {
|
|
147
|
+
this.parts.activity?.fail(INTERRUPTED_NOTICE);
|
|
148
|
+
} finally {
|
|
149
|
+
this.close(false);
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
135
153
|
}
|
|
136
154
|
|
|
137
155
|
callStarted(where: string): Uuid {
|
|
@@ -149,10 +167,14 @@ export class Runner {
|
|
|
149
167
|
return this.inbox.push('note', CALL_ENDED);
|
|
150
168
|
}
|
|
151
169
|
|
|
152
|
-
close(): void {
|
|
170
|
+
close(cancelActive = true): void {
|
|
153
171
|
this.clearStall();
|
|
154
|
-
|
|
155
|
-
|
|
172
|
+
try {
|
|
173
|
+
if (cancelActive && !this.ended) this.inbox.finished();
|
|
174
|
+
} finally {
|
|
175
|
+
this.inbox.close();
|
|
176
|
+
this.session?.close();
|
|
177
|
+
}
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
private callPush(text: string): Uuid {
|
|
@@ -199,6 +221,11 @@ export class Runner {
|
|
|
199
221
|
if (session === null) return;
|
|
200
222
|
await session.applyFlagSettings(allowedOnly(model));
|
|
201
223
|
this.parts.activity?.connected();
|
|
224
|
+
if (this.interrupted > 0 && !this.interruptionReported) {
|
|
225
|
+
this.interruptionReported = true;
|
|
226
|
+
this.parts.activity?.fail(INTERRUPTED_NOTICE);
|
|
227
|
+
log.warn({ count: this.interrupted }, INTERRUPTED_NOTICE);
|
|
228
|
+
}
|
|
202
229
|
if (model === this.model) return;
|
|
203
230
|
const was = this.model;
|
|
204
231
|
await session.setModel(model ?? undefined);
|
|
@@ -206,13 +233,20 @@ export class Runner {
|
|
|
206
233
|
log.info({ was, now: model }, 'sdk-runner: the session thinks with the new model from now on, with no restart');
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
private
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
236
|
+
private trackInput(m: Record<string, unknown>): void {
|
|
237
|
+
if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
|
|
238
|
+
if (m.type === 'result') {
|
|
239
|
+
const uuids = uuidsOf(m);
|
|
240
|
+
if (uuids !== null) this.inbox.finished(uuids);
|
|
241
|
+
return;
|
|
215
242
|
}
|
|
243
|
+
const started = startedCommand(m);
|
|
244
|
+
this.inbox.started([...(uuidsOf(m) ?? []), ...(started === null ? [] : [started])]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private keep(unanswered: Unanswered[]): void {
|
|
248
|
+
this.parts.store.saveUnanswered(unanswered);
|
|
249
|
+
this.parts.activity?.pending(unanswered.filter((entry) => entry.state !== 'started').length);
|
|
216
250
|
}
|
|
217
251
|
|
|
218
252
|
private note(m: Record<string, unknown>): void {
|
|
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
4
|
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
5
|
-
import type { Unanswered } from './inbox.js';
|
|
5
|
+
import type { Unanswered, Uuid } from './inbox.js';
|
|
6
|
+
import { recoverInputs } from './recovery.js';
|
|
6
7
|
|
|
7
8
|
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
8
9
|
|
|
@@ -11,10 +12,26 @@ export const projectFolder = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/
|
|
|
11
12
|
interface Stored {
|
|
12
13
|
sessionId: string | null;
|
|
13
14
|
unanswered: Unanswered[];
|
|
15
|
+
interrupted: Unanswered[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
function inputId(raw: unknown): Uuid | undefined {
|
|
19
|
+
if (raw === undefined) return undefined;
|
|
20
|
+
if (typeof raw !== 'string' || !SESSION_ID.test(raw)) throw new Error(UNREADABLE);
|
|
21
|
+
return raw as Uuid;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function inputState(raw: unknown): Unanswered['state'] {
|
|
25
|
+
if (raw === undefined || raw === 'queued' || raw === 'started') return raw;
|
|
26
|
+
throw new Error(UNREADABLE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function entry(raw: unknown): Unanswered | null {
|
|
30
|
+
if (!isRecord(raw) || typeof raw.text !== 'string' || typeof raw.at !== 'number') return null;
|
|
31
|
+
const uuid = inputId(raw.uuid);
|
|
32
|
+
const state = inputState(raw.state);
|
|
33
|
+
return { text: raw.text, at: raw.at, ...(uuid === undefined ? {} : { uuid }), ...(state === undefined ? {} : { state }) };
|
|
34
|
+
}
|
|
18
35
|
|
|
19
36
|
const UNREADABLE = 'The saved Agent SDK state cannot be read. Restore it before starting.';
|
|
20
37
|
|
|
@@ -22,11 +39,15 @@ function stored(raw: unknown): Stored {
|
|
|
22
39
|
if (!isRecord(raw)) throw new Error(UNREADABLE);
|
|
23
40
|
const id = raw.sessionId ?? null;
|
|
24
41
|
if (id !== null && (typeof id !== 'string' || !SESSION_ID.test(id))) throw new Error(UNREADABLE);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
return { sessionId: id, unanswered: entries(raw.unanswered), interrupted: entries(raw.interrupted) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function entries(raw: unknown): Unanswered[] {
|
|
46
|
+
if (raw === undefined) return [];
|
|
47
|
+
if (!Array.isArray(raw)) throw new Error(UNREADABLE);
|
|
48
|
+
const list = raw.map(entry);
|
|
49
|
+
if (!list.every((e): e is Unanswered => e !== null)) throw new Error(UNREADABLE);
|
|
50
|
+
return list;
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
export class SessionStore {
|
|
@@ -46,6 +67,15 @@ export class SessionStore {
|
|
|
46
67
|
return this.read().unanswered;
|
|
47
68
|
}
|
|
48
69
|
|
|
70
|
+
recover(): Stored {
|
|
71
|
+
const stored = this.read();
|
|
72
|
+
const transcript = stored.sessionId === null ? null : join(this.claudeDir, 'projects', projectFolder(this.cwd), `${stored.sessionId}.jsonl`);
|
|
73
|
+
const recovered = recoverInputs(stored.unanswered, transcript);
|
|
74
|
+
const next = { ...stored, unanswered: recovered.unanswered, interrupted: [...stored.interrupted, ...recovered.interrupted] };
|
|
75
|
+
if (stored.unanswered.length > 0) this.write(next);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
49
79
|
save(id: string): void {
|
|
50
80
|
const stored = this.read();
|
|
51
81
|
if (!SESSION_ID.test(id) || stored.sessionId === id) return;
|
|
@@ -60,7 +90,7 @@ export class SessionStore {
|
|
|
60
90
|
try {
|
|
61
91
|
return stored(JSON.parse(readFileSync(this.path, 'utf8')));
|
|
62
92
|
} catch (err) {
|
|
63
|
-
if (isRecord(err) && err.code === 'ENOENT') return { sessionId: null, unanswered: [] };
|
|
93
|
+
if (isRecord(err) && err.code === 'ENOENT') return { sessionId: null, unanswered: [], interrupted: [] };
|
|
64
94
|
throw new Error(UNREADABLE);
|
|
65
95
|
}
|
|
66
96
|
}
|
|
@@ -40,6 +40,7 @@ export class SessionWatch {
|
|
|
40
40
|
private readonly compacted: (() => void)[] = [];
|
|
41
41
|
private compactingNow = false;
|
|
42
42
|
private lastContext = 0;
|
|
43
|
+
private streamUsage: Record<string, unknown> = {};
|
|
43
44
|
|
|
44
45
|
constructor(private readonly readOnly: (tool: string) => boolean) {}
|
|
45
46
|
|
|
@@ -60,6 +61,7 @@ export class SessionWatch {
|
|
|
60
61
|
else if (m.type === 'result') this.settle();
|
|
61
62
|
if (m.parent_tool_use_id !== null) return;
|
|
62
63
|
if (m.type === 'assistant' && isRecord(m.message)) this.assistant(m.message);
|
|
64
|
+
else if (m.type === 'stream_event' && isRecord(m.event)) this.stream(m.event);
|
|
63
65
|
else if (m.type === 'user') for (const id of toolResultIds(m.message)) this.done(id);
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -77,9 +79,19 @@ export class SessionWatch {
|
|
|
77
79
|
else if (m.subtype === 'compact_boundary') {
|
|
78
80
|
this.setCompacting(false);
|
|
79
81
|
this.lastContext = 0;
|
|
82
|
+
this.streamUsage = {};
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
private stream(event: Record<string, unknown>): void {
|
|
87
|
+
if (event.type === 'message_start' && isRecord(event.message)) {
|
|
88
|
+
this.streamUsage = isRecord(event.message.usage) ? event.message.usage : {};
|
|
89
|
+
} else if (event.type === 'message_delta' && isRecord(event.usage)) {
|
|
90
|
+
this.streamUsage = { ...this.streamUsage, ...event.usage };
|
|
91
|
+
} else return;
|
|
92
|
+
this.lastContext = contextOf(this.streamUsage) || this.lastContext;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
private setCompacting(on: boolean): void {
|
|
84
96
|
this.compactingNow = on;
|
|
85
97
|
if (!on) for (const resume of this.compacted.splice(0)) resume();
|