agents-can-communicate 0.1.1 → 0.1.2

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 (31) 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-gemini-cli/extension/gemini-extension.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +28 -1
  13. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/cli/src/args.mjs +8 -2
  15. package/node_modules/@agents-can-communicate/cli/src/confirm.mjs +33 -0
  16. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +58 -7
  17. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  18. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  19. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +67 -5
  20. package/node_modules/@agents-can-communicate/cli/src/main.mjs +11 -2
  21. package/node_modules/@agents-can-communicate/cli/src/update-check.mjs +113 -0
  22. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +86 -0
  23. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
  27. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +9 -2
  28. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  31. 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.2",
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.2",
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.2",
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.2",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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.2",
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.2",
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.2",
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.2",
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.2",
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) =>
@@ -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.2",
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.2",
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.2",
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.2",
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.2",
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.2",
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.2",
4
4
  "type": "module",
5
5
  "description": "Model- and harness-agnostic coordination for independent AI agent sessions.",
6
6
  "keywords": [