@superjackfruitlabs/salaam 0.0.4 → 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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ are forward-only: keep a stopped-daemon backup before upgrading.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.0.5] - 2026-11-01
10
+
11
+ Salaam as a desk-side coding driver: an interactive terminal session in your own checkout, the same
12
+ session in Zed, and the approvals, sandbox and recovery to work in it daily.
13
+
14
+ ### Added
15
+
16
+ - Modes: Shift+Tab cycles review → accept edits → auto. `/full-access` runs commands without the
17
+ OS sandbox and asks only for dangerous ones; `/yolo` (typed confirmation, host setting
18
+ `allow_yolo_sessions`) asks for nothing and records everything.
19
+ - Approvals: Enter approves once, approve for the session or always in this repo, deny with a
20
+ reason that reaches the agent, and rules in `/permissions`. Each prompt says why it asks.
21
+ - Danger check: a parser-based check of commands (it unwraps `sudo`, `env`, `xargs`, `sh -c` and
22
+ more; anything unreadable is dangerous), plus an optional small-model classifier that can only
23
+ make a command ask (`/classifier`, off by default, hosts can pin or forbid it).
24
+ - Run one command outside the sandbox: a command may ask, saying why. It always asks except in
25
+ YOLO; auto mode and repo rules never approve it.
26
+ - Listening: a command you approve to listen may serve a dev server; macOS cannot limit it to
27
+ localhost, and the approval says so. Refused on Linux.
28
+ - Pushes and pull requests always ask, except in YOLO or your own rule.
29
+ - Rewind to any turn (files, conversation or both) and a per-file review of changes.
30
+ - `@` mentions attach files and images; background commands (`/jobs`) and desk notifications.
31
+ - Git helpers `/commit`, `/branch`, `/pr`, `/diff`; your own slash commands; hooks (a repo's hooks
32
+ run only after you trust them).
33
+ - Terminal: compact tool lines with Ctrl+X for the full transcript, a progress line with Esc to
34
+ stop, Ctrl+C to clear, Ctrl+R history, Ctrl+G editor, readline keys, `!command`, `salaam -c`,
35
+ a session picker, the `?` key sheet and the window title.
36
+ - Zed and other ACP editors: `salaam acp --local` opens your desk session in the project folder,
37
+ with the mode picker, approvals that show diffs and offer Allow for this session, finished edits
38
+ as diffs, and `/rewind` for files.
39
+
40
+ ### Fixed
41
+
42
+ - Salaam starts in Zed when you have MCP servers configured: the desk session ignores the editor's
43
+ own MCP servers instead of refusing them.
44
+ - Tool calls show in the turn they belong to, and the full transcript shows command output as text.
45
+ - Usage counts cached input and the whole cost.
46
+ - Choosing a model that needs a new session opens one with that model instead of the old one.
47
+ - A failed session shows its error; leftover background commands are stopped after a restart on
48
+ macOS.
49
+
9
50
  ## [0.0.4] - 2026-10-30
10
51
 
11
52
  Board work on a protected AgentPod station, made to match the Hermes workflow, plus a sandbox
@@ -0,0 +1,140 @@
1
+ import { isAbsolute, join } from 'node:path';
2
+ import { relativeTime } from './session-pick.js';
3
+ const MODES = [
4
+ { id: 'review', name: 'Review', description: 'Asks before every edit and command' },
5
+ {
6
+ id: 'accept-edits',
7
+ name: 'Accept edits',
8
+ description: 'Edits in the project apply at once; commands ask',
9
+ },
10
+ {
11
+ id: 'auto',
12
+ name: 'Auto',
13
+ description: 'Project edits and sandboxed commands run at once; the rest asks',
14
+ },
15
+ {
16
+ id: 'full-access',
17
+ name: 'Full access',
18
+ description: 'No OS sandbox; only dangerous commands ask',
19
+ },
20
+ ];
21
+ const YOLO = {
22
+ id: 'yolo',
23
+ name: 'YOLO',
24
+ description: 'No approvals at all and no sandbox. Turn it off in the terminal with /yolo',
25
+ };
26
+ export function deskMode(policy) {
27
+ if (policy.access === 'yolo')
28
+ return 'yolo';
29
+ if (policy.access === 'full')
30
+ return 'full-access';
31
+ return policy.mode;
32
+ }
33
+ /** `modes` and the matching `configOptions`, kept in step (ACP asks agents to send both). */
34
+ export function modeState(p) {
35
+ const current = deskMode(p.policy);
36
+ const list = MODES.filter((m) => m.id !== 'full-access' || p.full_access_allowed !== false);
37
+ if (current === 'yolo')
38
+ list.push(YOLO);
39
+ else if (!list.some((m) => m.id === current))
40
+ list.unshift({ id: current, name: current, description: 'Set in the terminal' });
41
+ return {
42
+ modes: { currentModeId: current, availableModes: list },
43
+ configOptions: [
44
+ {
45
+ id: 'mode',
46
+ name: 'Mode',
47
+ category: 'mode',
48
+ type: 'select',
49
+ currentValue: current,
50
+ options: list.map((m) => ({ value: m.id, name: m.name, description: m.description })),
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ /** The policy a chosen mode asks for. Picking a mode turns full access off; full access runs in
56
+ * auto, so edits and commands go ahead and only dangerous commands ask. */
57
+ export function policyFor(mode, allowed = {}) {
58
+ if (mode === 'review' || mode === 'accept-edits' || mode === 'auto')
59
+ return { mode, paths: [] };
60
+ if (mode === 'full-access') {
61
+ if (allowed.full === false)
62
+ throw new Error('The operator does not allow full access on this host. Commands stay in the OS sandbox.');
63
+ return { mode: 'auto', paths: [], access: 'full' };
64
+ }
65
+ if (mode === 'yolo')
66
+ throw new Error('YOLO is turned on only in the terminal: run /yolo there and type yolo.');
67
+ throw new Error('Unknown mode ' + JSON.stringify(mode));
68
+ }
69
+ export const FULL_ACCESS_WARNING = 'Later commands in this session run WITHOUT the OS sandbox: they can read and change any file your user can, and reach the network directly. They run without asking, except commands the danger check calls dangerous (force pushes, deletes, publishing, credentials, anything it cannot read), which still ask and say why. Pick another mode to turn it off.';
70
+ /** ACP diff content for an edit request: absolute paths, null old text for a new file. */
71
+ export function editDiffs(root, edits) {
72
+ return edits.map((e) => ({
73
+ type: 'diff',
74
+ path: isAbsolute(e.path) ? e.path : join(root, e.path),
75
+ oldText: e.before,
76
+ newText: e.after,
77
+ }));
78
+ }
79
+ /** Why an approval asks, from the reviewed text and the mode, in one line. */
80
+ export function askReason(value, mode) {
81
+ const danger = /^Dangerous \(([^)]+)\): (.*)$/m.exec(value);
82
+ if (danger)
83
+ return `Asks because this command is dangerous (${danger[1]}): ${danger[2]}`;
84
+ const outside = /^Outside the sandbox: (.*?)\. This one command/m.exec(value);
85
+ if (outside)
86
+ return `Asks because it wants to run outside the sandbox: ${outside[1]}`;
87
+ if (/^Publishes: /m.test(value))
88
+ return 'Asks because pushing and opening pull requests always ask.';
89
+ if (/^May listen: /m.test(value))
90
+ return 'Asks because this command may accept network connections; macOS cannot limit it to localhost.';
91
+ const edit = /^(---|\+\+\+) /m.test(value) && !/\nCommand:\n/.test(value);
92
+ if (mode === 'review')
93
+ return 'Asks because review mode asks before every edit and command.';
94
+ if (mode === 'accept-edits' && !edit)
95
+ return 'Asks because accept edits still asks before commands.';
96
+ if (mode === 'auto')
97
+ return edit
98
+ ? 'Asks because this edit is outside what auto mode approves on its own.'
99
+ : 'Asks because auto mode approves only sandboxed commands it can read.';
100
+ return 'Asks because nothing approves this request on its own.';
101
+ }
102
+ export const DESK_COMMANDS = [
103
+ {
104
+ name: 'rewind',
105
+ description: 'Put the files back as they were before an earlier turn',
106
+ input: { hint: 'turn number (leave empty to list turns)' },
107
+ },
108
+ ];
109
+ /** `/rewind`, `/rewind 3`. Anything else is not a desk command and goes to the model. */
110
+ export function parseDeskCommand(text) {
111
+ const match = /^\/rewind(?:\s+(\S+))?\s*$/.exec(text.trim());
112
+ if (!match)
113
+ return null;
114
+ if (match[1] === undefined)
115
+ return { command: 'rewind' };
116
+ const turn = Number(match[1]);
117
+ if (!Number.isSafeInteger(turn) || turn < 1)
118
+ throw new Error('Use /rewind with a turn number from the list, e.g. /rewind 3.');
119
+ return { command: 'rewind', turn };
120
+ }
121
+ export function checkpointList(points, now = Date.now()) {
122
+ if (!points.length)
123
+ return 'No turns to go back to yet.';
124
+ return [
125
+ 'Turns you can go back to (the files as they were before each turn ran):',
126
+ '',
127
+ ...points
128
+ .slice()
129
+ .reverse()
130
+ .map((p) => `- Turn ${p.turn} · ${relativeTime(p.at, now)} · ${p.text.split('\n')[0].slice(0, 80)}`),
131
+ '',
132
+ 'Run /rewind followed by a turn number to review and restore its files.',
133
+ ].join('\n');
134
+ }
135
+ /** Zed cannot follow a rewind into a new session, so from the editor only files are restored. */
136
+ export const REWIND_CHOICES = [
137
+ { optionId: 'files', name: 'Restore files', kind: 'allow_once' },
138
+ { optionId: 'cancel', name: 'Cancel', kind: 'reject_once' },
139
+ ];
140
+ export const CONVERSATION_REWIND_NOTE = 'To go back in the conversation too, run /rewind in the terminal (salaam -c opens this session): it continues in a new session.';
@@ -387,27 +387,31 @@ export function toolMetadata(name, input, root) {
387
387
  const action = String(args.action ?? '');
388
388
  const kind = name === 'salaam_board'
389
389
  ? 'read'
390
- : name === 'salaam_command' || (name === 'salaam_station' && action === 'check')
391
- ? ['start', 'cancel', 'check'].includes(action)
392
- ? 'execute'
393
- : ['status', 'logs'].includes(action)
394
- ? 'read'
395
- : 'other'
396
- : ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
397
- ? args.content === null
398
- ? 'delete'
399
- : 'edit'
400
- : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
401
- ['search', 'list'].includes(action)
402
- ? 'search'
403
- : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
404
- ['read', 'inspect', ...(name === 'salaam_repository' ? ['diff'] : [])].includes(action)
390
+ : name === 'salaam_local' && ['edit', 'command'].includes(action)
391
+ ? action === 'edit'
392
+ ? 'edit'
393
+ : 'execute'
394
+ : name === 'salaam_command' || (name === 'salaam_station' && action === 'check')
395
+ ? ['start', 'cancel', 'check'].includes(action)
396
+ ? 'execute'
397
+ : ['status', 'logs'].includes(action)
405
398
  ? 'read'
406
- : ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
399
+ : 'other'
400
+ : ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
401
+ ? args.content === null
402
+ ? 'delete'
403
+ : 'edit'
404
+ : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
405
+ ['search', 'list'].includes(action)
406
+ ? 'search'
407
+ : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
408
+ ['read', 'inspect', ...(name === 'salaam_repository' ? ['diff'] : [])].includes(action)
407
409
  ? 'read'
408
- : name === 'salaam_operator_mcp'
409
- ? operatorMcpKind(action, args.tool)
410
- : 'other';
410
+ : ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
411
+ ? 'read'
412
+ : name === 'salaam_operator_mcp'
413
+ ? operatorMcpKind(action, args.tool)
414
+ : 'other';
411
415
  const locations = [];
412
416
  if (['salaam_files', 'salaam_station'].includes(name) &&
413
417
  root &&
package/dist/src/acp.js CHANGED
@@ -8,6 +8,9 @@ import { AcpJournal } from './acp-journal.js';
8
8
  import { VERSION } from './config.js';
9
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 = {}) {
@@ -28,9 +31,11 @@ export async function acp(state, options = {}) {
28
31
  JSON.stringify({ version: 1, owner: 'superpipeline' }) ||
29
32
  bound?.binding.run.source !== 'superpipeline'))
30
33
  throw new Error('Unsupported authenticated work control handoff');
31
- 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;
32
35
  const sessions = new Map();
33
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();
34
39
  const activeRequests = new Set();
35
40
  let output = Promise.resolve();
36
41
  const send = (value) => {
@@ -114,6 +119,109 @@ export async function acp(state, options = {}) {
114
119
  content: { type: 'text', text: content },
115
120
  });
116
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
+ };
117
225
  const toolDetails = async (session, id, name, input) => (await call(socket, `/v1/sessions/${session}/tool-metadata?call_id=${encodeURIComponent(id)}`)) ?? toolMetadata(name, input);
118
226
  const readEntry = async (id, entry) => {
119
227
  let content = '', offset = 0;
@@ -213,11 +321,33 @@ export async function acp(state, options = {}) {
213
321
  const op = await call(socket, `/v1/operations/${approval.operation_id}`);
214
322
  c.permissions.add(approval.id);
215
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;
216
330
  const toolCall = {
217
331
  toolCallId: 'salaam-operation:' + op.id,
218
332
  title: op.kind + ': ' + op.target,
219
- kind: permissionKind(op.kind),
333
+ kind: edits ? 'edit' : permissionKind(op.kind),
220
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
+ : {}),
221
351
  rawInput: {
222
352
  target: op.target,
223
353
  value: op.value,
@@ -235,11 +365,29 @@ export async function acp(state, options = {}) {
235
365
  return;
236
366
  }
237
367
  if (outcome.outcome !== 'selected' ||
238
- !['allow-once', 'reject-once'].includes(String(outcome.optionId)))
368
+ !['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(String(outcome.optionId)))
239
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
+ }
240
388
  const decide = async () => {
241
389
  const outbox = new Outbox(client, c.id);
242
- outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'allow-once' ? 'approve' : 'deny');
390
+ outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'reject-once' ? 'deny' : 'approve');
243
391
  await outbox.flush(socket, c.id);
244
392
  };
245
393
  c.decisions = c.decisions.catch(() => { }).then(decide);
@@ -254,6 +402,15 @@ export async function acp(state, options = {}) {
254
402
  toolCall,
255
403
  options: [
256
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
+ : []),
257
414
  { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
258
415
  ],
259
416
  },
@@ -321,7 +478,15 @@ export async function acp(state, options = {}) {
321
478
  const started = event.kind === 'tool.started';
322
479
  const fresh = !c.liveTools.has(p.tool_call_id);
323
480
  c.liveTools.add(p.tool_call_id);
324
- 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, {
325
490
  sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
326
491
  toolCallId: p.tool_call_id,
327
492
  title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
@@ -333,17 +498,29 @@ export async function acp(state, options = {}) {
333
498
  : {}),
334
499
  status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
335
500
  ...(started && p.input ? { rawInput: p.input } : {}),
336
- ...(!started && p.output
337
- ? { content: [{ type: 'content', content: { type: 'text', text: p.output } }] }
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
+ }
338
510
  : {}),
339
511
  ...(p.parent_tool_call_id
340
512
  ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
341
513
  : {}),
342
- }).catch((error) => {
514
+ }))
515
+ .catch((error) => {
343
516
  c.failed = error;
344
517
  });
345
518
  }
346
519
  }
520
+ if (event.kind === 'permissions.configured' && c.desk)
521
+ void deskState(c, true).catch((error) => {
522
+ c.failed = error;
523
+ });
347
524
  if (event.kind.startsWith('work_task.'))
348
525
  void sendPlan(c).catch((error) => {
349
526
  c.failed = error;
@@ -409,6 +586,13 @@ export async function acp(state, options = {}) {
409
586
  };
410
587
  sessions.set(id, c);
411
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
+ }
412
596
  if (view.state !== 'ready') {
413
597
  const actor = await controller(c, object(params._meta ?? {})['salaam.dev/takeover'] === true, true);
414
598
  await call(socket, `/v1/sessions/${id}/resume`, actor);
@@ -448,10 +632,18 @@ export async function acp(state, options = {}) {
448
632
  await promptReceipt(id, record.request_id, record.receipt.attempt_id);
449
633
  if (wantsCancellations)
450
634
  await cancellationReceipts(c);
635
+ if (c.desk)
636
+ await deskState(c);
451
637
  await approvals(c, view);
452
638
  // The plan is visible before any step runs.
453
639
  await sendPlan(c, view.work_task ?? null);
454
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(() => { }));
455
647
  }
456
648
  catch (error) {
457
649
  sessions.delete(id);
@@ -531,7 +723,17 @@ export async function acp(state, options = {}) {
531
723
  }
532
724
  if (['session/new', 'session/load', 'session/resume'].includes(method)) {
533
725
  const cwd = string(params.cwd, 'cwd');
534
- 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);
535
737
  if (!isAbsolute(cwd) ||
536
738
  (Array.isArray(params.additionalDirectories) && params.additionalDirectories.length))
537
739
  throw new Error('Use an absolute cwd and no extra directories');
@@ -549,6 +751,22 @@ export async function acp(state, options = {}) {
549
751
  const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
550
752
  if (typeof coding !== 'boolean')
551
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
+ }
552
770
  if (coding) {
553
771
  const health = await call(socket, '/v1/health');
554
772
  if (!health.coding_root)
@@ -562,8 +780,21 @@ export async function acp(state, options = {}) {
562
780
  await connect(created.id, false, params);
563
781
  return { sessionId: created.id };
564
782
  }
565
- await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
566
- 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 : [] };
567
798
  }
568
799
  if (!['session/cancel', 'session/prompt'].includes(method))
569
800
  throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
@@ -593,6 +824,12 @@ export async function acp(state, options = {}) {
593
824
  throw new Error('Only text and resource links are supported');
594
825
  })
595
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
+ }
596
833
  const meta = object(params._meta ?? {});
597
834
  const logical = meta['salaam.dev/requestId'] === undefined
598
835
  ? randomUUID()
@@ -698,6 +935,13 @@ export async function acp(state, options = {}) {
698
935
  id = value.id;
699
936
  if (typeof value.method !== 'string') {
700
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
+ }
701
945
  if (typeof id === 'string' && permissionReplies.has(id)) {
702
946
  const respond = permissionReplies.get(id);
703
947
  permissionReplies.delete(id);
@@ -779,6 +1023,9 @@ export async function acp(state, options = {}) {
779
1023
  clearInterval(renew);
780
1024
  clearInterval(cancellationPoll);
781
1025
  permissionReplies.clear();
1026
+ for (const resolve of confirmations.values())
1027
+ resolve('cancelled');
1028
+ confirmations.clear();
782
1029
  for (const c of sessions.values()) {
783
1030
  c.feed?.close();
784
1031
  if (c.lease)