@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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
});
|
package/dist/src/task-work.js
CHANGED
|
@@ -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);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superjackfruitlabs/salaam",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
package/src/acp-outcome.ts
CHANGED
|
@@ -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 === '
|
|
328
|
-
?
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
? '
|
|
332
|
-
: '
|
|
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
|
-
:
|
|
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
|
-
: '
|
|
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 {
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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
|
+
}
|