@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.
- package/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- 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(
|
|
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(
|
|
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 === '
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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(
|
|
666
|
-
|
|
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)
|