@stage-labs/metro 0.1.0-beta.276 → 0.1.0-beta.277
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/claude.js +2 -1
- package/dist/native-recovery-scan.js +246 -0
- package/dist/native-recovery-state.js +154 -0
- package/dist/native-recovery.js +164 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -0
- package/runtime/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-tool.ts +80 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channel-source.ts +79 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channels.ts +186 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/gmail/src/channels.ts +66 -0
- package/runtime/node_modules/@metro-labs/gmail/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/outlook/src/channels.ts +60 -0
- package/runtime/node_modules/@metro-labs/outlook/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +19 -0
- package/runtime/node_modules/@metro-labs/telegram/src/channels.ts +58 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channel-files.ts +3 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channels.ts +43 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -34
- package/runtime/node_modules/@metro-labs/telegram-bot/src/observed-chats.ts +172 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +30 -3
- package/runtime/node_modules/@metro-labs/telegram-bot/src/updates.ts +26 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +13 -1
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +7 -1
- package/runtime/node_modules/@metro-labs/threema/src/channels.ts +37 -0
- package/runtime/node_modules/@metro-labs/threema/src/chats.ts +88 -0
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -0
- package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channel-chats.ts +137 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channels.ts +48 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +8 -6
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +3 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/channels.ts +56 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/network.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity-tasks.ts +7 -1
- package/runtime/sdk-runner/src/activity.ts +33 -3
- package/runtime/sdk-runner/src/app.ts +5 -2
- package/runtime/sdk-runner/src/inbox.ts +10 -1
- package/runtime/sdk-runner/src/input-queue.ts +6 -4
- package/runtime/sdk-runner/src/main.ts +21 -8
- package/runtime/sdk-runner/src/rules.ts +1 -0
- package/runtime/sdk-runner/src/runner.ts +18 -4
- package/runtime/sdk-runner/src/session-store.ts +19 -4
- package/runtime/sdk-runner/src/session-watch.ts +5 -1
- package/runtime/sdk-runner/src/task-ledger.ts +229 -0
- package/runtime/sdk-runner/src/task-recovery.ts +203 -0
- package/runtime/sdk-runner/src/task-state.ts +71 -0
package/dist/claude.js
CHANGED
|
@@ -7,6 +7,7 @@ import { localAgent } from './local.js';
|
|
|
7
7
|
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
8
8
|
import { localPort, localUrl } from './runtime.js';
|
|
9
9
|
import { keepInSession, resumeSessionId, takeBackConversation } from './background.js';
|
|
10
|
+
import { nativeRecoveryArgs } from './native-recovery.js';
|
|
10
11
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
11
12
|
const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
|
|
12
13
|
export const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
@@ -193,7 +194,7 @@ export async function launchClaude(extra) {
|
|
|
193
194
|
if (prompt !== null)
|
|
194
195
|
process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
|
|
195
196
|
try {
|
|
196
|
-
return await runClaude(claudeArgs(extra, mcp?.path, mode, prompt), keepInSession(channelEnv(inferenceEnv(decision, port))));
|
|
197
|
+
return await runClaude(claudeArgs(nativeRecoveryArgs(extra), mcp?.path, mode, prompt), keepInSession(channelEnv(inferenceEnv(decision, port))));
|
|
197
198
|
}
|
|
198
199
|
finally {
|
|
199
200
|
mcp?.cleanup();
|
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { claudeDir, projectDir, resumeSessionId } from './background.js';
|
|
5
|
+
import { scanNative } from './native-recovery-scan.js';
|
|
6
|
+
import { nativeToken, NATIVE_NOTICE_MAX, NATIVE_TASK_MAX, readNativeState, saveNativeState, withNativeStateLock } from './native-recovery-state.js';
|
|
7
|
+
const hash = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
|
8
|
+
const taskId = (owner, source, tool) => hash(owner, source ?? '', tool);
|
|
9
|
+
const RETRY_MS = [0, 60_000, 10 * 60_000];
|
|
10
|
+
const PROMPT_NOTICES = 8;
|
|
11
|
+
const taskReason = (task) => task.state === 'started' ? 'interrupted' : task.state === 'failed' ? 'failed' : task.state === 'restarted' ? 'restarted' : null;
|
|
12
|
+
function agentResult(task, outcome) {
|
|
13
|
+
if (nativeToken(outcome.agentId))
|
|
14
|
+
task.agent = outcome.agentId;
|
|
15
|
+
if (outcome.status === 'completed')
|
|
16
|
+
task.state = 'completed';
|
|
17
|
+
else if (outcome.status === 'async_launched')
|
|
18
|
+
task.state = 'started';
|
|
19
|
+
}
|
|
20
|
+
function messageResult(task, outcome) {
|
|
21
|
+
if (outcome.success === true && nativeToken(outcome.resumedAgentId)) {
|
|
22
|
+
task.agent = outcome.resumedAgentId;
|
|
23
|
+
task.state = 'started';
|
|
24
|
+
}
|
|
25
|
+
else if (outcome.success === true && outcome.resumedAgentId === undefined)
|
|
26
|
+
task.state = 'completed';
|
|
27
|
+
else if (outcome.success === false)
|
|
28
|
+
task.state = 'failed';
|
|
29
|
+
}
|
|
30
|
+
function result(task, event) {
|
|
31
|
+
if (event.at < task.updatedAt || event.receipt === task.receipt)
|
|
32
|
+
return;
|
|
33
|
+
if (event.failed)
|
|
34
|
+
task.state = 'failed';
|
|
35
|
+
else if (event.outcome !== null) {
|
|
36
|
+
if (task.kind === 'Agent')
|
|
37
|
+
agentResult(task, event.outcome);
|
|
38
|
+
else
|
|
39
|
+
messageResult(task, event.outcome);
|
|
40
|
+
}
|
|
41
|
+
task.receipt = event.receipt;
|
|
42
|
+
task.updatedAt = event.at;
|
|
43
|
+
}
|
|
44
|
+
function acknowledge(state, event) {
|
|
45
|
+
const notice = state.notices.find((row) => row.id === event.id);
|
|
46
|
+
if (notice !== undefined && notice.deliveredAt !== null && event.at >= notice.deliveredAt)
|
|
47
|
+
notice.acknowledgedAt = event.at;
|
|
48
|
+
}
|
|
49
|
+
function notification(state, event) {
|
|
50
|
+
const candidates = state.tasks.filter((row) => row.source === event.source && row.at <= event.at
|
|
51
|
+
&& (event.tool === null ? row.agent === event.agent : row.tool === event.tool && (row.agent === null || row.agent === event.agent)));
|
|
52
|
+
if (candidates.length > 1)
|
|
53
|
+
return false;
|
|
54
|
+
const task = candidates[0];
|
|
55
|
+
if (task !== undefined && event.at >= task.updatedAt) {
|
|
56
|
+
task.agent = event.agent;
|
|
57
|
+
task.state = event.state;
|
|
58
|
+
task.receipt = event.receipt;
|
|
59
|
+
task.updatedAt = event.at;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function applyEvent(state, event) {
|
|
64
|
+
if (event.type === 'ack') {
|
|
65
|
+
acknowledge(state, event);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (event.type === 'notification')
|
|
69
|
+
return notification(state, event);
|
|
70
|
+
const id = taskId(state.owner, event.source, event.tool);
|
|
71
|
+
const task = state.tasks.find((row) => row.id === id);
|
|
72
|
+
if (event.type === 'result') {
|
|
73
|
+
if (task !== undefined)
|
|
74
|
+
result(task, event);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (task !== undefined)
|
|
78
|
+
return true;
|
|
79
|
+
if (state.tasks.length >= NATIVE_TASK_MAX)
|
|
80
|
+
prune(state);
|
|
81
|
+
if (state.tasks.length >= NATIVE_TASK_MAX)
|
|
82
|
+
return false;
|
|
83
|
+
state.tasks.push({ id, source: event.source, tool: event.tool, kind: event.kind, agent: null, at: event.at, updatedAt: event.at, state: 'started', receipt: event.receipt });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
function addNotice(state, task, reason, gap = 'incomplete') {
|
|
87
|
+
const id = hash(state.owner, task?.id ?? 'scan', task?.receipt ?? gap, reason);
|
|
88
|
+
if (state.notices.some((row) => row.id === id))
|
|
89
|
+
return;
|
|
90
|
+
if (state.notices.length >= NATIVE_NOTICE_MAX)
|
|
91
|
+
throw new Error('Native recovery has too many retained notices. Saved state has not been overwritten.');
|
|
92
|
+
state.notices.push({ id, task: task?.id ?? null, reason, attempts: 0, deliveredAt: null, acknowledgedAt: null });
|
|
93
|
+
}
|
|
94
|
+
function prune(state) {
|
|
95
|
+
const pending = new Set(state.notices.filter((row) => row.acknowledgedAt === null).map((row) => row.task));
|
|
96
|
+
const acknowledged = new Set(state.notices.filter((row) => row.acknowledgedAt !== null).map((row) => row.id));
|
|
97
|
+
const removable = state.tasks.filter((row) => !pending.has(row.id) && (taskReason(row) === null || acknowledged.has(hash(state.owner, row.id, row.receipt, taskReason(row) ?? ''))))
|
|
98
|
+
.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
99
|
+
const removed = new Set(removable.slice(0, Math.max(0, state.tasks.length - NATIVE_TASK_MAX / 2)).map((row) => row.id));
|
|
100
|
+
state.tasks = state.tasks.filter((row) => !removed.has(row.id));
|
|
101
|
+
state.notices = state.notices.filter((row) => row.task === null || !removed.has(row.task));
|
|
102
|
+
}
|
|
103
|
+
function noticeLine(state, notice) {
|
|
104
|
+
const task = state.tasks.find((row) => row.id === notice.task);
|
|
105
|
+
const subject = task === undefined ? 'Native task history' : `${task.kind} receipt ${task.tool}${task.agent === null ? '' : `, worker ${task.agent}`}`;
|
|
106
|
+
const facts = {
|
|
107
|
+
interrupted: 'has no terminal receipt. Work may have run before interruption; its outcome is unknown.',
|
|
108
|
+
failed: 'reported failure. Earlier side effects may already have happened.',
|
|
109
|
+
restarted: 'was stopped by worker restart, not explicit user cancellation.',
|
|
110
|
+
incomplete: 'could not be fully checked within the bounded local scan. Other unfinished work may exist.',
|
|
111
|
+
};
|
|
112
|
+
return `${subject} ${facts[notice.reason]}\nAfter taking responsibility for this notice, emit a standalone assistant text block exactly: METRO_RECOVERY_ACK ${notice.id}`;
|
|
113
|
+
}
|
|
114
|
+
function promptFor(state, notices) {
|
|
115
|
+
return [
|
|
116
|
+
'Metro native recovery notice. This is recovery bookkeeping, not a new user task or permission to repeat actions.',
|
|
117
|
+
`Owner conversation: ${state.owner}. Native worker routing may have lost ownership metadata; this notice does not repair it.`,
|
|
118
|
+
'Review the affected work and current evidence, using a read-only worker if needed. Do not blindly rerun a task, resend a message, or repeat another side effect. Acknowledge awareness only; acknowledgment is not proof of successful completion. Do not post these bookkeeping markers to chat.',
|
|
119
|
+
...notices.map((notice) => noticeLine(state, notice)),
|
|
120
|
+
].join('\n\n');
|
|
121
|
+
}
|
|
122
|
+
function prepare(state, root) {
|
|
123
|
+
const scan = scanNative(root, state.owner, state.files, state.tasks);
|
|
124
|
+
prune(state);
|
|
125
|
+
for (const event of scan.events)
|
|
126
|
+
if (!applyEvent(state, event))
|
|
127
|
+
scan.gaps.push('unresolved-receipts');
|
|
128
|
+
state.files = scan.files;
|
|
129
|
+
for (const task of state.tasks) {
|
|
130
|
+
const reason = taskReason(task);
|
|
131
|
+
if (reason !== null)
|
|
132
|
+
addNotice(state, task, reason);
|
|
133
|
+
}
|
|
134
|
+
for (const gap of scan.gaps)
|
|
135
|
+
addNotice(state, null, 'incomplete', gap);
|
|
136
|
+
}
|
|
137
|
+
function isDue(notice, now) {
|
|
138
|
+
return notice.acknowledgedAt === null && notice.attempts < RETRY_MS.length
|
|
139
|
+
&& (notice.deliveredAt === null || now - notice.deliveredAt >= (RETRY_MS[notice.attempts] ?? Infinity));
|
|
140
|
+
}
|
|
141
|
+
function locations(owner, deps) {
|
|
142
|
+
const env = deps.env ?? process.env;
|
|
143
|
+
const given = env.HOME?.trim();
|
|
144
|
+
const home = resolve(given === '' ? homedir() : given ?? homedir());
|
|
145
|
+
return { path: join(home, '.metro', 'native-recovery', `${owner}.json`), root: projectDir(claudeDir(env), deps.cwd ?? process.cwd()) };
|
|
146
|
+
}
|
|
147
|
+
export function nativeRecoveryArgs(extra, deps = {}) {
|
|
148
|
+
const owner = resumeSessionId(extra);
|
|
149
|
+
if (extra.length !== 2 || owner === undefined || (extra[0] !== '--resume' && extra[0] !== '-r'))
|
|
150
|
+
return extra;
|
|
151
|
+
const { path, root } = locations(owner, deps);
|
|
152
|
+
return withNativeStateLock(path, () => {
|
|
153
|
+
const state = readNativeState(path, owner);
|
|
154
|
+
prepare(state, root);
|
|
155
|
+
const now = deps.now ?? Date.now();
|
|
156
|
+
const due = state.notices.filter((row) => isDue(row, now)).slice(0, PROMPT_NOTICES);
|
|
157
|
+
for (const notice of due) {
|
|
158
|
+
notice.attempts += 1;
|
|
159
|
+
notice.deliveredAt = now;
|
|
160
|
+
}
|
|
161
|
+
saveNativeState(path, state);
|
|
162
|
+
return due.length === 0 ? extra : [...extra, promptFor(state, due)];
|
|
163
|
+
});
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.277",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -35,6 +35,10 @@ function stationOfAccount(snap, account) {
|
|
|
35
35
|
function targets(snap, name, input) {
|
|
36
36
|
if (Array.isArray(snap.ungated) && snap.ungated.includes(name)) return [];
|
|
37
37
|
const account = text(input.account);
|
|
38
|
+
if (name === 'list_channels') {
|
|
39
|
+
const station = stationOfAccount(snap, account);
|
|
40
|
+
return station === undefined ? [] : [{ station, account }];
|
|
41
|
+
}
|
|
38
42
|
const line = text(input[LINE_FIELD[name] ?? 'line']);
|
|
39
43
|
if (line) return fromLine(line, name in LINE_FIELD || name === 'list_members' ? '' : account);
|
|
40
44
|
const owners = isRecord(snap.owners) ? snap.owners : {};
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"./lines": "./src/lines.ts",
|
|
22
22
|
"./endpoints": "./src/endpoints.ts",
|
|
23
23
|
"./stations/types": "./src/stations/types.ts",
|
|
24
|
+
"./stations/channel-directory": "./src/stations/channel-directory.ts",
|
|
25
|
+
"./stations/channel-pages": "./src/stations/channel-pages.ts",
|
|
24
26
|
"./stations/account-files": "./src/stations/account-files.ts",
|
|
25
27
|
"./stations/station-runtime": "./src/stations/station-runtime.ts",
|
|
26
28
|
"./stations/account-store": "./src/stations/account-store.ts",
|
|
@@ -91,6 +91,7 @@ export interface RunnerInput {
|
|
|
91
91
|
export interface RunnerActivity {
|
|
92
92
|
pid: number;
|
|
93
93
|
procStart?: string;
|
|
94
|
+
cancelSignal?: 'SIGUSR2';
|
|
94
95
|
runner: 'sdk';
|
|
95
96
|
phase: RunnerPhase;
|
|
96
97
|
mainPhase: RunnerPhase;
|
|
@@ -215,6 +216,7 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
|
215
216
|
if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
|
|
216
217
|
return {
|
|
217
218
|
runner: 'sdk', pid: count(raw.pid), ...processIdentity(raw.procStart), phase: raw.phase, mainPhase: phase(raw.mainPhase) ? raw.mainPhase : raw.phase,
|
|
219
|
+
...(raw.cancelSignal === 'SIGUSR2' ? { cancelSignal: raw.cancelSignal } : {}),
|
|
218
220
|
mainStartedAt: count(raw.mainStartedAt) || null, sessionId: sessionId(raw.sessionId), updatedAt: count(raw.updatedAt),
|
|
219
221
|
pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
|
|
220
222
|
tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
|