agents-can-communicate 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-codex/src/install.mjs +65 -21
- 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/hook-shim.mjs +28 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +8 -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 +67 -5
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +11 -2
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +15 -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/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 +1 -1
package/README.md
CHANGED
|
@@ -75,13 +75,42 @@ npm install -g agents-can-communicate
|
|
|
75
75
|
|
|
76
76
|
Then wire up the clients you have:
|
|
77
77
|
|
|
78
|
+
```bash
|
|
79
|
+
acc install
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It names every file it wrote, in your own home-relative paths, and how to undo it. Open your
|
|
83
|
+
clients in the project afterwards — in one directory or in several worktrees — and work
|
|
84
|
+
normally.
|
|
85
|
+
|
|
86
|
+
If you would rather look before it writes, `acc install --dry-run` prints the same list and
|
|
87
|
+
changes nothing. `acc uninstall` takes it all back out.
|
|
88
|
+
|
|
78
89
|
<!-- test:command -->
|
|
79
90
|
```bash
|
|
80
91
|
acc install --dry-run
|
|
81
92
|
```
|
|
82
93
|
|
|
83
|
-
|
|
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.3",
|
|
4
4
|
"description": "Coordinate this Codex session with other AI agent sessions working in the same workspace.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"keywords": ["coordination", "multi-agent", "claims", "handoff"],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { cp, mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
@@ -11,22 +11,35 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
11
11
|
const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
|
|
12
12
|
const PLUGIN_NAME = "agents-can-communicate";
|
|
13
13
|
|
|
14
|
-
// The marketplace ACC owns
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// The marketplace ACC owns, and its own root inside the agents home.
|
|
15
|
+
//
|
|
16
|
+
// Registering a separate one rather than joining the user's keeps the two
|
|
17
|
+
// apart. ACC used to write into `<home>/.agents/plugins/marketplace.json` -
|
|
18
|
+
// which is the marketplace this client discovers by itself, with no config
|
|
19
|
+
// entry at all, under whatever that manifest calls itself. So ACC merged its
|
|
20
|
+
// entry into someone else's marketplace and then enabled `…@acc-local`, an id
|
|
21
|
+
// this client never forms: `acc install` reported success and `codex plugin
|
|
22
|
+
// list` said `not installed`. Measured against Codex 0.147.0, then measured
|
|
23
|
+
// again to confirm a root of ACC's own is accepted and reported enabled.
|
|
17
24
|
const MARKETPLACE = "acc-local";
|
|
18
25
|
const QUALIFIED = `${PLUGIN_NAME}@${MARKETPLACE}`;
|
|
19
26
|
|
|
20
|
-
// A marketplace is a
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// A marketplace is a root holding `.agents/plugins/marketplace.json`, and every
|
|
28
|
+
// `source.path` in that manifest - `./plugins/<name>` - is resolved by this
|
|
29
|
+
// client against the *root*, not against the manifest's directory. Measured:
|
|
30
|
+
// `codex plugin list` prints the path it resolved, and for a plugin the user
|
|
31
|
+
// installed themselves it printed `<root>/plugins/x` from an entry spelled
|
|
32
|
+
// `./plugins/x`. The comment that used to be here said the opposite, and ACC
|
|
33
|
+
// wrote its tree two directories below where the client then looked.
|
|
34
|
+
//
|
|
35
|
+
// The root is under `.agents/` rather than the home itself: `<home>/plugins/`
|
|
36
|
+
// is where this client would put it, and nothing of ACC's belongs at the top of
|
|
37
|
+
// somebody's home.
|
|
38
|
+
const marketplaceRoot = agentsHome => path.join(agentsHome, ".agents", MARKETPLACE);
|
|
39
|
+
const marketplacePath = agentsHome =>
|
|
40
|
+
path.join(marketplaceRoot(agentsHome), ".agents", "plugins", "marketplace.json");
|
|
41
|
+
const pluginPath = (agentsHome, name = PLUGIN_NAME) =>
|
|
42
|
+
path.join(marketplaceRoot(agentsHome), "plugins", name);
|
|
30
43
|
const configPath = codexHome => path.join(codexHome, "config.toml");
|
|
31
44
|
// Where `codex plugin add` leaves the copy it actually runs. All three
|
|
32
45
|
// components are ACC's own - the marketplace it created, the plugin name it
|
|
@@ -111,6 +124,11 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
111
124
|
// plugin tree is laid down that nothing will then be able to remove.
|
|
112
125
|
const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
|
|
113
126
|
interface: { displayName: "Agents Can Communicate" }, plugins: [] });
|
|
127
|
+
// The name in the manifest, which is the one this client forms plugin ids
|
|
128
|
+
// from. ACC used its own regardless, so on a machine that already had a
|
|
129
|
+
// marketplace at this root - discovered without any config entry, under
|
|
130
|
+
// whatever its manifest calls itself - the id ACC enabled was one the client
|
|
131
|
+
// never forms, and the plugin sat there listed and not installed.
|
|
114
132
|
const before = await readFile(configPath(codexHome), "utf8").catch(() => "");
|
|
115
133
|
|
|
116
134
|
const target = pluginPath(agentsHome);
|
|
@@ -142,7 +160,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
142
160
|
await writeTomlBlock(config, [
|
|
143
161
|
`[marketplaces.${MARKETPLACE}]`,
|
|
144
162
|
`source_type = "local"`,
|
|
145
|
-
`source = ${tomlString(agentsHome)}`,
|
|
163
|
+
`source = ${tomlString(marketplaceRoot(agentsHome))}`,
|
|
146
164
|
"",
|
|
147
165
|
`[plugins.${tomlString(QUALIFIED)}]`,
|
|
148
166
|
"enabled = true",
|
|
@@ -156,13 +174,26 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
156
174
|
await rm(cached, { recursive: true, force: true });
|
|
157
175
|
await cp(target, cached, { recursive: true });
|
|
158
176
|
|
|
159
|
-
// The
|
|
160
|
-
//
|
|
177
|
+
// The plugin's own directory in the cache. Not the versioned one inside it,
|
|
178
|
+
// which goes stale the moment the version changes - and not the marketplace
|
|
179
|
+
// cache root above it, which belongs to whoever's marketplace this is: that
|
|
180
|
+
// root holds every plugin installed from it, and removing it took a plugin
|
|
181
|
+
// the user had installed themselves. Measured, on a real machine.
|
|
182
|
+
//
|
|
183
|
+
// The old comment here said the root was "what ACC owns", which was true only
|
|
184
|
+
// while ACC invented its own marketplace name and so had a root to itself.
|
|
161
185
|
// make the record stale the moment the plugin version changes.
|
|
162
|
-
return { ok: true, changes: [target, file, config,
|
|
186
|
+
return { ok: true, changes: [target, file, config, cachePath(codexHome)],
|
|
163
187
|
diagnostics: ["hooks require explicit trust in Codex before they run"] };
|
|
164
188
|
}
|
|
165
189
|
|
|
190
|
+
/** Remove each directory that is empty, in the order given. */
|
|
191
|
+
async function removeEmptyDirs(directories) {
|
|
192
|
+
for (const directory of directories) {
|
|
193
|
+
await rmdir(directory).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
166
197
|
export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
167
198
|
codexHome = path.join(home, ".codex"), keep = [] }) {
|
|
168
199
|
const file = marketplacePath(agentsHome);
|
|
@@ -190,8 +221,20 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
|
190
221
|
return value?.name === MARKETPLACE && (value.plugins ?? []).length === 0;
|
|
191
222
|
} });
|
|
192
223
|
|
|
193
|
-
await removeInstalledTree(
|
|
224
|
+
await removeInstalledTree(cachePath(codexHome), keep);
|
|
194
225
|
await removeInstalledTree(pluginPath(agentsHome), keep);
|
|
226
|
+
// The directories ACC made to hold those, once nothing is in them. They are
|
|
227
|
+
// ACC's own - a marketplace root it created and the cache directory named
|
|
228
|
+
// after it - and an empty one left behind is litter in a home that did not
|
|
229
|
+
// have it. Anything the user put inside stops this: the directory is not
|
|
230
|
+
// empty, and it stays.
|
|
231
|
+
await removeEmptyDirs([
|
|
232
|
+
path.join(marketplaceRoot(agentsHome), "plugins"),
|
|
233
|
+
path.dirname(marketplacePath(agentsHome)),
|
|
234
|
+
path.dirname(path.dirname(marketplacePath(agentsHome))),
|
|
235
|
+
marketplaceRoot(agentsHome),
|
|
236
|
+
cacheRoot(codexHome),
|
|
237
|
+
]);
|
|
195
238
|
return { ok: true, changes, diagnostics: [] };
|
|
196
239
|
}
|
|
197
240
|
|
|
@@ -202,7 +245,8 @@ export async function detectCodex({ home, agentsHome = home,
|
|
|
202
245
|
const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
|
|
203
246
|
const registered = config.includes(`[marketplaces.${MARKETPLACE}]`);
|
|
204
247
|
const enabled = config.includes(`[plugins."${QUALIFIED}"]`);
|
|
205
|
-
const cached = await stat(cachePath(codexHome))
|
|
248
|
+
const cached = await stat(cachePath(codexHome))
|
|
249
|
+
.then(() => true).catch(() => false);
|
|
206
250
|
return { ok: true, changes: [], diagnostics: [
|
|
207
251
|
published ? "acc plugin published in the marketplace" : "acc plugin not registered",
|
|
208
252
|
registered && enabled
|
|
@@ -228,7 +272,7 @@ export function planCodexInstall({ home, agentsHome = home,
|
|
|
228
272
|
codexHome = path.join(home, ".codex") }) {
|
|
229
273
|
return [
|
|
230
274
|
{ path: pluginPath(agentsHome), kind: "tree" },
|
|
231
|
-
{ path:
|
|
275
|
+
{ path: cachePath(codexHome), kind: "tree" },
|
|
232
276
|
{ path: marketplacePath(agentsHome), kind: "merge" },
|
|
233
277
|
{ path: configPath(codexHome), kind: "merge" },
|
|
234
278
|
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-can-communicate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Coordinate this Kimi Code session with other AI agent sessions working in the same workspace: shared presence, resource claims, typed messages, and handoffs.",
|
|
5
5
|
"skills": "./skills/",
|
|
6
6
|
"sessionStart": {
|
|
@@ -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);
|
|
@@ -56,11 +56,17 @@ export const COMMANDS = Object.freeze({
|
|
|
56
56
|
// inside a handler that has already decided what to do.
|
|
57
57
|
config: { required: [], optional: [], flags: ["yes", "force"],
|
|
58
58
|
subcommands: ["init", "validate"] },
|
|
59
|
-
|
|
59
|
+
// No `--yes`: neither of these ever asked, so the flag agreed to nothing. It
|
|
60
|
+
// was accepted and read by nobody, which is a promise that a confirmation
|
|
61
|
+
// exists to be skipped.
|
|
62
|
+
install: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
|
|
60
63
|
// `--dry-run` on both, because the preview was computed for either action and
|
|
61
64
|
// only `install` could ask for it. Removal is the side that reaches into a
|
|
62
65
|
// client's configuration - including a client that has left the machine.
|
|
63
|
-
uninstall: { required: [], optional: ["adapter", "home"], flags: ["dry-run"
|
|
66
|
+
uninstall: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
|
|
67
|
+
// Asking npm whether there is a newer ACC. The one command that touches the
|
|
68
|
+
// network, and never on the hook path.
|
|
69
|
+
update: { required: [], optional: [], flags: ["apply"] },
|
|
64
70
|
// The two things a person types first after installing from a registry. The
|
|
65
71
|
// CLI answered neither: `acc --version` and `acc --help` were both "unknown
|
|
66
72
|
// command", and `acc` on its own asked for a command without naming one.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ask a yes-or-no question and wait for the answer.
|
|
6
|
+
*
|
|
7
|
+
* A port rather than a call to the terminal, so the composition root hands it
|
|
8
|
+
* in and a test hands in two streams instead. There was no port at all:
|
|
9
|
+
* `runtime.confirm` fell back to a function that always answered no, so `acc
|
|
10
|
+
* config init` in a real terminal printed `not written` and never said why -
|
|
11
|
+
* and `--yes`, documented for runs with nobody to ask, was the only way to
|
|
12
|
+
* write the file.
|
|
13
|
+
*
|
|
14
|
+
* Anything that is not yes is no. A confirmation that reads a stray newline as
|
|
15
|
+
* agreement is not a confirmation.
|
|
16
|
+
*/
|
|
17
|
+
export async function askConfirmation(question, { input, output }) {
|
|
18
|
+
const dialogue = createInterface({ input, output });
|
|
19
|
+
try {
|
|
20
|
+
const asked = dialogue.question(`${question}\n[y/N] `);
|
|
21
|
+
// The input closing before an answer arrives is the reader leaving - Ctrl+D,
|
|
22
|
+
// or a pipe that ended - which is a refusal rather than a failure. Raced
|
|
23
|
+
// rather than caught: the question simply never settles on a stream that
|
|
24
|
+
// ends, so waiting for it alone hangs.
|
|
25
|
+
asked.catch(() => {});
|
|
26
|
+
const answer = await Promise.race([asked, once(dialogue, "close").then(() => "")]);
|
|
27
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
} finally {
|
|
31
|
+
dialogue.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
|
|
3
|
-
import { detectInstallation, verifyOwned }
|
|
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";
|
|
@@ -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
|
/**
|
|
@@ -102,7 +157,8 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
102
157
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
103
158
|
env: runtime.env ?? {} });
|
|
104
159
|
|
|
105
|
-
const detected = await detectInstallation({ adapters, context
|
|
160
|
+
const detected = await detectInstallation({ adapters, context,
|
|
161
|
+
probeTimeoutMs: probeTimeout(runtime.env) });
|
|
106
162
|
// An uninstall is planned from what ACC recorded writing, not only from what
|
|
107
163
|
// is on the machine now. A client can be removed after ACC installed into it,
|
|
108
164
|
// and its configuration directory - with ACC's files in it - stays behind.
|
|
@@ -112,7 +168,13 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
112
168
|
const plan = planInstallation({ adapters, detected, context, action, recorded });
|
|
113
169
|
|
|
114
170
|
const dryRun = options.dryRun === true;
|
|
115
|
-
|
|
171
|
+
// Recorded with the install, so a later run can tell that the bundle sitting
|
|
172
|
+
// in a client is older than the code now running. Updating the npm package
|
|
173
|
+
// replaces this CLI and the hook runtime and leaves that bundle untouched.
|
|
174
|
+
const accVersion = typeof runtime.version === "function"
|
|
175
|
+
? await runtime.version().catch(() => null)
|
|
176
|
+
: null;
|
|
177
|
+
const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
|
|
116
178
|
|
|
117
179
|
const acted = actedOn(result);
|
|
118
180
|
if (dryRun) {
|
|
@@ -123,6 +185,6 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
123
185
|
|
|
124
186
|
return { data: { ...result, plan, dataHome },
|
|
125
187
|
text: describeOutcome({ action, acted, failed: result.failed,
|
|
126
|
-
skipped: plan.skipped }),
|
|
188
|
+
skipped: plan.skipped, operations: result.operations, home }),
|
|
127
189
|
error: failureOf({ action, acted, failed: result.failed }) };
|
|
128
190
|
}
|
|
@@ -9,6 +9,7 @@ import { parseArgs, positiveNumber } from "./args.mjs";
|
|
|
9
9
|
// with the argument already half-applied.
|
|
10
10
|
const usage = message => new AccError(EXIT.USAGE, message);
|
|
11
11
|
import { describeCommands, helpText } from "./help.mjs";
|
|
12
|
+
import { runUpdateCommand } from "./update-command.mjs";
|
|
12
13
|
import { runConfigCommand } from "./config-command.mjs";
|
|
13
14
|
import { runInstallCommand } from "./install-command.mjs";
|
|
14
15
|
import { runDoctor } from "./doctor-command.mjs";
|
|
@@ -252,7 +253,12 @@ const HANDLERS = Object.freeze({
|
|
|
252
253
|
// command demands --yes rather than hanging or assuming consent.
|
|
253
254
|
interactive: runtime.stdout?.isTTY === true,
|
|
254
255
|
yes: options.yes === true,
|
|
255
|
-
|
|
256
|
+
// Not a silent no. A build that cannot ask says so: falling back to
|
|
257
|
+
// "declined" is how `acc config init` came to print `not written` in a
|
|
258
|
+
// terminal where nobody had been asked anything.
|
|
259
|
+
confirm: runtime.confirm ?? (async () => {
|
|
260
|
+
throw new AccError(EXIT.DATA, "this build was assembled without a way to ask");
|
|
261
|
+
}),
|
|
256
262
|
force: options.force === true,
|
|
257
263
|
// Opened lazily and only by `init`: `config validate` has to work on a
|
|
258
264
|
// workspace discovery cannot open, which is what a reader runs it to
|
|
@@ -275,6 +281,8 @@ const HANDLERS = Object.freeze({
|
|
|
275
281
|
|
|
276
282
|
doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
|
|
277
283
|
|
|
284
|
+
update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
|
|
285
|
+
|
|
278
286
|
help: async () => ({ data: { commands: describeCommands() }, text: helpText() }),
|
|
279
287
|
|
|
280
288
|
version: async ({ runtime }) => {
|
|
@@ -294,7 +302,8 @@ const HANDLERS = Object.freeze({
|
|
|
294
302
|
/**
|
|
295
303
|
* @returns {Promise<number>} the process exit code
|
|
296
304
|
*/
|
|
297
|
-
const NO_WORKSPACE = Object.freeze(["config", "install", "uninstall", "help", "version"
|
|
305
|
+
const NO_WORKSPACE = Object.freeze(["config", "install", "uninstall", "help", "version",
|
|
306
|
+
"update"]);
|
|
298
307
|
|
|
299
308
|
export async function main(argv, runtime) {
|
|
300
309
|
const write = (stream, text) => new Promise((resolve, reject) =>
|
|
@@ -23,11 +23,24 @@ export function runtimePaths({ dataHome, workspaceId, workspaceRoots = [] }) {
|
|
|
23
23
|
// Enforced here rather than only asserted in a test, because "just put it in
|
|
24
24
|
// .agents next to the project" is the exact regression this design exists to
|
|
25
25
|
// prevent, and it would otherwise look like it works.
|
|
26
|
+
//
|
|
27
|
+
// The message names both paths and what to do, because the case a person
|
|
28
|
+
// actually meets is not the one this was written for. Running `acc` in a home
|
|
29
|
+
// directory makes that directory the workspace - it is no checkout, so
|
|
30
|
+
// discovery falls back to where you are - and the platform's own state
|
|
31
|
+
// directory is inside a home by definition. So `acc status` in `~` answered
|
|
32
|
+
// "runtime state must not live inside the workspace", which reads as a
|
|
33
|
+
// misconfiguration and tells the reader nothing they can act on.
|
|
26
34
|
for (const workspaceRoot of workspaceRoots) {
|
|
27
35
|
const relative = path.relative(workspaceRoot, root);
|
|
28
36
|
if (relative === "" || (!path.isAbsolute(relative) && !relative.startsWith(".."))) {
|
|
29
|
-
throw new AccError(EXIT.USAGE,
|
|
30
|
-
|
|
37
|
+
throw new AccError(EXIT.USAGE,
|
|
38
|
+
// The data home rather than the workspace's own directory inside it:
|
|
39
|
+
// that is the one a reader can move, and the one the remedy names.
|
|
40
|
+
`${workspaceRoot} holds ACC's own state at ${path.join(dataHome, "acc")}, `
|
|
41
|
+
+ "so it cannot be a workspace. Run acc inside a project, or point "
|
|
42
|
+
+ "ACC_DATA_HOME outside this directory.",
|
|
43
|
+
{ root, dataHome, workspaceRoot });
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
return Object.freeze(Object.fromEntries([["root", root],
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Asking npm whether there is a newer ACC.
|
|
7
|
+
*
|
|
8
|
+
* This is the only part of ACC that touches the network, and it is kept to one
|
|
9
|
+
* file for that reason. It is never on the hook path: a hook runs every turn
|
|
10
|
+
* inside a five-second budget and fails open, so a stalled socket there would
|
|
11
|
+
* cost every turn on the machine something and report nothing. `acc update`
|
|
12
|
+
* asks, `acc doctor` reads what was cached, and the answer is remembered for a
|
|
13
|
+
* day so a diagnostic run does not become traffic.
|
|
14
|
+
*
|
|
15
|
+
* `ACC_NO_UPDATE_CHECK=1` turns it off entirely, and then it says it is off
|
|
16
|
+
* rather than saying the version is current.
|
|
17
|
+
*/
|
|
18
|
+
const REGISTRY = "https://registry.npmjs.org/agents-can-communicate/latest";
|
|
19
|
+
const EVERY_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
const TIMEOUT_MS = 3_000;
|
|
21
|
+
|
|
22
|
+
const cacheFile = dataHome => path.join(dataHome, "acc", "update-check.json");
|
|
23
|
+
|
|
24
|
+
export const checkingIsOff = env => {
|
|
25
|
+
const value = env?.ACC_NO_UPDATE_CHECK;
|
|
26
|
+
return value !== undefined && value !== "" && value !== "0";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const numbers = version => (typeof version === "string"
|
|
30
|
+
? version.trim().split("-", 1)[0].split(".") : [])
|
|
31
|
+
.map(part => Number.parseInt(part, 10));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether `candidate` is a later release than `current`.
|
|
35
|
+
*
|
|
36
|
+
* Anything unreadable is not newer. Telling somebody to upgrade because a
|
|
37
|
+
* version could not be parsed is worse than saying nothing.
|
|
38
|
+
*/
|
|
39
|
+
export function isNewer(candidate, current) {
|
|
40
|
+
const left = numbers(candidate);
|
|
41
|
+
const right = numbers(current);
|
|
42
|
+
if (left.length === 0 || left.some(Number.isNaN)) return false;
|
|
43
|
+
if (right.length === 0 || right.some(Number.isNaN)) return false;
|
|
44
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
45
|
+
const one = left[index] ?? 0;
|
|
46
|
+
const other = right[index] ?? 0;
|
|
47
|
+
if (one !== other) return one > other;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a remembered answer is old enough to ask again. */
|
|
53
|
+
export function checkDue({ checkedAt, now, everyMs = EVERY_MS }) {
|
|
54
|
+
const last = Date.parse(checkedAt ?? "");
|
|
55
|
+
return !Number.isFinite(last) || now - last >= everyMs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readCachedCheck(dataHome, { readFile }) {
|
|
59
|
+
try {
|
|
60
|
+
const cached = JSON.parse(await readFile(cacheFile(dataHome), "utf8"));
|
|
61
|
+
return { latest: cached.latest ?? null, checkedAt: cached.checkedAt ?? null };
|
|
62
|
+
} catch {
|
|
63
|
+
// A missing or unreadable note is the same as never having asked.
|
|
64
|
+
return { latest: null, checkedAt: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function writeCachedCheck(dataHome, entry, { writeFile, mkdir }) {
|
|
69
|
+
const file = cacheFile(dataHome);
|
|
70
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
71
|
+
await writeFile(file, `${JSON.stringify(entry, null, 2)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Ask the registry. The one network call in the product. */
|
|
75
|
+
export async function fetchLatest({ get = fetch, url = REGISTRY,
|
|
76
|
+
timeoutMs = TIMEOUT_MS } = {}) {
|
|
77
|
+
const response = await get(url, { signal: AbortSignal.timeout(timeoutMs),
|
|
78
|
+
headers: { accept: "application/json" } });
|
|
79
|
+
if (response?.ok !== true) {
|
|
80
|
+
throw new AccError(EXIT.DATA, `the registry answered ${response?.status ?? "nothing"}`,
|
|
81
|
+
{ url });
|
|
82
|
+
}
|
|
83
|
+
const body = await response.json();
|
|
84
|
+
if (typeof body?.version !== "string" || numbers(body.version).some(Number.isNaN)) {
|
|
85
|
+
throw new AccError(EXIT.DATA, "the registry did not answer with a version", { url });
|
|
86
|
+
}
|
|
87
|
+
return body.version;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What `acc doctor` says about a newer release, without becoming a network
|
|
92
|
+
* command.
|
|
93
|
+
*
|
|
94
|
+
* The answer is remembered for a day, so running the diagnostic twice does not
|
|
95
|
+
* ask twice. A registry that cannot be reached changes nothing: doctor is about
|
|
96
|
+
* this machine, and a network that is down is not a fault in this install.
|
|
97
|
+
*/
|
|
98
|
+
export async function noticeUpdate({ dataHome, running, env, now, get, io }) {
|
|
99
|
+
if (checkingIsOff(env)) return { checked: false, latest: null, newer: false };
|
|
100
|
+
|
|
101
|
+
const cached = await readCachedCheck(dataHome, io);
|
|
102
|
+
let latest = cached.latest;
|
|
103
|
+
if (checkDue({ checkedAt: cached.checkedAt, now })) {
|
|
104
|
+
try {
|
|
105
|
+
latest = await fetchLatest({ get });
|
|
106
|
+
await writeCachedCheck(dataHome,
|
|
107
|
+
{ latest, checkedAt: new Date(now).toISOString() }, io);
|
|
108
|
+
} catch {
|
|
109
|
+
latest = cached.latest;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { checked: true, latest: latest ?? null, newer: isNewer(latest, running) };
|
|
113
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { platformPaths } from "./platform-paths.mjs";
|
|
8
|
+
import { checkingIsOff, fetchLatest, isNewer, writeCachedCheck } from "./update-check.mjs";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The two commands an upgrade takes, and why it is two.
|
|
14
|
+
*
|
|
15
|
+
* `npm install -g` replaces this CLI and the hook runtime, because the shim a
|
|
16
|
+
* client runs points into the npm directory rather than at a copy. It does not
|
|
17
|
+
* touch what was written into the client: its hook wiring, and the skills the
|
|
18
|
+
* agents read. Measured after an upgrade - the client still had `0.1.0` while
|
|
19
|
+
* `acc --version` said `0.1.1`, and nothing said so.
|
|
20
|
+
*/
|
|
21
|
+
export const upgradeSteps = version => [
|
|
22
|
+
["npm", ["install", "--global", `agents-can-communicate@${version}`]],
|
|
23
|
+
["acc", ["install"]],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const spell = ([command, argv]) => ` ${command} ${argv.join(" ")}`;
|
|
27
|
+
|
|
28
|
+
export async function runUpdateCommand({ options, runtime }) {
|
|
29
|
+
const env = runtime.env ?? {};
|
|
30
|
+
const { data: dataHome } = platformPaths({ platform: runtime.platform, env });
|
|
31
|
+
const running = typeof runtime.version === "function"
|
|
32
|
+
? await runtime.version().catch(() => null)
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
// Off means off, and it says so rather than reporting that nothing is newer -
|
|
36
|
+
// which is a different fact, and one this run did not establish.
|
|
37
|
+
if (checkingIsOff(env)) {
|
|
38
|
+
return { data: { checked: false, running, latest: null },
|
|
39
|
+
text: "update checking is off (ACC_NO_UPDATE_CHECK); nothing was asked" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Caught, and then refused rather than degraded. `doctor` and `install` can
|
|
43
|
+
// carry on without knowing which ACC is running - they say less - but this
|
|
44
|
+
// command is the comparison, and `isNewer(latest, null)` is false: carrying on
|
|
45
|
+
// would answer "you have the latest" on the strength of not knowing.
|
|
46
|
+
if (running === null) {
|
|
47
|
+
throw new AccError(EXIT.DATA,
|
|
48
|
+
"cannot read the installed version, so there is nothing to compare against");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const latest = await fetchLatest({ get: runtime.fetch });
|
|
52
|
+
await writeCachedCheck(dataHome, { latest, checkedAt: runtime.clock.now() },
|
|
53
|
+
{ writeFile, mkdir });
|
|
54
|
+
|
|
55
|
+
if (!isNewer(latest, running)) {
|
|
56
|
+
return { data: { checked: true, running, latest, newer: false },
|
|
57
|
+
text: `acc ${running} is the latest` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const steps = upgradeSteps(latest);
|
|
61
|
+
const data = { checked: true, running, latest, newer: true,
|
|
62
|
+
steps: steps.map(([command, argv]) => [command, ...argv].join(" ")) };
|
|
63
|
+
|
|
64
|
+
if (options.apply !== true) {
|
|
65
|
+
return { data, text: [`acc ${latest} is available; you have ${running}`, "",
|
|
66
|
+
...steps.map(spell), "", "or run: acc update --apply"].join("\n") };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const spawn = runtime.spawn ?? ((command, argv) => execFileAsync(command, argv, { env }));
|
|
70
|
+
const done = [];
|
|
71
|
+
for (const [command, argv] of steps) {
|
|
72
|
+
try {
|
|
73
|
+
await spawn(command, argv);
|
|
74
|
+
done.push([command, ...argv].join(" "));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Named rather than swallowed, and the rest of the commands are printed:
|
|
77
|
+
// a global install refused for want of permission is the ordinary case,
|
|
78
|
+
// and the person can finish it by hand from here.
|
|
79
|
+
return { data: { ...data, applied: done, failed: [command, ...argv].join(" ") },
|
|
80
|
+
text: [`${command} failed: ${error.message}`, "", "finish it with:",
|
|
81
|
+
...steps.slice(done.length).map(spell)].join("\n"),
|
|
82
|
+
error: undefined };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { data: { ...data, applied: done }, text: `updated to ${latest}` };
|
|
86
|
+
}
|
|
@@ -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) =>
|