@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,13 +1,20 @@
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 } from 'node:http';
2
- import { mkdirSync, chmodSync, existsSync, unlinkSync, realpathSync, statSync } from 'node:fs';
5
+ import { loadPersona } from './persona.js';
6
+ import { mkdirSync, chmodSync, chownSync, existsSync, unlinkSync, realpathSync, statSync, } from 'node:fs';
3
7
  import { join, resolve } from 'node:path';
4
8
  import { providerRoute } from './provider-config.js';
5
9
  import { TaskContext } from './task-context.js';
6
10
  import { ContextAdmission } from './context-admission.js';
11
+ import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
7
12
  import { SLASH_COMMANDS } from './tui-commands.js';
8
13
  import { TaskWork } from './task-work.js';
9
14
  import { TaskBudget } from './task-budget.js';
10
15
  import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
16
+ import { RunGrants } from './run-grants.js';
17
+ import { TaskCritic } from './task-critic.js';
11
18
  import { AgentPodWork } from './agentpod-work.js';
12
19
  import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
13
20
  import { TaskVerification } from './task-verification.js';
@@ -17,6 +24,7 @@ import { OperatorInbox } from './operator-inbox.js';
17
24
  import { RecurringWork } from './recurring-work.js';
18
25
  import { JobWork } from './job-work.js';
19
26
  import { Store } from './store.js';
27
+ import { pickerSummary } from './session-pick.js';
20
28
  import { acquireStateLock } from './state-lock.js';
21
29
  import { Artifacts } from './artifacts.js';
22
30
  import { Supervisor } from './supervisor.js';
@@ -39,6 +47,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
39
47
  import { A2AInbound } from './a2a-inbound.js';
40
48
  import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
41
49
  import { ToolkitResources } from './toolkit-resources.js';
50
+ import { OperatorMcp } from './operator-mcp.js';
42
51
  import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
43
52
  import { ToolkitOwners } from './toolkit-owners.js';
44
53
  import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
@@ -49,13 +58,20 @@ import { McpAdmission } from './mcp-admission.js';
49
58
  import { McpWork } from './mcp-work.js';
50
59
  import { ToolkitWork } from './toolkit-work.js';
51
60
  import { permissionPolicy, configurePermissions } from './autonomy.js';
61
+ import { expandPrompt, storeImages } from './mentions.js';
62
+ import { ApprovalRules } from './approval-rules.js';
63
+ import { chooseLocalSandbox, resolveReadPaths } from './command-sandbox.js';
52
64
  import { LocalCoding } from './local-coding.js';
65
+ import { Hooks } from './hooks.js';
53
66
  import { localDiff } from './local-diff.js';
54
67
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
55
68
  import { forkPiSession } from './pi-sessions.js';
69
+ import { TurnCheckpoints } from './turn-rewind.js';
56
70
  import { StationWork } from './station-work.js';
57
71
  import { readToolkitConfig } from './toolkit-config.js';
72
+ import { probeToolsUser, serviceToolIdentity } from './tool-identity.js';
58
73
  export class SalaamService {
74
+ operatorMcpPath;
59
75
  agentpod;
60
76
  agentpodWork;
61
77
  toolkitResources;
@@ -92,6 +108,7 @@ export class SalaamService {
92
108
  workspaces;
93
109
  coding;
94
110
  localCoding;
111
+ toolIdentity;
95
112
  codingSocket;
96
113
  closed;
97
114
  resolveClosed;
@@ -104,12 +121,36 @@ export class SalaamService {
104
121
  revocationStops = new Set();
105
122
  authorityTimer;
106
123
  hostManifestDigest;
124
+ hostReceipt;
125
+ operatorAccess;
107
126
  agentpodWorkProgression;
127
+ /** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
128
+ allowFullAccessSessions;
129
+ allowYoloSessions;
130
+ reviewers = new Map();
131
+ reviewerDefault = 'off';
132
+ reviewerPin;
133
+ /** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
134
+ reviewerChoice(session) {
135
+ if (this.reviewerPin)
136
+ return this.reviewerPin;
137
+ const row = this.store.db
138
+ .prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
139
+ .get(session);
140
+ const choice = row?.choice ?? this.reviewerDefault;
141
+ return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
142
+ }
108
143
  constructor(options) {
144
+ this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
145
+ this.allowYoloSessions = options.allowYoloSessions !== false;
109
146
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
110
147
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
111
148
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
112
149
  this.hostManifestDigest = options.hostManifestDigest ?? null;
150
+ this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
151
+ if (options.operatorAccess && !this.agentpodWorkProgression)
152
+ throw Error('Operator group access is only for the strict work launcher profile');
153
+ this.operatorAccess = options.operatorAccess ?? null;
113
154
  if (process.platform === 'win32')
114
155
  throw new Error('Windows transport is not implemented');
115
156
  this.stateDir = resolve(options.stateDir);
@@ -134,20 +175,33 @@ export class SalaamService {
134
175
  this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
135
176
  mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
136
177
  this.stateDir = realpathSync(this.stateDir);
178
+ this.toolIdentity = options.toolsUser
179
+ ? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
180
+ : serviceToolIdentity();
137
181
  const mcpAdmission = options.managedMcp
138
182
  ? new McpAdmission(options.managedMcp.config, readAgentPodPins(resolve(options.agentpodConfig), this.stateDir), options.managedMcp.profile_digest)
139
183
  : undefined;
140
184
  this.socket = join(this.stateDir, 'operator.sock');
141
185
  this.bridgeSocket = join(this.stateDir, 'bridge.sock');
142
186
  if (Buffer.byteLength(this.bridgeSocket) > 100)
143
- throw new Error('State path is too long for a Unix socket; choose a shorter --state path');
187
+ throw new Error(`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
188
+ 'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.');
144
189
  this.release = acquireStateLock(this.stateDir);
145
190
  try {
146
191
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
192
+ this.store.toolExecution = {
193
+ mode: this.toolIdentity.mode,
194
+ user: this.toolIdentity.user,
195
+ uid: this.toolIdentity.uid,
196
+ };
147
197
  try {
148
198
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
149
199
  this.tasks = new TaskContext(this.store, this.artifacts);
150
200
  this.tasks.admitted = new ContextAdmission(this.store);
201
+ this.tasks.instructions = new ProjectInstructions(this.store, this.artifacts, options.userInstructions === undefined
202
+ ? userInstructionsPath()
203
+ : options.userInstructions);
204
+ this.tasks.checkpoints = new TurnCheckpoints(this.store, this.stateDir, this.toolIdentity.mode !== 'tools-user');
151
205
  this.taskWork = new TaskWork(this.store, this.tasks);
152
206
  this.taskBudget = new TaskBudget(this.store, this.taskWork);
153
207
  if (options.admitModelRequest) {
@@ -183,6 +237,16 @@ export class SalaamService {
183
237
  ]);
184
238
  this.coding = new CodingWork(this.store, this.workspaces, this.artifacts, executor, (id, error) => this.supervisor.failControl(id, error));
185
239
  this.taskVerification = new TaskVerification(this.store, this.taskWork, this.taskBudget, this.workspaces, this.coding, this.artifacts);
240
+ // Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
241
+ // only while the task's admitted plan still declares a critic.
242
+ this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
243
+ admit: (task, request) => this.agents.admitCritic(task.session_id, request, () => {
244
+ if (!this.taskWork.get(task.id).completion_gate?.critic)
245
+ throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
246
+ }),
247
+ spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) => this.children.spawnCritic(task.session_id, parentAttempt, step, prompt, budget, grant, snapshot),
248
+ workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
249
+ });
186
250
  this.taskCheckpoints = new TaskCheckpoints(this.store, this.taskWork, this.taskVerification, this.workspaces, this.artifacts);
187
251
  this.supervisor = new Supervisor(this.store, {
188
252
  ...options,
@@ -191,6 +255,16 @@ export class SalaamService {
191
255
  effectsEnabled: !!options.effectConnector,
192
256
  });
193
257
  this.supervisor.tasks = this.tasks;
258
+ if (options.personaConfig) {
259
+ const persona = loadPersona(options.personaConfig.config);
260
+ if (persona.digest !== options.personaConfig.digest)
261
+ throw Error('Host persona changed; run host plan again');
262
+ this.supervisor.persona = persona;
263
+ }
264
+ if (options.operatorMcpConfig) {
265
+ this.operatorMcpPath = options.operatorMcpConfig;
266
+ this.supervisor.operatorMcp = OperatorMcp.load(options.operatorMcpConfig, options.operatorMcpEnvironment ?? process.env);
267
+ }
194
268
  this.taskWork.providerCheck = (limits, session) => this.supervisor.assertTaskProviderSupported(limits, session ? this.store.localOutputCap(session) : undefined);
195
269
  this.supervisor.coding = this.coding;
196
270
  this.managed = new ManagedWork(this.store, {
@@ -205,8 +279,48 @@ export class SalaamService {
205
279
  : this.supervisor.failControl(id, error),
206
280
  });
207
281
  this.supervisor.managed = this.managed;
208
- this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt));
282
+ // Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
283
+ // usable; a host with neither refuses them unless the operator allowed them unsandboxed.
284
+ const localSandbox = {
285
+ ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
286
+ allowUnsandboxed: options.unsandboxedCommands === 'allow',
287
+ ...(options.localSandboxReadPaths?.length
288
+ ? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
289
+ : {}),
290
+ ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
291
+ };
292
+ this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt), this.toolIdentity, localSandbox);
293
+ this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
294
+ this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
295
+ this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
209
296
  this.supervisor.localCoding = this.localCoding;
297
+ if (options.dangerClassifierConfig) {
298
+ const loaded = loadDangerClassifierOptions(JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')));
299
+ for (const o of loaded.options)
300
+ this.reviewers.set(o.name, new DangerClassifier(o));
301
+ this.reviewerDefault = loaded.default;
302
+ }
303
+ if (options.dangerClassifierPin !== undefined) {
304
+ if (options.dangerClassifierPin !== 'off' &&
305
+ !this.reviewers.has(options.dangerClassifierPin))
306
+ throw Error(`The pinned command reviewer ${options.dangerClassifierPin} is not configured`);
307
+ this.reviewerPin = options.dangerClassifierPin;
308
+ }
309
+ if (this.reviewers.size) {
310
+ this.store.db.exec('CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)');
311
+ this.localCoding.classifier = {
312
+ review: (s, command, cwd) => {
313
+ const reviewer = this.reviewers.get(this.reviewerChoice(s));
314
+ return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
315
+ },
316
+ };
317
+ }
318
+ const local = this.localCoding;
319
+ this.supervisor.hooks = new Hooks(this.store, (session, command, input, timeout) => local.runHook(session, command, input, timeout), options.hooksFile);
320
+ const webConfig = options.webSearchConfig
321
+ ? loadWebSearchConfig(options.webSearchConfig)
322
+ : { providers: [] };
323
+ this.supervisor.web = new WebSearch(webConfig);
210
324
  this.agents = new AgentProfiles(this.store, this.supervisor.route, async (route, session) => {
211
325
  const prepared = await this.supervisor.prepareProviderRoute(route);
212
326
  const task = this.taskWork.forSession(session);
@@ -293,6 +407,17 @@ export class SalaamService {
293
407
  failed: (session, error) => this.supervisor.failControl(session, error),
294
408
  });
295
409
  this.supervisor.station = this.station;
410
+ // "Approve for this run": one grant store for decisions and the station.
411
+ this.station.grants = this.runGrants();
412
+ this.station.grantScope = (session) => {
413
+ const row = this.store.db
414
+ .prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
415
+ .get(session);
416
+ if (!row?.task_id)
417
+ return null;
418
+ const task = this.taskWork.get(row.task_id);
419
+ return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
420
+ };
296
421
  this.taskVerification.installStation(this.station);
297
422
  this.agentpodWork = new AgentPodWork(this.store, this.agentpod, this.taskWork, this.taskVerification);
298
423
  const cancelOwned = this.store.cancelChildren;
@@ -429,6 +554,7 @@ export class SalaamService {
429
554
  }
430
555
  await service.listen(service.bridgeServer, service.bridgeSocket);
431
556
  await service.listen(service.operator, service.socket);
557
+ service.shareOperatorSocket();
432
558
  service.taskDriver.start();
433
559
  service.jobs.start();
434
560
  service.recurring.start();
@@ -490,6 +616,17 @@ export class SalaamService {
490
616
  }
491
617
  await Promise.allSettled(jobs);
492
618
  }
619
+ /** The launcher profile's only widening: group traverse on state and rw on operator.sock.
620
+ * The bridge socket, journal, keys and every file below state stay owner-only. */
621
+ shareOperatorSocket() {
622
+ if (!this.operatorAccess)
623
+ return;
624
+ const { gid } = this.operatorAccess;
625
+ chownSync(this.socket, process.getuid(), gid);
626
+ chmodSync(this.socket, 0o660);
627
+ chownSync(this.stateDir, process.getuid(), gid);
628
+ chmodSync(this.stateDir, 0o710);
629
+ }
493
630
  async listen(server, path) {
494
631
  // The exclusive state lock is held before removing a stale socket.
495
632
  if (existsSync(path))
@@ -503,6 +640,11 @@ export class SalaamService {
503
640
  });
504
641
  });
505
642
  }
643
+ grantStore;
644
+ /** One "Approve for this run" grant store, shared by decisions and the station. */
645
+ runGrants() {
646
+ return (this.grantStore ??= new RunGrants(this.store));
647
+ }
506
648
  send(res, status, value) {
507
649
  res.writeHead(status, { 'content-type': 'application/json' });
508
650
  res.end(JSON.stringify(value));
@@ -602,6 +744,31 @@ export class SalaamService {
602
744
  });
603
745
  return;
604
746
  }
747
+ // Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
748
+ if (parts[2] === 'work-restore') {
749
+ if (!this.agentpodWork)
750
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
751
+ if (Object.keys(body).sort().join(',') !==
752
+ 'approved_by,binding,checkpoint_id,request_id,restore_digest')
753
+ throw new ServiceError('invalid_work_payload', 'Closed restore request required');
754
+ const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
755
+ if (!/^[a-f0-9]{64}$/.test(restoreDigest))
756
+ throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
757
+ this.send(res, 200, await this.agentpodWork.restore(body.binding, string(body.request_id, 'request_id', 128), string(body.checkpoint_id, 'checkpoint_id', 128), restoreDigest, string(body.approved_by, 'approved_by', 128)));
758
+ return;
759
+ }
760
+ // Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
761
+ if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
762
+ if (!this.agentpodWork)
763
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
764
+ const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
765
+ if (Object.keys(body).sort().join(',') !== keys)
766
+ throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
767
+ this.send(res, 200, parts[2] === 'work-checkpoints'
768
+ ? this.agentpodWork.checkpoints(body.binding)
769
+ : await this.agentpodWork.checkpointDiff(body.binding, string(body.checkpoint_id, 'checkpoint_id', 128)));
770
+ return;
771
+ }
605
772
  if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
606
773
  if (!this.agentpodWork || !this.agentpodWorkProgression)
607
774
  throw new ServiceError('work_disabled', 'Strict progression is not enabled');
@@ -690,13 +857,21 @@ export class SalaamService {
690
857
  this.send(res, 200, await this.agentpodWork.completion(body.binding));
691
858
  return;
692
859
  }
860
+ if (parts[2] === 'work-read') {
861
+ if (!this.agentpodWork)
862
+ throw new ServiceError('work_disabled', 'Strict progression is not enabled');
863
+ if (Object.keys(body).sort().join(',') !== 'answer,binding')
864
+ throw new ServiceError('invalid_board_read', 'Closed board read answer required');
865
+ this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
866
+ return;
867
+ }
693
868
  if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
694
869
  if (!this.agentpodWork)
695
870
  throw new ServiceError('verification_unavailable', 'Reviewed protected station backend required');
696
871
  const answering = parts[2] === 'work-decision';
697
872
  if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
698
873
  throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
699
- const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed);
874
+ const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed, this.runGrants());
700
875
  this.send(res, 200, answering ? decisions.answer(body.binding, body.answer) : decisions.list(body.binding));
701
876
  return;
702
877
  }
@@ -747,11 +922,13 @@ export class SalaamService {
747
922
  }
748
923
  const request = string(body.request_id, 'request_id', 128);
749
924
  if (parts[2] === 'admit') {
750
- this.send(res, 200, this.agentpod.admit(body.binding, request));
925
+ this.send(res, 200, this.agentpod.admit(body.binding, request, body.continue_from === undefined
926
+ ? undefined
927
+ : string(body.continue_from, 'continue_from', 128), body.approval_rules));
751
928
  return;
752
929
  }
753
930
  if (parts[2] === 'renew') {
754
- this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence')));
931
+ this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence'), body.approval_rules));
755
932
  return;
756
933
  }
757
934
  if (parts[2] === 'revoke') {
@@ -1070,11 +1247,22 @@ export class SalaamService {
1070
1247
  pid: process.pid,
1071
1248
  host_manifest_digest: this.hostManifestDigest,
1072
1249
  host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
1250
+ host_receipt: this.hostReceipt,
1073
1251
  runtime_node: realpathSync(process.execPath),
1074
1252
  service_version: VERSION,
1075
1253
  context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
1076
1254
  agentpod_config: this.agentpod?.configPath ?? null,
1077
1255
  agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
1256
+ persona: this.supervisor.persona
1257
+ ? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
1258
+ : null,
1259
+ operator_mcp: this.operatorMcpPath
1260
+ ? {
1261
+ config: this.operatorMcpPath,
1262
+ digest: this.supervisor.operatorMcp.digest,
1263
+ servers: this.supervisor.operatorMcp.provenance(),
1264
+ }
1265
+ : null,
1078
1266
  managed_mcp: this.mcp
1079
1267
  ? { profile_digest: this.mcp.options.admission.profileDigest }
1080
1268
  : null,
@@ -1086,6 +1274,12 @@ export class SalaamService {
1086
1274
  ? { configuration_digest: this.station.configurationDigest }
1087
1275
  : null,
1088
1276
  service: 'healthy',
1277
+ tool_execution: {
1278
+ mode: this.toolIdentity.mode,
1279
+ user: this.toolIdentity.user,
1280
+ uid: this.toolIdentity.uid,
1281
+ warning: this.toolIdentity.warning,
1282
+ },
1089
1283
  a2a: {
1090
1284
  inbound_enabled: !!this.a2aInbound,
1091
1285
  listener: this.a2aInbound?.server.address() ?? null,
@@ -1143,9 +1337,11 @@ export class SalaamService {
1143
1337
  backend: this.coding.executor?.evidence ?? null,
1144
1338
  }
1145
1339
  : EXECUTION_STATUS,
1340
+ last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
1146
1341
  sessions: sessions.map((x) => ({
1147
1342
  id: x.id,
1148
1343
  state: x.state,
1344
+ last_activity_at: iso(this.store.lastActivity(x.id)),
1149
1345
  error: x.error,
1150
1346
  capabilities: x.capabilities,
1151
1347
  pending_approvals: this.store
@@ -1167,7 +1363,10 @@ export class SalaamService {
1167
1363
  return;
1168
1364
  }
1169
1365
  if (url.pathname === '/v1/sessions' && req.method === 'GET') {
1170
- this.send(res, 200, this.store.sessions().map((x) => this.supervisor.inspect(x.id)));
1366
+ this.send(res, 200, this.store.sessions().map((x) => {
1367
+ const session = this.supervisor.inspect(x.id);
1368
+ return { ...session, ...pickerSummary(session, this.store) };
1369
+ }));
1171
1370
  return;
1172
1371
  }
1173
1372
  if (url.pathname === '/v1/sessions' && req.method === 'POST') {
@@ -1258,13 +1457,52 @@ export class SalaamService {
1258
1457
  }
1259
1458
  if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
1260
1459
  const id = parts[2];
1460
+ // Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
1461
+ // and remove. Adding or removing needs the session's control lease, like the permission policy.
1462
+ if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
1463
+ const rules = this.localCoding.rules;
1464
+ if (parts.length === 4 && req.method === 'GET') {
1465
+ this.send(res, 200, {
1466
+ applies: rules.applies(id),
1467
+ rules: rules.list(id),
1468
+ problems: rules.problems(id),
1469
+ });
1470
+ return;
1471
+ }
1472
+ const body = object(await readJson(req));
1473
+ this.store.authorizeControl(id, {
1474
+ client_id: string(body.client_id, 'client_id', 128),
1475
+ token: string(body.token, 'token', 128),
1476
+ });
1477
+ if (parts.length === 4 && req.method === 'POST') {
1478
+ const scope = string(body.scope, 'scope');
1479
+ if (scope !== 'session' && scope !== 'repo')
1480
+ throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
1481
+ const request = string(body.request_id, 'request_id', 128);
1482
+ this.send(res, 200, body.operation_id !== undefined
1483
+ ? rules.addFromOperation(id, string(body.operation_id, 'operation_id', 128), scope, request)
1484
+ : rules.add(id, scope, body.rule, request));
1485
+ return;
1486
+ }
1487
+ if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
1488
+ rules.remove(id, string(parts[4], 'rule', 128));
1489
+ this.send(res, 200, { removed: parts[4] });
1490
+ return;
1491
+ }
1492
+ }
1261
1493
  if (parts.length === 4 && parts[3] === 'permissions') {
1262
1494
  if (req.method === 'GET') {
1263
1495
  this.send(res, 200, {
1264
1496
  policy: permissionPolicy(this.store, id),
1265
1497
  configurable: this.store.localWorkspace(id),
1266
1498
  coding: this.store.localCoding(id),
1267
- command_auto_approval: false,
1499
+ // Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
1500
+ command_auto_approval: this.store.localCoding(id),
1501
+ full_access_allowed: this.allowFullAccessSessions,
1502
+ yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
1503
+ ...(this.store.localCoding(id)
1504
+ ? { command_sandbox: this.localCoding.commandSandbox(id) }
1505
+ : {}),
1268
1506
  });
1269
1507
  return;
1270
1508
  }
@@ -1273,16 +1511,128 @@ export class SalaamService {
1273
1511
  this.send(res, 200, configurePermissions(this.store, id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), body.policy, {
1274
1512
  client_id: string(body.client_id, 'client_id', 128),
1275
1513
  token: string(body.token, 'token', 128),
1514
+ }, {
1515
+ allowFullAccess: this.allowFullAccessSessions,
1516
+ allowYolo: this.allowYoloSessions,
1517
+ deskSession: !!this.localCoding.rules?.applies(id),
1276
1518
  }));
1277
1519
  return;
1278
1520
  }
1279
1521
  }
1522
+ if (parts.length === 4 && parts[3] === 'danger-classifier') {
1523
+ const view = () => {
1524
+ const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off', chosen = this.reviewers.get(choice)?.config;
1525
+ return {
1526
+ configured: this.reviewers.size > 0,
1527
+ enabled: !!chosen,
1528
+ ...(chosen
1529
+ ? {
1530
+ provider: chosen.provider,
1531
+ model: chosen.model,
1532
+ on_unavailable: chosen.on_unavailable,
1533
+ }
1534
+ : {}),
1535
+ choice,
1536
+ pinned: this.reviewerPin !== undefined,
1537
+ options: [...this.reviewers.values()].map(({ config: o }) => ({
1538
+ name: o.name,
1539
+ provider: o.provider,
1540
+ model: o.model,
1541
+ })),
1542
+ };
1543
+ };
1544
+ if (req.method === 'GET') {
1545
+ this.send(res, 200, view());
1546
+ return;
1547
+ }
1548
+ if (req.method === 'POST') {
1549
+ const body = object(await readJson(req));
1550
+ this.store.authorizeControl(id, {
1551
+ client_id: string(body.client_id, 'client_id', 128),
1552
+ token: string(body.token, 'token', 128),
1553
+ });
1554
+ // `enabled` is the earlier on/off form: on means the config's default.
1555
+ const choice = typeof body.enabled === 'boolean'
1556
+ ? body.enabled
1557
+ ? this.reviewerDefault === 'off'
1558
+ ? [...this.reviewers.keys()][0]
1559
+ : this.reviewerDefault
1560
+ : 'off'
1561
+ : string(body.choice, 'choice', 64);
1562
+ if (!this.reviewers.size && choice !== 'off')
1563
+ throw new ServiceError('classifier_unavailable', 'No command reviewer is configured on this host (--danger-classifier-config)');
1564
+ if (this.reviewerPin !== undefined)
1565
+ throw new ServiceError('classifier_pinned', `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`, 403);
1566
+ if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
1567
+ throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
1568
+ if (this.reviewers.size)
1569
+ this.store.transaction(() => {
1570
+ this.store.db
1571
+ .prepare('INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice')
1572
+ .run(id, choice);
1573
+ this.store.event(id, 'permission.classifier-set', { choice });
1574
+ });
1575
+ this.send(res, 200, view());
1576
+ return;
1577
+ }
1578
+ }
1579
+ // Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
1580
+ if (parts[3] === 'hooks' && this.supervisor.hooks) {
1581
+ if (!this.supervisor.isInteractiveCoding(id))
1582
+ throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
1583
+ const hooks = this.supervisor.hooks;
1584
+ if (parts.length === 4 && req.method === 'GET') {
1585
+ this.send(res, 200, { sources: hooks.sources(id) });
1586
+ return;
1587
+ }
1588
+ if (parts.length === 5 && req.method === 'POST') {
1589
+ const body = object(await readJson(req));
1590
+ this.store.authorizeControl(id, {
1591
+ client_id: string(body.client_id, 'client_id', 128),
1592
+ token: string(body.token, 'token', 128),
1593
+ });
1594
+ if (parts[4] === 'trust') {
1595
+ this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
1596
+ return;
1597
+ }
1598
+ if (parts[4] === 'revoke') {
1599
+ hooks.revoke(id);
1600
+ this.send(res, 200, { revoked: true });
1601
+ return;
1602
+ }
1603
+ }
1604
+ }
1605
+ if (parts[3] === 'local-tasks' && this.localCoding) {
1606
+ if (!this.supervisor.isInteractiveCoding(id))
1607
+ throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
1608
+ if (parts.length === 4 && req.method === 'GET') {
1609
+ this.send(res, 200, { tasks: this.localCoding.tasks(id) });
1610
+ return;
1611
+ }
1612
+ if (parts.length === 5 && req.method === 'GET') {
1613
+ this.send(res, 200, this.localCoding.taskOutput(id, parts[4]));
1614
+ return;
1615
+ }
1616
+ if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
1617
+ this.send(res, 200, this.localCoding.stopTask(id, parts[4]));
1618
+ return;
1619
+ }
1620
+ }
1280
1621
  if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
1281
1622
  if (!this.store.localWorkspace(id))
1282
1623
  throw new ServiceError('local_denied', 'Local workspace required');
1283
1624
  this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
1284
1625
  return;
1285
1626
  }
1627
+ // An edit request's exact files, before and after, so an editor can show it as a diff.
1628
+ if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
1629
+ if (!this.store.localCoding(id))
1630
+ throw new ServiceError('local_denied', 'Local coding session required');
1631
+ this.send(res, 200, this.localCoding.edits(id, url.searchParams.has('operation_id')
1632
+ ? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
1633
+ : { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) }));
1634
+ return;
1635
+ }
1286
1636
  if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
1287
1637
  const body = object(await readJson(req));
1288
1638
  this.send(res, 200, await this.supervisor.steer(id, string(body.request_id, 'request_id', 128), string(body.text, 'text', 65536), {
@@ -1315,7 +1665,84 @@ export class SalaamService {
1315
1665
  .prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
1316
1666
  .get(id))
1317
1667
  throw new ServiceError('session_busy', 'Wait for queued work before undo');
1318
- }));
1668
+ }, this.toolIdentity));
1669
+ return;
1670
+ }
1671
+ }
1672
+ if ((parts.length === 4 || parts.length === 5) &&
1673
+ (parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')) {
1674
+ const checkpoints = this.tasks.checkpoints;
1675
+ // Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
1676
+ const running = (value) => value && this.localCoding
1677
+ ? {
1678
+ ...value,
1679
+ background_running: this.localCoding
1680
+ .tasks(id)
1681
+ .filter((t) => t.state === 'running').length,
1682
+ }
1683
+ : value;
1684
+ const controller = (body) => ({
1685
+ client_id: string(body.client_id, 'client_id', 128),
1686
+ token: string(body.token, 'token', 128),
1687
+ });
1688
+ const idle = () => {
1689
+ this.supervisor.assertIdle(id);
1690
+ if (this.store.db
1691
+ .prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
1692
+ .get(id))
1693
+ throw new ServiceError('session_busy', 'Wait for the current turn to finish');
1694
+ };
1695
+ if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
1696
+ this.send(res, 200, { checkpoints: checkpoints.list(id) });
1697
+ return;
1698
+ }
1699
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
1700
+ this.send(res, 200, running(checkpoints.reviewRewind(id, string(url.searchParams.get('attempt'), 'attempt', 128), string(url.searchParams.get('mode'), 'mode', 16))));
1701
+ return;
1702
+ }
1703
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
1704
+ if (this.agentpod)
1705
+ throw new ServiceError('admission_required', 'Managed station sessions cannot rewind');
1706
+ const body = object(await readJson(req));
1707
+ const request = string(body.request_id, 'request_id', 128);
1708
+ this.send(res, 200, checkpoints.rewind(id, request, string(body.attempt_id, 'attempt_id', 128), string(body.mode, 'mode', 16), string(body.review_digest, 'review_digest', 128), controller(body), idle, (leaf, summary) => this.store.forkLocal(id, request + ':fork', this.store.session(id).revision, controller(body), (source, target) => {
1709
+ const dir = join(this.stateDir, 'workers', target, 'sessions');
1710
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1711
+ return forkPiSession(source.pi_session_file, source.cwd, dir, target, {
1712
+ leaf,
1713
+ summary,
1714
+ });
1715
+ }, { leaf })));
1716
+ return;
1717
+ }
1718
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
1719
+ this.send(res, 200, { review: running(checkpoints.review(id)) });
1720
+ return;
1721
+ }
1722
+ if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
1723
+ const q = (name) => string(url.searchParams.get(name), name, 1024);
1724
+ this.send(res, 200, {
1725
+ hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
1726
+ });
1727
+ return;
1728
+ }
1729
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
1730
+ const body = object(await readJson(req));
1731
+ const hunk = body.hunk === undefined
1732
+ ? undefined
1733
+ : {
1734
+ text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
1735
+ patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
1736
+ };
1737
+ this.send(res, 200, {
1738
+ review: running(checkpoints.act(id, {
1739
+ action: string(body.action, 'action', 16),
1740
+ path: string(body.path, 'path', 1024),
1741
+ ...(hunk ? { hunk } : {}),
1742
+ base: string(body.base, 'base', 128),
1743
+ current: string(body.current, 'current', 128),
1744
+ }, controller(body), idle)),
1745
+ });
1319
1746
  return;
1320
1747
  }
1321
1748
  }
@@ -1714,6 +2141,29 @@ export class SalaamService {
1714
2141
  this.send(res, 200, this.store.control(id, string(body.request_id, 'request_id', 128), string(body.client_id, 'client_id', 128), action, integer(body.expected_epoch, 'expected_epoch'), body.token === undefined ? undefined : string(body.token, 'token', 128)));
1715
2142
  return;
1716
2143
  }
2144
+ if (parts[3] === 'route' && req.method === 'POST') {
2145
+ const body = object(await readJson(req));
2146
+ this.store.authorizeControl(id, {
2147
+ client_id: string(body.client_id, 'client_id', 128),
2148
+ token: string(body.token, 'token', 128),
2149
+ });
2150
+ if (!this.tasks.instructions.applies(id))
2151
+ throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
2152
+ this.supervisor.assertIdle(id);
2153
+ const input = object(body.route);
2154
+ const requested = providerRoute(string(input.provider, 'provider'), input.model === undefined ? undefined : string(input.model, 'model'), undefined, undefined, undefined, input.reasoning);
2155
+ const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
2156
+ throw new ServiceError('provider_auth_required', `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`, 400);
2157
+ });
2158
+ if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
2159
+ throw new ServiceError('provider_output_cap_changed', 'That route enforces output caps differently; start a new session for it', 409);
2160
+ this.supervisor.assertIdle(id);
2161
+ this.store.switchRoute(id, prepared.route);
2162
+ await this.supervisor.stopSession(id);
2163
+ void this.supervisor.start(id).catch(() => { });
2164
+ this.send(res, 200, this.supervisor.inspect(id));
2165
+ return;
2166
+ }
1717
2167
  if (parts[3] === 'resume' && req.method === 'POST') {
1718
2168
  const body = object(await readJson(req));
1719
2169
  this.store.authorizeControl(id, {
@@ -1727,6 +2177,13 @@ export class SalaamService {
1727
2177
  throw new ServiceError('provider_route_changed', 'Restart the service with this session’s original provider route, or create a new session');
1728
2178
  if (session.state !== 'ready' && session.state !== 'starting')
1729
2179
  void this.supervisor.start(id).catch(() => { });
2180
+ // Show the project instructions an interactive session will use before its first turn.
2181
+ try {
2182
+ this.tasks.instructions?.refresh(id);
2183
+ }
2184
+ catch {
2185
+ /* reported on the next turn */
2186
+ }
1730
2187
  this.send(res, 202, this.supervisor.inspect(id));
1731
2188
  return;
1732
2189
  }
@@ -1742,12 +2199,23 @@ export class SalaamService {
1742
2199
  }
1743
2200
  if (parts[3] === 'submissions' && req.method === 'POST') {
1744
2201
  const body = object(await readJson(req));
1745
- const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), string(body.text, 'text', 256 * 1024), {
2202
+ const local = this.store.localCoding(id);
2203
+ let text = string(body.text, 'text', 256 * 1024);
2204
+ let images = [];
2205
+ // Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
2206
+ if (local) {
2207
+ if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
2208
+ throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
2209
+ ({ text, images } = expandPrompt(this.store.session(id).cwd, text, body.confirm_sensitive === true));
2210
+ }
2211
+ const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), text, {
1746
2212
  client_id: string(body.client_id, 'client_id', 128),
1747
2213
  token: string(body.token, 'token', 128),
1748
2214
  }, body.continues_attempt_id === undefined
1749
2215
  ? null
1750
2216
  : string(body.continues_attempt_id, 'continues_attempt_id', 128));
2217
+ // Same tick as the acceptance, so dispatch cannot claim the submission without them.
2218
+ storeImages(this.store.db, submission.id, images);
1751
2219
  this.send(res, 202, submission);
1752
2220
  return;
1753
2221
  }
@@ -1886,6 +2354,7 @@ export class SalaamService {
1886
2354
  await attempt(() => this.toolkitResolutions.close());
1887
2355
  await attempt(() => this.children.close());
1888
2356
  await attempt(() => this.supervisor.close());
2357
+ await attempt(() => this.supervisor.operatorMcp?.close());
1889
2358
  await attempt(() => this.dispatcher.drain());
1890
2359
  await attempt(() => this.managed.close());
1891
2360
  await attempt(() => Promise.all([this.operator, this.bridgeServer].map((server) => new Promise((resolve, reject) => {
@@ -1909,3 +2378,6 @@ export class SalaamService {
1909
2378
  throw this.shutdownFailure;
1910
2379
  }
1911
2380
  }
2381
+ function iso(ms) {
2382
+ return ms === null ? null : new Date(ms).toISOString();
2383
+ }