@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
@@ -1,12 +1,16 @@
1
+ import { BoardReads } from './board-reads.js';
2
+ import { takeImages } from './mentions.js';
3
+ import { HostBreaker, callThroughBreaker } from './host-breaker.js';
1
4
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
5
  import { execFileSync } from 'node:child_process';
3
6
  import { EventEmitter } from 'node:events';
4
- import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync } from 'node:fs';
7
+ import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync, rmSync } from 'node:fs';
5
8
  import { join, resolve } from 'node:path';
6
9
  import { PROTOCOL_VERSION, canonical, object, ServiceError, textDigest, workerTools, workerCapabilities, string, } from './protocol.js';
7
10
  import { PI_CLI, HOUSE_EXTENSION, POLICY_DIGEST, bundleDigest, VERSION, PI_VERSION, } from './config.js';
8
11
  import { readinessFingerprint } from './readiness-fingerprint.js';
9
12
  import { PiProcess, PiCommandError } from './pi.js';
13
+ import { mergeMcpProvenance } from './operator-mcp.js';
10
14
  import { OFFLINE_ROUTE, providerRoute, providerHasOutputCap, } from './provider-config.js';
11
15
  import { validateApiKey } from './credentials.js';
12
16
  import { resolveProviderAuth } from './provider-auth.js';
@@ -16,6 +20,28 @@ import { digest } from './protocol.js';
16
20
  import { readLocalWorkspace } from './local-workspace.js';
17
21
  import { repositoryPath } from './repository-snapshots.js';
18
22
  import { acpFailure, credentialStrings, parseAcpFailure, providerOutcome, redactFailureText, toolMetadata, } from './acp-outcome.js';
23
+ /** Errors a local tool call returns to the model as feedback; any other one fails the worker.
24
+ * Every code src/local-edits.ts can throw belongs here (a failed replacement is not a crash). */
25
+ export const LOCAL_TOOL_ERRORS = [
26
+ 'local_busy',
27
+ 'workspace_read_failed',
28
+ 'unsafe_path',
29
+ 'edit_mismatch',
30
+ 'workspace_changed',
31
+ 'workspace_busy',
32
+ 'workspace_limit',
33
+ 'workspace_conflict',
34
+ 'not_found',
35
+ 'binary_file',
36
+ 'line_limit',
37
+ 'invalid_field',
38
+ 'request_conflict',
39
+ 'command_conflict',
40
+ 'command_limit',
41
+ 'invalid_cursor',
42
+ 'shell_binding',
43
+ 'listen_unreachable',
44
+ ];
19
45
  export function processIdentity(pid) {
20
46
  if (!Number.isSafeInteger(pid) || pid < 1)
21
47
  return null;
@@ -37,7 +63,18 @@ export function processIdentity(pid) {
37
63
  throw Error('Invalid process start identity');
38
64
  return { start: fields[19], group: fields[2], session: fields[3] };
39
65
  };
40
- const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), executable = readlinkSync(path + '/exe'), argv = readFileSync(path + '/cmdline');
66
+ const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
67
+ // A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
68
+ executable = (() => {
69
+ try {
70
+ return readlinkSync(path + '/exe');
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'EACCES')
74
+ return 'unreadable';
75
+ throw error;
76
+ }
77
+ })(), argv = readFileSync(path + '/cmdline');
41
78
  if (!/^[a-f0-9-]{36}$/.test(boot) ||
42
79
  !argv.length ||
43
80
  argv.length > 16384 ||
@@ -58,6 +95,35 @@ export function processIdentity(pid) {
58
95
  return null;
59
96
  }
60
97
  }
98
+ /**
99
+ * When a process started, keyed to this boot. Unlike processIdentity it survives exec, so it
100
+ * names a background task whose gated shell has since become the user's command.
101
+ */
102
+ export function processStart(pid) {
103
+ if (!Number.isSafeInteger(pid) || pid < 1)
104
+ return null;
105
+ try {
106
+ if (process.platform === 'linux') {
107
+ const value = readFileSync(`/proc/${pid}/stat`, 'utf8');
108
+ const fields = value
109
+ .slice(value.lastIndexOf(') ') + 2)
110
+ .trim()
111
+ .split(/\s+/);
112
+ const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
113
+ return /^\d+$/.test(fields[19] ?? '') ? `linux-start-v1:${pid}:${boot}:${fields[19]}` : null;
114
+ }
115
+ const started = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
116
+ encoding: 'utf8',
117
+ stdio: ['ignore', 'pipe', 'ignore'],
118
+ timeout: 2000,
119
+ killSignal: 'SIGKILL',
120
+ }).trim();
121
+ return started ? `ps-start-v1:${pid}:${started}` : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
61
127
  function groupExists(pid) {
62
128
  try {
63
129
  process.kill(-pid, 0);
@@ -73,10 +139,18 @@ export class Supervisor extends EventEmitter {
73
139
  store;
74
140
  options;
75
141
  localCoding;
142
+ /** Built-in web search and fetch for interactive coding (operator ruling 2026-10-31). */
143
+ web;
144
+ /** Desk-session hooks (Phase 4b); absent means none run. */
145
+ hooks;
76
146
  tasks;
77
147
  compactionJobs = new Set();
148
+ fetchBreaker = new HostBreaker();
78
149
  managed;
79
150
  mcp;
151
+ operatorMcp;
152
+ /** Item 9: the host's pinned persona, given to every worker on this host. */
153
+ persona;
80
154
  toolkit;
81
155
  station;
82
156
  children;
@@ -118,7 +192,26 @@ export class Supervisor extends EventEmitter {
118
192
  const child = this.children?.owned(id);
119
193
  if (child)
120
194
  return JSON.parse(child.authority);
121
- return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id));
195
+ return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id), this.declaredMcp(id));
196
+ }
197
+ /** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
198
+ declaredMcp(id) {
199
+ return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
200
+ }
201
+ /** Every MCP server the session got, from both sources; persisted beside the worker. */
202
+ mcpServers(id) {
203
+ const live = this.workers.get(id)?.mcpServers;
204
+ if (live)
205
+ return structuredClone(live);
206
+ try {
207
+ const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
208
+ return existsSync(path)
209
+ ? JSON.parse(readFileSync(path, 'utf8'))
210
+ : undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
122
215
  }
123
216
  childBoundary(id, epoch) {
124
217
  const child = this.children?.owned(id);
@@ -149,6 +242,10 @@ export class Supervisor extends EventEmitter {
149
242
  }
150
243
  bootId = randomUUID();
151
244
  workers = new Map();
245
+ boardReadsStore;
246
+ get boardReads() {
247
+ return (this.boardReadsStore ??= new BoardReads(this.store));
248
+ }
152
249
  monitor;
153
250
  bundle;
154
251
  constructor(store, options) {
@@ -276,6 +373,18 @@ export class Supervisor extends EventEmitter {
276
373
  worker.mcp = await this.mcp.ready(id);
277
374
  if (this.toolkit && !child)
278
375
  worker.toolkit = await this.toolkit.ready(id);
376
+ if (this.declaredMcp(id))
377
+ worker.operatorMcp = this.operatorMcp.provenance();
378
+ if (worker.operatorMcp || worker.mcp) {
379
+ // Collisions are refused at admission; nothing is silently shadowed.
380
+ worker.mcpServers = mergeMcpProvenance(worker.operatorMcp ?? [], (worker.mcp?.servers ?? []).map((server) => ({
381
+ name: server.name,
382
+ source: 'agentpod',
383
+ transport: 'http',
384
+ endpoint_digest: server.endpoint_digest,
385
+ declaration_digest: digest(server),
386
+ })));
387
+ }
279
388
  if (this.station && !child)
280
389
  worker.station = this.station.ready(id);
281
390
  this.store.execution.assert(id);
@@ -290,6 +399,11 @@ export class Supervisor extends EventEmitter {
290
399
  }), { mode: 0o600 });
291
400
  const sessionDir = join(workerDir, 'sessions');
292
401
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
402
+ const provenancePath = join(workerDir, 'mcp-servers.json');
403
+ if (worker.mcpServers)
404
+ writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
405
+ else
406
+ rmSync(provenancePath, { force: true });
293
407
  const config = {
294
408
  socket: this.options.bridgeSocket,
295
409
  token: worker.token,
@@ -306,8 +420,11 @@ export class Supervisor extends EventEmitter {
306
420
  local: this.store.localWorkspace(id),
307
421
  localCoding: this.store.localCoding(id),
308
422
  ...(worker.mcp ? { mcp: worker.mcp } : {}),
423
+ ...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
424
+ ...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
309
425
  ...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
310
426
  ...(worker.station ? { station: worker.station } : {}),
427
+ ...(this.persona ? { persona: this.persona } : {}),
311
428
  };
312
429
  const args = [
313
430
  PI_CLI,
@@ -368,6 +485,16 @@ export class Supervisor extends EventEmitter {
368
485
  worker.pi.on('activity', (observation) => {
369
486
  if (!worker.attempt || worker.stopping)
370
487
  return;
488
+ // PostToolUse runs after the tool and cannot change it; it is recorded like any hook.
489
+ if (observation.phase === 'finished' && this.hooks) {
490
+ const input = parsedInput(observation.input);
491
+ void this.hook(id, 'PostToolUse', {
492
+ tool_name: observation.tool,
493
+ tool_input: input,
494
+ tool_use_id: observation.tool_call_id,
495
+ tool_response: { output: observation.output ?? '', failed: !!observation.failed },
496
+ }, [observation.tool, ...hookAliases(observation.tool, input)]).catch(() => { });
497
+ }
371
498
  try {
372
499
  this.store.request(`tool-activity:${id}:${epoch}:${worker.attempt}`, `${observation.phase}:${observation.tool_call_id}`, observation, () => {
373
500
  this.store.event(id, `tool.${observation.phase}`, {
@@ -543,6 +670,58 @@ export class Supervisor extends EventEmitter {
543
670
  }
544
671
  return w;
545
672
  }
673
+ /** Is this attempt a task step that has acceptance checks to decide it? */
674
+ /** A person's own local coding session: not a delegated child and not AgentPod-admitted work,
675
+ * whose strict semantics stay as they are. */
676
+ isInteractiveCoding(id) {
677
+ return this.interactiveCoding(id);
678
+ }
679
+ /** Run this desk session's hooks for `event`; other sessions (board, station, children) run none. */
680
+ async hook(id, event, payload, names = []) {
681
+ if (!this.hooks || !this.interactiveCoding(id))
682
+ return null;
683
+ return this.hooks.fire(id, event, payload, names);
684
+ }
685
+ /** A PreToolUse hook that blocks turns the tool call into an error the model reads. */
686
+ async preToolUse(id, tool, input, toolCallId) {
687
+ const out = await this.hook(id, 'PreToolUse', { tool_name: tool, tool_input: input, tool_use_id: toolCallId }, [tool, ...hookAliases(tool, input)]);
688
+ if (out?.blocked)
689
+ throw new ServiceError('hook_blocked', `Blocked by a PreToolUse hook: ${out.blocked}`);
690
+ }
691
+ interactiveCoding(id) {
692
+ return (this.store.localCoding(id) &&
693
+ !this.children?.owned(id) &&
694
+ !this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id));
695
+ }
696
+ checkedStepAttempt(attempt) {
697
+ const row = this.store.db
698
+ .prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
699
+ .get(attempt);
700
+ try {
701
+ const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
702
+ return Array.isArray(checks) && checks.length > 0;
703
+ }
704
+ catch {
705
+ return false;
706
+ }
707
+ }
708
+ /** A station call refused before it runs leaves no station_calls row, so record its
709
+ * method, requested action and refusal code: never its path, arguments or content. */
710
+ recordStationRefusal(id, method, body, error) {
711
+ const args = body.args && typeof body.args === 'object' ? body.args : {};
712
+ const ident = (v) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
713
+ try {
714
+ this.store.transaction(() => this.store.event(id, 'station.call_refused', {
715
+ attempt_id: this.workers.get(id)?.attempt ?? null,
716
+ method: ident(method),
717
+ action: ident(args.action),
718
+ code: error instanceof ServiceError ? ident(error.code) : 'error',
719
+ }));
720
+ }
721
+ catch {
722
+ // Recording a refusal never changes the refusal itself.
723
+ }
724
+ }
546
725
  async bridge(id, method, token, body) {
547
726
  const w = this.authenticate(id, token, body);
548
727
  const route = w.route;
@@ -587,8 +766,14 @@ export class Supervisor extends EventEmitter {
587
766
  context_digest: textDigest(w.frame),
588
767
  ...(boundary ? { worker_binding: boundary } : {}),
589
768
  ...(w.mcp ? { mcp: w.mcp } : {}),
769
+ ...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
770
+ ...(w.mcpServers
771
+ ? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
772
+ : {}),
590
773
  ...(w.toolkit ? { toolkit: w.toolkit } : {}),
591
774
  ...(w.station ? { station: w.station } : {}),
775
+ // Item 9: the worker proves it runs with the host's pinned persona.
776
+ ...(this.persona ? { persona_digest: this.persona.digest } : {}),
592
777
  };
593
778
  for (const [key, value] of Object.entries(expected))
594
779
  if (canonical(body[key]) !== canonical(value))
@@ -680,6 +865,45 @@ export class Supervisor extends EventEmitter {
680
865
  w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
681
866
  return auth;
682
867
  }
868
+ if (method.startsWith('operator_mcp.')) {
869
+ if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
870
+ throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
871
+ this.assertActive(id, w.epoch, w.attempt);
872
+ if (method === 'operator_mcp.catalog')
873
+ return await this.operatorMcp.catalog(id, body.server, body.tool);
874
+ if (method !== 'operator_mcp.call')
875
+ throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
876
+ const attempt = w.attempt;
877
+ const args = body.arguments ?? {};
878
+ const server = String(body.server ?? ''), tool = String(body.tool ?? '');
879
+ // A host failing again and again is paused briefly (never called, so no operator MCP call
880
+ // is recorded); the agent gets a tool error and the run carries on.
881
+ return await callThroughBreaker(this.fetchBreaker, id, args, (paused) => this.store.transaction(() => this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused })), async () => {
882
+ try {
883
+ const result = await this.operatorMcp.call(id, body.server, body.tool, args);
884
+ this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
885
+ attempt_id: attempt,
886
+ server,
887
+ tool,
888
+ argument_digest: digest(args),
889
+ outcome: result.is_error ? 'tool_error' : 'returned',
890
+ result_digest: digest(result),
891
+ }));
892
+ return result;
893
+ }
894
+ catch (error) {
895
+ this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
896
+ attempt_id: attempt,
897
+ server,
898
+ tool,
899
+ argument_digest: digest(args),
900
+ outcome: 'failed',
901
+ error: error.code ?? 'mcp_server_failed',
902
+ }));
903
+ throw error;
904
+ }
905
+ });
906
+ }
683
907
  if (method.startsWith('toolkit.')) {
684
908
  if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
685
909
  throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
@@ -732,6 +956,15 @@ export class Supervisor extends EventEmitter {
732
956
  : {}),
733
957
  };
734
958
  }
959
+ if (method === 'board.read' || method === 'board.status') {
960
+ // Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
961
+ if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
962
+ throw new ServiceError('station_disabled', 'Board reads need protected station work');
963
+ this.assertActive(id, w.epoch, w.attempt);
964
+ return method === 'board.read'
965
+ ? this.boardReads.request(id, string(body.tool_call_id, 'tool_call_id', 512), object(body.args))
966
+ : this.boardReads.get(id, string(body.read_id, 'read_id', 64));
967
+ }
735
968
  if (method.startsWith('station.')) {
736
969
  if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
737
970
  throw new ServiceError('station_disabled', 'Station execution is unavailable');
@@ -750,6 +983,77 @@ export class Supervisor extends EventEmitter {
750
983
  return this.station.view(c.id);
751
984
  throw new ServiceError('invalid_field', 'Unsupported station method');
752
985
  }
986
+ if (method === 'web.search' || method === 'web.fetch') {
987
+ if (!this.web || !this.store.localCoding(id) || this.children?.owned(id) || !w.attempt)
988
+ throw new ServiceError('web_denied', 'Web search is not admitted for this session', 403);
989
+ this.assertActive(id, w.epoch, w.attempt);
990
+ const args = object(body.args);
991
+ const toolCallId = string(body.tool_call_id, 'tool_call_id', 512);
992
+ await this.preToolUse(id, method === 'web.search' ? 'salaam_web_search' : 'salaam_web_fetch', args, toolCallId);
993
+ if (method === 'web.search') {
994
+ const out = await this.web.search({
995
+ query: string(args.query, 'query', 300),
996
+ ...(typeof args.kind === 'string'
997
+ ? { kind: args.kind }
998
+ : {}),
999
+ ...(typeof args.limit === 'number' ? { limit: args.limit } : {}),
1000
+ });
1001
+ // Recorded: kind, backends tried, which answered and how long. Never a key or the query.
1002
+ this.store.transaction(() => this.store.event(id, 'web.search', {
1003
+ attempt_id: w.attempt,
1004
+ tool_call_id: toolCallId,
1005
+ kind: out.kind,
1006
+ cached: !!out.cached,
1007
+ results: out.results.length,
1008
+ tried: out.tried,
1009
+ }));
1010
+ return out;
1011
+ }
1012
+ const url = string(args.url, 'url', 2000);
1013
+ let host = '';
1014
+ try {
1015
+ host = new URL(url).hostname;
1016
+ }
1017
+ catch {
1018
+ /* fetchPage refuses it */
1019
+ }
1020
+ try {
1021
+ const page = await (await import('./web-fetch.js')).fetchPage(url);
1022
+ this.store.transaction(() => this.store.event(id, 'web.fetch', {
1023
+ attempt_id: w.attempt,
1024
+ tool_call_id: toolCallId,
1025
+ host,
1026
+ status: page.status,
1027
+ chars: page.text.length,
1028
+ truncated: page.truncated,
1029
+ }));
1030
+ const { renderFetched } = await import('./web-fetch.js');
1031
+ return { ok: true, url: page.url, text: renderFetched(page) };
1032
+ }
1033
+ catch (error) {
1034
+ const message = String(error.message ?? error).slice(0, 300);
1035
+ this.store.transaction(() => this.store.event(id, 'web.fetch', {
1036
+ attempt_id: w.attempt,
1037
+ tool_call_id: toolCallId,
1038
+ host,
1039
+ error: message,
1040
+ }));
1041
+ return { ok: false, url, error: message };
1042
+ }
1043
+ }
1044
+ if (method.startsWith('bgtask.')) {
1045
+ if (!this.localCoding || !this.interactiveCoding(id) || !w.attempt)
1046
+ throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
1047
+ this.assertActive(id, w.epoch, w.attempt);
1048
+ if (method === 'bgtask.list')
1049
+ return { tasks: this.localCoding.tasks(id) };
1050
+ const task = string(body.task_id, 'task_id', 64);
1051
+ if (method === 'bgtask.output')
1052
+ return this.localCoding.taskOutput(id, task);
1053
+ if (method === 'bgtask.stop')
1054
+ return this.localCoding.stopTask(id, task);
1055
+ throw new ServiceError('invalid_field', 'Unsupported background task method');
1056
+ }
753
1057
  if (method.startsWith('local.') && method !== 'local.read') {
754
1058
  if (!this.localCoding ||
755
1059
  !this.store.localCoding(id) ||
@@ -757,8 +1061,21 @@ export class Supervisor extends EventEmitter {
757
1061
  !w.attempt)
758
1062
  throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
759
1063
  this.assertActive(id, w.epoch, w.attempt);
760
- if (method === 'local.prepare')
761
- return this.localCoding.prepare(id, w.epoch, w.attempt, string(body.tool_call_id, 'tool_call_id', 512), object(body.args));
1064
+ if (method === 'local.prepare') {
1065
+ const toolCall = string(body.tool_call_id, 'tool_call_id', 512);
1066
+ const args = object(body.args);
1067
+ // A retried prepare returns the recorded call; its hooks already ran.
1068
+ const fresh = !this.localCoding.hasCall(id, w.epoch, toolCall);
1069
+ if (fresh)
1070
+ await this.preToolUse(id, 'salaam_local', args, toolCall);
1071
+ const prepared = await this.localCoding.prepareReviewed(id, w.epoch, w.attempt, toolCall, args);
1072
+ if (fresh && prepared.approval?.state === 'pending')
1073
+ void this.hook(id, 'Notification', {
1074
+ notification_type: 'permission_prompt',
1075
+ message: `Salaam needs your approval for a ${String(args.action ?? 'local')} request`,
1076
+ }).catch(() => { });
1077
+ return prepared;
1078
+ }
762
1079
  const c = this.localCoding.get(string(body.call_id, 'call_id'));
763
1080
  if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
764
1081
  throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
@@ -774,6 +1091,7 @@ export class Supervisor extends EventEmitter {
774
1091
  this.assertActive(id, w.epoch, w.attempt);
775
1092
  this.store.taskGrantCheck?.(id);
776
1093
  const args = object(body.args);
1094
+ await this.preToolUse(id, 'salaam_workspace', args, string(body.tool_call_id, 'tool_call_id', 512));
777
1095
  const result = readLocalWorkspace(this.store.session(id).cwd, args);
778
1096
  this.store.transaction(() => this.store.event(id, 'workspace.read', {
779
1097
  attempt_id: w.attempt,
@@ -948,6 +1266,8 @@ export class Supervisor extends EventEmitter {
948
1266
  }
949
1267
  catch (error) {
950
1268
  this.store.taskBudgetDenied?.(id, error);
1269
+ if (method.startsWith('station.'))
1270
+ this.recordStationRefusal(id, method, body, error);
951
1271
  if (method.startsWith('station.') &&
952
1272
  error instanceof ServiceError &&
953
1273
  [
@@ -960,6 +1280,16 @@ export class Supervisor extends EventEmitter {
960
1280
  'not_found',
961
1281
  ].includes(error.code))
962
1282
  throw error;
1283
+ if (method.startsWith('operator_mcp.') &&
1284
+ error instanceof ServiceError &&
1285
+ [
1286
+ 'invalid_field',
1287
+ 'invalid_mcp_server',
1288
+ 'invalid_mcp_tool',
1289
+ 'invalid_mcp_arguments',
1290
+ 'mcp_server_failed',
1291
+ ].includes(error.code))
1292
+ throw error;
963
1293
  if ((method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
964
1294
  error instanceof ServiceError &&
965
1295
  [
@@ -989,25 +1319,13 @@ export class Supervisor extends EventEmitter {
989
1319
  'request_conflict',
990
1320
  ].includes(error.code))
991
1321
  throw error;
1322
+ // A hook that blocked a tool call is the person's policy speaking, not a broken control: the
1323
+ // model reads the reason as the tool's result (Phase 4b).
1324
+ if (error instanceof ServiceError && error.code === 'hook_blocked')
1325
+ throw error;
992
1326
  if ((method.startsWith('coding.') || method.startsWith('local.')) &&
993
1327
  error instanceof ServiceError &&
994
- [
995
- 'local_busy',
996
- 'workspace_read_failed',
997
- 'unsafe_path',
998
- 'workspace_changed',
999
- 'workspace_busy',
1000
- 'workspace_limit',
1001
- 'workspace_conflict',
1002
- 'not_found',
1003
- 'binary_file',
1004
- 'line_limit',
1005
- 'invalid_field',
1006
- 'request_conflict',
1007
- 'command_conflict',
1008
- 'command_limit',
1009
- 'invalid_cursor',
1010
- ].includes(error.code))
1328
+ LOCAL_TOOL_ERRORS.includes(error.code))
1011
1329
  throw error;
1012
1330
  if (method === 'history' &&
1013
1331
  error instanceof ServiceError &&
@@ -1055,6 +1373,7 @@ export class Supervisor extends EventEmitter {
1055
1373
  const state = {
1056
1374
  ...this.store.session(id),
1057
1375
  provider_route: this.store.providerRoute(id) ?? undefined,
1376
+ project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
1058
1377
  provider_capabilities: cap === undefined
1059
1378
  ? this.providerCapabilities()
1060
1379
  : {
@@ -1064,6 +1383,7 @@ export class Supervisor extends EventEmitter {
1064
1383
  ? null
1065
1384
  : 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
1066
1385
  },
1386
+ ...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
1067
1387
  };
1068
1388
  const w = this.workers.get(id);
1069
1389
  if (w?.invalid)
@@ -1317,16 +1637,49 @@ export class Supervisor extends EventEmitter {
1317
1637
  outcome = candidate;
1318
1638
  }
1319
1639
  }
1320
- if (value.type === 'tool_execution_end' && value.isError === true)
1640
+ // A checked task step is decided by its checks, not by a tool error the model went on
1641
+ // to recover from (operator decision, 2026-10-23). The error stays in the journal
1642
+ // (tool.finished failed=true). A delegated child turn, whose denied probes are its
1643
+ // boundary proof, and any attempt outside a checked step still fail on it, except an
1644
+ // interactive coding turn: at a desk a failed command is feedback, and the turn is
1645
+ // decided by how it ends (step 0, 2026-10-31).
1646
+ if (value.type === 'tool_execution_end' &&
1647
+ value.isError === true &&
1648
+ (this.children?.owned(id) ||
1649
+ (!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id))))
1321
1650
  outcome ??= {
1322
1651
  version: 1,
1323
1652
  stopReason: 'error',
1324
- failure: acpFailure(new ServiceError('tool_failed', 'Tool execution failed')),
1653
+ failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
1325
1654
  };
1326
1655
  };
1327
1656
  w.pi.on('record', record);
1328
1657
  try {
1329
- const response = await w.pi.command('prompt', { message: work.submission.text });
1658
+ // Interactive sessions pick up AGENTS.md edits at the start of each turn (recorded as a new
1659
+ // instructions.loaded revision). A discovery failure never blocks the turn.
1660
+ try {
1661
+ this.tasks.instructions?.refresh(id);
1662
+ }
1663
+ catch {
1664
+ /* the previously loaded guidance stays in force */
1665
+ }
1666
+ // The folder as it was before this turn, so /rewind can return to it.
1667
+ if (this.interactiveCoding(id))
1668
+ this.tasks.checkpoints?.capture(id, work.attempt.id);
1669
+ // UserPromptSubmit hooks may stop the prompt or add context to it (Phase 4b).
1670
+ let message = work.submission.text;
1671
+ const gate = await this.hook(id, 'UserPromptSubmit', { prompt: message });
1672
+ if (gate?.blocked) {
1673
+ this.store.finish(work.attempt.id, 'failed', null, 'Blocked by a UserPromptSubmit hook: ' + gate.blocked);
1674
+ return true;
1675
+ }
1676
+ if (gate?.context)
1677
+ message += '\n\n<hook-context>\n' + gate.context + '\n</hook-context>';
1678
+ const images = takeImages(this.store.db, work.submission.id);
1679
+ const response = await w.pi.command('prompt', {
1680
+ message,
1681
+ ...(images.length ? { images } : {}),
1682
+ });
1330
1683
  const disposition = object(response.data ?? {}).disposition;
1331
1684
  if (disposition !== 'started')
1332
1685
  throw new Error('Pi did not start the dispatched prompt');
@@ -1338,6 +1691,7 @@ export class Supervisor extends EventEmitter {
1338
1691
  const result = object((await w.pi.command('get_last_assistant_text')).data);
1339
1692
  this.childBoundary(id, w.epoch);
1340
1693
  this.store.finish(work.attempt.id, outcome ? 'failed' : 'completed', String(result.text ?? ''), outcome?.failure?.message ?? null, { epoch: w.epoch, ...entries }, outcome);
1694
+ void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => { });
1341
1695
  }
1342
1696
  catch (error) {
1343
1697
  const message = redactFailureText(error.message, w.secrets);
@@ -1386,6 +1740,7 @@ export class Supervisor extends EventEmitter {
1386
1740
  try {
1387
1741
  this.managed?.invalidateWorker(w.id, w.epoch);
1388
1742
  this.cancelMcpWorker(w);
1743
+ void this.operatorMcp?.stopSession(w.id).catch(() => { });
1389
1744
  this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
1390
1745
  }
1391
1746
  catch {
@@ -1414,3 +1769,31 @@ export class Supervisor extends EventEmitter {
1414
1769
  throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
1415
1770
  }
1416
1771
  }
1772
+ /** Names the failing tool (never its arguments or output), so a failed step says which call
1773
+ * failed it. Only a plain tool identifier is echoed. */
1774
+ export function toolFailureMessage(toolName) {
1775
+ return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
1776
+ ? `Tool execution failed (tool ${toolName})`
1777
+ : 'Tool execution failed';
1778
+ }
1779
+ /** Claude Code names for Salaam's tools, so a `Bash` or `Edit|Write` matcher works unchanged. */
1780
+ export function hookAliases(tool, input) {
1781
+ if (tool === 'salaam_local')
1782
+ return input.action === 'command' ? ['Bash'] : input.action === 'edit' ? ['Edit', 'Write'] : [];
1783
+ if (tool === 'salaam_web_search')
1784
+ return ['WebSearch'];
1785
+ if (tool === 'salaam_web_fetch')
1786
+ return ['WebFetch'];
1787
+ if (tool === 'salaam_workspace')
1788
+ return ['Read'];
1789
+ return [];
1790
+ }
1791
+ function parsedInput(input) {
1792
+ try {
1793
+ const value = JSON.parse(input ?? '{}');
1794
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1795
+ }
1796
+ catch {
1797
+ return {};
1798
+ }
1799
+ }