@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
package/src/supervisor.ts CHANGED
@@ -1,7 +1,10 @@
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 {
7
10
  CAPABILITIES,
@@ -32,6 +35,7 @@ import type { ManagedWork } from './managed.js';
32
35
  import type { McpWork, McpWorkerBinding } from './mcp-work.js';
33
36
  import type { StationWork, StationBinding } from './station-work.js';
34
37
  import type { ToolkitWork, ToolkitWorkerBinding } from './toolkit-work.js';
38
+ import { mergeMcpProvenance, type McpServerProvenance, type OperatorMcp } from './operator-mcp.js';
35
39
  import type { ChildWork } from './children.js';
36
40
  import {
37
41
  OFFLINE_ROUTE,
@@ -81,6 +85,8 @@ export interface SupervisorOptions {
81
85
  }
82
86
  interface Worker {
83
87
  mcp?: McpWorkerBinding;
88
+ operatorMcp?: McpServerProvenance[];
89
+ mcpServers?: McpServerProvenance[];
84
90
  toolkit?: ToolkitWorkerBinding;
85
91
  station?: StationBinding;
86
92
  secrets?: string[];
@@ -105,6 +111,28 @@ interface Worker {
105
111
  resolve: () => void;
106
112
  reject: (error: Error) => void;
107
113
  }
114
+ /** Errors a local tool call returns to the model as feedback; any other one fails the worker.
115
+ * Every code src/local-edits.ts can throw belongs here (a failed replacement is not a crash). */
116
+ export const LOCAL_TOOL_ERRORS = [
117
+ 'local_busy',
118
+ 'workspace_read_failed',
119
+ 'unsafe_path',
120
+ 'edit_mismatch',
121
+ 'workspace_changed',
122
+ 'workspace_busy',
123
+ 'workspace_limit',
124
+ 'workspace_conflict',
125
+ 'not_found',
126
+ 'binary_file',
127
+ 'line_limit',
128
+ 'invalid_field',
129
+ 'request_conflict',
130
+ 'command_conflict',
131
+ 'command_limit',
132
+ 'invalid_cursor',
133
+ 'shell_binding',
134
+ 'listen_unreachable',
135
+ ];
108
136
  export function processIdentity(pid: number): string | null {
109
137
  if (!Number.isSafeInteger(pid) || pid < 1) return null;
110
138
  try {
@@ -126,7 +154,15 @@ export function processIdentity(pid: number): string | null {
126
154
  };
127
155
  const before = stat(),
128
156
  boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
129
- executable = readlinkSync(path + '/exe'),
157
+ // A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
158
+ executable = (() => {
159
+ try {
160
+ return readlinkSync(path + '/exe');
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code === 'EACCES') return 'unreadable';
163
+ throw error;
164
+ }
165
+ })(),
130
166
  argv = readFileSync(path + '/cmdline');
131
167
  if (
132
168
  !/^[a-f0-9-]{36}$/.test(boot) ||
@@ -153,6 +189,33 @@ export function processIdentity(pid: number): string | null {
153
189
  return null;
154
190
  }
155
191
  }
192
+ /**
193
+ * When a process started, keyed to this boot. Unlike processIdentity it survives exec, so it
194
+ * names a background task whose gated shell has since become the user's command.
195
+ */
196
+ export function processStart(pid: number): string | null {
197
+ if (!Number.isSafeInteger(pid) || pid < 1) return null;
198
+ try {
199
+ if (process.platform === 'linux') {
200
+ const value = readFileSync(`/proc/${pid}/stat`, 'utf8');
201
+ const fields = value
202
+ .slice(value.lastIndexOf(') ') + 2)
203
+ .trim()
204
+ .split(/\s+/);
205
+ const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
206
+ return /^\d+$/.test(fields[19] ?? '') ? `linux-start-v1:${pid}:${boot}:${fields[19]}` : null;
207
+ }
208
+ const started = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
209
+ encoding: 'utf8',
210
+ stdio: ['ignore', 'pipe', 'ignore'],
211
+ timeout: 2000,
212
+ killSignal: 'SIGKILL',
213
+ }).trim();
214
+ return started ? `ps-start-v1:${pid}:${started}` : null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
156
219
  function groupExists(pid: number): boolean {
157
220
  try {
158
221
  process.kill(-pid, 0);
@@ -164,10 +227,18 @@ function groupExists(pid: number): boolean {
164
227
  }
165
228
  export class Supervisor extends EventEmitter {
166
229
  localCoding?: import('./local-coding.js').LocalCoding;
230
+ /** Built-in web search and fetch for interactive coding (operator ruling 2026-10-31). */
231
+ web?: import('./web-search.js').WebSearch;
232
+ /** Desk-session hooks (Phase 4b); absent means none run. */
233
+ hooks?: import('./hooks.js').Hooks;
167
234
  tasks!: TaskContext;
168
235
  private compactionJobs = new Set<Promise<void>>();
236
+ private readonly fetchBreaker = new HostBreaker();
169
237
  managed?: ManagedWork;
170
238
  mcp?: McpWork;
239
+ operatorMcp?: OperatorMcp;
240
+ /** Item 9: the host's pinned persona, given to every worker on this host. */
241
+ persona?: import('./persona.js').Persona;
171
242
  toolkit?: ToolkitWork;
172
243
  station?: StationWork;
173
244
  children?: ChildWork;
@@ -222,8 +293,26 @@ export class Supervisor extends EventEmitter {
222
293
  !!this.station,
223
294
  this.store.localWorkspace(id),
224
295
  this.store.localCoding(id),
296
+ this.declaredMcp(id),
225
297
  );
226
298
  }
299
+ /** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
300
+ private declaredMcp(id: string): boolean {
301
+ return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
302
+ }
303
+ /** Every MCP server the session got, from both sources; persisted beside the worker. */
304
+ mcpServers(id: string): McpServerProvenance[] | undefined {
305
+ const live = this.workers.get(id)?.mcpServers;
306
+ if (live) return structuredClone(live);
307
+ try {
308
+ const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
309
+ return existsSync(path)
310
+ ? (JSON.parse(readFileSync(path, 'utf8')) as McpServerProvenance[])
311
+ : undefined;
312
+ } catch {
313
+ return undefined;
314
+ }
315
+ }
227
316
  private childBoundary(id: string, epoch: number) {
228
317
  const child = this.children?.owned(id);
229
318
  return child ? this.store.workerBoundary.assert(child, epoch, this.routeFor(id)) : undefined;
@@ -255,6 +344,10 @@ export class Supervisor extends EventEmitter {
255
344
  }
256
345
  readonly bootId = randomUUID();
257
346
  private workers = new Map<string, Worker>();
347
+ private boardReadsStore?: BoardReads;
348
+ get boardReads(): BoardReads {
349
+ return (this.boardReadsStore ??= new BoardReads(this.store));
350
+ }
258
351
  private monitor: NodeJS.Timeout;
259
352
  readonly bundle: string;
260
353
  constructor(
@@ -406,6 +499,20 @@ export class Supervisor extends EventEmitter {
406
499
  const child = this.children?.owned(id);
407
500
  if (this.mcp && !child) worker.mcp = await this.mcp.ready(id);
408
501
  if (this.toolkit && !child) worker.toolkit = await this.toolkit.ready(id);
502
+ if (this.declaredMcp(id)) worker.operatorMcp = this.operatorMcp!.provenance();
503
+ if (worker.operatorMcp || worker.mcp) {
504
+ // Collisions are refused at admission; nothing is silently shadowed.
505
+ worker.mcpServers = mergeMcpProvenance(
506
+ worker.operatorMcp ?? [],
507
+ (worker.mcp?.servers ?? []).map((server) => ({
508
+ name: server.name,
509
+ source: 'agentpod' as const,
510
+ transport: 'http' as const,
511
+ endpoint_digest: server.endpoint_digest,
512
+ declaration_digest: digest(server),
513
+ })),
514
+ );
515
+ }
409
516
  if (this.station && !child) worker.station = this.station.ready(id);
410
517
  this.store.execution.assert(id);
411
518
  if (worker.stopping || worker.invalid) throw new Error('Worker startup cancelled');
@@ -421,6 +528,10 @@ export class Supervisor extends EventEmitter {
421
528
  );
422
529
  const sessionDir = join(workerDir, 'sessions');
423
530
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
531
+ const provenancePath = join(workerDir, 'mcp-servers.json');
532
+ if (worker.mcpServers)
533
+ writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
534
+ else rmSync(provenancePath, { force: true });
424
535
  const config = {
425
536
  socket: this.options.bridgeSocket,
426
537
  token: worker.token,
@@ -437,8 +548,11 @@ export class Supervisor extends EventEmitter {
437
548
  local: this.store.localWorkspace(id),
438
549
  localCoding: this.store.localCoding(id),
439
550
  ...(worker.mcp ? { mcp: worker.mcp } : {}),
551
+ ...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
552
+ ...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
440
553
  ...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
441
554
  ...(worker.station ? { station: worker.station } : {}),
555
+ ...(this.persona ? { persona: this.persona } : {}),
442
556
  };
443
557
  const args = [
444
558
  PI_CLI,
@@ -498,6 +612,21 @@ export class Supervisor extends EventEmitter {
498
612
  });
499
613
  worker.pi.on('activity', (observation: import('./pi.js').ToolObservation) => {
500
614
  if (!worker.attempt || worker.stopping) return;
615
+ // PostToolUse runs after the tool and cannot change it; it is recorded like any hook.
616
+ if (observation.phase === 'finished' && this.hooks) {
617
+ const input = parsedInput(observation.input);
618
+ void this.hook(
619
+ id,
620
+ 'PostToolUse',
621
+ {
622
+ tool_name: observation.tool,
623
+ tool_input: input,
624
+ tool_use_id: observation.tool_call_id,
625
+ tool_response: { output: observation.output ?? '', failed: !!observation.failed },
626
+ },
627
+ [observation.tool, ...hookAliases(observation.tool, input)],
628
+ ).catch(() => {});
629
+ }
501
630
  try {
502
631
  this.store.request(
503
632
  `tool-activity:${id}:${epoch}:${worker.attempt}`,
@@ -689,6 +818,81 @@ export class Supervisor extends EventEmitter {
689
818
  }
690
819
  return w;
691
820
  }
821
+ /** Is this attempt a task step that has acceptance checks to decide it? */
822
+ /** A person's own local coding session: not a delegated child and not AgentPod-admitted work,
823
+ * whose strict semantics stay as they are. */
824
+ isInteractiveCoding(id: string): boolean {
825
+ return this.interactiveCoding(id);
826
+ }
827
+ /** Run this desk session's hooks for `event`; other sessions (board, station, children) run none. */
828
+ private async hook(
829
+ id: string,
830
+ event: import('./hooks.js').HookEvent,
831
+ payload: Record<string, unknown>,
832
+ names: readonly string[] = [],
833
+ ): Promise<import('./hooks.js').HookOutcome | null> {
834
+ if (!this.hooks || !this.interactiveCoding(id)) return null;
835
+ return this.hooks.fire(id, event, payload, names);
836
+ }
837
+ /** A PreToolUse hook that blocks turns the tool call into an error the model reads. */
838
+ private async preToolUse(
839
+ id: string,
840
+ tool: string,
841
+ input: Record<string, unknown>,
842
+ toolCallId: string,
843
+ ): Promise<void> {
844
+ const out = await this.hook(
845
+ id,
846
+ 'PreToolUse',
847
+ { tool_name: tool, tool_input: input, tool_use_id: toolCallId },
848
+ [tool, ...hookAliases(tool, input)],
849
+ );
850
+ if (out?.blocked)
851
+ throw new ServiceError('hook_blocked', `Blocked by a PreToolUse hook: ${out.blocked}`);
852
+ }
853
+ private interactiveCoding(id: string): boolean {
854
+ return (
855
+ this.store.localCoding(id) &&
856
+ !this.children?.owned(id) &&
857
+ !this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id)
858
+ );
859
+ }
860
+ private checkedStepAttempt(attempt: string): boolean {
861
+ const row = this.store.db
862
+ .prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
863
+ .get(attempt) as { checks?: string } | undefined;
864
+ try {
865
+ const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
866
+ return Array.isArray(checks) && checks.length > 0;
867
+ } catch {
868
+ return false;
869
+ }
870
+ }
871
+ /** A station call refused before it runs leaves no station_calls row, so record its
872
+ * method, requested action and refusal code: never its path, arguments or content. */
873
+ private recordStationRefusal(
874
+ id: string,
875
+ method: string,
876
+ body: Record<string, unknown>,
877
+ error: unknown,
878
+ ): void {
879
+ const args =
880
+ body.args && typeof body.args === 'object' ? (body.args as Record<string, unknown>) : {};
881
+ const ident = (v: unknown) =>
882
+ typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
883
+ try {
884
+ this.store.transaction(() =>
885
+ this.store.event(id, 'station.call_refused', {
886
+ attempt_id: this.workers.get(id)?.attempt ?? null,
887
+ method: ident(method),
888
+ action: ident(args.action),
889
+ code: error instanceof ServiceError ? ident(error.code) : 'error',
890
+ }),
891
+ );
892
+ } catch {
893
+ // Recording a refusal never changes the refusal itself.
894
+ }
895
+ }
692
896
  async bridge(
693
897
  id: string,
694
898
  method: string,
@@ -732,8 +936,14 @@ export class Supervisor extends EventEmitter {
732
936
  context_digest: textDigest(w.frame),
733
937
  ...(boundary ? { worker_binding: boundary } : {}),
734
938
  ...(w.mcp ? { mcp: w.mcp } : {}),
939
+ ...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
940
+ ...(w.mcpServers
941
+ ? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
942
+ : {}),
735
943
  ...(w.toolkit ? { toolkit: w.toolkit } : {}),
736
944
  ...(w.station ? { station: w.station } : {}),
945
+ // Item 9: the worker proves it runs with the host's pinned persona.
946
+ ...(this.persona ? { persona_digest: this.persona.digest } : {}),
737
947
  };
738
948
  for (const [key, value] of Object.entries(expected))
739
949
  if (canonical(body[key]) !== canonical(value))
@@ -839,6 +1049,58 @@ export class Supervisor extends EventEmitter {
839
1049
  w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
840
1050
  return auth;
841
1051
  }
1052
+ if (method.startsWith('operator_mcp.')) {
1053
+ if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
1054
+ throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
1055
+ this.assertActive(id, w.epoch, w.attempt);
1056
+ if (method === 'operator_mcp.catalog')
1057
+ return await this.operatorMcp!.catalog(id, body.server, body.tool);
1058
+ if (method !== 'operator_mcp.call')
1059
+ throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
1060
+ const attempt = w.attempt;
1061
+ const args = body.arguments ?? {};
1062
+ const server = String(body.server ?? ''),
1063
+ tool = String(body.tool ?? '');
1064
+ // A host failing again and again is paused briefly (never called, so no operator MCP call
1065
+ // is recorded); the agent gets a tool error and the run carries on.
1066
+ return await callThroughBreaker(
1067
+ this.fetchBreaker,
1068
+ id,
1069
+ args,
1070
+ (paused) =>
1071
+ this.store.transaction(() =>
1072
+ this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused }),
1073
+ ),
1074
+ async () => {
1075
+ try {
1076
+ const result = await this.operatorMcp!.call(id, body.server, body.tool, args);
1077
+ this.store.transaction(() =>
1078
+ this.store.event(id, 'operator_mcp.call', {
1079
+ attempt_id: attempt,
1080
+ server,
1081
+ tool,
1082
+ argument_digest: digest(args),
1083
+ outcome: result.is_error ? 'tool_error' : 'returned',
1084
+ result_digest: digest(result),
1085
+ }),
1086
+ );
1087
+ return result;
1088
+ } catch (error) {
1089
+ this.store.transaction(() =>
1090
+ this.store.event(id, 'operator_mcp.call', {
1091
+ attempt_id: attempt,
1092
+ server,
1093
+ tool,
1094
+ argument_digest: digest(args),
1095
+ outcome: 'failed',
1096
+ error: (error as ServiceError).code ?? 'mcp_server_failed',
1097
+ }),
1098
+ );
1099
+ throw error;
1100
+ }
1101
+ },
1102
+ );
1103
+ }
842
1104
  if (method.startsWith('toolkit.')) {
843
1105
  if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
844
1106
  throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
@@ -923,6 +1185,19 @@ export class Supervisor extends EventEmitter {
923
1185
  : {}),
924
1186
  };
925
1187
  }
1188
+ if (method === 'board.read' || method === 'board.status') {
1189
+ // Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
1190
+ if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
1191
+ throw new ServiceError('station_disabled', 'Board reads need protected station work');
1192
+ this.assertActive(id, w.epoch, w.attempt);
1193
+ return method === 'board.read'
1194
+ ? this.boardReads.request(
1195
+ id,
1196
+ string(body.tool_call_id, 'tool_call_id', 512),
1197
+ object(body.args),
1198
+ )
1199
+ : this.boardReads.get(id, string(body.read_id, 'read_id', 64));
1200
+ }
926
1201
  if (method.startsWith('station.')) {
927
1202
  if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
928
1203
  throw new ServiceError('station_disabled', 'Station execution is unavailable');
@@ -955,6 +1230,83 @@ export class Supervisor extends EventEmitter {
955
1230
  if (method === 'station.status') return this.station.view(c.id);
956
1231
  throw new ServiceError('invalid_field', 'Unsupported station method');
957
1232
  }
1233
+ if (method === 'web.search' || method === 'web.fetch') {
1234
+ if (!this.web || !this.store.localCoding(id) || this.children?.owned(id) || !w.attempt)
1235
+ throw new ServiceError('web_denied', 'Web search is not admitted for this session', 403);
1236
+ this.assertActive(id, w.epoch, w.attempt);
1237
+ const args = object(body.args);
1238
+ const toolCallId = string(body.tool_call_id, 'tool_call_id', 512);
1239
+ await this.preToolUse(
1240
+ id,
1241
+ method === 'web.search' ? 'salaam_web_search' : 'salaam_web_fetch',
1242
+ args,
1243
+ toolCallId,
1244
+ );
1245
+ if (method === 'web.search') {
1246
+ const out = await this.web.search({
1247
+ query: string(args.query, 'query', 300),
1248
+ ...(typeof args.kind === 'string'
1249
+ ? { kind: args.kind as import('./web-search.js').SearchKind }
1250
+ : {}),
1251
+ ...(typeof args.limit === 'number' ? { limit: args.limit } : {}),
1252
+ });
1253
+ // Recorded: kind, backends tried, which answered and how long. Never a key or the query.
1254
+ this.store.transaction(() =>
1255
+ this.store.event(id, 'web.search', {
1256
+ attempt_id: w.attempt,
1257
+ tool_call_id: toolCallId,
1258
+ kind: out.kind,
1259
+ cached: !!out.cached,
1260
+ results: out.results.length,
1261
+ tried: out.tried,
1262
+ }),
1263
+ );
1264
+ return out;
1265
+ }
1266
+ const url = string(args.url, 'url', 2000);
1267
+ let host = '';
1268
+ try {
1269
+ host = new URL(url).hostname;
1270
+ } catch {
1271
+ /* fetchPage refuses it */
1272
+ }
1273
+ try {
1274
+ const page = await (await import('./web-fetch.js')).fetchPage(url);
1275
+ this.store.transaction(() =>
1276
+ this.store.event(id, 'web.fetch', {
1277
+ attempt_id: w.attempt,
1278
+ tool_call_id: toolCallId,
1279
+ host,
1280
+ status: page.status,
1281
+ chars: page.text.length,
1282
+ truncated: page.truncated,
1283
+ }),
1284
+ );
1285
+ const { renderFetched } = await import('./web-fetch.js');
1286
+ return { ok: true, url: page.url, text: renderFetched(page) };
1287
+ } catch (error) {
1288
+ const message = String((error as Error).message ?? error).slice(0, 300);
1289
+ this.store.transaction(() =>
1290
+ this.store.event(id, 'web.fetch', {
1291
+ attempt_id: w.attempt,
1292
+ tool_call_id: toolCallId,
1293
+ host,
1294
+ error: message,
1295
+ }),
1296
+ );
1297
+ return { ok: false, url, error: message };
1298
+ }
1299
+ }
1300
+ if (method.startsWith('bgtask.')) {
1301
+ if (!this.localCoding || !this.interactiveCoding(id) || !w.attempt)
1302
+ throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
1303
+ this.assertActive(id, w.epoch, w.attempt);
1304
+ if (method === 'bgtask.list') return { tasks: this.localCoding.tasks(id) };
1305
+ const task = string(body.task_id, 'task_id', 64);
1306
+ if (method === 'bgtask.output') return this.localCoding.taskOutput(id, task);
1307
+ if (method === 'bgtask.stop') return this.localCoding.stopTask(id, task);
1308
+ throw new ServiceError('invalid_field', 'Unsupported background task method');
1309
+ }
958
1310
  if (method.startsWith('local.') && method !== 'local.read') {
959
1311
  if (
960
1312
  !this.localCoding ||
@@ -964,14 +1316,26 @@ export class Supervisor extends EventEmitter {
964
1316
  )
965
1317
  throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
966
1318
  this.assertActive(id, w.epoch, w.attempt);
967
- if (method === 'local.prepare')
968
- return this.localCoding.prepare(
1319
+ if (method === 'local.prepare') {
1320
+ const toolCall = string(body.tool_call_id, 'tool_call_id', 512);
1321
+ const args = object(body.args);
1322
+ // A retried prepare returns the recorded call; its hooks already ran.
1323
+ const fresh = !this.localCoding.hasCall(id, w.epoch, toolCall);
1324
+ if (fresh) await this.preToolUse(id, 'salaam_local', args, toolCall);
1325
+ const prepared = await this.localCoding.prepareReviewed(
969
1326
  id,
970
1327
  w.epoch,
971
1328
  w.attempt,
972
- string(body.tool_call_id, 'tool_call_id', 512),
973
- object(body.args),
1329
+ toolCall,
1330
+ args,
974
1331
  );
1332
+ if (fresh && prepared.approval?.state === 'pending')
1333
+ void this.hook(id, 'Notification', {
1334
+ notification_type: 'permission_prompt',
1335
+ message: `Salaam needs your approval for a ${String(args.action ?? 'local')} request`,
1336
+ }).catch(() => {});
1337
+ return prepared;
1338
+ }
975
1339
  const c = this.localCoding.get(string(body.call_id, 'call_id'));
976
1340
  if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
977
1341
  throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
@@ -985,6 +1349,12 @@ export class Supervisor extends EventEmitter {
985
1349
  this.assertActive(id, w.epoch, w.attempt);
986
1350
  this.store.taskGrantCheck?.(id);
987
1351
  const args = object(body.args);
1352
+ await this.preToolUse(
1353
+ id,
1354
+ 'salaam_workspace',
1355
+ args,
1356
+ string(body.tool_call_id, 'tool_call_id', 512),
1357
+ );
988
1358
  const result = readLocalWorkspace(this.store.session(id).cwd, args);
989
1359
  this.store.transaction(() =>
990
1360
  this.store.event(id, 'workspace.read', {
@@ -1211,6 +1581,7 @@ export class Supervisor extends EventEmitter {
1211
1581
  return { ok: true };
1212
1582
  } catch (error) {
1213
1583
  this.store.taskBudgetDenied?.(id, error);
1584
+ if (method.startsWith('station.')) this.recordStationRefusal(id, method, body, error);
1214
1585
  if (
1215
1586
  method.startsWith('station.') &&
1216
1587
  error instanceof ServiceError &&
@@ -1225,6 +1596,18 @@ export class Supervisor extends EventEmitter {
1225
1596
  ].includes(error.code)
1226
1597
  )
1227
1598
  throw error;
1599
+ if (
1600
+ method.startsWith('operator_mcp.') &&
1601
+ error instanceof ServiceError &&
1602
+ [
1603
+ 'invalid_field',
1604
+ 'invalid_mcp_server',
1605
+ 'invalid_mcp_tool',
1606
+ 'invalid_mcp_arguments',
1607
+ 'mcp_server_failed',
1608
+ ].includes(error.code)
1609
+ )
1610
+ throw error;
1228
1611
  if (
1229
1612
  (method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
1230
1613
  error instanceof ServiceError &&
@@ -1258,26 +1641,13 @@ export class Supervisor extends EventEmitter {
1258
1641
  ].includes(error.code)
1259
1642
  )
1260
1643
  throw error;
1644
+ // A hook that blocked a tool call is the person's policy speaking, not a broken control: the
1645
+ // model reads the reason as the tool's result (Phase 4b).
1646
+ if (error instanceof ServiceError && error.code === 'hook_blocked') throw error;
1261
1647
  if (
1262
1648
  (method.startsWith('coding.') || method.startsWith('local.')) &&
1263
1649
  error instanceof ServiceError &&
1264
- [
1265
- 'local_busy',
1266
- 'workspace_read_failed',
1267
- 'unsafe_path',
1268
- 'workspace_changed',
1269
- 'workspace_busy',
1270
- 'workspace_limit',
1271
- 'workspace_conflict',
1272
- 'not_found',
1273
- 'binary_file',
1274
- 'line_limit',
1275
- 'invalid_field',
1276
- 'request_conflict',
1277
- 'command_conflict',
1278
- 'command_limit',
1279
- 'invalid_cursor',
1280
- ].includes(error.code)
1650
+ LOCAL_TOOL_ERRORS.includes(error.code)
1281
1651
  )
1282
1652
  throw error;
1283
1653
  if (
@@ -1329,6 +1699,7 @@ export class Supervisor extends EventEmitter {
1329
1699
  const state = {
1330
1700
  ...this.store.session(id),
1331
1701
  provider_route: this.store.providerRoute(id) ?? undefined,
1702
+ project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
1332
1703
  provider_capabilities:
1333
1704
  cap === undefined
1334
1705
  ? this.providerCapabilities()
@@ -1339,6 +1710,7 @@ export class Supervisor extends EventEmitter {
1339
1710
  ? null
1340
1711
  : 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
1341
1712
  },
1713
+ ...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
1342
1714
  };
1343
1715
  const w = this.workers.get(id);
1344
1716
  if (w?.invalid) return { ...state, state: 'unhealthy', capabilities: [], error: w.invalid };
@@ -1608,16 +1980,53 @@ export class Supervisor extends EventEmitter {
1608
1980
  if (candidate.stopReason !== 'end_turn') outcome = candidate;
1609
1981
  }
1610
1982
  }
1611
- if (value.type === 'tool_execution_end' && value.isError === true)
1983
+ // A checked task step is decided by its checks, not by a tool error the model went on
1984
+ // to recover from (operator decision, 2026-10-23). The error stays in the journal
1985
+ // (tool.finished failed=true). A delegated child turn, whose denied probes are its
1986
+ // boundary proof, and any attempt outside a checked step still fail on it, except an
1987
+ // interactive coding turn: at a desk a failed command is feedback, and the turn is
1988
+ // decided by how it ends (step 0, 2026-10-31).
1989
+ if (
1990
+ value.type === 'tool_execution_end' &&
1991
+ value.isError === true &&
1992
+ (this.children?.owned(id) ||
1993
+ (!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id)))
1994
+ )
1612
1995
  outcome ??= {
1613
1996
  version: 1,
1614
1997
  stopReason: 'error',
1615
- failure: acpFailure(new ServiceError('tool_failed', 'Tool execution failed')),
1998
+ failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
1616
1999
  };
1617
2000
  };
1618
2001
  w.pi.on('record', record);
1619
2002
  try {
1620
- const response = await w.pi.command('prompt', { message: work.submission.text });
2003
+ // Interactive sessions pick up AGENTS.md edits at the start of each turn (recorded as a new
2004
+ // instructions.loaded revision). A discovery failure never blocks the turn.
2005
+ try {
2006
+ this.tasks.instructions?.refresh(id);
2007
+ } catch {
2008
+ /* the previously loaded guidance stays in force */
2009
+ }
2010
+ // The folder as it was before this turn, so /rewind can return to it.
2011
+ if (this.interactiveCoding(id)) this.tasks.checkpoints?.capture(id, work.attempt.id);
2012
+ // UserPromptSubmit hooks may stop the prompt or add context to it (Phase 4b).
2013
+ let message = work.submission.text;
2014
+ const gate = await this.hook(id, 'UserPromptSubmit', { prompt: message });
2015
+ if (gate?.blocked) {
2016
+ this.store.finish(
2017
+ work.attempt.id,
2018
+ 'failed',
2019
+ null,
2020
+ 'Blocked by a UserPromptSubmit hook: ' + gate.blocked,
2021
+ );
2022
+ return true;
2023
+ }
2024
+ if (gate?.context) message += '\n\n<hook-context>\n' + gate.context + '\n</hook-context>';
2025
+ const images = takeImages(this.store.db, work.submission.id);
2026
+ const response = await w.pi.command('prompt', {
2027
+ message,
2028
+ ...(images.length ? { images } : {}),
2029
+ });
1621
2030
  const disposition = object(response.data ?? {}).disposition;
1622
2031
  if (disposition !== 'started') throw new Error('Pi did not start the dispatched prompt');
1623
2032
  this.store.running(work.attempt.id);
@@ -1634,6 +2043,7 @@ export class Supervisor extends EventEmitter {
1634
2043
  { epoch: w.epoch, ...entries },
1635
2044
  outcome,
1636
2045
  );
2046
+ void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => {});
1637
2047
  } catch (error) {
1638
2048
  const message = redactFailureText((error as Error).message, w.secrets);
1639
2049
  if (!w.stopping && !this.store.cancelled(work.attempt.id))
@@ -1684,6 +2094,7 @@ export class Supervisor extends EventEmitter {
1684
2094
  try {
1685
2095
  this.managed?.invalidateWorker(w.id, w.epoch);
1686
2096
  this.cancelMcpWorker(w);
2097
+ void this.operatorMcp?.stopSession(w.id).catch(() => {});
1687
2098
  this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
1688
2099
  } catch {
1689
2100
  /* shutdown still owns and stops the worker */
@@ -1711,3 +2122,29 @@ export class Supervisor extends EventEmitter {
1711
2122
  throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
1712
2123
  }
1713
2124
  }
2125
+
2126
+ /** Names the failing tool (never its arguments or output), so a failed step says which call
2127
+ * failed it. Only a plain tool identifier is echoed. */
2128
+ export function toolFailureMessage(toolName: unknown): string {
2129
+ return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
2130
+ ? `Tool execution failed (tool ${toolName})`
2131
+ : 'Tool execution failed';
2132
+ }
2133
+
2134
+ /** Claude Code names for Salaam's tools, so a `Bash` or `Edit|Write` matcher works unchanged. */
2135
+ export function hookAliases(tool: string, input: Record<string, unknown>): string[] {
2136
+ if (tool === 'salaam_local')
2137
+ return input.action === 'command' ? ['Bash'] : input.action === 'edit' ? ['Edit', 'Write'] : [];
2138
+ if (tool === 'salaam_web_search') return ['WebSearch'];
2139
+ if (tool === 'salaam_web_fetch') return ['WebFetch'];
2140
+ if (tool === 'salaam_workspace') return ['Read'];
2141
+ return [];
2142
+ }
2143
+ function parsedInput(input: string | undefined): Record<string, unknown> {
2144
+ try {
2145
+ const value = JSON.parse(input ?? '{}');
2146
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
2147
+ } catch {
2148
+ return {};
2149
+ }
2150
+ }