agents-can-communicate 0.1.0 → 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 (35) 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/config-merge.mjs +16 -1
  13. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +28 -1
  14. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  15. package/node_modules/@agents-can-communicate/adapter-sdk/src/json-text.mjs +195 -0
  16. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -2
  18. package/node_modules/@agents-can-communicate/cli/src/confirm.mjs +33 -0
  19. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +58 -7
  20. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  21. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  22. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +91 -8
  23. package/node_modules/@agents-can-communicate/cli/src/main.mjs +11 -2
  24. package/node_modules/@agents-can-communicate/cli/src/update-check.mjs +113 -0
  25. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +86 -0
  26. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
  30. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +9 -2
  31. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +28 -5
  32. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  34. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  35. package/package.json +4 -4
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
3
+ "version": "0.1.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { editJson } from "./json-text.mjs";
4
+
3
5
  const MARKER = "acc:owned";
4
6
  // Ownership of single entries inside a container someone else also writes to.
5
7
  // `enabledPlugins` in a Claude Code settings file holds every plugin the user
@@ -150,10 +152,23 @@ export function jsonStyleOf(text) {
150
152
  * Unchanged content is not rewritten at all, so a second install touches
151
153
  * nothing. A file that does not exist yet is ACC's to create, and gets the
152
154
  * conventional trailing newline.
155
+ *
156
+ * What is there is edited rather than re-emitted. Reading the style back out of
157
+ * the file kept the indentation, and still reformatted everything the style
158
+ * could not describe: a nested object a person had written on one line came
159
+ * back as three, and a blank line between sections was gone. The bytes are
160
+ * theirs, and the diff ACC leaves should be of what it changed.
153
161
  */
154
162
  export async function writeForeignJson(file, value, { readFile, writeFile, mkdir }) {
155
163
  const current = await readFile(file, "utf8").catch(() => null);
156
- const text = formatJsonAs(value, jsonStyleOf(current));
164
+ const style = jsonStyleOf(current);
165
+ // Null when the original cannot be read, or when the edit came back meaning
166
+ // something other than it was asked to write. Then this is the whole-file
167
+ // re-emit it has always been.
168
+ const spliced = typeof current === "string" ? editJson(current, value, style.indent) : null;
169
+ const text = spliced === null
170
+ ? formatJsonAs(value, style)
171
+ : (style.trailingNewline === false ? spliced : `${spliced}\n`);
157
172
  if (current === text) return false;
158
173
  await mkdir(path.dirname(file), { recursive: true });
159
174
  await writeFile(file, text);
@@ -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);
@@ -8,6 +8,7 @@ export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstal
8
8
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
9
9
  from "./toml-block.mjs";
10
10
  export { projectContext } from "./context-projector.mjs";
11
+ export { editJson, readJson } from "./json-text.mjs";
11
12
  export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEntries, ownedKeys,
12
13
  acccreatedFile, removeIfEmpty, removeOwnedConfig, removeOwnedEntries, writeForeignJson,
13
14
  blankJson, blankText } from "./config-merge.mjs";
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Edit a JSON document as text, so what was not changed is not rewritten.
3
+ *
4
+ * `JSON.parse` then `JSON.stringify` re-emits every byte of a file, which
5
+ * reformats the parts ACC was not asked to touch: a nested object a person kept
6
+ * on one line comes back as three, and a blank line between sections is gone.
7
+ * The bytes are the user's, and a tool that edits their settings should leave a
8
+ * diff of what it changed rather than of how it prints.
9
+ *
10
+ * So the original text is kept and spliced. Anything whose value is unchanged is
11
+ * copied across verbatim - not re-serialised and hoped to match - and only a
12
+ * member that was added, removed or replaced is written fresh.
13
+ *
14
+ * The result is parsed back and compared to the value it was asked to write. A
15
+ * splice that would change meaning is discarded rather than written, and the
16
+ * caller falls back to plain `JSON.stringify`: the worst this can do is what
17
+ * was already being done.
18
+ */
19
+
20
+ const WHITESPACE = " \t\n\r";
21
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
22
+
23
+ const isObject = value =>
24
+ value !== null && typeof value === "object" && !Array.isArray(value);
25
+
26
+ /**
27
+ * Read a JSON document into a tree that remembers where everything was.
28
+ *
29
+ * @returns the root node, or null for anything this cannot read - which the
30
+ * caller treats the same way as a file it must not touch.
31
+ */
32
+ export function readJson(text) {
33
+ if (typeof text !== "string") return null;
34
+ let at = 0;
35
+
36
+ const skip = () => { while (at < text.length && WHITESPACE.includes(text[at])) at += 1; };
37
+ const fail = () => { throw new SyntaxError(`unreadable JSON at ${at}`); };
38
+ const expect = character => { if (text[at] !== character) fail(); at += 1; };
39
+
40
+ const readString = () => {
41
+ expect('"');
42
+ while (at < text.length) {
43
+ if (text[at] === "\\") { at += 2; continue; }
44
+ if (text[at] === '"') { at += 1; return; }
45
+ at += 1;
46
+ }
47
+ fail();
48
+ };
49
+
50
+ const readValue = () => {
51
+ skip();
52
+ const start = at;
53
+ if (text[at] === "{") return readObject(start);
54
+ if (text[at] === "[") return readArray(start);
55
+ if (text[at] === '"') { readString(); }
56
+ else {
57
+ // Numbers, true, false, null: read to the next structural character and
58
+ // let `JSON.parse` below be the judge of what was read.
59
+ while (at < text.length && !",}]".includes(text[at]) && !WHITESPACE.includes(text[at])) {
60
+ at += 1;
61
+ }
62
+ if (at === start) fail();
63
+ }
64
+ const slice = text.slice(start, at);
65
+ return { kind: "scalar", start, end: at, value: JSON.parse(slice) };
66
+ };
67
+
68
+ function readObject(start) {
69
+ expect("{");
70
+ const members = [];
71
+ const value = {};
72
+ for (;;) {
73
+ // Everything between the previous member and this one - the comma, the
74
+ // newline, the indentation - is kept as it was written.
75
+ const gapStart = at;
76
+ skip();
77
+ if (text[at] === "}") { at += 1; break; }
78
+ if (members.length > 0) { expect(","); skip(); }
79
+ if (text[at] === "}") { at += 1; break; }
80
+ const keyStart = at;
81
+ readString();
82
+ const key = JSON.parse(text.slice(keyStart, at));
83
+ skip();
84
+ expect(":");
85
+ const node = readValue();
86
+ members.push({ key, gapStart, keyStart, valueStart: node.start, end: node.end, node });
87
+ // Defined rather than assigned: `value.__proto__ = …` sets the prototype
88
+ // of an ordinary object instead of adding a key to it, so a config with
89
+ // that key read back without it - and the comparison that decides whether
90
+ // a subtree changed would be answering about a different document than
91
+ // the one on disk. This is what `JSON.parse` does with the same input.
92
+ Object.defineProperty(value, key,
93
+ { value: node.value, writable: true, enumerable: true, configurable: true });
94
+ }
95
+ return { kind: "object", start, end: at, value, members };
96
+ }
97
+
98
+ function readArray(start) {
99
+ expect("[");
100
+ const items = [];
101
+ const value = [];
102
+ for (;;) {
103
+ skip();
104
+ if (text[at] === "]") { at += 1; break; }
105
+ if (items.length > 0) { expect(","); skip(); }
106
+ if (text[at] === "]") { at += 1; break; }
107
+ const node = readValue();
108
+ items.push(node);
109
+ value.push(node.value);
110
+ }
111
+ return { kind: "array", start, end: at, value, items };
112
+ }
113
+
114
+ try {
115
+ const root = readValue();
116
+ skip();
117
+ // Trailing content means this is not the document it appears to be.
118
+ return at === text.length ? root : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Write `value` into `text`, changing only what differs.
126
+ *
127
+ * @returns the edited document, or null when it cannot be done safely - an
128
+ * unreadable original, or a splice that came back meaning something else.
129
+ */
130
+ export function editJson(text, value, indent) {
131
+ const root = readJson(text);
132
+ if (root === null) return null;
133
+
134
+ const unit = typeof indent === "number" ? " ".repeat(indent) : (indent ?? " ");
135
+ const pad = depth => unit.repeat(depth);
136
+ const broken = unit !== "";
137
+
138
+ // A value with no original to preserve: printed the way this file prints,
139
+ // then moved to the depth it sits at.
140
+ const fresh = (subject, depth) => {
141
+ const printed = JSON.stringify(subject, null, unit);
142
+ return broken ? printed.split("\n").join(`\n${pad(depth)}`) : printed;
143
+ };
144
+
145
+ const render = (node, subject, depth) => {
146
+ if (node !== null && same(node.value, subject)) return text.slice(node.start, node.end);
147
+ if (node?.kind === "object" && isObject(subject)) return spliceObject(node, subject, depth);
148
+ return fresh(subject, depth);
149
+ };
150
+
151
+ function spliceObject(node, subject, depth) {
152
+ const byKey = new Map(node.members.map(member => [member.key, member]));
153
+ const pieces = [];
154
+
155
+ for (const key of Object.keys(subject)) {
156
+ const member = byKey.get(key);
157
+ if (member === undefined) {
158
+ // New: written in this file's own shape rather than copied from nowhere.
159
+ pieces.push(broken
160
+ ? `\n${pad(depth + 1)}${JSON.stringify(key)}: ${fresh(subject[key], depth + 1)}`
161
+ : `${JSON.stringify(key)}:${fresh(subject[key], depth + 1)}`);
162
+ continue;
163
+ }
164
+ // The separator that preceded it, the key as it was spelled, and the
165
+ // colon and spacing after it: all of it is the user's, and none of it is
166
+ // what changed.
167
+ const separator = text.slice(member.gapStart, member.keyStart);
168
+ const label = text.slice(member.keyStart, member.valueStart);
169
+ pieces.push(separator.replace(",", "") + label + render(member.node, subject[key], depth + 1));
170
+ }
171
+
172
+ // The comma belongs between members, so it is placed rather than copied:
173
+ // a member that used to be first no longer is, and one that is now first
174
+ // must not arrive with the comma it used to carry.
175
+ const body = pieces.join(",");
176
+ // What sat between the last member and the brace, when the last member is
177
+ // still the one that was there; otherwise this file's own closing shape.
178
+ const last = node.members.at(-1);
179
+ const closing = last !== undefined && Object.keys(subject).at(-1) === last.key
180
+ ? text.slice(last.end, node.end - 1)
181
+ : (broken ? `\n${pad(depth)}` : "");
182
+ if (pieces.length === 0) return "{}";
183
+ return `{${body}${closing}}`;
184
+ }
185
+
186
+ const edited = render(root, value, 0);
187
+ // The safety net. A splice that parses to something else is not written.
188
+ let read;
189
+ try {
190
+ read = JSON.parse(edited);
191
+ } catch {
192
+ return null;
193
+ }
194
+ return same(read, value) ? edited : null;
195
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,8 +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"] },
60
- uninstall: { required: [], optional: ["adapter", "home"], flags: ["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"] },
63
+ // `--dry-run` on both, because the preview was computed for either action and
64
+ // only `install` could ask for it. Removal is the side that reaches into a
65
+ // client's configuration - including a client that has left the machine.
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"] },
61
70
  // The two things a person types first after installing from a registry. The
62
71
  // CLI answered neither: `acc --version` and `acc --help` were both "unknown
63
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";
@@ -5,7 +5,7 @@ import { createClaudeCodeAdapter } from "@agents-can-communicate/adapter-claude-
5
5
  import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
6
6
  import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
7
7
  import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
8
- import { applyPlan, detectInstallation, planInstallation }
8
+ import { applyPlan, detectInstallation, loadOwnership, planInstallation }
9
9
  from "@agents-can-communicate/installer";
10
10
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
11
11
 
@@ -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
  /**
@@ -80,6 +135,21 @@ export function failureOf({ action, acted, failed = [] }) {
80
135
  describeOutcome({ action, acted, failed }), { failed });
81
136
  }
82
137
 
138
+ /**
139
+ * How many adapters this actually did something to.
140
+ *
141
+ * `applied` means the adapter's own step ran, which on an uninstall is true of
142
+ * every client on the machine whether ACC had ever written to it or not. So
143
+ * `uninstalled 3 adapter(s)` was the report on a machine where ACC had installed
144
+ * nothing at all - printed by the same run that skipped the one client it had
145
+ * actually written to.
146
+ */
147
+ export function actedOn(result) {
148
+ return result.operations.filter(operation => operation.applied
149
+ && (result.action === "install"
150
+ || (operation.removed?.length ?? 0) + (operation.changes?.length ?? 0) > 0)).length;
151
+ }
152
+
83
153
  export async function runInstallCommand({ options, runtime, action = "install" }) {
84
154
  const adapters = selectAdapters(options.adapter);
85
155
  const home = options.home ?? runtime.env?.HOME ?? homedir();
@@ -87,13 +157,26 @@ export async function runInstallCommand({ options, runtime, action = "install" }
87
157
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
88
158
  env: runtime.env ?? {} });
89
159
 
90
- const detected = await detectInstallation({ adapters, context });
91
- const plan = planInstallation({ adapters, detected, context, action });
160
+ const detected = await detectInstallation({ adapters, context,
161
+ probeTimeoutMs: probeTimeout(runtime.env) });
162
+ // An uninstall is planned from what ACC recorded writing, not only from what
163
+ // is on the machine now. A client can be removed after ACC installed into it,
164
+ // and its configuration directory - with ACC's files in it - stays behind.
165
+ const recorded = action === "uninstall"
166
+ ? (await loadOwnership({ dataHome })).installs
167
+ : [];
168
+ const plan = planInstallation({ adapters, detected, context, action, recorded });
92
169
 
93
170
  const dryRun = options.dryRun === true;
94
- 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 });
95
178
 
96
- const acted = result.operations.filter(operation => operation.applied).length;
179
+ const acted = actedOn(result);
97
180
  if (dryRun) {
98
181
  return { data: { ...result, plan, dataHome },
99
182
  text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
@@ -102,6 +185,6 @@ export async function runInstallCommand({ options, runtime, action = "install" }
102
185
 
103
186
  return { data: { ...result, plan, dataHome },
104
187
  text: describeOutcome({ action, acted, failed: result.failed,
105
- skipped: plan.skipped }),
188
+ skipped: plan.skipped, operations: result.operations, home }),
106
189
  error: failureOf({ action, acted, failed: result.failed }) };
107
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.0",
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.0",
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.0",
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) =>
@@ -7,11 +7,16 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
7
7
  * makes `--dry-run` worth reading - a plan computed differently from the thing
8
8
  * it previews is a decoration, and the operator would find out only afterwards.
9
9
  */
10
- export function planInstallation({ adapters, detected, context, action = "install" }) {
10
+ export function planInstallation({ adapters, detected, context, action = "install",
11
+ recorded = [] }) {
11
12
  if (!["install", "uninstall"].includes(action)) {
12
13
  throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
13
14
  }
14
15
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
16
+ // What ACC recorded writing, by client. For an uninstall this is the
17
+ // authority rather than detection: the record is the only account of what was
18
+ // written, and a client's configuration directory outlives the client.
19
+ const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
15
20
  const operations = [];
16
21
  const skipped = [];
17
22
 
@@ -24,20 +29,32 @@ export function planInstallation({ adapters, detected, context, action = "instal
24
29
  skipped.push({ adapterId: entry.adapterId, reason: "no adapter for this client" });
25
30
  continue;
26
31
  }
27
- if (!entry.present) {
32
+ // A client that has left the machine is still worth visiting when ACC wrote
33
+ // to it. Skipping it made that install permanently unremovable: `acc
34
+ // uninstall` reported success and exit 0, left ACC's tree in the client's
35
+ // configuration directory and ACC's entries in the user's own settings
36
+ // file, and said the same thing on every run afterwards.
37
+ const record = action === "uninstall" && !entry.present
38
+ ? recordedById.get(entry.adapterId)
39
+ : undefined;
40
+
41
+ if (!entry.present && record === undefined) {
28
42
  // Named rather than dropped: "nothing happened" and "that client is not
29
43
  // installed on this machine" look the same in an empty list.
30
44
  skipped.push({ adapterId: entry.adapterId,
31
45
  reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine` });
32
46
  continue;
33
47
  }
34
- if (typeof adapter.planInstall !== "function") {
48
+ if (record === undefined && typeof adapter.planInstall !== "function") {
35
49
  skipped.push({ adapterId: entry.adapterId,
36
50
  reason: "this adapter cannot describe what it would write" });
37
51
  continue;
38
52
  }
39
53
 
40
- const artifacts = adapter.planInstall(context)
54
+ // From the record when the client is gone, because that is what was written
55
+ // and so what will be removed. Asking the adapter instead would describe an
56
+ // install for a machine this one no longer is.
57
+ const artifacts = (record?.artifacts ?? adapter.planInstall(context))
41
58
  .map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
42
59
  .sort((a, b) => a.path.localeCompare(b.path));
43
60
 
@@ -45,12 +62,18 @@ export function planInstallation({ adapters, detected, context, action = "instal
45
62
  adapterId: adapter.id,
46
63
  displayName: adapter.displayName,
47
64
  action,
48
- clientVersion: entry.version ?? null,
65
+ clientVersion: entry.version ?? record?.version ?? null,
66
+ // "Remove these files for a client that is not here" is a different thing
67
+ // to approve than an ordinary uninstall, so it is said rather than left
68
+ // to be inferred from a client version that is null.
69
+ clientPresent: entry.present === true,
49
70
  alreadyInstalled: entry.installed === true,
50
71
  artifacts,
51
72
  // Said in the operator's terms, not in paths: which files ACC creates
52
73
  // outright and which belong to the user and are only edited.
53
74
  summary: [
75
+ ...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
76
+ + "this machine; removing what ACC recorded writing"]),
54
77
  ...artifacts.filter(a => a.kind === "tree")
55
78
  .map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
56
79
  ...artifacts.filter(a => a.kind === "merge")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.0",
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.0",
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.0",
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.0",
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": [
@@ -36,9 +36,9 @@
36
36
  "node": ">=24"
37
37
  },
38
38
  "bin": {
39
- "acc": "./bin/acc.mjs",
40
- "acc-mcp": "./bin/acc-mcp.mjs",
41
- "acc-hook": "./bin/acc-hook.mjs"
39
+ "acc": "bin/acc.mjs",
40
+ "acc-mcp": "bin/acc-mcp.mjs",
41
+ "acc-hook": "bin/acc-hook.mjs"
42
42
  },
43
43
  "files": [
44
44
  "bin/",