agents-can-communicate 0.1.1 → 0.1.3

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 (33) hide show
  1. package/README.md +31 -2
  2. package/bin/acc.mjs +8 -1
  3. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  4. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  6. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  7. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +65 -21
  8. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +28 -1
  14. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/cli/src/args.mjs +8 -2
  16. package/node_modules/@agents-can-communicate/cli/src/confirm.mjs +33 -0
  17. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +58 -7
  18. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  19. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  20. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +67 -5
  21. package/node_modules/@agents-can-communicate/cli/src/main.mjs +11 -2
  22. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +15 -2
  23. package/node_modules/@agents-can-communicate/cli/src/update-check.mjs +113 -0
  24. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +86 -0
  25. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
  29. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +9 -2
  30. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -75,13 +75,42 @@ npm install -g agents-can-communicate
75
75
 
76
76
  Then wire up the clients you have:
77
77
 
78
+ ```bash
79
+ acc install
80
+ ```
81
+
82
+ It names every file it wrote, in your own home-relative paths, and how to undo it. Open your
83
+ clients in the project afterwards — in one directory or in several worktrees — and work
84
+ normally.
85
+
86
+ If you would rather look before it writes, `acc install --dry-run` prints the same list and
87
+ changes nothing. `acc uninstall` takes it all back out.
88
+
78
89
  <!-- test:command -->
79
90
  ```bash
80
91
  acc install --dry-run
81
92
  ```
82
93
 
83
- This prints every file it would touch. Run `acc install` to apply it, then open your clients
84
- in the project — in one directory or in several worktrees — and work normally.
94
+ ## Keeping it current
95
+
96
+ ```bash
97
+ acc update # asks npm; --apply installs it and re-wires the clients
98
+ ```
99
+
100
+ An upgrade is two steps, because it lands in two places. `npm install -g` replaces the CLI
101
+ and the hook runtime — a client runs the runtime out of the npm directory rather than a copy
102
+ — and leaves the bundle written into that client alone, including the skills the agents
103
+ read. `acc install` refreshes it, and `acc doctor` says so when the two disagree:
104
+
105
+ ```console
106
+ $ acc doctor
107
+ store healthy; 2 live session(s); protection guarded; 3 of 4 adapter(s) installed
108
+ acc install --adapter claude_code # plugin is 0.1.1, acc is 0.2.0
109
+ ```
110
+
111
+ `acc update` is the only command that reaches the network. `acc doctor` reads what it
112
+ remembered and asks at most once a day; nothing on the hook path ever asks, since a hook
113
+ runs on every turn inside a five-second budget. `ACC_NO_UPDATE_CHECK=1` turns both off.
85
114
 
86
115
  ## Commands
87
116
 
package/bin/acc.mjs CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
 
5
5
  import { createId } from "@agents-can-communicate/protocol";
6
- import { main } from "@agents-can-communicate/cli";
6
+ import { askConfirmation, main } from "@agents-can-communicate/cli";
7
7
 
8
8
  // The composition root is the only place allowed to reach for ambient time and
9
9
  // randomness; everything below it receives them as ports.
@@ -15,6 +15,13 @@ const runtime = {
15
15
  stderr: process.stderr,
16
16
  clock: { now: () => new Date().toISOString() },
17
17
  ids: { next: kind => createId(kind, randomBytes) },
18
+ // Asked only by `acc config init`, and only when stdout is a terminal. There
19
+ // was no port here at all, so the question went to the fallback that always
20
+ // answers no: in a real terminal the command printed "not written" and never
21
+ // said why, and `--yes` - the flag documented for runs with nobody to ask -
22
+ // was the only way to write the file.
23
+ confirm: question => askConfirmation(question,
24
+ { input: process.stdin, output: process.stdout }),
18
25
  // Asked for only by `acc version`, so a package missing its own manifest
19
26
  // fails that one command rather than every command.
20
27
  version: async () => JSON.parse(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "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
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Coordinate this Codex session with other AI agent sessions working in the same workspace.",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": ["coordination", "multi-agent", "claims", "handoff"],
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { cp, mkdir, 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
 
@@ -11,22 +11,35 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
11
11
  const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
12
12
  const PLUGIN_NAME = "agents-can-communicate";
13
13
 
14
- // The marketplace ACC owns. Registering a separate one rather than editing the
15
- // user's keeps the two apart: uninstall removes a marketplace ACC created and
16
- // never touches entries someone else put in theirs.
14
+ // The marketplace ACC owns, and its own root inside the agents home.
15
+ //
16
+ // Registering a separate one rather than joining the user's keeps the two
17
+ // apart. ACC used to write into `<home>/.agents/plugins/marketplace.json` -
18
+ // which is the marketplace this client discovers by itself, with no config
19
+ // entry at all, under whatever that manifest calls itself. So ACC merged its
20
+ // entry into someone else's marketplace and then enabled `…@acc-local`, an id
21
+ // this client never forms: `acc install` reported success and `codex plugin
22
+ // list` said `not installed`. Measured against Codex 0.147.0, then measured
23
+ // again to confirm a root of ACC's own is accepted and reported enabled.
17
24
  const MARKETPLACE = "acc-local";
18
25
  const QUALIFIED = `${PLUGIN_NAME}@${MARKETPLACE}`;
19
26
 
20
- // A marketplace is a directory whose manifest sits at
21
- // `<root>/.agents/plugins/marketplace.json`, and every `source.path` in that
22
- // manifest is relative to the manifest's own directory - `./plugins/<name>`,
23
- // as the client's own entries are written. Resolving the plugin from `root`
24
- // instead put the files two levels above where the manifest pointed, so the
25
- // entry named a directory that did not exist and the client loaded nothing.
26
- const marketplaceDir = root => path.join(root, ".agents", "plugins");
27
- const marketplacePath = root => path.join(marketplaceDir(root), "marketplace.json");
28
- const pluginPath = (root, name = PLUGIN_NAME) =>
29
- path.join(marketplaceDir(root), "plugins", name);
27
+ // A marketplace is a root holding `.agents/plugins/marketplace.json`, and every
28
+ // `source.path` in that manifest - `./plugins/<name>` - is resolved by this
29
+ // client against the *root*, not against the manifest's directory. Measured:
30
+ // `codex plugin list` prints the path it resolved, and for a plugin the user
31
+ // installed themselves it printed `<root>/plugins/x` from an entry spelled
32
+ // `./plugins/x`. The comment that used to be here said the opposite, and ACC
33
+ // wrote its tree two directories below where the client then looked.
34
+ //
35
+ // The root is under `.agents/` rather than the home itself: `<home>/plugins/`
36
+ // is where this client would put it, and nothing of ACC's belongs at the top of
37
+ // somebody's home.
38
+ const marketplaceRoot = agentsHome => path.join(agentsHome, ".agents", MARKETPLACE);
39
+ const marketplacePath = agentsHome =>
40
+ path.join(marketplaceRoot(agentsHome), ".agents", "plugins", "marketplace.json");
41
+ const pluginPath = (agentsHome, name = PLUGIN_NAME) =>
42
+ path.join(marketplaceRoot(agentsHome), "plugins", name);
30
43
  const configPath = codexHome => path.join(codexHome, "config.toml");
31
44
  // Where `codex plugin add` leaves the copy it actually runs. All three
32
45
  // components are ACC's own - the marketplace it created, the plugin name it
@@ -111,6 +124,11 @@ export async function installCodexPlugin({ home, agentsHome = home,
111
124
  // plugin tree is laid down that nothing will then be able to remove.
112
125
  const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
113
126
  interface: { displayName: "Agents Can Communicate" }, plugins: [] });
127
+ // The name in the manifest, which is the one this client forms plugin ids
128
+ // from. ACC used its own regardless, so on a machine that already had a
129
+ // marketplace at this root - discovered without any config entry, under
130
+ // whatever its manifest calls itself - the id ACC enabled was one the client
131
+ // never forms, and the plugin sat there listed and not installed.
114
132
  const before = await readFile(configPath(codexHome), "utf8").catch(() => "");
115
133
 
116
134
  const target = pluginPath(agentsHome);
@@ -142,7 +160,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
142
160
  await writeTomlBlock(config, [
143
161
  `[marketplaces.${MARKETPLACE}]`,
144
162
  `source_type = "local"`,
145
- `source = ${tomlString(agentsHome)}`,
163
+ `source = ${tomlString(marketplaceRoot(agentsHome))}`,
146
164
  "",
147
165
  `[plugins.${tomlString(QUALIFIED)}]`,
148
166
  "enabled = true",
@@ -156,13 +174,26 @@ export async function installCodexPlugin({ home, agentsHome = home,
156
174
  await rm(cached, { recursive: true, force: true });
157
175
  await cp(target, cached, { recursive: true });
158
176
 
159
- // The cache *root* rather than the versioned directory inside it: that is
160
- // what ACC owns and what uninstall removes, and reporting the version would
177
+ // The plugin's own directory in the cache. Not the versioned one inside it,
178
+ // which goes stale the moment the version changes - and not the marketplace
179
+ // cache root above it, which belongs to whoever's marketplace this is: that
180
+ // root holds every plugin installed from it, and removing it took a plugin
181
+ // the user had installed themselves. Measured, on a real machine.
182
+ //
183
+ // The old comment here said the root was "what ACC owns", which was true only
184
+ // while ACC invented its own marketplace name and so had a root to itself.
161
185
  // make the record stale the moment the plugin version changes.
162
- return { ok: true, changes: [target, file, config, cacheRoot(codexHome)],
186
+ return { ok: true, changes: [target, file, config, cachePath(codexHome)],
163
187
  diagnostics: ["hooks require explicit trust in Codex before they run"] };
164
188
  }
165
189
 
190
+ /** Remove each directory that is empty, in the order given. */
191
+ async function removeEmptyDirs(directories) {
192
+ for (const directory of directories) {
193
+ await rmdir(directory).catch(() => {});
194
+ }
195
+ }
196
+
166
197
  export async function uninstallCodexPlugin({ home, agentsHome = home,
167
198
  codexHome = path.join(home, ".codex"), keep = [] }) {
168
199
  const file = marketplacePath(agentsHome);
@@ -190,8 +221,20 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
190
221
  return value?.name === MARKETPLACE && (value.plugins ?? []).length === 0;
191
222
  } });
192
223
 
193
- await removeInstalledTree(cacheRoot(codexHome), keep);
224
+ await removeInstalledTree(cachePath(codexHome), keep);
194
225
  await removeInstalledTree(pluginPath(agentsHome), keep);
226
+ // The directories ACC made to hold those, once nothing is in them. They are
227
+ // ACC's own - a marketplace root it created and the cache directory named
228
+ // after it - and an empty one left behind is litter in a home that did not
229
+ // have it. Anything the user put inside stops this: the directory is not
230
+ // empty, and it stays.
231
+ await removeEmptyDirs([
232
+ path.join(marketplaceRoot(agentsHome), "plugins"),
233
+ path.dirname(marketplacePath(agentsHome)),
234
+ path.dirname(path.dirname(marketplacePath(agentsHome))),
235
+ marketplaceRoot(agentsHome),
236
+ cacheRoot(codexHome),
237
+ ]);
195
238
  return { ok: true, changes, diagnostics: [] };
196
239
  }
197
240
 
@@ -202,7 +245,8 @@ export async function detectCodex({ home, agentsHome = home,
202
245
  const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
203
246
  const registered = config.includes(`[marketplaces.${MARKETPLACE}]`);
204
247
  const enabled = config.includes(`[plugins."${QUALIFIED}"]`);
205
- const cached = await stat(cachePath(codexHome)).then(() => true).catch(() => false);
248
+ const cached = await stat(cachePath(codexHome))
249
+ .then(() => true).catch(() => false);
206
250
  return { ok: true, changes: [], diagnostics: [
207
251
  published ? "acc plugin published in the marketplace" : "acc plugin not registered",
208
252
  registered && enabled
@@ -228,7 +272,7 @@ export function planCodexInstall({ home, agentsHome = home,
228
272
  codexHome = path.join(home, ".codex") }) {
229
273
  return [
230
274
  { path: pluginPath(agentsHome), kind: "tree" },
231
- { path: cacheRoot(codexHome), kind: "tree" },
275
+ { path: cachePath(codexHome), kind: "tree" },
232
276
  { path: marketplacePath(agentsHome), kind: "merge" },
233
277
  { path: configPath(codexHome), kind: "merge" },
234
278
  ];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "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
5
  "skills": "./skills/",
6
6
  "sessionStart": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -87,7 +87,34 @@ export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
87
87
  "# Generated by ACC at install time. The paths are pinned deliberately:",
88
88
  "# a hook runs with an environment that may carry neither PATH nor a shell",
89
89
  "# profile, and a command that cannot be found fails silently on every event.",
90
- `exec ${quote(node)} ${quote(runner)} ${adapterId} "$@"`,
90
+ "#",
91
+ "# Pinned first, and then asked for, because the pinned pair moves. A node",
92
+ "# version manager changes both the interpreter and the directory global",
93
+ "# packages live in, and a shim naming the old ones failed on every event",
94
+ "# with nothing to read but exit 126 - no presence, no claims, no messages,",
95
+ "# and nothing anywhere saying why.",
96
+ `ACC_NODE=${quote(node)}`,
97
+ `ACC_RUNNER=${quote(runner)}`,
98
+ 'if [ -x "$ACC_NODE" ] && [ -f "$ACC_RUNNER" ]; then',
99
+ ` exec "$ACC_NODE" "$ACC_RUNNER" ${adapterId} "$@"`,
100
+ "fi",
101
+ "# Whatever node is current, if this package is installed under it: `acc-hook`",
102
+ "# is the binary npm links, so a reinstall is found without knowing where.",
103
+ "# It needs node too - its shebang is `env node` - and `exec` that fails ends",
104
+ "# this script where it stands, taking the fallbacks below with it: measured",
105
+ "# as exit 127 and `env: node: No such file or directory`, in place of the",
106
+ "# line that says what to do.",
107
+ "if command -v acc-hook >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then",
108
+ ` exec acc-hook ${adapterId} "$@"`,
109
+ "fi",
110
+ 'if [ -f "$ACC_RUNNER" ] && command -v node >/dev/null 2>&1; then',
111
+ ` exec node "$ACC_RUNNER" ${adapterId} "$@"`,
112
+ "fi",
113
+ "# Say so, and let the turn continue. Hooks fail open by design, and a broken",
114
+ "# install must not be the reason somebody's session stops working.",
115
+ 'echo "acc: the hook runner installed here is gone (node: $ACC_NODE)." >&2',
116
+ 'echo "acc: run \'acc install\' to wire this client to the acc you have now." >&2',
117
+ "exit 0",
91
118
  "",
92
119
  ].join("\n"));
93
120
  await chmod(target, 0o755);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,11 +56,17 @@ export const COMMANDS = Object.freeze({
56
56
  // inside a handler that has already decided what to do.
57
57
  config: { required: [], optional: [], flags: ["yes", "force"],
58
58
  subcommands: ["init", "validate"] },
59
- install: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "yes"] },
59
+ // No `--yes`: neither of these ever asked, so the flag agreed to nothing. It
60
+ // was accepted and read by nobody, which is a promise that a confirmation
61
+ // exists to be skipped.
62
+ install: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
60
63
  // `--dry-run` on both, because the preview was computed for either action and
61
64
  // only `install` could ask for it. Removal is the side that reaches into a
62
65
  // client's configuration - including a client that has left the machine.
63
- uninstall: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "yes"] },
66
+ uninstall: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
67
+ // Asking npm whether there is a newer ACC. The one command that touches the
68
+ // network, and never on the hook path.
69
+ update: { required: [], optional: [], flags: ["apply"] },
64
70
  // The two things a person types first after installing from a registry. The
65
71
  // CLI answered neither: `acc --version` and `acc --help` were both "unknown
66
72
  // command", and `acc` on its own asked for a command without naming one.
@@ -0,0 +1,33 @@
1
+ import { once } from "node:events";
2
+ import { createInterface } from "node:readline/promises";
3
+
4
+ /**
5
+ * Ask a yes-or-no question and wait for the answer.
6
+ *
7
+ * A port rather than a call to the terminal, so the composition root hands it
8
+ * in and a test hands in two streams instead. There was no port at all:
9
+ * `runtime.confirm` fell back to a function that always answered no, so `acc
10
+ * config init` in a real terminal printed `not written` and never said why -
11
+ * and `--yes`, documented for runs with nobody to ask, was the only way to
12
+ * write the file.
13
+ *
14
+ * Anything that is not yes is no. A confirmation that reads a stray newline as
15
+ * agreement is not a confirmation.
16
+ */
17
+ export async function askConfirmation(question, { input, output }) {
18
+ const dialogue = createInterface({ input, output });
19
+ try {
20
+ const asked = dialogue.question(`${question}\n[y/N] `);
21
+ // The input closing before an answer arrives is the reader leaving - Ctrl+D,
22
+ // or a pipe that ended - which is a refusal rather than a failure. Raced
23
+ // rather than caught: the question simply never settles on a stream that
24
+ // ends, so waiting for it alone hangs.
25
+ asked.catch(() => {});
26
+ const answer = await Promise.race([asked, once(dialogue, "close").then(() => "")]);
27
+ return /^y(es)?$/i.test(answer.trim());
28
+ } catch {
29
+ return false;
30
+ } finally {
31
+ dialogue.close();
32
+ }
33
+ }
@@ -1,10 +1,13 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
2
3
 
3
- import { detectInstallation, verifyOwned } from "@agents-can-communicate/installer";
4
+ import { detectInstallation, loadOwnership, verifyOwned }
5
+ from "@agents-can-communicate/installer";
4
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
7
 
6
- import { ALL_ADAPTERS, clientContext } from "./install-command.mjs";
8
+ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
7
9
  import { platformPaths } from "./platform-paths.mjs";
10
+ import { noticeUpdate } from "./update-check.mjs";
8
11
  import { diagnoseFilesystemStore, repairFilesystemStore }
9
12
  from "@agents-can-communicate/storage-filesystem";
10
13
 
@@ -13,6 +16,23 @@ import { diagnoseFilesystemStore, repairFilesystemStore }
13
16
  * closed: anything blocked or corrupt stops the run rather than being repaired
14
17
  * on top of state the tool cannot even read.
15
18
  */
19
+ /**
20
+ * The bundle in a client outlives the package that put it there.
21
+ *
22
+ * `npm install -g` replaces this CLI and the hook runtime - the shim runs the
23
+ * runtime out of the npm directory rather than a copy - and does not touch what
24
+ * was written into the client: its `hooks.json`, and the skills the agents read.
25
+ * Measured: after an upgrade the client still had `0.1.0` while `acc --version`
26
+ * said `0.1.1`, and doctor called it healthy.
27
+ *
28
+ * Unknown when the install predates the record carrying it, and then nothing is
29
+ * said: "your plugin might be old" on every run is not a diagnosis.
30
+ */
31
+ export function staleInstall({ recorded, running }) {
32
+ if (typeof recorded !== "string" || typeof running !== "string") return null;
33
+ return recorded === running ? null : { recorded, running };
34
+ }
35
+
16
36
  async function diagnoseAdapters({ options, runtime }) {
17
37
  // The same home `acc install --home` writes to, or the real one. Reading a
18
38
  // different home than install wrote to reports every adapter as missing.
@@ -21,7 +41,12 @@ async function diagnoseAdapters({ options, runtime }) {
21
41
  const { data: dataHome } = platformPaths({ platform: runtime?.platform,
22
42
  env: runtime?.env ?? {} });
23
43
  const adapters = ALL_ADAPTERS();
24
- const detected = await detectInstallation({ adapters, context: clients });
44
+ const detected = await detectInstallation({ adapters, context: clients,
45
+ probeTimeoutMs: probeTimeout(runtime?.env) });
46
+ const record = await loadOwnership({ dataHome });
47
+ const running = typeof runtime?.version === "function"
48
+ ? await runtime.version().catch(() => null)
49
+ : null;
25
50
 
26
51
  return Promise.all(detected.map(async entry => {
27
52
  // Compared against what ACC recorded writing, so a plugin someone has since
@@ -37,7 +62,15 @@ async function diagnoseAdapters({ options, runtime }) {
37
62
  if (owned.missing.length > 0) {
38
63
  remediation.push(`acc install --adapter ${entry.adapterId} # files are missing`);
39
64
  }
40
- return { ...entry, owned: { modified: owned.modified, missing: owned.missing,
65
+ const stale = staleInstall({
66
+ recorded: record.installs.find(install => install.adapterId === entry.adapterId)
67
+ ?.accVersion ?? null,
68
+ running });
69
+ if (stale !== null) {
70
+ remediation.push(`acc install --adapter ${entry.adapterId}`
71
+ + ` # plugin is ${stale.recorded}, acc is ${stale.running}`);
72
+ }
73
+ return { ...entry, stale, owned: { modified: owned.modified, missing: owned.missing,
41
74
  intact: owned.intact.length }, remediation };
42
75
  }));
43
76
  }
@@ -48,6 +81,14 @@ export async function runDoctor({ options, context, runtime }) {
48
81
  ? await repairFilesystemStore({ root, clock: context.service.clock })
49
82
  : await diagnoseFilesystemStore({ root });
50
83
  const adapters = await diagnoseAdapters({ options, runtime });
84
+ const { data: dataHome } = platformPaths({ platform: runtime?.platform,
85
+ env: runtime?.env ?? {} });
86
+ const running = typeof runtime?.version === "function"
87
+ ? await runtime.version().catch(() => null)
88
+ : null;
89
+ const update = await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
90
+ now: Date.parse(context.service.clock.now()), get: runtime?.fetch,
91
+ io: { readFile, writeFile, mkdir } });
51
92
 
52
93
  // Before the store is read for anything else. `collectStatus` reads every
53
94
  // record, so on the store this command exists to describe it threw first and
@@ -76,10 +117,20 @@ export async function runDoctor({ options, context, runtime }) {
76
117
  store: report,
77
118
  // Capabilities are reported from what is actually installed, never assumed.
78
119
  adapters,
79
- remediation: adapters.flatMap(adapter => adapter.remediation),
120
+ update,
121
+ remediation: [...adapters.flatMap(adapter => adapter.remediation),
122
+ // Said here rather than in its own line of prose, because this list is
123
+ // what a reader acts on and an upgrade is one more thing to run.
124
+ ...(update.newer ? [`acc update --apply # ${update.latest} is on npm, `
125
+ + `you have ${running}`] : [])],
80
126
  };
81
127
  const installed = adapters.filter(adapter => adapter.installed).length;
82
- const text = `store healthy; ${status.counts.live} live session(s); `
83
- + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`;
128
+ // The remediation was computed, put in the data, and never printed: the
129
+ // command documented as saying "what to run next" said it only to `--json`.
130
+ // A person running `acc doctor` on a client wired to an older plugin was told
131
+ // the store was healthy and nothing else.
132
+ const text = [`store healthy; ${status.counts.live} live session(s); `
133
+ + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
134
+ ...data.remediation.map(line => ` ${line}`)].join("\n");
84
135
  return { data, text };
85
136
  }
@@ -16,7 +16,7 @@ const GROUPS = Object.freeze([
16
16
  ["In a session", ["status", "sync", "work", "claim", "release", "ack", "message",
17
17
  "request", "task", "workstream", "decide", "finish"]],
18
18
  ["Driven by adapters, not by people", ["attach", "heartbeat", "detach"]],
19
- ["About acc", ["help", "version"]],
19
+ ["About acc", ["help", "version", "update"]],
20
20
  ]);
21
21
 
22
22
  const SUMMARY = Object.freeze({
@@ -41,6 +41,7 @@ const SUMMARY = Object.freeze({
41
41
  detach: "close a session",
42
42
  help: "this list",
43
43
  version: "print the version that is installed",
44
+ update: "ask npm whether a newer acc exists; --apply installs it",
44
45
  });
45
46
 
46
47
  /** The same list `acc help --json` returns, so a tool can read it too. */
@@ -1,6 +1,7 @@
1
1
  // Composition root: discovery, runtime locations, and the CLI surface.
2
2
  export { main } from "./main.mjs";
3
3
  export { COMMANDS, parseArgs } from "./args.mjs";
4
+ export { askConfirmation } from "./confirm.mjs";
4
5
  // Exported so a test can hold every adapter to where it plans to write and
5
6
  // which binary decides it runs at all.
6
7
  export { ALL_ADAPTERS, clientContext } from "./install-command.mjs";
@@ -28,6 +28,20 @@ export const clientContext = home => ({
28
28
  export const ALL_ADAPTERS = () => [createClaudeCodeAdapter(), createCodexAdapter(),
29
29
  createGeminiCliAdapter(), createKimiAdapter()];
30
30
 
31
+ /**
32
+ * How long to wait for a client to say its version.
33
+ *
34
+ * Detection spawns the client's own binary, and three seconds is generous on an
35
+ * idle machine and not always enough on a busy one: a cold start that overruns
36
+ * it makes an installed client look absent, and the installer skips it saying so
37
+ * in as many words. Raising it is for the machine that needs it - a loaded CI
38
+ * runner, a slow disk - rather than a default nobody can change.
39
+ */
40
+ export function probeTimeout(env) {
41
+ const asked = Number.parseInt(env?.ACC_PROBE_TIMEOUT_MS ?? "", 10);
42
+ return Number.isFinite(asked) && asked > 0 ? asked : undefined;
43
+ }
44
+
31
45
  function selectAdapters(requested) {
32
46
  const all = ALL_ADAPTERS();
33
47
  if (requested === undefined) return all;
@@ -60,11 +74,52 @@ function selectAdapters(requested) {
60
74
  * carries one per skipped adapter and the result one per failure - and only
61
75
  * `--json` ever showed them.
62
76
  */
63
- export function describeOutcome({ action, acted, failed = [], skipped = [] }) {
77
+ const shorten = (file, home) =>
78
+ typeof home === "string" && home !== "" && file.startsWith(`${home}/`)
79
+ ? `~${file.slice(home.length)}`
80
+ : file;
81
+
82
+ /**
83
+ * What one adapter did, path by path.
84
+ *
85
+ * `installed 3 adapter(s)` was the whole account of a command that had just
86
+ * written into three other tools' configuration inside someone's home. The list
87
+ * existed all along - it is what `--dry-run` prints - and the run that actually
88
+ * did the work printed a number.
89
+ *
90
+ * An install says what it wrote from the plan it carried out, in the same words
91
+ * the preview uses. An uninstall says what was removed and what was held back,
92
+ * because those are decided while it runs: bytes that stopped matching what ACC
93
+ * wrote are someone's now, and are kept.
94
+ */
95
+ export function describeChanges(operation, home) {
96
+ const artifacts = operation.artifacts ?? [];
97
+ const edited = artifacts.filter(artifact => artifact.kind === "merge")
98
+ .map(artifact => ` edited ${shorten(artifact.path, home)}`);
99
+ if (operation.action !== "uninstall") {
100
+ return [...artifacts.filter(artifact => artifact.kind !== "merge")
101
+ .map(artifact => ` created ${shorten(artifact.path, home)}`), ...edited];
102
+ }
103
+ return [
104
+ ...(operation.removed ?? []).map(file => ` removed ${shorten(file, home)}`),
105
+ ...edited,
106
+ ...(operation.kept ?? [])
107
+ .map(file => ` kept ${shorten(file, home)} - changed since ACC wrote it`),
108
+ ];
109
+ }
110
+
111
+ export function describeOutcome({ action, acted, failed = [], skipped = [],
112
+ operations = [], home }) {
64
113
  return [`${action}ed ${acted} adapter(s)`
65
114
  + (failed.length > 0 ? `; ${failed.length} failed` : ""),
115
+ ...operations.filter(operation => operation.applied)
116
+ .flatMap(operation => describeChanges(operation, home)),
66
117
  ...skipped.map(entry => ` skip ${entry.adapterId}: ${entry.reason}`),
67
- ...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`)].join("\n");
118
+ ...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`),
119
+ // Said once, where it is needed: the reader has just been shown a list of
120
+ // their own files with ACC's name in them.
121
+ ...(action === "install" && acted > 0 ? ["", "undo with: acc uninstall"] : []),
122
+ ].join("\n");
68
123
  }
69
124
 
70
125
  /**
@@ -102,7 +157,8 @@ export async function runInstallCommand({ options, runtime, action = "install" }
102
157
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
103
158
  env: runtime.env ?? {} });
104
159
 
105
- const detected = await detectInstallation({ adapters, context });
160
+ const detected = await detectInstallation({ adapters, context,
161
+ probeTimeoutMs: probeTimeout(runtime.env) });
106
162
  // An uninstall is planned from what ACC recorded writing, not only from what
107
163
  // is on the machine now. A client can be removed after ACC installed into it,
108
164
  // and its configuration directory - with ACC's files in it - stays behind.
@@ -112,7 +168,13 @@ export async function runInstallCommand({ options, runtime, action = "install" }
112
168
  const plan = planInstallation({ adapters, detected, context, action, recorded });
113
169
 
114
170
  const dryRun = options.dryRun === true;
115
- const result = await applyPlan({ plan, adapters, context, dataHome, dryRun });
171
+ // Recorded with the install, so a later run can tell that the bundle sitting
172
+ // in a client is older than the code now running. Updating the npm package
173
+ // replaces this CLI and the hook runtime and leaves that bundle untouched.
174
+ const accVersion = typeof runtime.version === "function"
175
+ ? await runtime.version().catch(() => null)
176
+ : null;
177
+ const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
116
178
 
117
179
  const acted = actedOn(result);
118
180
  if (dryRun) {
@@ -123,6 +185,6 @@ export async function runInstallCommand({ options, runtime, action = "install" }
123
185
 
124
186
  return { data: { ...result, plan, dataHome },
125
187
  text: describeOutcome({ action, acted, failed: result.failed,
126
- skipped: plan.skipped }),
188
+ skipped: plan.skipped, operations: result.operations, home }),
127
189
  error: failureOf({ action, acted, failed: result.failed }) };
128
190
  }
@@ -9,6 +9,7 @@ import { parseArgs, positiveNumber } from "./args.mjs";
9
9
  // with the argument already half-applied.
10
10
  const usage = message => new AccError(EXIT.USAGE, message);
11
11
  import { describeCommands, helpText } from "./help.mjs";
12
+ import { runUpdateCommand } from "./update-command.mjs";
12
13
  import { runConfigCommand } from "./config-command.mjs";
13
14
  import { runInstallCommand } from "./install-command.mjs";
14
15
  import { runDoctor } from "./doctor-command.mjs";
@@ -252,7 +253,12 @@ const HANDLERS = Object.freeze({
252
253
  // command demands --yes rather than hanging or assuming consent.
253
254
  interactive: runtime.stdout?.isTTY === true,
254
255
  yes: options.yes === true,
255
- confirm: runtime.confirm ?? (async () => false),
256
+ // Not a silent no. A build that cannot ask says so: falling back to
257
+ // "declined" is how `acc config init` came to print `not written` in a
258
+ // terminal where nobody had been asked anything.
259
+ confirm: runtime.confirm ?? (async () => {
260
+ throw new AccError(EXIT.DATA, "this build was assembled without a way to ask");
261
+ }),
256
262
  force: options.force === true,
257
263
  // Opened lazily and only by `init`: `config validate` has to work on a
258
264
  // workspace discovery cannot open, which is what a reader runs it to
@@ -275,6 +281,8 @@ const HANDLERS = Object.freeze({
275
281
 
276
282
  doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
277
283
 
284
+ update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
285
+
278
286
  help: async () => ({ data: { commands: describeCommands() }, text: helpText() }),
279
287
 
280
288
  version: async ({ runtime }) => {
@@ -294,7 +302,8 @@ const HANDLERS = Object.freeze({
294
302
  /**
295
303
  * @returns {Promise<number>} the process exit code
296
304
  */
297
- const NO_WORKSPACE = Object.freeze(["config", "install", "uninstall", "help", "version"]);
305
+ const NO_WORKSPACE = Object.freeze(["config", "install", "uninstall", "help", "version",
306
+ "update"]);
298
307
 
299
308
  export async function main(argv, runtime) {
300
309
  const write = (stream, text) => new Promise((resolve, reject) =>
@@ -23,11 +23,24 @@ export function runtimePaths({ dataHome, workspaceId, workspaceRoots = [] }) {
23
23
  // Enforced here rather than only asserted in a test, because "just put it in
24
24
  // .agents next to the project" is the exact regression this design exists to
25
25
  // prevent, and it would otherwise look like it works.
26
+ //
27
+ // The message names both paths and what to do, because the case a person
28
+ // actually meets is not the one this was written for. Running `acc` in a home
29
+ // directory makes that directory the workspace - it is no checkout, so
30
+ // discovery falls back to where you are - and the platform's own state
31
+ // directory is inside a home by definition. So `acc status` in `~` answered
32
+ // "runtime state must not live inside the workspace", which reads as a
33
+ // misconfiguration and tells the reader nothing they can act on.
26
34
  for (const workspaceRoot of workspaceRoots) {
27
35
  const relative = path.relative(workspaceRoot, root);
28
36
  if (relative === "" || (!path.isAbsolute(relative) && !relative.startsWith(".."))) {
29
- throw new AccError(EXIT.USAGE, "runtime state must not live inside the workspace",
30
- { root, workspaceRoot });
37
+ throw new AccError(EXIT.USAGE,
38
+ // The data home rather than the workspace's own directory inside it:
39
+ // that is the one a reader can move, and the one the remedy names.
40
+ `${workspaceRoot} holds ACC's own state at ${path.join(dataHome, "acc")}, `
41
+ + "so it cannot be a workspace. Run acc inside a project, or point "
42
+ + "ACC_DATA_HOME outside this directory.",
43
+ { root, dataHome, workspaceRoot });
31
44
  }
32
45
  }
33
46
  return Object.freeze(Object.fromEntries([["root", root],
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
4
+
5
+ /**
6
+ * Asking npm whether there is a newer ACC.
7
+ *
8
+ * This is the only part of ACC that touches the network, and it is kept to one
9
+ * file for that reason. It is never on the hook path: a hook runs every turn
10
+ * inside a five-second budget and fails open, so a stalled socket there would
11
+ * cost every turn on the machine something and report nothing. `acc update`
12
+ * asks, `acc doctor` reads what was cached, and the answer is remembered for a
13
+ * day so a diagnostic run does not become traffic.
14
+ *
15
+ * `ACC_NO_UPDATE_CHECK=1` turns it off entirely, and then it says it is off
16
+ * rather than saying the version is current.
17
+ */
18
+ const REGISTRY = "https://registry.npmjs.org/agents-can-communicate/latest";
19
+ const EVERY_MS = 24 * 60 * 60 * 1000;
20
+ const TIMEOUT_MS = 3_000;
21
+
22
+ const cacheFile = dataHome => path.join(dataHome, "acc", "update-check.json");
23
+
24
+ export const checkingIsOff = env => {
25
+ const value = env?.ACC_NO_UPDATE_CHECK;
26
+ return value !== undefined && value !== "" && value !== "0";
27
+ };
28
+
29
+ const numbers = version => (typeof version === "string"
30
+ ? version.trim().split("-", 1)[0].split(".") : [])
31
+ .map(part => Number.parseInt(part, 10));
32
+
33
+ /**
34
+ * Whether `candidate` is a later release than `current`.
35
+ *
36
+ * Anything unreadable is not newer. Telling somebody to upgrade because a
37
+ * version could not be parsed is worse than saying nothing.
38
+ */
39
+ export function isNewer(candidate, current) {
40
+ const left = numbers(candidate);
41
+ const right = numbers(current);
42
+ if (left.length === 0 || left.some(Number.isNaN)) return false;
43
+ if (right.length === 0 || right.some(Number.isNaN)) return false;
44
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
45
+ const one = left[index] ?? 0;
46
+ const other = right[index] ?? 0;
47
+ if (one !== other) return one > other;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /** Whether a remembered answer is old enough to ask again. */
53
+ export function checkDue({ checkedAt, now, everyMs = EVERY_MS }) {
54
+ const last = Date.parse(checkedAt ?? "");
55
+ return !Number.isFinite(last) || now - last >= everyMs;
56
+ }
57
+
58
+ export async function readCachedCheck(dataHome, { readFile }) {
59
+ try {
60
+ const cached = JSON.parse(await readFile(cacheFile(dataHome), "utf8"));
61
+ return { latest: cached.latest ?? null, checkedAt: cached.checkedAt ?? null };
62
+ } catch {
63
+ // A missing or unreadable note is the same as never having asked.
64
+ return { latest: null, checkedAt: null };
65
+ }
66
+ }
67
+
68
+ export async function writeCachedCheck(dataHome, entry, { writeFile, mkdir }) {
69
+ const file = cacheFile(dataHome);
70
+ await mkdir(path.dirname(file), { recursive: true });
71
+ await writeFile(file, `${JSON.stringify(entry, null, 2)}\n`);
72
+ }
73
+
74
+ /** Ask the registry. The one network call in the product. */
75
+ export async function fetchLatest({ get = fetch, url = REGISTRY,
76
+ timeoutMs = TIMEOUT_MS } = {}) {
77
+ const response = await get(url, { signal: AbortSignal.timeout(timeoutMs),
78
+ headers: { accept: "application/json" } });
79
+ if (response?.ok !== true) {
80
+ throw new AccError(EXIT.DATA, `the registry answered ${response?.status ?? "nothing"}`,
81
+ { url });
82
+ }
83
+ const body = await response.json();
84
+ if (typeof body?.version !== "string" || numbers(body.version).some(Number.isNaN)) {
85
+ throw new AccError(EXIT.DATA, "the registry did not answer with a version", { url });
86
+ }
87
+ return body.version;
88
+ }
89
+
90
+ /**
91
+ * What `acc doctor` says about a newer release, without becoming a network
92
+ * command.
93
+ *
94
+ * The answer is remembered for a day, so running the diagnostic twice does not
95
+ * ask twice. A registry that cannot be reached changes nothing: doctor is about
96
+ * this machine, and a network that is down is not a fault in this install.
97
+ */
98
+ export async function noticeUpdate({ dataHome, running, env, now, get, io }) {
99
+ if (checkingIsOff(env)) return { checked: false, latest: null, newer: false };
100
+
101
+ const cached = await readCachedCheck(dataHome, io);
102
+ let latest = cached.latest;
103
+ if (checkDue({ checkedAt: cached.checkedAt, now })) {
104
+ try {
105
+ latest = await fetchLatest({ get });
106
+ await writeCachedCheck(dataHome,
107
+ { latest, checkedAt: new Date(now).toISOString() }, io);
108
+ } catch {
109
+ latest = cached.latest;
110
+ }
111
+ }
112
+ return { checked: true, latest: latest ?? null, newer: isNewer(latest, running) };
113
+ }
@@ -0,0 +1,86 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+
5
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
+
7
+ import { platformPaths } from "./platform-paths.mjs";
8
+ import { checkingIsOff, fetchLatest, isNewer, writeCachedCheck } from "./update-check.mjs";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ /**
13
+ * The two commands an upgrade takes, and why it is two.
14
+ *
15
+ * `npm install -g` replaces this CLI and the hook runtime, because the shim a
16
+ * client runs points into the npm directory rather than at a copy. It does not
17
+ * touch what was written into the client: its hook wiring, and the skills the
18
+ * agents read. Measured after an upgrade - the client still had `0.1.0` while
19
+ * `acc --version` said `0.1.1`, and nothing said so.
20
+ */
21
+ export const upgradeSteps = version => [
22
+ ["npm", ["install", "--global", `agents-can-communicate@${version}`]],
23
+ ["acc", ["install"]],
24
+ ];
25
+
26
+ const spell = ([command, argv]) => ` ${command} ${argv.join(" ")}`;
27
+
28
+ export async function runUpdateCommand({ options, runtime }) {
29
+ const env = runtime.env ?? {};
30
+ const { data: dataHome } = platformPaths({ platform: runtime.platform, env });
31
+ const running = typeof runtime.version === "function"
32
+ ? await runtime.version().catch(() => null)
33
+ : null;
34
+
35
+ // Off means off, and it says so rather than reporting that nothing is newer -
36
+ // which is a different fact, and one this run did not establish.
37
+ if (checkingIsOff(env)) {
38
+ return { data: { checked: false, running, latest: null },
39
+ text: "update checking is off (ACC_NO_UPDATE_CHECK); nothing was asked" };
40
+ }
41
+
42
+ // Caught, and then refused rather than degraded. `doctor` and `install` can
43
+ // carry on without knowing which ACC is running - they say less - but this
44
+ // command is the comparison, and `isNewer(latest, null)` is false: carrying on
45
+ // would answer "you have the latest" on the strength of not knowing.
46
+ if (running === null) {
47
+ throw new AccError(EXIT.DATA,
48
+ "cannot read the installed version, so there is nothing to compare against");
49
+ }
50
+
51
+ const latest = await fetchLatest({ get: runtime.fetch });
52
+ await writeCachedCheck(dataHome, { latest, checkedAt: runtime.clock.now() },
53
+ { writeFile, mkdir });
54
+
55
+ if (!isNewer(latest, running)) {
56
+ return { data: { checked: true, running, latest, newer: false },
57
+ text: `acc ${running} is the latest` };
58
+ }
59
+
60
+ const steps = upgradeSteps(latest);
61
+ const data = { checked: true, running, latest, newer: true,
62
+ steps: steps.map(([command, argv]) => [command, ...argv].join(" ")) };
63
+
64
+ if (options.apply !== true) {
65
+ return { data, text: [`acc ${latest} is available; you have ${running}`, "",
66
+ ...steps.map(spell), "", "or run: acc update --apply"].join("\n") };
67
+ }
68
+
69
+ const spawn = runtime.spawn ?? ((command, argv) => execFileAsync(command, argv, { env }));
70
+ const done = [];
71
+ for (const [command, argv] of steps) {
72
+ try {
73
+ await spawn(command, argv);
74
+ done.push([command, ...argv].join(" "));
75
+ } catch (error) {
76
+ // Named rather than swallowed, and the rest of the commands are printed:
77
+ // a global install refused for want of permission is the ordinary case,
78
+ // and the person can finish it by hand from here.
79
+ return { data: { ...data, applied: done, failed: [command, ...argv].join(" ") },
80
+ text: [`${command} failed: ${error.message}`, "", "finish it with:",
81
+ ...steps.slice(done.length).map(spell)].join("\n"),
82
+ error: undefined };
83
+ }
84
+ }
85
+ return { data: { ...data, applied: done }, text: `updated to ${latest}` };
86
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -12,7 +12,8 @@ import { recordInstall, removeOwned } from "./ownership.mjs";
12
12
  * A failure does not end the run. Someone installing four clients wants the
13
13
  * three that work, plus the name of the one that did not.
14
14
  */
15
- export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false }) {
15
+ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false,
16
+ accVersion = null }) {
16
17
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
17
18
  const results = { action: plan.action, dryRun, operations: [], skipped: plan.skipped,
18
19
  failed: [] };
@@ -34,7 +35,8 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
34
35
  // did not happen. The reverse order would leave uninstall trying to
35
36
  // remove files nothing created.
36
37
  await recordInstall({ dataHome, adapterId: adapter.id,
37
- version: operation.clientVersion ?? null, artifacts: operation.artifacts });
38
+ version: operation.clientVersion ?? null, accVersion,
39
+ artifacts: operation.artifacts });
38
40
  results.operations.push({ ...operation, applied: true,
39
41
  changes: outcome.changes ?? [], diagnostics: outcome.diagnostics ?? [] });
40
42
  } else {
@@ -102,7 +102,14 @@ async function saveOwnership({ dataHome, record }) {
102
102
  * second run's record describes what is actually on disk now, and an accumulated
103
103
  * one would list artifacts from a layout that no longer exists.
104
104
  */
105
- export async function recordInstall({ dataHome, adapterId, version, artifacts }) {
105
+ /**
106
+ * @param version the client's version, as detected. `accVersion` is ACC's own,
107
+ * which is what tells a later run that the plugin in the client is older than
108
+ * the code now running: updating the npm package replaces the CLI and the hook
109
+ * runtime, and leaves the bundle inside the client exactly where it was.
110
+ */
111
+ export async function recordInstall({ dataHome, adapterId, version, accVersion = null,
112
+ artifacts }) {
106
113
  const stamped = await Promise.all(artifacts.map(async artifact => ({
107
114
  path: artifact.path,
108
115
  kind: artifact.kind ?? "file",
@@ -113,7 +120,7 @@ export async function recordInstall({ dataHome, adapterId, version, artifacts })
113
120
  const record = await loadOwnership({ dataHome });
114
121
  await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
115
122
  installs: [...record.installs.filter(install => install.adapterId !== adapterId),
116
- { adapterId, version, artifacts: stamped }] } });
123
+ { adapterId, version, accVersion, artifacts: stamped }] } });
117
124
  }
118
125
 
119
126
  const installFor = (record, adapterId) =>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
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.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Model- and harness-agnostic coordination for independent AI agent sessions.",
6
6
  "keywords": [