@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/service.ts CHANGED
@@ -1,13 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { DangerClassifier, loadDangerClassifierOptions } from './danger-classifier.js';
3
+ import { WebSearch, loadWebSearchConfig } from './web-search.js';
1
4
  import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
2
- import { mkdirSync, chmodSync, existsSync, unlinkSync, realpathSync, statSync } from 'node:fs';
5
+ import { loadPersona } from './persona.js';
6
+ import {
7
+ mkdirSync,
8
+ chmodSync,
9
+ chownSync,
10
+ existsSync,
11
+ unlinkSync,
12
+ realpathSync,
13
+ statSync,
14
+ } from 'node:fs';
3
15
  import { join, resolve } from 'node:path';
4
16
  import { providerRoute } from './provider-config.js';
5
17
  import { TaskContext } from './task-context.js';
6
18
  import { ContextAdmission } from './context-admission.js';
19
+ import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
7
20
  import { SLASH_COMMANDS } from './tui-commands.js';
8
21
  import { TaskWork } from './task-work.js';
9
22
  import { TaskBudget } from './task-budget.js';
10
23
  import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
24
+ import { RunGrants } from './run-grants.js';
25
+ import { TaskCritic } from './task-critic.js';
11
26
  import { AgentPodWork } from './agentpod-work.js';
12
27
  import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
13
28
  import { TaskVerification } from './task-verification.js';
@@ -17,6 +32,7 @@ import { OperatorInbox } from './operator-inbox.js';
17
32
  import { RecurringWork } from './recurring-work.js';
18
33
  import { JobWork } from './job-work.js';
19
34
  import { Store } from './store.js';
35
+ import { pickerSummary } from './session-pick.js';
20
36
  import { acquireStateLock } from './state-lock.js';
21
37
  import { Artifacts } from './artifacts.js';
22
38
  import { Supervisor, type SupervisorOptions } from './supervisor.js';
@@ -41,6 +57,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
41
57
  import { A2AInbound } from './a2a-inbound.js';
42
58
  import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
43
59
  import { ToolkitResources } from './toolkit-resources.js';
60
+ import { OperatorMcp } from './operator-mcp.js';
44
61
  import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
45
62
  import { ToolkitOwners } from './toolkit-owners.js';
46
63
  import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
@@ -51,16 +68,35 @@ import { McpAdmission } from './mcp-admission.js';
51
68
  import { McpWork } from './mcp-work.js';
52
69
  import { ToolkitWork, type ToolkitWorkConfig } from './toolkit-work.js';
53
70
  import { permissionPolicy, configurePermissions } from './autonomy.js';
54
- import { LocalCoding } from './local-coding.js';
71
+ import { expandPrompt, storeImages, type PromptImage } from './mentions.js';
72
+ import { ApprovalRules } from './approval-rules.js';
73
+ import { chooseLocalSandbox, type SandboxLevel, resolveReadPaths } from './command-sandbox.js';
74
+ import { LocalCoding, type LocalSandbox } from './local-coding.js';
75
+ import { Hooks } from './hooks.js';
55
76
  import { localDiff } from './local-diff.js';
56
77
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
57
78
  import { forkPiSession } from './pi-sessions.js';
79
+ import { TurnCheckpoints, type RewindMode } from './turn-rewind.js';
58
80
  import { StationWork } from './station-work.js';
59
81
  import { readToolkitConfig } from './toolkit-config.js';
82
+ import { probeToolsUser, serviceToolIdentity, type ToolIdentity } from './tool-identity.js';
60
83
 
61
84
  export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'bridgeSocket'> {
85
+ /** The user-level AGENTS.md for interactive sessions; null turns it off. Default:
86
+ * /salaam/AGENTS.md or ~/.config/salaam/AGENTS.md. */
87
+ userInstructions?: string | null;
62
88
  hostManifestDigest?: string;
89
+ /** Receipt fields for the launcher profile; reported only by a manifest-served host. */
90
+ hostReceipt?: Record<string, unknown>;
91
+ /** Launcher profile: one group may traverse state and use the operator socket. */
92
+ operatorAccess?: { group: string; gid: number };
63
93
  agentpodConfig?: string;
94
+ /** Operator-declared MCP file (never a workspace path); absent means none declared. */
95
+ operatorMcpConfig?: string;
96
+ /** Item 9: the host's pinned persona file and the digest host plan reviewed. */
97
+ personaConfig?: { config: string; digest: string };
98
+ /** Environment that ${VAR} references resolve from; defaults to process.env. */
99
+ operatorMcpEnvironment?: NodeJS.ProcessEnv;
64
100
  managedMcp?: { config: string; profile_digest: string };
65
101
  /** Internal trusted host seam; never accepted from ACP or a worker. */
66
102
  managedToolkit?: ToolkitWorkConfig;
@@ -83,11 +119,45 @@ export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'br
83
119
  effectConnector?: EffectConnector;
84
120
  approvalTtlMs?: number;
85
121
  codingRoot?: string;
122
+ /** Unprivileged OS user for agent-initiated local commands and edits (Linux, sudo -n -u). */
123
+ toolsUser?: string;
124
+ /** Item 10: the sandbox level for agent-initiated local commands (default workspace-write).
125
+ * full-access is unsandboxed and must be chosen explicitly. */
126
+ localSandbox?: SandboxLevel;
127
+ /** Host config `unsandboxed_commands: "allow"`: run local commands unsandboxed on a host with no
128
+ * usable sandbox. Absent, they are refused (operator ruling 2026-10-30, option A). */
129
+ unsandboxedCommands?: 'allow';
130
+ /** Read-only paths outside the workspace local commands may read (resolved, `toolchain` expanded). */
131
+ localSandboxReadPaths?: string[];
132
+ /** Web search config file: optional providers (searxng, brave, tavily, exa) and their key
133
+ * references. Keyless official APIs answer without one (operator ruling 2026-10-31). */
134
+ webSearchConfig?: string;
135
+ /** Phase 4c: the optional small-model command reviewer (a JSON file); absent means off. */
136
+ dangerClassifierConfig?: string;
137
+ /** A host's reviewed pin: one option's name (sessions cannot switch or turn it off), or "off"
138
+ * (the reviewer is forbidden). Absent: sessions choose from the config's options. */
139
+ dangerClassifierPin?: string;
140
+ /** The user's approval rules file (default ~/.config/salaam/permissions.json). */
141
+ permissionsFile?: string;
142
+ /** Your hooks file (Phase 4b); defaults to ~/.config/salaam/hooks.json or $SALAAM_HOOKS_FILE. */
143
+ hooksFile?: string;
144
+ /** Host config `command_network: "broker"`: sandboxed local commands reach the network only
145
+ * through this stk local-commands socket (operator rulings 2026-10-30). */
146
+ commandNetwork?: { socket: string };
147
+ /** Phase 2b: may an interactive session choose full access? Default true for local desk use;
148
+ * a host started from a manifest passes its reviewed `full_access_sessions` setting. */
149
+ allowFullAccessSessions?: boolean;
150
+ /** Whether an interactive session may choose YOLO (no sandbox, no approvals). Desk default allows
151
+ * it; a host started from a manifest passes its reviewed `yolo_sessions` setting. */
152
+ allowYoloSessions?: boolean;
153
+ /** Internal test seam: argv prefix replacing `sudo -n -u <toolsUser> --`. */
154
+ toolsLauncher?: string[];
86
155
  codingSocket?: string;
87
156
  codingBackend?: ExecutionBackend;
88
157
  expectedExecution?: { profile_digest: string; engine_id: string };
89
158
  }
90
159
  export class SalaamService {
160
+ operatorMcpPath?: string;
91
161
  readonly agentpod?: AgentPodAuthority;
92
162
  readonly agentpodWork?: AgentPodWork;
93
163
  readonly toolkitResources: ToolkitResources;
@@ -124,6 +194,7 @@ export class SalaamService {
124
194
  readonly workspaces: Workspaces;
125
195
  readonly coding: CodingWork;
126
196
  readonly localCoding: LocalCoding;
197
+ readonly toolIdentity: ToolIdentity;
127
198
  readonly codingSocket: string | null;
128
199
  readonly closed: Promise<void>;
129
200
  private resolveClosed!: () => void;
@@ -136,12 +207,35 @@ export class SalaamService {
136
207
  private revocationStops = new Set<Promise<void>>();
137
208
  private authorityTimer?: NodeJS.Timeout;
138
209
  private readonly hostManifestDigest: string | null;
210
+ private readonly hostReceipt: Record<string, unknown> | null;
211
+ private readonly operatorAccess: { group: string; gid: number } | null;
139
212
  private readonly agentpodWorkProgression: boolean;
213
+ /** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
214
+ private readonly allowFullAccessSessions: boolean;
215
+ private readonly allowYoloSessions: boolean;
216
+ private readonly reviewers = new Map<string, DangerClassifier>();
217
+ private reviewerDefault = 'off';
218
+ private reviewerPin?: string;
219
+ /** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
220
+ private reviewerChoice(session: string): string {
221
+ if (this.reviewerPin) return this.reviewerPin;
222
+ const row = this.store.db
223
+ .prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
224
+ .get(session) as { choice: string } | undefined;
225
+ const choice = row?.choice ?? this.reviewerDefault;
226
+ return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
227
+ }
140
228
  private constructor(options: ServiceOptions) {
229
+ this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
230
+ this.allowYoloSessions = options.allowYoloSessions !== false;
141
231
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
142
232
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
143
233
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
144
234
  this.hostManifestDigest = options.hostManifestDigest ?? null;
235
+ this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
236
+ if (options.operatorAccess && !this.agentpodWorkProgression)
237
+ throw Error('Operator group access is only for the strict work launcher profile');
238
+ this.operatorAccess = options.operatorAccess ?? null;
145
239
  if (process.platform === 'win32') throw new Error('Windows transport is not implemented');
146
240
  this.stateDir = resolve(options.stateDir);
147
241
  if (options.managedMcp && !options.agentpodConfig)
@@ -169,6 +263,9 @@ export class SalaamService {
169
263
  this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
170
264
  mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
171
265
  this.stateDir = realpathSync(this.stateDir);
266
+ this.toolIdentity = options.toolsUser
267
+ ? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
268
+ : serviceToolIdentity();
172
269
  const mcpAdmission = options.managedMcp
173
270
  ? new McpAdmission(
174
271
  options.managedMcp.config,
@@ -179,14 +276,34 @@ export class SalaamService {
179
276
  this.socket = join(this.stateDir, 'operator.sock');
180
277
  this.bridgeSocket = join(this.stateDir, 'bridge.sock');
181
278
  if (Buffer.byteLength(this.bridgeSocket) > 100)
182
- throw new Error('State path is too long for a Unix socket; choose a shorter --state path');
279
+ throw new Error(
280
+ `State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
281
+ 'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.',
282
+ );
183
283
  this.release = acquireStateLock(this.stateDir);
184
284
  try {
185
285
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
286
+ this.store.toolExecution = {
287
+ mode: this.toolIdentity.mode,
288
+ user: this.toolIdentity.user,
289
+ uid: this.toolIdentity.uid,
290
+ };
186
291
  try {
187
292
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
188
293
  this.tasks = new TaskContext(this.store, this.artifacts);
189
294
  this.tasks.admitted = new ContextAdmission(this.store);
295
+ this.tasks.instructions = new ProjectInstructions(
296
+ this.store,
297
+ this.artifacts,
298
+ options.userInstructions === undefined
299
+ ? userInstructionsPath()
300
+ : options.userInstructions,
301
+ );
302
+ this.tasks.checkpoints = new TurnCheckpoints(
303
+ this.store,
304
+ this.stateDir,
305
+ this.toolIdentity.mode !== 'tools-user',
306
+ );
190
307
  this.taskWork = new TaskWork(this.store, this.tasks);
191
308
  this.taskBudget = new TaskBudget(this.store, this.taskWork);
192
309
  if (options.admitModelRequest) {
@@ -248,6 +365,26 @@ export class SalaamService {
248
365
  this.coding,
249
366
  this.artifacts,
250
367
  );
368
+ // Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
369
+ // only while the task's admitted plan still declares a critic.
370
+ this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
371
+ admit: (task, request) =>
372
+ this.agents.admitCritic(task.session_id, request, () => {
373
+ if (!this.taskWork.get(task.id).completion_gate?.critic)
374
+ throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
375
+ }),
376
+ spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) =>
377
+ this.children.spawnCritic(
378
+ task.session_id,
379
+ parentAttempt,
380
+ step,
381
+ prompt,
382
+ budget,
383
+ grant,
384
+ snapshot,
385
+ ),
386
+ workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
387
+ });
251
388
  this.taskCheckpoints = new TaskCheckpoints(
252
389
  this.store,
253
390
  this.taskWork,
@@ -262,6 +399,19 @@ export class SalaamService {
262
399
  effectsEnabled: !!options.effectConnector,
263
400
  });
264
401
  this.supervisor.tasks = this.tasks;
402
+ if (options.personaConfig) {
403
+ const persona = loadPersona(options.personaConfig.config);
404
+ if (persona.digest !== options.personaConfig.digest)
405
+ throw Error('Host persona changed; run host plan again');
406
+ this.supervisor.persona = persona;
407
+ }
408
+ if (options.operatorMcpConfig) {
409
+ this.operatorMcpPath = options.operatorMcpConfig;
410
+ this.supervisor.operatorMcp = OperatorMcp.load(
411
+ options.operatorMcpConfig,
412
+ options.operatorMcpEnvironment ?? process.env,
413
+ );
414
+ }
265
415
  this.taskWork.providerCheck = (limits, session) =>
266
416
  this.supervisor.assertTaskProviderSupported(
267
417
  limits,
@@ -283,10 +433,65 @@ export class SalaamService {
283
433
  : this.supervisor.failControl(id, error),
284
434
  });
285
435
  this.supervisor.managed = this.managed;
286
- this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) =>
287
- this.supervisor.assertActive(id, epoch, attempt),
436
+ // Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
437
+ // usable; a host with neither refuses them unless the operator allowed them unsandboxed.
438
+ const localSandbox: LocalSandbox = {
439
+ ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
440
+ allowUnsandboxed: options.unsandboxedCommands === 'allow',
441
+ ...(options.localSandboxReadPaths?.length
442
+ ? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
443
+ : {}),
444
+ ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
445
+ };
446
+ this.localCoding = new LocalCoding(
447
+ this.store,
448
+ this.managed,
449
+ (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt),
450
+ this.toolIdentity,
451
+ localSandbox,
288
452
  );
453
+ this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
454
+ this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
455
+ this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
289
456
  this.supervisor.localCoding = this.localCoding;
457
+ if (options.dangerClassifierConfig) {
458
+ const loaded = loadDangerClassifierOptions(
459
+ JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')),
460
+ );
461
+ for (const o of loaded.options) this.reviewers.set(o.name, new DangerClassifier(o));
462
+ this.reviewerDefault = loaded.default;
463
+ }
464
+ if (options.dangerClassifierPin !== undefined) {
465
+ if (
466
+ options.dangerClassifierPin !== 'off' &&
467
+ !this.reviewers.has(options.dangerClassifierPin)
468
+ )
469
+ throw Error(
470
+ `The pinned command reviewer ${options.dangerClassifierPin} is not configured`,
471
+ );
472
+ this.reviewerPin = options.dangerClassifierPin;
473
+ }
474
+ if (this.reviewers.size) {
475
+ this.store.db.exec(
476
+ 'CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)',
477
+ );
478
+ this.localCoding.classifier = {
479
+ review: (s, command, cwd) => {
480
+ const reviewer = this.reviewers.get(this.reviewerChoice(s));
481
+ return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
482
+ },
483
+ };
484
+ }
485
+ const local = this.localCoding;
486
+ this.supervisor.hooks = new Hooks(
487
+ this.store,
488
+ (session, command, input, timeout) => local.runHook(session, command, input, timeout),
489
+ options.hooksFile,
490
+ );
491
+ const webConfig = options.webSearchConfig
492
+ ? loadWebSearchConfig(options.webSearchConfig)
493
+ : { providers: [] };
494
+ this.supervisor.web = new WebSearch(webConfig);
290
495
  this.agents = new AgentProfiles(
291
496
  this.store,
292
497
  this.supervisor.route,
@@ -459,6 +664,16 @@ export class SalaamService {
459
664
  },
460
665
  );
461
666
  this.supervisor.station = this.station;
667
+ // "Approve for this run": one grant store for decisions and the station.
668
+ this.station.grants = this.runGrants();
669
+ this.station.grantScope = (session) => {
670
+ const row = this.store.db
671
+ .prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
672
+ .get(session) as { task_id?: string } | undefined;
673
+ if (!row?.task_id) return null;
674
+ const task = this.taskWork.get(row.task_id);
675
+ return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
676
+ };
462
677
  this.taskVerification.installStation(this.station);
463
678
  this.agentpodWork = new AgentPodWork(
464
679
  this.store,
@@ -613,6 +828,7 @@ export class SalaamService {
613
828
  }
614
829
  await service.listen(service.bridgeServer, service.bridgeSocket);
615
830
  await service.listen(service.operator, service.socket);
831
+ service.shareOperatorSocket();
616
832
  service.taskDriver.start();
617
833
  service.jobs.start();
618
834
  service.recurring.start();
@@ -685,6 +901,16 @@ export class SalaamService {
685
901
  }
686
902
  await Promise.allSettled(jobs);
687
903
  }
904
+ /** The launcher profile's only widening: group traverse on state and rw on operator.sock.
905
+ * The bridge socket, journal, keys and every file below state stay owner-only. */
906
+ private shareOperatorSocket(): void {
907
+ if (!this.operatorAccess) return;
908
+ const { gid } = this.operatorAccess;
909
+ chownSync(this.socket, process.getuid!(), gid);
910
+ chmodSync(this.socket, 0o660);
911
+ chownSync(this.stateDir, process.getuid!(), gid);
912
+ chmodSync(this.stateDir, 0o710);
913
+ }
688
914
  private async listen(server: Server, path: string): Promise<void> {
689
915
  // The exclusive state lock is held before removing a stale socket.
690
916
  if (existsSync(path)) unlinkSync(path);
@@ -697,6 +923,11 @@ export class SalaamService {
697
923
  });
698
924
  });
699
925
  }
926
+ private grantStore?: RunGrants;
927
+ /** One "Approve for this run" grant store, shared by decisions and the station. */
928
+ private runGrants(): RunGrants {
929
+ return (this.grantStore ??= new RunGrants(this.store));
930
+ }
700
931
  private send(res: ServerResponse, status: number, value: unknown): void {
701
932
  res.writeHead(status, { 'content-type': 'application/json' });
702
933
  res.end(JSON.stringify(value));
@@ -816,6 +1047,50 @@ export class SalaamService {
816
1047
  });
817
1048
  return;
818
1049
  }
1050
+ // Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
1051
+ if (parts[2] === 'work-restore') {
1052
+ if (!this.agentpodWork)
1053
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
1054
+ if (
1055
+ Object.keys(body).sort().join(',') !==
1056
+ 'approved_by,binding,checkpoint_id,request_id,restore_digest'
1057
+ )
1058
+ throw new ServiceError('invalid_work_payload', 'Closed restore request required');
1059
+ const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
1060
+ if (!/^[a-f0-9]{64}$/.test(restoreDigest))
1061
+ throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
1062
+ this.send(
1063
+ res,
1064
+ 200,
1065
+ await this.agentpodWork.restore(
1066
+ body.binding,
1067
+ string(body.request_id, 'request_id', 128),
1068
+ string(body.checkpoint_id, 'checkpoint_id', 128),
1069
+ restoreDigest,
1070
+ string(body.approved_by, 'approved_by', 128),
1071
+ ),
1072
+ );
1073
+ return;
1074
+ }
1075
+ // Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
1076
+ if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
1077
+ if (!this.agentpodWork)
1078
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
1079
+ const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
1080
+ if (Object.keys(body).sort().join(',') !== keys)
1081
+ throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
1082
+ this.send(
1083
+ res,
1084
+ 200,
1085
+ parts[2] === 'work-checkpoints'
1086
+ ? this.agentpodWork.checkpoints(body.binding)
1087
+ : await this.agentpodWork.checkpointDiff(
1088
+ body.binding,
1089
+ string(body.checkpoint_id, 'checkpoint_id', 128),
1090
+ ),
1091
+ );
1092
+ return;
1093
+ }
819
1094
  if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
820
1095
  if (!this.agentpodWork || !this.agentpodWorkProgression)
821
1096
  throw new ServiceError('work_disabled', 'Strict progression is not enabled');
@@ -1002,6 +1277,14 @@ export class SalaamService {
1002
1277
  this.send(res, 200, await this.agentpodWork.completion(body.binding));
1003
1278
  return;
1004
1279
  }
1280
+ if (parts[2] === 'work-read') {
1281
+ if (!this.agentpodWork)
1282
+ throw new ServiceError('work_disabled', 'Strict progression is not enabled');
1283
+ if (Object.keys(body).sort().join(',') !== 'answer,binding')
1284
+ throw new ServiceError('invalid_board_read', 'Closed board read answer required');
1285
+ this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
1286
+ return;
1287
+ }
1005
1288
  if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
1006
1289
  if (!this.agentpodWork)
1007
1290
  throw new ServiceError(
@@ -1011,7 +1294,11 @@ export class SalaamService {
1011
1294
  const answering = parts[2] === 'work-decision';
1012
1295
  if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
1013
1296
  throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
1014
- const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed);
1297
+ const decisions = new AgentPodWorkDecisions(
1298
+ this.agentpodWork,
1299
+ this.managed,
1300
+ this.runGrants(),
1301
+ );
1015
1302
  this.send(
1016
1303
  res,
1017
1304
  200,
@@ -1090,7 +1377,18 @@ export class SalaamService {
1090
1377
  }
1091
1378
  const request = string(body.request_id, 'request_id', 128);
1092
1379
  if (parts[2] === 'admit') {
1093
- this.send(res, 200, this.agentpod.admit(body.binding, request));
1380
+ this.send(
1381
+ res,
1382
+ 200,
1383
+ this.agentpod.admit(
1384
+ body.binding,
1385
+ request,
1386
+ body.continue_from === undefined
1387
+ ? undefined
1388
+ : string(body.continue_from, 'continue_from', 128),
1389
+ body.approval_rules,
1390
+ ),
1391
+ );
1094
1392
  return;
1095
1393
  }
1096
1394
  if (parts[2] === 'renew') {
@@ -1101,6 +1399,7 @@ export class SalaamService {
1101
1399
  body.binding,
1102
1400
  request,
1103
1401
  integer(body.lease_sequence, 'lease_sequence'),
1402
+ body.approval_rules,
1104
1403
  ),
1105
1404
  );
1106
1405
  return;
@@ -1664,11 +1963,22 @@ export class SalaamService {
1664
1963
  pid: process.pid,
1665
1964
  host_manifest_digest: this.hostManifestDigest,
1666
1965
  host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
1966
+ host_receipt: this.hostReceipt,
1667
1967
  runtime_node: realpathSync(process.execPath),
1668
1968
  service_version: VERSION,
1669
1969
  context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
1670
1970
  agentpod_config: this.agentpod?.configPath ?? null,
1671
1971
  agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
1972
+ persona: this.supervisor.persona
1973
+ ? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
1974
+ : null,
1975
+ operator_mcp: this.operatorMcpPath
1976
+ ? {
1977
+ config: this.operatorMcpPath,
1978
+ digest: this.supervisor.operatorMcp!.digest,
1979
+ servers: this.supervisor.operatorMcp!.provenance(),
1980
+ }
1981
+ : null,
1672
1982
  managed_mcp: this.mcp
1673
1983
  ? { profile_digest: this.mcp.options.admission.profileDigest }
1674
1984
  : null,
@@ -1680,6 +1990,12 @@ export class SalaamService {
1680
1990
  ? { configuration_digest: this.station.configurationDigest }
1681
1991
  : null,
1682
1992
  service: 'healthy',
1993
+ tool_execution: {
1994
+ mode: this.toolIdentity.mode,
1995
+ user: this.toolIdentity.user,
1996
+ uid: this.toolIdentity.uid,
1997
+ warning: this.toolIdentity.warning,
1998
+ },
1683
1999
  a2a: {
1684
2000
  inbound_enabled: !!this.a2aInbound,
1685
2001
  listener: this.a2aInbound?.server.address() ?? null,
@@ -1741,9 +2057,11 @@ export class SalaamService {
1741
2057
  backend: this.coding.executor?.evidence ?? null,
1742
2058
  }
1743
2059
  : EXECUTION_STATUS,
2060
+ last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
1744
2061
  sessions: sessions.map((x) => ({
1745
2062
  id: x.id,
1746
2063
  state: x.state,
2064
+ last_activity_at: iso(this.store.lastActivity(x.id)),
1747
2065
  error: x.error,
1748
2066
  capabilities: x.capabilities,
1749
2067
  pending_approvals: this.store
@@ -1768,7 +2086,10 @@ export class SalaamService {
1768
2086
  this.send(
1769
2087
  res,
1770
2088
  200,
1771
- this.store.sessions().map((x) => this.supervisor.inspect(x.id)),
2089
+ this.store.sessions().map((x) => {
2090
+ const session = this.supervisor.inspect(x.id);
2091
+ return { ...session, ...pickerSummary(session, this.store) };
2092
+ }),
1772
2093
  );
1773
2094
  return;
1774
2095
  }
@@ -1911,13 +2232,61 @@ export class SalaamService {
1911
2232
  }
1912
2233
  if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
1913
2234
  const id = parts[2];
2235
+ // Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
2236
+ // and remove. Adding or removing needs the session's control lease, like the permission policy.
2237
+ if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
2238
+ const rules = this.localCoding.rules!;
2239
+ if (parts.length === 4 && req.method === 'GET') {
2240
+ this.send(res, 200, {
2241
+ applies: rules.applies(id),
2242
+ rules: rules.list(id),
2243
+ problems: rules.problems(id),
2244
+ });
2245
+ return;
2246
+ }
2247
+ const body = object(await readJson(req));
2248
+ this.store.authorizeControl(id, {
2249
+ client_id: string(body.client_id, 'client_id', 128),
2250
+ token: string(body.token, 'token', 128),
2251
+ });
2252
+ if (parts.length === 4 && req.method === 'POST') {
2253
+ const scope = string(body.scope, 'scope');
2254
+ if (scope !== 'session' && scope !== 'repo')
2255
+ throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
2256
+ const request = string(body.request_id, 'request_id', 128);
2257
+ this.send(
2258
+ res,
2259
+ 200,
2260
+ body.operation_id !== undefined
2261
+ ? rules.addFromOperation(
2262
+ id,
2263
+ string(body.operation_id, 'operation_id', 128),
2264
+ scope,
2265
+ request,
2266
+ )
2267
+ : rules.add(id, scope, body.rule, request),
2268
+ );
2269
+ return;
2270
+ }
2271
+ if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
2272
+ rules.remove(id, string(parts[4], 'rule', 128));
2273
+ this.send(res, 200, { removed: parts[4] });
2274
+ return;
2275
+ }
2276
+ }
1914
2277
  if (parts.length === 4 && parts[3] === 'permissions') {
1915
2278
  if (req.method === 'GET') {
1916
2279
  this.send(res, 200, {
1917
2280
  policy: permissionPolicy(this.store, id),
1918
2281
  configurable: this.store.localWorkspace(id),
1919
2282
  coding: this.store.localCoding(id),
1920
- command_auto_approval: false,
2283
+ // Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
2284
+ command_auto_approval: this.store.localCoding(id),
2285
+ full_access_allowed: this.allowFullAccessSessions,
2286
+ yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
2287
+ ...(this.store.localCoding(id)
2288
+ ? { command_sandbox: this.localCoding.commandSandbox(id) }
2289
+ : {}),
1921
2290
  });
1922
2291
  return;
1923
2292
  }
@@ -1936,17 +2305,152 @@ export class SalaamService {
1936
2305
  client_id: string(body.client_id, 'client_id', 128),
1937
2306
  token: string(body.token, 'token', 128),
1938
2307
  },
2308
+ {
2309
+ allowFullAccess: this.allowFullAccessSessions,
2310
+ allowYolo: this.allowYoloSessions,
2311
+ deskSession: !!this.localCoding.rules?.applies(id),
2312
+ },
1939
2313
  ),
1940
2314
  );
1941
2315
  return;
1942
2316
  }
1943
2317
  }
2318
+ if (parts.length === 4 && parts[3] === 'danger-classifier') {
2319
+ const view = () => {
2320
+ const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off',
2321
+ chosen = this.reviewers.get(choice)?.config;
2322
+ return {
2323
+ configured: this.reviewers.size > 0,
2324
+ enabled: !!chosen,
2325
+ ...(chosen
2326
+ ? {
2327
+ provider: chosen.provider,
2328
+ model: chosen.model,
2329
+ on_unavailable: chosen.on_unavailable,
2330
+ }
2331
+ : {}),
2332
+ choice,
2333
+ pinned: this.reviewerPin !== undefined,
2334
+ options: [...this.reviewers.values()].map(({ config: o }) => ({
2335
+ name: o.name,
2336
+ provider: o.provider,
2337
+ model: o.model,
2338
+ })),
2339
+ };
2340
+ };
2341
+ if (req.method === 'GET') {
2342
+ this.send(res, 200, view());
2343
+ return;
2344
+ }
2345
+ if (req.method === 'POST') {
2346
+ const body = object(await readJson(req));
2347
+ this.store.authorizeControl(id, {
2348
+ client_id: string(body.client_id, 'client_id', 128),
2349
+ token: string(body.token, 'token', 128),
2350
+ });
2351
+ // `enabled` is the earlier on/off form: on means the config's default.
2352
+ const choice =
2353
+ typeof body.enabled === 'boolean'
2354
+ ? body.enabled
2355
+ ? this.reviewerDefault === 'off'
2356
+ ? [...this.reviewers.keys()][0]
2357
+ : this.reviewerDefault
2358
+ : 'off'
2359
+ : string(body.choice, 'choice', 64);
2360
+ if (!this.reviewers.size && choice !== 'off')
2361
+ throw new ServiceError(
2362
+ 'classifier_unavailable',
2363
+ 'No command reviewer is configured on this host (--danger-classifier-config)',
2364
+ );
2365
+ if (this.reviewerPin !== undefined)
2366
+ throw new ServiceError(
2367
+ 'classifier_pinned',
2368
+ `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`,
2369
+ 403,
2370
+ );
2371
+ if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
2372
+ throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
2373
+ if (this.reviewers.size)
2374
+ this.store.transaction(() => {
2375
+ this.store.db
2376
+ .prepare(
2377
+ 'INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice',
2378
+ )
2379
+ .run(id, choice!);
2380
+ this.store.event(id, 'permission.classifier-set', { choice });
2381
+ });
2382
+ this.send(res, 200, view());
2383
+ return;
2384
+ }
2385
+ }
2386
+ // Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
2387
+ if (parts[3] === 'hooks' && this.supervisor.hooks) {
2388
+ if (!this.supervisor.isInteractiveCoding(id))
2389
+ throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
2390
+ const hooks = this.supervisor.hooks;
2391
+ if (parts.length === 4 && req.method === 'GET') {
2392
+ this.send(res, 200, { sources: hooks.sources(id) });
2393
+ return;
2394
+ }
2395
+ if (parts.length === 5 && req.method === 'POST') {
2396
+ const body = object(await readJson(req));
2397
+ this.store.authorizeControl(id, {
2398
+ client_id: string(body.client_id, 'client_id', 128),
2399
+ token: string(body.token, 'token', 128),
2400
+ });
2401
+ if (parts[4] === 'trust') {
2402
+ this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
2403
+ return;
2404
+ }
2405
+ if (parts[4] === 'revoke') {
2406
+ hooks.revoke(id);
2407
+ this.send(res, 200, { revoked: true });
2408
+ return;
2409
+ }
2410
+ }
2411
+ }
2412
+ if (parts[3] === 'local-tasks' && this.localCoding) {
2413
+ if (!this.supervisor.isInteractiveCoding(id))
2414
+ throw new ServiceError(
2415
+ 'background_denied',
2416
+ 'Background commands are not admitted',
2417
+ 403,
2418
+ );
2419
+ if (parts.length === 4 && req.method === 'GET') {
2420
+ this.send(res, 200, { tasks: this.localCoding.tasks(id) });
2421
+ return;
2422
+ }
2423
+ if (parts.length === 5 && req.method === 'GET') {
2424
+ this.send(res, 200, this.localCoding.taskOutput(id, parts[4]!));
2425
+ return;
2426
+ }
2427
+ if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
2428
+ this.send(res, 200, this.localCoding.stopTask(id, parts[4]!));
2429
+ return;
2430
+ }
2431
+ }
1944
2432
  if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
1945
2433
  if (!this.store.localWorkspace(id))
1946
2434
  throw new ServiceError('local_denied', 'Local workspace required');
1947
2435
  this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
1948
2436
  return;
1949
2437
  }
2438
+ // An edit request's exact files, before and after, so an editor can show it as a diff.
2439
+ if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
2440
+ if (!this.store.localCoding(id))
2441
+ throw new ServiceError('local_denied', 'Local coding session required');
2442
+ this.send(
2443
+ res,
2444
+ 200,
2445
+ this.localCoding.edits(
2446
+ id,
2447
+ url.searchParams.has('operation_id')
2448
+ ? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
2449
+ : { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) },
2450
+ ),
2451
+ );
2452
+ return;
2453
+ }
1950
2454
  if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
1951
2455
  const body = object(await readJson(req));
1952
2456
  this.send(
@@ -2011,11 +2515,138 @@ export class SalaamService {
2011
2515
  )
2012
2516
  throw new ServiceError('session_busy', 'Wait for queued work before undo');
2013
2517
  },
2518
+ this.toolIdentity,
2014
2519
  ),
2015
2520
  );
2016
2521
  return;
2017
2522
  }
2018
2523
  }
2524
+ if (
2525
+ (parts.length === 4 || parts.length === 5) &&
2526
+ (parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')
2527
+ ) {
2528
+ const checkpoints = this.tasks.checkpoints!;
2529
+ // Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
2530
+ const running = <T extends object | null>(value: T): T =>
2531
+ value && this.localCoding
2532
+ ? {
2533
+ ...value,
2534
+ background_running: this.localCoding
2535
+ .tasks(id)
2536
+ .filter((t) => t.state === 'running').length,
2537
+ }
2538
+ : value;
2539
+ const controller = (body: Record<string, unknown>) => ({
2540
+ client_id: string(body.client_id, 'client_id', 128),
2541
+ token: string(body.token, 'token', 128),
2542
+ });
2543
+ const idle = () => {
2544
+ this.supervisor.assertIdle(id);
2545
+ if (
2546
+ this.store.db
2547
+ .prepare(
2548
+ "SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
2549
+ )
2550
+ .get(id)
2551
+ )
2552
+ throw new ServiceError('session_busy', 'Wait for the current turn to finish');
2553
+ };
2554
+ if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
2555
+ this.send(res, 200, { checkpoints: checkpoints.list(id) });
2556
+ return;
2557
+ }
2558
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
2559
+ this.send(
2560
+ res,
2561
+ 200,
2562
+ running(
2563
+ checkpoints.reviewRewind(
2564
+ id,
2565
+ string(url.searchParams.get('attempt'), 'attempt', 128),
2566
+ string(url.searchParams.get('mode'), 'mode', 16) as RewindMode,
2567
+ ),
2568
+ ),
2569
+ );
2570
+ return;
2571
+ }
2572
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
2573
+ if (this.agentpod)
2574
+ throw new ServiceError(
2575
+ 'admission_required',
2576
+ 'Managed station sessions cannot rewind',
2577
+ );
2578
+ const body = object(await readJson(req));
2579
+ const request = string(body.request_id, 'request_id', 128);
2580
+ this.send(
2581
+ res,
2582
+ 200,
2583
+ checkpoints.rewind(
2584
+ id,
2585
+ request,
2586
+ string(body.attempt_id, 'attempt_id', 128),
2587
+ string(body.mode, 'mode', 16) as RewindMode,
2588
+ string(body.review_digest, 'review_digest', 128),
2589
+ controller(body),
2590
+ idle,
2591
+ (leaf, summary) =>
2592
+ this.store.forkLocal(
2593
+ id,
2594
+ request + ':fork',
2595
+ this.store.session(id).revision,
2596
+ controller(body),
2597
+ (source, target) => {
2598
+ const dir = join(this.stateDir, 'workers', target, 'sessions');
2599
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
2600
+ return forkPiSession(source.pi_session_file!, source.cwd, dir, target, {
2601
+ leaf,
2602
+ summary,
2603
+ });
2604
+ },
2605
+ { leaf },
2606
+ ),
2607
+ ),
2608
+ );
2609
+ return;
2610
+ }
2611
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
2612
+ this.send(res, 200, { review: running(checkpoints.review(id)) });
2613
+ return;
2614
+ }
2615
+ if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
2616
+ const q = (name: string) => string(url.searchParams.get(name), name, 1024);
2617
+ this.send(res, 200, {
2618
+ hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
2619
+ });
2620
+ return;
2621
+ }
2622
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
2623
+ const body = object(await readJson(req));
2624
+ const hunk =
2625
+ body.hunk === undefined
2626
+ ? undefined
2627
+ : {
2628
+ text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
2629
+ patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
2630
+ };
2631
+ this.send(res, 200, {
2632
+ review: running(
2633
+ checkpoints.act(
2634
+ id,
2635
+ {
2636
+ action: string(body.action, 'action', 16) as 'accept' | 'revert',
2637
+ path: string(body.path, 'path', 1024),
2638
+ ...(hunk ? { hunk } : {}),
2639
+ base: string(body.base, 'base', 128),
2640
+ current: string(body.current, 'current', 128),
2641
+ },
2642
+ controller(body),
2643
+ idle,
2644
+ ),
2645
+ ),
2646
+ });
2647
+ return;
2648
+ }
2649
+ }
2019
2650
  if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
2020
2651
  if (this.agentpod)
2021
2652
  throw new ServiceError(
@@ -2619,6 +3250,48 @@ export class SalaamService {
2619
3250
  );
2620
3251
  return;
2621
3252
  }
3253
+ if (parts[3] === 'route' && req.method === 'POST') {
3254
+ const body = object(await readJson(req));
3255
+ this.store.authorizeControl(id, {
3256
+ client_id: string(body.client_id, 'client_id', 128),
3257
+ token: string(body.token, 'token', 128),
3258
+ });
3259
+ if (!this.tasks.instructions!.applies(id))
3260
+ throw new ServiceError(
3261
+ 'route_switch_unavailable',
3262
+ 'Only interactive sessions can switch model mid-session',
3263
+ 409,
3264
+ );
3265
+ this.supervisor.assertIdle(id);
3266
+ const input = object(body.route);
3267
+ const requested = providerRoute(
3268
+ string(input.provider, 'provider'),
3269
+ input.model === undefined ? undefined : string(input.model, 'model'),
3270
+ undefined,
3271
+ undefined,
3272
+ undefined,
3273
+ input.reasoning as import('./provider-config.js').ReasoningLevel | undefined,
3274
+ );
3275
+ const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
3276
+ throw new ServiceError(
3277
+ 'provider_auth_required',
3278
+ `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`,
3279
+ 400,
3280
+ );
3281
+ });
3282
+ if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
3283
+ throw new ServiceError(
3284
+ 'provider_output_cap_changed',
3285
+ 'That route enforces output caps differently; start a new session for it',
3286
+ 409,
3287
+ );
3288
+ this.supervisor.assertIdle(id);
3289
+ this.store.switchRoute(id, prepared.route);
3290
+ await this.supervisor.stopSession(id);
3291
+ void this.supervisor.start(id).catch(() => {});
3292
+ this.send(res, 200, this.supervisor.inspect(id));
3293
+ return;
3294
+ }
2622
3295
  if (parts[3] === 'resume' && req.method === 'POST') {
2623
3296
  const body = object(await readJson(req));
2624
3297
  this.store.authorizeControl(id, {
@@ -2637,6 +3310,12 @@ export class SalaamService {
2637
3310
  );
2638
3311
  if (session.state !== 'ready' && session.state !== 'starting')
2639
3312
  void this.supervisor.start(id).catch(() => {});
3313
+ // Show the project instructions an interactive session will use before its first turn.
3314
+ try {
3315
+ this.tasks.instructions?.refresh(id);
3316
+ } catch {
3317
+ /* reported on the next turn */
3318
+ }
2640
3319
  this.send(res, 202, this.supervisor.inspect(id));
2641
3320
  return;
2642
3321
  }
@@ -2652,11 +3331,24 @@ export class SalaamService {
2652
3331
  }
2653
3332
  if (parts[3] === 'submissions' && req.method === 'POST') {
2654
3333
  const body = object(await readJson(req));
3334
+ const local = this.store.localCoding(id);
3335
+ let text = string(body.text, 'text', 256 * 1024);
3336
+ let images: PromptImage[] = [];
3337
+ // Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
3338
+ if (local) {
3339
+ if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
3340
+ throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
3341
+ ({ text, images } = expandPrompt(
3342
+ this.store.session(id).cwd,
3343
+ text,
3344
+ body.confirm_sensitive === true,
3345
+ ));
3346
+ }
2655
3347
  const submission = this.store.accept(
2656
3348
  id,
2657
3349
  string(body.request_id, 'request_id', 128),
2658
3350
  integer(body.expected_revision, 'expected_revision'),
2659
- string(body.text, 'text', 256 * 1024),
3351
+ text,
2660
3352
  {
2661
3353
  client_id: string(body.client_id, 'client_id', 128),
2662
3354
  token: string(body.token, 'token', 128),
@@ -2665,6 +3357,8 @@ export class SalaamService {
2665
3357
  ? null
2666
3358
  : string(body.continues_attempt_id, 'continues_attempt_id', 128),
2667
3359
  );
3360
+ // Same tick as the acceptance, so dispatch cannot claim the submission without them.
3361
+ storeImages(this.store.db, submission.id, images);
2668
3362
  this.send(res, 202, submission);
2669
3363
  return;
2670
3364
  }
@@ -2795,6 +3489,7 @@ export class SalaamService {
2795
3489
  await attempt(() => this.toolkitResolutions.close());
2796
3490
  await attempt(() => this.children.close());
2797
3491
  await attempt(() => this.supervisor.close());
3492
+ await attempt(() => this.supervisor.operatorMcp?.close());
2798
3493
  await attempt(() => this.dispatcher.drain());
2799
3494
  await attempt(() => this.managed.close());
2800
3495
  await attempt(() =>
@@ -2826,3 +3521,7 @@ export class SalaamService {
2826
3521
  if (this.shutdownFailure) throw this.shutdownFailure;
2827
3522
  }
2828
3523
  }
3524
+
3525
+ function iso(ms: number | null): string | null {
3526
+ return ms === null ? null : new Date(ms).toISOString();
3527
+ }