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