@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.
Files changed (79) hide show
  1. package/dist/agent.js +128 -0
  2. package/dist/background.js +19 -1
  3. package/dist/claude.js +11 -7
  4. package/dist/cli.js +8 -0
  5. package/dist/route.js +17 -9
  6. package/dist/runtime-install.js +3 -2
  7. package/package.json +1 -1
  8. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  9. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  10. package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
  11. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
  24. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
  29. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
  30. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
  32. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
  37. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
  38. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
  39. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
  40. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
  41. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
  42. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
  43. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
  44. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
  45. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
  46. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
  47. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
  48. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
  49. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
  50. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
  51. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
  52. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
  53. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
  54. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
  55. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
  56. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
  57. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
  58. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
  59. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
  60. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
  61. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
  62. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
  63. package/runtime/sdk-runner/runner.json +10 -0
  64. package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
  65. package/runtime/sdk-runner/src/activity.ts +293 -0
  66. package/runtime/sdk-runner/src/app.ts +77 -0
  67. package/runtime/sdk-runner/src/approvals.ts +28 -0
  68. package/runtime/sdk-runner/src/channel-text.ts +26 -0
  69. package/runtime/sdk-runner/src/config.ts +50 -0
  70. package/runtime/sdk-runner/src/inbox.ts +94 -0
  71. package/runtime/sdk-runner/src/link.ts +130 -0
  72. package/runtime/sdk-runner/src/main.ts +44 -0
  73. package/runtime/sdk-runner/src/rules.ts +11 -0
  74. package/runtime/sdk-runner/src/runner.ts +251 -0
  75. package/runtime/sdk-runner/src/session-store.ts +71 -0
  76. package/runtime/sdk-runner/src/session-watch.ts +111 -0
  77. package/runtime/sdk-runner/src/singleton.ts +15 -0
  78. package/runtime/sdk-runner/src/speech.ts +79 -0
  79. package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
3
+
4
+ export type Uuid = ReturnType<typeof randomUUID>;
5
+ export type InputKind = 'chat' | 'call' | 'note';
6
+ export type Priority = 'now' | 'next' | 'later';
7
+
8
+ export interface Unanswered {
9
+ text: string;
10
+ at: number;
11
+ }
12
+
13
+ const KINDS_MAX = 2_000;
14
+ const LEDGER_MAX = 100;
15
+ const LEDGER_AGE_MS = 6 * 60 * 60_000;
16
+
17
+ export class Inbox implements AsyncIterable<SDKUserMessage> {
18
+ private readonly queue: SDKUserMessage[] = [];
19
+ private readonly kinds = new Map<string, InputKind>();
20
+ private readonly ledger = new Map<string, Unanswered>();
21
+ private wake: (() => void) | null = null;
22
+ private closed = false;
23
+
24
+ constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined) {}
25
+
26
+ push(kind: InputKind, text: string, priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
27
+ this.mark(uuid, kind);
28
+ if (kind === 'chat') this.remember(uuid, text, at);
29
+ this.queue.push({
30
+ type: 'user',
31
+ message: { role: 'user', content: text },
32
+ parent_tool_use_id: null,
33
+ uuid,
34
+ origin: { kind: 'channel', server: 'metro' },
35
+ ...(priority === undefined ? {} : { priority }),
36
+ });
37
+ this.wake?.();
38
+ return uuid;
39
+ }
40
+
41
+ mark(uuid: string, kind: InputKind): void {
42
+ this.kinds.set(uuid, kind);
43
+ if (this.kinds.size <= KINDS_MAX) return;
44
+ const oldest = this.kinds.keys().next().value;
45
+ if (oldest !== undefined) this.kinds.delete(oldest);
46
+ }
47
+
48
+ kindOf(uuid: string): InputKind | undefined {
49
+ return this.kinds.get(uuid);
50
+ }
51
+
52
+ consumed(uuids: readonly string[]): void {
53
+ let changed = false;
54
+ for (const uuid of uuids) changed = this.ledger.delete(uuid) || changed;
55
+ if (changed) this.changed(this.unanswered());
56
+ }
57
+
58
+ unanswered(now = Date.now()): Unanswered[] {
59
+ return [...this.ledger.values()].filter((entry) => now - entry.at < LEDGER_AGE_MS);
60
+ }
61
+
62
+ close(): void {
63
+ this.closed = true;
64
+ this.wake?.();
65
+ }
66
+
67
+ async *[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
68
+ while (!this.closed) {
69
+ const next = this.queue.shift();
70
+ if (next !== undefined) {
71
+ yield next;
72
+ continue;
73
+ }
74
+ await new Promise<void>((resolve) => {
75
+ this.wake = resolve;
76
+ });
77
+ this.wake = null;
78
+ }
79
+ }
80
+
81
+ again(left: readonly Unanswered[]): void {
82
+ for (const entry of left) this.push('chat', entry.text, undefined, randomUUID(), entry.at);
83
+ }
84
+
85
+ private remember(uuid: string, text: string, at: number): void {
86
+ this.ledger.set(uuid, { text, at });
87
+ while (this.ledger.size > LEDGER_MAX) {
88
+ const oldest = this.ledger.keys().next().value;
89
+ if (oldest === undefined) break;
90
+ this.ledger.delete(oldest);
91
+ }
92
+ this.changed(this.unanswered());
93
+ }
94
+ }
@@ -0,0 +1,130 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { CallToolResultSchema, type CallToolRequest, type CallToolResult, type ListToolsResult, type Notification } from '@modelcontextprotocol/sdk/types.js';
4
+ import { errMsg, log } from '@metro-labs/core/log';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { channelEvent, type ChannelEvent } from './channel-text.js';
7
+
8
+ const CHANNEL = 'notifications/claude/channel';
9
+ const ASK = 'notifications/claude/channel/permission_request';
10
+ const ANSWER = 'notifications/claude/channel/permission';
11
+ const TOOLS_CHANGED = 'notifications/tools/list_changed';
12
+ const MODEL = 'notifications/metro/model';
13
+ const TOOL_TIMEOUT_MS = 30 * 60_000;
14
+ const RECONNECT = { initialReconnectionDelay: 500, maxReconnectionDelay: 5_000, reconnectionDelayGrowFactor: 1.5, maxRetries: 60 };
15
+ const GAVE_UP = 'Maximum reconnection attempts';
16
+
17
+ export type Behavior = 'allow' | 'deny';
18
+
19
+ const modelIn = (params: Record<string, unknown>): string | null => (typeof params.model === 'string' && params.model !== '' ? params.model : null);
20
+
21
+ export interface PermissionAsk {
22
+ request_id: string;
23
+ tool_name: string;
24
+ description: string;
25
+ input_preview: string;
26
+ }
27
+
28
+ export interface LinkEvents {
29
+ channel(event: ChannelEvent): void;
30
+ toolsChanged(): void;
31
+ model(model: string | null): void;
32
+ lost(reason: string): void;
33
+ }
34
+
35
+ export interface Asker {
36
+ ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior>;
37
+ }
38
+
39
+ export class MetroLink implements Asker {
40
+ private readonly waiting = new Map<string, (behavior: Behavior) => void>();
41
+
42
+ private constructor(
43
+ private readonly client: Client,
44
+ private readonly events: LinkEvents,
45
+ ) {}
46
+
47
+ static async open(url: string, key: string, events: LinkEvents): Promise<MetroLink> {
48
+ const client = new Client({ name: 'metro-sdk-runner', version: '0.1.0' });
49
+ const link = new MetroLink(client, events);
50
+ client.fallbackNotificationHandler = (notification): Promise<void> => {
51
+ link.notified(notification);
52
+ return Promise.resolve();
53
+ };
54
+ client.onerror = (err): void => {
55
+ link.failed(err);
56
+ };
57
+ const transport = new StreamableHTTPClientTransport(new URL(url), {
58
+ requestInit: { headers: { authorization: `Bearer ${key}` } },
59
+ reconnectionOptions: RECONNECT,
60
+ });
61
+ await client.connect(transport);
62
+ return link;
63
+ }
64
+
65
+ get instructions(): string | undefined {
66
+ return this.client.getInstructions();
67
+ }
68
+
69
+ listTools(): Promise<ListToolsResult> {
70
+ return this.client.listTools();
71
+ }
72
+
73
+ callTool(params: CallToolRequest['params'], signal: AbortSignal): Promise<CallToolResult> {
74
+ return this.client.request({ method: 'tools/call', params }, CallToolResultSchema, {
75
+ signal,
76
+ timeout: TOOL_TIMEOUT_MS,
77
+ resetTimeoutOnProgress: true,
78
+ });
79
+ }
80
+
81
+ ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior> {
82
+ return new Promise((resolve) => {
83
+ const settle = (behavior: Behavior): void => {
84
+ this.waiting.delete(ask.request_id);
85
+ resolve(behavior);
86
+ };
87
+ this.waiting.set(ask.request_id, settle);
88
+ signal.addEventListener('abort', () => {
89
+ settle('deny');
90
+ }, { once: true });
91
+ this.client.notification({ method: ASK, params: { ...ask } }).catch((err: unknown) => {
92
+ log.warn({ err: errMsg(err), id: ask.request_id }, 'sdk-runner: could not hand an approval to metro');
93
+ settle('deny');
94
+ });
95
+ });
96
+ }
97
+
98
+ async close(): Promise<void> {
99
+ for (const settle of [...this.waiting.values()]) settle('deny');
100
+ await this.client.close();
101
+ }
102
+
103
+ private notified(notification: Notification): void {
104
+ const params = isRecord(notification.params) ? notification.params : {};
105
+ if (notification.method === CHANNEL) {
106
+ const event = channelEvent(params);
107
+ if (event !== null) this.events.channel(event);
108
+ } else if (notification.method === ANSWER) {
109
+ this.answered(params);
110
+ } else if (notification.method === TOOLS_CHANGED) {
111
+ this.events.toolsChanged();
112
+ } else if (notification.method === MODEL) {
113
+ this.events.model(modelIn(params));
114
+ }
115
+ }
116
+
117
+ private answered(params: Record<string, unknown>): void {
118
+ const id = typeof params.request_id === 'string' ? params.request_id : '';
119
+ this.waiting.get(id)?.(params.behavior === 'allow' ? 'allow' : 'deny');
120
+ }
121
+
122
+ private failed(err: Error): void {
123
+ const reason = errMsg(err);
124
+ if (reason.includes(GAVE_UP)) {
125
+ this.events.lost(reason);
126
+ return;
127
+ }
128
+ log.debug({ err: reason }, 'sdk-runner: the metro link reported an error');
129
+ }
130
+ }
@@ -0,0 +1,44 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { log } from '@metro-labs/core/log';
3
+ import { Activity, failureSummary } from './activity.js';
4
+ import { startAgent, type RunningAgent } from './app.js';
5
+ import { runnerConfig } from './config.js';
6
+
7
+ let agent: RunningAgent | null = null;
8
+ let activity: Activity | null = null;
9
+
10
+ async function exit(code: number): Promise<never> {
11
+ await agent?.stop();
12
+ process.exit(code);
13
+ }
14
+
15
+ const exitWith = (code: number) => (): void => {
16
+ exit(code).catch(() => process.exit(code));
17
+ };
18
+
19
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, exitWith(0));
20
+
21
+ try {
22
+ const cfg = runnerConfig();
23
+ activity = new Activity(join(dirname(cfg.statePath), 'agent-status.json'));
24
+ agent = await startAgent(cfg, {
25
+ activity,
26
+ speech: {
27
+ say: (text) => { log.debug({ chars: text.length }, 'sdk-runner: speech'); },
28
+ done: () => { log.debug('sdk-runner: speech done'); },
29
+ },
30
+ lost: () => {
31
+ activity?.fail('The SDK lost its Metro connection. The session will restart.');
32
+ exitWith(1)();
33
+ },
34
+ });
35
+ try {
36
+ await agent.done;
37
+ activity.fail('The Agent SDK session ended unexpectedly.');
38
+ } catch (err) {
39
+ activity.fail(failureSummary(err));
40
+ }
41
+ } catch (err) {
42
+ log.error({ reason: failureSummary(err) }, 'sdk-runner: the Agent SDK session failed to start');
43
+ }
44
+ await exit(1);
@@ -0,0 +1,11 @@
1
+ export const FRONT_RULES = `You are the orchestrator of this agent's Agent SDK chat session. Chat messages (<channel> blocks) and the reports of your background workers arrive in this conversation. Voice calls still use a separate session. Do not claim that chat and calls share context.
2
+ - Be fast. When you can answer a chat message at once, make the reaction and the answer one response of parallel tool calls (react, then send), with no typing signal. Use typing only when the answer will take a while.
3
+ - Stay light: talk, decide and delegate. Real work goes to background workers (Agent tool, subagent_type "worker", run_in_background: true); keep answering while they run, and relay what they report.
4
+ - Text in [square brackets] comes from Metro, not from a person.`;
5
+
6
+ export const callStarted = (where: string): string =>
7
+ `[A voice call started: ${where}. Until it ends, the caller's words arrive as <call> blocks, and your plain text answer to a <call> block is spoken aloud: one to three short sentences, no markdown, lists, links or emoji. A <call> block comes first: start your response with the words for the caller, before any tool call, then finish other work, and write no more text in that response unless the caller needs to hear it. Never answer a <call> block with send. Chat messages that arrive during the call keep their usual handling with the metro tools; your text about them is not spoken. When a worker finishes during the call, tell the caller in a sentence or two. Greet the caller now in one short sentence.]`;
8
+
9
+ export const CALL_ENDED = '[The voice call ended. Nothing about it is posted anywhere unless someone asks.]';
10
+
11
+ export const callWords = (text: string): string => `<call>${text}</call>`;
@@ -0,0 +1,251 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { query, type CanUseTool, type Options, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import type { Activity } from './activity.js';
5
+ import { channelText, type ChannelEvent } from './channel-text.js';
6
+ import type { RunnerConfig } from './config.js';
7
+ import { Inbox, type Unanswered, type Uuid } from './inbox.js';
8
+ import { CALL_ENDED, callStarted, callWords, FRONT_RULES } from './rules.js';
9
+ import type { SessionStore } from './session-store.js';
10
+ import { SessionWatch, startedCommand, uuidsOf } from './session-watch.js';
11
+ import { SpeechRouter, type SpeechSink } from './speech.js';
12
+ import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
13
+
14
+ const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
15
+ const STALL_MS = 2_000;
16
+ export const COMPACT_AT = 120_000;
17
+
18
+ const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
19
+
20
+ export function runnerEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
21
+ const unpinned = Object.fromEntries(Object.entries(env).filter(([name]) => !MODEL_PINS.has(name)));
22
+ return { [STARTUP_WAIT]: '0', ...unpinned, DISABLE_AUTOUPDATER: '1' };
23
+ }
24
+
25
+ export type ModelSettings = Parameters<Query['applyFlagSettings']>[0];
26
+
27
+ export const allowedOnly = (model: string | null): ModelSettings =>
28
+ model === null ? { availableModels: null, enforceAvailableModels: null } : { availableModels: [model], enforceAvailableModels: true };
29
+
30
+ export function runnerOptions(cfg: RunnerConfig, tools: MetroTools, canUseTool: CanUseTool, resume: string | null, env = process.env): Options {
31
+ return {
32
+ ...(cfg.model === null ? {} : { model: cfg.model }),
33
+ cwd: cfg.cwd,
34
+ env: runnerEnv(env),
35
+ ...(cfg.claude === null ? {} : { pathToClaudeCodeExecutable: cfg.claude }),
36
+ settingSources: ['user', 'project', 'local'],
37
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: cfg.prompt === null ? FRONT_RULES : `${cfg.prompt}\n\n${FRONT_RULES}` },
38
+ mcpServers: { [METRO_SERVER]: tools.config },
39
+ permissionMode: cfg.permissionMode,
40
+ allowDangerouslySkipPermissions: cfg.permissionMode === 'bypassPermissions',
41
+ canUseTool,
42
+ includePartialMessages: true,
43
+ extraArgs: { 'system-prompt-snapshot': 'off' },
44
+ ...(resume === null ? {} : { resume }),
45
+ stderr: (text: string) => {
46
+ log.debug({ text: text.slice(0, 500) }, 'sdk-runner: claude stderr');
47
+ },
48
+ };
49
+ }
50
+
51
+ export interface CompactState {
52
+ context: number;
53
+ floor: number | null;
54
+ limit: number;
55
+ busy: boolean;
56
+ }
57
+
58
+ export const compactDue = (s: CompactState): boolean => !s.busy && s.context >= s.limit && s.context - (s.floor ?? 0) >= s.limit / 2;
59
+
60
+ export type OpenSession = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => Query;
61
+
62
+ export interface RunnerParts {
63
+ store: SessionStore;
64
+ sink: SpeechSink;
65
+ readOnly: (tool: string) => boolean;
66
+ compactAt?: number;
67
+ open?: OpenSession;
68
+ activity?: Activity;
69
+ }
70
+
71
+ export class Runner {
72
+ readonly inbox: Inbox;
73
+ private readonly speech: SpeechRouter;
74
+ private readonly watch: SessionWatch;
75
+ private session: Query | null = null;
76
+ private sessionId: string | null = null;
77
+ private compactAsked = false;
78
+ private compactFloor: number | null = null;
79
+ private stall: ReturnType<typeof setTimeout> | null = null;
80
+ private model: string | null = null;
81
+ private switching: Promise<void> = Promise.resolve();
82
+
83
+ constructor(private readonly parts: RunnerParts) {
84
+ this.inbox = new Inbox((unanswered) => {
85
+ this.keep(unanswered);
86
+ });
87
+ this.speech = new SpeechRouter((uuid) => this.inbox.kindOf(uuid), {
88
+ say: (text) => {
89
+ this.clearStall();
90
+ parts.sink.say(text);
91
+ },
92
+ done: () => {
93
+ parts.sink.done();
94
+ },
95
+ });
96
+ this.watch = new SessionWatch(parts.readOnly);
97
+ const again = parts.store.unanswered();
98
+ this.inbox.again(again);
99
+ if (again.length > 0) log.info({ count: again.length }, 'sdk-runner: chat messages the last session never read go in again');
100
+ }
101
+
102
+ get id(): string | null {
103
+ return this.sessionId;
104
+ }
105
+
106
+ start(options: Options): Query {
107
+ if (this.session !== null) throw new Error('the runner was already started');
108
+ this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
109
+ this.model = options.model ?? null;
110
+ this.queueModel(this.model);
111
+ return this.session;
112
+ }
113
+
114
+ switchModel(model: string | null): Promise<void> {
115
+ this.queueModel(model);
116
+ return this.switching;
117
+ }
118
+
119
+ async run(observe?: (message: SDKMessage) => void): Promise<void> {
120
+ if (this.session === null) throw new Error('the runner was not started');
121
+ for await (const message of this.session) {
122
+ const m: Record<string, unknown> = { ...message };
123
+ const started = startedCommand(m);
124
+ this.inbox.consumed([...(uuidsOf(m) ?? []), ...(started === null ? [] : [started])]);
125
+ this.watch.observe(m);
126
+ this.speech.observe(message);
127
+ this.note(m);
128
+ this.parts.activity?.observe(message);
129
+ observe?.(message);
130
+ }
131
+ }
132
+
133
+ chat(event: ChannelEvent): Uuid {
134
+ return this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
135
+ }
136
+
137
+ callStarted(where: string): Uuid {
138
+ this.speech.setCallLive(true);
139
+ return this.callPush(callStarted(where));
140
+ }
141
+
142
+ heard(text: string): Uuid {
143
+ return this.callPush(callWords(text));
144
+ }
145
+
146
+ callEnded(): Uuid {
147
+ this.speech.setCallLive(false);
148
+ this.clearStall();
149
+ return this.inbox.push('note', CALL_ENDED);
150
+ }
151
+
152
+ close(): void {
153
+ this.clearStall();
154
+ this.inbox.close();
155
+ this.session?.close();
156
+ }
157
+
158
+ private callPush(text: string): Uuid {
159
+ const uuid = randomUUID();
160
+ this.inbox.mark(uuid, 'call');
161
+ this.armStall();
162
+ if (this.watch.compacting) log.info('sdk-runner: a call message waits for the compaction to finish');
163
+ this.watch
164
+ .whenNotCompacting()
165
+ .then(() => this.watch.whenNotWriting())
166
+ .then(() => this.inbox.push('call', text, 'now', uuid))
167
+ .catch((err: unknown) => {
168
+ log.warn({ err: errMsg(err) }, 'sdk-runner: could not hand a call message to the session');
169
+ });
170
+ return uuid;
171
+ }
172
+
173
+ private armStall(): void {
174
+ this.clearStall();
175
+ if (this.parts.sink.stalled === undefined) return;
176
+ this.stall = setTimeout(() => {
177
+ this.stall = null;
178
+ if (this.speech.callLive && !this.speech.talking) this.parts.sink.stalled?.();
179
+ }, STALL_MS);
180
+ this.stall.unref();
181
+ }
182
+
183
+ private clearStall(): void {
184
+ if (this.stall !== null) clearTimeout(this.stall);
185
+ this.stall = null;
186
+ }
187
+
188
+ private queueModel(model: string | null): void {
189
+ this.switching = this.switching
190
+ .then(() => this.applyModel(model))
191
+ .catch((err: unknown) => {
192
+ this.parts.activity?.fail('The SDK could not apply the selected model. Check the Model page.');
193
+ log.warn({ model, err: errMsg(err) }, 'sdk-runner: the session could not take the model the Model page picked');
194
+ });
195
+ }
196
+
197
+ private async applyModel(model: string | null): Promise<void> {
198
+ const session = this.session;
199
+ if (session === null) return;
200
+ await session.applyFlagSettings(allowedOnly(model));
201
+ this.parts.activity?.connected();
202
+ if (model === this.model) return;
203
+ const was = this.model;
204
+ await session.setModel(model ?? undefined);
205
+ this.model = model;
206
+ log.info({ was, now: model }, 'sdk-runner: the session thinks with the new model from now on, with no restart');
207
+ }
208
+
209
+ private keep(unanswered: Unanswered[]): void {
210
+ this.parts.activity?.pending(unanswered.length);
211
+ try {
212
+ this.parts.store.saveUnanswered(unanswered);
213
+ } catch (err) {
214
+ log.warn({ err: errMsg(err) }, 'sdk-runner: could not keep the unanswered chat messages');
215
+ }
216
+ }
217
+
218
+ private note(m: Record<string, unknown>): void {
219
+ if (m.type === 'system' && m.subtype === 'init') this.started(m);
220
+ else if (m.type === 'result') this.turnDone(m);
221
+ else if (m.type === 'system' && (m.subtype === 'task_started' || m.subtype === 'task_notification')) log.info({ task: m.task_id, status: m.status ?? 'started' }, `sdk-runner: worker ${m.subtype}`);
222
+ else if (m.type === 'system' && m.subtype === 'compact_boundary') {
223
+ this.compactAsked = false;
224
+ this.compactFloor = null;
225
+ log.info({ compact: m.compact_metadata }, 'sdk-runner: the conversation was compacted');
226
+ }
227
+ }
228
+
229
+ private turnDone(m: Record<string, unknown>): void {
230
+ log.info({ turnMs: m.duration_ms, context: this.watch.context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
231
+ const limit = this.parts.compactAt ?? COMPACT_AT;
232
+ const context = this.watch.context;
233
+ if (context > 0) this.compactFloor ??= context;
234
+ if (!compactDue({ context, floor: this.compactFloor, limit, busy: this.compactAsked || this.speech.callLive || this.watch.compacting })) return;
235
+ this.compactAsked = true;
236
+ log.info({ context: this.watch.context }, 'sdk-runner: compacting while no call is live');
237
+ this.inbox.push('note', '/compact');
238
+ }
239
+
240
+ private started(m: Record<string, unknown>): void {
241
+ const id = typeof m.session_id === 'string' ? m.session_id : null;
242
+ if (id === null || id === this.sessionId) return;
243
+ this.sessionId = id;
244
+ log.info({ session: id, model: m.model, claude: m.claude_code_version }, 'sdk-runner: session up');
245
+ try {
246
+ this.parts.store.save(id);
247
+ } catch (err) {
248
+ log.warn({ err: errMsg(err) }, 'sdk-runner: could not keep the session id for the next start');
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { writeAtomic } from '@metro-labs/core/secure-fs';
5
+ import type { Unanswered } from './inbox.js';
6
+
7
+ 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
+ export const projectFolder = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-');
10
+
11
+ interface Stored {
12
+ sessionId: string | null;
13
+ unanswered: Unanswered[];
14
+ }
15
+
16
+ const entry = (raw: unknown): Unanswered | null =>
17
+ isRecord(raw) && typeof raw.text === 'string' && typeof raw.at === 'number' ? { text: raw.text, at: raw.at } : null;
18
+
19
+ const UNREADABLE = 'The saved Agent SDK state cannot be read. Restore it before starting.';
20
+
21
+ function stored(raw: unknown): Stored {
22
+ if (!isRecord(raw)) throw new Error(UNREADABLE);
23
+ const id = raw.sessionId ?? null;
24
+ if (id !== null && (typeof id !== 'string' || !SESSION_ID.test(id))) throw new Error(UNREADABLE);
25
+ const list = raw.unanswered === undefined ? [] : raw.unanswered;
26
+ if (!Array.isArray(list)) throw new Error(UNREADABLE);
27
+ const unanswered = list.map(entry);
28
+ if (!unanswered.every((e): e is Unanswered => e !== null)) throw new Error(UNREADABLE);
29
+ return { sessionId: id, unanswered };
30
+ }
31
+
32
+ export class SessionStore {
33
+ constructor(
34
+ private readonly path: string,
35
+ private readonly claudeDir: string,
36
+ private readonly cwd: string,
37
+ ) {}
38
+
39
+ resumable(): string | null {
40
+ const id = this.read().sessionId;
41
+ if (id === null) return null;
42
+ return existsSync(join(this.claudeDir, 'projects', projectFolder(this.cwd), `${id}.jsonl`)) ? id : null;
43
+ }
44
+
45
+ unanswered(): Unanswered[] {
46
+ return this.read().unanswered;
47
+ }
48
+
49
+ save(id: string): void {
50
+ const stored = this.read();
51
+ if (!SESSION_ID.test(id) || stored.sessionId === id) return;
52
+ this.write({ ...stored, sessionId: id });
53
+ }
54
+
55
+ saveUnanswered(unanswered: Unanswered[]): void {
56
+ this.write({ ...this.read(), unanswered });
57
+ }
58
+
59
+ private read(): Stored {
60
+ try {
61
+ return stored(JSON.parse(readFileSync(this.path, 'utf8')));
62
+ } catch (err) {
63
+ if (isRecord(err) && err.code === 'ENOENT') return { sessionId: null, unanswered: [] };
64
+ throw new Error(UNREADABLE);
65
+ }
66
+ }
67
+
68
+ private write(stored: Stored): void {
69
+ writeAtomic(this.path, `${JSON.stringify({ ...stored, savedAt: new Date().toISOString() }, null, 2)}\n`, 0o600);
70
+ }
71
+ }