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.
Files changed (86) hide show
  1. package/README.md +17 -7
  2. package/bin/acc-bootstrap.mjs +56 -0
  3. package/bin/acc-claude-channel.mjs +177 -0
  4. package/bin/acc.mjs +7 -2
  5. package/docs/ADAPTER_AUTHORING.md +34 -2
  6. package/docs/CAPABILITIES.md +25 -10
  7. package/docs/CLI.md +10 -3
  8. package/docs/CONFIGURATION.md +4 -0
  9. package/docs/HOW_IT_WORKS.md +277 -0
  10. package/docs/PROTOCOL.md +1 -1
  11. package/docs/RELEASING.md +7 -1
  12. package/docs/TROUBLESHOOTING.md +7 -1
  13. package/docs/index.md +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +4 -2
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  26. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
  27. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
  28. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
  29. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  30. package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
  31. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +4 -2
  32. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
  33. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  36. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
  37. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +4 -2
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
  42. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
  43. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
  47. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +4 -2
  49. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
  50. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +4 -2
  52. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
  54. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
  55. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
  56. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  57. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  58. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  59. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
  60. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
  62. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
  63. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +103 -11
  64. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
  67. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
  69. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  70. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  71. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
  72. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
  74. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  75. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
  76. package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
  77. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
  80. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  82. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  83. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  84. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
  85. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  86. 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
- delivery = "off" }) {
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 liveDeliverySupported = entry.capabilities?.delivery?.livePush === true;
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} has no certified live delivery for this client; durable fallback remains active`
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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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: listOf(oneOf("nextTurn", "livePush", "replyRoute")),
117
+ availableModes: uniqueListOf(oneOf(...DELIVERY_MODES)),
108
118
  livePolicy: oneOf("off", "actionable", "all"), opaqueEndpointRef: prose,
109
- leaseUntil: timestamp },
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.2.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",