@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/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.';
|
package/dist/src/acp-outcome.js
CHANGED
|
@@ -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 === '
|
|
391
|
-
?
|
|
392
|
-
? '
|
|
393
|
-
:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
409
|
-
?
|
|
410
|
-
: '
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
})
|
|
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
|
-
|
|
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)
|