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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/acc-hook.mjs +53 -0
  4. package/bin/acc-mcp.mjs +46 -0
  5. package/bin/acc.mjs +24 -0
  6. package/docs/CAPABILITIES.md +153 -0
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
  8. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
  9. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
  14. package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
  16. package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
  22. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
  29. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
  30. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
  31. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
  32. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
  33. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
  34. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
  36. package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
  37. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
  38. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
  43. package/node_modules/@agents-can-communicate/cli/package.json +12 -0
  44. package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
  45. package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
  46. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
  47. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
  48. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
  49. package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
  50. package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
  53. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
  54. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
  55. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
  56. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
  57. package/node_modules/@agents-can-communicate/core/package.json +12 -0
  58. package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
  59. package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
  60. package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
  61. package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
  62. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
  63. package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
  64. package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
  65. package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
  70. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
  71. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
  73. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
  74. package/node_modules/@agents-can-communicate/installer/package.json +10 -0
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
  76. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
  77. package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
  80. package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
  84. package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
  85. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
  86. package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
  87. package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
  88. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
  89. package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
  90. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
  91. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
  92. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
  93. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
  98. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
  99. package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
  100. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
  101. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
  103. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
  104. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
  105. package/package.json +81 -0
@@ -0,0 +1,125 @@
1
+ import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
2
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
3
+
4
+ // Not read from documentation and not guessed: this client validates its config
5
+ // with a strict schema, and rejecting an empty hook entry makes it name every
6
+ // accepted event. See COMPATIBILITY.md for the transcript.
7
+ export const KIMI_HOOK_EVENTS = Object.freeze(["PreToolUse", "PostToolUse",
8
+ "PostToolUseFailure", "PermissionRequest", "PermissionResult", "UserPromptSubmit",
9
+ "UserPromptQueued", "TurnStarted", "Stop", "StopFailure", "Interrupt", "SessionStart",
10
+ "SessionEnd", "SessionHeartbeat", "SubagentStart", "SubagentStop", "TaskStarted",
11
+ "PreCompact", "PostCompact", "Notification"]);
12
+
13
+ // The tools this client actually names. Codex calls its editor apply_patch, and
14
+ // a matcher borrowed across harnesses guards nothing at all, so these were read
15
+ // out of a real request rather than assumed from the family resemblance.
16
+ export const KIMI_EDIT_TOOLS = Object.freeze(["Write", "Edit"]);
17
+ export const KIMI_SHELL_TOOLS = Object.freeze(["Bash"]);
18
+
19
+ const KIND_BY_EVENT = Object.freeze({
20
+ SessionStart: "sessionStart",
21
+ SessionEnd: "sessionEnd",
22
+ SessionHeartbeat: "heartbeat",
23
+ UserPromptSubmit: "beforeTurn",
24
+ PreToolUse: "beforeTool",
25
+ PostToolUse: "afterTool",
26
+ PostToolUseFailure: "afterTool",
27
+ Stop: "turnEnd",
28
+ StopFailure: "turnEnd",
29
+ SubagentStart: "childStart",
30
+ SubagentStop: "childEnd",
31
+ });
32
+
33
+ /**
34
+ * Normalise a Kimi Code hook payload.
35
+ *
36
+ * A whitelist, not a filter. `TurnStarted` carries the raw prompt, and
37
+ * `UserPromptSubmit` carries it as content blocks; `PostToolUse` carries the
38
+ * tool's output and `PostToolUseFailure` the error text. This client hands
39
+ * conversation content to hooks directly, so keeping it out is a property of
40
+ * this function rather than an absence of opportunity.
41
+ */
42
+ export function normalizeKimiHook(payload) {
43
+ const event = payload?.hook_event_name;
44
+ if (typeof event !== "string" || !KIMI_HOOK_EVENTS.includes(event)) {
45
+ throw new AccError(EXIT.DATA, "unrecognised Kimi Code hook event",
46
+ { event: event ?? null });
47
+ }
48
+ if (typeof payload.session_id !== "string" || typeof payload.cwd !== "string") {
49
+ throw new AccError(EXIT.DATA, "hook payload has no session id or working directory",
50
+ { event, received: Object.keys(payload) });
51
+ }
52
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : null;
53
+ return normalizedEvent({
54
+ kind: KIND_BY_EVENT[event] ?? "other",
55
+ sessionId: payload.session_id,
56
+ cwd: payload.cwd,
57
+ // Only SessionStart carries it; every other event leaves it null rather
58
+ // than inheriting a stale value.
59
+ model: typeof payload.model === "string" ? payload.model : null,
60
+ // No subagent was observed running, so there is no field to map yet and
61
+ // nothing is invented from timing.
62
+ parentSessionId: null,
63
+ tool,
64
+ targets: writeTargets(tool, payload.tool_input),
65
+ });
66
+ }
67
+
68
+ /**
69
+ * The paths a tool call would write.
70
+ *
71
+ * Both editing tools take `path` - confirmed from their declared schemas, where
72
+ * `Write` requires `path` and `content` and `Edit` requires `path`,
73
+ * `old_string` and `new_string`. Nothing else is read: the contents and the
74
+ * replacement strings are conversation content and stay out.
75
+ *
76
+ * `Bash` declares nothing. A command can write anywhere, and a path guessed out
77
+ * of one would give a guard that blocks work it holds no claim over while
78
+ * missing writes it does.
79
+ */
80
+ function writeTargets(tool, input) {
81
+ if (!KIMI_EDIT_TOOLS.includes(tool)) return [];
82
+ const path = input?.path;
83
+ return typeof path === "string" && path !== "" ? [path] : [];
84
+ }
85
+
86
+ /**
87
+ * Deny a tool call in the one shape this client acts on.
88
+ *
89
+ * Five candidate shapes were run against a real session. Only two stopped the
90
+ * tool: exit code 2, and this structured reply. `{"decision":"block"}`,
91
+ * `{"permission":"deny"}` and exit code 1 all looked plausible and let the write
92
+ * through. This one is preferred over exit 2 because the reason survives: it
93
+ * reaches the model as the failed call's `error.message`.
94
+ */
95
+ export function denyResponse(reason) {
96
+ return { hookSpecificOutput: { hookEventName: "PreToolUse",
97
+ permissionDecision: "deny", permissionDecisionReason: reason } };
98
+ }
99
+
100
+ export function allowResponse() {
101
+ return {};
102
+ }
103
+
104
+ /**
105
+ * Context for the next turn, as this client delivers it.
106
+ *
107
+ * Unlike Claude Code, this client does not unwrap `additionalContext`: it wraps
108
+ * a hook's entire stdout in `<hook_result hook_event="...">` and shows the model
109
+ * whatever that was. Emitting the JSON envelope here would put the envelope
110
+ * itself into the conversation, so the injection is plain text.
111
+ */
112
+ export function injectResponse(context) {
113
+ return context === "" ? null : context;
114
+ }
115
+
116
+ // How a denial and an injection reach this client. Both were measured; they use
117
+ // different mechanisms, and neither matches all three of the other adapters.
118
+ export function denyOutcome(reason) {
119
+ return { stdout: `${JSON.stringify(denyResponse(reason))}\n`, stderr: "", exitCode: 0 };
120
+ }
121
+
122
+ export function injectOutcome(context) {
123
+ const rendered = injectResponse(context);
124
+ return { stdout: rendered === null ? "" : `${rendered}\n`, stderr: "", exitCode: 0 };
125
+ }
@@ -0,0 +1,216 @@
1
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { assertRunner, bakeSkillCommand, blankText, defaultRunner, removeIfEmpty,
8
+ removeInstalledTree, runnerExists, writeForeignJson }
9
+ from "@agents-can-communicate/adapter-sdk";
10
+
11
+ const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
12
+ const PLUGIN_NAME = "agents-can-communicate";
13
+
14
+ // The block markers are the whole ownership story. This client keeps its hooks
15
+ // in the user's TOML config, and ACC ships without dependencies, so parsing and
16
+ // re-emitting that file would mean writing a TOML round-tripper and losing the
17
+ // user's comments and formatting to it. Instead ACC owns a delimited region and
18
+ // never reads the rest: install replaces the region, uninstall deletes it, and
19
+ // everything outside comes back byte for byte.
20
+ export const BEGIN = "# >>> agents-can-communicate (managed; edits here are overwritten)";
21
+ export const END = "# <<< agents-can-communicate";
22
+
23
+ // Seconds, not milliseconds. A hook that sleeps 3s dies under `timeout = 1`,
24
+ // which is how this was settled; copying another harness's `10000` would have
25
+ // let a hung hook stall a turn for the better part of three hours.
26
+ const TIMEOUT_SECONDS = 10;
27
+
28
+ // Only events observed firing get wired. Guard events carry the matcher, which
29
+ // was proven to select: "NoSuchTool" never fired, "Write|Edit|Bash" fired twice.
30
+ const WIRING = Object.freeze([
31
+ { event: "SessionStart", kind: "sessionStart" },
32
+ { event: "UserPromptSubmit", kind: "beforeTurn" },
33
+ { event: "SessionHeartbeat", kind: "heartbeat" },
34
+ { event: "PreToolUse", kind: "beforeTool", matcher: "Write|Edit|Bash" },
35
+ { event: "Stop", kind: "turnEnd" },
36
+ ]);
37
+
38
+ const configPath = home => path.join(home, "config.toml");
39
+ const pluginPath = home => path.join(home, "plugins", "managed", PLUGIN_NAME);
40
+ const registryPath = home => path.join(home, "plugins", "installed.json");
41
+
42
+ // This client's config has no variable to stand in for an install directory -
43
+ // unlike the plugin manifests of the other three, which expand a plugin root -
44
+ // so the runner is written in as an absolute path at install time. Codex taught
45
+ // this the hard way: a relative hook command fails silently, hook after hook.
46
+
47
+ // TOML basic strings take backslash escapes. A path is user-controlled input
48
+ // here, so it is escaped rather than trusted to be boring.
49
+ const tomlString = value => `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
50
+
51
+ async function readText(file, fallback) {
52
+ try {
53
+ return await readFile(file, "utf8");
54
+ } catch (error) {
55
+ if (error.code === "ENOENT") return fallback;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read a client's own JSON, and say which file when it will not parse.
62
+ *
63
+ * A malformed config is the user's to fix, and the message they get has to name
64
+ * it. `Unexpected end of JSON input` arrived with no path attached, from an
65
+ * install that touches four clients' homes, and left them to guess which.
66
+ */
67
+ async function readJson(file, fallback) {
68
+ const source = await readText(file, null);
69
+ if (source === null) return fallback;
70
+ try {
71
+ return JSON.parse(source);
72
+ } catch (error) {
73
+ throw new AccError(EXIT.DATA, `${file} is not valid JSON: ${error.message}`,
74
+ { file, cause: error.message });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Remove ACC's region and nothing else.
80
+ *
81
+ * Written to survive a config that has no block, several blocks, or a block the
82
+ * user has half-deleted: an unterminated marker consumes to end of file rather
83
+ * than leaving stray `[[hooks]]` entries that would fail the client's schema
84
+ * and lock the user out of their own tool.
85
+ */
86
+ export function stripBlock(source) {
87
+ const lines = source.split("\n");
88
+ const kept = [];
89
+ let inside = false;
90
+ for (const line of lines) {
91
+ if (line.trimEnd() === BEGIN) { inside = true; continue; }
92
+ if (inside) {
93
+ if (line.trimEnd() === END) inside = false;
94
+ continue;
95
+ }
96
+ kept.push(line);
97
+ }
98
+ return kept.join("\n");
99
+ }
100
+
101
+ export function renderBlock(runner, node = process.execPath) {
102
+ const entries = WIRING.map(({ event, kind, matcher }) => {
103
+ // Both paths are shell-quoted inside the TOML string: a hook runs through a
104
+ // shell, and a space in either path would otherwise split the command.
105
+ // The interpreter is named outright rather than left to PATH, which a hook's
106
+ // environment does not reliably carry.
107
+ const command = `${tomlString(node)} ${tomlString(runner)} kimi ${kind}`;
108
+ const lines = ["[[hooks]]", `event = ${tomlString(event)}`,
109
+ `command = ${tomlString(command)}`];
110
+ if (matcher !== undefined) lines.push(`matcher = ${tomlString(matcher)}`);
111
+ lines.push(`timeout = ${TIMEOUT_SECONDS}`);
112
+ return lines.join("\n");
113
+ });
114
+ return [BEGIN, ...entries, END].join("\n");
115
+ }
116
+
117
+ const registerPlugin = (registry, root) => {
118
+ const plugins = (registry.plugins ?? []).filter(entry => entry.id !== PLUGIN_NAME);
119
+ return { ...registry, version: registry.version ?? 1,
120
+ plugins: [...plugins, { id: PLUGIN_NAME, root, source: "local", enabled: true }] };
121
+ };
122
+
123
+ export async function installKimiPlugin({ home, runner = defaultRunner(), node }) {
124
+ // A hook whose command does not exist fails silently, on every event, for as
125
+ // long as it stays installed: the client reports nothing and ACC simply never
126
+ // sees a session. Writing that entry and hoping is worse than refusing.
127
+ await assertRunner(runner);
128
+ // Read before writing: a registry that will not parse must be found before a
129
+ // plugin tree is laid down that nothing will then be able to remove.
130
+ const registered = await readJson(registryPath(home), { version: 1, plugins: [] });
131
+
132
+ const target = pluginPath(home);
133
+ await rm(target, { recursive: true, force: true });
134
+ await cp(bundle, target, { recursive: true });
135
+ // The skill ships with a placeholder where the command belongs: `acc` is
136
+ // not on PATH everywhere, and an agent that cannot run it improvises.
137
+ await bakeSkillCommand({ root: target, node });
138
+
139
+ const file = configPath(home);
140
+ const existing = await readText(file, "");
141
+ const withoutOurs = stripBlock(existing);
142
+ // A block appended at top level closes whatever table preceded it, so the
143
+ // user's last section cannot swallow our entries.
144
+ const separator = withoutOurs === "" || withoutOurs.endsWith("\n") ? "" : "\n";
145
+ await mkdir(path.dirname(file), { recursive: true });
146
+ await writeFile(file, `${withoutOurs}${separator}${renderBlock(runner, node)}\n`);
147
+
148
+ const registry = registryPath(home);
149
+ await mkdir(path.dirname(registry), { recursive: true });
150
+ await writeForeignJson(registry, registerPlugin(registered, target),
151
+ { readFile, writeFile, mkdir });
152
+
153
+ return { ok: true, changes: [target, file, registry], diagnostics: [] };
154
+ }
155
+
156
+ export async function uninstallKimiPlugin({ home, keep = [] }) {
157
+ const file = configPath(home);
158
+ const existing = await readText(file, null);
159
+ const changes = [];
160
+ if (existing !== null) {
161
+ const stripped = stripBlock(existing);
162
+ if (stripped !== existing) changes.push(file);
163
+ await writeFile(file, stripped);
164
+ }
165
+
166
+ const registry = registryPath(home);
167
+ const loaded = await readJson(registry, null);
168
+ if (loaded !== null) {
169
+ const plugins = (loaded.plugins ?? []).filter(entry => entry.id !== PLUGIN_NAME);
170
+ if (plugins.length !== (loaded.plugins ?? []).length) changes.push(registry);
171
+ // The registry exists because a plugin was installed. Leaving an empty one
172
+ // behind is litter in a home that had no such file - measured after an
173
+ // uninstall that was otherwise clean. An empty registry and an absent one
174
+ // mean the same thing to this client.
175
+ if (plugins.length > 0) {
176
+ await writeForeignJson(registry, { ...loaded, plugins },
177
+ { readFile, writeFile, mkdir });
178
+ } else {
179
+ await rm(registry, { force: true });
180
+ }
181
+ }
182
+
183
+ // Blank and absent are the same to this client, so there is nothing to lose
184
+ // by removing a config that holds nothing - and no record of who created it is
185
+ // needed, unlike a JSON settings file where `{}` can be the user's own.
186
+ await removeIfEmpty(configPath(home), { readFile, rm, isEmpty: blankText });
187
+
188
+ await removeInstalledTree(pluginPath(home), keep);
189
+ return { ok: true, changes, diagnostics: [] };
190
+ }
191
+
192
+ export async function detectKimi({ home, runner = defaultRunner() }) {
193
+ const source = await readText(configPath(home), "");
194
+ const installed = source.includes(BEGIN);
195
+ const registry = await readJson(registryPath(home), null);
196
+ const registered = (registry?.plugins ?? []).some(entry => entry.id === PLUGIN_NAME);
197
+ return { ok: true, changes: [], diagnostics: [
198
+ installed ? "acc hooks registered in config.toml" : "acc hooks not registered",
199
+ registered ? "acc plugin registered" : "acc plugin not registered",
200
+ await runnerExists(runner)
201
+ ? `hook runner present at ${runner}`
202
+ : `hook runner MISSING at ${runner}; every hook would fail silently`,
203
+ ] };
204
+ }
205
+
206
+ /**
207
+ * The paths an install would write, without writing them. Same helpers as the
208
+ * install, so a dry run cannot drift from what actually happens.
209
+ */
210
+ export function planKimiInstall({ home }) {
211
+ return [
212
+ { path: pluginPath(home), kind: "tree" },
213
+ { path: configPath(home), kind: "merge" },
214
+ { path: registryPath(home), kind: "merge" },
215
+ ];
216
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@agents-can-communicate/adapter-sdk",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ]
12
+ }
@@ -0,0 +1,101 @@
1
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
2
+
3
+ // The capability surface, documented in docs/ADAPTER_AUTHORING.md and measured
4
+ // per client in docs/CAPABILITIES.md. False is the default for every
5
+ // entry: an adapter earns a true value by implementing the method that backs it
6
+ // and by proving it in the conformance suite. Optimistic defaults are how a
7
+ // tool ends up claiming protection it cannot deliver.
8
+ export const CAPABILITY_SHAPE = Object.freeze({
9
+ lifecycle: ["sessionStart", "sessionResume", "sessionEnd", "heartbeat", "childSessions"],
10
+ context: ["startupInjection", "beforeTurnInjection", "safePointInjection"],
11
+ guards: ["beforeRead", "beforeWrite", "beforeShell"],
12
+ delivery: ["polling", "activeNotification", "wakeDormantSession"],
13
+ execution: ["launch", "resume", "terminate"],
14
+ });
15
+
16
+ // Each true capability names the method that has to exist for it to be true.
17
+ const BACKING_METHOD = Object.freeze({
18
+ "lifecycle.sessionStart": "startSession",
19
+ "lifecycle.sessionResume": "resumeSession",
20
+ "lifecycle.sessionEnd": "endSession",
21
+ // A timer-driven event from the client, distinct from delivery.polling: it
22
+ // keeps presence fresh while the session is idle, which turn-driven hooks
23
+ // cannot do.
24
+ "lifecycle.heartbeat": "heartbeat",
25
+ "lifecycle.childSessions": "mapChildSession",
26
+ "context.startupInjection": "renderContext",
27
+ "context.beforeTurnInjection": "renderContext",
28
+ "context.safePointInjection": "renderContext",
29
+ "guards.beforeRead": "guardRead",
30
+ "guards.beforeWrite": "guardWrite",
31
+ "guards.beforeShell": "guardShell",
32
+ "delivery.polling": "poll",
33
+ "delivery.activeNotification": "notify",
34
+ "delivery.wakeDormantSession": "wake",
35
+ "execution.launch": "launch",
36
+ "execution.resume": "resumeProcess",
37
+ "execution.terminate": "terminate",
38
+ });
39
+
40
+ const BASE_METHODS = Object.freeze(["detect", "install", "uninstall", "doctor",
41
+ "normalizeHook", "renderContext"]);
42
+
43
+ function usage(message, details = {}) {
44
+ throw new AccError(EXIT.USAGE, message, details);
45
+ }
46
+
47
+ export function assertCapabilities(declared = {}, implementation = {}) {
48
+ const resolved = {};
49
+ for (const [group, names] of Object.entries(CAPABILITY_SHAPE)) {
50
+ resolved[group] = Object.fromEntries(names.map(name => [name, false]));
51
+ }
52
+ for (const [group, values] of Object.entries(declared)) {
53
+ if (!Object.hasOwn(CAPABILITY_SHAPE, group)) {
54
+ usage(`unknown capability group: ${group}`, { group });
55
+ }
56
+ for (const [name, value] of Object.entries(values ?? {})) {
57
+ if (!CAPABILITY_SHAPE[group].includes(name)) {
58
+ usage(`unknown capability: ${group}.${name}`, { group, name });
59
+ }
60
+ if (typeof value !== "boolean") {
61
+ usage(`capability ${group}.${name} must be a boolean`, { group, name, value });
62
+ }
63
+ if (value === true) {
64
+ const method = BACKING_METHOD[`${group}.${name}`];
65
+ if (typeof implementation[method] !== "function") {
66
+ usage(`capability ${group}.${name} requires ${method}()`, { group, name, method });
67
+ }
68
+ }
69
+ resolved[group][name] = value;
70
+ }
71
+ Object.freeze(resolved[group]);
72
+ }
73
+ for (const group of Object.keys(CAPABILITY_SHAPE)) Object.freeze(resolved[group]);
74
+ return Object.freeze(resolved);
75
+ }
76
+
77
+ export function defineAdapter(manifest) {
78
+ if (typeof manifest?.id !== "string") usage("an adapter must declare an id");
79
+ assertPortableId(manifest.id, "adapter id");
80
+ if (typeof manifest.displayName !== "string" || manifest.displayName.trim() === "") {
81
+ usage("an adapter must declare a displayName", { id: manifest.id });
82
+ }
83
+ // Detection spawns this to decide whether the client is on the machine.
84
+ // Left undeclared it used to fall back to the adapter id, so `claude_code`
85
+ // and `gemini_cli` probed binaries that do not exist and were reported absent
86
+ // on every machine - `acc install` silently skipped half its clients.
87
+ const command = manifest.client?.command;
88
+ if (typeof command !== "string" || command.trim() === "") {
89
+ usage("an adapter must declare client.command, the binary its client installs",
90
+ { id: manifest.id });
91
+ }
92
+ for (const method of BASE_METHODS) {
93
+ if (typeof manifest[method] !== "function") {
94
+ usage(`an adapter must implement ${method}()`, { id: manifest.id, method });
95
+ }
96
+ }
97
+ return Object.freeze({
98
+ ...manifest,
99
+ capabilities: assertCapabilities(manifest.capabilities, manifest),
100
+ });
101
+ }
@@ -0,0 +1,215 @@
1
+ import path from "node:path";
2
+
3
+ const MARKER = "acc:owned";
4
+ // Ownership of single entries inside a container someone else also writes to.
5
+ // `enabledPlugins` in a Claude Code settings file holds every plugin the user
6
+ // has, so taking the whole key would destroy them - and giving it back on
7
+ // uninstall would destroy them again.
8
+ const ENTRY_MARKER = "acc:ownedEntries";
9
+ // Containers ACC had to create. Recorded separately from whole owned keys:
10
+ // creating one is permission to remove it when it ends up empty, not permission
11
+ // to take whatever the user has put in it since.
12
+ const CREATED_MARKER = "acc:createdContainers";
13
+ // The file itself, as opposed to a container inside it. Recorded at install
14
+ // because uninstall cannot tell afterwards: a settings file holding `{}` looks
15
+ // the same whether ACC made it or the user did, and deleting the user's would
16
+ // be the same overreach as taking a container they had put entries in.
17
+ const CREATED_FILE = "acc:createdFile";
18
+
19
+ /**
20
+ * Merge ACC entries into a user's config and record ownership, so uninstall can
21
+ * remove exactly what ACC added and nothing else.
22
+ *
23
+ * The ownership record is what makes uninstall safe: without it the only way to
24
+ * remove entries is to guess by shape, which is how a tool deletes a user's
25
+ * unrelated hook that happened to look similar.
26
+ */
27
+ export function mergeOwnedConfig(existing, additions, { owner = MARKER } = {}) {
28
+ const merged = { ...existing };
29
+ const owned = new Set(existing?.[owner] ?? []);
30
+ for (const [key, value] of Object.entries(additions)) {
31
+ merged[key] = value;
32
+ owned.add(key);
33
+ }
34
+ merged[owner] = [...owned].sort();
35
+ return merged;
36
+ }
37
+
38
+ export function removeOwnedConfig(existing, { owner = MARKER } = {}) {
39
+ const owned = new Set(existing?.[owner] ?? []);
40
+ const result = {};
41
+ for (const [key, value] of Object.entries(existing ?? {})) {
42
+ // Unrelated keys survive untouched, including keys a user added by hand
43
+ // under a name ACC also uses elsewhere.
44
+ if (key !== owner && !owned.has(key)) result[key] = value;
45
+ }
46
+ return result;
47
+ }
48
+
49
+ export function ownedKeys(existing, { owner = MARKER } = {}) {
50
+ return [...(existing?.[owner] ?? [])];
51
+ }
52
+
53
+ /**
54
+ * Add entries inside containers ACC shares with the user.
55
+ *
56
+ * Records `[container, key]` pairs, so uninstall removes exactly the entries ACC
57
+ * added. A container ACC had to create is recorded too, but as a container it may
58
+ * clean up rather than as a key it owns: an empty one left behind is litter, and
59
+ * one the user has since put their own entries into is theirs.
60
+ */
61
+ export function mergeOwnedEntries(existing, additions,
62
+ { entryOwner = ENTRY_MARKER, createdOwner = CREATED_MARKER, createdFile } = {}) {
63
+ const merged = { ...existing };
64
+ if (createdFile === true) merged[CREATED_FILE] = true;
65
+ const entries = new Map((existing?.[entryOwner] ?? [])
66
+ .map(pair => [`${pair[0]}\u0000${pair[1]}`, pair]));
67
+ const created = new Set(existing?.[createdOwner] ?? []);
68
+
69
+ for (const [container, values] of Object.entries(additions)) {
70
+ if (!Object.hasOwn(existing ?? {}, container)) created.add(container);
71
+ merged[container] = { ...(existing?.[container] ?? {}) };
72
+ for (const [key, value] of Object.entries(values)) {
73
+ merged[container][key] = value;
74
+ entries.set(`${container}\u0000${key}`, [container, key]);
75
+ }
76
+ }
77
+
78
+ merged[entryOwner] = [...entries.values()]
79
+ .sort((left, right) => left.join("/").localeCompare(right.join("/")));
80
+ if (created.size > 0) merged[createdOwner] = [...created].sort();
81
+ return merged;
82
+ }
83
+
84
+ /** Remove only the entries ACC recorded adding, leaving every other one. */
85
+ export function removeOwnedEntries(existing,
86
+ { owner = MARKER, entryOwner = ENTRY_MARKER, createdOwner = CREATED_MARKER } = {}) {
87
+ const result = { ...(existing ?? {}) };
88
+ for (const [container, key] of result[entryOwner] ?? []) {
89
+ if (result[container] === null || typeof result[container] !== "object") continue;
90
+ const remaining = { ...result[container] };
91
+ delete remaining[key];
92
+ result[container] = remaining;
93
+ }
94
+ // A container ACC created goes only if nothing is left in it. Taking it
95
+ // outright would delete entries the user added after the install - the exact
96
+ // loss that recording ownership per entry exists to prevent.
97
+ for (const container of result[createdOwner] ?? []) {
98
+ const value = result[container];
99
+ if (value !== null && typeof value === "object" && Object.keys(value).length === 0) {
100
+ delete result[container];
101
+ }
102
+ }
103
+ delete result[entryOwner];
104
+ delete result[createdOwner];
105
+ delete result[CREATED_FILE];
106
+ return removeOwnedConfig(result, { owner });
107
+ }
108
+
109
+ export function ownedEntries(existing, { entryOwner = ENTRY_MARKER } = {}) {
110
+ return (existing?.[entryOwner] ?? []).map(pair => [...pair]);
111
+ }
112
+
113
+ /**
114
+ * Write JSON back into someone else's file, in that file's own style.
115
+ *
116
+ * ACC re-emits what it edits, and re-emitting with a fixed style rewrites bytes
117
+ * it was not asked to touch. Measured after an install and uninstall that
118
+ * changed nothing else: three clients' configs came back one byte longer, a
119
+ * trailing newline appended to files that had none. It is the same defect that
120
+ * was found once in Claude Code's registries and fixed there by hand - fixed for
121
+ * two files rather than for the shape, so the other three kept doing it.
122
+ *
123
+ * The style is read from the file rather than declared per client: what a client
124
+ * writes is a fact about the client, and asking the file cannot go stale.
125
+ * Absent file, absent style: a file ACC creates is its own, and gets the
126
+ * conventional trailing newline.
127
+ */
128
+ export function jsonStyleOf(text) {
129
+ if (typeof text !== "string") return { indent: 2, trailingNewline: true };
130
+ // The whitespace itself, not a count of it. `JSON.stringify` takes a string
131
+ // for its indent, so a tab-indented file stays tab-indented; measuring the
132
+ // length instead turned one tab into one space and reformatted every line of
133
+ // a file this exists to leave alone. Matched without `\s`, which would span
134
+ // the blank line before an indented one and report its own newline as indent.
135
+ const indented = /\n([ \t]+)\S/.exec(text);
136
+ // A file written on one line was written that way on purpose, and expanding it
137
+ // is the same unasked-for rewrite as changing its indent.
138
+ const oneLine = !text.trimEnd().includes("\n");
139
+ // Ten is what `JSON.stringify` itself honours; more is silently truncated,
140
+ // and truncating here keeps what is written equal to what was measured.
141
+ return {
142
+ indent: oneLine ? 0 : (indented === null ? 2 : indented[1].slice(0, 10)),
143
+ trailingNewline: text.endsWith("\n"),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Write a file ACC did not create, keeping the shape of what was there.
149
+ *
150
+ * Unchanged content is not rewritten at all, so a second install touches
151
+ * nothing. A file that does not exist yet is ACC's to create, and gets the
152
+ * conventional trailing newline.
153
+ */
154
+ export async function writeForeignJson(file, value, { readFile, writeFile, mkdir }) {
155
+ const current = await readFile(file, "utf8").catch(() => null);
156
+ const text = formatJsonAs(value, jsonStyleOf(current));
157
+ if (current === text) return false;
158
+ await mkdir(path.dirname(file), { recursive: true });
159
+ await writeFile(file, text);
160
+ return true;
161
+ }
162
+
163
+ export function formatJsonAs(value, style) {
164
+ const measured = style?.indent;
165
+ const indent = typeof measured === "string" && measured !== "" ? measured
166
+ : (Number.isInteger(measured) && measured >= 0 ? measured : 2);
167
+ const text = JSON.stringify(value, null, indent);
168
+ return style?.trailingNewline === false ? text : `${text}\n`;
169
+ }
170
+
171
+ /**
172
+ * Remove a file ACC wrote once nothing is left in it.
173
+ *
174
+ * The same rule as a container ACC created: what is empty was ACC's alone, and
175
+ * leaving it is litter in a home that did not have it. Measured after an install
176
+ * and uninstall in a home that started with nothing - four files left behind,
177
+ * two of them empty, one a marketplace manifest naming ACC's own marketplace.
178
+ *
179
+ * It was already fixed for two registries. The fixture that proved it seeded
180
+ * every config first, so the branch where ACC creates the file was never taken:
181
+ * the instance was fixed and the shape was not.
182
+ */
183
+ export function acccreatedFile(value) {
184
+ return value?.[CREATED_FILE] === true;
185
+ }
186
+
187
+ export async function removeIfEmpty(file, { readFile, rm, isEmpty, created = true }) {
188
+ if (created !== true) return false;
189
+ const current = await readFile(file, "utf8").catch(() => null);
190
+ if (current === null) return false;
191
+ let empty;
192
+ try {
193
+ empty = isEmpty(current);
194
+ } catch {
195
+ // Unparseable is not empty. A file nobody can read is the user's to fix,
196
+ // and deleting it would take whatever it was meant to hold.
197
+ return false;
198
+ }
199
+ if (!empty) return false;
200
+ await rm(file, { force: true });
201
+ return true;
202
+ }
203
+
204
+ /** Nothing but whitespace, for the clients whose config is TOML. */
205
+ export const blankText = text => text.trim() === "";
206
+
207
+ /** An object with no keys, or only the ones named as ACC's own. */
208
+ export const blankJson = (ours = []) => text => {
209
+ const value = JSON.parse(text);
210
+ if (value === null || typeof value !== "object") return false;
211
+ return Object.entries(value)
212
+ .every(([key, held]) => ours.includes(key)
213
+ || (Array.isArray(held) ? held.length === 0
214
+ : held !== null && typeof held === "object" && Object.keys(held).length === 0));
215
+ };