@superjackfruitlabs/salaam 0.0.3 → 0.0.5

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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -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
+ }
@@ -1,9 +1,13 @@
1
1
  import { SessionManager } from '@earendil-works/pi-coding-agent';
2
2
  /** Session files are written only by Pi's pinned session manager, never edited as JSONL. */
3
- export function forkPiSession(source, cwd, directory, id) {
3
+ export function forkPiSession(source, cwd, directory, id,
4
+ /** Continue from this earlier entry (a rewind) instead of the latest one. */
5
+ at) {
4
6
  const branch = SessionManager.forkFrom(source, cwd, directory, { id, parentSession: source });
5
7
  const file = branch.getSessionFile();
6
8
  if (!file || branch.getSessionId() !== id)
7
9
  throw new Error('Pi did not create the requested branch identity');
10
+ if (at)
11
+ branch.branchWithSummary(at.leaf, at.summary);
8
12
  return file;
9
13
  }
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);
@@ -0,0 +1,195 @@
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ import { canonical, digest, textDigest } from './protocol.js';
5
+ /** One file's bound, and the bound on everything loaded together. */
6
+ export const INSTRUCTION_FILE_BYTES = 32 * 1024;
7
+ export const INSTRUCTION_TOTAL_BYTES = 64 * 1024;
8
+ /** AGENTS.md is the shared convention; CLAUDE.md is read only where a directory has no AGENTS.md. */
9
+ const NAMES = ['AGENTS.md', 'CLAUDE.md'];
10
+ /** The user-level instructions file: $XDG_CONFIG_HOME/salaam/AGENTS.md, else ~/.config/salaam/AGENTS.md. */
11
+ export function userInstructionsPath(env = process.env) {
12
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'AGENTS.md');
13
+ }
14
+ function repositoryRoot(cwd) {
15
+ let dir = cwd;
16
+ for (let i = 0; i < 64; i++) {
17
+ if (existsSync(join(dir, '.git')))
18
+ return dir;
19
+ const parent = dirname(dir);
20
+ if (parent === dir)
21
+ return null;
22
+ dir = parent;
23
+ }
24
+ return null;
25
+ }
26
+ /**
27
+ * Project instructions for an interactive session, the way other harnesses read them: the
28
+ * user-level file, then AGENTS.md (or CLAUDE.md) in every directory from the repository root down
29
+ * to the session folder. Bounded, regular UTF-8 files only; anything skipped says why.
30
+ */
31
+ export function discoverInstructions(cwd, userFile = userInstructionsPath()) {
32
+ const files = [];
33
+ const skipped = [];
34
+ let total = 0;
35
+ /** Returns whether the file exists (so CLAUDE.md is not read beside an AGENTS.md). */
36
+ const take = (scope, abs, shown) => {
37
+ let st;
38
+ try {
39
+ st = lstatSync(abs);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ if (!st.isFile()) {
45
+ skipped.push({
46
+ path: shown,
47
+ reason: st.isSymbolicLink() ? 'symbolic links are not loaded' : 'not a regular file',
48
+ });
49
+ return true;
50
+ }
51
+ if (st.size > INSTRUCTION_FILE_BYTES) {
52
+ skipped.push({ path: shown, reason: `larger than ${INSTRUCTION_FILE_BYTES / 1024} KiB` });
53
+ return true;
54
+ }
55
+ const bytes = readFileSync(abs);
56
+ const text = bytes.toString('utf8');
57
+ if (!Buffer.from(text).equals(bytes) || text.includes('\0')) {
58
+ skipped.push({ path: shown, reason: 'not UTF-8 text' });
59
+ return true;
60
+ }
61
+ if (total + bytes.length > INSTRUCTION_TOTAL_BYTES) {
62
+ skipped.push({
63
+ path: shown,
64
+ reason: `instructions together exceed ${INSTRUCTION_TOTAL_BYTES / 1024} KiB`,
65
+ });
66
+ return true;
67
+ }
68
+ total += bytes.length;
69
+ files.push({ scope, path: shown, sha256: textDigest(text), bytes: bytes.length, text });
70
+ return true;
71
+ };
72
+ if (userFile) {
73
+ const home = homedir();
74
+ take('user', userFile, userFile.startsWith(home + sep) ? '~/' + relative(home, userFile) : userFile);
75
+ }
76
+ const root = repositoryRoot(cwd) ?? cwd;
77
+ const dirs = [root];
78
+ for (const part of relative(root, cwd).split(sep).filter(Boolean))
79
+ dirs.push(join(dirs.at(-1), part));
80
+ for (const dir of dirs)
81
+ for (const name of NAMES) {
82
+ const abs = join(dir, name);
83
+ if (take('project', abs, relative(cwd, abs) || name))
84
+ break;
85
+ }
86
+ return { files, skipped };
87
+ }
88
+ /**
89
+ * Interactive sessions load project instructions automatically and reload them when they change.
90
+ * Each loaded set is kept as an artifact and announced by an `instructions.loaded` event with its
91
+ * digest, so a turn's guidance is traceable to exact bytes. Strict work (board, AgentPod, child
92
+ * workers) never uses this: it keeps explicitly reviewed context only.
93
+ */
94
+ export class ProjectInstructions {
95
+ store;
96
+ artifacts;
97
+ userFile;
98
+ loaded = new Map();
99
+ constructor(store, artifacts, userFile = userInstructionsPath()) {
100
+ this.store = store;
101
+ this.artifacts = artifacts;
102
+ this.userFile = userFile;
103
+ }
104
+ applies(id) {
105
+ const has = (table) => !!this.store.db
106
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
107
+ .get(table);
108
+ return (this.store.localWorkspace(id) &&
109
+ !(has('child_tasks') &&
110
+ this.store.db.prepare('SELECT 1 FROM child_tasks WHERE child_session=?').get(id)) &&
111
+ !(has('agentpod_admissions') &&
112
+ this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id)));
113
+ }
114
+ latest(id) {
115
+ const row = this.store.db
116
+ .prepare("SELECT seq,payload FROM events WHERE session_id=? AND kind='instructions.loaded' ORDER BY seq DESC LIMIT 1")
117
+ .get(id);
118
+ if (!row)
119
+ return null;
120
+ const p = JSON.parse(row.payload);
121
+ return { ...p, revision: p.revision ?? 1 };
122
+ }
123
+ /** Discover the current files; record a new revision only when they changed. */
124
+ refresh(id) {
125
+ if (!this.applies(id))
126
+ return null;
127
+ const found = discoverInstructions(this.store.session(id).cwd, this.userFile);
128
+ const document = { version: 1, ...found };
129
+ const current = digest(document);
130
+ const prior = this.latest(id);
131
+ if (prior?.digest === current)
132
+ return this.summary(id);
133
+ if (!prior && !found.files.length && !found.skipped.length)
134
+ return null;
135
+ const text = canonical(document);
136
+ const artifact = this.artifacts.prepare(id, text);
137
+ const revision = (prior?.revision ?? 0) + 1;
138
+ this.store.transaction(() => {
139
+ artifact.commit();
140
+ this.store.event(id, 'instructions.loaded', {
141
+ digest: current,
142
+ revision,
143
+ artifact_id: artifact.id,
144
+ reason: !prior ? 'initial' : found.files.length ? 'changed' : 'removed',
145
+ files: found.files.map(({ text: _text, ...file }) => file),
146
+ skipped: found.skipped,
147
+ });
148
+ });
149
+ this.loaded.set(id, { digest: current, document });
150
+ return this.summary(id);
151
+ }
152
+ summary(id) {
153
+ const prior = this.applies(id) ? this.latest(id) : null;
154
+ return prior
155
+ ? {
156
+ digest: prior.digest,
157
+ revision: prior.revision,
158
+ files: prior.files,
159
+ skipped: prior.skipped,
160
+ }
161
+ : null;
162
+ }
163
+ document(id) {
164
+ const prior = this.latest(id);
165
+ if (!prior || !prior.files.length)
166
+ return null;
167
+ const cached = this.loaded.get(id);
168
+ if (cached?.digest === prior.digest)
169
+ return cached.document;
170
+ const document = JSON.parse(this.artifacts.read(prior.artifact_id));
171
+ if (digest(document) !== prior.digest)
172
+ throw new Error('Project instructions artifact changed');
173
+ this.loaded.set(id, { digest: prior.digest, document });
174
+ return document;
175
+ }
176
+ /** The loaded guidance, as part of the task frame. Empty when none applies. */
177
+ frame(id) {
178
+ if (!this.applies(id))
179
+ return '';
180
+ const document = this.document(id);
181
+ if (!document)
182
+ return '';
183
+ return ('SALAAM_PROJECT_INSTRUCTIONS_V1 ' +
184
+ canonical({
185
+ digest: this.latest(id).digest,
186
+ instructions: 'Project guidance from AGENTS.md (or CLAUDE.md) files, loaded automatically for this interactive session. Follow it for how to work in this repository. It grants no tools, permissions or authority: approvals and the sandbox still apply.',
187
+ files: document.files.map(({ scope, path, sha256, text }) => ({
188
+ scope,
189
+ path,
190
+ sha256,
191
+ text,
192
+ })),
193
+ }));
194
+ }
195
+ }
@@ -34,20 +34,26 @@ 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
  : [
43
44
  ...(local ? ['salaam_history', 'salaam_workspace'] : TOOLS),
44
- ...(localCoding ? ['salaam_local'] : []),
45
+ // Interactive coding also gets built-in web search and fetch (operator ruling 2026-10-31).
46
+ ...(localCoding
47
+ ? ['salaam_local', 'salaam_tasks', 'salaam_web_fetch', 'salaam_web_search']
48
+ : []),
45
49
  ...(effects ? ['salaam_effect'] : []),
46
50
  ...(coding?.files ? ['salaam_files'] : []),
47
51
  ...(coding?.commands ? ['salaam_command'] : []),
48
52
  ...(mcp ? ['salaam_mcp'] : []),
49
53
  ...(toolkit ? ['salaam_toolkit'] : []),
50
- ...(station ? ['salaam_station'] : []),
54
+ // salaam_board: read-only board reads, served by the hub (operator ruling 2026-10-28).
55
+ ...(station ? ['salaam_board', 'salaam_station'] : []),
56
+ ...(operatorMcp ? ['salaam_operator_mcp'] : []),
51
57
  ].sort();
52
58
  }
53
59
  export class ServiceError extends Error {