agents-can-communicate 0.1.8 → 0.1.10

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 (25) hide show
  1. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  2. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +0 -1
  3. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +15 -2
  4. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +10 -3
  6. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +12 -3
  7. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +0 -1
  10. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +5 -0
  11. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  13. package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +81 -0
  14. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
  16. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +81 -7
  17. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +13 -4
  18. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +58 -4
  22. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  25. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,4 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.6",
4
3
  "description": "Coordinate this Claude Code session with other AI agent sessions working in the same workspace: shared presence, resource claims, typed messages, and handoffs."
5
4
  }
@@ -1,10 +1,11 @@
1
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { acccreatedFile, bakeSkillCommand, blankJson, mergeOwnedEntries, ownedEntries,
8
+ keepOnlyVersion, ownVersion, stampPluginVersion,
8
9
  removeIfEmpty,
9
10
  removeInstalledTree,
10
11
  removeOwnedEntries, writeForeignJson, writeHookShim }
@@ -122,7 +123,9 @@ const writeRegistry = async (file, value, remaining) => {
122
123
  return rm(file, { force: true });
123
124
  };
124
125
 
125
- const pluginVersion = async () => (await readJson(manifest, {})).version ?? "0.0.0";
126
+ // One version on this machine: the package's own. The shipped manifest carries
127
+ // none, so there is nothing in the repository to fall out of step.
128
+ const pluginVersion = async () => ownVersion(import.meta.url);
126
129
 
127
130
  /** The marketplace manifest, in the shape the client's own directory sources use. */
128
131
  const marketplaceManifest = () => ({
@@ -147,6 +150,11 @@ async function layOutPlugin(target, { runner, node }) {
147
150
  // The bundle's hooks.json names this script, and nothing else writes it.
148
151
  await writeHookShim({ dir: path.join(target, "hooks"), adapterId: "claude_code",
149
152
  runner, node });
153
+ // The copy the client reads says which ACC wrote it. The shipped manifest
154
+ // carries no version, so there is nothing in the repository to fall out of
155
+ // step - which is how every client came to report 0.1.6 while running 0.1.9.
156
+ await stampPluginVersion({ file: path.join(target, ".claude-plugin", "plugin.json"),
157
+ version: await pluginVersion(), io: { readFile, writeFile } });
150
158
  }
151
159
 
152
160
  export async function installClaudePlugin({ configDir, runner, node, now = new Date() }) {
@@ -172,6 +180,11 @@ export async function installClaudePlugin({ configDir, runner, node, now = new D
172
180
  // run `claude plugin install`, exactly as the Codex adapter does, because the
173
181
  // command's only effect is this copy plus the two registry entries below.
174
182
  await layOutPlugin(cached, { runner, node });
183
+ // One copy, the one just written. A client caches a plugin under its version,
184
+ // so every upgrade would otherwise leave the previous release's tree beside
185
+ // this one - invisible while the version never moved, three deep once it did.
186
+ await keepOnlyVersion({ root: path.dirname(cached), version,
187
+ io: { readdir, rm } });
175
188
 
176
189
  await writeClientJson(knownMarketplacesPath(configDir), {
177
190
  ...known,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.6",
4
3
  "description": "Coordinate this Codex session with other AI agent sessions working in the same workspace.",
5
4
  "license": "UNLICENSED",
6
- "keywords": ["coordination", "multi-agent", "claims", "handoff"],
5
+ "keywords": [
6
+ "coordination",
7
+ "multi-agent",
8
+ "claims",
9
+ "handoff"
10
+ ],
7
11
  "skills": "./skills/",
8
12
  "hooks": "./hooks.json",
9
13
  "interface": {
@@ -12,6 +16,9 @@
12
16
  "longDescription": "Attaches this session to a local coordination plane shared with Codex, Claude Code, Gemini CLI, and MCP clients working in the same workspace. Publishes what this session is doing, warns before two sessions edit the same resource, carries typed messages between participants, and records a handoff at the end.",
13
17
  "developerName": "automatis-tools",
14
18
  "category": "Developer Tools",
15
- "capabilities": ["Read", "Write"]
19
+ "capabilities": [
20
+ "Read",
21
+ "Write"
22
+ ]
16
23
  }
17
24
  }
@@ -1,8 +1,9 @@
1
- import { cp, mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
1
+ import { cp, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { bakeSkillCommand, blankJson, blankText, removeIfEmpty, removeInstalledTree,
6
+ keepOnlyVersion, ownVersion, stampPluginVersion,
6
7
  removeTomlBlock, stripBlock, tomlString,
7
8
  writeForeignJson, writeHookShim, writeTomlBlock }
8
9
  from "@agents-can-communicate/adapter-sdk";
@@ -199,11 +200,19 @@ export async function installCodexPlugin({ home, agentsHome = home,
199
200
 
200
201
  // The client runs the cached copy, so this has to happen after the shim and
201
202
  // the rewritten hooks.json are in place.
202
- const { version } = await readJson(
203
- path.join(target, ".codex-plugin", "plugin.json"), { version: "0.0.0" });
203
+ // One version on this machine: the package's own. The shipped manifest carries
204
+ // none, so nothing in the repository can fall out of step with it - which is
205
+ // how every client came to report 0.1.6 while running 0.1.9. The copy the
206
+ // client reads is stamped, so it says which ACC wrote it.
207
+ const version = await ownVersion(import.meta.url);
208
+ await stampPluginVersion({ file: path.join(target, ".codex-plugin", "plugin.json"),
209
+ version, io: { readFile, writeFile } });
204
210
  const cached = cachedVersionPath(codexHome, version);
205
211
  await rm(cached, { recursive: true, force: true });
206
212
  await cp(target, cached, { recursive: true });
213
+ // One copy, the one just written - and only inside this plugin's own
214
+ // directory. The marketplace cache root above it holds other people's plugins.
215
+ await keepOnlyVersion({ root: path.dirname(cached), version, io: { readdir, rm } });
207
216
 
208
217
  // The plugin's own directory in the cache. Not the versioned one inside it,
209
218
  // which goes stale the moment the version changes - and not the marketplace
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.6",
4
3
  "description": "Coordinate this Kimi Code session with other AI agent sessions working in the same workspace: shared presence, resource claims, typed messages, and handoffs.",
5
4
  "skills": "./skills/",
6
5
  "sessionStart": {
@@ -5,6 +5,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { assertRunner, bakeSkillCommand, blankText, defaultRunner, removeIfEmpty,
8
+ ownVersion, stampPluginVersion,
8
9
  removeInstalledTree, runnerExists, writeForeignJson }
9
10
  from "@agents-can-communicate/adapter-sdk";
10
11
 
@@ -132,6 +133,10 @@ export async function installKimiPlugin({ home, runner = defaultRunner(), node }
132
133
  const target = pluginPath(home);
133
134
  await rm(target, { recursive: true, force: true });
134
135
  await cp(bundle, target, { recursive: true });
136
+ // The copy this client reads says which ACC wrote it. Nothing here reads it
137
+ // back, but a manifest that names a version it is not is a lie either way.
138
+ await stampPluginVersion({ file: path.join(target, ".kimi-plugin", "plugin.json"),
139
+ version: await ownVersion(import.meta.url), io: { readFile, writeFile } });
135
140
  // The skill ships with a placeholder where the command belongs: `acc` is
136
141
  // not on PATH everywhere, and an agent that cannot run it improvises.
137
142
  await bakeSkillCommand({ root: target, node });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,6 +9,7 @@ export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, write
9
9
  from "./toml-block.mjs";
10
10
  export { projectContext } from "./context-projector.mjs";
11
11
  export { shellWriteTargets } from "./shell-writes.mjs";
12
+ export { keepOnlyVersion, ownVersion, stampPluginVersion } from "./own-version.mjs";
12
13
  export { editJson, readJson } from "./json-text.mjs";
13
14
  export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEntries, ownedKeys,
14
15
  acccreatedFile, removeIfEmpty, removeOwnedConfig, removeOwnedEntries, writeForeignJson,
@@ -0,0 +1,81 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * The version of the ACC that is running.
7
+ *
8
+ * Every plugin manifest used to carry its own version literal, updated by hand
9
+ * and by nobody: three releases after 0.1.6 the package was 0.1.9 while every
10
+ * client had cached, listed and reported 0.1.6. That is not cosmetic - the
11
+ * version string is how a client decides whether its cached copy is current, so
12
+ * a bundle whose version never changes is one it has no reason to replace.
13
+ *
14
+ * Read by walking up for the nearest `package.json`, which lands on the calling
15
+ * package in a checkout and on the same file once bundled, where every package
16
+ * is versioned together. Answers `0.0.0` rather than throwing: an install that
17
+ * cannot read its own version should still lay a plugin down, under a version
18
+ * that is visibly wrong rather than crash.
19
+ */
20
+ export async function ownVersion(fromUrl) {
21
+ let directory = path.dirname(fileURLToPath(fromUrl));
22
+ for (;;) {
23
+ const candidate = path.join(directory, "package.json");
24
+ const text = await readFile(candidate, "utf8").catch(() => null);
25
+ if (text !== null) {
26
+ try {
27
+ const version = JSON.parse(text).version;
28
+ if (typeof version === "string" && version !== "") return version;
29
+ } catch {
30
+ // A manifest that will not parse is not this package's version.
31
+ }
32
+ }
33
+ const parent = path.dirname(directory);
34
+ if (parent === directory) return "0.0.0";
35
+ directory = parent;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Stamp a laid-out plugin manifest with the version that wrote it.
41
+ *
42
+ * The shipped manifest carries no version of its own, so there is nothing in the
43
+ * repository to fall out of step. Best-effort: a manifest that is not there was
44
+ * not ours to stamp.
45
+ */
46
+ export async function stampPluginVersion({ file, version, io }) {
47
+ const text = await io.readFile(file, "utf8").catch(() => null);
48
+ if (text === null) return false;
49
+ let manifest;
50
+ try {
51
+ manifest = JSON.parse(text);
52
+ } catch {
53
+ return false;
54
+ }
55
+ await io.writeFile(file, `${JSON.stringify({ ...manifest, version }, null, 2)}\n`);
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Leave one copy of a versioned plugin, the one just written.
61
+ *
62
+ * These clients cache a plugin under its version. Until the version tracked the
63
+ * package it never changed, every install landed in the same directory and
64
+ * overwrote itself, and nothing accumulated. Once it started moving, the first
65
+ * upgrade left three copies of ACC in a home that should hold one.
66
+ *
67
+ * Scoped to the plugin's own directory. The marketplace cache root above it
68
+ * holds every plugin installed from that marketplace, and removing that root
69
+ * once took a plugin the user had installed themselves - so a sibling here is
70
+ * an older ACC, and a sibling one level up is somebody else's.
71
+ */
72
+ export async function keepOnlyVersion({ root, version, io }) {
73
+ const entries = await io.readdir(root, { withFileTypes: true }).catch(() => []);
74
+ const removed = [];
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory() || entry.name === version) continue;
77
+ await io.rm(path.join(root, entry.name), { recursive: true, force: true });
78
+ removed.push(entry.name);
79
+ }
80
+ return removed;
81
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,7 +63,10 @@ export const COMMANDS = Object.freeze({
63
63
  // No `--yes`: neither of these ever asked, so the flag agreed to nothing. It
64
64
  // was accepted and read by nobody, which is a promise that a confirmation
65
65
  // exists to be skipped.
66
- install: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
66
+ // `--downgrade` because an older acc first on PATH will otherwise rewire every
67
+ // client to itself, and the only symptom is a guard behaving like the version
68
+ // it came from.
69
+ install: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "downgrade"] },
67
70
  // `--dry-run` on both, because the preview was computed for either action and
68
71
  // only `install` could ask for it. Removal is the side that reaches into a
69
72
  // client's configuration - including a client that has left the machine.
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
@@ -30,11 +30,77 @@ import { diagnoseFilesystemStore, repairFilesystemStore }
30
30
  * Unknown when the install predates the record carrying it, and then nothing is
31
31
  * said: "your plugin might be old" on every run is not a diagnosis.
32
32
  */
33
+ /**
34
+ * The ACC version a client will actually run, read out of its own shim.
35
+ *
36
+ * `staleInstall` compares the version recorded at install time against the one
37
+ * running now. That holds until the thing that rewrote the wiring is an ACC old
38
+ * enough not to know the field: an 0.1.1 first on PATH for one install rewired
39
+ * four clients to itself and rewrote the record with `accVersion: null`, erasing
40
+ * the evidence along with the wiring. The record is written by whoever writes
41
+ * last; the shim carries the absolute path of the runner the client executes,
42
+ * and an old ACC writes it honestly, pointing at itself.
43
+ *
44
+ * Null for anything unreadable. "Might be old" on every run is not a diagnosis.
45
+ */
46
+ export async function wiredVersion(shimPath) {
47
+ if (typeof shimPath !== "string" || shimPath === "") return null;
48
+ const text = await readFile(shimPath, "utf8").catch(() => null);
49
+ if (text === null) return null;
50
+ const runner = /["']?(\/[^"'\s]*\/agents-can-communicate)\/bin\/acc-hook\.mjs["']?/.exec(text);
51
+ if (runner === null) return null;
52
+ const manifest = await readFile(path.join(runner[1], "package.json"), "utf8")
53
+ .catch(() => null);
54
+ if (manifest === null) return null;
55
+ try {
56
+ const version = JSON.parse(manifest).version;
57
+ return typeof version === "string" ? version : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
33
63
  export function staleInstall({ recorded, running }) {
34
64
  if (typeof recorded !== "string" || typeof running !== "string") return null;
35
65
  return recorded === running ? null : { recorded, running };
36
66
  }
37
67
 
68
+ /**
69
+ * The runner version behind whatever ACC wrote for one client.
70
+ *
71
+ * Two shapes, because the four clients differ: a config file ACC merged its hook
72
+ * commands into, and a tree ACC created with a shim inside it. The first
73
+ * readable answer wins, and every read is best-effort - a doctor that throws on
74
+ * a missing file diagnoses nothing.
75
+ */
76
+ async function wiredVersionFor(artifacts) {
77
+ for (const artifact of artifacts ?? []) {
78
+ if (artifact.kind === "tree") {
79
+ for (const shim of await findShims(artifact.path, 4)) {
80
+ const version = await wiredVersion(shim);
81
+ if (version !== null) return version;
82
+ }
83
+ continue;
84
+ }
85
+ const version = await wiredVersion(artifact.path);
86
+ if (version !== null) return version;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** Shim files under a tree ACC created, to a bounded depth. */
92
+ async function findShims(root, depth) {
93
+ if (depth < 0) return [];
94
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
95
+ const found = [];
96
+ for (const entry of entries) {
97
+ const target = path.join(root, entry.name);
98
+ if (entry.isDirectory()) found.push(...await findShims(target, depth - 1));
99
+ else if (entry.name.endsWith(".sh")) found.push(target);
100
+ }
101
+ return found;
102
+ }
103
+
38
104
  async function diagnoseAdapters({ options, runtime }) {
39
105
  // The same home `acc install --home` writes to, or the real one. Reading a
40
106
  // different home than install wrote to reports every adapter as missing.
@@ -64,16 +130,24 @@ async function diagnoseAdapters({ options, runtime }) {
64
130
  if (owned.missing.length > 0) {
65
131
  remediation.push(`acc install --adapter ${entry.adapterId} # files are missing`);
66
132
  }
67
- const stale = staleInstall({
68
- recorded: record.installs.find(install => install.adapterId === entry.adapterId)
69
- ?.accVersion ?? null,
70
- running });
133
+ const installed = record.installs.find(one => one.adapterId === entry.adapterId);
134
+ const stale = staleInstall({ recorded: installed?.accVersion ?? null, running });
71
135
  if (stale !== null) {
72
136
  remediation.push(`acc install --adapter ${entry.adapterId}`
73
137
  + ` # plugin is ${stale.recorded}, acc is ${stale.running}`);
74
138
  }
75
- return { ...entry, stale, owned: { modified: owned.modified, missing: owned.missing,
76
- intact: owned.intact.length }, remediation };
139
+ // Read from the wiring rather than from the record. An ACC old enough not to
140
+ // know the record's version field still rewrites that record - blank - while
141
+ // pointing every client at itself, so the record goes quiet exactly when it
142
+ // matters. The shim names the runner the client will execute.
143
+ const wired = await wiredVersionFor(installed?.artifacts);
144
+ if (stale === null && typeof wired === "string" && typeof running === "string"
145
+ && wired !== running) {
146
+ remediation.push(`acc install --adapter ${entry.adapterId}`
147
+ + ` # wired to acc ${wired}, this is ${running}`);
148
+ }
149
+ return { ...entry, stale, wired, owned: { modified: owned.modified,
150
+ missing: owned.missing, intact: owned.intact.length }, remediation };
77
151
  }));
78
152
  }
79
153
 
@@ -168,10 +168,11 @@ export async function runInstallCommand({ options, runtime, action = "install" }
168
168
  // An uninstall is planned from what ACC recorded writing, not only from what
169
169
  // is on the machine now. A client can be removed after ACC installed into it,
170
170
  // and its configuration directory - with ACC's files in it - stays behind.
171
- const recorded = action === "uninstall"
172
- ? (await loadOwnership({ dataHome })).installs
173
- : [];
174
- const plan = planInstallation({ adapters, detected, context, action, recorded });
171
+ // Read on both actions now. An uninstall plans from it because a client can
172
+ // leave the machine after ACC wrote to it; an install reads it to see which
173
+ // ACC is already wired, so an older copy cannot replace a newer one in
174
+ // silence.
175
+ const recorded = (await loadOwnership({ dataHome })).installs;
175
176
 
176
177
  const dryRun = options.dryRun === true;
177
178
  // Recorded with the install, so a later run can tell that the bundle sitting
@@ -180,6 +181,14 @@ export async function runInstallCommand({ options, runtime, action = "install" }
180
181
  const accVersion = typeof runtime.version === "function"
181
182
  ? await runtime.version().catch(() => null)
182
183
  : null;
184
+ // Naming a client is an answer to the question the version probe was guessing
185
+ // at. `acc install --adapter gemini_cli` says the client is here, whatever
186
+ // PATH this process happens to carry.
187
+ const requested = options.adapter === undefined
188
+ ? []
189
+ : (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
190
+ const plan = planInstallation({ adapters, detected, context, action, recorded,
191
+ accVersion, allowDowngrade: options.downgrade === true, requested });
183
192
  const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
184
193
 
185
194
  const acted = actedOn(result);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -8,7 +8,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
  * it previews is a decoration, and the operator would find out only afterwards.
9
9
  */
10
10
  export function planInstallation({ adapters, detected, context, action = "install",
11
- recorded = [] }) {
11
+ recorded = [], accVersion = null, allowDowngrade = false, requested = [] }) {
12
12
  if (!["install", "uninstall"].includes(action)) {
13
13
  throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
14
14
  }
@@ -17,6 +17,15 @@ export function planInstallation({ adapters, detected, context, action = "instal
17
17
  // authority rather than detection: the record is the only account of what was
18
18
  // written, and a client's configuration directory outlives the client.
19
19
  const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
20
+ // Presence is decided by running the client's `--version`, which answers "can
21
+ // ACC run this client" - not the question that matters, since ACC never runs
22
+ // it: the client runs ACC's hook. A client installed under a different Node
23
+ // version sat there with its own configuration directory while every install
24
+ // reported it missing. Its existence cannot be inferred from that directory,
25
+ // because ACC creates one itself - an earlier attempt at this read a client's
26
+ // own test fixture as proof the client was there. A person naming the client
27
+ // can be.
28
+ const askedFor = new Set(requested ?? []);
20
29
  const operations = [];
21
30
  const skipped = [];
22
31
 
@@ -38,11 +47,30 @@ export function planInstallation({ adapters, detected, context, action = "instal
38
47
  ? recordedById.get(entry.adapterId)
39
48
  : undefined;
40
49
 
41
- if (!entry.present && record === undefined) {
50
+ if (!entry.present && record === undefined && !askedFor.has(entry.adapterId)) {
42
51
  // Named rather than dropped: "nothing happened" and "that client is not
43
- // installed on this machine" look the same in an empty list.
52
+ // installed on this machine" look the same in an empty list. And the
53
+ // message names the way past itself, because the verdict is on PATH
54
+ // rather than on the machine.
44
55
  skipped.push({ adapterId: entry.adapterId,
45
- reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine` });
56
+ reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine; `
57
+ + `if it is, wire it with --adapter ${entry.adapterId}` });
58
+ continue;
59
+ }
60
+ // The version doing the installing is whichever `acc` came first on PATH,
61
+ // and the shim it writes pins that copy's node and runner. So a second ACC
62
+ // on the machine quietly replaces every client's wiring with its own, older
63
+ // code, and the only symptom is a guard behaving like the version it came
64
+ // from. `recordInstall` has always written `accVersion` for exactly this
65
+ // comparison; nothing read it in this direction until now.
66
+ //
67
+ // Never on an uninstall: the older ACC is often the only thing that knows
68
+ // what it wrote, and refusing it would strand the wiring this undoes.
69
+ const wired = recordedById.get(entry.adapterId)?.accVersion;
70
+ if (action === "install" && !allowDowngrade && isOlder(accVersion, wired)) {
71
+ skipped.push({ adapterId: entry.adapterId,
72
+ reason: `${wired} is already wired here and this is ${accVersion}; `
73
+ + "run the newer acc, or pass --downgrade to wire this one deliberately" });
46
74
  continue;
47
75
  }
48
76
  if (record === undefined && typeof adapter.planInstall !== "function") {
@@ -83,3 +111,29 @@ export function planInstallation({ adapters, detected, context, action = "instal
83
111
  }
84
112
  return { schemaVersion: 1, action, operations, skipped };
85
113
  }
114
+
115
+ /**
116
+ * Is `candidate` an earlier release than `installed`?
117
+ *
118
+ * Numeric per segment, because the first comparison that matters in practice is
119
+ * 0.1.9 against 0.1.10, where a string comparison reverses the answer and starts
120
+ * refusing every legitimate install. Anything unreadable answers false: a
121
+ * corrupt record must not make the machine unrepairable by the command that
122
+ * exists to repair it.
123
+ */
124
+ function isOlder(candidate, installed) {
125
+ const parse = value => {
126
+ if (typeof value !== "string") return null;
127
+ const parts = value.trim().split(".");
128
+ if (parts.length !== 3) return null;
129
+ const numbers = parts.map(part => (/^\d+$/.test(part) ? Number(part) : null));
130
+ return numbers.includes(null) ? null : numbers;
131
+ };
132
+ const left = parse(candidate);
133
+ const right = parse(installed);
134
+ if (left === null || right === null) return false;
135
+ for (let index = 0; index < 3; index += 1) {
136
+ if (left[index] !== right[index]) return left[index] < right[index];
137
+ }
138
+ return false;
139
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [