@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308

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 (64) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  35. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  40. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  42. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  43. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  44. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/runner.json +1 -1
  47. package/dist/background.js +0 -141
  48. package/dist/claude.js +0 -204
  49. package/dist/mcp-config.js +0 -27
  50. package/dist/native-recovery-scan.js +0 -246
  51. package/dist/native-recovery-state.js +0 -154
  52. package/dist/native-recovery.js +0 -164
  53. package/dist/onboarding.js +0 -44
  54. package/dist/voice.js +0 -56
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  57. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  58. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  59. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  60. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  61. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  62. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  63. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  64. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
package/dist/claude.js DELETED
@@ -1,204 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { markOnboardingDone, seedChannels } from './onboarding.js';
3
- import { writeMcpConfig } from './mcp-config.js';
4
- import { currentRoute, harnessRunner, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
- import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
- import { localAgent } from './local.js';
7
- import { PROVIDER_FLAGS } from './provider-flags.js';
8
- import { localPort, localUrl } from './runtime.js';
9
- import { keepInSession, resumeSessionId, takeBackConversation } from './background.js';
10
- import { nativeRecoveryArgs } from './native-recovery.js';
11
- const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
12
- const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
13
- export const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
14
- const KEY_HEADER = 'x-metro-key';
15
- const PROBE_MS = 3_000;
16
- const AUTH_STATUS_MS = 20_000;
17
- const KILL_GRACE_MS = 30_000;
18
- export const claudeArgs = (extra, mcpConfig, mode = 'auto', prompt = null) => [
19
- ...CHANNEL_FLAGS,
20
- '--permission-mode',
21
- PERMISSION_MODE_FLAG[mode],
22
- ...FRESH_PROMPT_FLAGS,
23
- ...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
24
- ...(prompt === null ? [] : ['--append-system-prompt', prompt]),
25
- ...extra,
26
- ];
27
- const set = (env, name) => (env[name] ?? '').trim() !== '';
28
- export function pinnedBy(env) {
29
- if (set(env, 'ANTHROPIC_BASE_URL'))
30
- return 'ANTHROPIC_BASE_URL';
31
- return PROVIDER_FLAGS.find((flag) => set(env, flag)) ?? null;
32
- }
33
- export function gatewayEnv(base, agentKey, port) {
34
- if (agentKey === null || pinnedBy(base) !== null)
35
- return base;
36
- const own = (base.ANTHROPIC_CUSTOM_HEADERS ?? '').trim();
37
- const mine = `${KEY_HEADER}: ${agentKey}`;
38
- return {
39
- ...base,
40
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}/gateway`,
41
- ANTHROPIC_CUSTOM_HEADERS: own === '' ? mine : `${own}\n${mine}`,
42
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
43
- };
44
- }
45
- export const channelEnv = (env) => set(env, 'MCP_PROTOCOL_NEGOTIATION') ? env : { ...env, MCP_PROTOCOL_NEGOTIATION: 'legacy' };
46
- export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
47
- const RM_PROMPT_OFF = 'CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT';
48
- export const rmPromptEnv = (env) => set(env, RM_PROMPT_OFF) ? env : { ...env, [RM_PROMPT_OFF]: '1' };
49
- const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
50
- export function credentialEnv(env, agentKey, signedIn) {
51
- if (signedIn || OWN_CREDENTIALS.some((name) => set(env, name)))
52
- return env;
53
- return { ...env, ANTHROPIC_AUTH_TOKEN: agentKey };
54
- }
55
- function claudeSignedIn() {
56
- const run = spawnSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: AUTH_STATUS_MS });
57
- if (run.error !== undefined || typeof run.stdout !== 'string')
58
- return false;
59
- try {
60
- const parsed = JSON.parse(run.stdout);
61
- return parsed.authenticated === true || parsed.loggedIn === true || typeof parsed.email === 'string' || typeof parsed.organization === 'string';
62
- }
63
- catch {
64
- return false;
65
- }
66
- }
67
- export function servingDaemon(body) {
68
- if (typeof body !== 'object' || body === null)
69
- return false;
70
- const mode = body;
71
- return mode.mode === 'local' && mode.stopped !== true;
72
- }
73
- async function daemonServing(base = localUrl()) {
74
- try {
75
- const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
76
- return res.ok && servingDaemon(await res.json());
77
- }
78
- catch {
79
- return false;
80
- }
81
- }
82
- export function agentKey(agent) {
83
- return agent === null ? { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' } : { key: agent.key };
84
- }
85
- async function verdict() {
86
- const pinned = pinnedBy(process.env);
87
- if (pinned !== null)
88
- return { skip: `${pinned} is set, so Claude Code keeps talking to it` };
89
- const conflicts = settingsConflicts(settingsFiles());
90
- if (conflicts.length > 0)
91
- return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
92
- const picked = agentKey(localAgent());
93
- if ('skip' in picked)
94
- return picked;
95
- if (await daemonServing())
96
- return picked;
97
- return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
98
- }
99
- function deadline(child, timeoutMs) {
100
- if (timeoutMs === undefined)
101
- return () => undefined;
102
- let kill;
103
- const term = setTimeout(() => {
104
- child.kill('SIGTERM');
105
- kill = setTimeout(() => {
106
- child.kill('SIGKILL');
107
- }, KILL_GRACE_MS);
108
- }, timeoutMs);
109
- return () => {
110
- clearTimeout(term);
111
- clearTimeout(kill);
112
- };
113
- }
114
- export function runClaude(args, env, headless, command = 'claude') {
115
- return new Promise((resolve, reject) => {
116
- const leaveToChild = () => undefined;
117
- process.on('SIGINT', leaveToChild);
118
- process.on('SIGTERM', leaveToChild);
119
- const child = spawn(command, args, { stdio: [headless === undefined ? 'inherit' : 'ignore', 'inherit', 'inherit'], env });
120
- const stop = deadline(child, headless?.timeoutMs);
121
- child.on('error', (err) => {
122
- reject(new Error(err.code === 'ENOENT'
123
- ? `the \`${command}\` command is not on PATH${command === 'claude' ? ' — install Claude Code first' : ''}`
124
- : err.message));
125
- });
126
- child.on('exit', (code) => {
127
- stop();
128
- resolve(code ?? 1);
129
- });
130
- });
131
- }
132
- async function servedKey(decision) {
133
- if ('key' in decision)
134
- return decision.key;
135
- const picked = agentKey(localAgent());
136
- if ('skip' in picked)
137
- return null;
138
- return (await daemonServing()) ? picked.key : null;
139
- }
140
- function mcpConfigFor(key, port) {
141
- if (key === null)
142
- return null;
143
- const file = writeMcpConfig(key, port);
144
- process.stderr.write(`metro claude: the metro MCP server is loaded for this session from the daemon at http://127.0.0.1:${String(port)}/mcp, nothing to add by hand\n`);
145
- return file;
146
- }
147
- function gatewayLaunchEnv(key, port) {
148
- process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
149
- const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
150
- const routed = toolSearchEnv(pointed);
151
- if (routed !== pointed)
152
- process.stderr.write('metro claude: MCP tool search is on, so connector tools load when Claude needs them instead of all at start\n');
153
- const env = credentialEnv(routed, key, claudeSignedIn());
154
- if (env !== routed)
155
- process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
156
- if (seedChannels() === 'marked')
157
- process.stderr.write('metro claude: marked Channels as available in Claude Code cached flags, since a box that sends no telemetry never fetches them\n');
158
- if (markOnboardingDone() === 'marked')
159
- process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
160
- return env;
161
- }
162
- function inferenceEnv(decision, port) {
163
- if ('key' in decision)
164
- return gatewayLaunchEnv(decision.key, port);
165
- process.stderr.write(`metro claude: ${decision.skip}\n`);
166
- return process.env;
167
- }
168
- export async function headlessEnv() {
169
- return keepInSession(rmPromptEnv(inferenceEnv(await verdict(), localPort())));
170
- }
171
- const continues = (args) => args.includes('-c') || args.includes('--continue');
172
- async function reclaimConversation(extra) {
173
- const sessionId = resumeSessionId(extra);
174
- if (sessionId === undefined && !continues(extra))
175
- return;
176
- try {
177
- const note = await takeBackConversation({ sessionId });
178
- if (note !== null)
179
- process.stderr.write(`metro claude: ${note}\n`);
180
- }
181
- catch (err) {
182
- const message = err instanceof Error ? err.message : String(err);
183
- process.stderr.write(`metro claude: could not check for a Claude Code background session holding the conversation (${message})\n`);
184
- }
185
- }
186
- const SDK_HOLDS_CHAT = 'the Harness runs this agent as an Agent SDK session (metro agent), which holds the metro chat; a metro claude session would take it away. Switch the Harness runner back to the Claude Code session first, or open the conversation with plain claude --resume <session id>';
187
- export async function launchClaude(extra) {
188
- if (harnessRunner() === 'sdk')
189
- throw new Error(SDK_HOLDS_CHAT);
190
- await reclaimConversation(extra);
191
- const decision = await verdict();
192
- const port = localPort();
193
- const mcp = mcpConfigFor(await servedKey(decision), port);
194
- const mode = permissionMode();
195
- const prompt = systemPrompt();
196
- if (prompt !== null)
197
- process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
198
- try {
199
- return await runClaude(claudeArgs(nativeRecoveryArgs(extra), mcp?.path, mode, prompt), keepInSession(rmPromptEnv(channelEnv(inferenceEnv(decision, port)))));
200
- }
201
- finally {
202
- mcp?.cleanup();
203
- }
204
- }
@@ -1,27 +0,0 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- const METRO_SERVER = 'metro';
5
- const CHAT_PATH = '/mcp';
6
- export const VOICE_MCP_PATH = '/mcp/voice';
7
- export function metroMcpConfig(agentKey, port, path = CHAT_PATH) {
8
- const servers = {
9
- [METRO_SERVER]: {
10
- type: 'http',
11
- url: `http://127.0.0.1:${String(port)}${path}`,
12
- headers: { Authorization: `Bearer ${agentKey}` },
13
- },
14
- };
15
- return `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`;
16
- }
17
- export function writeMcpConfig(agentKey, port, base = tmpdir(), mcpPath = CHAT_PATH) {
18
- const dir = mkdtempSync(join(base, 'metro-claude-'));
19
- const path = join(dir, 'mcp.json');
20
- writeFileSync(path, metroMcpConfig(agentKey, port, mcpPath), { mode: 0o600 });
21
- return {
22
- path,
23
- cleanup: () => {
24
- rmSync(dir, { recursive: true, force: true });
25
- },
26
- };
27
- }
@@ -1,246 +0,0 @@
1
- import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { isRecord } from './background.js';
4
- import { nativeId, nativeToken, NATIVE_CURSOR_MAX, NATIVE_FILE_MAX } from './native-recovery-state.js';
5
- export const NATIVE_SCAN_BYTES = 256 * 1024;
6
- export const NATIVE_LINE_BYTES = 64 * 1024;
7
- const RECORD_MAX = 2048;
8
- function decodeRows(buffer, start, end, read) {
9
- const lines = buffer.subarray(start, end).toString('utf8').split('\n');
10
- for (const line of lines.slice(0, RECORD_MAX)) {
11
- if (line === '')
12
- continue;
13
- if (Buffer.byteLength(line) > NATIVE_LINE_BYTES) {
14
- read.incomplete = true;
15
- continue;
16
- }
17
- try {
18
- read.rows.push(JSON.parse(line));
19
- }
20
- catch {
21
- read.incomplete = true;
22
- }
23
- }
24
- read.incomplete ||= lines.length > RECORD_MAX;
25
- return read;
26
- }
27
- function readRows(path, offset) {
28
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
29
- try {
30
- const stat = fstatSync(fd);
31
- if (!stat.isFile())
32
- throw new Error('Native transcript is not a regular file');
33
- if (offset > stat.size)
34
- return { rows: [], offset, incomplete: true };
35
- const from = Math.max(offset, stat.size - NATIVE_SCAN_BYTES);
36
- const buffer = Buffer.alloc(Math.min(stat.size - from, NATIVE_SCAN_BYTES));
37
- const count = readSync(fd, buffer, 0, buffer.length, from);
38
- const end = buffer.lastIndexOf(10, count - 1);
39
- const incomplete = from > offset || count < buffer.length || end !== count - 1;
40
- if (count === 0)
41
- return { rows: [], offset: from, incomplete };
42
- if (end < 0)
43
- return { rows: [], offset: from, incomplete: true };
44
- const start = from > offset ? buffer.indexOf(10) + 1 : 0;
45
- return decodeRows(buffer, start, end, { rows: [], offset: from + end + 1, incomplete });
46
- }
47
- finally {
48
- closeSync(fd);
49
- }
50
- }
51
- function contentOf(row) {
52
- return isRecord(row.message) ? row.message.content : undefined;
53
- }
54
- function textOf(content) {
55
- if (typeof content === 'string')
56
- return content;
57
- if (!Array.isArray(content) || content.length !== 1)
58
- return null;
59
- const block = content[0];
60
- return isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : null;
61
- }
62
- function resultOf(value) {
63
- if (isRecord(value))
64
- return value;
65
- if (typeof value !== 'string' || value.length > NATIVE_LINE_BYTES)
66
- return null;
67
- try {
68
- const raw = JSON.parse(value);
69
- return isRecord(raw) ? raw : null;
70
- }
71
- catch {
72
- return null;
73
- }
74
- }
75
- function tag(text, name) {
76
- const matches = [...text.matchAll(new RegExp(`<${name}>([^<]*)</${name}>`, 'g'))];
77
- return matches.length > 1 ? undefined : matches[0]?.[1] ?? null;
78
- }
79
- function terminal(value, reason) {
80
- if (value === 'stopped' && reason === 'worker_restart')
81
- return 'restarted';
82
- return value === 'completed' || value === 'failed' || value === 'stopped' ? value : null;
83
- }
84
- const queued = (row) => isRecord(row.attachment) && row.attachment.type === 'queued_command' ? row.attachment : null;
85
- function textNotification(row) {
86
- const attachment = queued(row);
87
- const origin = attachment?.origin ?? row.origin;
88
- if (!isRecord(origin) || origin.kind !== 'task-notification')
89
- return null;
90
- if (row.type !== 'user' && row.type !== 'attachment')
91
- return null;
92
- const text = textOf(attachment?.value ?? contentOf(row));
93
- return text === null ? null : notificationTags(text.trim());
94
- }
95
- function notificationOf(agent, tool, status, reason) {
96
- const state = terminal(status, reason);
97
- if (!nativeToken(agent) || state === null)
98
- return null;
99
- if (tool != null && !nativeToken(tool))
100
- return null;
101
- return { agent, state, tool: nativeToken(tool) ? tool : null };
102
- }
103
- function notificationTags(text) {
104
- const open = '<task-notification>';
105
- const close = '</task-notification>';
106
- if (!text.startsWith(open) || !text.endsWith(close))
107
- return null;
108
- if (text.includes(open, 1) || text.indexOf(close) !== text.length - close.length)
109
- return null;
110
- const fields = ['task-id', 'tool-use-id', 'status', 'reason'].map((name) => tag(text, name));
111
- if (fields.some((field) => field === undefined))
112
- return null;
113
- return notificationOf(fields[0], fields[1], fields[2], fields[3]);
114
- }
115
- function notification(row) {
116
- if (row.type !== 'system' || row.subtype !== 'task_notification')
117
- return textNotification(row);
118
- return notificationOf(row.task_id, row.tool_use_id, row.status, row.reason);
119
- }
120
- function ackOf(block, base) {
121
- if (block.type !== 'text' || typeof block.text !== 'string')
122
- return null;
123
- const match = /^METRO_RECOVERY_ACK ([a-f0-9]{64})$/.exec(block.text.trim());
124
- return nativeId(match?.[1]) ? { ...base, type: 'ack', id: match[1] } : null;
125
- }
126
- function callOf(block, base) {
127
- if (block.type !== 'tool_use' || !nativeToken(block.id))
128
- return null;
129
- return block.name === 'Agent' || block.name === 'SendMessage' ? { ...base, type: 'call', tool: block.id, kind: block.name } : null;
130
- }
131
- const rootAssistant = (row, base) => base.source === null && row.aborted !== true && row.parent_tool_use_id == null && row.parent_agent_id == null;
132
- function blockEvent(block, row, base, single) {
133
- if (!isRecord(block))
134
- return null;
135
- if (row.type === 'assistant') {
136
- const ack = rootAssistant(row, base) ? ackOf(block, base) : null;
137
- return ack ?? callOf(block, base);
138
- }
139
- if (row.type !== 'user' || block.type !== 'tool_result' || !nativeToken(block.tool_use_id))
140
- return null;
141
- return { ...base, type: 'result', tool: block.tool_use_id, failed: block.is_error === true, outcome: resultOf(single ? row.toolUseResult : null) ?? resultOf(block.content) };
142
- }
143
- const sidechain = (row, source) => source === null && (row.isSidechain === true || typeof row.agentId === 'string');
144
- function eventsOf(row, source) {
145
- if (sidechain(row, source))
146
- return [];
147
- const at = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : NaN;
148
- if (!Number.isSafeInteger(at) || at < 0 || !nativeToken(row.uuid))
149
- return [];
150
- const base = { source, at, receipt: row.uuid };
151
- const end = notification(row);
152
- if (end !== null)
153
- return [{ ...base, type: 'notification', ...end }];
154
- const content = contentOf(row);
155
- const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
156
- return blocks.flatMap((block) => { const event = blockEvent(block, row, base, blocks.length === 1); return event === null ? [] : [event]; });
157
- }
158
- function resultChild(kind, outcome) {
159
- if (kind === 'Agent' && (outcome.status === 'async_launched' || outcome.status === 'completed'))
160
- return nativeToken(outcome.agentId) ? outcome.agentId : null;
161
- return kind === 'SendMessage' && outcome.success === true && nativeToken(outcome.resumedAgentId) ? outcome.resumedAgentId : null;
162
- }
163
- function childOf(event, calls) {
164
- if (event.type === 'ack')
165
- return null;
166
- const key = `${event.source ?? ''}/${event.tool ?? ''}`;
167
- if (event.type === 'call')
168
- calls.set(key, event.kind);
169
- if (event.type === 'notification')
170
- return calls.has(key) ? event.agent : null;
171
- if (event.type !== 'result' || event.failed || event.outcome === null)
172
- return null;
173
- return resultChild(calls.get(key), event.outcome);
174
- }
175
- function missingEnvelope(row) {
176
- const message = row.type === 'assistant' || row.type === 'user' || row.type === 'attachment' || row.subtype === 'task_notification';
177
- if (!message)
178
- return false;
179
- return !nativeToken(row.uuid) || typeof row.timestamp !== 'string' || !Number.isSafeInteger(Date.parse(row.timestamp));
180
- }
181
- function rowsOf(read, agent, owner) {
182
- const events = [];
183
- for (const row of read.rows) {
184
- if (!isRecord(row)) {
185
- read.incomplete = true;
186
- continue;
187
- }
188
- if (agent === null && typeof row.sessionId === 'string' && row.sessionId !== owner) {
189
- read.incomplete = true;
190
- continue;
191
- }
192
- read.incomplete ||= missingEnvelope(row);
193
- events.push(...eventsOf(row, agent));
194
- }
195
- return events;
196
- }
197
- function discover(context, event, index) {
198
- const child = childOf(event, context.calls);
199
- if (child === null || context.pending.includes(child))
200
- return;
201
- if (context.files.size >= NATIVE_CURSOR_MAX) {
202
- context.scan.gaps.push('file-capacity');
203
- return;
204
- }
205
- context.files.set(child, { agent: child, offset: 0 });
206
- context.pending.splice(index + 1, 0, child);
207
- }
208
- function scanFile(context, agent, index) {
209
- const { root, owner, files, scan } = context;
210
- const before = files.get(agent)?.offset ?? 0;
211
- const path = agent === null ? join(root, `${owner}.jsonl`) : join(root, owner, 'subagents', `agent-${agent}.jsonl`);
212
- files.delete(agent);
213
- try {
214
- const read = readRows(path, before);
215
- const events = rowsOf(read, agent, owner);
216
- files.set(agent, { agent, offset: read.offset });
217
- if (read.incomplete)
218
- scan.gaps.push(`${agent ?? 'root'}:${String(before)}`);
219
- for (const event of events) {
220
- discover(context, event, index);
221
- if (scan.events.length < RECORD_MAX)
222
- scan.events.push(event);
223
- else
224
- scan.gaps.push('event-capacity');
225
- }
226
- }
227
- catch {
228
- scan.gaps.push(`${agent ?? 'root'}:${String(before)}`);
229
- files.set(agent, { agent, offset: before });
230
- }
231
- }
232
- export function scanNative(root, owner, previous, knownCalls) {
233
- const pending = [null, ...previous.flatMap((file) => file.agent === null ? [] : [file.agent])];
234
- const files = new Map(previous.map((file) => [file.agent, file]));
235
- const calls = new Map(knownCalls.map((call) => [`${call.source ?? ''}/${call.tool}`, call.kind]));
236
- const scan = { events: [], files: [], gaps: [] };
237
- const context = { root, owner, calls, pending, files, scan };
238
- for (let i = 0; i < pending.length && i < NATIVE_FILE_MAX; i += 1)
239
- scanFile(context, pending[i] ?? null, i);
240
- if (pending.length > NATIVE_FILE_MAX)
241
- scan.gaps.push('file-budget');
242
- scan.files = [...files.values()];
243
- scan.gaps = [...new Set(scan.gaps)];
244
- scan.events.sort((a, b) => a.at - b.at);
245
- return scan;
246
- }
@@ -1,154 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, opendirSync, openSync, readSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { isRecord, processAlive } from './background.js';
5
- export const NATIVE_TASK_MAX = 256;
6
- export const NATIVE_FILE_MAX = 16;
7
- export const NATIVE_CURSOR_MAX = 256;
8
- export const NATIVE_NOTICE_MAX = 512;
9
- export const NATIVE_STATE_BYTES = 512 * 1024;
10
- export const nativeToken = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
11
- export const nativeId = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
12
- const integer = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
13
- const nullable = (value, valid) => value === null || valid(value);
14
- const keys = (value, fields) => Object.keys(value).length === fields.length && fields.every((key) => Object.hasOwn(value, key));
15
- const statuses = new Set(['started', 'completed', 'failed', 'stopped', 'restarted']);
16
- const reasons = new Set(['interrupted', 'failed', 'restarted', 'incomplete']);
17
- function taskFields(value) {
18
- return nullable(value.source, nativeToken) && nativeToken(value.tool) && (value.kind === 'Agent' || value.kind === 'SendMessage')
19
- && nullable(value.agent, nativeToken) && statuses.has(value.state) && nativeToken(value.receipt);
20
- }
21
- function task(value) {
22
- return isRecord(value) && keys(value, ['id', 'source', 'tool', 'kind', 'agent', 'at', 'updatedAt', 'state', 'receipt']) && nativeId(value.id)
23
- && integer(value.at) && integer(value.updatedAt) && value.updatedAt >= value.at && taskFields(value);
24
- }
25
- function noticeTimes(value) {
26
- if (!nullable(value.deliveredAt, integer) || !nullable(value.acknowledgedAt, integer))
27
- return false;
28
- if (value.attempts === 0)
29
- return value.deliveredAt === null && value.acknowledgedAt === null;
30
- if (typeof value.deliveredAt !== 'number')
31
- return false;
32
- return value.acknowledgedAt === null || (typeof value.acknowledgedAt === 'number' && value.acknowledgedAt >= value.deliveredAt);
33
- }
34
- function notice(value) {
35
- return isRecord(value) && keys(value, ['id', 'task', 'reason', 'attempts', 'deliveredAt', 'acknowledgedAt']) && nativeId(value.id)
36
- && nullable(value.task, nativeId) && reasons.has(value.reason) && integer(value.attempts) && value.attempts <= 3 && noticeTimes(value);
37
- }
38
- function cursor(value) {
39
- return isRecord(value) && keys(value, ['agent', 'offset']) && nullable(value.agent, nativeToken) && integer(value.offset);
40
- }
41
- function rows(value, max, valid, key) {
42
- return Array.isArray(value) && value.length <= max && value.every(valid) && new Set(value.map(key)).size === value.length;
43
- }
44
- function validState(value, owner) {
45
- if (!isRecord(value) || !keys(value, ['version', 'owner', 'tasks', 'notices', 'files']) || value.version !== 1 || value.owner !== owner)
46
- return false;
47
- const { tasks, notices, files } = value;
48
- return rows(tasks, NATIVE_TASK_MAX, task, (v) => v.id) && rows(notices, NATIVE_NOTICE_MAX, notice, (v) => v.id)
49
- && rows(files, NATIVE_CURSOR_MAX, cursor, (v) => v.agent)
50
- && notices.every((row) => row.task === null || tasks.some((entry) => entry.id === row.task));
51
- }
52
- function readStateBytes(path) {
53
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
54
- try {
55
- const stat = fstatSync(fd);
56
- if (!stat.isFile() || stat.size > NATIVE_STATE_BYTES)
57
- throw new Error('state exceeds its size bound');
58
- const buffer = Buffer.alloc(NATIVE_STATE_BYTES + 1);
59
- const count = readSync(fd, buffer, 0, buffer.length, 0);
60
- if (count !== stat.size || fstatSync(fd).size !== stat.size)
61
- throw new Error('state changed while reading');
62
- return buffer.subarray(0, count).toString('utf8');
63
- }
64
- finally {
65
- closeSync(fd);
66
- }
67
- }
68
- export function readNativeState(path, owner) {
69
- try {
70
- const raw = JSON.parse(readStateBytes(path));
71
- if (!validState(raw, owner))
72
- throw new Error('state does not match its schema');
73
- return raw;
74
- }
75
- catch (err) {
76
- if (isRecord(err) && err.code === 'ENOENT')
77
- return { version: 1, owner, tasks: [], notices: [], files: [] };
78
- throw new Error('Native recovery state cannot be read safely. Restore it before resuming; it has not been overwritten.', { cause: err });
79
- }
80
- }
81
- function retireLock(path) {
82
- if (!lstatSync(path).isDirectory())
83
- throw new Error('Native recovery lock is not a directory');
84
- const dir = opendirSync(path);
85
- let name;
86
- try {
87
- name = dir.readSync()?.name;
88
- if (dir.readSync() !== null)
89
- throw new Error('Native recovery lock has unexpected entries');
90
- }
91
- finally {
92
- dir.closeSync();
93
- }
94
- const pid = /^(\d{1,10})-[a-f0-9-]{36}$/.exec(name ?? '')?.[1];
95
- if (pid === undefined || Number(pid) < 1 || processAlive(Number(pid)))
96
- throw new Error('Native recovery preparation is locked. Restore an unreadable lock before resuming.');
97
- unlinkSync(join(path, name ?? ''));
98
- rmdirSync(path);
99
- }
100
- export function withNativeStateLock(path, work) {
101
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
102
- const lock = `${path}.lock`;
103
- try {
104
- mkdirSync(lock, { mode: 0o700 });
105
- }
106
- catch (err) {
107
- if (!isRecord(err) || err.code !== 'EEXIST')
108
- throw err;
109
- retireLock(lock);
110
- mkdirSync(lock, { mode: 0o700 });
111
- }
112
- const owner = join(lock, `${String(process.pid)}-${randomUUID()}`);
113
- writeFileSync(owner, '', { flag: 'wx', mode: 0o600 });
114
- try {
115
- return work();
116
- }
117
- finally {
118
- unlinkSync(owner);
119
- rmdirSync(lock);
120
- }
121
- }
122
- function syncDirectory(path) {
123
- if (process.platform === 'win32')
124
- return;
125
- const fd = openSync(path, 'r');
126
- try {
127
- fsyncSync(fd);
128
- }
129
- finally {
130
- closeSync(fd);
131
- }
132
- }
133
- export function saveNativeState(path, state) {
134
- const text = JSON.stringify(state);
135
- if (!validState(state, state.owner) || Buffer.byteLength(text) > NATIVE_STATE_BYTES)
136
- throw new Error('Native recovery state exceeds its bounds; it has not been overwritten.');
137
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
138
- const tmp = `${path}.${randomUUID()}.tmp`;
139
- try {
140
- const fd = openSync(tmp, 'wx', 0o600);
141
- try {
142
- writeFileSync(fd, text);
143
- fsyncSync(fd);
144
- }
145
- finally {
146
- closeSync(fd);
147
- }
148
- renameSync(tmp, path);
149
- syncDirectory(dirname(path));
150
- }
151
- finally {
152
- rmSync(tmp, { force: true });
153
- }
154
- }