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,379 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
6
+ from "@agents-can-communicate/adapter-sdk";
7
+ import { createCoordinationService } from "@agents-can-communicate/core";
8
+ import { createId } from "@agents-can-communicate/protocol";
9
+ import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
10
+ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
11
+ from "@agents-can-communicate/cli";
12
+
13
+ // A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
14
+ // let a call through than to make someone's session sit waiting on us.
15
+ const DEFAULT_BUDGET_MS = 5_000;
16
+
17
+ // Declared by this process on the session it opens, so peers can tell an idle
18
+ // session from a dead one. Only one of the four clients fires a heartbeat event,
19
+ // so the rest refresh here: on every turn, and during a long one whenever the
20
+ // last sign of life is older than half the cadence.
21
+ //
22
+ // Until they did, a session went stale three minutes after it started and stayed
23
+ // stale however hard it was working. Every roster showed every peer as stale, so
24
+ // the word stopped meaning anything - and a requester was told "nobody is
25
+ // working on it" about work a peer had accepted and was doing.
26
+ const CADENCE_MS = 60_000;
27
+ const REFRESH_AFTER_MS = CADENCE_MS / 2;
28
+
29
+ /**
30
+ * Whether the write guard should spend a write saying this session is alive.
31
+ *
32
+ * A value that is not a timestamp means nothing is known about its last sign of
33
+ * life, so it gets one. Said outright rather than left to `Date.parse`: the
34
+ * previous form leaned on `Date.parse(0)` coercing to the string "0" and landing
35
+ * in the year 2000, which gives the right answer and reads like an accident,
36
+ * because it is one.
37
+ */
38
+ export function needsRefresh(heartbeatAt, now) {
39
+ const last = Date.parse(heartbeatAt ?? "");
40
+ return !Number.isFinite(last) || now - last > REFRESH_AFTER_MS;
41
+ }
42
+
43
+ const defaultRuntime = () => ({
44
+ clock: { now: () => new Date().toISOString() },
45
+ ids: { next: kind => createId(kind, randomBytes) },
46
+ // Injected rather than imported at the call site so a test can drive the
47
+ // resolution without needing a real symlink on the filesystem it runs on.
48
+ realpath,
49
+ });
50
+
51
+ /**
52
+ * Map an absolute path to the resource identifier claims are written against.
53
+ *
54
+ * Returns null when the path is not inside the workspace. Relativising it
55
+ * anyway is the tempting bug: `/etc/src/a.mjs` would become `src/a.mjs` and be
56
+ * blocked by a claim on this workspace's `src/**`, which never covered it.
57
+ */
58
+ export function resourceFor(root, target) {
59
+ const absolute = path.isAbsolute(target) ? target : path.resolve(root, target);
60
+ const relative = path.relative(root, absolute);
61
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
62
+ return `file:${relative.split(path.sep).join("/")}`;
63
+ }
64
+
65
+ /**
66
+ * The same path, spelled the way the workspace root is spelled.
67
+ *
68
+ * Discovery resolves its root through `realpath`, so the descriptor is always
69
+ * canonical. A hook payload is not: it carries whatever the client had, and a
70
+ * client's cwd is whatever the human typed. When the two differ only by a
71
+ * symlinked ancestor - `/tmp` and `/var` on macOS, a symlinked checkout
72
+ * anywhere - `resourceFor` relativised to `../..`, the target list emptied, and
73
+ * every write was allowed while `acc status` still said `protection guarded`.
74
+ *
75
+ * The leaf usually does not exist yet, because the tool call being guarded is
76
+ * what would create it. So the deepest existing ancestor is resolved and the
77
+ * remainder appended, which is also what keeps this from following a symlink
78
+ * the write itself would replace.
79
+ *
80
+ * A relative target is resolved against `base` - the session's own cwd, as the
81
+ * payload states it - and never against this process's. A hook is a child whose
82
+ * working directory belongs to the client, and Codex's `apply_patch` names its
83
+ * files relative to the session. Resolving those against wherever the hook
84
+ * happened to start sent them outside the workspace, `resourceFor` returned
85
+ * null, and every write was allowed while `acc status` said `protection
86
+ * guarded`. It only ever worked because a client usually starts hooks in the
87
+ * project directory - and "usually" is what made it invisible.
88
+ */
89
+ export async function canonicalTarget(realpath, target, base = process.cwd()) {
90
+ let current = path.resolve(base, target);
91
+ const trailing = [];
92
+ for (;;) {
93
+ try {
94
+ return path.join(await realpath(current), ...trailing);
95
+ } catch (error) {
96
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return path.resolve(base, target);
97
+ const parent = path.dirname(current);
98
+ // Reached the filesystem root without finding anything that exists. The
99
+ // unresolved path is the best answer available, and `resourceFor` will
100
+ // reject it if it is outside the workspace.
101
+ if (parent === current) return path.resolve(base, target);
102
+ trailing.unshift(path.basename(current));
103
+ current = parent;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Same rule as the core's claim overlap, applied to a concrete path: a claim on
109
+ // `file:src/**` covers `file:src/a.mjs`, and `file:srcx/a.mjs` is not inside it.
110
+ export const covers = (resource, target) => {
111
+ if (resource === target) return true;
112
+ if (!resource.endsWith("/**")) return false;
113
+ const prefix = resource.slice(0, -3);
114
+ return target === prefix || target.startsWith(`${prefix}/`);
115
+ };
116
+
117
+ // A participant is one running agent, not one brand of client. Two Codex
118
+ // sessions are two agents even in the same directory, so identity cannot be
119
+ // derived from where they run - deriving it from the branch made two agents in
120
+ // one checkout indistinguishable again.
121
+ //
122
+ // The default distinguishes them by the client's own session, which is stable
123
+ // for as long as that agent is running and changes when it restarts. An agent
124
+ // meant to be addressable across restarts is named by whoever launches it:
125
+ //
126
+ // ACC_PARTICIPANT=backend-codex codex
127
+ //
128
+ // Work is addressed to a participant, so a pinned name is what makes a request
129
+ // survive the recipient closing its terminal.
130
+ const PORTABLE = /[^A-Za-z0-9._-]+/g;
131
+
132
+ export function participantFor(adapterId, harnessSessionId, env = {}) {
133
+ const declared = env.ACC_PARTICIPANT;
134
+ if (typeof declared === "string" && declared.trim() !== "") {
135
+ return declared.trim().replace(PORTABLE, "-").slice(0, 60);
136
+ }
137
+ const suffix = createHash("sha256").update(String(harnessSessionId))
138
+ .digest("base64url").slice(0, 6);
139
+ return `${adapterId}-${suffix}`;
140
+ }
141
+
142
+ async function openContext({ cwd, dataHome, runtime, env }) {
143
+ const descriptor = await discoverWorkspace({ cwd, env: env ?? {},
144
+ gitProbe: createGitProbe() });
145
+ const paths = runtimePaths({
146
+ dataHome: dataHome ?? platformDataHome({ env: env ?? {} }),
147
+ workspaceId: descriptor.id,
148
+ workspaceRoots: descriptor.roots,
149
+ });
150
+ const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
151
+ ids: runtime.ids, workspaceId: descriptor.id });
152
+ return { descriptor, paths, env: env ?? {}, realpath: runtime.realpath ?? realpath,
153
+ service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
154
+ }
155
+
156
+ const HANDLERS = {
157
+ async sessionStart({ event, context, adapter, adapterId, paths }) {
158
+ const capabilities = adapter.capabilities ?? {};
159
+ const session = await context.service.openSession({
160
+ workspaceId: context.descriptor.id,
161
+ participantId: participantFor(adapterId, event.sessionId, context.env),
162
+ displayName: participantFor(adapterId, event.sessionId, context.env),
163
+ harness: adapterId,
164
+ parentSessionId: null,
165
+ // Declared from what this adapter proved, not from the fact that it is an
166
+ // adapter at all. A peer reading the roster can then tell a session whose
167
+ // writes can be stopped from one whose cannot.
168
+ enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
169
+ lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
170
+ heartbeatCadenceMs: CADENCE_MS,
171
+ // Which checkout this agent is in. One workspace spans every worktree of
172
+ // a repository, so this is the only thing that distinguishes them.
173
+ checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
174
+ branch: context.descriptor.git?.branch ?? null,
175
+ descriptor: context.descriptor,
176
+ });
177
+ await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
178
+ accSessionId: session.sessionId, generation: session.generation });
179
+ return { accSessionId: session.sessionId, generation: session.generation };
180
+ },
181
+
182
+ async heartbeat({ binding, context }) {
183
+ if (binding === null) return {};
184
+ await context.service.heartbeatSession({ sessionId: binding.accSessionId,
185
+ generation: binding.generation });
186
+ return {};
187
+ },
188
+
189
+ async sessionEnd({ binding, context, event, paths }) {
190
+ if (binding === null) return {};
191
+ await context.service.closeSession({ sessionId: binding.accSessionId,
192
+ generation: binding.generation });
193
+ await clearSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId });
194
+ return {};
195
+ },
196
+
197
+ async beforeTurn({ binding, context, adapter }) {
198
+ if (binding === null) return {};
199
+ // A turn is the clearest sign a session is alive. Never a reason to fail:
200
+ // this runs in front of somebody's prompt.
201
+ await context.service.heartbeatSession({ sessionId: binding.accSessionId,
202
+ generation: binding.generation }).catch(() => null);
203
+ const sync = await context.service.sync({ sessionId: binding.accSessionId,
204
+ cursor: null, scope: "delta" });
205
+
206
+ // Claims held by others, and whether this session can actually be stopped
207
+ // from breaking them. For a harness that guards writes this is useful
208
+ // warning; for one that cannot - a Codex model editing through the shell,
209
+ // an MCP client - it is the only protection there is, so it has to say
210
+ // plainly that the responsibility has moved to the session itself.
211
+ const status = await context.service.collectStatus({
212
+ workspaceId: context.descriptor.id });
213
+ const mine = status.participants
214
+ .find(participant => participant.sessionId === binding.accSessionId);
215
+ // Two independent facts, and both are needed. `enforceable` is whether ACC
216
+ // could stop *this* session at all; `enforcement` is what the claim's owner
217
+ // asked for. A guarded session facing an advisory claim is not blocked from
218
+ // anything, so reporting either one alone mislabels the other case.
219
+ const enforceable = mine?.enforcement === "guarded";
220
+ const claims = status.claims
221
+ .filter(claim => claim.ownerSessionId !== binding.accSessionId)
222
+ .map(claim => ({ resource: claim.resource, enforcement: claim.enforcement,
223
+ enforceable,
224
+ ownerParticipantId: status.participants
225
+ .find(p => p.sessionId === claim.ownerSessionId)?.participantId
226
+ ?? claim.ownerSessionId }));
227
+
228
+ // What peers have said to this participant and no model has been shown yet.
229
+ // Without this the projector's peer block never ran in production: an agent
230
+ // saw only the subject of a message through its attention line, and the
231
+ // `injected` delivery state was unreachable.
232
+ const messages = await context.service.pendingMessages({
233
+ workspaceId: context.descriptor.id,
234
+ participantId: mine?.participantId,
235
+ exceptSessionId: binding.accSessionId });
236
+
237
+ // Solo costs nothing: nothing to say means nothing printed, not a banner
238
+ // announcing that nobody else is here. But something already said to you is
239
+ // not nothing - the check used to run before the inbox was read, so the
240
+ // answer to your own request vanished the moment the agent working on it
241
+ // closed and left you as the only session.
242
+ if (sync.solo && messages.length === 0) return { stdout: "" };
243
+
244
+ // The ceiling a team agreed on in `acc.workspace.json`, or the default when
245
+ // there is no config. Validated by the protocol and, until now, never read:
246
+ // the projector was always called with its own default.
247
+ const projected = await adapter.renderContext?.({ ...sync, claims, messages },
248
+ { budgetBytes: context.descriptor.policy?.contextBudgetBytes }) ?? "";
249
+ if (projected === "") return { stdout: "" };
250
+
251
+ // Only what the model was actually shown is recorded as delivered. The
252
+ // budget can leave a message out, and a receipt reading `injected` for text
253
+ // nobody saw is worse than one still reading `queued` - the sender would be
254
+ // told it landed. A message left behind stays queued and goes out next turn.
255
+ const failures = [];
256
+ for (const message of messages) {
257
+ if (!projected.includes(message.messageId)) continue;
258
+ await context.service.markDelivery({ sessionId: binding.accSessionId,
259
+ generation: binding.generation, messageId: message.messageId,
260
+ recipientParticipantId: mine.participantId, state: "injected" })
261
+ .catch(error => failures.push(`${message.messageId}: ${error.message}`));
262
+ }
263
+ // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
264
+ // and Claude Code want an envelope and drop a bare string.
265
+ // Reported rather than swallowed. The context still goes out - losing it
266
+ // over bookkeeping would be the worse trade - but a receipt that failed to
267
+ // advance has to be visible somewhere, and stdout belongs to the model.
268
+ const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
269
+ if (failures.length === 0) return outcome;
270
+ return { ...outcome,
271
+ stderr: [outcome.stderr, `acc: delivery not recorded for ${failures.join(", ")}`]
272
+ .filter(Boolean).join("\n") };
273
+ },
274
+
275
+ async beforeTool({ binding, context, event, adapter }) {
276
+ if (binding === null) return { decision: "allow" };
277
+ if (event.targets.length === 0) {
278
+ // The runner cannot tell what a shell command touches. Saying so is the
279
+ // honest answer; guessing would block work at random and miss real writes.
280
+ return { decision: "allow", unguarded: true };
281
+ }
282
+
283
+ // The narrow read: who holds a live claim, and what that owner is called.
284
+ // `collectStatus` answers a person's question and reads the whole store,
285
+ // which put the cost of guarding one write in proportion to every message
286
+ // the workspace had ever carried. This runs in front of every file an agent
287
+ // writes, and the budget that keeps it from failing open is five seconds.
288
+ const status = await context.service.guardState({
289
+ workspaceId: context.descriptor.id });
290
+ // A turn that runs for half an hour is one turn, and a session working that
291
+ // hard should not look dead to its peers. Written at most twice a cadence,
292
+ // so guarding a write stays a read in the ordinary case.
293
+ const mine = status.participants
294
+ .find(participant => participant.sessionId === binding.accSessionId);
295
+ if (mine !== undefined && needsRefresh(mine.heartbeatAt, Date.now())) {
296
+ await context.service.heartbeatSession({ sessionId: binding.accSessionId,
297
+ generation: binding.generation }).catch(() => null);
298
+ }
299
+ // Anchored to the repository, not to wherever this session was started. A
300
+ // session opened in `repo/src` relativised the same file to
301
+ // `file:physics.mjs` where one at `repo` called it `file:src/physics.mjs`,
302
+ // so a claim on either did not cover the other and both agents edited it
303
+ // freely. Repository-relative is also the convention every documented claim
304
+ // already uses - `file:packages/core/**` means one thing in a project.
305
+ //
306
+ // A declared config is the exception: its roots are the stated boundary, and
307
+ // they may deliberately not be the checkout.
308
+ const root = context.descriptor.source === "git" && context.descriptor.git !== undefined
309
+ ? context.descriptor.git.worktreeRoot
310
+ : context.descriptor.roots[0];
311
+ const resolved = await Promise.all(event.targets
312
+ .map(target => canonicalTarget(context.realpath, target, event.cwd)));
313
+ const wanted = resolved
314
+ .map(target => resourceFor(root, target))
315
+ .filter(resource => resource !== null);
316
+
317
+ const blocking = status.claims.find(claim =>
318
+ claim.enforcement === "guarded"
319
+ && claim.ownerSessionId !== binding.accSessionId
320
+ && wanted.some(resource => covers(claim.resource, resource)));
321
+
322
+ if (blocking === undefined) return { decision: "allow" };
323
+ const owner = status.participants
324
+ .find(participant => participant.sessionId === blocking.ownerSessionId);
325
+ const reason = `${blocking.resource} is claimed by ${owner?.participantId
326
+ ?? blocking.ownerSessionId} (session ${blocking.ownerSessionId})`;
327
+ // How a denial is expressed is the adapter's business, and the four do not
328
+ // agree on the modality, let alone the shape: three answer with JSON on
329
+ // stdout, Codex denies by exiting 2 with the reason on stderr.
330
+ return { decision: "deny", ...adapter.denyOutcome(reason) };
331
+ },
332
+ };
333
+
334
+ /**
335
+ * Run one hook invocation.
336
+ *
337
+ * Every failure path ends in "allow, exit 0". A coordination tool that can stop
338
+ * someone's session from working is worse than no coordination at all, so the
339
+ * only thing this function refuses to do is fail closed.
340
+ */
341
+ export async function runHook({ adapterId, payload, adapters, dataHome, env,
342
+ runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS }) {
343
+ const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [] };
344
+ let timer = null;
345
+ try {
346
+ const adapter = adapters?.[adapterId];
347
+ if (adapter === undefined) throw new Error(`no adapter named ${adapterId}`);
348
+
349
+ const event = await adapter.normalizeHook(payload);
350
+ const context = await openContext({ cwd: event.cwd, dataHome, runtime, env });
351
+ const binding = await loadSessionBinding({ runtimeDir: context.paths.root,
352
+ harnessSessionId: event.sessionId }).catch(() => null);
353
+
354
+ const handler = HANDLERS[event.kind];
355
+ const work = handler === undefined
356
+ ? Promise.resolve({})
357
+ : handler({ event, context, adapter, adapterId, binding, paths: context.paths });
358
+
359
+ // The loser of a race is not cancelled, so the timer is cleared explicitly:
360
+ // an outstanding one keeps the process alive long past its answer.
361
+ const budget = new Promise(resolve => {
362
+ timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
363
+ });
364
+ Object.assign(result, await Promise.race([work, budget]));
365
+
366
+ const status = await context.service.collectStatus({
367
+ workspaceId: context.descriptor.id });
368
+ result.sessions = status.participants.filter(p => p.presence !== "offline");
369
+ result.service = context.service;
370
+ } catch (error) {
371
+ result.failed = true;
372
+ result.reason = error.message;
373
+ result.decision = "allow";
374
+ result.stdout = "";
375
+ } finally {
376
+ if (timer !== null) clearTimeout(timer);
377
+ }
378
+ return result;
379
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@agents-can-communicate/installer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": { ".": "./src/index.mjs" },
7
+ "files": [
8
+ "src/"
9
+ ]
10
+ }
@@ -0,0 +1,58 @@
1
+ import { recordInstall, removeOwned } from "./ownership.mjs";
2
+
3
+ /**
4
+ * Carry out a plan, one adapter at a time.
5
+ *
6
+ * Each adapter is its own unit of work: it is installed, and only then is its
7
+ * ownership recorded. A crash between two adapters therefore leaves the first
8
+ * fully installed and recorded and the second untouched - and because every
9
+ * adapter's install is idempotent, re-running finishes the job rather than
10
+ * doubling the part that already succeeded.
11
+ *
12
+ * A failure does not end the run. Someone installing four clients wants the
13
+ * three that work, plus the name of the one that did not.
14
+ */
15
+ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false }) {
16
+ const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
17
+ const results = { action: plan.action, dryRun, operations: [], skipped: plan.skipped,
18
+ failed: [] };
19
+
20
+ for (const operation of plan.operations) {
21
+ const adapter = byId.get(operation.adapterId);
22
+ if (dryRun) {
23
+ // Nothing is opened, let alone written. The plan already says what would
24
+ // happen, and re-deriving it here would be a second implementation of the
25
+ // thing the operator is being shown.
26
+ results.operations.push({ ...operation, changes: [], applied: false });
27
+ continue;
28
+ }
29
+
30
+ try {
31
+ if (plan.action === "install") {
32
+ const outcome = await adapter.install(context);
33
+ // Recorded after the write, so a record never claims an install that
34
+ // did not happen. The reverse order would leave uninstall trying to
35
+ // remove files nothing created.
36
+ await recordInstall({ dataHome, adapterId: adapter.id,
37
+ version: operation.clientVersion ?? null, artifacts: operation.artifacts });
38
+ results.operations.push({ ...operation, applied: true,
39
+ changes: outcome.changes ?? [], diagnostics: outcome.diagnostics ?? [] });
40
+ } else {
41
+ // Ownership first: it decides what may be deleted, and the adapter's own
42
+ // uninstall then unpicks the entries it added to files the user owns.
43
+ const owned = await removeOwned({ dataHome, adapterId: adapter.id });
44
+ // What ownership held back is passed on, because the adapter would
45
+ // otherwise remove its own layout unconditionally and undo the decision.
46
+ // The case that matters: someone put their own work inside a directory
47
+ // ACC created, and a recognised path is not a reason to delete it.
48
+ const outcome = await adapter.uninstall({ ...context, keep: owned.kept });
49
+ results.operations.push({ ...operation, applied: true,
50
+ changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
51
+ diagnostics: outcome.diagnostics ?? [] });
52
+ }
53
+ } catch (error) {
54
+ results.failed.push({ adapterId: operation.adapterId, error: error.message });
55
+ }
56
+ }
57
+ return results;
58
+ }
@@ -0,0 +1,79 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const run = promisify(execFile);
5
+
6
+ const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
7
+
8
+ // Clients print their version in their own shape: "codex-cli 0.147.0", a bare
9
+ // "0.36.1", a banner with the number somewhere inside. The number is extracted
10
+ // where it can be, and the raw line is kept either way - "present, version
11
+ // unreadable" is a real state and hiding it would make the client look absent.
12
+ const VERSION = /\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/;
13
+
14
+ /**
15
+ * Ask the operating system what a client reports as its version.
16
+ *
17
+ * Spawning is the only way to know, and it is the only side effect detection
18
+ * has: nothing is written, and a client that is not installed simply fails to
19
+ * start.
20
+ */
21
+ export const spawnProbe = async (command, args) => {
22
+ const { stdout, stderr } = await run(command, args);
23
+ return `${stdout}${stderr}`.trim();
24
+ };
25
+
26
+ const withTimeout = (work, ms, label) => new Promise((resolve, reject) => {
27
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
28
+ work.then(resolve, reject).finally(() => clearTimeout(timer));
29
+ });
30
+
31
+ /**
32
+ * Read-only report of what is on this machine.
33
+ *
34
+ * One failing adapter never ends the run. A client whose config is unreadable is
35
+ * exactly the case someone is running this command to find out about, and
36
+ * letting it throw would hide the other three behind it.
37
+ */
38
+ export async function detectInstallation({ adapters, context, probe = spawnProbe,
39
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS }) {
40
+ const entries = await Promise.all([...adapters]
41
+ // Ordered by id so two runs can be diffed, and so a plan built from this is
42
+ // deterministic rather than dependent on registry order.
43
+ .sort((left, right) => left.id.localeCompare(right.id))
44
+ .map(async adapter => {
45
+ const entry = { adapterId: adapter.id, displayName: adapter.displayName,
46
+ present: false, version: null, versionOutput: null, installed: false,
47
+ diagnostics: [], capabilities: adapter.capabilities ?? {}, error: null };
48
+
49
+ try {
50
+ const output = await withTimeout(
51
+ // The declared binary, never the adapter id. Guessing the id made
52
+ // every client whose command differs from it look uninstalled.
53
+ Promise.resolve(probe(adapter.client.command,
54
+ adapter.client.versionArgs ?? ["--version"])),
55
+ probeTimeoutMs, `${adapter.id} version probe`);
56
+ if (typeof output === "string" && output !== "") {
57
+ entry.present = true;
58
+ entry.versionOutput = output;
59
+ entry.version = VERSION.exec(output)?.[1] ?? null;
60
+ }
61
+ } catch (error) {
62
+ entry.error = error.message;
63
+ }
64
+
65
+ try {
66
+ const detected = await adapter.detect(context);
67
+ entry.diagnostics = detected.diagnostics ?? [];
68
+ // "Installed" is the adapter's own answer, phrased in its own terms.
69
+ // The installer does not second-guess it by looking at files it does
70
+ // not understand.
71
+ entry.installed = entry.diagnostics.some(line =>
72
+ /registered|installed/.test(line) && !/not registered|not installed/.test(line));
73
+ } catch (error) {
74
+ entry.error = entry.error ?? error.message;
75
+ }
76
+ return entry;
77
+ }));
78
+ return entries;
79
+ }
@@ -0,0 +1,6 @@
1
+ // Detection, planning, application, and the record of what was installed.
2
+ export { detectInstallation, spawnProbe } from "./detect.mjs";
3
+ export { planInstallation } from "./plan.mjs";
4
+ export { applyPlan } from "./apply.mjs";
5
+ export { fingerprint, loadOwnership, recordInstall, removeOwned, treeFingerprint,
6
+ verifyOwned } from "./ownership.mjs";