@superjackfruitlabs/salaam 0.0.3 → 0.0.4
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 +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- 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/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- 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 +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- 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.js +22 -1
- package/dist/src/protocol.js +5 -2
- 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 +141 -6
- 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 +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- 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/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- 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/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- 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 +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- 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 +222 -6
- 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 +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- 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/tool-identity.ts +110 -0
- package/src/usage.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,51 @@ are forward-only: keep a stopped-daemon backup before upgrading.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.0.4] - 2026-10-30
|
|
10
|
+
|
|
11
|
+
Board work on a protected AgentPod station, made to match the Hermes workflow, plus a sandbox
|
|
12
|
+
for local commands.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Approvals: "Approve for this run", per-agent "Always allow" (offered on workspace writes, owners
|
|
17
|
+
and admins only), plain-words approval offers, and rules added at renewal joining the run in
|
|
18
|
+
flight.
|
|
19
|
+
- Completion critic: a plan may declare a rubric; a read-only reviewer judges the verified
|
|
20
|
+
deliverables before the task completes. Completion hooks are required checks.
|
|
21
|
+
- Checkpoints: read-only checkpoints of a station run, their diff against the live workspace, and
|
|
22
|
+
restore exactly as approved.
|
|
23
|
+
- Command sandbox: bubblewrap on Linux and Seatbelt on macOS. With no usable sandbox, local
|
|
24
|
+
commands are refused unless the host config sets `unsandboxed_commands: "allow"`.
|
|
25
|
+
- Command network through the broker: with `command_network: "broker"` in the host config, local
|
|
26
|
+
commands reach only the hosts the operator lists in the stk policy (`local-commands`
|
|
27
|
+
egress `hosts`). Everything direct stays blocked, and each command's result counts the hosts it
|
|
28
|
+
reached or was refused. Needs SuperToolkit v0.0.4.
|
|
29
|
+
- Board parity: the work-status tool-step tail carries each step's output and a readable title;
|
|
30
|
+
plan updates reach the card; board reads are served by the hub.
|
|
31
|
+
- Operator persona pinned by digest in the host manifest.
|
|
32
|
+
- Budgets and retries: step request budgets, rate-limit retry then a provable stop, and
|
|
33
|
+
verification-budget stops that settle without anyone clearing them.
|
|
34
|
+
- A fetch host that keeps failing is paused briefly instead of failing the run.
|
|
35
|
+
- Add `--tools-user` (`SALAAM_TOOLS_USER`) to run local agent commands, edits and
|
|
36
|
+
undo as a separate unprivileged OS user via a runas-restricted sudo rule, so
|
|
37
|
+
tools cannot read the provider credential. Without it, startup warns.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- A verification read refused at spawn no longer leaves the daemon unable to start; a stuck
|
|
42
|
+
pid-less helper settles once `/proc` proves no helper runs (Linux only; elsewhere it stays
|
|
43
|
+
fenced).
|
|
44
|
+
- A run stopped by its own failed acceptance check reaches the board as a failed run instead of
|
|
45
|
+
parking.
|
|
46
|
+
- Readable titles for MCP steps with no input, and for finished steps.
|
|
47
|
+
|
|
48
|
+
### Upgrade notes
|
|
49
|
+
|
|
50
|
+
- Hosts without a usable bubblewrap (Linux) or Seatbelt (macOS) now refuse local commands. Install
|
|
51
|
+
or repair the sandbox, or set `unsandboxed_commands: "allow"` explicitly.
|
|
52
|
+
- Release after AgentPod v0.1.118 (hub): the step output field and board reads need that hub.
|
|
53
|
+
|
|
9
54
|
## [0.0.3] - 2026-10-22
|
|
10
55
|
|
|
11
56
|
- Update the MCP SDK and optional Hermes certificate bundle to patched releases.
|
package/README.md
CHANGED
|
@@ -99,17 +99,18 @@ by OS permissions. See [providers and credentials](docs/providers.md).
|
|
|
99
99
|
|
|
100
100
|
## What stays with the work
|
|
101
101
|
|
|
102
|
-
| Capability | What it means
|
|
103
|
-
| ------------------------------------------------ |
|
|
104
|
-
| [Sessions](docs/service-reference.md) | Durable dispatch, client outboxes, controller leases and committed event replay.
|
|
105
|
-
| [Coding](docs/coding.md) | Owned workspace copies, hash-bound patches and isolated commands through a proven Docker backend.
|
|
106
|
-
| [Tasks](docs/durable-tasks.md) | Stable steps, checked plans, budgets, checkpoints and opt-in bounded progression. Model text cannot complete a checked task.
|
|
107
|
-
| [Specialists and teams](docs/agents.md) | Admitted profiles, isolated coding proposals, explicit integration, durable task-step claims and passive messages.
|
|
108
|
-
| [External harnesses](docs/external-harnesses.md) | Pinned Gemini, Claude ACP and Codex workers with explicit invocation limits and shared approvals.
|
|
109
|
-
| [A2A peers](docs/a2a.md) | Authenticated, bounded peer analysis, explicit inbound grants and durable task ownership.
|
|
110
|
-
| [Jobs and schedules](docs/jobs.md) | Occurrence deduplication, immutable recurring templates and explicit missed-run and timezone/DST policies.
|
|
111
|
-
| [Host operation](docs/host-operation.md) | Reviewed runtime/backend manifests, bounded supervisor restart and a shared CLI/TUI/ACP inbox.
|
|
112
|
-
| [Clients](docs/clients.md) | Terminal attachment, SSH and ACP v1 against the same service.
|
|
102
|
+
| Capability | What it means |
|
|
103
|
+
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
104
|
+
| [Sessions](docs/service-reference.md) | Durable dispatch, client outboxes, controller leases and committed event replay. |
|
|
105
|
+
| [Coding](docs/coding.md) | Owned workspace copies, hash-bound patches and isolated commands through a proven Docker backend. |
|
|
106
|
+
| [Tasks](docs/durable-tasks.md) | Stable steps, checked plans, budgets, checkpoints and opt-in bounded progression. Model text cannot complete a checked task. |
|
|
107
|
+
| [Specialists and teams](docs/agents.md) | Admitted profiles, isolated coding proposals, explicit integration, durable task-step claims and passive messages. |
|
|
108
|
+
| [External harnesses](docs/external-harnesses.md) | Pinned Gemini, Claude ACP and Codex workers with explicit invocation limits and shared approvals. |
|
|
109
|
+
| [A2A peers](docs/a2a.md) | Authenticated, bounded peer analysis, explicit inbound grants and durable task ownership. |
|
|
110
|
+
| [Jobs and schedules](docs/jobs.md) | Occurrence deduplication, immutable recurring templates and explicit missed-run and timezone/DST policies. |
|
|
111
|
+
| [Host operation](docs/host-operation.md) | Reviewed runtime/backend manifests, bounded supervisor restart and a shared CLI/TUI/ACP inbox. |
|
|
112
|
+
| [Clients](docs/clients.md) | Terminal attachment, SSH and ACP v1 against the same service. |
|
|
113
|
+
| [MCP servers](docs/mcp.md) | Operator-declared stdio and HTTP servers (`sal mcp add`); workspaces never add tools; no inline secrets; per-session provenance. |
|
|
113
114
|
|
|
114
115
|
## Read this before trusting it
|
|
115
116
|
|
package/dist/src/acp-outcome.js
CHANGED
|
@@ -243,6 +243,139 @@ export function acpFailure(error, context = {}) {
|
|
|
243
243
|
...(kind === 'unknown' ? {} : { retryable: false }),
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
|
+
/** ACP tool kind for an approval, so a client's modes can tell edits apart:
|
|
247
|
+
* AgentPod's accept-edits approves only kind "edit"; full-auto approves all; ask none. */
|
|
248
|
+
export function permissionKind(operationKind) {
|
|
249
|
+
if (operationKind === 'workspace')
|
|
250
|
+
return 'edit';
|
|
251
|
+
if (operationKind === 'harness')
|
|
252
|
+
return 'execute';
|
|
253
|
+
return 'other';
|
|
254
|
+
}
|
|
255
|
+
/** An operator MCP catalog read is a read; a server's `fetch` tool is a fetch; others stay other. */
|
|
256
|
+
function operatorMcpKind(action, tool) {
|
|
257
|
+
if (action === 'catalog')
|
|
258
|
+
return 'read';
|
|
259
|
+
return action === 'call' && tool === 'fetch' ? 'fetch' : 'other';
|
|
260
|
+
}
|
|
261
|
+
function toolArgs(input) {
|
|
262
|
+
try {
|
|
263
|
+
const value = typeof input === 'string' ? JSON.parse(input) : input;
|
|
264
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
const label = (v) => (typeof v === 'string' && /^[\w.-]{1,128}$/.test(v) ? v : undefined);
|
|
271
|
+
/** One line of what a tool acted on, for a card title: no control characters, at most `max`. */
|
|
272
|
+
function shown(v, max) {
|
|
273
|
+
if (typeof v !== 'string')
|
|
274
|
+
return undefined;
|
|
275
|
+
const t = v
|
|
276
|
+
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
|
277
|
+
.replace(/\s+/g, ' ')
|
|
278
|
+
.trim();
|
|
279
|
+
if (!t)
|
|
280
|
+
return undefined;
|
|
281
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
282
|
+
}
|
|
283
|
+
/** A URL as host and path only: never its login, query or fragment (they may carry a secret). */
|
|
284
|
+
function shownUrl(v) {
|
|
285
|
+
if (typeof v !== 'string')
|
|
286
|
+
return undefined;
|
|
287
|
+
try {
|
|
288
|
+
const u = new URL(v);
|
|
289
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
290
|
+
return undefined;
|
|
291
|
+
return u.host + (u.pathname === '/' ? '' : u.pathname);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const titled = (verb, what) => {
|
|
298
|
+
if (!what)
|
|
299
|
+
return verb;
|
|
300
|
+
const t = `${verb}: ${what}`;
|
|
301
|
+
return t.length > 80 ? t.slice(0, 79) + '…' : t;
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* The ACP title for a tool call, the way a board agent's card reads: the tool and what it acted on
|
|
305
|
+
* ("fetch: host/path", "write: runs/r/answer.md"). Every operator MCP call goes through one wrapper
|
|
306
|
+
* tool, so it is titled by the MCP tool itself. Only plain identifiers, URL host and path, paths,
|
|
307
|
+
* queries and commands are echoed, clipped to one line of 80 characters.
|
|
308
|
+
*/
|
|
309
|
+
/** A clipped input no longer parses: read the leading plain string fields it still carries. */
|
|
310
|
+
function clippedArgs(input) {
|
|
311
|
+
if (typeof input !== 'string')
|
|
312
|
+
return {};
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const key of [
|
|
315
|
+
'action',
|
|
316
|
+
'path',
|
|
317
|
+
'query',
|
|
318
|
+
'pattern',
|
|
319
|
+
'command',
|
|
320
|
+
'server',
|
|
321
|
+
'tool',
|
|
322
|
+
'item_id',
|
|
323
|
+
]) {
|
|
324
|
+
const m = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.){0,512})"`).exec(input);
|
|
325
|
+
if (m) {
|
|
326
|
+
try {
|
|
327
|
+
out[key] = JSON.parse(`"${m[1]}"`);
|
|
328
|
+
}
|
|
329
|
+
catch { }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// A clipped patch is a long one, so a write: a delete (content null) is short and never clipped.
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
export function toolTitle(name, input) {
|
|
336
|
+
const parsed = toolArgs(input);
|
|
337
|
+
const args = Object.keys(parsed).length ? parsed : clippedArgs(input);
|
|
338
|
+
const action = typeof args.action === 'string' ? args.action : '';
|
|
339
|
+
if (name === 'salaam_operator_mcp') {
|
|
340
|
+
const server = label(args.server);
|
|
341
|
+
const tool = label(args.tool);
|
|
342
|
+
if (action === 'call' && server && tool) {
|
|
343
|
+
const a = toolArgs(args.arguments);
|
|
344
|
+
return titled(tool, shownUrl(a.url) ?? shown(a.query ?? a.path ?? a.pattern, 80));
|
|
345
|
+
}
|
|
346
|
+
if (action === 'catalog')
|
|
347
|
+
return titled('MCP catalog', server);
|
|
348
|
+
if (action === 'call')
|
|
349
|
+
return titled('MCP call', server ?? tool);
|
|
350
|
+
return action ? titled('MCP request', label(action)) : 'MCP request (no input)';
|
|
351
|
+
}
|
|
352
|
+
if (name === 'salaam_board') {
|
|
353
|
+
if (action === 'card')
|
|
354
|
+
return 'read this card';
|
|
355
|
+
if (action === 'attempts')
|
|
356
|
+
return 'read this card’s runs';
|
|
357
|
+
if (action === 'library_search')
|
|
358
|
+
return titled('library_search', shown(args.query, 80));
|
|
359
|
+
if (action === 'library_get')
|
|
360
|
+
return titled('library_get', shown(args.item_id, 32));
|
|
361
|
+
return name;
|
|
362
|
+
}
|
|
363
|
+
if (['salaam_station', 'salaam_files', 'salaam_repository'].includes(name)) {
|
|
364
|
+
if (action === 'patch')
|
|
365
|
+
return titled(args.content === null ? 'delete' : 'write', shown(args.path, 80));
|
|
366
|
+
if (action === 'inspect' && args.path === undefined)
|
|
367
|
+
return 'inspect workspace';
|
|
368
|
+
if (action === 'check')
|
|
369
|
+
return titled('check', shown(args.command, 80));
|
|
370
|
+
if (['read', 'list', 'inspect', 'diff'].includes(action))
|
|
371
|
+
return titled(action, shown(args.path, 80));
|
|
372
|
+
if (action === 'search')
|
|
373
|
+
return titled('search', shown(args.query ?? args.pattern, 80));
|
|
374
|
+
if (action)
|
|
375
|
+
return action;
|
|
376
|
+
}
|
|
377
|
+
return name;
|
|
378
|
+
}
|
|
246
379
|
export function toolMetadata(name, input, root) {
|
|
247
380
|
let args = {};
|
|
248
381
|
try {
|
|
@@ -252,25 +385,29 @@ export function toolMetadata(name, input, root) {
|
|
|
252
385
|
}
|
|
253
386
|
catch { }
|
|
254
387
|
const action = String(args.action ?? '');
|
|
255
|
-
const kind = name === '
|
|
256
|
-
?
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
? '
|
|
260
|
-
: '
|
|
261
|
-
: ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
|
|
262
|
-
? args.content === null
|
|
263
|
-
? 'delete'
|
|
264
|
-
: 'edit'
|
|
265
|
-
: ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
|
|
266
|
-
['search', 'list'].includes(action)
|
|
267
|
-
? 'search'
|
|
268
|
-
: ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
|
|
269
|
-
['read', 'inspect', ...(name === 'salaam_repository' ? ['diff'] : [])].includes(action)
|
|
388
|
+
const kind = name === 'salaam_board'
|
|
389
|
+
? 'read'
|
|
390
|
+
: name === 'salaam_command' || (name === 'salaam_station' && action === 'check')
|
|
391
|
+
? ['start', 'cancel', 'check'].includes(action)
|
|
392
|
+
? 'execute'
|
|
393
|
+
: ['status', 'logs'].includes(action)
|
|
270
394
|
? 'read'
|
|
271
|
-
:
|
|
395
|
+
: 'other'
|
|
396
|
+
: ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
|
|
397
|
+
? args.content === null
|
|
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)
|
|
272
405
|
? 'read'
|
|
273
|
-
: '
|
|
406
|
+
: ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
|
|
407
|
+
? 'read'
|
|
408
|
+
: name === 'salaam_operator_mcp'
|
|
409
|
+
? operatorMcpKind(action, args.tool)
|
|
410
|
+
: 'other';
|
|
274
411
|
const locations = [];
|
|
275
412
|
if (['salaam_files', 'salaam_station'].includes(name) &&
|
|
276
413
|
root &&
|
|
@@ -286,3 +423,26 @@ export function toolMetadata(name, input, root) {
|
|
|
286
423
|
}
|
|
287
424
|
return { kind, locations };
|
|
288
425
|
}
|
|
426
|
+
/** A durable task's steps as the ACP `plan` session update: the whole list, replaced on every
|
|
427
|
+
* change (https://agentclientprotocol.com/protocol/agent-plan). ACP has only pending,
|
|
428
|
+
* in_progress and completed, so a step that stopped says so in its text instead. */
|
|
429
|
+
export function planUpdate(task) {
|
|
430
|
+
if (!task?.steps.length)
|
|
431
|
+
return null;
|
|
432
|
+
return {
|
|
433
|
+
sessionUpdate: 'plan',
|
|
434
|
+
entries: task.steps.map((step) => {
|
|
435
|
+
const status = step.state === 'completed'
|
|
436
|
+
? 'completed'
|
|
437
|
+
: step.state === 'running' || step.state === 'awaiting_review'
|
|
438
|
+
? 'in_progress'
|
|
439
|
+
: 'pending';
|
|
440
|
+
const stopped = ['cancelled', 'failed', 'interrupted'].includes(step.state);
|
|
441
|
+
return {
|
|
442
|
+
content: stopped ? `${step.title} (${step.state})` : step.title,
|
|
443
|
+
priority: 'medium',
|
|
444
|
+
status,
|
|
445
|
+
};
|
|
446
|
+
}),
|
|
447
|
+
};
|
|
448
|
+
}
|
package/dist/src/acp.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
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
11
|
import { object, string, ServiceError, } from './protocol.js';
|
|
12
12
|
/** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
|
|
13
13
|
export async function acp(state, options = {}) {
|
|
14
14
|
const socket = join(state, 'operator.sock');
|
|
15
|
-
const
|
|
15
|
+
const client = acpClientState(state);
|
|
16
|
+
const journal = new AcpJournal(client);
|
|
16
17
|
const admission = process.env.SALAAM_AGENTPOD_ADMISSION, admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
|
|
17
18
|
delete process.env.SALAAM_AGENTPOD_ADMISSION;
|
|
18
19
|
delete process.env.SALAAM_AGENTPOD_TOKEN;
|
|
@@ -60,6 +61,15 @@ export async function acp(state, options = {}) {
|
|
|
60
61
|
params: { sessionId: id, update: out },
|
|
61
62
|
});
|
|
62
63
|
};
|
|
64
|
+
/** Send the session's durable task plan when it differs from the last one sent. */
|
|
65
|
+
const sendPlan = async (c, task) => {
|
|
66
|
+
const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
|
|
67
|
+
const key = plan ? JSON.stringify(plan) : undefined;
|
|
68
|
+
if (!plan || key === c.plan)
|
|
69
|
+
return;
|
|
70
|
+
c.plan = key;
|
|
71
|
+
await update(c.id, plan);
|
|
72
|
+
};
|
|
63
73
|
const promptReceipt = async (session, request, attemptId) => {
|
|
64
74
|
if (!bound || !wantsReceipts)
|
|
65
75
|
return;
|
|
@@ -144,7 +154,7 @@ export async function acp(state, options = {}) {
|
|
|
144
154
|
await update(c.id, {
|
|
145
155
|
sessionUpdate: 'tool_call',
|
|
146
156
|
toolCallId: String(part.id),
|
|
147
|
-
title: String(part.name),
|
|
157
|
+
title: toolTitle(String(part.name), part.arguments),
|
|
148
158
|
...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
|
|
149
159
|
status: 'in_progress',
|
|
150
160
|
rawInput: part.arguments,
|
|
@@ -206,7 +216,7 @@ export async function acp(state, options = {}) {
|
|
|
206
216
|
const toolCall = {
|
|
207
217
|
toolCallId: 'salaam-operation:' + op.id,
|
|
208
218
|
title: op.kind + ': ' + op.target,
|
|
209
|
-
kind:
|
|
219
|
+
kind: permissionKind(op.kind),
|
|
210
220
|
status: 'pending',
|
|
211
221
|
rawInput: {
|
|
212
222
|
target: op.target,
|
|
@@ -228,7 +238,7 @@ export async function acp(state, options = {}) {
|
|
|
228
238
|
!['allow-once', 'reject-once'].includes(String(outcome.optionId)))
|
|
229
239
|
throw new Error('Invalid permission outcome');
|
|
230
240
|
const decide = async () => {
|
|
231
|
-
const outbox = new Outbox(
|
|
241
|
+
const outbox = new Outbox(client, c.id);
|
|
232
242
|
outbox.enqueueDecision(approval.id, approval.operation_digest, approval.revision, outcome.optionId === 'allow-once' ? 'approve' : 'deny');
|
|
233
243
|
await outbox.flush(socket, c.id);
|
|
234
244
|
};
|
|
@@ -314,7 +324,7 @@ export async function acp(state, options = {}) {
|
|
|
314
324
|
void update(c.id, {
|
|
315
325
|
sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
|
|
316
326
|
toolCallId: p.tool_call_id,
|
|
317
|
-
title: p.tool
|
|
327
|
+
title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
|
|
318
328
|
...(started
|
|
319
329
|
? {
|
|
320
330
|
kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
|
|
@@ -323,6 +333,9 @@ export async function acp(state, options = {}) {
|
|
|
323
333
|
: {}),
|
|
324
334
|
status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
|
|
325
335
|
...(started && p.input ? { rawInput: p.input } : {}),
|
|
336
|
+
...(!started && p.output
|
|
337
|
+
? { content: [{ type: 'content', content: { type: 'text', text: p.output } }] }
|
|
338
|
+
: {}),
|
|
326
339
|
...(p.parent_tool_call_id
|
|
327
340
|
? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
|
|
328
341
|
: {}),
|
|
@@ -331,6 +344,10 @@ export async function acp(state, options = {}) {
|
|
|
331
344
|
});
|
|
332
345
|
}
|
|
333
346
|
}
|
|
347
|
+
if (event.kind.startsWith('work_task.'))
|
|
348
|
+
void sendPlan(c).catch((error) => {
|
|
349
|
+
c.failed = error;
|
|
350
|
+
});
|
|
334
351
|
if ([
|
|
335
352
|
'inbox.changed',
|
|
336
353
|
'inbox.resolved',
|
|
@@ -432,6 +449,8 @@ export async function acp(state, options = {}) {
|
|
|
432
449
|
if (wantsCancellations)
|
|
433
450
|
await cancellationReceipts(c);
|
|
434
451
|
await approvals(c, view);
|
|
452
|
+
// The plan is visible before any step runs.
|
|
453
|
+
await sendPlan(c, view.work_task ?? null);
|
|
435
454
|
watch(c);
|
|
436
455
|
}
|
|
437
456
|
catch (error) {
|
|
@@ -773,3 +792,18 @@ export async function acp(state, options = {}) {
|
|
|
773
792
|
}
|
|
774
793
|
}
|
|
775
794
|
}
|
|
795
|
+
/** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
|
|
796
|
+
* journal and outbox in the caller's state home, keyed by the daemon state path. */
|
|
797
|
+
export function acpClientState(state, env = process.env) {
|
|
798
|
+
let own = false;
|
|
799
|
+
try {
|
|
800
|
+
own = statSync(state).uid === process.getuid?.();
|
|
801
|
+
}
|
|
802
|
+
catch { }
|
|
803
|
+
if (own)
|
|
804
|
+
return state;
|
|
805
|
+
const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
|
|
806
|
+
const dir = join(home, 'salaam/acp-clients', createHash('sha256').update(state).digest('hex').slice(0, 32));
|
|
807
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
808
|
+
return dir;
|
|
809
|
+
}
|
|
@@ -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);
|