@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
@@ -1,3 +1,4 @@
1
+ import { requiredChecks } from './task-policy.js';
1
2
  import { StationTaskVerification } from './station-task-verification.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { commandTerminal } from './coding.js';
@@ -39,7 +40,7 @@ export class TaskVerification {
39
40
  };
40
41
  }
41
42
  assertIdle(task, stationInspection) {
42
- if (this.budget.active(task) > 0 ||
43
+ if (this.budget.active(task, { verification: true }) > 0 ||
43
44
  this.store.db
44
45
  .prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
45
46
  .get(task.session_id))
@@ -105,7 +106,7 @@ export class TaskVerification {
105
106
  this.assertIdle(task);
106
107
  if (!this.station)
107
108
  this.coding.assertSafe(task.session_id);
108
- if (!task.acceptance.some((c) => c.key === key))
109
+ if (!requiredChecks(task).some((c) => c.key === key))
109
110
  throw new ServiceError('invalid_check', 'Check is not in this task plan');
110
111
  if (step &&
111
112
  !task.steps.some((s) => s.id === step && s.state === 'awaiting_review' && s.checks?.includes(key)))
@@ -147,7 +148,7 @@ export class TaskVerification {
147
148
  this.station.dispatch(id);
148
149
  return;
149
150
  }
150
- const task = this.tasks.get(receipt.task_id), check = task.acceptance.find((c) => c.key === receipt.check_key);
151
+ const task = this.tasks.get(receipt.task_id), check = requiredChecks(task).find((c) => c.key === receipt.check_key);
151
152
  try {
152
153
  const existing = this.store.db
153
154
  .prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
@@ -383,6 +384,26 @@ export class TaskVerification {
383
384
  throw new ServiceError('verification_required', 'Protected station completion requires awaited verification');
384
385
  return this.finishRecorded(id, request, expected, exception, authorize);
385
386
  }
387
+ /** Item 8: the completion critic, when the service has one. */
388
+ critic;
389
+ /** The critic's verdict on the current fresh verified evidence, starting it the first time.
390
+ * Reads the evidence outside the finishing transaction, so starting the reviewer can never be
391
+ * rolled back with a refused finish; the finish itself re-checks the verdict against its own
392
+ * evidence. */
393
+ async criticGateAsync(task) {
394
+ if (!task.completion_gate?.critic)
395
+ return { state: 'pass' };
396
+ if (!this.critic || !this.station)
397
+ return { state: 'failed', reason: 'A critic needs the station verification boundary' };
398
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
399
+ if (checks.some((c) => !c || c.state !== 'passed'))
400
+ return { state: 'pending' };
401
+ const fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => ({ evidence, deliverables }));
402
+ return this.critic.gate(task, digest(fresh.evidence), fresh.deliverables.map(({ path, content }) => ({
403
+ path,
404
+ content,
405
+ })));
406
+ }
386
407
  async finishAsync(id, request, expected, exception, authorize) {
387
408
  if (!this.station)
388
409
  return this.finish(id, request, expected, exception, authorize);
@@ -401,7 +422,7 @@ export class TaskVerification {
401
422
  throw new ServiceError('verification_required', 'Strict station work requires verified completion without exceptions');
402
423
  if (!task.acceptance.length || !task.deliverables.length)
403
424
  throw new ServiceError('verification_required', 'Configured checks and deliverables required');
404
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
425
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
405
426
  if (checks.some((c) => !c || c.state !== 'passed'))
406
427
  throw new ServiceError('verification_required', 'Passed configured checks required');
407
428
  return this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => this.finishRecorded(id, request, expected, null, authorize, { evidence, deliverables }));
@@ -415,7 +436,7 @@ export class TaskVerification {
415
436
  if (view.completion?.state === 'succeeded') {
416
437
  try {
417
438
  const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
418
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
439
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
419
440
  if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
420
441
  throw Error('Changed checks');
421
442
  view.completion.fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (fresh) => view.completion.plan_revision === this.tasks.get(task.id).plan_revision &&
@@ -441,7 +462,7 @@ export class TaskVerification {
441
462
  this.assertIdle(task);
442
463
  if (task.steps.some((s) => s.state !== 'completed'))
443
464
  throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
444
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
465
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
445
466
  const missing = [];
446
467
  if (!task.acceptance.length)
447
468
  missing.push('No configured acceptance checks');
@@ -452,13 +473,21 @@ export class TaskVerification {
452
473
  (protectedResult
453
474
  ? !protectedResult.evidence.some((e) => e.check_id === checks[n].id)
454
475
  : !this.fresh(checks[n])))
455
- missing.push('Fresh check required: ' + task.acceptance[n].key);
476
+ missing.push('Fresh check required: ' + requiredChecks(task)[n].key);
456
477
  let workspace = null, deliverables = [], diff = null;
457
478
  try {
458
479
  if (protectedResult) {
459
480
  const e = protectedResult.evidence[0];
460
481
  workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
461
- deliverables = protectedResult.deliverables;
482
+ // Keep each deliverable's verified bytes as a session artifact, committed with the
483
+ // completion, so publication reads the verified revision and never the live workspace.
484
+ deliverables = protectedResult.deliverables.map(({ path, sha256, content }) => {
485
+ if (typeof content !== 'string')
486
+ throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
487
+ const artifact = this.artifacts.prepare(task.session_id, content);
488
+ artifact.commit();
489
+ return { path, sha256, artifact_id: artifact.id };
490
+ });
462
491
  diff = {
463
492
  backend: 'linux-systemd-station-v1',
464
493
  root_digest: e.root_digest,
@@ -481,6 +510,10 @@ export class TaskVerification {
481
510
  catch (error) {
482
511
  missing.push('Workspace verification unavailable: ' + error.message);
483
512
  }
513
+ // Item 8: a declared critic must have passed this exact verified evidence.
514
+ if (task.completion_gate?.critic &&
515
+ !(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence))))
516
+ missing.push('Critic review required');
484
517
  if (missing.length && !exception)
485
518
  throw new ServiceError('verification_required', missing.join('; '));
486
519
  if (exception) {
@@ -544,7 +577,7 @@ export class TaskVerification {
544
577
  if (completion.state === 'succeeded')
545
578
  fresh =
546
579
  fresh &&
547
- task.acceptance.every((check) => {
580
+ requiredChecks(task).every((check) => {
548
581
  const latest = this.latest(task, check.key, null);
549
582
  return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
550
583
  });
@@ -34,6 +34,52 @@ export class TaskWork {
34
34
  .prepare('UPDATE task_runtime SET remaining_steps=remaining_steps-1 WHERE task_id=?')
35
35
  .run(task.id);
36
36
  }
37
+ /** Item 8: re-run the last completed step with the critic's findings added to its instruction,
38
+ * under its own re-prepared progression grant and a request of its own. */
39
+ reopenForCritic(taskId, stepId, round, epoch, findings) {
40
+ const task = this.get(taskId);
41
+ const step = task.steps.find((s) => s.id === stepId);
42
+ const grant = this.store.db
43
+ .prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
44
+ .get(task.id, stepId, task.plan_revision, epoch);
45
+ if (!grant || !step || step.state !== 'completed' || task.steps.at(-1)?.id !== stepId)
46
+ throw new ServiceError('task_authority', 'Only the completed last step can be reopened');
47
+ this.store.transaction(() => {
48
+ this.store.db.prepare("UPDATE task_steps SET state='pending' WHERE id=?").run(stepId);
49
+ this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
50
+ this.store.event(task.session_id, 'work_task.step_reopened', {
51
+ task_id: task.id,
52
+ step_id: stepId,
53
+ grant_id: grant.id,
54
+ round,
55
+ reason: 'Critic requested changes',
56
+ });
57
+ });
58
+ return this.run(task.id, stepId, 'task-grant:' + grant.id + ':critic-reopen:' + round, this.get(task.id).revision, step.instruction +
59
+ '\n\nBefore this work is accepted, a reviewer asked for these changes:\n' +
60
+ findings, null, null, grant.id);
61
+ }
62
+ /** Re-run a step the provider rate-limited, as a fresh attempt under its own progression grant
63
+ * (re-prepared, with a request of its own, so it is never a replay of the refused attempt). */
64
+ retryStep(taskId, stepId, n, epoch) {
65
+ const task = this.get(taskId);
66
+ const grant = this.store.db
67
+ .prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
68
+ .get(task.id, stepId, task.plan_revision, epoch);
69
+ if (!grant || task.steps.find((s) => s.id === stepId)?.state !== 'failed')
70
+ throw new ServiceError('task_authority', 'No settled grant for this rate-limited step');
71
+ this.store.transaction(() => {
72
+ this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
73
+ this.store.event(task.session_id, 'work_task.step_retried', {
74
+ task_id: task.id,
75
+ step_id: stepId,
76
+ grant_id: grant.id,
77
+ retry: n,
78
+ reason: 'Provider rate limit',
79
+ });
80
+ });
81
+ return this.run(task.id, stepId, 'task-grant:' + grant.id + ':rate-limit-retry:' + n, task.revision, null, null, null, grant.id);
82
+ }
37
83
  advance(grantId) {
38
84
  const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId);
39
85
  if (!grant)
@@ -439,6 +485,7 @@ export class TaskWork {
439
485
  objective: task.objective,
440
486
  constraints: task.constraints,
441
487
  acceptance: task.acceptance,
488
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
442
489
  deliverables: task.deliverables,
443
490
  limits: task.limits,
444
491
  active_step_id: task.steps.find((s) => s.state === 'running')?.id ?? null,
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { userInfo } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { existsSync } from 'node:fs';
6
+ import { ServiceError } from './protocol.js';
7
+ export const sharedUserWarning = (user) => `Tool execution runs as ${user}, the same OS user that holds the provider credential. Set --tools-user to separate them.`;
8
+ export function serviceToolIdentity() {
9
+ const user = userInfo().username;
10
+ return {
11
+ mode: 'service-user',
12
+ user,
13
+ uid: process.getuid?.() ?? -1,
14
+ prefix: [],
15
+ warning: sharedUserWarning(user),
16
+ };
17
+ }
18
+ const run = (argv, input) => spawnSync(argv[0], argv.slice(1), {
19
+ env: { PATH: '/usr/local/bin:/usr/bin:/bin', LANG: 'C' },
20
+ input,
21
+ encoding: 'utf8',
22
+ timeout: 10000,
23
+ killSignal: 'SIGKILL',
24
+ maxBuffer: 1 << 20,
25
+ });
26
+ /** Fail closed unless the tools user exists, is reachable without a password, and cannot read state. */
27
+ export function probeToolsUser(user, stateDir, prefix = ['sudo', '-n', '-u', user, '--']) {
28
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(user))
29
+ throw new Error('--tools-user must be a plain Unix user name');
30
+ if (process.platform !== 'linux' && prefix[0] === 'sudo')
31
+ throw new Error('--tools-user is supported on Linux hosts only');
32
+ const id = run([...prefix, '/usr/bin/id', '-u']);
33
+ const uid = Number(String(id.stdout).trim());
34
+ if (id.status !== 0 || !Number.isSafeInteger(uid))
35
+ throw new Error(`Cannot run as tools user ${user} (sudo -n -u ${user} failed); see docs/security.md host setup`);
36
+ if (uid === 0 || uid === process.getuid?.())
37
+ throw new Error('The tools user must be an unprivileged user distinct from the service user');
38
+ if (run([...prefix, '/usr/bin/test', '-r', stateDir]).status === 0)
39
+ throw new Error(`Tools user ${user} can read the Salaam state directory; restrict it to 0700`);
40
+ return { mode: 'tools-user', user, uid, prefix, warning: null };
41
+ }
42
+ export function toolArgv(identity, argv) {
43
+ return [...identity.prefix, ...argv];
44
+ }
45
+ /** Kill a tool process group owned by the tools user; the daemon cannot signal another uid. */
46
+ export function killToolGroup(identity, pgid) {
47
+ if (identity.mode !== 'tools-user' || !Number.isSafeInteger(pgid) || pgid < 2)
48
+ return;
49
+ run(toolArgv(identity, ['/bin/kill', '-KILL', '--', '-' + pgid]));
50
+ }
51
+ const here = fileURLToPath(import.meta.url);
52
+ /** Source runs (tests) use the built helper; packages ship it beside this file. */
53
+ export const EDIT_HELPER = here.endsWith('.ts')
54
+ ? join(dirname(here), '..', 'dist/src/local-edit-helper.js')
55
+ : join(dirname(here), 'local-edit-helper.js');
56
+ /** Apply reviewed edits as the tools user. Reports each applied path; any failure throws. */
57
+ export function applyEditsAsTools(identity, root, edits, applied) {
58
+ if (!existsSync(EDIT_HELPER))
59
+ throw new ServiceError('tool_identity', 'Edit helper is missing');
60
+ const result = run(toolArgv(identity, [process.execPath, EDIT_HELPER, root]), JSON.stringify(edits));
61
+ for (const line of String(result.stdout ?? '').split('\n')) {
62
+ if (line.startsWith('applied '))
63
+ applied(JSON.parse(line.slice(8)));
64
+ }
65
+ if (result.status !== 0)
66
+ throw new ServiceError('tool_identity', `Edit as ${identity.user} failed: ${String(result.stderr ?? '')
67
+ .trim()
68
+ .slice(0, 400)}`);
69
+ }
package/dist/src/usage.js CHANGED
@@ -25,7 +25,7 @@ export class UsageLedger {
25
25
  this.store.execution.assert(session);
26
26
  this.store.taskModelAdmission?.(session, attempt, bounds?.input ?? 0, bounds?.output ?? 0);
27
27
  if (attempt) {
28
- this.budget(attempt);
28
+ this.budget(attempt, this.store.taskAttemptRequestLimit?.(session) ?? 16);
29
29
  const changed = this.store.db
30
30
  .prepare('UPDATE attempt_budgets SET spent_requests=spent_requests+1 WHERE attempt_id=? AND spent_requests+reserved_requests<limit_requests')
31
31
  .run(attempt);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@superjackfruitlabs/salaam",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@superjackfruitlabs/salaam",
9
- "version": "0.0.3",
9
+ "version": "0.0.4",
10
10
  "bundleDependencies": [
11
11
  "@earendil-works/pi-ai",
12
12
  "@earendil-works/pi-coding-agent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superjackfruitlabs/salaam",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -312,6 +312,123 @@ export function acpFailure(
312
312
  ...(kind === 'unknown' ? {} : { retryable: false }),
313
313
  };
314
314
  }
315
+ /** ACP tool kind for an approval, so a client's modes can tell edits apart:
316
+ * AgentPod's accept-edits approves only kind "edit"; full-auto approves all; ask none. */
317
+ export function permissionKind(operationKind: string): 'edit' | 'execute' | 'other' {
318
+ if (operationKind === 'workspace') return 'edit';
319
+ if (operationKind === 'harness') return 'execute';
320
+ return 'other';
321
+ }
322
+ /** An operator MCP catalog read is a read; a server's `fetch` tool is a fetch; others stay other. */
323
+ function operatorMcpKind(action: string, tool: unknown): string {
324
+ if (action === 'catalog') return 'read';
325
+ return action === 'call' && tool === 'fetch' ? 'fetch' : 'other';
326
+ }
327
+
328
+ function toolArgs(input: unknown): Record<string, unknown> {
329
+ try {
330
+ const value = typeof input === 'string' ? JSON.parse(input) : input;
331
+ if (value && typeof value === 'object' && !Array.isArray(value))
332
+ return value as Record<string, unknown>;
333
+ } catch {}
334
+ return {};
335
+ }
336
+
337
+ const label = (v: unknown) => (typeof v === 'string' && /^[\w.-]{1,128}$/.test(v) ? v : undefined);
338
+
339
+ /** One line of what a tool acted on, for a card title: no control characters, at most `max`. */
340
+ function shown(v: unknown, max: number): string | undefined {
341
+ if (typeof v !== 'string') return undefined;
342
+ const t = v
343
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
344
+ .replace(/\s+/g, ' ')
345
+ .trim();
346
+ if (!t) return undefined;
347
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
348
+ }
349
+ /** A URL as host and path only: never its login, query or fragment (they may carry a secret). */
350
+ function shownUrl(v: unknown): string | undefined {
351
+ if (typeof v !== 'string') return undefined;
352
+ try {
353
+ const u = new URL(v);
354
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return undefined;
355
+ return u.host + (u.pathname === '/' ? '' : u.pathname);
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }
360
+ const titled = (verb: string, what: string | undefined) => {
361
+ if (!what) return verb;
362
+ const t = `${verb}: ${what}`;
363
+ return t.length > 80 ? t.slice(0, 79) + '…' : t;
364
+ };
365
+
366
+ /**
367
+ * The ACP title for a tool call, the way a board agent's card reads: the tool and what it acted on
368
+ * ("fetch: host/path", "write: runs/r/answer.md"). Every operator MCP call goes through one wrapper
369
+ * tool, so it is titled by the MCP tool itself. Only plain identifiers, URL host and path, paths,
370
+ * queries and commands are echoed, clipped to one line of 80 characters.
371
+ */
372
+ /** A clipped input no longer parses: read the leading plain string fields it still carries. */
373
+ function clippedArgs(input: unknown): Record<string, unknown> {
374
+ if (typeof input !== 'string') return {};
375
+ const out: Record<string, unknown> = {};
376
+ for (const key of [
377
+ 'action',
378
+ 'path',
379
+ 'query',
380
+ 'pattern',
381
+ 'command',
382
+ 'server',
383
+ 'tool',
384
+ 'item_id',
385
+ ]) {
386
+ const m = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.){0,512})"`).exec(input);
387
+ if (m) {
388
+ try {
389
+ out[key] = JSON.parse(`"${m[1]}"`);
390
+ } catch {}
391
+ }
392
+ }
393
+ // A clipped patch is a long one, so a write: a delete (content null) is short and never clipped.
394
+ return out;
395
+ }
396
+
397
+ export function toolTitle(name: string, input: unknown): string {
398
+ const parsed = toolArgs(input);
399
+ const args = Object.keys(parsed).length ? parsed : clippedArgs(input);
400
+ const action = typeof args.action === 'string' ? args.action : '';
401
+ if (name === 'salaam_operator_mcp') {
402
+ const server = label(args.server);
403
+ const tool = label(args.tool);
404
+ if (action === 'call' && server && tool) {
405
+ const a = toolArgs(args.arguments);
406
+ return titled(tool, shownUrl(a.url) ?? shown(a.query ?? a.path ?? a.pattern, 80));
407
+ }
408
+ if (action === 'catalog') return titled('MCP catalog', server);
409
+ if (action === 'call') return titled('MCP call', server ?? tool);
410
+ return action ? titled('MCP request', label(action)) : 'MCP request (no input)';
411
+ }
412
+ if (name === 'salaam_board') {
413
+ if (action === 'card') return 'read this card';
414
+ if (action === 'attempts') return 'read this card’s runs';
415
+ if (action === 'library_search') return titled('library_search', shown(args.query, 80));
416
+ if (action === 'library_get') return titled('library_get', shown(args.item_id, 32));
417
+ return name;
418
+ }
419
+ if (['salaam_station', 'salaam_files', 'salaam_repository'].includes(name)) {
420
+ if (action === 'patch')
421
+ return titled(args.content === null ? 'delete' : 'write', shown(args.path, 80));
422
+ if (action === 'inspect' && args.path === undefined) return 'inspect workspace';
423
+ if (action === 'check') return titled('check', shown(args.command, 80));
424
+ if (['read', 'list', 'inspect', 'diff'].includes(action))
425
+ return titled(action, shown(args.path, 80));
426
+ if (action === 'search') return titled('search', shown(args.query ?? args.pattern, 80));
427
+ if (action) return action;
428
+ }
429
+ return name;
430
+ }
431
+
315
432
  export function toolMetadata(
316
433
  name: string,
317
434
  input: unknown,
@@ -324,27 +441,31 @@ export function toolMetadata(
324
441
  } catch {}
325
442
  const action = String(args.action ?? '');
326
443
  const kind =
327
- name === 'salaam_command' || (name === 'salaam_station' && action === 'check')
328
- ? ['start', 'cancel', 'check'].includes(action)
329
- ? 'execute'
330
- : ['status', 'logs'].includes(action)
331
- ? 'read'
332
- : 'other'
333
- : ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
334
- ? args.content === null
335
- ? 'delete'
336
- : 'edit'
337
- : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
338
- ['search', 'list'].includes(action)
339
- ? 'search'
340
- : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
341
- ['read', 'inspect', ...(name === 'salaam_repository' ? ['diff'] : [])].includes(
342
- action,
343
- )
444
+ name === 'salaam_board'
445
+ ? 'read'
446
+ : name === 'salaam_command' || (name === 'salaam_station' && action === 'check')
447
+ ? ['start', 'cancel', 'check'].includes(action)
448
+ ? 'execute'
449
+ : ['status', 'logs'].includes(action)
344
450
  ? 'read'
345
- : ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
451
+ : 'other'
452
+ : ['salaam_files', 'salaam_station'].includes(name) && action === 'patch'
453
+ ? args.content === null
454
+ ? 'delete'
455
+ : 'edit'
456
+ : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
457
+ ['search', 'list'].includes(action)
458
+ ? 'search'
459
+ : ['salaam_files', 'salaam_repository', 'salaam_station'].includes(name) &&
460
+ ['read', 'inspect', ...(name === 'salaam_repository' ? ['diff'] : [])].includes(
461
+ action,
462
+ )
346
463
  ? 'read'
347
- : 'other';
464
+ : ['salaam_history', 'inspect_child', 'consume_child_result'].includes(name)
465
+ ? 'read'
466
+ : name === 'salaam_operator_mcp'
467
+ ? operatorMcpKind(action, args.tool)
468
+ : 'other';
348
469
  const locations: { path: string; line?: number }[] = [];
349
470
  if (
350
471
  ['salaam_files', 'salaam_station'].includes(name) &&
@@ -361,3 +482,32 @@ export function toolMetadata(
361
482
  }
362
483
  return { kind, locations };
363
484
  }
485
+
486
+ /** A durable task's steps as the ACP `plan` session update: the whole list, replaced on every
487
+ * change (https://agentclientprotocol.com/protocol/agent-plan). ACP has only pending,
488
+ * in_progress and completed, so a step that stopped says so in its text instead. */
489
+ export function planUpdate(
490
+ task: { steps: { title: string; state: string }[] } | null | undefined,
491
+ ): {
492
+ sessionUpdate: 'plan';
493
+ entries: { content: string; priority: 'medium'; status: string }[];
494
+ } | null {
495
+ if (!task?.steps.length) return null;
496
+ return {
497
+ sessionUpdate: 'plan',
498
+ entries: task.steps.map((step) => {
499
+ const status =
500
+ step.state === 'completed'
501
+ ? 'completed'
502
+ : step.state === 'running' || step.state === 'awaiting_review'
503
+ ? 'in_progress'
504
+ : 'pending';
505
+ const stopped = ['cancelled', 'failed', 'interrupted'].includes(step.state);
506
+ return {
507
+ content: stopped ? `${step.title} (${step.state})` : step.title,
508
+ priority: 'medium' as const,
509
+ status,
510
+ };
511
+ }),
512
+ };
513
+ }
package/src/acp.ts CHANGED
@@ -1,12 +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, type Feed } 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 {
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';
12
19
  import {
@@ -40,11 +47,14 @@ interface Connection {
40
47
  dirty: boolean;
41
48
  decisions: Promise<void>;
42
49
  failed?: Error;
50
+ /** The last plan sent, so an unchanged task sends nothing. */
51
+ plan?: string;
43
52
  }
44
53
  /** ACP v1 newline-delimited stdio adapter. The daemon remains the only worker owner. */
45
54
  export async function acp(state: string, options: { coding?: boolean } = {}): Promise<void> {
46
55
  const socket = join(state, 'operator.sock');
47
- const journal = new AcpJournal(state);
56
+ const client = acpClientState(state);
57
+ const journal = new AcpJournal(client);
48
58
  const admission = process.env.SALAAM_AGENTPOD_ADMISSION,
49
59
  admissionToken = process.env.SALAAM_AGENTPOD_TOKEN;
50
60
  delete process.env.SALAAM_AGENTPOD_ADMISSION;
@@ -102,6 +112,14 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
102
112
  params: { sessionId: id, update: out },
103
113
  });
104
114
  };
115
+ /** Send the session's durable task plan when it differs from the last one sent. */
116
+ const sendPlan = async (c: Connection, task?: SessionSnapshot['work_task']) => {
117
+ const plan = planUpdate(task === undefined ? (await snapshot(c.id)).work_task : task);
118
+ const key = plan ? JSON.stringify(plan) : undefined;
119
+ if (!plan || key === c.plan) return;
120
+ c.plan = key;
121
+ await update(c.id, plan as unknown as Json);
122
+ };
105
123
  const promptReceipt = async (session: string, request: string, attemptId: string) => {
106
124
  if (!bound || !wantsReceipts) return;
107
125
  const attempt = await call<Attempt>(socket, `/v1/attempts/${attemptId}`);
@@ -201,7 +219,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
201
219
  await update(c.id, {
202
220
  sessionUpdate: 'tool_call',
203
221
  toolCallId: String(part.id),
204
- title: String(part.name),
222
+ title: toolTitle(String(part.name), part.arguments),
205
223
  ...(await toolDetails(c.id, String(part.id), String(part.name), part.arguments)),
206
224
  status: 'in_progress',
207
225
  rawInput: part.arguments,
@@ -267,7 +285,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
267
285
  const toolCall = {
268
286
  toolCallId: 'salaam-operation:' + op.id,
269
287
  title: op.kind + ': ' + op.target,
270
- kind: 'other',
288
+ kind: permissionKind(op.kind),
271
289
  status: 'pending',
272
290
  rawInput: {
273
291
  target: op.target,
@@ -290,7 +308,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
290
308
  )
291
309
  throw new Error('Invalid permission outcome');
292
310
  const decide = async () => {
293
- const outbox = new Outbox(state, c.id);
311
+ const outbox = new Outbox(client, c.id);
294
312
  outbox.enqueueDecision(
295
313
  approval.id,
296
314
  approval.operation_digest,
@@ -381,6 +399,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
381
399
  tool?: string;
382
400
  input?: string;
383
401
  failed?: boolean;
402
+ output?: string;
384
403
  kind?: string;
385
404
  locations?: unknown[];
386
405
  };
@@ -391,7 +410,7 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
391
410
  void update(c.id, {
392
411
  sessionUpdate: started && fresh ? 'tool_call' : 'tool_call_update',
393
412
  toolCallId: p.tool_call_id,
394
- title: p.tool ?? 'Tool',
413
+ title: p.tool ? toolTitle(p.tool, p.input) : 'Tool',
395
414
  ...(started
396
415
  ? {
397
416
  kind: p.kind ?? toolMetadata(p.tool ?? '', p.input).kind,
@@ -400,6 +419,9 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
400
419
  : {}),
401
420
  status: started ? 'in_progress' : p.failed ? 'failed' : 'completed',
402
421
  ...(started && p.input ? { rawInput: p.input } : {}),
422
+ ...(!started && p.output
423
+ ? { content: [{ type: 'content', content: { type: 'text', text: p.output } }] }
424
+ : {}),
403
425
  ...(p.parent_tool_call_id
404
426
  ? { _meta: { 'salaam.dev/parentToolCallId': p.parent_tool_call_id } }
405
427
  : {}),
@@ -408,6 +430,10 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
408
430
  });
409
431
  }
410
432
  }
433
+ if (event.kind.startsWith('work_task.'))
434
+ void sendPlan(c).catch((error) => {
435
+ c.failed = error;
436
+ });
411
437
  if (
412
438
  [
413
439
  'inbox.changed',
@@ -518,6 +544,8 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
518
544
  if (record.receipt) await promptReceipt(id, record.request_id, record.receipt.attempt_id);
519
545
  if (wantsCancellations) await cancellationReceipts(c);
520
546
  await approvals(c, view);
547
+ // The plan is visible before any step runs.
548
+ await sendPlan(c, view.work_task ?? null);
521
549
  watch(c);
522
550
  } catch (error) {
523
551
  sessions.delete(id);
@@ -878,3 +906,21 @@ export async function acp(state: string, options: { coding?: boolean } = {}): Pr
878
906
  }
879
907
  }
880
908
  }
909
+
910
+ /** A launcher-profile daemon's state belongs to root; its ACP clients keep their own
911
+ * journal and outbox in the caller's state home, keyed by the daemon state path. */
912
+ export function acpClientState(state: string, env: NodeJS.ProcessEnv = process.env): string {
913
+ let own = false;
914
+ try {
915
+ own = statSync(state).uid === process.getuid?.();
916
+ } catch {}
917
+ if (own) return state;
918
+ const home = env.XDG_STATE_HOME || join(env.HOME ?? '/nonexistent', '.local/state');
919
+ const dir = join(
920
+ home,
921
+ 'salaam/acp-clients',
922
+ createHash('sha256').update(state).digest('hex').slice(0, 32),
923
+ );
924
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
925
+ return dir;
926
+ }