@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/acp.ts CHANGED
@@ -1,14 +1,39 @@
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, type Feed } 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 {
10
+ acpFailure,
11
+ AcpFailureError,
12
+ permissionKind,
13
+ toolMetadata,
14
+ toolTitle,
15
+ planUpdate,
16
+ } from './acp-outcome.js';
10
17
  import type { CancellationReceipt } from './turn-cancellations.js';
11
18
  import { validateManagedMcpDeclarations } from './mcp-acp.js';
19
+ import {
20
+ askReason,
21
+ checkpointList,
22
+ CONVERSATION_REWIND_NOTE,
23
+ DESK_COMMANDS,
24
+ editDiffs,
25
+ FULL_ACCESS_WARNING,
26
+ modeState,
27
+ parseDeskCommand,
28
+ policyFor,
29
+ REWIND_CHOICES,
30
+ type DeskPermissions,
31
+ } from './acp-desk.js';
32
+ import { onceOnlyReason } from './approval-rules.js';
33
+ import { rewindSummary } from './tui-review.js';
34
+ import type { PermissionMode } from './autonomy.js';
35
+ import type { LocalEdit } from './local-edits.js';
36
+ import type { RewindReview, TurnCheckpoint } from './turn-rewind.js';
12
37
  import {
13
38
  object,
14
39
  string,
@@ -40,11 +65,19 @@ interface Connection {
40
65
  dirty: boolean;
41
66
  decisions: Promise<void>;
42
67
  failed?: Error;
68
+ /** The last plan sent, so an unchanged task sends nothing. */
69
+ plan?: string;
70
+ /** Your own local coding session: modes, rules, diffs and /rewind apply. */
71
+ desk?: { rules: boolean; mode?: string; policyMode?: PermissionMode };
43
72
  }
44
73
  /** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
45
- export async function acp(state: string, options: { coding?: boolean } = {}): Promise<void> {
74
+ export async function acp(
75
+ state: string,
76
+ options: { coding?: boolean; local?: boolean } = {},
77
+ ): Promise<void> {
46
78
  const socket = join(state, 'operator.sock');
47
- const journal = new AcpJournal(state);
79
+ const client = acpClientState(state);
80
+ const journal = new AcpJournal(client);
48
81
  const admission = process.env.SALAAM_AGENTPOD_ADMISSION,
49
82
  admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
50
83
  delete process.env.SALAAM_AGENTPOD_ADMISSION;
@@ -68,12 +101,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
68
101
  )
69
102
  throw new Error('Unsupported authenticated work control handoff');
70
103
  let closed = false,
104
+ ignoredMcp = false,
71
105
  initialized = false,
72
106
  wantsReceipts = false,
73
107
  wantsCancellations = false,
74
108
  wantsTypedStops = false;
75
109
  const sessions = new Map<string, Connection>();
76
110
  const permissionReplies = new Map<string, (result: Json) => Promise<void>>();
111
+ /** Questions asked through the editor's permission prompt; an error or cancel counts as no. */
112
+ const confirmations = new Map<string, (option: string) => void>();
77
113
  const activeRequests = new Set<string>();
78
114
  let output = Promise.resolve();
79
115
  const send = (value: unknown): Promise<void> => {
@@ -102,6 +138,14 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
102
138
  params: { sessionId: id, update: out },
103
139
  });
104
140
  };
141
+ /** Send the session's durable task plan when it differs from the last one sent. */
142
+ const sendPlan = async (c: Connection, task?: SessionSnapshot['work_task']) => {
143
+ const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
144
+ const key = plan ? JSON.stringify(plan) : undefined;
145
+ if (!plan || key === c.plan) return;
146
+ c.plan = key;
147
+ await update(c.id, plan as unknown as Json);
148
+ };
105
149
  const promptReceipt = async (session: string, request: string, attemptId: string) => {
106
150
  if (!bound || !wantsReceipts) return;
107
151
  const attempt = await call<Attempt>(socket, `/v1/attempts/${attemptId}`);
@@ -148,6 +192,130 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
148
192
  content: { type: 'text', text: content },
149
193
  });
150
194
  const snapshot = (id: string) => call<SessionSnapshot>(socket, `/v1/sessions/${id}/snapshot`);
195
+ const permissions = (id: string) =>
196
+ call<DeskPermissions & { coding: boolean; configurable: boolean }>(
197
+ socket,
198
+ `/v1/sessions/${id}/permissions`,
199
+ );
200
+ const say = (id: string, text: string) =>
201
+ update(id, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
202
+ /** Ask one question in the editor and wait for the chosen option id. */
203
+ const ask = async (
204
+ c: Connection,
205
+ title: string,
206
+ body: string,
207
+ options: { optionId: string; name: string; kind: string }[],
208
+ ): Promise<string> => {
209
+ const id = 'confirm:' + randomUUID();
210
+ const toolCall = {
211
+ toolCallId: 'salaam-' + id,
212
+ title,
213
+ kind: 'other',
214
+ status: 'pending',
215
+ content: [{ type: 'content', content: { type: 'text', text: body } }],
216
+ };
217
+ await update(c.id, { sessionUpdate: 'tool_call', ...toolCall });
218
+ const chosen = new Promise<string>((resolve) => confirmations.set(id, resolve));
219
+ await send({
220
+ jsonrpc: '2.0',
221
+ id,
222
+ method: 'session/request_permission',
223
+ params: { sessionId: c.id, toolCall, options },
224
+ });
225
+ const option = await chosen;
226
+ await update(c.id, {
227
+ sessionUpdate: 'tool_call_update',
228
+ toolCallId: toolCall.toolCallId,
229
+ status: option === 'cancel' || option === 'cancelled' ? 'failed' : 'completed',
230
+ });
231
+ return option;
232
+ };
233
+ /** The mode picker's state; sent again when the mode changes anywhere, e.g. Shift+Tab in the terminal. */
234
+ const deskState = async (c: Connection, announce = false) => {
235
+ if (!c.desk) return {};
236
+ const current = await permissions(c.id);
237
+ const state = modeState(current);
238
+ const changed = c.desk.mode !== state.modes.currentModeId;
239
+ c.desk.mode = state.modes.currentModeId;
240
+ c.desk.policyMode = current.policy.mode;
241
+ if (announce && changed) {
242
+ await update(c.id, {
243
+ sessionUpdate: 'current_mode_update',
244
+ currentModeId: state.modes.currentModeId,
245
+ });
246
+ await update(c.id, {
247
+ sessionUpdate: 'config_option_update',
248
+ configOptions: state.configOptions,
249
+ });
250
+ }
251
+ return state;
252
+ };
253
+ const setMode = async (c: Connection, modeId: string) => {
254
+ if (!c.desk)
255
+ throw new ServiceError(
256
+ 'modes_unavailable',
257
+ 'Modes are for your own local coding sessions (salaam acp --local)',
258
+ );
259
+ const current = await permissions(c.id);
260
+ const next = policyFor(modeId, { full: current.full_access_allowed });
261
+ if (next.access === 'full' && current.policy.access !== 'full') {
262
+ const choice = await ask(c, 'Turn on full access?', FULL_ACCESS_WARNING, [
263
+ { optionId: 'on', name: 'Turn on full access', kind: 'allow_once' },
264
+ { optionId: 'cancel', name: 'Keep the current mode', kind: 'reject_once' },
265
+ ]);
266
+ if (choice !== 'on') {
267
+ // The picker already moved; put it back.
268
+ c.desk.mode = undefined;
269
+ return deskState(c, true);
270
+ }
271
+ }
272
+ await call(socket, `/v1/sessions/${c.id}/permissions`, {
273
+ ...(await controller(c)),
274
+ request_id: randomUUID(),
275
+ expected_revision: current.policy.revision,
276
+ policy: next,
277
+ });
278
+ return deskState(c, true);
279
+ };
280
+ /** /rewind from the editor: list turns, or review one turn's files and restore them on a yes. */
281
+ const rewind = async (c: Connection, turn: number | undefined) => {
282
+ if (!c.desk) throw new Error('/rewind is for your own local coding sessions.');
283
+ const { checkpoints } = await call<{ checkpoints: TurnCheckpoint[] }>(
284
+ socket,
285
+ `/v1/sessions/${c.id}/checkpoints`,
286
+ );
287
+ if (turn === undefined) return say(c.id, checkpointList(checkpoints));
288
+ const point = checkpoints.find((p) => p.turn === turn);
289
+ if (!point) return say(c.id, `No checkpoint for turn ${turn}. /rewind lists them.`);
290
+ const review = await call<RewindReview>(
291
+ socket,
292
+ `/v1/sessions/${c.id}/rewind?attempt=${encodeURIComponent(point.attempt_id)}&mode=files`,
293
+ );
294
+ const body =
295
+ rewindSummary(review) +
296
+ (review.background_running
297
+ ? `\n${review.background_running} background command(s) are still running and may write while you decide.`
298
+ : '') +
299
+ '\n\n' +
300
+ CONVERSATION_REWIND_NOTE;
301
+ if (
302
+ (await ask(c, `Rewind files to before turn ${review.turn}`, body, REWIND_CHOICES)) !== 'files'
303
+ )
304
+ return say(c.id, 'Nothing was restored.');
305
+ const result = await call<{ state: string }>(socket, `/v1/sessions/${c.id}/rewind`, {
306
+ ...(await controller(c)),
307
+ request_id: randomUUID(),
308
+ attempt_id: review.attempt_id,
309
+ mode: review.mode,
310
+ review_digest: review.digest,
311
+ });
312
+ return say(
313
+ c.id,
314
+ result.state === 'completed'
315
+ ? `Files restored to before turn ${review.turn}. The conversation is kept as it is.`
316
+ : 'Rewind outcome is uncertain; inspect files before continuing.',
317
+ );
318
+ };
151
319
  const toolDetails = async (session: string, id: string, name: string, input: unknown) =>
152
320
  (await call<Json | null>(
153
321
  socket,
@@ -201,7 +369,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
201
369
  await update(c.id, {
202
370
  sessionUpdate: 'tool_call',
203
371
  toolCallId: String(part.id),
204
- title: String(part.name),
372
+ title: toolTitle(String(part.name), part.arguments),
205
373
  ...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
206
374
  status: 'in_progress',
207
375
  rawInput: part.arguments,
@@ -264,11 +432,36 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
264
432
  const op = await call<Operation>(socket, `/v1/operations/${approval.operation_id}`);
265
433
  c.permissions.add(approval.id);
266
434
  const id = 'permission:' + randomUUID();
435
+ const value = String(op.value);
436
+ // Your own session can approve for the rest of it, unless the command can never be a rule.
437
+ const sessionRule = !!c.desk?.rules && !onceOnlyReason(value);
438
+ const edits = c.desk
439
+ ? await call<{ root: string; edits: LocalEdit[] } | null>(
440
+ socket,
441
+ `/v1/sessions/${c.id}/local-edits?operation_id=${encodeURIComponent(op.id)}`,
442
+ ).catch(() => null)
443
+ : null;
267
444
  const toolCall = {
268
445
  toolCallId: 'salaam-operation:' + op.id,
269
446
  title: op.kind + ': ' + op.target,
270
- kind: 'other',
447
+ kind: edits ? 'edit' : permissionKind(op.kind),
271
448
  status: 'pending',
449
+ ...(c.desk
450
+ ? {
451
+ content: [
452
+ {
453
+ type: 'content',
454
+ content: { type: 'text', text: askReason(value, c.desk.policyMode) },
455
+ },
456
+ ...(edits?.edits.length
457
+ ? editDiffs(edits.root, edits.edits)
458
+ : [{ type: 'content', content: { type: 'text', text: value } }]),
459
+ ],
460
+ ...(edits?.edits.length
461
+ ? { locations: editDiffs(edits.root, edits.edits).map((d) => ({ path: d.path })) }
462
+ : {}),
463
+ }
464
+ : {}),
272
465
  rawInput: {
273
466
  target: op.target,
274
467
  value: op.value,
@@ -286,16 +479,38 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
286
479
  }
287
480
  if (
288
481
  outcome.outcome !== 'selected' ||
289
- !['allow-once', 'reject-once'].includes(String(outcome.optionId))
482
+ !['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(
483
+ String(outcome.optionId),
484
+ )
290
485
  )
291
486
  throw new Error('Invalid permission outcome');
487
+ if (outcome.optionId === 'allow-session') {
488
+ // The rule is the exact request just reviewed. If it cannot become one, nothing is
489
+ // approved and the request is asked again.
490
+ try {
491
+ await call(socket, `/v1/sessions/${c.id}/approval-rules`, {
492
+ ...(await controller(c)),
493
+ request_id: randomUUID(),
494
+ scope: 'session',
495
+ operation_id: op.id,
496
+ });
497
+ } catch (error) {
498
+ await say(
499
+ c.id,
500
+ `Not saved as a rule: ${(error as Error).message} Nothing was approved; it will ask again.`,
501
+ );
502
+ c.permissions.delete(approval.id);
503
+ void refresh(c).catch(() => {});
504
+ return;
505
+ }
506
+ }
292
507
  const decide = async () => {
293
- const outbox = new Outbox(state, c.id);
508
+ const outbox = new Outbox(client, c.id);
294
509
  outbox.enqueueDecision(
295
510
  approval.id,
296
511
  approval.operation_digest,
297
512
  approval.revision,
298
- outcome.optionId === 'allow-once' ? 'approve' : 'deny',
513
+ outcome.optionId === 'reject-once' ? 'deny' : 'approve',
299
514
  );
300
515
  await outbox.flush(socket, c.id);
301
516
  };
@@ -311,6 +526,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
311
526
  toolCall,
312
527
  options: [
313
528
  { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
529
+ ...(sessionRule
530
+ ? [
531
+ {
532
+ optionId: 'allow-session',
533
+ name: 'Allow for this session',
534
+ kind: 'allow_always',
535
+ },
536
+ ]
537
+ : []),
314
538
  { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
315
539
  ],
316
540
  },
@@ -381,6 +605,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
381
605
  tool?: string;
382
606
  input?: string;
383
607
  failed?: boolean;
608
+ output?: string;
384
609
  kind?: string;
385
610
  locations?: unknown[];
386
611
  };
@@ -388,26 +613,59 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
388
613
  const started = event.kind === 'tool.started';
389
614
  const fresh = !c.liveTools.has(p.tool_call_id);
390
615
  c.liveTools.add(p.tool_call_id);
391
- void update(c.id, {
392
- sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
393
- toolCallId: p.tool_call_id,
394
- title: p.tool ?? 'Tool',
395
- ...(started
396
- ? {
397
- kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
398
- locations: p.locations ?? [],
399
- }
400
- : {}),
401
- status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
402
- ...(started && p.input ? { rawInput: p.input } : {}),
403
- ...(p.parent_tool_call_id
404
- ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
405
- : {}),
406
- }).catch((error) => {
407
- c.failed = error;
408
- });
616
+ const callId = p.tool_call_id;
617
+ // A finished edit in your own session shows as a diff, whichever mode applied it.
618
+ const diffs =
619
+ !started && !p.failed && c.desk && p.tool === 'salaam_local'
620
+ ? call<{ root: string; edits: LocalEdit[] } | null>(
621
+ socket,
622
+ `/v1/sessions/${c.id}/local-edits?tool_call=${encodeURIComponent(callId)}`,
623
+ )
624
+ .then((found) => (found ? editDiffs(found.root, found.edits) : []))
625
+ .catch(() => [])
626
+ : Promise.resolve([]);
627
+ void diffs
628
+ .then(async (diff) =>
629
+ update(c.id, {
630
+ sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
631
+ toolCallId: p.tool_call_id,
632
+ title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
633
+ ...(started
634
+ ? {
635
+ kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
636
+ locations: p.locations ?? [],
637
+ }
638
+ : {}),
639
+ status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
640
+ ...(started && p.input ? { rawInput: p.input } : {}),
641
+ ...(!started && (p.output || diff.length)
642
+ ? {
643
+ content: [
644
+ ...diff,
645
+ ...(p.output
646
+ ? [{ type: 'content', content: { type: 'text', text: p.output } }]
647
+ : []),
648
+ ],
649
+ }
650
+ : {}),
651
+ ...(p.parent_tool_call_id
652
+ ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
653
+ : {}),
654
+ }),
655
+ )
656
+ .catch((error) => {
657
+ c.failed = error;
658
+ });
409
659
  }
410
660
  }
661
+ if (event.kind === 'permissions.configured' && c.desk)
662
+ void deskState(c, true).catch((error) => {
663
+ c.failed = error;
664
+ });
665
+ if (event.kind.startsWith('work_task.'))
666
+ void sendPlan(c).catch((error) => {
667
+ c.failed = error;
668
+ });
411
669
  if (
412
670
  [
413
671
  'inbox.changed',
@@ -472,6 +730,16 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
472
730
  };
473
731
  sessions.set(id, c);
474
732
  try {
733
+ if (!bound) {
734
+ const current = await permissions(id).catch(() => undefined);
735
+ if (current?.coding && current.configurable) {
736
+ const rules = await call<{ applies: boolean }>(
737
+ socket,
738
+ `/v1/sessions/${id}/approval-rules`,
739
+ ).catch(() => ({ applies: false }));
740
+ c.desk = { rules: rules.applies };
741
+ }
742
+ }
475
743
  if (view.state !== 'ready') {
476
744
  const actor = await controller(
477
745
  c,
@@ -517,8 +785,20 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
517
785
  for (const record of journal.records(id))
518
786
  if (record.receipt) await promptReceipt(id, record.request_id, record.receipt.attempt_id);
519
787
  if (wantsCancellations) await cancellationReceipts(c);
788
+ if (c.desk) await deskState(c);
520
789
  await approvals(c, view);
790
+ // The plan is visible before any step runs.
791
+ await sendPlan(c, view.work_task ?? null);
521
792
  watch(c);
793
+ if (c.desk)
794
+ // After the response, so the editor knows the session first.
795
+ setImmediate(
796
+ () =>
797
+ void update(id, {
798
+ sessionUpdate: 'available_commands_update',
799
+ availableCommands: DESK_COMMANDS,
800
+ }).catch(() => {}),
801
+ );
522
802
  } catch (error) {
523
803
  sessions.delete(id);
524
804
  c.feed?.close();
@@ -600,7 +880,18 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
600
880
  }
601
881
  if (['session/new', 'session/load', 'session/resume'].includes(method)) {
602
882
  const cwd = string(params.cwd, 'cwd');
603
- validateManagedMcpDeclarations(params.mcpServers, bound?.managed_mcp);
883
+ // An editor forwards its own MCP servers. A desk session never connects to them, so it
884
+ // ignores the list; station work keeps the exact node assignment check.
885
+ const desk =
886
+ !bound && (object(params._meta ?? {})['salaam.dev/local'] ?? options.local) === true;
887
+ if (desk && Array.isArray(params.mcpServers)) {
888
+ if (params.mcpServers.length && !ignoredMcp) {
889
+ ignoredMcp = true;
890
+ console.error(
891
+ `Salaam does not use the editor's MCP servers; ignored ${params.mcpServers.length}.`,
892
+ );
893
+ }
894
+ } else validateManagedMcpDeclarations(params.mcpServers, bound?.managed_mcp);
604
895
  if (
605
896
  !isAbsolute(cwd) ||
606
897
  (Array.isArray(params.additionalDirectories) && params.additionalDirectories.length)
@@ -619,6 +910,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
619
910
  }
620
911
  const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
621
912
  if (typeof coding !== 'boolean') throw new Error('Coding selection must be a boolean.');
913
+ const local = object(params._meta ?? {})['salaam.dev/local'] ?? options.local ?? false;
914
+ if (typeof local !== 'boolean') throw new Error('Local selection must be a boolean.');
915
+ if (local && coding)
916
+ throw new Error('Choose a local session in the project folder or a guarded coding copy.');
917
+ if (local) {
918
+ // Your desk session in the project folder itself, the same as salaam in a terminal there.
919
+ const created = await call<{ id: string }>(socket, '/v1/sessions', {
920
+ request_id: randomUUID(),
921
+ cwd,
922
+ local_workspace: true,
923
+ local_coding: true,
924
+ });
925
+ const c = await connect(created.id, false, params);
926
+ return { sessionId: created.id, ...(await deskState(c)) };
927
+ }
622
928
  if (coding) {
623
929
  const health = await call<{ coding_root?: string | null }>(socket, '/v1/health');
624
930
  if (!health.coding_root)
@@ -634,8 +940,23 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
634
940
  await connect(created.id, false, params);
635
941
  return { sessionId: created.id };
636
942
  }
637
- await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
638
- return {};
943
+ const c = await connect(
944
+ string(params.sessionId, 'sessionId', 128),
945
+ method === 'session/load',
946
+ params,
947
+ );
948
+ return await deskState(c);
949
+ }
950
+ if (method === 'session/set_mode' || method === 'session/set_config_option') {
951
+ const c = sessions.get(string(params.sessionId, 'sessionId', 128));
952
+ if (!c) throw new Error('Load or resume this session first');
953
+ if (method === 'session/set_mode') {
954
+ await setMode(c, string(params.modeId, 'modeId', 64));
955
+ return {};
956
+ }
957
+ if (params.configId !== 'mode') throw new Error('Unknown setting');
958
+ const state = await setMode(c, string(params.value, 'value', 64));
959
+ return { configOptions: 'configOptions' in state ? state.configOptions : [] };
639
960
  }
640
961
  if (!['session/cancel', 'session/prompt'].includes(method))
641
962
  throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
@@ -667,6 +988,12 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
667
988
  throw new Error('Only text and resource links are supported');
668
989
  })
669
990
  .join('\n');
991
+ const command = c.desk ? parseDeskCommand(input) : null;
992
+ if (command) {
993
+ // Handled here, never sent to the model.
994
+ await rewind(c, command.turn);
995
+ return { stopReason: 'end_turn' };
996
+ }
670
997
  const meta = object(params._meta ?? {});
671
998
  const logical =
672
999
  meta['salaam.dev/requestId'] === undefined
@@ -791,6 +1118,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
791
1118
  id = value.id;
792
1119
  if (typeof value.method !== 'string') {
793
1120
  response = true;
1121
+ if (typeof id === 'string' && confirmations.has(id)) {
1122
+ const resolve = confirmations.get(id)!;
1123
+ confirmations.delete(id);
1124
+ const outcome = object(object(value.result ?? {}).outcome ?? {});
1125
+ resolve(
1126
+ !value.error && outcome.outcome === 'selected' ? String(outcome.optionId) : 'cancelled',
1127
+ );
1128
+ return;
1129
+ }
794
1130
  if (typeof id === 'string' && permissionReplies.has(id)) {
795
1131
  const respond = permissionReplies.get(id)!;
796
1132
  permissionReplies.delete(id);
@@ -865,6 +1201,8 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
865
1201
  clearInterval(renew);
866
1202
  clearInterval(cancellationPoll);
867
1203
  permissionReplies.clear();
1204
+ for (const resolve of confirmations.values()) resolve('cancelled');
1205
+ confirmations.clear();
868
1206
  for (const c of sessions.values()) {
869
1207
  c.feed?.close();
870
1208
  if (c.lease)
@@ -878,3 +1216,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
878
1216
  }
879
1217
  }
880
1218
  }
1219
+
1220
+ /** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
1221
+ * journal and outbox in the caller's state home, keyed by the daemon state path. */
1222
+ export function acpClientState(state: string, env: NodeJS.ProcessEnv = process.env): string {
1223
+ let own = false;
1224
+ try {
1225
+ own = statSync(state).uid === process.getuid?.();
1226
+ } catch {}
1227
+ if (own) return state;
1228
+ const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
1229
+ const dir = join(
1230
+ home,
1231
+ 'salaam/acp-clients',
1232
+ createHash('sha256').update(state).digest('hex').slice(0, 32),
1233
+ );
1234
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1235
+ return dir;
1236
+ }
@@ -208,6 +208,54 @@ export class AgentProfiles {
208
208
  if (!profile) throw new ServiceError('not_found', 'Agent profile not found', 404);
209
209
  return profile;
210
210
  }
211
+ /**
212
+ * The task critic's grant (item 8): always the built-in salaam/reviewer, never another profile,
213
+ * with no facts. It is granted under the work admission, not by an operator controller, and
214
+ * only while the caller's authorize holds (the admitted plan declares a critic). The reviewer
215
+ * reads only the immutable snapshot it is given and can only report findings.
216
+ */
217
+ async admitCritic(session: string, request: string, authorize: () => void): Promise<AgentGrant> {
218
+ const profileId = 'salaam/reviewer';
219
+ const profile = this.inspect(session, profileId);
220
+ const expectedDigest = digest(profile);
221
+ const input = { kind: 'critic-admit', session, profileId, expectedDigest };
222
+ const saved = this.store.cachedRequest<AgentGrant>('task-critic', request, input);
223
+ if (saved) return saved;
224
+ if (!BUILTIN_PROFILES.some((p) => p.id === profileId && digest(p) === expectedDigest))
225
+ throw new ServiceError('profile_changed', 'The critic must be the built-in reviewer');
226
+ const prepared = await this.prepareRoute(this.route, session);
227
+ return this.store.request('task-critic', request, input, () => {
228
+ authorize();
229
+ const grant: AgentGrant = {
230
+ schema_version: 1,
231
+ id: randomUUID(),
232
+ owner_session: session,
233
+ profile,
234
+ profile_digest: expectedDigest,
235
+ route: prepared.route,
236
+ output_cap: prepared.output_cap,
237
+ facts: [],
238
+ };
239
+ this.store.db
240
+ .prepare(
241
+ 'INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)',
242
+ )
243
+ .run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
244
+ this.store.db
245
+ .prepare(
246
+ 'INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)',
247
+ )
248
+ .run(profile.id, profile.revision, expectedDigest, canonical(profile));
249
+ this.store.event(session, 'agent.admitted', {
250
+ grant_id: grant.id,
251
+ profile_id: profileId,
252
+ profile_digest: expectedDigest,
253
+ route: prepared.route,
254
+ critic: true,
255
+ });
256
+ return grant;
257
+ });
258
+ }
211
259
  async admit(
212
260
  session: string,
213
261
  request: string,