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.
- package/README.md +31 -2
- package/bin/acc.mjs +8 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +16 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +28 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/json-text.mjs +195 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -2
- package/node_modules/@agents-can-communicate/cli/src/confirm.mjs +33 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +58 -7
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +91 -8
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +11 -2
- package/node_modules/@agents-can-communicate/cli/src/update-check.mjs +113 -0
- package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +9 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +28 -5
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- 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
|
-
|
|
84
|
-
|
|
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",
|
|
3
|
-
"version": "0.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.
|
|
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,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
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
60
|
-
|
|
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 }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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}`)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-can-communicate",
|
|
3
|
-
"version": "0.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": [
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"node": ">=24"
|
|
37
37
|
},
|
|
38
38
|
"bin": {
|
|
39
|
-
"acc": "
|
|
40
|
-
"acc-mcp": "
|
|
41
|
-
"acc-hook": "
|
|
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/",
|