@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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/dist/agent.js +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import type { RunnerEventKind, RunnerTask, RunnerTaskState } from '@metro-labs/core/runner-activity';
|
|
3
|
+
|
|
4
|
+
export const activityName = (name: unknown): string | null => typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(name) ? name : null;
|
|
5
|
+
const live = (task: RunnerTask): boolean => ['running', 'pending', 'paused'].includes(task.status);
|
|
6
|
+
const terminal = (status: RunnerTaskState): boolean => ['completed', 'failed', 'stopped'].includes(status);
|
|
7
|
+
type System = Extract<SDKMessage, { type: 'system' }>;
|
|
8
|
+
type Emit = (kind: RunnerEventKind, tool?: string | null, taskId?: string | null) => void;
|
|
9
|
+
|
|
10
|
+
export class ActivityTasks {
|
|
11
|
+
private readonly tasks = new Map<string, RunnerTask>();
|
|
12
|
+
private readonly parents = new Map<string, string>();
|
|
13
|
+
private readonly hidden = new Map<string, string>();
|
|
14
|
+
constructor(private readonly emit: Emit) {}
|
|
15
|
+
|
|
16
|
+
snapshot(): RunnerTask[] {
|
|
17
|
+
return [...this.tasks.values()].sort((a, b) => Number(live(b)) - Number(live(a)) || b.updatedAt - a.updatedAt).slice(0, 30).map((task) => ({ ...task }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get running(): number { return [...this.tasks.values()].filter(live).length; }
|
|
21
|
+
taskFor(parent: string | null | undefined): string | null { return parent ? this.parents.get(parent) ?? null : null; }
|
|
22
|
+
known(id: string | null | undefined): string | null { return id && this.tasks.has(id) ? id : null; }
|
|
23
|
+
isHidden(id: string | null): boolean { return id !== null && this.hidden.has(id); }
|
|
24
|
+
isFinished(id: string): boolean { const task = this.tasks.get(id); return task !== undefined && !live(task); }
|
|
25
|
+
|
|
26
|
+
observe(message: System): void {
|
|
27
|
+
switch (message.subtype) {
|
|
28
|
+
case 'task_started': this.start(message); break;
|
|
29
|
+
case 'task_progress': this.progress(message); break;
|
|
30
|
+
case 'task_updated': this.patch(message); break;
|
|
31
|
+
case 'task_notification': this.end(message); break;
|
|
32
|
+
case 'background_tasks_changed': this.replace(message); break;
|
|
33
|
+
default: break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stop(): void {
|
|
38
|
+
for (const task of this.tasks.values()) if (live(task)) this.status(task, 'stopped');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private get(id: string): RunnerTask | null {
|
|
42
|
+
if (!activityName(id) || this.hidden.has(id)) return null;
|
|
43
|
+
const known = this.tasks.get(id);
|
|
44
|
+
if (known) return known;
|
|
45
|
+
if (this.tasks.size >= 200) {
|
|
46
|
+
const old = [...this.tasks.values()].find((task) => !live(task));
|
|
47
|
+
if (!old) return null;
|
|
48
|
+
this.tasks.delete(old.id);
|
|
49
|
+
for (const [parent, taskId] of this.parents) if (taskId === old.id) this.parents.delete(parent);
|
|
50
|
+
}
|
|
51
|
+
const task: RunnerTask = { id, kind: null, agent: null, status: 'unknown', background: false, startedAt: 0, updatedAt: Date.now(), endedAt: null, lastTool: null, toolUses: 0, durationMs: 0 };
|
|
52
|
+
this.tasks.set(id, task);
|
|
53
|
+
return task;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private hide(id: string, toolUseId?: string): void {
|
|
57
|
+
this.tasks.delete(id);
|
|
58
|
+
const parents = [...this.parents].filter(([, task]) => task === id).map(([parent]) => parent);
|
|
59
|
+
for (const key of [id, toolUseId, ...parents]) {
|
|
60
|
+
if (key === undefined) continue;
|
|
61
|
+
const oldest = this.hidden.keys().next();
|
|
62
|
+
if (this.hidden.size >= 400 && !oldest.done) this.hidden.delete(oldest.value);
|
|
63
|
+
this.hidden.set(key, id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private show(id: string): RunnerTask | null {
|
|
68
|
+
this.hidden.delete(id);
|
|
69
|
+
const task = this.get(id);
|
|
70
|
+
if (!task) return null;
|
|
71
|
+
for (const [key, taskId] of this.hidden) {
|
|
72
|
+
if (taskId !== id) continue;
|
|
73
|
+
this.hidden.delete(key);
|
|
74
|
+
this.parents.set(key, id);
|
|
75
|
+
}
|
|
76
|
+
return task;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private start(message: Extract<System, { subtype: 'task_started' }>): void {
|
|
80
|
+
if (message.ambient || message.skip_transcript) { this.hide(message.task_id, message.tool_use_id); return; }
|
|
81
|
+
const task = this.get(message.task_id);
|
|
82
|
+
if (!task) return;
|
|
83
|
+
task.kind = activityName(message.task_type);
|
|
84
|
+
task.agent = activityName(message.subagent_type);
|
|
85
|
+
task.background = message.is_backgrounded === true;
|
|
86
|
+
task.startedAt = Date.now();
|
|
87
|
+
task.endedAt = null;
|
|
88
|
+
if (message.tool_use_id) this.parents.set(message.tool_use_id, task.id);
|
|
89
|
+
this.status(task, 'running');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private progress(message: Extract<System, { subtype: 'task_progress' }>): void {
|
|
93
|
+
const task = this.tasks.get(message.task_id);
|
|
94
|
+
if (!task || terminal(task.status)) return;
|
|
95
|
+
task.lastTool = activityName(message.last_tool_name);
|
|
96
|
+
task.toolUses = message.usage.tool_uses;
|
|
97
|
+
task.durationMs = message.usage.duration_ms;
|
|
98
|
+
task.updatedAt = Date.now();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private patch(message: Extract<System, { subtype: 'task_updated' }>): void {
|
|
102
|
+
const task = this.tasks.get(message.task_id);
|
|
103
|
+
if (!task) return;
|
|
104
|
+
if (message.patch.is_backgrounded !== undefined) task.background = message.patch.is_backgrounded;
|
|
105
|
+
if (message.patch.status) this.status(task, message.patch.status === 'killed' ? 'stopped' : message.patch.status);
|
|
106
|
+
task.updatedAt = Date.now();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private end(message: Extract<System, { subtype: 'task_notification' }>): void {
|
|
110
|
+
if (message.ambient || message.skip_transcript) { this.hide(message.task_id); return; }
|
|
111
|
+
const task = this.get(message.task_id);
|
|
112
|
+
if (!task) return;
|
|
113
|
+
if (message.usage) {
|
|
114
|
+
task.durationMs = message.usage.duration_ms;
|
|
115
|
+
task.toolUses = message.usage.tool_uses;
|
|
116
|
+
}
|
|
117
|
+
this.status(task, message.status);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private replace(message: Extract<System, { subtype: 'background_tasks_changed' }>): void {
|
|
121
|
+
const ids = new Set(message.tasks.map((task) => task.task_id));
|
|
122
|
+
for (const task of this.tasks.values()) if (task.background && live(task) && !ids.has(task.id)) this.status(task, 'unknown');
|
|
123
|
+
for (const row of message.tasks) {
|
|
124
|
+
if (row.ambient) { this.hide(row.task_id); continue; }
|
|
125
|
+
const task = this.show(row.task_id);
|
|
126
|
+
if (!task || terminal(task.status)) continue;
|
|
127
|
+
task.kind = activityName(row.task_type);
|
|
128
|
+
task.background = true;
|
|
129
|
+
this.status(task, 'running');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private status(task: RunnerTask, status: RunnerTaskState): void {
|
|
134
|
+
const changed = task.status !== status;
|
|
135
|
+
task.status = status;
|
|
136
|
+
task.updatedAt = Date.now();
|
|
137
|
+
task.endedAt = terminal(status) ? task.updatedAt : null;
|
|
138
|
+
if (!changed) return;
|
|
139
|
+
const event: Partial<Record<RunnerTaskState, RunnerEventKind>> = { running: 'task_started', completed: 'task_completed', failed: 'task_failed', stopped: 'task_stopped' };
|
|
140
|
+
this.emit(event[status] ?? 'task_updated', null, task.id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { log } from '@metro-labs/core/log';
|
|
5
|
+
import type { RunnerActivity, RunnerEventKind, RunnerPhase, RunnerTool } from '@metro-labs/core/runner-activity';
|
|
6
|
+
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
7
|
+
import { ActivityTasks, activityName } from './activity-tasks.js';
|
|
8
|
+
import type { ApprovalAsk } from './approvals.js';
|
|
9
|
+
|
|
10
|
+
const HEARTBEAT_MS = 5_000;
|
|
11
|
+
const MAX_TRACKED = 200;
|
|
12
|
+
const MAX_TOOLS = 20;
|
|
13
|
+
const MAX_EVENTS = 40;
|
|
14
|
+
const STATUS_UNAVAILABLE = 'The SDK status file cannot be written. Check its path and permissions before starting.';
|
|
15
|
+
type System = Extract<SDKMessage, { type: 'system' }>;
|
|
16
|
+
interface TrackedTool extends RunnerTool { parent: string | null }
|
|
17
|
+
interface Approval { tool: string | null; taskId: string | null; worker: boolean }
|
|
18
|
+
|
|
19
|
+
export function failureSummary(err: unknown): string {
|
|
20
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
21
|
+
if (text === STATUS_UNAVAILABLE) return STATUS_UNAVAILABLE;
|
|
22
|
+
if (/saved Agent SDK state/i.test(text)) return 'The saved Agent SDK state cannot be read. Restore it before starting.';
|
|
23
|
+
if (/unauthorized|authentication|invalid.*key|401|403/i.test(text)) return 'The provider refused authentication. Check the Model page.';
|
|
24
|
+
if (/ENOENT|not found|executable|spawn/i.test(text)) return 'The SDK executable could not start. Prepare the SDK runtime again.';
|
|
25
|
+
if (/ECONN|network|fetch failed|timed? ?out/i.test(text)) return 'The SDK connection failed. Check the server and provider connection.';
|
|
26
|
+
return 'The Agent SDK session failed. Check Terminal and the Model page.';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function processIdentity(): Pick<RunnerActivity, 'procStart'> {
|
|
30
|
+
if (process.platform !== 'linux') return {};
|
|
31
|
+
try {
|
|
32
|
+
const stat = readFileSync('/proc/self/stat', 'utf8');
|
|
33
|
+
const start = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
34
|
+
return start && /^\d{1,32}$/.test(start) ? { procStart: start } : {};
|
|
35
|
+
} catch {
|
|
36
|
+
log.warn('sdk-runner: process identity is unavailable');
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Activity {
|
|
42
|
+
private readonly tools = new Map<string, TrackedTool>();
|
|
43
|
+
private readonly tasks = new ActivityTasks((kind, tool, taskId) => { this.taskEvent(kind, tool ?? null, taskId ?? null); });
|
|
44
|
+
private readonly approvals = new Map<string, Approval>();
|
|
45
|
+
private busy = false;
|
|
46
|
+
private failed = false;
|
|
47
|
+
private compacting = false;
|
|
48
|
+
private ready = false;
|
|
49
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
50
|
+
private lastWritten = '';
|
|
51
|
+
private writeFailed = false;
|
|
52
|
+
private readonly state: RunnerActivity = {
|
|
53
|
+
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,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
constructor(private readonly path: string) {}
|
|
58
|
+
|
|
59
|
+
start(): void {
|
|
60
|
+
this.flush(true);
|
|
61
|
+
if (this.writeFailed) throw new Error(STATUS_UNAVAILABLE);
|
|
62
|
+
this.timer = setInterval(() => { this.flush(true); }, HEARTBEAT_MS);
|
|
63
|
+
this.timer.unref();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
snapshot(): RunnerActivity {
|
|
67
|
+
const s = this.state;
|
|
68
|
+
return {
|
|
69
|
+
...s, tools: [...s.tools], activeTools: s.activeTools.map((tool) => ({ ...tool })),
|
|
70
|
+
tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map((event) => ({ ...event })),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pending(count: number): void {
|
|
75
|
+
this.state.pending = count;
|
|
76
|
+
this.update();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
connected(): void {
|
|
80
|
+
this.ready = true;
|
|
81
|
+
this.update();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
approval(id: string, ask: ApprovalAsk | null): void {
|
|
85
|
+
if (ask !== null && this.tasks.isHidden(ask.worker)) return;
|
|
86
|
+
const known = this.approvals.get(id);
|
|
87
|
+
if (ask !== null && known === undefined) {
|
|
88
|
+
const entry: Approval = { tool: activityName(ask.tool), taskId: this.tasks.known(ask.worker), worker: ask.worker !== null };
|
|
89
|
+
this.approvals.set(id, entry);
|
|
90
|
+
this.event('approval_waiting', entry.tool, entry.taskId);
|
|
91
|
+
} else if (ask === null && known !== undefined) {
|
|
92
|
+
this.approvals.delete(id);
|
|
93
|
+
this.event('approval_ended', known.tool, known.taskId);
|
|
94
|
+
}
|
|
95
|
+
this.update();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fail(message: string): void {
|
|
99
|
+
this.failed = true;
|
|
100
|
+
this.state.lastError = message;
|
|
101
|
+
this.event('session_failed');
|
|
102
|
+
this.state.phase = 'error';
|
|
103
|
+
this.state.mainPhase = 'error';
|
|
104
|
+
log.error({ reason: message }, 'sdk-runner: activity error');
|
|
105
|
+
this.flush();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop(): void {
|
|
109
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
110
|
+
this.timer = null;
|
|
111
|
+
this.tasks.stop();
|
|
112
|
+
this.tools.clear();
|
|
113
|
+
this.approvals.clear();
|
|
114
|
+
this.busy = false;
|
|
115
|
+
this.state.mainStartedAt = null;
|
|
116
|
+
this.sync();
|
|
117
|
+
if (this.state.phase !== 'error') this.state.phase = 'stopped';
|
|
118
|
+
if (this.state.mainPhase !== 'error') this.state.mainPhase = 'stopped';
|
|
119
|
+
this.flush(true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
observe(message: SDKMessage): void {
|
|
123
|
+
if (message.type === 'system') this.system(message);
|
|
124
|
+
else if (message.type === 'result') this.result(message);
|
|
125
|
+
else if (message.type === 'assistant' || message.type === 'user') this.content(message);
|
|
126
|
+
else if (message.type === 'stream_event' && message.parent_tool_use_id === null && !this.turnOpen()) this.turn();
|
|
127
|
+
else return;
|
|
128
|
+
this.update();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private turnOpen(): boolean {
|
|
132
|
+
return this.busy && this.state.mainStartedAt !== null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private system(message: System): void {
|
|
136
|
+
switch (message.subtype) {
|
|
137
|
+
case 'init': this.init(message.session_id); break;
|
|
138
|
+
case 'status': this.status(message); break;
|
|
139
|
+
case 'session_state_changed': this.session(message.state); break;
|
|
140
|
+
case 'api_retry': this.event('api_retry'); break;
|
|
141
|
+
case 'permission_denied': this.event('permission_denied', activityName(message.tool_name), this.tasks.known(message.agent_id)); break;
|
|
142
|
+
default: this.tasks.observe(message); this.settle(message); break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private init(sessionId: string): void {
|
|
147
|
+
this.state.sessionId = sessionId;
|
|
148
|
+
this.ready = true;
|
|
149
|
+
this.turn();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private status(message: Extract<System, { subtype: 'status' }>): void {
|
|
153
|
+
const compacting = message.status === 'compacting';
|
|
154
|
+
if (compacting && !this.compacting) this.event('compacting');
|
|
155
|
+
this.compacting = compacting;
|
|
156
|
+
if (message.compact_result === 'success') this.event('compacted');
|
|
157
|
+
else if (message.compact_result === 'failed') {
|
|
158
|
+
this.state.lastError = 'Conversation compaction failed.';
|
|
159
|
+
this.event('compact_failed');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private session(state: 'idle' | 'running' | 'requires_action'): void {
|
|
164
|
+
if (state === 'running') this.turn();
|
|
165
|
+
else if (state === 'idle') {
|
|
166
|
+
this.busy = false;
|
|
167
|
+
this.state.mainStartedAt = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private turn(): void {
|
|
172
|
+
this.busy = true;
|
|
173
|
+
if (this.state.mainStartedAt !== null) return;
|
|
174
|
+
this.state.mainStartedAt = Date.now();
|
|
175
|
+
this.event('turn_started');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private settle(message: System): void {
|
|
179
|
+
if (message.subtype === 'task_notification' && message.tool_use_id !== undefined) this.tools.delete(message.tool_use_id);
|
|
180
|
+
for (const [id, tool] of this.tools) {
|
|
181
|
+
tool.taskId ??= this.tasks.taskFor(tool.parent);
|
|
182
|
+
if (this.tasks.isHidden(tool.parent) || this.taskHiddenOrFinished(tool.taskId)) this.tools.delete(id);
|
|
183
|
+
}
|
|
184
|
+
for (const [id, approval] of this.approvals) if (this.taskHiddenOrFinished(approval.taskId)) this.approvals.delete(id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private taskHiddenOrFinished(id: string | null): boolean {
|
|
188
|
+
return id !== null && (this.tasks.isHidden(id) || this.tasks.isFinished(id));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private taskEvent(kind: RunnerEventKind, tool: string | null, taskId: string | null): void {
|
|
192
|
+
if (kind === 'task_failed') this.state.lastError = 'A background task failed. See Conversations and Terminal.';
|
|
193
|
+
this.event(kind, tool, taskId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private result(message: Extract<SDKMessage, { type: 'result' }>): void {
|
|
197
|
+
this.busy = (message.queued_turn_count ?? 0) > 0;
|
|
198
|
+
this.compacting = false;
|
|
199
|
+
this.state.mainStartedAt = null;
|
|
200
|
+
for (const [id, tool] of this.tools) if (tool.parent === null) this.tools.delete(id);
|
|
201
|
+
this.failed = message.is_error || message.subtype !== 'success';
|
|
202
|
+
if (!this.failed) {
|
|
203
|
+
this.event('turn_finished');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const code = message.subtype === 'success' ? 'provider_error' : message.subtype;
|
|
207
|
+
this.state.lastError = `SDK turn failed (${code}). Check Conversations and the Model page.`;
|
|
208
|
+
this.event('turn_failed');
|
|
209
|
+
log.error({ reason: code }, 'sdk-runner: turn failed');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private content(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): void {
|
|
213
|
+
const content = message.message.content;
|
|
214
|
+
const parent = message.parent_tool_use_id ?? null;
|
|
215
|
+
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
|
+
}
|
|
218
|
+
|
|
219
|
+
private block(block: Record<string, unknown>, parent: string | null): void {
|
|
220
|
+
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);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private tool(id: string, rawName: unknown, parent: string | null): void {
|
|
225
|
+
if (this.tools.has(id) || this.tools.size >= MAX_TRACKED) return;
|
|
226
|
+
if (this.tasks.isHidden(parent)) return;
|
|
227
|
+
const name = activityName(rawName) ?? 'tool';
|
|
228
|
+
const taskId = this.tasks.taskFor(parent);
|
|
229
|
+
this.tools.set(id, { id, name, taskId, startedAt: Date.now(), parent });
|
|
230
|
+
if (parent === null) this.event('tool_started', name);
|
|
231
|
+
log.info({ tool: name, worker: taskId }, 'sdk-runner: tool started');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private finish(id: string, failed: boolean, parent: string | null): void {
|
|
235
|
+
const tool = this.tools.get(id);
|
|
236
|
+
if (tool === undefined && this.tasks.isHidden(parent)) return;
|
|
237
|
+
this.tools.delete(id);
|
|
238
|
+
const name = tool?.name ?? 'tool';
|
|
239
|
+
if (failed) {
|
|
240
|
+
this.state.lastError = `${name} failed. See its result in Conversations.`;
|
|
241
|
+
this.event('tool_failed', name, tool?.taskId ?? null);
|
|
242
|
+
log.warn({ tool: name }, 'sdk-runner: tool failed');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (tool?.parent === null) this.event('tool_finished', name);
|
|
246
|
+
log.info({ tool: name }, 'sdk-runner: tool finished');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private event(kind: RunnerEventKind, tool: string | null = null, taskId: string | null = null): void {
|
|
250
|
+
this.state.events.unshift({ at: Date.now(), kind, tool, taskId });
|
|
251
|
+
if (this.state.events.length > MAX_EVENTS) this.state.events.length = MAX_EVENTS;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private phaseOf(approval: boolean, working: boolean): RunnerPhase {
|
|
255
|
+
if (!this.ready) return 'starting';
|
|
256
|
+
if (this.compacting) return 'compacting';
|
|
257
|
+
if (approval) return 'approval';
|
|
258
|
+
if (working) return 'working';
|
|
259
|
+
return this.failed ? 'error' : 'idle';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private sync(): void {
|
|
263
|
+
const tools = [...this.tools.values()];
|
|
264
|
+
this.state.tools = [...new Set(tools.map((tool) => tool.name))].slice(0, MAX_TOOLS);
|
|
265
|
+
this.state.activeTools = tools.slice(0, MAX_TOOLS).map(({ id, name, taskId, startedAt, parent }) => ({ id, name, taskId, startedAt, worker: parent !== null }));
|
|
266
|
+
this.state.tasks = this.tasks.snapshot();
|
|
267
|
+
this.state.workers = this.tasks.running;
|
|
268
|
+
this.state.approvals = this.approvals.size;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private update(): void {
|
|
272
|
+
this.sync();
|
|
273
|
+
const main = this.busy || this.state.pending > 0;
|
|
274
|
+
const approvals = [...this.approvals.values()];
|
|
275
|
+
this.state.mainPhase = this.phaseOf(approvals.some((approval) => !approval.worker), main || [...this.tools.values()].some((tool) => tool.parent === null));
|
|
276
|
+
this.state.phase = this.phaseOf(approvals.length > 0, main || this.tools.size > 0 || this.state.workers > 0);
|
|
277
|
+
this.flush();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private flush(force = false): void {
|
|
281
|
+
const next = JSON.stringify({ ...this.state, updatedAt: 0 });
|
|
282
|
+
if (!force && next === this.lastWritten) return;
|
|
283
|
+
this.state.updatedAt = Date.now();
|
|
284
|
+
try {
|
|
285
|
+
writeAtomic(this.path, `${JSON.stringify(this.state)}\n`, 0o600);
|
|
286
|
+
this.lastWritten = next;
|
|
287
|
+
this.writeFailed = false;
|
|
288
|
+
} catch {
|
|
289
|
+
if (!this.writeFailed) log.error('sdk-runner: could not save activity status');
|
|
290
|
+
this.writeFailed = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { failureSummary, type Activity } from './activity.js';
|
|
4
|
+
import { approvalsThrough } from './approvals.js';
|
|
5
|
+
import type { RunnerConfig } from './config.js';
|
|
6
|
+
import { MetroLink } from './link.js';
|
|
7
|
+
import { Runner, runnerOptions } from './runner.js';
|
|
8
|
+
import { SessionStore } from './session-store.js';
|
|
9
|
+
import { claimRunner } from './singleton.js';
|
|
10
|
+
import type { SpeechSink } from './speech.js';
|
|
11
|
+
import { metroTools, type MetroTools } from './tool-proxy.js';
|
|
12
|
+
|
|
13
|
+
export interface RunningAgent {
|
|
14
|
+
runner: Runner;
|
|
15
|
+
link: MetroLink;
|
|
16
|
+
done: Promise<void>;
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AppHooks {
|
|
21
|
+
speech: SpeechSink;
|
|
22
|
+
lost(reason: string): void;
|
|
23
|
+
observe?: (message: SDKMessage) => void;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
compactAt?: number;
|
|
26
|
+
activity?: Activity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Promise<void>): Promise<RunningAgent> {
|
|
30
|
+
const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
|
|
31
|
+
let tools: MetroTools | null = null;
|
|
32
|
+
const runner = new Runner({ store, sink: hooks.speech, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
|
|
33
|
+
const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
|
|
34
|
+
channel: (event) => { runner.chat(event); },
|
|
35
|
+
toolsChanged: () => { tools?.changed(); },
|
|
36
|
+
model: (model) => {
|
|
37
|
+
log.info({ model }, 'sdk-runner: the Model page picked a model');
|
|
38
|
+
runner.switchModel(model).catch((err: unknown) => {
|
|
39
|
+
log.warn({ err: errMsg(err) }, 'sdk-runner: could not switch the model');
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
lost: (reason) => { hooks.lost(reason); },
|
|
43
|
+
});
|
|
44
|
+
let stopped = false;
|
|
45
|
+
const stop = async (): Promise<void> => {
|
|
46
|
+
if (stopped) return;
|
|
47
|
+
stopped = true;
|
|
48
|
+
runner.close();
|
|
49
|
+
await link.close().catch(() => { log.debug('sdk-runner: closing the metro link failed'); });
|
|
50
|
+
hooks.activity?.stop();
|
|
51
|
+
await release();
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
tools = metroTools(link);
|
|
55
|
+
const resume = store.resumable();
|
|
56
|
+
log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
|
|
57
|
+
runner.start(runnerOptions(cfg, tools, approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }), resume, hooks.env));
|
|
58
|
+
return { runner, link, done: runner.run(hooks.observe), stop };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
runner.close();
|
|
61
|
+
await link.close().catch(() => { log.debug('sdk-runner: closing the metro link failed'); });
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function startAgent(cfg: RunnerConfig, hooks: AppHooks): Promise<RunningAgent> {
|
|
67
|
+
const release = await claimRunner(cfg.cwd);
|
|
68
|
+
try {
|
|
69
|
+
hooks.activity?.start();
|
|
70
|
+
return await connected(cfg, hooks, release);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
hooks.activity?.fail(failureSummary(err));
|
|
73
|
+
hooks.activity?.stop();
|
|
74
|
+
await release().catch(() => { log.warn('sdk-runner: releasing process ownership failed'); });
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { approvalId } from '@metro-labs/core/ids';
|
|
2
|
+
import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { log } from '@metro-labs/core/log';
|
|
4
|
+
import type { Asker } from './link.js';
|
|
5
|
+
|
|
6
|
+
const DENIED = 'The owner did not approve this.';
|
|
7
|
+
|
|
8
|
+
export interface ApprovalAsk { tool: string; worker: string | null }
|
|
9
|
+
export type ApprovalWatch = (id: string, ask: ApprovalAsk | null) => void;
|
|
10
|
+
|
|
11
|
+
export function approvalsThrough(asker: Asker, waiting: ApprovalWatch = () => undefined): CanUseTool {
|
|
12
|
+
return async (tool, input, options) => {
|
|
13
|
+
const id = approvalId();
|
|
14
|
+
log.info({ tool, id, worker: options.agentID ?? null }, 'sdk-runner: a tool waits for the owner’s approval');
|
|
15
|
+
waiting(id, { tool, worker: options.agentID ?? null });
|
|
16
|
+
const behavior = await asker.ask(
|
|
17
|
+
{
|
|
18
|
+
request_id: id,
|
|
19
|
+
tool_name: tool,
|
|
20
|
+
description: options.description ?? options.title ?? options.decisionReason ?? '',
|
|
21
|
+
input_preview: JSON.stringify(input),
|
|
22
|
+
},
|
|
23
|
+
options.signal,
|
|
24
|
+
).finally(() => { waiting(id, null); });
|
|
25
|
+
log.info({ tool, id, behavior }, 'sdk-runner: the owner answered');
|
|
26
|
+
return behavior === 'allow' ? { behavior, updatedInput: input } : { behavior, message: DENIED };
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
|
|
3
|
+
const KEY = /^[A-Za-z0-9_]+$/;
|
|
4
|
+
|
|
5
|
+
const attr = (value: string): string => value.replace(/&/g, '&').replace(/"/g, '"');
|
|
6
|
+
|
|
7
|
+
const shown = (value: unknown): value is string | number | boolean =>
|
|
8
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
9
|
+
|
|
10
|
+
export function channelText(source: string, content: string, meta: Record<string, unknown>): string {
|
|
11
|
+
const attrs = Object.entries(meta)
|
|
12
|
+
.filter((entry): entry is [string, string | number | boolean] => KEY.test(entry[0]) && shown(entry[1]))
|
|
13
|
+
.map(([key, value]) => ` ${key}="${attr(String(value))}"`)
|
|
14
|
+
.join('');
|
|
15
|
+
return `<channel source="${attr(source)}"${attrs}>\n${content}\n</channel>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChannelEvent {
|
|
19
|
+
content: string;
|
|
20
|
+
meta: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function channelEvent(params: unknown): ChannelEvent | null {
|
|
24
|
+
if (!isRecord(params) || typeof params.content !== 'string') return null;
|
|
25
|
+
return { content: params.content, meta: isRecord(params.meta) ? params.meta : {} };
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
|
|
5
|
+
|
|
6
|
+
export interface RunnerConfig {
|
|
7
|
+
mcpUrl: string;
|
|
8
|
+
key: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
claude: string | null;
|
|
11
|
+
model: string | null;
|
|
12
|
+
permissionMode: PermissionMode;
|
|
13
|
+
prompt: string | null;
|
|
14
|
+
statePath: string;
|
|
15
|
+
claudeDir: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const given = (value: string | undefined): string | null => {
|
|
19
|
+
const trimmed = value?.trim() ?? '';
|
|
20
|
+
return trimmed === '' ? null : trimmed;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function required(env: NodeJS.ProcessEnv, name: string): string {
|
|
24
|
+
const value = given(env[name]);
|
|
25
|
+
if (value === null) throw new Error(`${name} is not set; start the runner with metro agent`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function realDir(dir: string): string {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(dir);
|
|
32
|
+
} catch {
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function runnerConfig(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): RunnerConfig {
|
|
38
|
+
const home = given(env.HOME) ?? homedir();
|
|
39
|
+
return {
|
|
40
|
+
mcpUrl: required(env, 'METRO_RUNNER_MCP_URL'),
|
|
41
|
+
key: required(env, 'METRO_AGENT_KEY'),
|
|
42
|
+
cwd: realDir(cwd),
|
|
43
|
+
claude: given(env.METRO_RUNNER_CLAUDE),
|
|
44
|
+
model: given(env.METRO_RUNNER_MODEL),
|
|
45
|
+
permissionMode: env.METRO_RUNNER_PERMISSION_MODE === 'bypass' ? 'bypassPermissions' : 'auto',
|
|
46
|
+
prompt: given(env.METRO_RUNNER_PROMPT),
|
|
47
|
+
statePath: given(env.METRO_RUNNER_STATE) ?? join(home, '.metro', 'agent-session.json'),
|
|
48
|
+
claudeDir: given(env.CLAUDE_CONFIG_DIR) ?? join(home, '.claude'),
|
|
49
|
+
};
|
|
50
|
+
}
|