agents-can-communicate 0.1.0
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/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
// Installation state belongs to the machine, never to a project. A repository
|
|
10
|
+
// carrying it would hand one machine's paths to every clone, where none of them
|
|
11
|
+
// exist and all of them look like something to clean up.
|
|
12
|
+
const recordPath = dataHome => path.join(dataHome, "acc", "installs.json");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Content fingerprint, or null when the file is gone.
|
|
16
|
+
*
|
|
17
|
+
* Uninstall compares this against what is on disk, so ACC removes what it wrote
|
|
18
|
+
* and leaves what someone has since made their own. Deleting by name alone
|
|
19
|
+
* throws away other people's work on the strength of a path it recognises.
|
|
20
|
+
*/
|
|
21
|
+
export async function fingerprint(file) {
|
|
22
|
+
try {
|
|
23
|
+
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === "ENOENT" || error.code === "EISDIR") return null;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fingerprint of a whole directory ACC created.
|
|
32
|
+
*
|
|
33
|
+
* Every file's path and contents, in sorted order, so an edit anywhere inside a
|
|
34
|
+
* plugin bundle is as visible as an edit to a single file. Sorting is what makes
|
|
35
|
+
* it reproducible: directory order is a filesystem detail, and a hash that
|
|
36
|
+
* depended on it would report a modification after a harmless copy.
|
|
37
|
+
*/
|
|
38
|
+
export async function treeFingerprint(root) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === "ENOENT") return null;
|
|
44
|
+
if (error.code === "ENOTDIR") return fingerprint(root);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const files = entries.filter(entry => entry.isFile())
|
|
48
|
+
.map(entry => path.relative(root, path.join(entry.parentPath ?? entry.path, entry.name)))
|
|
49
|
+
.sort();
|
|
50
|
+
const hash = createHash("sha256");
|
|
51
|
+
for (const relative of files) {
|
|
52
|
+
hash.update(relative).update("\0");
|
|
53
|
+
hash.update(await fingerprint(path.join(root, relative)) ?? "");
|
|
54
|
+
hash.update("\0");
|
|
55
|
+
}
|
|
56
|
+
return hash.digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fingerprintFor = (artifact) => (artifact.kind === "tree"
|
|
60
|
+
? treeFingerprint(artifact.path)
|
|
61
|
+
: fingerprint(artifact.path));
|
|
62
|
+
|
|
63
|
+
export async function loadOwnership({ dataHome }) {
|
|
64
|
+
const file = recordPath(dataHome);
|
|
65
|
+
let source;
|
|
66
|
+
try {
|
|
67
|
+
source = await readFile(file, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === "ENOENT") return { schemaVersion: SCHEMA_VERSION, installs: [] };
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
let record;
|
|
73
|
+
try {
|
|
74
|
+
record = JSON.parse(source);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Treating a corrupt record as empty would make the next uninstall a no-op
|
|
77
|
+
// and orphan every file ACC has ever written on this machine.
|
|
78
|
+
throw new AccError(EXIT.DATA, "the installation record is not valid JSON",
|
|
79
|
+
{ file, cause: error.message });
|
|
80
|
+
}
|
|
81
|
+
if (record?.schemaVersion !== SCHEMA_VERSION) {
|
|
82
|
+
throw new AccError(EXIT.DATA, "unknown installation record schemaVersion",
|
|
83
|
+
{ file, schemaVersion: record?.schemaVersion ?? null });
|
|
84
|
+
}
|
|
85
|
+
return { schemaVersion: SCHEMA_VERSION, installs: record.installs ?? [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function saveOwnership({ dataHome, record }) {
|
|
89
|
+
const file = recordPath(dataHome);
|
|
90
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
91
|
+
// Published by rename so a crash mid-write leaves the previous record intact
|
|
92
|
+
// rather than a truncated one that would fail to load at all.
|
|
93
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
94
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
95
|
+
await rename(temporary, file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Record what an install wrote, replacing any previous record for that adapter.
|
|
100
|
+
*
|
|
101
|
+
* Replacing rather than appending is what makes a re-run after a crash safe: the
|
|
102
|
+
* second run's record describes what is actually on disk now, and an accumulated
|
|
103
|
+
* one would list artifacts from a layout that no longer exists.
|
|
104
|
+
*/
|
|
105
|
+
export async function recordInstall({ dataHome, adapterId, version, artifacts }) {
|
|
106
|
+
const stamped = await Promise.all(artifacts.map(async artifact => ({
|
|
107
|
+
path: artifact.path,
|
|
108
|
+
kind: artifact.kind ?? "file",
|
|
109
|
+
// A merge artifact is a file ACC edited but does not own, so its bytes are
|
|
110
|
+
// expected to change and hashing them would only ever produce a false alarm.
|
|
111
|
+
sha256: artifact.kind === "merge" ? null : await fingerprintFor(artifact),
|
|
112
|
+
})));
|
|
113
|
+
const record = await loadOwnership({ dataHome });
|
|
114
|
+
await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
|
|
115
|
+
installs: [...record.installs.filter(install => install.adapterId !== adapterId),
|
|
116
|
+
{ adapterId, version, artifacts: stamped }] } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const installFor = (record, adapterId) =>
|
|
120
|
+
record.installs.find(install => install.adapterId === adapterId) ?? null;
|
|
121
|
+
|
|
122
|
+
/** Compare what was written against what is there now. Read-only. */
|
|
123
|
+
export async function verifyOwned({ dataHome, adapterId }) {
|
|
124
|
+
const install = installFor(await loadOwnership({ dataHome }), adapterId);
|
|
125
|
+
const result = { adapterId, present: install !== null, modified: [], missing: [],
|
|
126
|
+
intact: [], delegated: [] };
|
|
127
|
+
for (const artifact of install?.artifacts ?? []) {
|
|
128
|
+
if (artifact.kind === "merge") { result.delegated.push(artifact.path); continue; }
|
|
129
|
+
const current = await fingerprintFor(artifact);
|
|
130
|
+
if (current === null) result.missing.push(artifact.path);
|
|
131
|
+
else if (current !== artifact.sha256) result.modified.push(artifact.path);
|
|
132
|
+
else result.intact.push(artifact.path);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove the files this adapter's install wrote, and only those.
|
|
139
|
+
*
|
|
140
|
+
* A modified file is kept and reported. A merge artifact is never deleted at
|
|
141
|
+
* all: the user owns that file and ACC owns some entries inside it, which is the
|
|
142
|
+
* adapter's own uninstall to unpick because it knows the format.
|
|
143
|
+
*/
|
|
144
|
+
export async function removeOwned({ dataHome, adapterId }) {
|
|
145
|
+
const record = await loadOwnership({ dataHome });
|
|
146
|
+
const install = installFor(record, adapterId);
|
|
147
|
+
const result = { adapterId, removed: [], kept: [], missing: [], delegated: [] };
|
|
148
|
+
if (install === null) return result;
|
|
149
|
+
|
|
150
|
+
for (const artifact of install.artifacts) {
|
|
151
|
+
if (artifact.kind === "merge") { result.delegated.push(artifact.path); continue; }
|
|
152
|
+
const current = await fingerprintFor(artifact);
|
|
153
|
+
if (current === null) { result.missing.push(artifact.path); continue; }
|
|
154
|
+
if (current !== artifact.sha256) { result.kept.push(artifact.path); continue; }
|
|
155
|
+
await rm(artifact.path, { force: true, recursive: artifact.kind === "tree" });
|
|
156
|
+
result.removed.push(artifact.path);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
|
|
160
|
+
installs: record.installs.filter(entry => entry.adapterId !== adapterId) } });
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turn a detection report into exactly what would happen.
|
|
5
|
+
*
|
|
6
|
+
* Pure and deterministic: same detection, same plan, byte for byte. That is what
|
|
7
|
+
* makes `--dry-run` worth reading - a plan computed differently from the thing
|
|
8
|
+
* it previews is a decoration, and the operator would find out only afterwards.
|
|
9
|
+
*/
|
|
10
|
+
export function planInstallation({ adapters, detected, context, action = "install" }) {
|
|
11
|
+
if (!["install", "uninstall"].includes(action)) {
|
|
12
|
+
throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
|
|
13
|
+
}
|
|
14
|
+
const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
|
|
15
|
+
const operations = [];
|
|
16
|
+
const skipped = [];
|
|
17
|
+
|
|
18
|
+
// Sorted by id, so two runs on the same machine produce identical JSON and a
|
|
19
|
+
// diff between them means something changed rather than that a registry
|
|
20
|
+
// enumerated in a different order.
|
|
21
|
+
for (const entry of [...detected].sort((a, b) => a.adapterId.localeCompare(b.adapterId))) {
|
|
22
|
+
const adapter = byId.get(entry.adapterId);
|
|
23
|
+
if (adapter === undefined) {
|
|
24
|
+
skipped.push({ adapterId: entry.adapterId, reason: "no adapter for this client" });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!entry.present) {
|
|
28
|
+
// Named rather than dropped: "nothing happened" and "that client is not
|
|
29
|
+
// installed on this machine" look the same in an empty list.
|
|
30
|
+
skipped.push({ adapterId: entry.adapterId,
|
|
31
|
+
reason: `${entry.displayName ?? entry.adapterId} is not installed on this machine` });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (typeof adapter.planInstall !== "function") {
|
|
35
|
+
skipped.push({ adapterId: entry.adapterId,
|
|
36
|
+
reason: "this adapter cannot describe what it would write" });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const artifacts = adapter.planInstall(context)
|
|
41
|
+
.map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
|
|
42
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
43
|
+
|
|
44
|
+
operations.push({
|
|
45
|
+
adapterId: adapter.id,
|
|
46
|
+
displayName: adapter.displayName,
|
|
47
|
+
action,
|
|
48
|
+
clientVersion: entry.version ?? null,
|
|
49
|
+
alreadyInstalled: entry.installed === true,
|
|
50
|
+
artifacts,
|
|
51
|
+
// Said in the operator's terms, not in paths: which files ACC creates
|
|
52
|
+
// outright and which belong to the user and are only edited.
|
|
53
|
+
summary: [
|
|
54
|
+
...artifacts.filter(a => a.kind === "tree")
|
|
55
|
+
.map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
|
|
56
|
+
...artifacts.filter(a => a.kind === "merge")
|
|
57
|
+
.map(a => `${action === "install" ? "add ACC entries to" : "remove ACC entries from"} ${a.path}`),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { schemaVersion: 1, action, operations, skipped };
|
|
62
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
// Peer-authored strings reach a human's terminal through whatever renders these
|
|
4
|
+
// resources, so control sequences become visible escapes here rather than
|
|
5
|
+
// somewhere downstream. Attribution is never dropped: a message without its
|
|
6
|
+
// sender and type is indistinguishable from an instruction.
|
|
7
|
+
function escapeText(value) {
|
|
8
|
+
let result = "";
|
|
9
|
+
for (const character of String(value)) {
|
|
10
|
+
const code = character.codePointAt(0);
|
|
11
|
+
result += (code < 32 && code !== 10 && code !== 9) || code === 127
|
|
12
|
+
? `\\u${code.toString(16).padStart(4, "0")}`
|
|
13
|
+
: character;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const attributedMessage = message => ({
|
|
19
|
+
messageId: message.messageId,
|
|
20
|
+
from: message.fromSessionId,
|
|
21
|
+
type: message.type,
|
|
22
|
+
priority: message.priority,
|
|
23
|
+
requiresAck: message.requiresAck,
|
|
24
|
+
sentAt: message.sentAt,
|
|
25
|
+
trust: "untrusted peer content",
|
|
26
|
+
subject: escapeText(message.subject),
|
|
27
|
+
body: escapeText(message.body),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function readResource(uri, { service, participantId, workspaceId }) {
|
|
31
|
+
const snapshot = await service.store.snapshot(workspaceId);
|
|
32
|
+
switch (uri) {
|
|
33
|
+
case "acc://snapshot":
|
|
34
|
+
return { ...snapshot,
|
|
35
|
+
messages: snapshot.messages.map(attributedMessage) };
|
|
36
|
+
case "acc://roster":
|
|
37
|
+
return (await service.sync({ workspaceId })).roster;
|
|
38
|
+
case "acc://workstreams":
|
|
39
|
+
return snapshot.workstreams;
|
|
40
|
+
case "acc://tasks":
|
|
41
|
+
return snapshot.tasks;
|
|
42
|
+
case "acc://inbox": {
|
|
43
|
+
const mine = new Set(snapshot.receipts
|
|
44
|
+
.filter(receipt => receipt.recipientParticipantId === participantId)
|
|
45
|
+
.map(receipt => receipt.messageId));
|
|
46
|
+
// A participant sees what was addressed to it, plus what it sent, so a
|
|
47
|
+
// fresh reader can follow its own thread.
|
|
48
|
+
return snapshot.messages
|
|
49
|
+
.filter(message => mine.has(message.messageId)
|
|
50
|
+
|| message.toParticipantIds.includes(participantId)
|
|
51
|
+
|| snapshot.sessions.some(session => session.sessionId === message.fromSessionId
|
|
52
|
+
&& session.participantId === participantId))
|
|
53
|
+
.map(attributedMessage);
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
throw new AccError(EXIT.DATA, `unknown resource: ${uri}`, { uri });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
|
|
3
|
+
from "@agents-can-communicate/adapter-sdk";
|
|
4
|
+
|
|
5
|
+
import { readResource } from "./resources.mjs";
|
|
6
|
+
import { MCP_CAPABILITIES, PUBLIC_TOOLS, RESOURCES } from "./tools.mjs";
|
|
7
|
+
|
|
8
|
+
export const PROTOCOL_VERSION = "2026-07-28";
|
|
9
|
+
export const SUPPORTED_VERSIONS = Object.freeze([PROTOCOL_VERSION]);
|
|
10
|
+
const SERVER_INFO = Object.freeze({ name: "agents-can-communicate", version: "0.0.0" });
|
|
11
|
+
|
|
12
|
+
const META = "io.modelcontextprotocol";
|
|
13
|
+
const HEARTBEAT_CADENCE_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
const complete = result => ({ resultType: "complete",
|
|
16
|
+
_meta: { [`${META}/serverInfo`]: SERVER_INFO }, ...result });
|
|
17
|
+
|
|
18
|
+
function requireProtocolMeta(params) {
|
|
19
|
+
const meta = params?._meta ?? {};
|
|
20
|
+
const version = meta[`${META}/protocolVersion`];
|
|
21
|
+
const capabilities = meta[`${META}/clientCapabilities`];
|
|
22
|
+
// The revision requires both on every request and mandates -32602 when one is
|
|
23
|
+
// missing. No prior request may be used to supply them.
|
|
24
|
+
if (typeof version !== "string" || capabilities === undefined) {
|
|
25
|
+
throw Object.assign(new Error(
|
|
26
|
+
"each request requires _meta protocolVersion and clientCapabilities"),
|
|
27
|
+
{ rpcCode: -32602 });
|
|
28
|
+
}
|
|
29
|
+
if (!SUPPORTED_VERSIONS.includes(version)) {
|
|
30
|
+
throw Object.assign(new Error(`unsupported protocol version: ${version}`),
|
|
31
|
+
{ rpcCode: -32022, rpcData: { supported: [...SUPPORTED_VERSIONS] } });
|
|
32
|
+
}
|
|
33
|
+
return { version, capabilities };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the ACC session for this server from its own launch configuration.
|
|
38
|
+
*
|
|
39
|
+
* Approved 2026-08-16. The protocol is stateless and forbids treating process or
|
|
40
|
+
* connection identity as session continuity, so the session cannot be anchored
|
|
41
|
+
* to the stdio process. It is derived from the participant and workspace this
|
|
42
|
+
* server was configured with - available identically on every request - and
|
|
43
|
+
* persisted through a binding so a restarted process resolves to the same
|
|
44
|
+
* session instead of creating a second participant.
|
|
45
|
+
*/
|
|
46
|
+
async function resolveSession(context) {
|
|
47
|
+
const key = `mcp:${context.participantId}:${context.workspaceId}`;
|
|
48
|
+
const existing = await loadSessionBinding({ runtimeDir: context.runtimeDir,
|
|
49
|
+
harnessSessionId: key });
|
|
50
|
+
if (existing !== null) {
|
|
51
|
+
try {
|
|
52
|
+
return await context.service.heartbeatSession({ sessionId: existing.accSessionId,
|
|
53
|
+
generation: existing.generation, workspaceId: context.workspaceId });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code !== EXIT.CONFLICT) throw error;
|
|
56
|
+
// The recorded generation is gone. Clearing before reopening keeps the
|
|
57
|
+
// failure visible instead of silently accumulating bindings.
|
|
58
|
+
await clearSessionBinding({ runtimeDir: context.runtimeDir, harnessSessionId: key });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const session = await context.service.openSession({
|
|
62
|
+
workspaceId: context.workspaceId,
|
|
63
|
+
participantId: context.participantId,
|
|
64
|
+
displayName: context.participantId,
|
|
65
|
+
harness: "mcp",
|
|
66
|
+
heartbeatCadenceMs: HEARTBEAT_CADENCE_MS,
|
|
67
|
+
descriptor: context.descriptor,
|
|
68
|
+
});
|
|
69
|
+
await storeSessionBinding({ runtimeDir: context.runtimeDir, harnessSessionId: key,
|
|
70
|
+
accSessionId: session.sessionId, generation: session.generation });
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A poll is this client's turn.
|
|
76
|
+
*
|
|
77
|
+
* The hook runtime hands a session its pending messages when it builds a turn,
|
|
78
|
+
* and marks them delivered. An MCP client has no turn and no hook, and no tool
|
|
79
|
+
* ever handed it anything: it saw a `direct_request` line carrying a subject and
|
|
80
|
+
* an id, and to read what a peer had actually said it had to ask for the whole
|
|
81
|
+
* snapshot and search every message in the workspace for its own name.
|
|
82
|
+
*
|
|
83
|
+
* The receipt never moved either. It stayed `queued` for as long as the client
|
|
84
|
+
* ran, so the sender was told its message had not been delivered by an agent
|
|
85
|
+
* that had answered it.
|
|
86
|
+
*
|
|
87
|
+
* Returning them here is delivery, in the same sense and with the same honesty
|
|
88
|
+
* as the turn: what is handed over is marked `injected`, and nothing else is.
|
|
89
|
+
* Acknowledgement stays a separate act, because being shown something is not
|
|
90
|
+
* agreeing to it.
|
|
91
|
+
*/
|
|
92
|
+
async function syncWithMail(service, owner, context, args) {
|
|
93
|
+
const sync = await service.sync({ ...owner, cursor: args.cursor ?? null,
|
|
94
|
+
scope: args.scope, limit: args.limit });
|
|
95
|
+
const messages = await service.pendingMessages({
|
|
96
|
+
workspaceId: context.workspaceId,
|
|
97
|
+
participantId: context.participantId,
|
|
98
|
+
exceptSessionId: owner.sessionId });
|
|
99
|
+
if (messages.length === 0) return sync;
|
|
100
|
+
|
|
101
|
+
for (const message of messages) {
|
|
102
|
+
// One failure must not swallow the rest: the client is holding the message
|
|
103
|
+
// either way, and a receipt that cannot be written is not a reason to hide
|
|
104
|
+
// what a peer said.
|
|
105
|
+
await service.markDelivery({ ...owner, messageId: message.messageId,
|
|
106
|
+
state: "injected" }).catch(() => null);
|
|
107
|
+
}
|
|
108
|
+
return { ...sync, messages };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function callTool(name, args, context) {
|
|
112
|
+
const session = await resolveSession(context);
|
|
113
|
+
const owner = { sessionId: session.sessionId, generation: session.generation,
|
|
114
|
+
workspaceId: context.workspaceId, descriptor: context.descriptor };
|
|
115
|
+
const service = context.service;
|
|
116
|
+
|
|
117
|
+
switch (name) {
|
|
118
|
+
case "acc_sync":
|
|
119
|
+
return syncWithMail(service, owner, context, args);
|
|
120
|
+
case "acc_work":
|
|
121
|
+
if (args.clear === true) return service.clearIntent({ ...owner });
|
|
122
|
+
return service.setIntent({ ...owner, summary: args.summary, mode: args.mode,
|
|
123
|
+
state: args.state, workstreamId: args.workstreamId ?? null,
|
|
124
|
+
resourceHints: args.resourceHints ?? [] });
|
|
125
|
+
case "acc_claim":
|
|
126
|
+
if (args.action === "release") return service.releaseClaim({ ...owner,
|
|
127
|
+
claimId: args.claimId }) ?? { released: args.claimId };
|
|
128
|
+
if (args.action === "renew") return service.renewClaim({ ...owner,
|
|
129
|
+
claimId: args.claimId, leaseSeconds: args.leaseSeconds });
|
|
130
|
+
return service.acquireClaim({ ...owner, resource: args.resource,
|
|
131
|
+
mode: args.mode ?? "exclusive", enforcement: "advisory",
|
|
132
|
+
reason: args.reason ?? "unspecified", leaseSeconds: args.leaseSeconds });
|
|
133
|
+
case "acc_message":
|
|
134
|
+
return service.sendMessage({ ...owner, toParticipantIds: args.to ?? [],
|
|
135
|
+
subject: args.subject, body: args.body, type: args.type ?? "note",
|
|
136
|
+
priority: args.priority, requiresAck: args.requiresAck === true,
|
|
137
|
+
workstreamId: args.workstreamId ?? null });
|
|
138
|
+
case "acc_task":
|
|
139
|
+
if (args.action === "claim") return service.claimTask({ ...owner,
|
|
140
|
+
taskId: args.taskId, force: args.force === true });
|
|
141
|
+
if (args.action === "decline") return service.declineTask({ ...owner,
|
|
142
|
+
taskId: args.taskId, reason: args.reason });
|
|
143
|
+
if (args.action === "transition") return service.transitionTask({ ...owner,
|
|
144
|
+
taskId: args.taskId, state: args.state });
|
|
145
|
+
return service.createTask({ ...owner, workstreamId: args.workstreamId,
|
|
146
|
+
title: args.title, detail: args.detail, taskId: args.taskId,
|
|
147
|
+
assigneeParticipantId: args.assigneeParticipantId,
|
|
148
|
+
dependsOn: args.dependsOn ?? [] });
|
|
149
|
+
case "acc_request":
|
|
150
|
+
return service.requestWork({ ...owner, toParticipantId: args.toParticipantId,
|
|
151
|
+
title: args.title, detail: args.detail, workstreamId: args.workstreamId,
|
|
152
|
+
priority: args.priority, dependsOn: args.dependsOn ?? [] });
|
|
153
|
+
case "acc_ack":
|
|
154
|
+
return service.markDelivery({ ...owner, messageId: args.messageId,
|
|
155
|
+
state: args.state ?? "acknowledged" });
|
|
156
|
+
case "acc_decide":
|
|
157
|
+
return service.recordDecision({ ...owner, title: args.title, outcome: args.outcome,
|
|
158
|
+
authority: args.authority ?? "workstream", workstreamId: args.workstreamId ?? null,
|
|
159
|
+
decidedBy: args.decidedBy, supersedes: args.supersedes ?? null,
|
|
160
|
+
humanConfirmed: args.humanConfirmed === true });
|
|
161
|
+
case "acc_workstream":
|
|
162
|
+
if (args.action === "coordinate") {
|
|
163
|
+
return service.acquireCoordinator({ ...owner, workstreamId: args.workstreamId });
|
|
164
|
+
}
|
|
165
|
+
if (args.action === "release") {
|
|
166
|
+
return service.releaseCoordinator({ ...owner, workstreamId: args.workstreamId });
|
|
167
|
+
}
|
|
168
|
+
return service.createWorkstream({ ...owner, title: args.title,
|
|
169
|
+
objective: args.objective });
|
|
170
|
+
case "acc_finish":
|
|
171
|
+
return service.finishSession({ ...owner, goal: args.goal, status: args.status,
|
|
172
|
+
completed: args.completed ?? [], remaining: args.remaining ?? [],
|
|
173
|
+
blockers: args.blockers ?? [], toParticipantId: args.toParticipantId ?? null });
|
|
174
|
+
default:
|
|
175
|
+
throw new AccError(EXIT.USAGE, `unknown tool: ${name}`, { name });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function handle(message, context) {
|
|
180
|
+
const { method, params } = message;
|
|
181
|
+
if (method === "server/discover") {
|
|
182
|
+
requireProtocolMeta(params);
|
|
183
|
+
return complete({ supportedVersions: [...SUPPORTED_VERSIONS], capabilities: {
|
|
184
|
+
tools: {}, resources: {} }, serverInfo: SERVER_INFO, accCapabilities: MCP_CAPABILITIES });
|
|
185
|
+
}
|
|
186
|
+
requireProtocolMeta(params);
|
|
187
|
+
switch (method) {
|
|
188
|
+
case "tools/list":
|
|
189
|
+
return complete({ tools: [...PUBLIC_TOOLS] });
|
|
190
|
+
case "resources/list":
|
|
191
|
+
return complete({ resources: [...RESOURCES] });
|
|
192
|
+
case "resources/read": {
|
|
193
|
+
const value = await readResource(params.uri, context);
|
|
194
|
+
return complete({ contents: [{ uri: params.uri, mimeType: "application/json",
|
|
195
|
+
text: JSON.stringify(value, null, 2) }] });
|
|
196
|
+
}
|
|
197
|
+
case "tools/call": {
|
|
198
|
+
try {
|
|
199
|
+
const value = await callTool(params.name, params.arguments ?? {}, context);
|
|
200
|
+
return complete({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
201
|
+
structuredContent: JSON.stringify(value) });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// A failing operation is a tool result, not a transport failure: the
|
|
204
|
+
// model must see it and be able to react.
|
|
205
|
+
return complete({ isError: true,
|
|
206
|
+
content: [{ type: "text", text: `${params.name}: ${error.message}` }] });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
throw Object.assign(new Error(`unknown method: ${method}`), { rpcCode: -32601 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Newline-delimited JSON-RPC over the given streams. stdout carries protocol
|
|
216
|
+
* messages only; anything the server wants to say goes to stderr.
|
|
217
|
+
*/
|
|
218
|
+
export async function serve({ input, output, log, context }) {
|
|
219
|
+
const write = value => output.write(`${JSON.stringify(value)}\n`);
|
|
220
|
+
let buffer = "";
|
|
221
|
+
|
|
222
|
+
for await (const chunk of input) {
|
|
223
|
+
buffer += chunk;
|
|
224
|
+
let index = buffer.indexOf("\n");
|
|
225
|
+
while (index !== -1) {
|
|
226
|
+
const line = buffer.slice(0, index).trim();
|
|
227
|
+
buffer = buffer.slice(index + 1);
|
|
228
|
+
index = buffer.indexOf("\n");
|
|
229
|
+
if (line === "") continue;
|
|
230
|
+
let message;
|
|
231
|
+
try {
|
|
232
|
+
message = JSON.parse(line);
|
|
233
|
+
} catch {
|
|
234
|
+
write({ jsonrpc: "2.0", error: { code: -32700, message: "parse error" } });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Notifications get no reply, by rule.
|
|
238
|
+
if (message.id === undefined || message.id === null) continue;
|
|
239
|
+
try {
|
|
240
|
+
write({ jsonrpc: "2.0", id: message.id, result: await handle(message, context) });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
log?.(`${message.method}: ${error.message}`);
|
|
243
|
+
write({ jsonrpc: "2.0", id: message.id, error: {
|
|
244
|
+
code: error.rpcCode ?? -32603,
|
|
245
|
+
message: error.message,
|
|
246
|
+
...(error.rpcData === undefined ? {} : { data: error.rpcData }) } });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|