@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/src/acp.ts CHANGED
@@ -16,6 +16,24 @@ import {
16
16
  } from './acp-outcome.js';
17
17
  import type { CancellationReceipt } from './turn-cancellations.js';
18
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';
19
37
  import {
20
38
  object,
21
39
  string,
@@ -49,9 +67,14 @@ interface Connection {
49
67
  failed?: Error;
50
68
  /** The last plan sent, so an unchanged task sends nothing. */
51
69
  plan?: string;
70
+ /** Your own local coding session: modes, rules, diffs and /rewind apply. */
71
+ desk?: { rules: boolean; mode?: string; policyMode?: PermissionMode };
52
72
  }
53
73
  /** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
54
- 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> {
55
78
  const socket = join(state, 'operator.sock');
56
79
  const client = acpClientState(state);
57
80
  const journal = new AcpJournal(client);
@@ -78,12 +101,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
78
101
  )
79
102
  throw new Error('Unsupported authenticated work control handoff');
80
103
  let closed = false,
104
+ ignoredMcp = false,
81
105
  initialized = false,
82
106
  wantsReceipts = false,
83
107
  wantsCancellations = false,
84
108
  wantsTypedStops = false;
85
109
  const sessions = new Map<string, Connection>();
86
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>();
87
113
  const activeRequests = new Set<string>();
88
114
  let output = Promise.resolve();
89
115
  const send = (value: unknown): Promise<void> => {
@@ -166,6 +192,130 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
166
192
  content: { type: 'text', text: content },
167
193
  });
168
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
+ };
169
319
  const toolDetails = async (session: string, id: string, name: string, input: unknown) =>
170
320
  (await call<Json | null>(
171
321
  socket,
@@ -282,11 +432,36 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
282
432
  const op = await call<Operation>(socket, `/v1/operations/${approval.operation_id}`);
283
433
  c.permissions.add(approval.id);
284
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;
285
444
  const toolCall = {
286
445
  toolCallId: 'salaam-operation:' + op.id,
287
446
  title: op.kind + ': ' + op.target,
288
- kind: permissionKind(op.kind),
447
+ kind: edits ? 'edit' : permissionKind(op.kind),
289
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
+ : {}),
290
465
  rawInput: {
291
466
  target: op.target,
292
467
  value: op.value,
@@ -304,16 +479,38 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
304
479
  }
305
480
  if (
306
481
  outcome.outcome !== 'selected' ||
307
- !['allow-once', 'reject-once'].includes(String(outcome.optionId))
482
+ !['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(
483
+ String(outcome.optionId),
484
+ )
308
485
  )
309
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
+ }
310
507
  const decide = async () => {
311
508
  const outbox = new Outbox(client, c.id);
312
509
  outbox.enqueueDecision(
313
510
  approval.id,
314
511
  approval.operation_digest,
315
512
  approval.revision,
316
- outcome.optionId === 'allow-once' ? 'approve' : 'deny',
513
+ outcome.optionId === 'reject-once' ? 'deny' : 'approve',
317
514
  );
318
515
  await outbox.flush(socket, c.id);
319
516
  };
@@ -329,6 +526,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
329
526
  toolCall,
330
527
  options: [
331
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
+ : []),
332
538
  { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
333
539
  ],
334
540
  },
@@ -407,29 +613,55 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
407
613
  const started = event.kind === 'tool.started';
408
614
  const fresh = !c.liveTools.has(p.tool_call_id);
409
615
  c.liveTools.add(p.tool_call_id);
410
- void update(c.id, {
411
- sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
412
- toolCallId: p.tool_call_id,
413
- title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
414
- ...(started
415
- ? {
416
- kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
417
- locations: p.locations ?? [],
418
- }
419
- : {}),
420
- status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
421
- ...(started && p.input ? { rawInput: p.input } : {}),
422
- ...(!started && p.output
423
- ? { content: [{ type: 'content', content: { type: 'text', text: p.output } }] }
424
- : {}),
425
- ...(p.parent_tool_call_id
426
- ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
427
- : {}),
428
- }).catch((error) => {
429
- c.failed = error;
430
- });
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
+ });
431
659
  }
432
660
  }
661
+ if (event.kind === 'permissions.configured' && c.desk)
662
+ void deskState(c, true).catch((error) => {
663
+ c.failed = error;
664
+ });
433
665
  if (event.kind.startsWith('work_task.'))
434
666
  void sendPlan(c).catch((error) => {
435
667
  c.failed = error;
@@ -498,6 +730,16 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
498
730
  };
499
731
  sessions.set(id, c);
500
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
+ }
501
743
  if (view.state !== 'ready') {
502
744
  const actor = await controller(
503
745
  c,
@@ -543,10 +785,20 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
543
785
  for (const record of journal.records(id))
544
786
  if (record.receipt) await promptReceipt(id, record.request_id, record.receipt.attempt_id);
545
787
  if (wantsCancellations) await cancellationReceipts(c);
788
+ if (c.desk) await deskState(c);
546
789
  await approvals(c, view);
547
790
  // The plan is visible before any step runs.
548
791
  await sendPlan(c, view.work_task ?? null);
549
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
+ );
550
802
  } catch (error) {
551
803
  sessions.delete(id);
552
804
  c.feed?.close();
@@ -628,7 +880,18 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
628
880
  }
629
881
  if (['session/new', 'session/load', 'session/resume'].includes(method)) {
630
882
  const cwd = string(params.cwd, 'cwd');
631
- 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);
632
895
  if (
633
896
  !isAbsolute(cwd) ||
634
897
  (Array.isArray(params.additionalDirectories) && params.additionalDirectories.length)
@@ -647,6 +910,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
647
910
  }
648
911
  const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
649
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
+ }
650
928
  if (coding) {
651
929
  const health = await call<{ coding_root?: string | null }>(socket, '/v1/health');
652
930
  if (!health.coding_root)
@@ -662,8 +940,23 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
662
940
  await connect(created.id, false, params);
663
941
  return { sessionId: created.id };
664
942
  }
665
- await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
666
- 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 : [] };
667
960
  }
668
961
  if (!['session/cancel', 'session/prompt'].includes(method))
669
962
  throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
@@ -695,6 +988,12 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
695
988
  throw new Error('Only text and resource links are supported');
696
989
  })
697
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
+ }
698
997
  const meta = object(params._meta ?? {});
699
998
  const logical =
700
999
  meta['salaam.dev/requestId'] === undefined
@@ -819,6 +1118,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
819
1118
  id = value.id;
820
1119
  if (typeof value.method !== 'string') {
821
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
+ }
822
1130
  if (typeof id === 'string' && permissionReplies.has(id)) {
823
1131
  const respond = permissionReplies.get(id)!;
824
1132
  permissionReplies.delete(id);
@@ -893,6 +1201,8 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
893
1201
  clearInterval(renew);
894
1202
  clearInterval(cancellationPoll);
895
1203
  permissionReplies.clear();
1204
+ for (const resolve of confirmations.values()) resolve('cancelled');
1205
+ confirmations.clear();
896
1206
  for (const c of sessions.values()) {
897
1207
  c.feed?.close();
898
1208
  if (c.lease)