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,217 @@
|
|
|
1
|
+
const DEFAULT_BUDGET_BYTES = 6_000;
|
|
2
|
+
// Held back from the required lines so the "not shown" note can always be
|
|
3
|
+
// written. A projection that silently drops what it could not fit is how an
|
|
4
|
+
// agent ends up confidently unaware.
|
|
5
|
+
// Enough for the note *and* the command that reads what the note is about. It
|
|
6
|
+
// said only that something had been withheld, and nothing anywhere - not the
|
|
7
|
+
// skills, not the docs - said how to see it. A turn that reports a thing the
|
|
8
|
+
// reader cannot reach is how an agent ends up inventing its own way in.
|
|
9
|
+
const NOTE_RESERVE = 80;
|
|
10
|
+
const FENCE = "```";
|
|
11
|
+
// A peer cannot close a block it cannot name. The fence carries a marker that
|
|
12
|
+
// is stripped from peer content, so forged delimiters stay inside the block.
|
|
13
|
+
const BLOCK = "acc-peer-message";
|
|
14
|
+
|
|
15
|
+
const bytes = value => Buffer.byteLength(value, "utf8");
|
|
16
|
+
|
|
17
|
+
// Written from char codes rather than a literal class: an escaped control
|
|
18
|
+
// range in a regex literal is corrupted silently by editors and patches, and a
|
|
19
|
+
// corrupted range fails open.
|
|
20
|
+
const CONTROL_CHARACTERS = new RegExp(
|
|
21
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(8)}`
|
|
22
|
+
+ `${String.fromCharCode(11)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, "g");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render peer-controlled text as displayable data.
|
|
26
|
+
*
|
|
27
|
+
* Two separate jobs. Control sequences become visible escapes, so a message
|
|
28
|
+
* cannot repaint or retitle the human's terminal. Fence markers are stripped,
|
|
29
|
+
* so a message cannot break out of its own data block and continue as if ACC
|
|
30
|
+
* had written the following lines.
|
|
31
|
+
*/
|
|
32
|
+
function escapePeerText(value) {
|
|
33
|
+
return String(value)
|
|
34
|
+
.replaceAll(new RegExp(`${FENCE}${BLOCK}`, "g"), `'${FENCE}${BLOCK}`)
|
|
35
|
+
.replaceAll(FENCE, `'${FENCE}`)
|
|
36
|
+
.replace(CONTROL_CHARACTERS,
|
|
37
|
+
character => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function truncate(line, limit) {
|
|
41
|
+
if (bytes(line) <= limit) return line;
|
|
42
|
+
let cut = line;
|
|
43
|
+
while (bytes(`${cut}…`) > limit && cut.length > 0) cut = cut.slice(0, -1);
|
|
44
|
+
return `${cut}…`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* An attention line an agent can act on without a round trip.
|
|
49
|
+
*
|
|
50
|
+
* `- [task_unblocked] Tank sinks through mud` says work is waiting and does not
|
|
51
|
+
* say which work. The commands that take it - `acc task --take --task <id>` -
|
|
52
|
+
* all need the id, and the only other place it appears is `acc sync --json`. An
|
|
53
|
+
* agent that is told to act and not told on what improvises, which in this
|
|
54
|
+
* project has already meant one hand-editing the store rather than admitting it
|
|
55
|
+
* could not name the task.
|
|
56
|
+
*
|
|
57
|
+
* Every kind carries such an id and every one is the argument to a command:
|
|
58
|
+
* a message to `acc ack --message`, a task to `acc task --task`, a claim to
|
|
59
|
+
* `acc release --claim`. So they are all shown, not only the ones that happened
|
|
60
|
+
* to be noticed first.
|
|
61
|
+
*/
|
|
62
|
+
function attentionLines(attention) {
|
|
63
|
+
return attention.map(item => (typeof item.sourceId === "string" && item.sourceId !== ""
|
|
64
|
+
? `- [${item.kind}] ${item.sourceId} ${item.summary}`
|
|
65
|
+
: `- [${item.kind}] ${item.summary}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Claims other sessions hold, and whether this session can be stopped from
|
|
70
|
+
* breaking them.
|
|
71
|
+
*
|
|
72
|
+
* Ranked with the required lines rather than the roster, because a claim is
|
|
73
|
+
* what changes what this session should do next. When it cannot be enforced -
|
|
74
|
+
* a model that edits through the shell, an MCP client with no hooks - saying so
|
|
75
|
+
* is the whole mitigation: ACC will not intercept the write, so respecting the
|
|
76
|
+
* claim is this session's own responsibility and it needs to know that.
|
|
77
|
+
*/
|
|
78
|
+
function claimNote(claim) {
|
|
79
|
+
// Enforcement is declared per claim, and the guard only ever blocks a guarded
|
|
80
|
+
// one. Reading this session's capability alone would announce a block that
|
|
81
|
+
// will never happen, on a claim whose owner explicitly did not ask for one.
|
|
82
|
+
if (claim.enforcement !== "guarded") {
|
|
83
|
+
return " - advisory; nothing will stop you, the owner is asking";
|
|
84
|
+
}
|
|
85
|
+
if (claim.enforceable === false) {
|
|
86
|
+
return " - not enforced for this session; do not edit it";
|
|
87
|
+
}
|
|
88
|
+
// Guarded, and this session can be stopped - but only on a file edit. No
|
|
89
|
+
// harness intercepts a shell command, so an edit made through one goes
|
|
90
|
+
// through whatever the claim says, and a session told merely "this is
|
|
91
|
+
// claimed" would reasonably assume otherwise.
|
|
92
|
+
return " - file edits are blocked; edits made through a shell are not";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function claimLines(claims) {
|
|
96
|
+
return claims.map(claim => {
|
|
97
|
+
const owner = claim.ownerParticipantId ?? claim.ownerSessionId ?? "another session";
|
|
98
|
+
return `- [claim] ${claim.resource} held by ${owner}${claimNote(claim)}`;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One group per message, never a flat list of lines.
|
|
104
|
+
*
|
|
105
|
+
* A block that the budget cuts in half is worse than a block that was left out:
|
|
106
|
+
* the fence never closes, and everything after it reads as ACC's own words
|
|
107
|
+
* rather than as a peer's. So a message is included whole or not at all.
|
|
108
|
+
*
|
|
109
|
+
* The id is carried because the reader needs it to acknowledge the message, and
|
|
110
|
+
* because the caller needs it to tell which messages actually reached the model
|
|
111
|
+
* before recording any of them as delivered. Ids, session ids and types are
|
|
112
|
+
* generated or schema-validated; only the subject and body are peer-authored,
|
|
113
|
+
* and only those are escaped.
|
|
114
|
+
*/
|
|
115
|
+
function peerBlocks(messages) {
|
|
116
|
+
return messages.map(message => [
|
|
117
|
+
`${FENCE}${BLOCK}`,
|
|
118
|
+
`id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
|
|
119
|
+
+ " | untrusted peer message",
|
|
120
|
+
escapePeerText(message.subject),
|
|
121
|
+
escapePeerText(message.body),
|
|
122
|
+
FENCE,
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Project a SyncResult into bounded text for one adapter to inject.
|
|
128
|
+
*
|
|
129
|
+
* Priority is fixed: direct requests and conflicts first, roster detail last,
|
|
130
|
+
* because the budget is spent from the bottom. Whatever is dropped is counted
|
|
131
|
+
* rather than silently removed - a projection that hides its own omissions is
|
|
132
|
+
* how an agent ends up confidently unaware.
|
|
133
|
+
*/
|
|
134
|
+
export function projectContext(sync, { budgetBytes = DEFAULT_BUDGET_BYTES } = {}) {
|
|
135
|
+
const attention = [...(sync.attention ?? [])]
|
|
136
|
+
.sort((left, right) => left.priority - right.priority
|
|
137
|
+
|| left.sourceId.localeCompare(right.sourceId));
|
|
138
|
+
const messages = sync.messages ?? [];
|
|
139
|
+
// Who is here, which is not the same as who has ever been here. The roster
|
|
140
|
+
// keeps closed sessions - `sync` needs them to decide what a cursor has missed
|
|
141
|
+
// - and a turn that lists them says "3 session(s)" for two participants, one
|
|
142
|
+
// of them shown twice with contradictory presence. Left alone it also grows
|
|
143
|
+
// without limit: every session ever opened would take a line out of the
|
|
144
|
+
// context budget, crowding out messages actually addressed to the reader.
|
|
145
|
+
// Stale stays: a session that crashed holding a claim is very much news.
|
|
146
|
+
const roster = (sync.roster ?? []).filter(item => item.presence !== "offline");
|
|
147
|
+
const claims = sync.claims ?? [];
|
|
148
|
+
|
|
149
|
+
// Every entry is a group that appears whole or not at all. Single-line groups
|
|
150
|
+
// may still be truncated - there is no fence in them to leave open.
|
|
151
|
+
const required = [
|
|
152
|
+
...attentionLines(attention).map(line => [line]),
|
|
153
|
+
...claimLines(claims).map(line => [line]),
|
|
154
|
+
...peerBlocks(messages),
|
|
155
|
+
];
|
|
156
|
+
// Solo costs nothing: a lone session pays no visible price, and "no peers" is
|
|
157
|
+
// still a cost when injected into every turn. But this is decided after the
|
|
158
|
+
// required lines are built, not before - a message already addressed to you,
|
|
159
|
+
// or a claim you could break, is not nothing, and returning early swallowed
|
|
160
|
+
// exactly the things worth saying to someone working alone.
|
|
161
|
+
if (sync.solo === true && required.length === 0) return "";
|
|
162
|
+
|
|
163
|
+
// Named by participant, because that is what another agent addresses work to
|
|
164
|
+
// - a session id cannot be used with `--to`. The branch says where they are,
|
|
165
|
+
// which is how a workspace spanning several worktrees stays legible.
|
|
166
|
+
const optional = roster.map(item => {
|
|
167
|
+
const who = item.participantId ?? item.sessionId;
|
|
168
|
+
const place = item.branch === null || item.branch === undefined
|
|
169
|
+
? ""
|
|
170
|
+
: ` on ${item.branch}`;
|
|
171
|
+
return `- ${who}${place} (${item.harness}, ${item.presence})`;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const header = `${roster.length} session(s); cursor ${sync.cursor}`;
|
|
175
|
+
const lines = [header];
|
|
176
|
+
let used = bytes(header);
|
|
177
|
+
|
|
178
|
+
// Reserved so the note below always fits. Without it the projection could run
|
|
179
|
+
// out of room to say that it ran out of room.
|
|
180
|
+
const ceiling = budgetBytes - NOTE_RESERVE;
|
|
181
|
+
let dropped = 0;
|
|
182
|
+
for (const group of required) {
|
|
183
|
+
if (group.length === 1) {
|
|
184
|
+
const candidate = truncate(group[0], Math.max(0, ceiling - used - 1));
|
|
185
|
+
if (candidate === "" || used + bytes(candidate) + 1 > ceiling) { dropped += 1; continue; }
|
|
186
|
+
lines.push(candidate);
|
|
187
|
+
used += bytes(candidate) + 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const size = group.reduce((total, line) => total + bytes(line) + 1, 0);
|
|
191
|
+
// Skipped rather than stopped at: the groups are ordered by priority, and a
|
|
192
|
+
// large message must not hide the shorter ones behind it.
|
|
193
|
+
if (used + size > ceiling) { dropped += 1; continue; }
|
|
194
|
+
lines.push(...group);
|
|
195
|
+
used += size;
|
|
196
|
+
}
|
|
197
|
+
if (dropped > 0) {
|
|
198
|
+
const note = `- +${dropped} not shown, over budget; read them with `
|
|
199
|
+
+ "`acc sync --scope full --json`";
|
|
200
|
+
lines.push(note);
|
|
201
|
+
used += bytes(note) + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let shown = 0;
|
|
205
|
+
for (const line of optional) {
|
|
206
|
+
if (used + bytes(line) + 1 > budgetBytes - 16) break;
|
|
207
|
+
lines.push(line);
|
|
208
|
+
used += bytes(line) + 1;
|
|
209
|
+
shown += 1;
|
|
210
|
+
}
|
|
211
|
+
if (shown < optional.length) {
|
|
212
|
+
const note = `- +${optional.length - shown} more`;
|
|
213
|
+
if (used + bytes(note) + 1 <= budgetBytes) lines.push(note);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
// The shape every adapter normalises to. Kept here rather than in each adapter
|
|
4
|
+
// so the four cannot drift: a consumer that has to ask which adapter produced an
|
|
5
|
+
// event is a consumer that will eventually get it wrong.
|
|
6
|
+
export const NORMALIZED_EVENT_KEYS = Object.freeze(["kind", "sessionId", "cwd", "model",
|
|
7
|
+
"parentSessionId", "tool", "targets"]);
|
|
8
|
+
|
|
9
|
+
export const EVENT_KINDS = Object.freeze(["sessionStart", "sessionEnd", "heartbeat",
|
|
10
|
+
"beforeTurn", "beforeTool", "afterTool", "turnEnd", "childStart", "childEnd", "other"]);
|
|
11
|
+
|
|
12
|
+
const data = (message, details) => {
|
|
13
|
+
throw new AccError(EXIT.DATA, message, details);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a normalised hook event.
|
|
18
|
+
*
|
|
19
|
+
* `targets` is the list of paths the call would write. It is a resource
|
|
20
|
+
* identifier, not conversation content: without it a guard has nothing to
|
|
21
|
+
* compare against a claim, and a declared write guard protects nothing. What is
|
|
22
|
+
* deliberately *not* here is the file's contents, the command text, the prompt
|
|
23
|
+
* and the transcript path - all of which the harnesses hand to hooks, and none
|
|
24
|
+
* of which ACC needs.
|
|
25
|
+
*
|
|
26
|
+
* A call whose targets cannot be determined declares none. Guessing a path from
|
|
27
|
+
* a shell command would produce a guard that is wrong in both directions: it
|
|
28
|
+
* would block work it has no claim over and miss writes it does.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizedEvent(fields) {
|
|
31
|
+
for (const key of Object.keys(fields)) {
|
|
32
|
+
if (!NORMALIZED_EVENT_KEYS.includes(key)) {
|
|
33
|
+
data(`unknown normalised event field: ${key}`, { key });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const { kind, sessionId, cwd, model = null, parentSessionId = null, tool = null,
|
|
37
|
+
targets = [] } = fields;
|
|
38
|
+
|
|
39
|
+
if (!EVENT_KINDS.includes(kind)) data("unknown normalised event kind", { kind });
|
|
40
|
+
if (typeof sessionId !== "string" || sessionId === "") {
|
|
41
|
+
data("normalised event has no session id", { kind });
|
|
42
|
+
}
|
|
43
|
+
if (typeof cwd !== "string" || cwd === "") {
|
|
44
|
+
data("normalised event has no working directory", { kind });
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(targets)) data("normalised event targets must be an array", { kind });
|
|
47
|
+
for (const target of targets) {
|
|
48
|
+
if (typeof target !== "string" || target === "") {
|
|
49
|
+
data("normalised event target must be a non-empty string", { kind, target });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Object.freeze({ kind, sessionId, cwd, model, parentSessionId, tool,
|
|
54
|
+
targets: Object.freeze([...targets]) });
|
|
55
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { access, chmod, mkdir, readFile, readdir, rm, writeFile }
|
|
2
|
+
from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the hook runtime lives, resolved from this package rather than looked
|
|
11
|
+
* up on PATH. A hook's environment does not reliably carry the user's PATH, and
|
|
12
|
+
* a command that cannot be found fails silently on every event.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The hook runner, found rather than counted to.
|
|
16
|
+
*
|
|
17
|
+
* `../../../bin/acc-hook.mjs` is the answer in a development checkout and only
|
|
18
|
+
* there. Published, this module sits at
|
|
19
|
+
* `<package>/node_modules/@agents-can-communicate/adapter-sdk/src/`, which is one
|
|
20
|
+
* level deeper, so the path resolved to `node_modules/bin/acc-hook.mjs` and
|
|
21
|
+
* every install refused: `the hook runner does not exist`, for every client on
|
|
22
|
+
* the machine, from a clean `npm install` of the package.
|
|
23
|
+
*
|
|
24
|
+
* Nothing caught it. `acc install` exited 0 with the failures counted in a line
|
|
25
|
+
* that began with a success, and the release check threw that output away - so
|
|
26
|
+
* the gate reported PASS on a package whose main command could not run.
|
|
27
|
+
*
|
|
28
|
+
* Walking up for it holds in both layouts and in whatever a future one is: the
|
|
29
|
+
* nearest `bin/acc-hook.mjs` above this file is the one that belongs to this
|
|
30
|
+
* copy of the package.
|
|
31
|
+
*/
|
|
32
|
+
export const defaultRunner = () => {
|
|
33
|
+
let directory = fileURLToPath(new URL(".", import.meta.url));
|
|
34
|
+
for (;;) {
|
|
35
|
+
const candidate = path.join(directory, "bin", "acc-hook.mjs");
|
|
36
|
+
if (existsSync(candidate)) return candidate;
|
|
37
|
+
const parent = path.dirname(directory);
|
|
38
|
+
// Reached the root without finding one. The development answer is returned
|
|
39
|
+
// so the refusal that follows names a path a reader can recognise.
|
|
40
|
+
if (parent === directory) {
|
|
41
|
+
return fileURLToPath(new URL("../../../bin/acc-hook.mjs", import.meta.url));
|
|
42
|
+
}
|
|
43
|
+
directory = parent;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const runnerExists = async runner => {
|
|
48
|
+
try {
|
|
49
|
+
await access(runner);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export async function assertRunner(runner) {
|
|
57
|
+
if (!await runnerExists(runner)) {
|
|
58
|
+
throw new AccError(EXIT.DATA,
|
|
59
|
+
"refusing to install: the hook runner does not exist", { runner });
|
|
60
|
+
}
|
|
61
|
+
return runner;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What the shipped skill carries where the runnable command belongs.
|
|
65
|
+
const PLACEHOLDER = "{{ACC}}";
|
|
66
|
+
|
|
67
|
+
// Shell metacharacters in a path are the caller's problem to survive, not the
|
|
68
|
+
// shell's to interpret.
|
|
69
|
+
const quote = value => `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write the small shim a client's hook command points at.
|
|
73
|
+
*
|
|
74
|
+
* Clients disagree about how a hook command may be written - some expand a
|
|
75
|
+
* plugin-root variable, some demand an absolute path, one has no variable at
|
|
76
|
+
* all - but every one of them can run a shell script. The shim absorbs that
|
|
77
|
+
* difference and pins the interpreter and the runner to absolute paths at
|
|
78
|
+
* install time.
|
|
79
|
+
*/
|
|
80
|
+
export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
|
|
81
|
+
node = process.execPath, name = "acc-hook.sh" }) {
|
|
82
|
+
await assertRunner(runner);
|
|
83
|
+
const target = path.join(dir, name);
|
|
84
|
+
await mkdir(dir, { recursive: true });
|
|
85
|
+
await writeFile(target, [
|
|
86
|
+
"#!/bin/sh",
|
|
87
|
+
"# Generated by ACC at install time. The paths are pinned deliberately:",
|
|
88
|
+
"# a hook runs with an environment that may carry neither PATH nor a shell",
|
|
89
|
+
"# profile, and a command that cannot be found fails silently on every event.",
|
|
90
|
+
`exec ${quote(node)} ${quote(runner)} ${adapterId} "$@"`,
|
|
91
|
+
"",
|
|
92
|
+
].join("\n"));
|
|
93
|
+
await chmod(target, 0o755);
|
|
94
|
+
return target;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Remove a directory this adapter installed, unless it has been kept back.
|
|
99
|
+
*
|
|
100
|
+
* The installer decides what may be deleted by comparing what is on disk
|
|
101
|
+
* against what ACC recorded writing. An adapter that removed its layout
|
|
102
|
+
* unconditionally would undo that decision - and the case it undoes is exactly
|
|
103
|
+
* the one that matters: someone put their own work inside a directory ACC
|
|
104
|
+
* created, and a recognised path is not a reason to delete it.
|
|
105
|
+
*/
|
|
106
|
+
export async function removeInstalledTree(target, keep = []) {
|
|
107
|
+
if (keep.includes(target)) return false;
|
|
108
|
+
await rm(target, { recursive: true, force: true });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Where the CLI lives, resolved from this package rather than from PATH. */
|
|
113
|
+
export const defaultCli = () =>
|
|
114
|
+
fileURLToPath(new URL("../../../bin/acc.mjs", import.meta.url));
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bake the runnable command into the skill this adapter installs.
|
|
118
|
+
*
|
|
119
|
+
* The skill is what tells an agent how to coordinate, and it used to say `acc`.
|
|
120
|
+
* A hook never depends on PATH - its command is pinned at install time - but the
|
|
121
|
+
* skill did, and on a machine where the package is not installed globally the
|
|
122
|
+
* word `acc` runs nothing.
|
|
123
|
+
*
|
|
124
|
+
* A model that cannot run the command does not stop. Observed on a real Claude
|
|
125
|
+
* Code session: told to take a task and finding no `acc`, it read the store's
|
|
126
|
+
* JSON, worked out the schema, and wrote records and events by hand - inventing
|
|
127
|
+
* an event type, a harness name, and its own generation tokens. So the command
|
|
128
|
+
* is pinned here for the same reason it is pinned in the shim.
|
|
129
|
+
*/
|
|
130
|
+
export async function bakeSkillCommand({ root, node = process.execPath,
|
|
131
|
+
cli = defaultCli() }) {
|
|
132
|
+
const command = `${quote(node)} ${quote(cli)}`;
|
|
133
|
+
const baked = [];
|
|
134
|
+
const walk = async directory => {
|
|
135
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
136
|
+
const full = path.join(directory, entry.name);
|
|
137
|
+
if (entry.isDirectory()) await walk(full);
|
|
138
|
+
else if (entry.name === "SKILL.md") {
|
|
139
|
+
const text = await readFile(full, "utf8");
|
|
140
|
+
if (!text.includes(PLACEHOLDER)) continue;
|
|
141
|
+
await writeFile(full, text.replaceAll(PLACEHOLDER, command));
|
|
142
|
+
baked.push(full);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
await walk(root);
|
|
147
|
+
return baked;
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Capability contract, context projection, config ownership, and the binding
|
|
2
|
+
// that survives between two ephemeral hook processes.
|
|
3
|
+
export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
|
|
4
|
+
export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
|
|
5
|
+
export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstalledTree,
|
|
6
|
+
runnerExists, writeHookShim }
|
|
7
|
+
from "./hook-shim.mjs";
|
|
8
|
+
export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
|
|
9
|
+
from "./toml-block.mjs";
|
|
10
|
+
export { projectContext } from "./context-projector.mjs";
|
|
11
|
+
export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEntries, ownedKeys,
|
|
12
|
+
acccreatedFile, removeIfEmpty, removeOwnedConfig, removeOwnedEntries, writeForeignJson,
|
|
13
|
+
blankJson, blankText } from "./config-merge.mjs";
|
|
14
|
+
export { clearSessionBinding, listSessionBindings, loadSessionBinding,
|
|
15
|
+
storeSessionBinding }
|
|
16
|
+
from "./session-binding.mjs";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
// Harness session ids are foreign input of unknown shape. Hashing them makes
|
|
10
|
+
// the filename safe by construction rather than by sanitising, so no id can
|
|
11
|
+
// select which file is written.
|
|
12
|
+
const fileFor = (runtimeDir, harnessSessionId) => path.join(runtimeDir, "bindings",
|
|
13
|
+
`${createHash("sha256").update(String(harnessSessionId)).digest("hex").slice(0, 32)}.json`);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hook executables are ephemeral: the process that attaches is gone by the time
|
|
17
|
+
* the next hook fires. The binding is what lets a later hook heartbeat and
|
|
18
|
+
* detach the exact generation instead of opening a second session.
|
|
19
|
+
*
|
|
20
|
+
* It lives in the runtime directory, never the project, and carries identity
|
|
21
|
+
* only - no prompt, no transcript, no harness state.
|
|
22
|
+
*/
|
|
23
|
+
export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
|
|
24
|
+
generation }) {
|
|
25
|
+
const file = fileFor(runtimeDir, harnessSessionId);
|
|
26
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
|
|
28
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
29
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
30
|
+
// Replace rather than append: re-attaching supersedes the old generation, and
|
|
31
|
+
// two live bindings for one harness session would be worse than none.
|
|
32
|
+
await rename(temporary, file);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadSessionBinding({ runtimeDir, harnessSessionId }) {
|
|
36
|
+
const file = fileFor(runtimeDir, harnessSessionId);
|
|
37
|
+
let source;
|
|
38
|
+
try {
|
|
39
|
+
source = await readFile(file, "utf8");
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code === "ENOENT") return null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
let record;
|
|
45
|
+
try {
|
|
46
|
+
record = JSON.parse(source);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Returning null here would make the hook silently open a second session
|
|
49
|
+
// and orphan the first. Failing closed is the honest outcome.
|
|
50
|
+
throw new AccError(EXIT.DATA, "session binding is not valid JSON",
|
|
51
|
+
{ file, cause: error.message });
|
|
52
|
+
}
|
|
53
|
+
if (record?.schemaVersion !== SCHEMA_VERSION) {
|
|
54
|
+
throw new AccError(EXIT.DATA, "unknown session binding schemaVersion",
|
|
55
|
+
{ file, schemaVersion: record?.schemaVersion });
|
|
56
|
+
}
|
|
57
|
+
return { accSessionId: record.accSessionId, generation: record.generation };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function clearSessionBinding({ runtimeDir, harnessSessionId }) {
|
|
61
|
+
await rm(fileFor(runtimeDir, harnessSessionId), { force: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every binding in this workspace, whole.
|
|
66
|
+
*
|
|
67
|
+
* `loadSessionBinding` answers "which session is *this* harness session", which
|
|
68
|
+
* needs the harness id up front. The CLI has the opposite problem: an agent runs
|
|
69
|
+
* `acc work` from a shell that was never told any id, so the question is which
|
|
70
|
+
* of the live bindings is the one that spawned it.
|
|
71
|
+
*
|
|
72
|
+
* A file that cannot be read is skipped rather than fatal - the caller is trying
|
|
73
|
+
* to identify itself among several, and one unreadable neighbour should not stop
|
|
74
|
+
* it recognising its own.
|
|
75
|
+
*/
|
|
76
|
+
export async function listSessionBindings({ runtimeDir }) {
|
|
77
|
+
const dir = path.join(runtimeDir, "bindings");
|
|
78
|
+
let names;
|
|
79
|
+
try {
|
|
80
|
+
names = await readdir(dir);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === "ENOENT") return [];
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const bindings = [];
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
if (!name.endsWith(".json")) continue;
|
|
88
|
+
const record = await readFile(path.join(dir, name), "utf8")
|
|
89
|
+
.then(JSON.parse).catch(() => null);
|
|
90
|
+
if (record?.schemaVersion !== SCHEMA_VERSION) continue;
|
|
91
|
+
bindings.push({ harnessSessionId: record.harnessSessionId,
|
|
92
|
+
accSessionId: record.accSessionId, generation: record.generation });
|
|
93
|
+
}
|
|
94
|
+
return bindings;
|
|
95
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Two clients keep configuration ACC must add to in TOML, and ACC ships without
|
|
5
|
+
// dependencies. Parsing and re-emitting the file would mean writing a TOML
|
|
6
|
+
// round-tripper and losing the user's comments and formatting to it. Instead ACC
|
|
7
|
+
// owns a delimited region and never reads the rest: install replaces the region,
|
|
8
|
+
// uninstall deletes it, and everything outside comes back byte for byte.
|
|
9
|
+
export const BEGIN = "# >>> agents-can-communicate (managed; edits here are overwritten)";
|
|
10
|
+
export const END = "# <<< agents-can-communicate";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Remove ACC's region and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* Written to survive a file with no block, several blocks, or a block the user
|
|
16
|
+
* has half-deleted: an unterminated marker consumes to end of file rather than
|
|
17
|
+
* leaving stray table headers behind, which would fail the client's schema and
|
|
18
|
+
* lock the user out of their own tool.
|
|
19
|
+
*/
|
|
20
|
+
export function stripBlock(source) {
|
|
21
|
+
const kept = [];
|
|
22
|
+
let inside = false;
|
|
23
|
+
for (const line of source.split("\n")) {
|
|
24
|
+
if (line.trimEnd() === BEGIN) { inside = true; continue; }
|
|
25
|
+
if (inside) {
|
|
26
|
+
if (line.trimEnd() === END) inside = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
kept.push(line);
|
|
30
|
+
}
|
|
31
|
+
return kept.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const renderBlock = body => [BEGIN, ...body, END].join("\n");
|
|
35
|
+
|
|
36
|
+
// TOML basic strings take backslash escapes. A path is user-controlled input, so
|
|
37
|
+
// it is escaped rather than trusted to be boring.
|
|
38
|
+
export const tomlString = value =>
|
|
39
|
+
`"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Replace ACC's region in a TOML file, creating the file if needed.
|
|
43
|
+
*
|
|
44
|
+
* The block goes at the end because a table header there closes whatever table
|
|
45
|
+
* preceded it: appended anywhere else, the user's last section would swallow
|
|
46
|
+
* ACC's keys.
|
|
47
|
+
*/
|
|
48
|
+
export async function writeTomlBlock(file, body) {
|
|
49
|
+
const existing = await readFile(file, "utf8").catch(error => {
|
|
50
|
+
if (error.code === "ENOENT") return "";
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
const withoutOurs = stripBlock(existing);
|
|
54
|
+
const separator = withoutOurs === "" || withoutOurs.endsWith("\n") ? "" : "\n";
|
|
55
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
56
|
+
await writeFile(file, `${withoutOurs}${separator}${renderBlock(body)}\n`);
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Remove ACC's region, reporting whether anything was there. */
|
|
61
|
+
export async function removeTomlBlock(file) {
|
|
62
|
+
const existing = await readFile(file, "utf8").catch(error => {
|
|
63
|
+
if (error.code === "ENOENT") return null;
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
if (existing === null) return false;
|
|
67
|
+
const stripped = stripBlock(existing);
|
|
68
|
+
if (stripped === existing) return false;
|
|
69
|
+
await writeFile(file, stripped);
|
|
70
|
+
return true;
|
|
71
|
+
}
|