@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/src/acp.ts
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
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, type Feed } from './attach.js';
|
|
7
7
|
import { AcpJournal } from './acp-journal.js';
|
|
8
8
|
import { VERSION } from './config.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
acpFailure,
|
|
11
|
+
AcpFailureError,
|
|
12
|
+
permissionKind,
|
|
13
|
+
toolMetadata,
|
|
14
|
+
toolTitle,
|
|
15
|
+
planUpdate,
|
|
16
|
+
} from './acp-outcome.js';
|
|
10
17
|
import type { CancellationReceipt } from './turn-cancellations.js';
|
|
11
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';
|
|
12
37
|
import {
|
|
13
38
|
object,
|
|
14
39
|
string,
|
|
@@ -40,11 +65,19 @@ interface Connection {
|
|
|
40
65
|
dirty: boolean;
|
|
41
66
|
decisions: Promise<void>;
|
|
42
67
|
failed?: Error;
|
|
68
|
+
/** The last plan sent, so an unchanged task sends nothing. */
|
|
69
|
+
plan?: string;
|
|
70
|
+
/** Your own local coding session: modes, rules, diffs and /rewind apply. */
|
|
71
|
+
desk?: { rules: boolean; mode?: string; policyMode?: PermissionMode };
|
|
43
72
|
}
|
|
44
73
|
/** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
|
|
45
|
-
export async function acp(
|
|
74
|
+
export async function acp(
|
|
75
|
+
state: string,
|
|
76
|
+
options: { coding?: boolean; local?: boolean } = {},
|
|
77
|
+
): Promise<void> {
|
|
46
78
|
const socket = join(state, 'operator.sock');
|
|
47
|
-
const
|
|
79
|
+
const client = acpClientState(state);
|
|
80
|
+
const journal = new AcpJournal(client);
|
|
48
81
|
const admission = process.env.SALAAM_AGENTPOD_ADMISSION,
|
|
49
82
|
admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
|
|
50
83
|
delete process.env.SALAAM_AGENTPOD_ADMISSION;
|
|
@@ -68,12 +101,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
68
101
|
)
|
|
69
102
|
throw new Error('Unsupported authenticated work control handoff');
|
|
70
103
|
let closed = false,
|
|
104
|
+
ignoredMcp = false,
|
|
71
105
|
initialized = false,
|
|
72
106
|
wantsReceipts = false,
|
|
73
107
|
wantsCancellations = false,
|
|
74
108
|
wantsTypedStops = false;
|
|
75
109
|
const sessions = new Map<string, Connection>();
|
|
76
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>();
|
|
77
113
|
const activeRequests = new Set<string>();
|
|
78
114
|
let output = Promise.resolve();
|
|
79
115
|
const send = (value: unknown): Promise<void> => {
|
|
@@ -102,6 +138,14 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
102
138
|
params: { sessionId: id, update: out },
|
|
103
139
|
});
|
|
104
140
|
};
|
|
141
|
+
/** Send the session's durable task plan when it differs from the last one sent. */
|
|
142
|
+
const sendPlan = async (c: Connection, task?: SessionSnapshot['work_task']) => {
|
|
143
|
+
const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
|
|
144
|
+
const key = plan ? JSON.stringify(plan) : undefined;
|
|
145
|
+
if (!plan || key === c.plan) return;
|
|
146
|
+
c.plan = key;
|
|
147
|
+
await update(c.id, plan as unknown as Json);
|
|
148
|
+
};
|
|
105
149
|
const promptReceipt = async (session: string, request: string, attemptId: string) => {
|
|
106
150
|
if (!bound || !wantsReceipts) return;
|
|
107
151
|
const attempt = await call<Attempt>(socket, `/v1/attempts/${attemptId}`);
|
|
@@ -148,6 +192,130 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
148
192
|
content: { type: 'text', text: content },
|
|
149
193
|
});
|
|
150
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
|
+
};
|
|
151
319
|
const toolDetails = async (session: string, id: string, name: string, input: unknown) =>
|
|
152
320
|
(await call<Json | null>(
|
|
153
321
|
socket,
|
|
@@ -201,7 +369,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
201
369
|
await update(c.id, {
|
|
202
370
|
sessionUpdate: 'tool_call',
|
|
203
371
|
toolCallId: String(part.id),
|
|
204
|
-
title: String(part.name),
|
|
372
|
+
title: toolTitle(String(part.name), part.arguments),
|
|
205
373
|
...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
|
|
206
374
|
status: 'in_progress',
|
|
207
375
|
rawInput: part.arguments,
|
|
@@ -264,11 +432,36 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
264
432
|
const op = await call<Operation>(socket, `/v1/operations/${approval.operation_id}`);
|
|
265
433
|
c.permissions.add(approval.id);
|
|
266
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;
|
|
267
444
|
const toolCall = {
|
|
268
445
|
toolCallId: 'salaam-operation:' + op.id,
|
|
269
446
|
title: op.kind + ': ' + op.target,
|
|
270
|
-
kind: '
|
|
447
|
+
kind: edits ? 'edit' : permissionKind(op.kind),
|
|
271
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
|
+
: {}),
|
|
272
465
|
rawInput: {
|
|
273
466
|
target: op.target,
|
|
274
467
|
value: op.value,
|
|
@@ -286,16 +479,38 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
286
479
|
}
|
|
287
480
|
if (
|
|
288
481
|
outcome.outcome !== 'selected' ||
|
|
289
|
-
!['allow-once', 'reject-once'].includes(
|
|
482
|
+
!['allow-once', 'reject-once', ...(sessionRule ? ['allow-session'] : [])].includes(
|
|
483
|
+
String(outcome.optionId),
|
|
484
|
+
)
|
|
290
485
|
)
|
|
291
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
|
+
}
|
|
292
507
|
const decide = async () => {
|
|
293
|
-
const outbox = new Outbox(
|
|
508
|
+
const outbox = new Outbox(client, c.id);
|
|
294
509
|
outbox.enqueueDecision(
|
|
295
510
|
approval.id,
|
|
296
511
|
approval.operation_digest,
|
|
297
512
|
approval.revision,
|
|
298
|
-
outcome.optionId === '
|
|
513
|
+
outcome.optionId === 'reject-once' ? 'deny' : 'approve',
|
|
299
514
|
);
|
|
300
515
|
await outbox.flush(socket, c.id);
|
|
301
516
|
};
|
|
@@ -311,6 +526,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
311
526
|
toolCall,
|
|
312
527
|
options: [
|
|
313
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
|
+
: []),
|
|
314
538
|
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
|
315
539
|
],
|
|
316
540
|
},
|
|
@@ -381,6 +605,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
381
605
|
tool?: string;
|
|
382
606
|
input?: string;
|
|
383
607
|
failed?: boolean;
|
|
608
|
+
output?: string;
|
|
384
609
|
kind?: string;
|
|
385
610
|
locations?: unknown[];
|
|
386
611
|
};
|
|
@@ -388,26 +613,59 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
388
613
|
const started = event.kind === 'tool.started';
|
|
389
614
|
const fresh = !c.liveTools.has(p.tool_call_id);
|
|
390
615
|
c.liveTools.add(p.tool_call_id);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
});
|
|
409
659
|
}
|
|
410
660
|
}
|
|
661
|
+
if (event.kind === 'permissions.configured' && c.desk)
|
|
662
|
+
void deskState(c, true).catch((error) => {
|
|
663
|
+
c.failed = error;
|
|
664
|
+
});
|
|
665
|
+
if (event.kind.startsWith('work_task.'))
|
|
666
|
+
void sendPlan(c).catch((error) => {
|
|
667
|
+
c.failed = error;
|
|
668
|
+
});
|
|
411
669
|
if (
|
|
412
670
|
[
|
|
413
671
|
'inbox.changed',
|
|
@@ -472,6 +730,16 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
472
730
|
};
|
|
473
731
|
sessions.set(id, c);
|
|
474
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
|
+
}
|
|
475
743
|
if (view.state !== 'ready') {
|
|
476
744
|
const actor = await controller(
|
|
477
745
|
c,
|
|
@@ -517,8 +785,20 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
517
785
|
for (const record of journal.records(id))
|
|
518
786
|
if (record.receipt) await promptReceipt(id, record.request_id, record.receipt.attempt_id);
|
|
519
787
|
if (wantsCancellations) await cancellationReceipts(c);
|
|
788
|
+
if (c.desk) await deskState(c);
|
|
520
789
|
await approvals(c, view);
|
|
790
|
+
// The plan is visible before any step runs.
|
|
791
|
+
await sendPlan(c, view.work_task ?? null);
|
|
521
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
|
+
);
|
|
522
802
|
} catch (error) {
|
|
523
803
|
sessions.delete(id);
|
|
524
804
|
c.feed?.close();
|
|
@@ -600,7 +880,18 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
600
880
|
}
|
|
601
881
|
if (['session/new', 'session/load', 'session/resume'].includes(method)) {
|
|
602
882
|
const cwd = string(params.cwd, 'cwd');
|
|
603
|
-
|
|
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);
|
|
604
895
|
if (
|
|
605
896
|
!isAbsolute(cwd) ||
|
|
606
897
|
(Array.isArray(params.additionalDirectories) && params.additionalDirectories.length)
|
|
@@ -619,6 +910,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
619
910
|
}
|
|
620
911
|
const coding = object(params._meta ?? {})['salaam.dev/coding'] ?? options.coding ?? false;
|
|
621
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
|
+
}
|
|
622
928
|
if (coding) {
|
|
623
929
|
const health = await call<{ coding_root?: string | null }>(socket, '/v1/health');
|
|
624
930
|
if (!health.coding_root)
|
|
@@ -634,8 +940,23 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
634
940
|
await connect(created.id, false, params);
|
|
635
941
|
return { sessionId: created.id };
|
|
636
942
|
}
|
|
637
|
-
await connect(
|
|
638
|
-
|
|
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 : [] };
|
|
639
960
|
}
|
|
640
961
|
if (!['session/cancel', 'session/prompt'].includes(method))
|
|
641
962
|
throw new ServiceError('method_not_found', 'Unsupported ACP method', 404);
|
|
@@ -667,6 +988,12 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
667
988
|
throw new Error('Only text and resource links are supported');
|
|
668
989
|
})
|
|
669
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
|
+
}
|
|
670
997
|
const meta = object(params._meta ?? {});
|
|
671
998
|
const logical =
|
|
672
999
|
meta['salaam.dev/requestId'] === undefined
|
|
@@ -791,6 +1118,15 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
791
1118
|
id = value.id;
|
|
792
1119
|
if (typeof value.method !== 'string') {
|
|
793
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
|
+
}
|
|
794
1130
|
if (typeof id === 'string' && permissionReplies.has(id)) {
|
|
795
1131
|
const respond = permissionReplies.get(id)!;
|
|
796
1132
|
permissionReplies.delete(id);
|
|
@@ -865,6 +1201,8 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
865
1201
|
clearInterval(renew);
|
|
866
1202
|
clearInterval(cancellationPoll);
|
|
867
1203
|
permissionReplies.clear();
|
|
1204
|
+
for (const resolve of confirmations.values()) resolve('cancelled');
|
|
1205
|
+
confirmations.clear();
|
|
868
1206
|
for (const c of sessions.values()) {
|
|
869
1207
|
c.feed?.close();
|
|
870
1208
|
if (c.lease)
|
|
@@ -878,3 +1216,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
|
|
|
878
1216
|
}
|
|
879
1217
|
}
|
|
880
1218
|
}
|
|
1219
|
+
|
|
1220
|
+
/** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
|
|
1221
|
+
* journal and outbox in the caller's state home, keyed by the daemon state path. */
|
|
1222
|
+
export function acpClientState(state: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
1223
|
+
let own = false;
|
|
1224
|
+
try {
|
|
1225
|
+
own = statSync(state).uid === process.getuid?.();
|
|
1226
|
+
} catch {}
|
|
1227
|
+
if (own) return state;
|
|
1228
|
+
const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
|
|
1229
|
+
const dir = join(
|
|
1230
|
+
home,
|
|
1231
|
+
'salaam/acp-clients',
|
|
1232
|
+
createHash('sha256').update(state).digest('hex').slice(0, 32),
|
|
1233
|
+
);
|
|
1234
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1235
|
+
return dir;
|
|
1236
|
+
}
|
package/src/agent-profiles.ts
CHANGED
|
@@ -208,6 +208,54 @@ export class AgentProfiles {
|
|
|
208
208
|
if (!profile) throw new ServiceError('not_found', 'Agent profile not found', 404);
|
|
209
209
|
return profile;
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The task critic's grant (item 8): always the built-in salaam/reviewer, never another profile,
|
|
213
|
+
* with no facts. It is granted under the work admission, not by an operator controller, and
|
|
214
|
+
* only while the caller's authorize holds (the admitted plan declares a critic). The reviewer
|
|
215
|
+
* reads only the immutable snapshot it is given and can only report findings.
|
|
216
|
+
*/
|
|
217
|
+
async admitCritic(session: string, request: string, authorize: () => void): Promise<AgentGrant> {
|
|
218
|
+
const profileId = 'salaam/reviewer';
|
|
219
|
+
const profile = this.inspect(session, profileId);
|
|
220
|
+
const expectedDigest = digest(profile);
|
|
221
|
+
const input = { kind: 'critic-admit', session, profileId, expectedDigest };
|
|
222
|
+
const saved = this.store.cachedRequest<AgentGrant>('task-critic', request, input);
|
|
223
|
+
if (saved) return saved;
|
|
224
|
+
if (!BUILTIN_PROFILES.some((p) => p.id === profileId && digest(p) === expectedDigest))
|
|
225
|
+
throw new ServiceError('profile_changed', 'The critic must be the built-in reviewer');
|
|
226
|
+
const prepared = await this.prepareRoute(this.route, session);
|
|
227
|
+
return this.store.request('task-critic', request, input, () => {
|
|
228
|
+
authorize();
|
|
229
|
+
const grant: AgentGrant = {
|
|
230
|
+
schema_version: 1,
|
|
231
|
+
id: randomUUID(),
|
|
232
|
+
owner_session: session,
|
|
233
|
+
profile,
|
|
234
|
+
profile_digest: expectedDigest,
|
|
235
|
+
route: prepared.route,
|
|
236
|
+
output_cap: prepared.output_cap,
|
|
237
|
+
facts: [],
|
|
238
|
+
};
|
|
239
|
+
this.store.db
|
|
240
|
+
.prepare(
|
|
241
|
+
'INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)',
|
|
242
|
+
)
|
|
243
|
+
.run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
|
|
244
|
+
this.store.db
|
|
245
|
+
.prepare(
|
|
246
|
+
'INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)',
|
|
247
|
+
)
|
|
248
|
+
.run(profile.id, profile.revision, expectedDigest, canonical(profile));
|
|
249
|
+
this.store.event(session, 'agent.admitted', {
|
|
250
|
+
grant_id: grant.id,
|
|
251
|
+
profile_id: profileId,
|
|
252
|
+
profile_digest: expectedDigest,
|
|
253
|
+
route: prepared.route,
|
|
254
|
+
critic: true,
|
|
255
|
+
});
|
|
256
|
+
return grant;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
211
259
|
async admit(
|
|
212
260
|
session: string,
|
|
213
261
|
request: string,
|