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,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,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";
|