@superjackfruitlabs/salaam 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- 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/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -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/usage.js +1 -1
- 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 +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -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/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/dist/src/acp.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import { once } from 'node:events';
|
|
5
5
|
import { call } from './transport.js';
|
|
6
6
|
import { acquireControl, Outbox, subscribe } from './attach.js';
|
|
7
7
|
import { AcpJournal } from './acp-journal.js';
|
|
8
8
|
import { VERSION } from './config.js';
|
|
9
|
-
import { acpFailure, AcpFailureError, toolMetadata } from './acp-outcome.js';
|
|
9
|
+
import { 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 = {}) {
|
|
14
17
|
const socket = join(state, 'operator.sock');
|
|
15
|
-
const
|
|
18
|
+
const client = acpClientState(state);
|
|
19
|
+
const journal = new AcpJournal(client);
|
|
16
20
|
const admission = process.env.SALAAM_AGENTPOD_ADMISSION, admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
|
|
17
21
|
delete process.env.SALAAM_AGENTPOD_ADMISSION;
|
|
18
22
|
delete process.env.SALAAM_AGENTPOD_TOKEN;
|
|
@@ -27,9 +31,11 @@ export async function acp(state, options = {}) {
|
|
|
27
31
|
JSON.stringify({ version: 1, owner: 'superpipeline' }) ||
|
|
28
32
|
bound?.binding.run.source !== 'superpipeline'))
|
|
29
33
|
throw new Error('Unsupported authenticated work control handoff');
|
|
30
|
-
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;
|
|
31
35
|
const sessions = new Map();
|
|
32
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();
|
|
33
39
|
const activeRequests = new Set();
|
|
34
40
|
let output = Promise.resolve();
|
|
35
41
|
const send = (value) => {
|
|
@@ -60,6 +66,15 @@ export async function acp(state, options = {}) {
|
|
|
60
66
|
params: { sessionId: id, update: out },
|
|
61
67
|
});
|
|
62
68
|
};
|
|
69
|
+
/** Send the session's durable task plan when it differs from the last one sent. */
|
|
70
|
+
const sendPlan = async (c, task) => {
|
|
71
|
+
const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
|
|
72
|
+
const key = plan ? JSON.stringify(plan) : undefined;
|
|
73
|
+
if (!plan || key === c.plan)
|
|
74
|
+
return;
|
|
75
|
+
c.plan = key;
|
|
76
|
+
await update(c.id, plan);
|
|
77
|
+
};
|
|
63
78
|
const promptReceipt = async (session, request, attemptId) => {
|
|
64
79
|
if (!bound || !wantsReceipts)
|
|
65
80
|
return;
|
|
@@ -104,6 +119,109 @@ export async function acp(state, options = {}) {
|
|
|
104
119
|
content: { type: 'text', text: content },
|
|
105
120
|
});
|
|
106
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
|
+
};
|
|
107
225
|
const toolDetails = async (session, id, name, input) => (await call(socket, `/v1/sessions/${session}/tool-metadata?call_id=${encodeURIComponent(id)}`)) ?? toolMetadata(name, input);
|
|
108
226
|
const readEntry = async (id, entry) => {
|
|
109
227
|
let content = '', offset = 0;
|
|
@@ -144,7 +262,7 @@ export async function acp(state, options = {}) {
|
|
|
144
262
|
await update(c.id, {
|
|
145
263
|
sessionUpdate: 'tool_call',
|
|
146
264
|
toolCallId: String(part.id),
|
|
147
|
-
title: String(part.name),
|
|
265
|
+
title: toolTitle(String(part.name), part.arguments),
|
|
148
266
|
...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
|
|
149
267
|
status: 'in_progress',
|
|
150
268
|
rawInput: part.arguments,
|
|
@@ -203,11 +321,33 @@ export async function acp(state, options = {}) {
|
|
|
203
321
|
const op = await call(socket, `/v1/operations/${approval.operation_id}`);
|
|
204
322
|
c.permissions.add(approval.id);
|
|
205
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;
|
|
206
330
|
const toolCall = {
|
|
207
331
|
toolCallId: 'salaam-operation:' + op.id,
|
|
208
332
|
title: op.kind + ': ' + op.target,
|
|
209
|
-
kind: '
|
|
333
|
+
kind: edits ? 'edit' : permissionKind(op.kind),
|
|
210
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
|
+
: {}),
|
|
211
351
|
rawInput: {
|
|
212
352
|
target: op.target,
|
|
213
353
|
value: op.value,
|
|
@@ -225,11 +365,29 @@ export async function acp(state, options = {}) {
|
|
|
225
365
|
return;
|
|
226
366
|
}
|
|
227
367
|
if (outcome.outcome !== 'selected' ||
|
|
228
|
-
!['allow-once', 'reject-once'].includes(String(outcome.optionId)))
|
|
368
|
+
!['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(String(outcome.optionId)))
|
|
229
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
|
+
}
|
|
230
388
|
const decide = async () => {
|
|
231
|
-
const outbox = new Outbox(
|
|
232
|
-
outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === '
|
|
389
|
+
const outbox = new Outbox(client, c.id);
|
|
390
|
+
outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'reject-once' ? 'deny' : 'approve');
|
|
233
391
|
await outbox.flush(socket, c.id);
|
|
234
392
|
};
|
|
235
393
|
c.decisions = c.decisions.catch(() => { }).then(decide);
|
|
@@ -244,6 +402,15 @@ export async function acp(state, options = {}) {
|
|
|
244
402
|
toolCall,
|
|
245
403
|
options: [
|
|
246
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
|
+
: []),
|
|
247
414
|
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
|
248
415
|
],
|
|
249
416
|
},
|
|
@@ -311,10 +478,18 @@ export async function acp(state, options = {}) {
|
|
|
311
478
|
const started = event.kind === 'tool.started';
|
|
312
479
|
const fresh = !c.liveTools.has(p.tool_call_id);
|
|
313
480
|
c.liveTools.add(p.tool_call_id);
|
|
314
|
-
|
|
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, {
|
|
315
490
|
sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
|
|
316
491
|
toolCallId: p.tool_call_id,
|
|
317
|
-
title: p.tool
|
|
492
|
+
title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
|
|
318
493
|
...(started
|
|
319
494
|
? {
|
|
320
495
|
kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
|
|
@@ -323,14 +498,33 @@ export async function acp(state, options = {}) {
|
|
|
323
498
|
: {}),
|
|
324
499
|
status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
|
|
325
500
|
...(started && p.input ? { rawInput: p.input } : {}),
|
|
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
|
+
}
|
|
510
|
+
: {}),
|
|
326
511
|
...(p.parent_tool_call_id
|
|
327
512
|
? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
|
|
328
513
|
: {}),
|
|
329
|
-
})
|
|
514
|
+
}))
|
|
515
|
+
.catch((error) => {
|
|
330
516
|
c.failed = error;
|
|
331
517
|
});
|
|
332
518
|
}
|
|
333
519
|
}
|
|
520
|
+
if (event.kind === 'permissions.configured' && c.desk)
|
|
521
|
+
void deskState(c, true).catch((error) => {
|
|
522
|
+
c.failed = error;
|
|
523
|
+
});
|
|
524
|
+
if (event.kind.startsWith('work_task.'))
|
|
525
|
+
void sendPlan(c).catch((error) => {
|
|
526
|
+
c.failed = error;
|
|
527
|
+
});
|
|
334
528
|
if ([
|
|
335
529
|
'inbox.changed',
|
|
336
530
|
'inbox.resolved',
|
|
@@ -392,6 +586,13 @@ export async function acp(state, options = {}) {
|
|
|
392
586
|
};
|
|
393
587
|
sessions.set(id, c);
|
|
394
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
|
+
}
|
|
395
596
|
if (view.state !== 'ready') {
|
|
396
597
|
const actor = await controller(c, object(params._meta ?? {})['salaam.dev/takeover'] === true, true);
|
|
397
598
|
await call(socket, `/v1/sessions/${id}/resume`, actor);
|
|
@@ -431,8 +632,18 @@ export async function acp(state, options = {}) {
|
|
|
431
632
|
await promptReceipt(id, record.request_id, record.receipt.attempt_id);
|
|
432
633
|
if (wantsCancellations)
|
|
433
634
|
await cancellationReceipts(c);
|
|
635
|
+
if (c.desk)
|
|
636
|
+
await deskState(c);
|
|
434
637
|
await approvals(c, view);
|
|
638
|
+
// The plan is visible before any step runs.
|
|
639
|
+
await sendPlan(c, view.work_task ?? null);
|
|
435
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(() => { }));
|
|
436
647
|
}
|
|
437
648
|
catch (error) {
|
|
438
649
|
sessions.delete(id);
|
|
@@ -512,7 +723,17 @@ export async function acp(state, options = {}) {
|
|
|
512
723
|
}
|
|
513
724
|
if (['session/new', 'session/load', 'session/resume'].includes(method)) {
|
|
514
725
|
const cwd = string(params.cwd, 'cwd');
|
|
515
|
-
|
|
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);
|
|
516
737
|
if (!isAbsolute(cwd) ||
|
|
517
738
|
(Array.isArray(params.additionalDirectories) && params.additionalDirectories.length))
|
|
518
739
|
throw new Error('Use an absolute cwd and no extra directories');
|
|
@@ -530,6 +751,22 @@ export async function acp(state, options = {}) {
|
|
|
530
751
|
const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
|
|
531
752
|
if (typeof coding !== 'boolean')
|
|
532
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
|
+
}
|
|
533
770
|
if (coding) {
|
|
534
771
|
const health = await call(socket, '/v1/health');
|
|
535
772
|
if (!health.coding_root)
|
|
@@ -543,8 +780,21 @@ export async function acp(state, options = {}) {
|
|
|
543
780
|
await connect(created.id, false, params);
|
|
544
781
|
return { sessionId: created.id };
|
|
545
782
|
}
|
|
546
|
-
await connect(string(params.sessionId, 'sessionId', 128), method === 'session/load', params);
|
|
547
|
-
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 : [] };
|
|
548
798
|
}
|
|
549
799
|
if (!['session/cancel', 'session/prompt'].includes(method))
|
|
550
800
|
throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
|
|
@@ -574,6 +824,12 @@ export async function acp(state, options = {}) {
|
|
|
574
824
|
throw new Error('Only text and resource links are supported');
|
|
575
825
|
})
|
|
576
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
|
+
}
|
|
577
833
|
const meta = object(params._meta ?? {});
|
|
578
834
|
const logical = meta['salaam.dev/requestId'] === undefined
|
|
579
835
|
? randomUUID()
|
|
@@ -679,6 +935,13 @@ export async function acp(state, options = {}) {
|
|
|
679
935
|
id = value.id;
|
|
680
936
|
if (typeof value.method !== 'string') {
|
|
681
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
|
+
}
|
|
682
945
|
if (typeof id === 'string' && permissionReplies.has(id)) {
|
|
683
946
|
const respond = permissionReplies.get(id);
|
|
684
947
|
permissionReplies.delete(id);
|
|
@@ -760,6 +1023,9 @@ export async function acp(state, options = {}) {
|
|
|
760
1023
|
clearInterval(renew);
|
|
761
1024
|
clearInterval(cancellationPoll);
|
|
762
1025
|
permissionReplies.clear();
|
|
1026
|
+
for (const resolve of confirmations.values())
|
|
1027
|
+
resolve('cancelled');
|
|
1028
|
+
confirmations.clear();
|
|
763
1029
|
for (const c of sessions.values()) {
|
|
764
1030
|
c.feed?.close();
|
|
765
1031
|
if (c.lease)
|
|
@@ -773,3 +1039,18 @@ export async function acp(state, options = {}) {
|
|
|
773
1039
|
}
|
|
774
1040
|
}
|
|
775
1041
|
}
|
|
1042
|
+
/** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
|
|
1043
|
+
* journal and outbox in the caller's state home, keyed by the daemon state path. */
|
|
1044
|
+
export function acpClientState(state, env = process.env) {
|
|
1045
|
+
let own = false;
|
|
1046
|
+
try {
|
|
1047
|
+
own = statSync(state).uid === process.getuid?.();
|
|
1048
|
+
}
|
|
1049
|
+
catch { }
|
|
1050
|
+
if (own)
|
|
1051
|
+
return state;
|
|
1052
|
+
const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
|
|
1053
|
+
const dir = join(home, 'salaam/acp-clients', createHash('sha256').update(state).digest('hex').slice(0, 32));
|
|
1054
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1055
|
+
return dir;
|
|
1056
|
+
}
|
|
@@ -154,6 +154,51 @@ export class AgentProfiles {
|
|
|
154
154
|
throw new ServiceError('not_found', 'Agent profile not found', 404);
|
|
155
155
|
return profile;
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* The task critic's grant (item 8): always the built-in salaam/reviewer, never another profile,
|
|
159
|
+
* with no facts. It is granted under the work admission, not by an operator controller, and
|
|
160
|
+
* only while the caller's authorize holds (the admitted plan declares a critic). The reviewer
|
|
161
|
+
* reads only the immutable snapshot it is given and can only report findings.
|
|
162
|
+
*/
|
|
163
|
+
async admitCritic(session, request, authorize) {
|
|
164
|
+
const profileId = 'salaam/reviewer';
|
|
165
|
+
const profile = this.inspect(session, profileId);
|
|
166
|
+
const expectedDigest = digest(profile);
|
|
167
|
+
const input = { kind: 'critic-admit', session, profileId, expectedDigest };
|
|
168
|
+
const saved = this.store.cachedRequest('task-critic', request, input);
|
|
169
|
+
if (saved)
|
|
170
|
+
return saved;
|
|
171
|
+
if (!BUILTIN_PROFILES.some((p) => p.id === profileId && digest(p) === expectedDigest))
|
|
172
|
+
throw new ServiceError('profile_changed', 'The critic must be the built-in reviewer');
|
|
173
|
+
const prepared = await this.prepareRoute(this.route, session);
|
|
174
|
+
return this.store.request('task-critic', request, input, () => {
|
|
175
|
+
authorize();
|
|
176
|
+
const grant = {
|
|
177
|
+
schema_version: 1,
|
|
178
|
+
id: randomUUID(),
|
|
179
|
+
owner_session: session,
|
|
180
|
+
profile,
|
|
181
|
+
profile_digest: expectedDigest,
|
|
182
|
+
route: prepared.route,
|
|
183
|
+
output_cap: prepared.output_cap,
|
|
184
|
+
facts: [],
|
|
185
|
+
};
|
|
186
|
+
this.store.db
|
|
187
|
+
.prepare('INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)')
|
|
188
|
+
.run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
|
|
189
|
+
this.store.db
|
|
190
|
+
.prepare('INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)')
|
|
191
|
+
.run(profile.id, profile.revision, expectedDigest, canonical(profile));
|
|
192
|
+
this.store.event(session, 'agent.admitted', {
|
|
193
|
+
grant_id: grant.id,
|
|
194
|
+
profile_id: profileId,
|
|
195
|
+
profile_digest: expectedDigest,
|
|
196
|
+
route: prepared.route,
|
|
197
|
+
critic: true,
|
|
198
|
+
});
|
|
199
|
+
return grant;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
157
202
|
async admit(session, request, profileId, expectedDigest, factIds, controller) {
|
|
158
203
|
const input = { kind: 'agent-admit', session, profileId, expectedDigest, factIds };
|
|
159
204
|
const saved = this.store.cachedRequest('local-operator', request, input);
|