@superjackfruitlabs/salaam 0.0.3 → 0.0.4

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 (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -0,0 +1,342 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, dirname, isAbsolute, join } from 'node:path';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
+ import { digest, ServiceError } from './protocol.js';
8
+ const NAME = /^[a-z][a-z0-9_-]{0,63}$/;
9
+ const REF = /\$\{([A-Za-z_][A-Za-z0-9_]{0,127})\}/g;
10
+ // Literal text around references may only be a short scheme word such as "Bearer ".
11
+ const LITERAL = /^(?:[A-Za-z][A-Za-z-]{0,15} ?)?$/;
12
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
13
+ const HEADER = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
14
+ const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
15
+ export function operatorMcpPath(env = process.env) {
16
+ const base = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)
17
+ ? env.XDG_CONFIG_HOME
18
+ : join(env.HOME || homedir(), '.config');
19
+ return join(base, 'salaam', 'mcp.json');
20
+ }
21
+ function fail(message) {
22
+ throw new ServiceError('invalid_mcp_config', message, 400);
23
+ }
24
+ function keys(value, allowed, where) {
25
+ for (const key of Object.keys(value))
26
+ if (!allowed.includes(key))
27
+ fail(`${where}: unknown key "${key}"`);
28
+ }
29
+ function record(value, where) {
30
+ if (!value || typeof value !== 'object' || Array.isArray(value))
31
+ fail(`${where} must be an object`);
32
+ return value;
33
+ }
34
+ /** A value must be built from ${VAR} references; inline secrets are refused. */
35
+ export function assertReference(value, where) {
36
+ if (typeof value !== 'string' || value.length > 1024)
37
+ fail(`${where} must be a string`);
38
+ const names = [...value.matchAll(REF)].map((m) => m[1]);
39
+ const literal = value.replace(REF, '');
40
+ if (!names.length || !LITERAL.test(literal))
41
+ fail(`${where} must reference an environment variable as \${VAR}; inline secrets are refused`);
42
+ return names;
43
+ }
44
+ function referenceMap(value, where, key) {
45
+ const map = record(value, where);
46
+ for (const [k, v] of Object.entries(map)) {
47
+ if (!key.test(k))
48
+ fail(`${where}: invalid name "${k}"`);
49
+ assertReference(v, `${where}.${k}`);
50
+ }
51
+ return map;
52
+ }
53
+ /** Shape validation shared by the CLI and the service. Does not touch PATH or env. */
54
+ export function validateDeclaration(input, index = 0) {
55
+ const where = `servers[${index}]`;
56
+ const s = record(input, where);
57
+ if (typeof s.name !== 'string' || !NAME.test(s.name))
58
+ fail(`${where}.name must match ${NAME.source}`);
59
+ const at = `server "${s.name}"`;
60
+ if (s.transport === 'stdio') {
61
+ keys(s, ['name', 'transport', 'command', 'args', 'cwd', 'env'], at);
62
+ if (typeof s.command !== 'string' || !s.command || s.command.length > 4096)
63
+ fail(`${at}: command is required`);
64
+ if (!isAbsolute(s.command) && s.command.includes('/'))
65
+ fail(`${at}: command must be absolute or a bare name on PATH`);
66
+ if (s.args !== undefined &&
67
+ (!Array.isArray(s.args) ||
68
+ s.args.length > 256 ||
69
+ s.args.some((a) => typeof a !== 'string' || a.length > 4096)))
70
+ fail(`${at}: args must be a list of strings`);
71
+ if (s.cwd !== undefined && (typeof s.cwd !== 'string' || !isAbsolute(s.cwd)))
72
+ fail(`${at}: cwd must be absolute`);
73
+ if (s.env !== undefined)
74
+ referenceMap(s.env, `${at}: env`, ENV_KEY);
75
+ return {
76
+ name: s.name,
77
+ transport: 'stdio',
78
+ command: s.command,
79
+ ...(s.args ? { args: [...s.args] } : {}),
80
+ ...(s.cwd ? { cwd: s.cwd } : {}),
81
+ ...(s.env ? { env: { ...s.env } } : {}),
82
+ };
83
+ }
84
+ if (s.transport === 'http') {
85
+ keys(s, ['name', 'transport', 'url', 'headers'], at);
86
+ if (typeof s.url !== 'string' || !URL.canParse(s.url))
87
+ fail(`${at}: url is invalid`);
88
+ const url = new URL(s.url);
89
+ if (url.username || url.password)
90
+ fail(`${at}: url must not carry credentials`);
91
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname)))
92
+ fail(`${at}: url must be https (http only on loopback)`);
93
+ if (s.headers !== undefined)
94
+ referenceMap(s.headers, `${at}: headers`, HEADER);
95
+ return {
96
+ name: s.name,
97
+ transport: 'http',
98
+ url: s.url,
99
+ ...(s.headers ? { headers: { ...s.headers } } : {}),
100
+ };
101
+ }
102
+ fail(`${at}: transport must be "stdio" or "http"`);
103
+ }
104
+ export function validateOperatorMcp(input) {
105
+ const file = record(input, 'mcp.json');
106
+ keys(file, ['version', 'servers'], 'mcp.json');
107
+ if (file.version !== 1)
108
+ fail('mcp.json: version must be 1');
109
+ if (!Array.isArray(file.servers) || file.servers.length > 64)
110
+ fail('mcp.json: servers must be a list of at most 64');
111
+ const servers = file.servers.map((s, i) => validateDeclaration(s, i));
112
+ const seen = new Set();
113
+ for (const s of servers) {
114
+ if (seen.has(s.name))
115
+ fail(`mcp.json: duplicate server name "${s.name}"`);
116
+ seen.add(s.name);
117
+ }
118
+ return { version: 1, servers };
119
+ }
120
+ export function readOperatorMcpFile(path) {
121
+ if (!existsSync(path))
122
+ return { version: 1, servers: [] };
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
126
+ }
127
+ catch {
128
+ fail(`${path} is not valid JSON`);
129
+ }
130
+ return validateOperatorMcp(parsed);
131
+ }
132
+ export function writeOperatorMcpFile(path, file) {
133
+ const valid = validateOperatorMcp(file);
134
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
135
+ const temp = `${path}.${process.pid}.tmp`;
136
+ writeFileSync(temp, JSON.stringify(valid, null, 2) + '\n', { mode: 0o600 });
137
+ renameSync(temp, path);
138
+ }
139
+ export const declarationDigest = (d) => digest(d);
140
+ export function resolveCommand(command, env) {
141
+ const executable = (path) => {
142
+ try {
143
+ return statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ };
149
+ if (isAbsolute(command)) {
150
+ if (!executable(command))
151
+ fail(`command ${command} is not an executable file`);
152
+ return command;
153
+ }
154
+ for (const dir of (env.PATH ?? '').split(delimiter))
155
+ if (dir && isAbsolute(dir) && executable(join(dir, command)))
156
+ return join(dir, command);
157
+ fail(`command "${command}" is not on PATH`);
158
+ }
159
+ function resolveReferences(map, env, where) {
160
+ if (!map)
161
+ return undefined;
162
+ return Object.fromEntries(Object.entries(map).map(([k, v]) => [
163
+ k,
164
+ v.replace(REF, (_m, name) => {
165
+ const value = env[name];
166
+ if (value === undefined)
167
+ fail(`${where}.${k} references unset variable ${name}`);
168
+ return value;
169
+ }),
170
+ ]));
171
+ }
172
+ const CALL_TIMEOUT_MS = 120_000;
173
+ const RESULT_LIMIT = 262_144;
174
+ export class OperatorMcp {
175
+ file;
176
+ servers;
177
+ digest;
178
+ sessions = new Map();
179
+ secrets;
180
+ /** Resolves commands and references now, so a bad declaration refuses service start. */
181
+ constructor(file, env = process.env) {
182
+ this.file = file;
183
+ const valid = validateOperatorMcp(file);
184
+ this.servers = valid.servers.map((declaration) => {
185
+ const declaration_digest = declarationDigest(declaration);
186
+ if (declaration.transport === 'stdio') {
187
+ const command = resolveCommand(declaration.command, env);
188
+ return {
189
+ declaration,
190
+ command,
191
+ env: resolveReferences(declaration.env, env, `server "${declaration.name}": env`),
192
+ provenance: {
193
+ name: declaration.name,
194
+ source: 'operator',
195
+ transport: 'stdio',
196
+ command,
197
+ args: declaration.args ?? [],
198
+ declaration_digest,
199
+ },
200
+ };
201
+ }
202
+ return {
203
+ declaration,
204
+ headers: resolveReferences(declaration.headers, env, `server "${declaration.name}": headers`),
205
+ provenance: {
206
+ name: declaration.name,
207
+ source: 'operator',
208
+ transport: 'http',
209
+ url: declaration.url,
210
+ declaration_digest,
211
+ },
212
+ };
213
+ });
214
+ this.digest = digest(valid);
215
+ this.secrets = this.servers
216
+ .flatMap((s) => [...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})])
217
+ .filter((v) => v.length >= 8);
218
+ }
219
+ static load(path, env = process.env) {
220
+ return new OperatorMcp(readOperatorMcpFile(path), env);
221
+ }
222
+ provenance() {
223
+ return this.servers.map((s) => structuredClone(s.provenance));
224
+ }
225
+ get empty() {
226
+ return this.servers.length === 0;
227
+ }
228
+ server(name) {
229
+ const server = this.servers.find((s) => s.declaration.name === name);
230
+ if (!server)
231
+ throw new ServiceError('invalid_mcp_server', 'Unknown declared MCP server', 400);
232
+ return server;
233
+ }
234
+ connection(session, server) {
235
+ let owned = this.sessions.get(session);
236
+ if (!owned)
237
+ this.sessions.set(session, (owned = new Map()));
238
+ const existing = owned.get(server.declaration.name);
239
+ if (existing)
240
+ return existing;
241
+ const client = new Client({ name: 'salaam', version: '1' }, { capabilities: {} });
242
+ const d = server.declaration;
243
+ const transport = d.transport === 'stdio'
244
+ ? new StdioClientTransport({
245
+ command: server.command,
246
+ args: d.args ?? [],
247
+ ...(d.cwd ? { cwd: d.cwd } : {}),
248
+ ...(server.env ? { env: server.env } : {}),
249
+ stderr: 'ignore',
250
+ })
251
+ : new StreamableHTTPClientTransport(new URL(d.url), {
252
+ requestInit: { headers: server.headers ?? {}, redirect: 'error' },
253
+ });
254
+ const connection = {
255
+ client,
256
+ transport,
257
+ ready: client.connect(transport, { timeout: 30_000 }),
258
+ };
259
+ connection.ready.catch(() => owned.delete(d.name));
260
+ owned.set(d.name, connection);
261
+ return connection;
262
+ }
263
+ redact(text) {
264
+ let out = text;
265
+ for (const secret of this.secrets)
266
+ out = out.split(secret).join('[redacted]');
267
+ return out;
268
+ }
269
+ failure(error) {
270
+ if (error instanceof ServiceError)
271
+ throw error;
272
+ throw new ServiceError('mcp_server_failed', this.redact(`Declared MCP server failed: ${error?.message ?? 'unknown'}`).slice(0, 512), 502);
273
+ }
274
+ async catalog(session, serverName, tool) {
275
+ if (serverName === undefined || serverName === null || serverName === '')
276
+ return { servers: this.provenance().map(({ name, transport }) => ({ name, transport })) };
277
+ const server = this.server(serverName);
278
+ try {
279
+ const c = this.connection(session, server);
280
+ await c.ready;
281
+ const { tools } = await c.client.listTools(undefined, { timeout: 30_000 });
282
+ if (tool === undefined || tool === null || tool === '')
283
+ return {
284
+ server: server.declaration.name,
285
+ tools: tools.map((t) => ({ name: t.name, description: t.description ?? '' })),
286
+ };
287
+ const found = tools.find((t) => t.name === tool);
288
+ if (!found)
289
+ throw new ServiceError('invalid_mcp_tool', 'Unknown tool on that server', 400);
290
+ return { server: server.declaration.name, tool: found };
291
+ }
292
+ catch (error) {
293
+ this.failure(error);
294
+ }
295
+ }
296
+ async call(session, serverName, tool, args, signal) {
297
+ const server = this.server(serverName);
298
+ if (typeof tool !== 'string' || !tool || tool.length > 128)
299
+ throw new ServiceError('invalid_mcp_tool', 'A tool name is required', 400);
300
+ if (args !== undefined && (args === null || typeof args !== 'object' || Array.isArray(args)))
301
+ throw new ServiceError('invalid_mcp_arguments', 'Arguments must be an object', 400);
302
+ try {
303
+ const c = this.connection(session, server);
304
+ await c.ready;
305
+ const result = await c.client.callTool({ name: tool, arguments: (args ?? {}) }, undefined, { timeout: CALL_TIMEOUT_MS, ...(signal ? { signal } : {}) });
306
+ const parts = Array.isArray(result.content) ? result.content : [];
307
+ let budget = RESULT_LIMIT;
308
+ const content = parts.map((part) => {
309
+ const text = this.redact(part.type === 'text' ? String(part.text) : JSON.stringify(part)).slice(0, Math.max(0, budget));
310
+ budget -= text.length;
311
+ return { type: 'text', text };
312
+ });
313
+ if (result.structuredContent && !content.length)
314
+ content.push({
315
+ type: 'text',
316
+ text: this.redact(JSON.stringify(result.structuredContent)).slice(0, RESULT_LIMIT),
317
+ });
318
+ return { content, is_error: !!result.isError };
319
+ }
320
+ catch (error) {
321
+ this.failure(error);
322
+ }
323
+ }
324
+ async stopSession(session) {
325
+ const owned = this.sessions.get(session);
326
+ this.sessions.delete(session);
327
+ if (!owned)
328
+ return;
329
+ await Promise.allSettled([...owned.values()].map((c) => c.client.close()));
330
+ }
331
+ async close() {
332
+ await Promise.allSettled([...this.sessions.keys()].map((s) => this.stopSession(s)));
333
+ }
334
+ }
335
+ /** Both sources may serve one session; a shared name is refused rather than shadowed. */
336
+ export function mergeMcpProvenance(operator, agentpod) {
337
+ const names = new Set(agentpod.map((s) => s.name));
338
+ const clash = operator.find((s) => names.has(s.name));
339
+ if (clash)
340
+ throw new ServiceError('mcp_name_collision', `Declared MCP server "${clash.name}" collides with an AgentPod-assigned server`, 409);
341
+ return [...operator, ...agentpod];
342
+ }
@@ -0,0 +1,31 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ import { digest } from './protocol.js';
4
+ /**
5
+ * Item 9: the operator's persona for this host's agent. A plain UTF-8 text file the operator
6
+ * reviews at `host plan`; the manifest pins its path and digest, `host start` refuses a changed
7
+ * file, and every worker on the host gets it as a final system-prompt section. It shapes voice and
8
+ * conduct only: it grants no tool, path or permission, and no workspace file can supply it.
9
+ */
10
+ export const PERSONA_MAX_BYTES = 16 * 1024;
11
+ export function loadPersona(path) {
12
+ if (!isAbsolute(path) || realpathSync(path) !== path)
13
+ throw Error('Use a canonical absolute persona path');
14
+ const st = statSync(path);
15
+ if (!st.isFile() || st.size > PERSONA_MAX_BYTES)
16
+ throw Error('A persona is a regular file of at most 16 KiB');
17
+ const raw = readFileSync(path);
18
+ const text = raw.toString('utf8');
19
+ if (Buffer.from(text, 'utf8').compare(raw) !== 0 || text.includes('\0'))
20
+ throw Error('A persona is UTF-8 text');
21
+ if (!text.trim())
22
+ throw Error('A persona must not be empty');
23
+ return { text, digest: digest({ persona: text }) };
24
+ }
25
+ /** The section appended to the system prompt. */
26
+ export function personaSection(p) {
27
+ return ("\n\n## Persona (set by this host's operator)\n" +
28
+ 'This shapes your voice and conduct only. It grants no tools, paths or permissions, and it never ' +
29
+ 'overrides approvals, policy or the facts of a tool result.\n\n' +
30
+ p.text.trim());
31
+ }
package/dist/src/pi.js CHANGED
@@ -1,9 +1,30 @@
1
+ import { redactFailureText } from './acp-outcome.js';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { EventEmitter } from 'node:events';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { ServiceError, object, textDigest } from './protocol.js';
5
6
  export class PiCommandError extends Error {
6
7
  }
8
+ /**
9
+ * The text a finished tool returned, for the board's activity stream. A card showed every step
10
+ * with `result: null` because only the status crossed over. Images are left out, known secret
11
+ * shapes are redacted, and the text is clipped to 2 KiB.
12
+ */
13
+ function toolOutput(result) {
14
+ const content = result?.content;
15
+ if (!Array.isArray(content))
16
+ return {};
17
+ const text = content
18
+ .filter((p) => p && p.type === 'text' && typeof p.text === 'string')
19
+ .map((p) => p.text)
20
+ .join('\n');
21
+ if (!text)
22
+ return {};
23
+ const clean = redactFailureText(text);
24
+ return clean.length > 2048
25
+ ? { output: clean.slice(0, 2047) + '…', output_truncated: true }
26
+ : { output: clean };
27
+ }
7
28
  // The upstream client does not expose process identity/group ownership or the
8
29
  // extension UI subprotocol. Keep this narrow transport behind the Pi adapter.
9
30
  export class PiProcess extends EventEmitter {
@@ -78,7 +99,7 @@ export class PiProcess extends EventEmitter {
78
99
  input_digest: textDigest(input),
79
100
  input_truncated: input.length > 8192,
80
101
  }
81
- : { failed: record.isError === true }),
102
+ : { failed: record.isError === true, ...toolOutput(record.result) }),
82
103
  });
83
104
  }
84
105
  this.emit('record', record);
@@ -34,9 +34,10 @@ export function workerCapabilities(coding, childTools) {
34
34
  ...(childTools?.includes('salaam_station') ? ['protected-station-v1'] : []),
35
35
  ...(childTools?.includes('salaam_workspace') ? ['local-workspace-read'] : []),
36
36
  ...(childTools?.includes('salaam_local') ? ['local-coding-reviewed'] : []),
37
+ ...(childTools?.includes('salaam_operator_mcp') ? ['operator-mcp-v1'] : []),
37
38
  ];
38
39
  }
39
- export function workerTools(effects, child = false, coding, childTools, mcp = false, toolkit = false, station = false, local = false, localCoding = false) {
40
+ export function workerTools(effects, child = false, coding, childTools, mcp = false, toolkit = false, station = false, local = false, localCoding = false, operatorMcp = false) {
40
41
  return child
41
42
  ? [...(childTools ?? ['salaam_probe'])].sort()
42
43
  : [
@@ -47,7 +48,9 @@ export function workerTools(effects, child = false, coding, childTools, mcp = fa
47
48
  ...(coding?.commands ? ['salaam_command'] : []),
48
49
  ...(mcp ? ['salaam_mcp'] : []),
49
50
  ...(toolkit ? ['salaam_toolkit'] : []),
50
- ...(station ? ['salaam_station'] : []),
51
+ // salaam_board: read-only board reads, served by the hub (operator ruling 2026-10-28).
52
+ ...(station ? ['salaam_board', 'salaam_station'] : []),
53
+ ...(operatorMcp ? ['salaam_operator_mcp'] : []),
51
54
  ].sort();
52
55
  }
53
56
  export class ServiceError extends Error {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A provider 429 on a step is retried as a fresh admitted attempt of that step, after a backoff,
3
+ * up to RATE_LIMIT_MAX_FAILURES rate-limited attempts in all. Each retry is a new attempt with its
4
+ * own admission and budget charge, never a replay of the refused request. After the last one the
5
+ * task stops with RATE_LIMIT_STOP, a block the stopped-run recovery can prove and settle.
6
+ */
7
+ export const RATE_LIMIT_MAX_FAILURES = 3;
8
+ export const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000];
9
+ export const RATE_LIMIT_STOP = 'Provider rate limit reached';
10
+ export class RateLimitRetries {
11
+ store;
12
+ constructor(store) {
13
+ this.store = store;
14
+ store.db.exec(`CREATE TABLE IF NOT EXISTS task_rate_limits(
15
+ task_id TEXT NOT NULL,
16
+ step_id TEXT NOT NULL,
17
+ attempt_id TEXT NOT NULL UNIQUE,
18
+ failures INTEGER NOT NULL,
19
+ retry_at INTEGER NOT NULL,
20
+ PRIMARY KEY(task_id, step_id, failures))`);
21
+ }
22
+ /** Whether this attempt failed because the provider rate-limited it. */
23
+ rateLimited(attempt) {
24
+ const row = this.store.db.prepare('SELECT state,outcome FROM attempts WHERE id=?').get(attempt);
25
+ if (!row || row.state !== 'failed' || typeof row.outcome !== 'string')
26
+ return false;
27
+ try {
28
+ return JSON.parse(row.outcome)?.failure?.kind === 'rate_limit';
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /** What the driver does with a task whose step failed. Records each rate-limited attempt once. */
35
+ decide(task, now = Date.now()) {
36
+ const failed = task.steps.filter((s) => ['failed', 'interrupted', 'cancelled'].includes(s.state));
37
+ const step = failed[0];
38
+ if (failed.length !== 1 || !step || step.state !== 'failed' || !step.attempt_id)
39
+ return { action: 'none' };
40
+ if (!this.rateLimited(step.attempt_id))
41
+ return { action: 'none' };
42
+ let row = this.store.db
43
+ .prepare('SELECT failures,retry_at FROM task_rate_limits WHERE attempt_id=?')
44
+ .get(step.attempt_id);
45
+ if (!row) {
46
+ const prior = Number(this.store.db
47
+ .prepare('SELECT count(*) AS n FROM task_rate_limits WHERE task_id=? AND step_id=?')
48
+ .get(task.id, step.id).n);
49
+ const failures = prior + 1;
50
+ const retry_at = now + (RATE_LIMIT_BACKOFF_MS[failures - 1] ?? RATE_LIMIT_BACKOFF_MS.at(-1));
51
+ this.store.db
52
+ .prepare('INSERT INTO task_rate_limits VALUES (?,?,?,?,?)')
53
+ .run(task.id, step.id, step.attempt_id, failures, retry_at);
54
+ row = { failures, retry_at };
55
+ }
56
+ if (row.failures >= RATE_LIMIT_MAX_FAILURES)
57
+ return { action: 'stop', reason: RATE_LIMIT_STOP };
58
+ if (now < row.retry_at)
59
+ return { action: 'wait' };
60
+ return { action: 'retry', step: step.id, n: row.failures };
61
+ }
62
+ }
@@ -110,6 +110,7 @@ export class RecurringWork {
110
110
  checks,
111
111
  })),
112
112
  acceptance: task.acceptance,
113
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
113
114
  deliverables: task.deliverables,
114
115
  limits: task.limits,
115
116
  };
@@ -54,9 +54,12 @@ export class RepositorySnapshots {
54
54
  }
55
55
  read(binding, args) {
56
56
  const tree = this.tree(binding), action = string(args.action, 'action', 32), offset = integer(args.offset ?? 0, 'offset');
57
+ // A snapshot-only reviewer (the critic) has no workspace; leave those fields out rather than
58
+ // undefined, since every read result is digested into the journal (card_5dcbd5d9be5c4183).
57
59
  const identity = {
58
- workspace_id: binding.workspace?.id,
59
- revision: binding.workspace?.revision,
60
+ ...(binding.workspace
61
+ ? { workspace_id: binding.workspace.id, revision: binding.workspace.revision }
62
+ : {}),
60
63
  digest: binding.repository.digest,
61
64
  artifact_id: binding.repository.artifact_id,
62
65
  };
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export const runGrantPrefix = (runId) => /^run_[A-Za-z0-9]{6,}$/.test(runId) ? `runs/${runId}/` : null;
3
+ /** Whether a station patch's arguments are a write a grant for this prefix may answer. */
4
+ export function runGrantCovers(prefix, args) {
5
+ if (args.action !== 'patch' || typeof args.content !== 'string')
6
+ return false;
7
+ const path = args.path;
8
+ if (typeof path !== 'string' || !path.startsWith(prefix) || path.length === prefix.length)
9
+ return false;
10
+ return path.split('/').every((s) => s !== '' && s !== '.' && s !== '..' && !s.startsWith('.'));
11
+ }
12
+ export class RunGrants {
13
+ store;
14
+ constructor(store) {
15
+ this.store = store;
16
+ store.db.exec(`CREATE TABLE IF NOT EXISTS run_grants(
17
+ id TEXT PRIMARY KEY NOT NULL,
18
+ session_id TEXT NOT NULL,
19
+ task_id TEXT NOT NULL,
20
+ plan_revision INTEGER NOT NULL,
21
+ binding_digest TEXT NOT NULL,
22
+ path_prefix TEXT NOT NULL,
23
+ approval_id TEXT NOT NULL UNIQUE,
24
+ granted_by TEXT NOT NULL,
25
+ created_at TEXT NOT NULL,
26
+ revoked_at TEXT)`);
27
+ }
28
+ /** Within the caller's transaction, next to the decision that granted it. Idempotent per approval. */
29
+ createInTransaction(input) {
30
+ if (!this.store.db.isTransaction)
31
+ throw Error('A run grant is created with its decision');
32
+ const saved = this.store.db
33
+ .prepare('SELECT * FROM run_grants WHERE approval_id=?')
34
+ .get(input.approval_id);
35
+ if (saved)
36
+ return saved;
37
+ const grant = {
38
+ ...input,
39
+ id: randomUUID(),
40
+ created_at: new Date().toISOString(),
41
+ revoked_at: null,
42
+ };
43
+ this.store.db
44
+ .prepare('INSERT INTO run_grants VALUES (?,?,?,?,?,?,?,?,?,?)')
45
+ .run(grant.id, grant.session_id, grant.task_id, grant.plan_revision, grant.binding_digest, grant.path_prefix, grant.approval_id, grant.granted_by, grant.created_at, null);
46
+ this.store.event(grant.session_id, 'run_grant.created', {
47
+ grant_id: grant.id,
48
+ approval_id: grant.approval_id,
49
+ task_id: grant.task_id,
50
+ path_prefix: grant.path_prefix,
51
+ });
52
+ return grant;
53
+ }
54
+ /** The live grant for exactly this session, task, plan revision and binding, if any. */
55
+ active(scope) {
56
+ if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(scope.task_state))
57
+ return null;
58
+ return (this.store.db
59
+ .prepare('SELECT * FROM run_grants WHERE session_id=? AND task_id=? AND plan_revision=? AND binding_digest=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1')
60
+ .get(scope.session_id, scope.task_id, scope.plan_revision, scope.binding_digest) ?? null);
61
+ }
62
+ }