agents-can-communicate 0.2.0 → 0.3.0
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/README.md +17 -7
- package/bin/acc-bootstrap.mjs +56 -0
- package/bin/acc-claude-channel.mjs +177 -0
- package/bin/acc.mjs +7 -2
- package/docs/ADAPTER_AUTHORING.md +34 -2
- package/docs/CAPABILITIES.md +25 -10
- package/docs/CLI.md +10 -3
- package/docs/CONFIGURATION.md +4 -0
- package/docs/HOW_IT_WORKS.md +277 -0
- package/docs/PROTOCOL.md +1 -1
- package/docs/RELEASING.md +7 -1
- package/docs/TROUBLESHOOTING.md +7 -1
- package/docs/index.md +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +4 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +4 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +4 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +103 -11
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
- package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
- package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, constants } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { defaultBootstrap } from "@agents-can-communicate/adapter-sdk";
|
|
6
|
+
|
|
7
|
+
import { installShellBootstrap, planShellBootstrap, uninstallShellBootstrap }
|
|
8
|
+
from "./shell-bootstrap.mjs";
|
|
9
|
+
|
|
10
|
+
// The installer's side of a native activation: which owned mechanisms an
|
|
11
|
+
// eligible adapter asked for, how they are applied in a fixed order, what is
|
|
12
|
+
// recorded so uninstall removes only ACC's bytes, and how a recorded
|
|
13
|
+
// activation is taken back. Adapter-owned native config is written by the
|
|
14
|
+
// adapter's own install; a vendor service is started only here, only during
|
|
15
|
+
// apply, and only when it did not already exist.
|
|
16
|
+
|
|
17
|
+
export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
|
|
18
|
+
const MECHANISM_ORDER = ["native-config", "native-service", "shell-bootstrap"];
|
|
19
|
+
const SERVICE_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
export const livePolicyOf = install => (LIVE_POLICIES.includes(install?.nativeActivation?.livePolicy)
|
|
22
|
+
? install.nativeActivation.livePolicy : "off");
|
|
23
|
+
|
|
24
|
+
export const shellOf = env => {
|
|
25
|
+
const shell = env?.SHELL;
|
|
26
|
+
return typeof shell === "string" && shell !== "" ? path.basename(shell) : null;
|
|
27
|
+
};
|
|
28
|
+
export const shimDirFor = stateRoot => path.join(stateRoot, "bin");
|
|
29
|
+
// `.zshrc`, deliberately, and that makes the shim interactive-only: zsh reads
|
|
30
|
+
// this file for interactive shells and not for `zsh -lc` or a script. An
|
|
31
|
+
// interactive launch is where a client session comes from, and putting the PATH
|
|
32
|
+
// entry somewhere every shell reads would put ACC in front of a vendor command
|
|
33
|
+
// in scripts and CI that never asked for it. Worth knowing when checking an
|
|
34
|
+
// install: a non-interactive shell resolving the vendor binary directly is this
|
|
35
|
+
// choice working, not a broken bootstrap.
|
|
36
|
+
export const rcFileFor = (home, shell) => (shell === "zsh" && typeof home === "string"
|
|
37
|
+
? path.join(home, ".zshrc") : null);
|
|
38
|
+
|
|
39
|
+
// The vendor executable a shim will exec: the first executable on PATH that is
|
|
40
|
+
// not ACC's own shim directory, so a shim never resolves itself.
|
|
41
|
+
export async function resolveExecutable(command, { pathEnv = "", exclude = [] } = {}) {
|
|
42
|
+
const excluded = exclude.filter(Boolean).map(directory => path.resolve(directory));
|
|
43
|
+
for (const directory of String(pathEnv).split(path.delimiter).filter(Boolean)) {
|
|
44
|
+
if (excluded.includes(path.resolve(directory))) continue;
|
|
45
|
+
const candidate = path.join(directory, command);
|
|
46
|
+
try {
|
|
47
|
+
await access(candidate, constants.X_OK);
|
|
48
|
+
return candidate;
|
|
49
|
+
} catch {
|
|
50
|
+
// not here; keep walking PATH
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const defaultExec = (executable, args) => new Promise((resolve, reject) => {
|
|
57
|
+
execFile(executable, args, { timeout: SERVICE_TIMEOUT_MS, windowsHide: true },
|
|
58
|
+
error => (error === null ? resolve() : reject(error)));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ordered = mechanisms => [...mechanisms].sort((left, right) =>
|
|
62
|
+
MECHANISM_ORDER.indexOf(left.kind) - MECHANISM_ORDER.indexOf(right.kind));
|
|
63
|
+
|
|
64
|
+
const renderCommand = command => [command.executable, ...command.args].join(" ");
|
|
65
|
+
|
|
66
|
+
/** What an activation would touch, in the operator's words. */
|
|
67
|
+
export function describeActivation(activation) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
for (const mechanism of ordered(activation.mechanisms)) {
|
|
70
|
+
if (mechanism.kind === "shell-bootstrap") {
|
|
71
|
+
lines.push(`create shim ${path.join(activation.shimDir, mechanism.command)} for `
|
|
72
|
+
+ `${mechanism.command} (${activation.livePolicy} live delivery)`);
|
|
73
|
+
lines.push(`add a PATH block to ${activation.rcFile}`);
|
|
74
|
+
} else if (mechanism.kind === "native-config") {
|
|
75
|
+
lines.push(`write native config ${mechanism.artifactIds.join(", ")}`);
|
|
76
|
+
} else if (mechanism.preExisting) {
|
|
77
|
+
lines.push(`use the existing ${mechanism.serviceId} service`);
|
|
78
|
+
} else if (mechanism.applyCommand !== null) {
|
|
79
|
+
lines.push(`start the ${mechanism.serviceId} service: ${renderCommand(mechanism.applyCommand)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function describeDeactivation(nativeActivation) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
for (const mechanism of nativeActivation?.mechanisms ?? []) {
|
|
88
|
+
if (mechanism.kind === "shell-bootstrap") {
|
|
89
|
+
for (const file of mechanism.ownedFiles) lines.push(`remove shim ${file.path}`);
|
|
90
|
+
lines.push(`remove the PATH block from ${mechanism.rcFile.path} once no ACC shim remains`);
|
|
91
|
+
} else if (mechanism.kind === "native-service") {
|
|
92
|
+
lines.push(mechanism.createdByAcc && mechanism.teardownCommand !== null
|
|
93
|
+
? `stop the ${mechanism.serviceId} service: ${renderCommand(mechanism.teardownCommand)}`
|
|
94
|
+
: `leave the ${mechanism.serviceId} service in place (${mechanism.createdByAcc
|
|
95
|
+
? "no vendor teardown exists" : "it existed before ACC"})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function applyNativeActivation({ adapter, activation, dataHome,
|
|
102
|
+
node = process.execPath, bootstrap = defaultBootstrap(), exec = defaultExec }) {
|
|
103
|
+
const record = { livePolicy: activation.livePolicy,
|
|
104
|
+
protocolContract: activation.protocolContract, mechanisms: [] };
|
|
105
|
+
let shell = null;
|
|
106
|
+
try {
|
|
107
|
+
for (const mechanism of ordered(activation.mechanisms)) {
|
|
108
|
+
if (mechanism.kind === "native-config") {
|
|
109
|
+
record.mechanisms.push({ kind: mechanism.kind, artifactIds: [...mechanism.artifactIds] });
|
|
110
|
+
} else if (mechanism.kind === "native-service") {
|
|
111
|
+
let createdByAcc = false;
|
|
112
|
+
if (!mechanism.preExisting && mechanism.applyCommand !== null) {
|
|
113
|
+
await exec(mechanism.applyCommand.executable, mechanism.applyCommand.args);
|
|
114
|
+
createdByAcc = true;
|
|
115
|
+
}
|
|
116
|
+
record.mechanisms.push({ kind: mechanism.kind, serviceId: mechanism.serviceId,
|
|
117
|
+
createdByAcc, teardownCommand: mechanism.teardownCommand });
|
|
118
|
+
} else {
|
|
119
|
+
const plan = planShellBootstrap({ shell: activation.shell, rcFile: activation.rcFile,
|
|
120
|
+
shimDir: activation.shimDir, runtime: { node, bootstrap, dataHome },
|
|
121
|
+
entries: [{ adapterId: adapter.id, command: mechanism.command,
|
|
122
|
+
realExecutable: mechanism.realExecutable, prefixArgs: mechanism.prefixArgs,
|
|
123
|
+
livePolicy: activation.livePolicy }] });
|
|
124
|
+
const result = await installShellBootstrap({ plan });
|
|
125
|
+
if (!result.ok) throw new Error(`shell bootstrap refused: ${result.reasonCode}`);
|
|
126
|
+
shell = result;
|
|
127
|
+
record.mechanisms.push({ kind: mechanism.kind, shimDir: activation.shimDir,
|
|
128
|
+
ownedFiles: result.shims.map(shim => ({ path: shim.path, sha256: shim.sha256 })),
|
|
129
|
+
rcFile: result.rcFile });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { nativeActivation: record, appendedRcBlock: shell?.rcFile.appended === true };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// Only bytes this operation wrote are taken back; a pre-existing service
|
|
135
|
+
// or a user's own file is never touched on the way out.
|
|
136
|
+
if (shell !== null) {
|
|
137
|
+
await uninstallShellBootstrap({ ownership: { shims: shell.shims, shimDir: activation.shimDir,
|
|
138
|
+
rcFile: shell.rcFile } }).catch(() => null);
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function deactivateNative({ nativeActivation, exec = defaultExec }) {
|
|
145
|
+
const report = { shell: null, services: [] };
|
|
146
|
+
for (const mechanism of nativeActivation?.mechanisms ?? []) {
|
|
147
|
+
if (mechanism.kind === "shell-bootstrap") {
|
|
148
|
+
report.shell = await uninstallShellBootstrap({ ownership: { shims: mechanism.ownedFiles,
|
|
149
|
+
shimDir: mechanism.shimDir, rcFile: mechanism.rcFile } });
|
|
150
|
+
} else if (mechanism.kind === "native-service") {
|
|
151
|
+
if (mechanism.createdByAcc && mechanism.teardownCommand !== null) {
|
|
152
|
+
await exec(mechanism.teardownCommand.executable, mechanism.teardownCommand.args);
|
|
153
|
+
report.services.push({ serviceId: mechanism.serviceId, outcome: "stopped" });
|
|
154
|
+
} else {
|
|
155
|
+
report.services.push({ serviceId: mechanism.serviceId,
|
|
156
|
+
outcome: mechanism.createdByAcc ? "retained_no_teardown" : "retained_pre_existing" });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return report;
|
|
161
|
+
}
|
|
@@ -110,7 +110,7 @@ async function saveOwnership({ dataHome, record }) {
|
|
|
110
110
|
* runtime, and leaves the bundle inside the client exactly where it was.
|
|
111
111
|
*/
|
|
112
112
|
export async function recordInstall({ dataHome, adapterId, version, accVersion = null,
|
|
113
|
-
artifacts, createdDirectories = [] }) {
|
|
113
|
+
artifacts, createdDirectories = [], nativeActivation = null }) {
|
|
114
114
|
const stamped = await Promise.all(artifacts.map(async artifact => ({
|
|
115
115
|
path: artifact.path,
|
|
116
116
|
kind: artifact.kind ?? "file",
|
|
@@ -127,7 +127,11 @@ export async function recordInstall({ dataHome, adapterId, version, accVersion =
|
|
|
127
127
|
await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
|
|
128
128
|
installs: [...record.installs.filter(install => install.adapterId !== adapterId),
|
|
129
129
|
{ adapterId, version, accVersion, artifacts: stamped,
|
|
130
|
-
...(directories.length === 0 ? {} : { createdDirectories: directories })
|
|
130
|
+
...(directories.length === 0 ? {} : { createdDirectories: directories }),
|
|
131
|
+
// Present only for a consented native activation. A record without it
|
|
132
|
+
// - every 0.2 install - reads as live policy off and is never rewritten
|
|
133
|
+
// merely to add the field.
|
|
134
|
+
...(nativeActivation === null ? {} : { nativeActivation }) }] } });
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
const installFor = (record, adapterId) =>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
2
|
|
|
3
|
+
import { LIVE_POLICIES, describeActivation, describeDeactivation, rcFileFor, shimDirFor }
|
|
4
|
+
from "./native-activation.mjs";
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Turn a detection report into exactly what would happen.
|
|
5
8
|
*
|
|
@@ -9,14 +12,26 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
9
12
|
*/
|
|
10
13
|
export function planInstallation({ adapters, detected, context, action = "install",
|
|
11
14
|
recorded = [], accVersion = null, allowDowngrade = false, requested = [],
|
|
12
|
-
|
|
15
|
+
deliveryByAdapter = {} }) {
|
|
13
16
|
if (!["install", "uninstall"].includes(action)) {
|
|
14
17
|
throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
|
|
15
18
|
}
|
|
16
|
-
if (!["off", "actionable", "all"].includes(delivery)) {
|
|
17
|
-
throw new AccError(EXIT.USAGE, `unknown delivery policy: ${delivery}`, { delivery });
|
|
18
|
-
}
|
|
19
19
|
const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
|
|
20
|
+
// One explicit answer per selected client. A missing entry is off; a policy
|
|
21
|
+
// for a client that is not part of this run is a mistake to say out loud,
|
|
22
|
+
// not something to store for later.
|
|
23
|
+
if (deliveryByAdapter === null || typeof deliveryByAdapter !== "object") {
|
|
24
|
+
throw new AccError(EXIT.USAGE, "deliveryByAdapter must map adapter ids to policies");
|
|
25
|
+
}
|
|
26
|
+
for (const [adapterId, policy] of Object.entries(deliveryByAdapter)) {
|
|
27
|
+
if (!byId.has(adapterId)) {
|
|
28
|
+
throw new AccError(EXIT.USAGE,
|
|
29
|
+
`delivery policy names ${adapterId}, which is not part of this install`, { adapterId });
|
|
30
|
+
}
|
|
31
|
+
if (!LIVE_POLICIES.includes(policy)) {
|
|
32
|
+
throw new AccError(EXIT.USAGE, `unknown delivery policy: ${policy}`, { adapterId, policy });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
// What ACC recorded writing, by client. For an uninstall this is the
|
|
21
36
|
// authority rather than detection: the record is the only account of what was
|
|
22
37
|
// written, and a client's configuration directory outlives the client.
|
|
@@ -86,15 +101,31 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
86
101
|
// From the record when the client is gone, because that is what was written
|
|
87
102
|
// and so what will be removed. Asking the adapter instead would describe an
|
|
88
103
|
// install for a machine this one no longer is.
|
|
89
|
-
const
|
|
104
|
+
const delivery = deliveryByAdapter[entry.adapterId] ?? "off";
|
|
105
|
+
const native = entry.nativeDelivery ?? null;
|
|
106
|
+
const liveDeliverySupported = native?.state === "eligible"
|
|
107
|
+
&& native.activationPlan?.eligible === true;
|
|
90
108
|
const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
|
|
91
109
|
const deliveryDiagnostic = action === "install" && delivery !== "off"
|
|
92
110
|
&& !liveDeliverySupported
|
|
93
111
|
? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
|
|
94
|
-
?? `${adapter.displayName ?? adapter.id}
|
|
112
|
+
?? `${adapter.displayName ?? adapter.id} cannot receive native delivery `
|
|
113
|
+
+ `(${native?.reasonCode ?? "native_delivery_unsupported"}); durable fallback remains active`
|
|
95
114
|
: null;
|
|
96
115
|
const installContext = { ...context, requestedLivePolicy: delivery,
|
|
97
116
|
livePolicy: effectiveLivePolicy };
|
|
117
|
+
// A consented activation that this run keeps, activates, or takes back.
|
|
118
|
+
// Only an explicit off or an uninstall removes one; an absent record never
|
|
119
|
+
// creates one.
|
|
120
|
+
const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
|
|
121
|
+
const nativeActivation = action === "install" && effectiveLivePolicy !== "off"
|
|
122
|
+
? { livePolicy: effectiveLivePolicy, protocolContract: native.eligibility.protocolContract,
|
|
123
|
+
shell: context?.shell ?? null, rcFile: rcFileFor(context?.home, context?.shell),
|
|
124
|
+
shimDir: typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null,
|
|
125
|
+
mechanisms: native.activationPlan.mechanisms }
|
|
126
|
+
: null;
|
|
127
|
+
const deactivation = previous !== null
|
|
128
|
+
&& (action === "uninstall" || effectiveLivePolicy === "off") ? previous : null;
|
|
98
129
|
const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
|
|
99
130
|
.map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
|
|
100
131
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -112,6 +143,8 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
112
143
|
livePolicy: delivery,
|
|
113
144
|
effectiveLivePolicy,
|
|
114
145
|
...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
|
|
146
|
+
...(nativeActivation === null ? {} : { nativeActivation }),
|
|
147
|
+
...(deactivation === null ? {} : { deactivation }),
|
|
115
148
|
artifacts,
|
|
116
149
|
// Said in the operator's terms, not in paths: which files ACC creates
|
|
117
150
|
// outright and which belong to the user and are only edited.
|
|
@@ -123,6 +156,8 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
123
156
|
.map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
|
|
124
157
|
...artifacts.filter(a => a.kind === "merge")
|
|
125
158
|
.map(a => `${action === "install" ? "add ACC entries to" : "remove ACC entries from"} ${a.path}`),
|
|
159
|
+
...(nativeActivation === null ? [] : describeActivation(nativeActivation)),
|
|
160
|
+
...(deactivation === null ? [] : describeDeactivation(deactivation)),
|
|
126
161
|
],
|
|
127
162
|
});
|
|
128
163
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
// The reversible shell half of native delivery: one marked PATH block in the
|
|
8
|
+
// user's zsh rc and one per-command shim in an ACC-owned directory. The shim
|
|
9
|
+
// keeps the user's command name, runs the bounded bootstrap check, and then
|
|
10
|
+
// replaces itself with the real vendor executable through `exec`; ACC is never
|
|
11
|
+
// the parent of a model session. Every byte ACC writes is recorded with its
|
|
12
|
+
// hash so uninstall removes only what is still ACC's and refuses a block or
|
|
13
|
+
// shim someone has edited.
|
|
14
|
+
|
|
15
|
+
export const BLOCK_BEGIN = "# >>> agents-can-communicate native delivery >>>";
|
|
16
|
+
export const BLOCK_END = "# <<< agents-can-communicate native delivery <<<";
|
|
17
|
+
export const SHIM_MARKER = "# agents-can-communicate native delivery shim";
|
|
18
|
+
export const SUPPORTED_SHELLS = Object.freeze(["zsh"]);
|
|
19
|
+
export const SHIM_POLICIES = Object.freeze(["actionable", "all"]);
|
|
20
|
+
const COMMAND_NAME = /^[a-z][a-z0-9_.-]*$/;
|
|
21
|
+
|
|
22
|
+
const usage = (message, details = {}) => { throw new AccError(EXIT.USAGE, message, details); };
|
|
23
|
+
const sha256 = text => createHash("sha256").update(text).digest("hex");
|
|
24
|
+
// Single-quoted POSIX literal: the only escaping is the quote itself, so no
|
|
25
|
+
// byte of a path or argument is ever interpreted by the shell.
|
|
26
|
+
export const shellLiteral = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
27
|
+
const isAbsolute = value => typeof value === "string" && path.isAbsolute(value);
|
|
28
|
+
const inside = (root, candidate) => {
|
|
29
|
+
const relative = path.relative(root, candidate);
|
|
30
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function renderPathBlock(shimDir) {
|
|
34
|
+
return `${BLOCK_BEGIN}\nexport PATH=${shellLiteral(shimDir)}:"$PATH"\n${BLOCK_END}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateShimEntry(entry) {
|
|
38
|
+
if (entry === null || typeof entry !== "object") usage("a shim entry must be an object");
|
|
39
|
+
for (const key of Object.keys(entry)) {
|
|
40
|
+
if (!["adapterId", "command", "realExecutable", "prefixArgs", "livePolicy"].includes(key)) {
|
|
41
|
+
usage(`unknown shim entry field ${key}`, { key });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
assertPortableId(entry.adapterId, "shim adapter id");
|
|
45
|
+
if (typeof entry.command !== "string" || !COMMAND_NAME.test(entry.command)) {
|
|
46
|
+
usage("a shim command must be a bare command name", { command: entry.command });
|
|
47
|
+
}
|
|
48
|
+
if (!isAbsolute(entry.realExecutable)) usage("a shim realExecutable must be an absolute path");
|
|
49
|
+
if (!Array.isArray(entry.prefixArgs) || entry.prefixArgs.some(arg => typeof arg !== "string"
|
|
50
|
+
|| arg === "" || /[\0\n]/.test(arg))) {
|
|
51
|
+
usage("shim prefixArgs must be non-empty argument strings without NUL or newline");
|
|
52
|
+
}
|
|
53
|
+
if (!SHIM_POLICIES.includes(entry.livePolicy)) {
|
|
54
|
+
usage("a shim livePolicy must be actionable or all; off means no shim", { livePolicy: entry.livePolicy });
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({ ...entry, prefixArgs: Object.freeze([...entry.prefixArgs]) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderCommandShim({ node, bootstrap, dataHome, entry }) {
|
|
60
|
+
const shim = validateShimEntry(entry);
|
|
61
|
+
for (const [name, value] of Object.entries({ node, bootstrap, dataHome })) {
|
|
62
|
+
if (!isAbsolute(value)) usage(`shim ${name} must be an absolute path`, { [name]: value });
|
|
63
|
+
}
|
|
64
|
+
const real = shellLiteral(shim.realExecutable);
|
|
65
|
+
const check = [node, bootstrap, "--adapter", shim.adapterId, "--real-executable",
|
|
66
|
+
shim.realExecutable, "--data-home", dataHome].map(shellLiteral).join(" ");
|
|
67
|
+
const prefix = shim.prefixArgs.map(shellLiteral).join(" ");
|
|
68
|
+
return [
|
|
69
|
+
"#!/bin/sh",
|
|
70
|
+
`${SHIM_MARKER} for ${shellLiteral(shim.command)}. Generated; do not edit.`,
|
|
71
|
+
"# ACC_BYPASS=1 runs the vendor command untouched. A failed or missing check",
|
|
72
|
+
"# does the same: the vendor command is never blocked on ACC.",
|
|
73
|
+
'if [ "${ACC_BYPASS-}" = "1" ]; then',
|
|
74
|
+
" unset ACC_NATIVE_DELIVERY_POLICY",
|
|
75
|
+
` exec ${real} "$@"`,
|
|
76
|
+
"fi",
|
|
77
|
+
`if ${check} </dev/null >/dev/null 2>&1; then`,
|
|
78
|
+
` ACC_NATIVE_DELIVERY_POLICY=${shellLiteral(shim.livePolicy)}`,
|
|
79
|
+
" export ACC_NATIVE_DELIVERY_POLICY",
|
|
80
|
+
` exec ${real}${prefix === "" ? "" : ` ${prefix}`} "$@"`,
|
|
81
|
+
"fi",
|
|
82
|
+
"unset ACC_NATIVE_DELIVERY_POLICY",
|
|
83
|
+
`exec ${real} "$@"`,
|
|
84
|
+
"",
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function planShellBootstrap({ shell, rcFile, shimDir, entries = [], runtime = null }) {
|
|
89
|
+
if (!SUPPORTED_SHELLS.includes(shell)) {
|
|
90
|
+
return Object.freeze({ eligible: false, reasonCode: "unsupported_shell", shell: shell ?? null,
|
|
91
|
+
rcFile: null, shimDir: null, block: null, shims: Object.freeze([]), runtime: null });
|
|
92
|
+
}
|
|
93
|
+
if (!isAbsolute(rcFile) || !isAbsolute(shimDir)) usage("rcFile and shimDir must be absolute");
|
|
94
|
+
const validated = entries.map(validateShimEntry);
|
|
95
|
+
const commands = new Set();
|
|
96
|
+
for (const entry of validated) {
|
|
97
|
+
if (commands.has(entry.command)) usage(`duplicate shim command ${entry.command}`);
|
|
98
|
+
commands.add(entry.command);
|
|
99
|
+
// A shim that resolved to itself would loop forever; the real executable
|
|
100
|
+
// is resolved before the shim directory is ever on PATH.
|
|
101
|
+
if (inside(shimDir, entry.realExecutable)) {
|
|
102
|
+
usage("a shim realExecutable cannot live inside the shim directory",
|
|
103
|
+
{ realExecutable: entry.realExecutable });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze({ eligible: true, reasonCode: null, shell, rcFile, shimDir,
|
|
107
|
+
block: renderPathBlock(shimDir), runtime,
|
|
108
|
+
shims: Object.freeze(validated.map(entry => Object.freeze({
|
|
109
|
+
path: path.join(shimDir, entry.command), entry }))) });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const defaultIo = Object.freeze({ readFile, writeFile, mkdir, chmod, rename, rm, rmdir, readdir });
|
|
113
|
+
|
|
114
|
+
async function readText(io, file) {
|
|
115
|
+
try {
|
|
116
|
+
return await io.readFile(file, "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.code === "ENOENT") return null;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function writeAtomic(io, file, content, mode) {
|
|
124
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
125
|
+
await io.writeFile(temporary, content, { mode });
|
|
126
|
+
await io.rename(temporary, file);
|
|
127
|
+
await io.chmod(file, mode);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The block's exact span inside the rc text, or null. The block is matched by
|
|
131
|
+
// its two sentinel lines so a user's other lines are never touched.
|
|
132
|
+
export function locateBlock(text) {
|
|
133
|
+
if (typeof text !== "string") return null;
|
|
134
|
+
const begin = text.indexOf(BLOCK_BEGIN);
|
|
135
|
+
if (begin === -1 || (begin > 0 && text[begin - 1] !== "\n")) return null;
|
|
136
|
+
const endLine = text.indexOf(BLOCK_END, begin);
|
|
137
|
+
if (endLine === -1) return null;
|
|
138
|
+
const end = text.indexOf("\n", endLine);
|
|
139
|
+
return { start: begin, end: end === -1 ? text.length : end + 1 };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function installShellBootstrap({ plan, io = defaultIo }) {
|
|
143
|
+
if (!plan?.eligible) return { ok: false, reasonCode: plan?.reasonCode ?? "unsupported_shell" };
|
|
144
|
+
const { node, bootstrap, dataHome } = plan.runtime ?? {};
|
|
145
|
+
const current = await readText(io, plan.rcFile);
|
|
146
|
+
const span = locateBlock(current);
|
|
147
|
+
const existing = span === null ? null : current.slice(span.start, span.end);
|
|
148
|
+
if (existing !== null && existing !== plan.block) {
|
|
149
|
+
return { ok: false, reasonCode: "rc_block_modified", rcFile: plan.rcFile };
|
|
150
|
+
}
|
|
151
|
+
await io.mkdir(plan.shimDir, { recursive: true, mode: 0o700 });
|
|
152
|
+
await io.chmod(plan.shimDir, 0o700);
|
|
153
|
+
const shims = [];
|
|
154
|
+
for (const shim of plan.shims) {
|
|
155
|
+
const content = renderCommandShim({ node, bootstrap, dataHome, entry: shim.entry });
|
|
156
|
+
await writeAtomic(io, shim.path, content, 0o700);
|
|
157
|
+
shims.push({ path: shim.path, command: shim.entry.command, sha256: sha256(content) });
|
|
158
|
+
}
|
|
159
|
+
let appended = false;
|
|
160
|
+
if (existing === null) {
|
|
161
|
+
const base = current ?? "";
|
|
162
|
+
const separator = base === "" || base.endsWith("\n") ? "" : "\n";
|
|
163
|
+
await writeAtomic(io, plan.rcFile, `${base}${separator}${plan.block}`, 0o600);
|
|
164
|
+
appended = true;
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, reasonCode: null, shell: plan.shell, shimDir: plan.shimDir,
|
|
167
|
+
rcFile: { path: plan.rcFile, blockSha256: sha256(plan.block), appended },
|
|
168
|
+
shims };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function uninstallShellBootstrap({ ownership, io = defaultIo }) {
|
|
172
|
+
const result = { ok: true, reasonCode: null, removedShims: [], keptShims: [],
|
|
173
|
+
missingShims: [], rcBlock: "absent" };
|
|
174
|
+
for (const shim of ownership?.shims ?? []) {
|
|
175
|
+
const content = await readText(io, shim.path);
|
|
176
|
+
if (content === null) { result.missingShims.push(shim.path); continue; }
|
|
177
|
+
if (sha256(content) !== shim.sha256) { result.keptShims.push(shim.path); continue; }
|
|
178
|
+
await io.rm(shim.path, { force: true });
|
|
179
|
+
result.removedShims.push(shim.path);
|
|
180
|
+
}
|
|
181
|
+
const shimDir = ownership?.shimDir;
|
|
182
|
+
let remaining = [];
|
|
183
|
+
if (typeof shimDir === "string") {
|
|
184
|
+
const names = await io.readdir(shimDir).catch(() => []);
|
|
185
|
+
for (const name of names) {
|
|
186
|
+
const content = await readText(io, path.join(shimDir, name));
|
|
187
|
+
if (content !== null && content.includes(SHIM_MARKER)) remaining.push(name);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const rcFile = ownership?.rcFile;
|
|
191
|
+
const text = typeof rcFile?.path === "string" ? await readText(io, rcFile.path) : null;
|
|
192
|
+
const span = locateBlock(text);
|
|
193
|
+
if (span !== null) {
|
|
194
|
+
const block = text.slice(span.start, span.end);
|
|
195
|
+
if (sha256(block) !== rcFile.blockSha256) {
|
|
196
|
+
result.rcBlock = "modified";
|
|
197
|
+
result.ok = false;
|
|
198
|
+
result.reasonCode = "rc_block_modified";
|
|
199
|
+
} else if (remaining.length > 0) {
|
|
200
|
+
result.rcBlock = "kept";
|
|
201
|
+
} else {
|
|
202
|
+
await writeAtomic(io, rcFile.path, text.slice(0, span.start) + text.slice(span.end), 0o600);
|
|
203
|
+
result.rcBlock = "removed";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (typeof shimDir === "string" && remaining.length === 0 && result.keptShims.length === 0) {
|
|
207
|
+
await io.rmdir(shimDir).catch(() => null);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
@@ -74,6 +74,23 @@ export const listOf = inner => (value, field) => {
|
|
|
74
74
|
export const nullable = inner => (value, field) =>
|
|
75
75
|
(value === null ? null : inner(value, field));
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A field that may be absent entirely.
|
|
79
|
+
*
|
|
80
|
+
* For a fact that either happened or did not, absence is the honest encoding of
|
|
81
|
+
* "not yet" - and it is what a record written before the field existed already
|
|
82
|
+
* says. Every record carries a schemaVersion, but adding a required field to a
|
|
83
|
+
* kind that outlives an upgrade turns a stored record into an unreadable one:
|
|
84
|
+
* measured, one binding written by the previous build made `acc status` fail
|
|
85
|
+
* outright with "deliveryBinding requires retiredAt". A coordination tool must
|
|
86
|
+
* not refuse to read its own store because it was upgraded.
|
|
87
|
+
*/
|
|
88
|
+
export const optional = inner => {
|
|
89
|
+
const check = (value, field) => (value === undefined ? undefined : inner(value, field));
|
|
90
|
+
check.optional = true;
|
|
91
|
+
return check;
|
|
92
|
+
};
|
|
93
|
+
|
|
77
94
|
export const positiveInteger = (value, field) => {
|
|
78
95
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
79
96
|
invalid(field, "must be a positive integer", value);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertMessageSemantics, MESSAGE_KINDS, OBLIGATIONS } from "./conversations.mjs";
|
|
2
2
|
import { AccError, EXIT } from "./errors.mjs";
|
|
3
|
-
import { id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
|
|
3
|
+
import { id, invalid, listOf, nullable, oneOf, optional, plainObject, positiveInteger,
|
|
4
4
|
resourceUri, sequence, text, timestamp } from "./fields.mjs";
|
|
5
5
|
|
|
6
6
|
export const SCHEMA_VERSION = 3;
|
|
@@ -64,6 +64,16 @@ const EVENT_TYPES = Object.freeze([
|
|
|
64
64
|
]);
|
|
65
65
|
|
|
66
66
|
const eventType = oneOf(...EVENT_TYPES);
|
|
67
|
+
|
|
68
|
+
// nextTurn is the durable hook projection; the other four are what a native
|
|
69
|
+
// transport proved for one session generation. A mode listed twice would let a
|
|
70
|
+
// reader count capabilities it does not have.
|
|
71
|
+
export const DELIVERY_MODES = Object.freeze(["nextTurn", "livePush", "idleWake", "busyQueue",
|
|
72
|
+
"replyRoute"]);
|
|
73
|
+
const uniqueListOf = check => (value, field) => {
|
|
74
|
+
listOf(check)(value, field);
|
|
75
|
+
if (new Set(value).size !== value.length) invalid(field, "must not repeat entries", value);
|
|
76
|
+
};
|
|
67
77
|
const receiptState = oneOf("queued", "offered", "retrieved", "acknowledged");
|
|
68
78
|
|
|
69
79
|
const DURABLE_RECORDS = Object.freeze({
|
|
@@ -104,9 +114,13 @@ const DURABLE_RECORDS = Object.freeze({
|
|
|
104
114
|
const RECORDS = Object.freeze({
|
|
105
115
|
...DURABLE_RECORDS,
|
|
106
116
|
deliveryBinding: { sessionId: id, generation: id, adapterId: id, clientVersion: line,
|
|
107
|
-
availableModes:
|
|
117
|
+
availableModes: uniqueListOf(oneOf(...DELIVERY_MODES)),
|
|
108
118
|
livePolicy: oneOf("off", "actionable", "all"), opaqueEndpointRef: prose,
|
|
109
|
-
|
|
119
|
+
// The lease says how long the endpoint's owner has vouched for it; the
|
|
120
|
+
// retirement says the session gave it up. They were once the same field -
|
|
121
|
+
// clearing expired the lease - which made a renewal by a channel that had
|
|
122
|
+
// not yet noticed indistinguishable from a legitimate extension.
|
|
123
|
+
leaseUntil: timestamp, retiredAt: optional(nullable(timestamp)) },
|
|
110
124
|
});
|
|
111
125
|
|
|
112
126
|
export const RECORD_KINDS = Object.freeze(Object.keys(DURABLE_RECORDS));
|
|
@@ -128,6 +142,7 @@ export function validateRecord(kind, value) {
|
|
|
128
142
|
}
|
|
129
143
|
for (const [field, check] of Object.entries(fields)) {
|
|
130
144
|
if (!Object.hasOwn(value, field)) {
|
|
145
|
+
if (check.optional === true) continue;
|
|
131
146
|
throw new AccError(EXIT.DATA, `${kind} requires ${field}`, { kind, field });
|
|
132
147
|
}
|
|
133
148
|
check(value[field], field);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-can-communicate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local-first coordination for independently opened AI agent sessions.",
|
|
6
6
|
"keywords": [
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"docs/DESIGN_DECISIONS.md",
|
|
55
55
|
"docs/GETTING_STARTED.md",
|
|
56
56
|
"docs/GLOSSARY.md",
|
|
57
|
+
"docs/HOW_IT_WORKS.md",
|
|
57
58
|
"docs/MCP.md",
|
|
58
59
|
"docs/PROTOCOL.md",
|
|
59
60
|
"docs/RELEASING.md",
|