@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/dist/src/acp.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { realpathSync } from 'node:fs';
2
+ import { mkdirSync, realpathSync, statSync } from 'node:fs';
3
3
  import { isAbsolute, join } from 'node:path';
4
4
  import { once } from 'node:events';
5
5
  import { call } from './transport.js';
6
6
  import { acquireControl, Outbox, subscribe } from './attach.js';
7
7
  import { AcpJournal } from './acp-journal.js';
8
8
  import { VERSION } from './config.js';
9
- import { acpFailure, AcpFailureError, toolMetadata } from './acp-outcome.js';
9
+ import { acpFailure, AcpFailureError, permissionKind, toolMetadata, toolTitle, planUpdate, } from './acp-outcome.js';
10
10
  import { validateManagedMcpDeclarations } from './mcp-acp.js';
11
+ import { askReason, checkpointList, CONVERSATION_REWIND_NOTE, DESK_COMMANDS, editDiffs, FULL_ACCESS_WARNING, modeState, parseDeskCommand, policyFor, REWIND_CHOICES, } from './acp-desk.js';
12
+ import { onceOnlyReason } from './approval-rules.js';
13
+ import { rewindSummary } from './tui-review.js';
11
14
  import { object, string, ServiceError, } from './protocol.js';
12
15
  /** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
13
16
  export async function acp(state, options = {}) {
14
17
  const socket = join(state, 'operator.sock');
15
- const journal = new AcpJournal(state);
18
+ const client = acpClientState(state);
19
+ const journal = new AcpJournal(client);
16
20
  const admission = process.env.SALAAM_AGENTPOD_ADMISSION, admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
17
21
  delete process.env.SALAAM_AGENTPOD_ADMISSION;
18
22
  delete process.env.SALAAM_AGENTPOD_TOKEN;
@@ -27,9 +31,11 @@ export async function acp(state, options = {}) {
27
31
  JSON.stringify({ version: 1, owner: 'superpipeline' }) ||
28
32
  bound?.binding.run.source !== 'superpipeline'))
29
33
  throw new Error('Unsupported authenticated work control handoff');
30
- let closed = false, initialized = false, wantsReceipts = false, wantsCancellations = false, wantsTypedStops = false;
34
+ let closed = false, ignoredMcp = false, initialized = false, wantsReceipts = false, wantsCancellations = false, wantsTypedStops = false;
31
35
  const sessions = new Map();
32
36
  const permissionReplies = new Map();
37
+ /** Questions asked through the editor's permission prompt; an error or cancel counts as no. */
38
+ const confirmations = new Map();
33
39
  const activeRequests = new Set();
34
40
  let output = Promise.resolve();
35
41
  const send = (value) => {
@@ -60,6 +66,15 @@ export async function acp(state, options = {}) {
60
66
  params: { sessionId: id, update: out },
61
67
  });
62
68
  };
69
+ /** Send the session's durable task plan when it differs from the last one sent. */
70
+ const sendPlan = async (c, task) => {
71
+ const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
72
+ const key = plan ? JSON.stringify(plan) : undefined;
73
+ if (!plan || key === c.plan)
74
+ return;
75
+ c.plan = key;
76
+ await update(c.id, plan);
77
+ };
63
78
  const promptReceipt = async (session, request, attemptId) => {
64
79
  if (!bound || !wantsReceipts)
65
80
  return;
@@ -104,6 +119,109 @@ export async function acp(state, options = {}) {
104
119
  content: { type: 'text', text: content },
105
120
  });
106
121
  const snapshot = (id) => call(socket, `/v1/sessions/${id}/snapshot`);
122
+ const permissions = (id) => call(socket, `/v1/sessions/${id}/permissions`);
123
+ const say = (id, text) => update(id, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
124
+ /** Ask one question in the editor and wait for the chosen option id. */
125
+ const ask = async (c, title, body, options) => {
126
+ const id = 'confirm:' + randomUUID();
127
+ const toolCall = {
128
+ toolCallId: 'salaam-' + id,
129
+ title,
130
+ kind: 'other',
131
+ status: 'pending',
132
+ content: [{ type: 'content', content: { type: 'text', text: body } }],
133
+ };
134
+ await update(c.id, { sessionUpdate: 'tool_call', ...toolCall });
135
+ const chosen = new Promise((resolve) => confirmations.set(id, resolve));
136
+ await send({
137
+ jsonrpc: '2.0',
138
+ id,
139
+ method: 'session/request_permission',
140
+ params: { sessionId: c.id, toolCall, options },
141
+ });
142
+ const option = await chosen;
143
+ await update(c.id, {
144
+ sessionUpdate: 'tool_call_update',
145
+ toolCallId: toolCall.toolCallId,
146
+ status: option === 'cancel' || option === 'cancelled' ? 'failed' : 'completed',
147
+ });
148
+ return option;
149
+ };
150
+ /** The mode picker's state; sent again when the mode changes anywhere, e.g. Shift+Tab in the terminal. */
151
+ const deskState = async (c, announce = false) => {
152
+ if (!c.desk)
153
+ return {};
154
+ const current = await permissions(c.id);
155
+ const state = modeState(current);
156
+ const changed = c.desk.mode !== state.modes.currentModeId;
157
+ c.desk.mode = state.modes.currentModeId;
158
+ c.desk.policyMode = current.policy.mode;
159
+ if (announce && changed) {
160
+ await update(c.id, {
161
+ sessionUpdate: 'current_mode_update',
162
+ currentModeId: state.modes.currentModeId,
163
+ });
164
+ await update(c.id, {
165
+ sessionUpdate: 'config_option_update',
166
+ configOptions: state.configOptions,
167
+ });
168
+ }
169
+ return state;
170
+ };
171
+ const setMode = async (c, modeId) => {
172
+ if (!c.desk)
173
+ throw new ServiceError('modes_unavailable', 'Modes are for your own local coding sessions (salaam acp --local)');
174
+ const current = await permissions(c.id);
175
+ const next = policyFor(modeId, { full: current.full_access_allowed });
176
+ if (next.access === 'full' && current.policy.access !== 'full') {
177
+ const choice = await ask(c, 'Turn on full access?', FULL_ACCESS_WARNING, [
178
+ { optionId: 'on', name: 'Turn on full access', kind: 'allow_once' },
179
+ { optionId: 'cancel', name: 'Keep the current mode', kind: 'reject_once' },
180
+ ]);
181
+ if (choice !== 'on') {
182
+ // The picker already moved; put it back.
183
+ c.desk.mode = undefined;
184
+ return deskState(c, true);
185
+ }
186
+ }
187
+ await call(socket, `/v1/sessions/${c.id}/permissions`, {
188
+ ...(await controller(c)),
189
+ request_id: randomUUID(),
190
+ expected_revision: current.policy.revision,
191
+ policy: next,
192
+ });
193
+ return deskState(c, true);
194
+ };
195
+ /** /rewind from the editor: list turns, or review one turn's files and restore them on a yes. */
196
+ const rewind = async (c, turn) => {
197
+ if (!c.desk)
198
+ throw new Error('/rewind is for your own local coding sessions.');
199
+ const { checkpoints } = await call(socket, `/v1/sessions/${c.id}/checkpoints`);
200
+ if (turn === undefined)
201
+ return say(c.id, checkpointList(checkpoints));
202
+ const point = checkpoints.find((p) => p.turn === turn);
203
+ if (!point)
204
+ return say(c.id, `No checkpoint for turn ${turn}. /rewind lists them.`);
205
+ const review = await call(socket, `/v1/sessions/${c.id}/rewind?attempt=${encodeURIComponent(point.attempt_id)}&mode=files`);
206
+ const body = rewindSummary(review) +
207
+ (review.background_running
208
+ ? `\n${review.background_running} background command(s) are still running and may write while you decide.`
209
+ : '') +
210
+ '\n\n' +
211
+ CONVERSATION_REWIND_NOTE;
212
+ if ((await ask(c, `Rewind files to before turn ${review.turn}`, body, REWIND_CHOICES)) !== 'files')
213
+ return say(c.id, 'Nothing was restored.');
214
+ const result = await call(socket, `/v1/sessions/${c.id}/rewind`, {
215
+ ...(await controller(c)),
216
+ request_id: randomUUID(),
217
+ attempt_id: review.attempt_id,
218
+ mode: review.mode,
219
+ review_digest: review.digest,
220
+ });
221
+ return say(c.id, result.state === 'completed'
222
+ ? `Files restored to before turn ${review.turn}. The conversation is kept as it is.`
223
+ : 'Rewind outcome is uncertain; inspect files before continuing.');
224
+ };
107
225
  const toolDetails = async (session, id, name, input) => (await call(socket, `/v1/sessions/${session}/tool-metadata?call_id=${encodeURIComponent(id)}`)) ?? toolMetadata(name, input);
108
226
  const readEntry = async (id, entry) => {
109
227
  let content = '', offset = 0;
@@ -144,7 +262,7 @@ export async function acp(state, options = {}) {
144
262
  await update(c.id, {
145
263
  sessionUpdate: 'tool_call',
146
264
  toolCallId: String(part.id),
147
- title: String(part.name),
265
+ title: toolTitle(String(part.name), part.arguments),
148
266
  ...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
149
267
  status: 'in_progress',
150
268
  rawInput: part.arguments,
@@ -203,11 +321,33 @@ export async function acp(state, options = {}) {
203
321
  const op = await call(socket, `/v1/operations/${approval.operation_id}`);
204
322
  c.permissions.add(approval.id);
205
323
  const id = 'permission:' + randomUUID();
324
+ const value = String(op.value);
325
+ // Your own session can approve for the rest of it, unless the command can never be a rule.
326
+ const sessionRule = !!c.desk?.rules && !onceOnlyReason(value);
327
+ const edits = c.desk
328
+ ? await call(socket, `/v1/sessions/${c.id}/local-edits?operation_id=${encodeURIComponent(op.id)}`).catch(() => null)
329
+ : null;
206
330
  const toolCall = {
207
331
  toolCallId: 'salaam-operation:' + op.id,
208
332
  title: op.kind + ': ' + op.target,
209
- kind: 'other',
333
+ kind: edits ? 'edit' : permissionKind(op.kind),
210
334
  status: 'pending',
335
+ ...(c.desk
336
+ ? {
337
+ content: [
338
+ {
339
+ type: 'content',
340
+ content: { type: 'text', text: askReason(value, c.desk.policyMode) },
341
+ },
342
+ ...(edits?.edits.length
343
+ ? editDiffs(edits.root, edits.edits)
344
+ : [{ type: 'content', content: { type: 'text', text: value } }]),
345
+ ],
346
+ ...(edits?.edits.length
347
+ ? { locations: editDiffs(edits.root, edits.edits).map((d) => ({ path: d.path })) }
348
+ : {}),
349
+ }
350
+ : {}),
211
351
  rawInput: {
212
352
  target: op.target,
213
353
  value: op.value,
@@ -225,11 +365,29 @@ export async function acp(state, options = {}) {
225
365
  return;
226
366
  }
227
367
  if (outcome.outcome !== 'selected' ||
228
- !['allow-once', 'reject-once'].includes(String(outcome.optionId)))
368
+ !['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(String(outcome.optionId)))
229
369
  throw new Error('Invalid permission outcome');
370
+ if (outcome.optionId === 'allow-session') {
371
+ // The rule is the exact request just reviewed. If it cannot become one, nothing is
372
+ // approved and the request is asked again.
373
+ try {
374
+ await call(socket, `/v1/sessions/${c.id}/approval-rules`, {
375
+ ...(await controller(c)),
376
+ request_id: randomUUID(),
377
+ scope: 'session',
378
+ operation_id: op.id,
379
+ });
380
+ }
381
+ catch (error) {
382
+ await say(c.id, `Not saved as a rule: ${error.message} Nothing was approved; it will ask again.`);
383
+ c.permissions.delete(approval.id);
384
+ void refresh(c).catch(() => { });
385
+ return;
386
+ }
387
+ }
230
388
  const decide = async () => {
231
- const outbox = new Outbox(state, c.id);
232
- outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'allow-once' ? 'approve' : 'deny');
389
+ const outbox = new Outbox(client, c.id);
390
+ outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'reject-once' ? 'deny' : 'approve');
233
391
  await outbox.flush(socket, c.id);
234
392
  };
235
393
  c.decisions = c.decisions.catch(() => { }).then(decide);
@@ -244,6 +402,15 @@ export async function acp(state, options = {}) {
244
402
  toolCall,
245
403
  options: [
246
404
  { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
405
+ ...(sessionRule
406
+ ? [
407
+ {
408
+ optionId: 'allow-session',
409
+ name: 'Allow for this session',
410
+ kind: 'allow_always',
411
+ },
412
+ ]
413
+ : []),
247
414
  { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
248
415
  ],
249
416
  },
@@ -311,10 +478,18 @@ export async function acp(state, options = {}) {
311
478
  const started = event.kind === 'tool.started';
312
479
  const fresh = !c.liveTools.has(p.tool_call_id);
313
480
  c.liveTools.add(p.tool_call_id);
314
- void update(c.id, {
481
+ const callId = p.tool_call_id;
482
+ // A finished edit in your own session shows as a diff, whichever mode applied it.
483
+ const diffs = !started && !p.failed && c.desk && p.tool === 'salaam_local'
484
+ ? call(socket, `/v1/sessions/${c.id}/local-edits?tool_call=${encodeURIComponent(callId)}`)
485
+ .then((found) => (found ? editDiffs(found.root, found.edits) : []))
486
+ .catch(() => [])
487
+ : Promise.resolve([]);
488
+ void diffs
489
+ .then(async (diff) => update(c.id, {
315
490
  sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
316
491
  toolCallId: p.tool_call_id,
317
- title: p.tool ?? 'Tool',
492
+ title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
318
493
  ...(started
319
494
  ? {
320
495
  kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
@@ -323,14 +498,33 @@ export async function acp(state, options = {}) {
323
498
  : {}),
324
499
  status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
325
500
  ...(started && p.input ? { rawInput: p.input } : {}),
501
+ ...(!started && (p.output || diff.length)
502
+ ? {
503
+ content: [
504
+ ...diff,
505
+ ...(p.output
506
+ ? [{ type: 'content', content: { type: 'text', text: p.output } }]
507
+ : []),
508
+ ],
509
+ }
510
+ : {}),
326
511
  ...(p.parent_tool_call_id
327
512
  ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
328
513
  : {}),
329
- }).catch((error) => {
514
+ }))
515
+ .catch((error) => {
330
516
  c.failed = error;
331
517
  });
332
518
  }
333
519
  }
520
+ if (event.kind === 'permissions.configured' && c.desk)
521
+ void deskState(c, true).catch((error) => {
522
+ c.failed = error;
523
+ });
524
+ if (event.kind.startsWith('work_task.'))
525
+ void sendPlan(c).catch((error) => {
526
+ c.failed = error;
527
+ });
334
528
  if ([
335
529
  'inbox.changed',
336
530
  'inbox.resolved',
@@ -392,6 +586,13 @@ export async function acp(state, options = {}) {
392
586
  };
393
587
  sessions.set(id, c);
394
588
  try {
589
+ if (!bound) {
590
+ const current = await permissions(id).catch(() => undefined);
591
+ if (current?.coding && current.configurable) {
592
+ const rules = await call(socket, `/v1/sessions/${id}/approval-rules`).catch(() => ({ applies: false }));
593
+ c.desk = { rules: rules.applies };
594
+ }
595
+ }
395
596
  if (view.state !== 'ready') {
396
597
  const actor = await controller(c, object(params._meta ?? {})['salaam.dev/takeover'] === true, true);
397
598
  await call(socket, `/v1/sessions/${id}/resume`, actor);
@@ -431,8 +632,18 @@ export async function acp(state, options = {}) {
431
632
  await promptReceipt(id, record.request_id, record.receipt.attempt_id);
432
633
  if (wantsCancellations)
433
634
  await cancellationReceipts(c);
635
+ if (c.desk)
636
+ await deskState(c);
434
637
  await approvals(c, view);
638
+ // The plan is visible before any step runs.
639
+ await sendPlan(c, view.work_task ?? null);
435
640
  watch(c);
641
+ if (c.desk)
642
+ // After the response, so the editor knows the session first.
643
+ setImmediate(() => void update(id, {
644
+ sessionUpdate: 'available_commands_update',
645
+ availableCommands: DESK_COMMANDS,
646
+ }).catch(() => { }));
436
647
  }
437
648
  catch (error) {
438
649
  sessions.delete(id);
@@ -512,7 +723,17 @@ export async function acp(state, options = {}) {
512
723
  }
513
724
  if (['session/new', 'session/load', 'session/resume'].includes(method)) {
514
725
  const cwd = string(params.cwd, 'cwd');
515
- validateManagedMcpDeclarations(params.mcpServers, bound?.managed_mcp);
726
+ // An editor forwards its own MCP servers. A desk session never connects to them, so it
727
+ // ignores the list; station work keeps the exact node assignment check.
728
+ const desk = !bound && (object(params._meta ?? {})['salaam.dev/local'] ?? options.local) === true;
729
+ if (desk && Array.isArray(params.mcpServers)) {
730
+ if (params.mcpServers.length && !ignoredMcp) {
731
+ ignoredMcp = true;
732
+ console.error(`Salaam does not use the editor's MCP servers; ignored ${params.mcpServers.length}.`);
733
+ }
734
+ }
735
+ else
736
+ validateManagedMcpDeclarations(params.mcpServers, bound?.managed_mcp);
516
737
  if (!isAbsolute(cwd) ||
517
738
  (Array.isArray(params.additionalDirectories) && params.additionalDirectories.length))
518
739
  throw new Error('Use an absolute cwd and no extra directories');
@@ -530,6 +751,22 @@ export async function acp(state, options = {}) {
530
751
  const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
531
752
  if (typeof coding !== 'boolean')
532
753
  throw new Error('Coding selection must be a boolean.');
754
+ const local = object(params._meta ?? {})['salaam.dev/local'] ?? options.local ?? false;
755
+ if (typeof local !== 'boolean')
756
+ throw new Error('Local selection must be a boolean.');
757
+ if (local && coding)
758
+ throw new Error('Choose a local session in the project folder or a guarded coding copy.');
759
+ if (local) {
760
+ // Your desk session in the project folder itself, the same as salaam in a terminal there.
761
+ const created = await call(socket, '/v1/sessions', {
762
+ request_id: randomUUID(),
763
+ cwd,
764
+ local_workspace: true,
765
+ local_coding: true,
766
+ });
767
+ const c = await connect(created.id, false, params);
768
+ return { sessionId: created.id, ...(await deskState(c)) };
769
+ }
533
770
  if (coding) {
534
771
  const health = await call(socket, '/v1/health');
535
772
  if (!health.coding_root)
@@ -543,8 +780,21 @@ export async function acp(state, options = {}) {
543
780
  await connect(created.id, false, params);
544
781
  return { sessionId: created.id };
545
782
  }
546
- await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
547
- return {};
783
+ const c = await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
784
+ return await deskState(c);
785
+ }
786
+ if (method === 'session/set_mode' || method === 'session/set_config_option') {
787
+ const c = sessions.get(string(params.sessionId, 'sessionId', 128));
788
+ if (!c)
789
+ throw new Error('Load or resume this session first');
790
+ if (method === 'session/set_mode') {
791
+ await setMode(c, string(params.modeId, 'modeId', 64));
792
+ return {};
793
+ }
794
+ if (params.configId !== 'mode')
795
+ throw new Error('Unknown setting');
796
+ const state = await setMode(c, string(params.value, 'value', 64));
797
+ return { configOptions: 'configOptions' in state ? state.configOptions : [] };
548
798
  }
549
799
  if (!['session/cancel', 'session/prompt'].includes(method))
550
800
  throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
@@ -574,6 +824,12 @@ export async function acp(state, options = {}) {
574
824
  throw new Error('Only text and resource links are supported');
575
825
  })
576
826
  .join('\n');
827
+ const command = c.desk ? parseDeskCommand(input) : null;
828
+ if (command) {
829
+ // Handled here, never sent to the model.
830
+ await rewind(c, command.turn);
831
+ return { stopReason: 'end_turn' };
832
+ }
577
833
  const meta = object(params._meta ?? {});
578
834
  const logical = meta['salaam.dev/requestId'] === undefined
579
835
  ? randomUUID()
@@ -679,6 +935,13 @@ export async function acp(state, options = {}) {
679
935
  id = value.id;
680
936
  if (typeof value.method !== 'string') {
681
937
  response = true;
938
+ if (typeof id === 'string' && confirmations.has(id)) {
939
+ const resolve = confirmations.get(id);
940
+ confirmations.delete(id);
941
+ const outcome = object(object(value.result ?? {}).outcome ?? {});
942
+ resolve(!value.error && outcome.outcome === 'selected' ? String(outcome.optionId) : 'cancelled');
943
+ return;
944
+ }
682
945
  if (typeof id === 'string' && permissionReplies.has(id)) {
683
946
  const respond = permissionReplies.get(id);
684
947
  permissionReplies.delete(id);
@@ -760,6 +1023,9 @@ export async function acp(state, options = {}) {
760
1023
  clearInterval(renew);
761
1024
  clearInterval(cancellationPoll);
762
1025
  permissionReplies.clear();
1026
+ for (const resolve of confirmations.values())
1027
+ resolve('cancelled');
1028
+ confirmations.clear();
763
1029
  for (const c of sessions.values()) {
764
1030
  c.feed?.close();
765
1031
  if (c.lease)
@@ -773,3 +1039,18 @@ export async function acp(state, options = {}) {
773
1039
  }
774
1040
  }
775
1041
  }
1042
+ /** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
1043
+ * journal and outbox in the caller's state home, keyed by the daemon state path. */
1044
+ export function acpClientState(state, env = process.env) {
1045
+ let own = false;
1046
+ try {
1047
+ own = statSync(state).uid === process.getuid?.();
1048
+ }
1049
+ catch { }
1050
+ if (own)
1051
+ return state;
1052
+ const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
1053
+ const dir = join(home, 'salaam/acp-clients', createHash('sha256').update(state).digest('hex').slice(0, 32));
1054
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1055
+ return dir;
1056
+ }
@@ -154,6 +154,51 @@ export class AgentProfiles {
154
154
  throw new ServiceError('not_found', 'Agent profile not found', 404);
155
155
  return profile;
156
156
  }
157
+ /**
158
+ * The task critic's grant (item 8): always the built-in salaam/reviewer, never another profile,
159
+ * with no facts. It is granted under the work admission, not by an operator controller, and
160
+ * only while the caller's authorize holds (the admitted plan declares a critic). The reviewer
161
+ * reads only the immutable snapshot it is given and can only report findings.
162
+ */
163
+ async admitCritic(session, request, authorize) {
164
+ const profileId = 'salaam/reviewer';
165
+ const profile = this.inspect(session, profileId);
166
+ const expectedDigest = digest(profile);
167
+ const input = { kind: 'critic-admit', session, profileId, expectedDigest };
168
+ const saved = this.store.cachedRequest('task-critic', request, input);
169
+ if (saved)
170
+ return saved;
171
+ if (!BUILTIN_PROFILES.some((p) => p.id === profileId && digest(p) === expectedDigest))
172
+ throw new ServiceError('profile_changed', 'The critic must be the built-in reviewer');
173
+ const prepared = await this.prepareRoute(this.route, session);
174
+ return this.store.request('task-critic', request, input, () => {
175
+ authorize();
176
+ const grant = {
177
+ schema_version: 1,
178
+ id: randomUUID(),
179
+ owner_session: session,
180
+ profile,
181
+ profile_digest: expectedDigest,
182
+ route: prepared.route,
183
+ output_cap: prepared.output_cap,
184
+ facts: [],
185
+ };
186
+ this.store.db
187
+ .prepare('INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)')
188
+ .run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
189
+ this.store.db
190
+ .prepare('INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)')
191
+ .run(profile.id, profile.revision, expectedDigest, canonical(profile));
192
+ this.store.event(session, 'agent.admitted', {
193
+ grant_id: grant.id,
194
+ profile_id: profileId,
195
+ profile_digest: expectedDigest,
196
+ route: prepared.route,
197
+ critic: true,
198
+ });
199
+ return grant;
200
+ });
201
+ }
157
202
  async admit(session, request, profileId, expectedDigest, factIds, controller) {
158
203
  const input = { kind: 'agent-admit', session, profileId, expectedDigest, factIds };
159
204
  const saved = this.store.cachedRequest('local-operator', request, input);