@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. 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
 
@@ -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 === 'salaam_command' || (name === 'salaam_station' && action === 'check')
256
- ? ['start', 'cancel', 'check'].includes(action)
257
- ? 'execute'
258
- : ['status', 'logs'].includes(action)
259
- ? 'read'
260
- : 'other'
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
- : ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
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
- : 'other';
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 journal = new AcpJournal(state);
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: 'other',
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(state, c.id);
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 ?? '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);