agents-can-communicate 0.1.7 → 0.1.9
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/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.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-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +20 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +81 -7
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +13 -4
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/status.mjs +10 -0
- 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/plan.mjs +58 -4
- 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
|
@@ -33,6 +33,12 @@ function escapePeerText(value) {
|
|
|
33
33
|
return String(value)
|
|
34
34
|
.replaceAll(new RegExp(`${FENCE}${BLOCK}`, "g"), `'${FENCE}${BLOCK}`)
|
|
35
35
|
.replaceAll(FENCE, `'${FENCE}`)
|
|
36
|
+
// The labels that frame this block are ACC's words at the start of a line.
|
|
37
|
+
// A peer writing one would otherwise produce a second line reading as ACC
|
|
38
|
+
// framing a different message - the same break-out the fence rule prevents,
|
|
39
|
+
// and neutralised the same way rather than by reflowing the text, which a
|
|
40
|
+
// handoff body cannot survive.
|
|
41
|
+
.replace(/^(subject:|body:)/gm, "'$1")
|
|
36
42
|
.replace(CONTROL_CHARACTERS,
|
|
37
43
|
character => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
|
|
38
44
|
}
|
|
@@ -118,12 +124,25 @@ function peerBlocks(messages) {
|
|
|
118
124
|
`${FENCE}${BLOCK}`,
|
|
119
125
|
`id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
|
|
120
126
|
+ " | untrusted peer message",
|
|
121
|
-
escapePeerText(message.subject)
|
|
127
|
+
`subject: ${oneLine(escapePeerText(message.subject))}`,
|
|
128
|
+
"body:",
|
|
122
129
|
escapePeerText(message.body),
|
|
123
130
|
FENCE,
|
|
124
131
|
]);
|
|
125
132
|
}
|
|
126
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A subject is one line, whatever the peer sent.
|
|
136
|
+
*
|
|
137
|
+
* The subject sits on the label's own line, so a newline inside it would push
|
|
138
|
+
* peer text to column 0 where ACC's labels live. Rendering the break visibly
|
|
139
|
+
* keeps the text readable and the frame ACC's.
|
|
140
|
+
*/
|
|
141
|
+
function oneLine(value) {
|
|
142
|
+
return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
127
146
|
/**
|
|
128
147
|
* Project a SyncResult into bounded text for one adapter to inject.
|
|
129
148
|
*
|
|
@@ -63,7 +63,10 @@ export const COMMANDS = Object.freeze({
|
|
|
63
63
|
// No `--yes`: neither of these ever asked, so the flag agreed to nothing. It
|
|
64
64
|
// was accepted and read by nobody, which is a promise that a confirmation
|
|
65
65
|
// exists to be skipped.
|
|
66
|
-
|
|
66
|
+
// `--downgrade` because an older acc first on PATH will otherwise rewire every
|
|
67
|
+
// client to itself, and the only symptom is a guard behaving like the version
|
|
68
|
+
// it came from.
|
|
69
|
+
install: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "downgrade"] },
|
|
67
70
|
// `--dry-run` on both, because the preview was computed for either action and
|
|
68
71
|
// only `install` could ask for it. Removal is the side that reaches into a
|
|
69
72
|
// client's configuration - including a client that has left the machine.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
@@ -30,11 +30,77 @@ import { diagnoseFilesystemStore, repairFilesystemStore }
|
|
|
30
30
|
* Unknown when the install predates the record carrying it, and then nothing is
|
|
31
31
|
* said: "your plugin might be old" on every run is not a diagnosis.
|
|
32
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* The ACC version a client will actually run, read out of its own shim.
|
|
35
|
+
*
|
|
36
|
+
* `staleInstall` compares the version recorded at install time against the one
|
|
37
|
+
* running now. That holds until the thing that rewrote the wiring is an ACC old
|
|
38
|
+
* enough not to know the field: an 0.1.1 first on PATH for one install rewired
|
|
39
|
+
* four clients to itself and rewrote the record with `accVersion: null`, erasing
|
|
40
|
+
* the evidence along with the wiring. The record is written by whoever writes
|
|
41
|
+
* last; the shim carries the absolute path of the runner the client executes,
|
|
42
|
+
* and an old ACC writes it honestly, pointing at itself.
|
|
43
|
+
*
|
|
44
|
+
* Null for anything unreadable. "Might be old" on every run is not a diagnosis.
|
|
45
|
+
*/
|
|
46
|
+
export async function wiredVersion(shimPath) {
|
|
47
|
+
if (typeof shimPath !== "string" || shimPath === "") return null;
|
|
48
|
+
const text = await readFile(shimPath, "utf8").catch(() => null);
|
|
49
|
+
if (text === null) return null;
|
|
50
|
+
const runner = /["']?(\/[^"'\s]*\/agents-can-communicate)\/bin\/acc-hook\.mjs["']?/.exec(text);
|
|
51
|
+
if (runner === null) return null;
|
|
52
|
+
const manifest = await readFile(path.join(runner[1], "package.json"), "utf8")
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
if (manifest === null) return null;
|
|
55
|
+
try {
|
|
56
|
+
const version = JSON.parse(manifest).version;
|
|
57
|
+
return typeof version === "string" ? version : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
export function staleInstall({ recorded, running }) {
|
|
34
64
|
if (typeof recorded !== "string" || typeof running !== "string") return null;
|
|
35
65
|
return recorded === running ? null : { recorded, running };
|
|
36
66
|
}
|
|
37
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The runner version behind whatever ACC wrote for one client.
|
|
70
|
+
*
|
|
71
|
+
* Two shapes, because the four clients differ: a config file ACC merged its hook
|
|
72
|
+
* commands into, and a tree ACC created with a shim inside it. The first
|
|
73
|
+
* readable answer wins, and every read is best-effort - a doctor that throws on
|
|
74
|
+
* a missing file diagnoses nothing.
|
|
75
|
+
*/
|
|
76
|
+
async function wiredVersionFor(artifacts) {
|
|
77
|
+
for (const artifact of artifacts ?? []) {
|
|
78
|
+
if (artifact.kind === "tree") {
|
|
79
|
+
for (const shim of await findShims(artifact.path, 4)) {
|
|
80
|
+
const version = await wiredVersion(shim);
|
|
81
|
+
if (version !== null) return version;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const version = await wiredVersion(artifact.path);
|
|
86
|
+
if (version !== null) return version;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Shim files under a tree ACC created, to a bounded depth. */
|
|
92
|
+
async function findShims(root, depth) {
|
|
93
|
+
if (depth < 0) return [];
|
|
94
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
95
|
+
const found = [];
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const target = path.join(root, entry.name);
|
|
98
|
+
if (entry.isDirectory()) found.push(...await findShims(target, depth - 1));
|
|
99
|
+
else if (entry.name.endsWith(".sh")) found.push(target);
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
38
104
|
async function diagnoseAdapters({ options, runtime }) {
|
|
39
105
|
// The same home `acc install --home` writes to, or the real one. Reading a
|
|
40
106
|
// different home than install wrote to reports every adapter as missing.
|
|
@@ -64,16 +130,24 @@ async function diagnoseAdapters({ options, runtime }) {
|
|
|
64
130
|
if (owned.missing.length > 0) {
|
|
65
131
|
remediation.push(`acc install --adapter ${entry.adapterId} # files are missing`);
|
|
66
132
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
?.accVersion ?? null,
|
|
70
|
-
running });
|
|
133
|
+
const installed = record.installs.find(one => one.adapterId === entry.adapterId);
|
|
134
|
+
const stale = staleInstall({ recorded: installed?.accVersion ?? null, running });
|
|
71
135
|
if (stale !== null) {
|
|
72
136
|
remediation.push(`acc install --adapter ${entry.adapterId}`
|
|
73
137
|
+ ` # plugin is ${stale.recorded}, acc is ${stale.running}`);
|
|
74
138
|
}
|
|
75
|
-
|
|
76
|
-
|
|
139
|
+
// Read from the wiring rather than from the record. An ACC old enough not to
|
|
140
|
+
// know the record's version field still rewrites that record - blank - while
|
|
141
|
+
// pointing every client at itself, so the record goes quiet exactly when it
|
|
142
|
+
// matters. The shim names the runner the client will execute.
|
|
143
|
+
const wired = await wiredVersionFor(installed?.artifacts);
|
|
144
|
+
if (stale === null && typeof wired === "string" && typeof running === "string"
|
|
145
|
+
&& wired !== running) {
|
|
146
|
+
remediation.push(`acc install --adapter ${entry.adapterId}`
|
|
147
|
+
+ ` # wired to acc ${wired}, this is ${running}`);
|
|
148
|
+
}
|
|
149
|
+
return { ...entry, stale, wired, owned: { modified: owned.modified,
|
|
150
|
+
missing: owned.missing, intact: owned.intact.length }, remediation };
|
|
77
151
|
}));
|
|
78
152
|
}
|
|
79
153
|
|
|
@@ -168,10 +168,11 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
168
168
|
// An uninstall is planned from what ACC recorded writing, not only from what
|
|
169
169
|
// is on the machine now. A client can be removed after ACC installed into it,
|
|
170
170
|
// and its configuration directory - with ACC's files in it - stays behind.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
// Read on both actions now. An uninstall plans from it because a client can
|
|
172
|
+
// leave the machine after ACC wrote to it; an install reads it to see which
|
|
173
|
+
// ACC is already wired, so an older copy cannot replace a newer one in
|
|
174
|
+
// silence.
|
|
175
|
+
const recorded = (await loadOwnership({ dataHome })).installs;
|
|
175
176
|
|
|
176
177
|
const dryRun = options.dryRun === true;
|
|
177
178
|
// Recorded with the install, so a later run can tell that the bundle sitting
|
|
@@ -180,6 +181,14 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
180
181
|
const accVersion = typeof runtime.version === "function"
|
|
181
182
|
? await runtime.version().catch(() => null)
|
|
182
183
|
: null;
|
|
184
|
+
// Naming a client is an answer to the question the version probe was guessing
|
|
185
|
+
// at. `acc install --adapter gemini_cli` says the client is here, whatever
|
|
186
|
+
// PATH this process happens to carry.
|
|
187
|
+
const requested = options.adapter === undefined
|
|
188
|
+
? []
|
|
189
|
+
: (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
|
|
190
|
+
const plan = planInstallation({ adapters, detected, context, action, recorded,
|
|
191
|
+
accVersion, allowDowngrade: options.downgrade === true, requested });
|
|
183
192
|
const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
|
|
184
193
|
|
|
185
194
|
const acted = actedOn(result);
|
|
@@ -108,12 +108,22 @@ export function createStatusService(ports, sessions) {
|
|
|
108
108
|
state: workstream.state,
|
|
109
109
|
coordinatorSessionId: workstream.coordinatorSessionId,
|
|
110
110
|
})),
|
|
111
|
+
// The owner is named twice on purpose. Every command that reaches a peer
|
|
112
|
+
// takes a participant id, so a claim that gave only a session id sent the
|
|
113
|
+
// reader back through the roster to answer "who is holding this, and how
|
|
114
|
+
// do I ask them for it". The session id stays because it is what
|
|
115
|
+
// `acc release --authority` acts on, and because two sessions of one
|
|
116
|
+
// participant are still two holders.
|
|
111
117
|
claims: claims.map(claim => ({
|
|
112
118
|
claimId: claim.claimId,
|
|
113
119
|
resource: claim.resource,
|
|
114
120
|
mode: claim.mode,
|
|
115
121
|
enforcement: claim.enforcement,
|
|
116
122
|
ownerSessionId: claim.ownerSessionId,
|
|
123
|
+
// Read from every session on record, not only the live ones: a claim
|
|
124
|
+
// outliving its session is exactly when this question gets asked.
|
|
125
|
+
ownerParticipantId: sessionRecords
|
|
126
|
+
.find(session => session.sessionId === claim.ownerSessionId)?.participantId ?? null,
|
|
117
127
|
expiresAt: claim.expiresAt,
|
|
118
128
|
})),
|
|
119
129
|
attention: computeAttention(snapshot, { session: null,
|
|
@@ -8,7 +8,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
8
8
|
* it previews is a decoration, and the operator would find out only afterwards.
|
|
9
9
|
*/
|
|
10
10
|
export function planInstallation({ adapters, detected, context, action = "install",
|
|
11
|
-
recorded = [] }) {
|
|
11
|
+
recorded = [], accVersion = null, allowDowngrade = false, requested = [] }) {
|
|
12
12
|
if (!["install", "uninstall"].includes(action)) {
|
|
13
13
|
throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
|
|
14
14
|
}
|
|
@@ -17,6 +17,15 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
17
17
|
// authority rather than detection: the record is the only account of what was
|
|
18
18
|
// written, and a client's configuration directory outlives the client.
|
|
19
19
|
const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
|
|
20
|
+
// Presence is decided by running the client's `--version`, which answers "can
|
|
21
|
+
// ACC run this client" - not the question that matters, since ACC never runs
|
|
22
|
+
// it: the client runs ACC's hook. A client installed under a different Node
|
|
23
|
+
// version sat there with its own configuration directory while every install
|
|
24
|
+
// reported it missing. Its existence cannot be inferred from that directory,
|
|
25
|
+
// because ACC creates one itself - an earlier attempt at this read a client's
|
|
26
|
+
// own test fixture as proof the client was there. A person naming the client
|
|
27
|
+
// can be.
|
|
28
|
+
const askedFor = new Set(requested ?? []);
|
|
20
29
|
const operations = [];
|
|
21
30
|
const skipped = [];
|
|
22
31
|
|
|
@@ -38,11 +47,30 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
38
47
|
? recordedById.get(entry.adapterId)
|
|
39
48
|
: undefined;
|
|
40
49
|
|
|
41
|
-
if (!entry.present && record === undefined) {
|
|
50
|
+
if (!entry.present && record === undefined && !askedFor.has(entry.adapterId)) {
|
|
42
51
|
// Named rather than dropped: "nothing happened" and "that client is not
|
|
43
|
-
// installed on this machine" look the same in an empty list.
|
|
52
|
+
// installed on this machine" look the same in an empty list. And the
|
|
53
|
+
// message names the way past itself, because the verdict is on PATH
|
|
54
|
+
// rather than on the machine.
|
|
44
55
|
skipped.push({ adapterId: entry.adapterId,
|
|
45
|
-
reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine`
|
|
56
|
+
reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine; `
|
|
57
|
+
+ `if it is, wire it with --adapter ${entry.adapterId}` });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// The version doing the installing is whichever `acc` came first on PATH,
|
|
61
|
+
// and the shim it writes pins that copy's node and runner. So a second ACC
|
|
62
|
+
// on the machine quietly replaces every client's wiring with its own, older
|
|
63
|
+
// code, and the only symptom is a guard behaving like the version it came
|
|
64
|
+
// from. `recordInstall` has always written `accVersion` for exactly this
|
|
65
|
+
// comparison; nothing read it in this direction until now.
|
|
66
|
+
//
|
|
67
|
+
// Never on an uninstall: the older ACC is often the only thing that knows
|
|
68
|
+
// what it wrote, and refusing it would strand the wiring this undoes.
|
|
69
|
+
const wired = recordedById.get(entry.adapterId)?.accVersion;
|
|
70
|
+
if (action === "install" && !allowDowngrade && isOlder(accVersion, wired)) {
|
|
71
|
+
skipped.push({ adapterId: entry.adapterId,
|
|
72
|
+
reason: `${wired} is already wired here and this is ${accVersion}; `
|
|
73
|
+
+ "run the newer acc, or pass --downgrade to wire this one deliberately" });
|
|
46
74
|
continue;
|
|
47
75
|
}
|
|
48
76
|
if (record === undefined && typeof adapter.planInstall !== "function") {
|
|
@@ -83,3 +111,29 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
83
111
|
}
|
|
84
112
|
return { schemaVersion: 1, action, operations, skipped };
|
|
85
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is `candidate` an earlier release than `installed`?
|
|
117
|
+
*
|
|
118
|
+
* Numeric per segment, because the first comparison that matters in practice is
|
|
119
|
+
* 0.1.9 against 0.1.10, where a string comparison reverses the answer and starts
|
|
120
|
+
* refusing every legitimate install. Anything unreadable answers false: a
|
|
121
|
+
* corrupt record must not make the machine unrepairable by the command that
|
|
122
|
+
* exists to repair it.
|
|
123
|
+
*/
|
|
124
|
+
function isOlder(candidate, installed) {
|
|
125
|
+
const parse = value => {
|
|
126
|
+
if (typeof value !== "string") return null;
|
|
127
|
+
const parts = value.trim().split(".");
|
|
128
|
+
if (parts.length !== 3) return null;
|
|
129
|
+
const numbers = parts.map(part => (/^\d+$/.test(part) ? Number(part) : null));
|
|
130
|
+
return numbers.includes(null) ? null : numbers;
|
|
131
|
+
};
|
|
132
|
+
const left = parse(candidate);
|
|
133
|
+
const right = parse(installed);
|
|
134
|
+
if (left === null || right === null) return false;
|
|
135
|
+
for (let index = 0; index < 3; index += 1) {
|
|
136
|
+
if (left[index] !== right[index]) return left[index] < right[index];
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|