agents-can-communicate 0.1.18 → 0.3.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/README.md +87 -69
- package/SECURITY.md +31 -0
- package/bin/acc-bootstrap.mjs +56 -0
- package/bin/acc-claude-channel.mjs +177 -0
- package/bin/acc-hook.mjs +94 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +13 -3
- package/docs/ADAPTER_AUTHORING.md +204 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +117 -214
- package/docs/CLI.md +164 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +147 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/HOW_IT_WORKS.md +277 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +115 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +108 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
- package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
- package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
- package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
- package/package.json +20 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
|
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
|
|
5
|
+
import { clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
|
|
6
6
|
from "@agents-can-communicate/adapter-sdk";
|
|
7
7
|
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
8
8
|
import { createId } from "@agents-can-communicate/protocol";
|
|
@@ -11,6 +11,8 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
|
|
|
11
11
|
from "@agents-can-communicate/cli";
|
|
12
12
|
|
|
13
13
|
import { resolveClientPid } from "./client-pid.mjs";
|
|
14
|
+
import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
|
|
15
|
+
import { establishNativeBinding, livePolicyFrom } from "./native-binding.mjs";
|
|
14
16
|
import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
|
|
15
17
|
|
|
16
18
|
// Kept cohesive above 300 lines because every handler shares one fail-open
|
|
@@ -22,6 +24,28 @@ import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs
|
|
|
22
24
|
// let a call through than to make someone's session sit waiting on us.
|
|
23
25
|
const DEFAULT_BUDGET_MS = 5_000;
|
|
24
26
|
|
|
27
|
+
const byteLength = value => Buffer.byteLength(value, "utf8");
|
|
28
|
+
|
|
29
|
+
function compactInboxRecovery(messages) {
|
|
30
|
+
const first = messages[0].messageId;
|
|
31
|
+
const rest = messages.length > 1 ? ` (+${messages.length - 1} more in \`acc inbox\`)` : "";
|
|
32
|
+
return `ACC: read pending peer message: \`acc inbox --message ${first}\`${rest}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fitDegradation(projection, visibleDegradation, messages, budgetBytes) {
|
|
36
|
+
const full = [projection, visibleDegradation].filter(Boolean).join("\n");
|
|
37
|
+
if (byteLength(full) <= budgetBytes) return full;
|
|
38
|
+
const recovery = compactInboxRecovery(messages);
|
|
39
|
+
const exactRecovery = compactInboxRecovery(messages.slice(0, 1));
|
|
40
|
+
const withRecovery = [projection, recovery].filter(Boolean).join("\n");
|
|
41
|
+
if (byteLength(withRecovery) <= budgetBytes) return withRecovery;
|
|
42
|
+
const withExactRecovery = [projection, exactRecovery].filter(Boolean).join("\n");
|
|
43
|
+
if (byteLength(withExactRecovery) <= budgetBytes) return withExactRecovery;
|
|
44
|
+
if (byteLength(recovery) <= budgetBytes) return recovery;
|
|
45
|
+
if (byteLength(exactRecovery) <= budgetBytes) return exactRecovery;
|
|
46
|
+
return projection;
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
// Declared by this process on the session it opens, so peers can tell an idle
|
|
26
50
|
// session from a dead one. Only one of the four clients fires a heartbeat event,
|
|
27
51
|
// so the rest refresh here: on every turn, and during a long one whenever the
|
|
@@ -161,16 +185,130 @@ async function openContext({ cwd, dataHome, runtime, env }) {
|
|
|
161
185
|
service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
// What this turn is shown and which complete groups it may commit as
|
|
189
|
+
// offered. Runs after the heartbeat and the bounded native retry.
|
|
190
|
+
async function projectTurn({ binding, context, adapter, adapterId }) {
|
|
191
|
+
const sync = await context.service.sync({ sessionId: binding.accSessionId,
|
|
192
|
+
cursor: null, scope: "delta" });
|
|
193
|
+
|
|
194
|
+
// Sync already carries the roster. Calling collectStatus here used to read
|
|
195
|
+
// the entire materialised store a second time on every prompt merely to
|
|
196
|
+
// rediscover this session's participant id.
|
|
197
|
+
const mine = sync.roster
|
|
198
|
+
.find(participant => participant.sessionId === binding.accSessionId);
|
|
199
|
+
|
|
200
|
+
// What peers have said to this participant and no model has been shown yet.
|
|
201
|
+
// Without this the projector's peer block never runs in production: an
|
|
202
|
+
// agent sees only the obligation attention line, not the durable body that
|
|
203
|
+
// may be offered after the stdout transport succeeds.
|
|
204
|
+
const delivery = await context.service.nextTurnDelivery({
|
|
205
|
+
workspaceId: context.descriptor.id,
|
|
206
|
+
participantId: mine?.participantId,
|
|
207
|
+
exceptSessionId: binding.accSessionId });
|
|
208
|
+
const messages = delivery.queuedMessages;
|
|
209
|
+
|
|
210
|
+
// Solo costs nothing: nothing to say means nothing printed, not a banner
|
|
211
|
+
// announcing that nobody else is here. But something already said to you is
|
|
212
|
+
// not nothing - the check used to run before the inbox was read, so the
|
|
213
|
+
// answer to your own request vanished the moment the agent working on it
|
|
214
|
+
// closed and left you as the only session.
|
|
215
|
+
if (sync.solo && messages.length === 0) return { stdout: "" };
|
|
216
|
+
|
|
217
|
+
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
218
|
+
// there is no config. Validated by the protocol and, until now, never read:
|
|
219
|
+
// the projector was always called with its own default.
|
|
220
|
+
const effective = effectiveCapabilities(adapter, binding);
|
|
221
|
+
const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
|
|
222
|
+
const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
|
|
223
|
+
const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
|
|
224
|
+
liveOfferedMessageIds: delivery.liveOfferedMessageIds,
|
|
225
|
+
roomMessageIds: delivery.roomMessageIds,
|
|
226
|
+
currentParticipantId: mine?.participantId };
|
|
227
|
+
const projectionOptions = {
|
|
228
|
+
budgetBytes: context.descriptor.policy?.contextBudgetBytes };
|
|
229
|
+
// Delivery is state, not text parsing. Peer-controlled bodies can imitate
|
|
230
|
+
// another message's visible header, so only projector metadata proves
|
|
231
|
+
// which complete groups survived the byte budget. A custom adapter without
|
|
232
|
+
// metadata may still inject text, but cannot advance a receipt from it.
|
|
233
|
+
const projection = !hasStructuredRenderer
|
|
234
|
+
? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
|
|
235
|
+
offeredMessageIds: [], includedAttentionIds: [] }
|
|
236
|
+
: await adapter.renderContextResult(projectionInput, projectionOptions);
|
|
237
|
+
const clientFactsKnown = typeof binding.clientVersion === "string"
|
|
238
|
+
&& typeof binding.platform === "string";
|
|
239
|
+
const reason = !effective.delivery.nextTurn
|
|
240
|
+
? clientFactsKnown
|
|
241
|
+
? `client ${binding.clientVersion} on ${binding.platform} is not certified for nextTurn`
|
|
242
|
+
: "the client version or platform is unknown"
|
|
243
|
+
: !hasStructuredRenderer ? "this adapter lacks structured delivery metadata" : null;
|
|
244
|
+
const degradation = reason !== null && messages.length > 0
|
|
245
|
+
? `acc: ${messages.length} pending message(s) withheld because ${reason}; read `
|
|
246
|
+
+ `${messages[0].messageId} with acc inbox --message ${messages[0].messageId}` : null;
|
|
247
|
+
const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
|
|
248
|
+
const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
|
|
249
|
+
const projected = degradation === null ? projection.text
|
|
250
|
+
: fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
|
|
251
|
+
if (projected === "") {
|
|
252
|
+
return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The renderer returns ids as metadata, never as text to parse. A peer body
|
|
256
|
+
// can imitate every visible label, so only a complete group selected by the
|
|
257
|
+
// projector is eligible for the post-write offer commit.
|
|
258
|
+
const offered = new Set(canOfferNextTurn ? projection.offeredMessageIds ?? [] : []);
|
|
259
|
+
const offerInputs = messages.filter(message => offered.has(message.messageId))
|
|
260
|
+
.map(message => ({ messageId: message.messageId,
|
|
261
|
+
recipientParticipantId: mine.participantId,
|
|
262
|
+
targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
|
|
263
|
+
transport: "next-turn", adapterId,
|
|
264
|
+
clientVersion: binding.clientVersion }));
|
|
265
|
+
// Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
|
|
266
|
+
// and Claude Code want an envelope and drop a bare string.
|
|
267
|
+
// The entry point owns the transport boundary. This handler only prepares
|
|
268
|
+
// offer inputs; recording them here would claim delivery before stdout's
|
|
269
|
+
// callback proves that the bytes crossed.
|
|
270
|
+
const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
|
|
271
|
+
const writableOffers = outcome.stdout === "" ? [] : offerInputs;
|
|
272
|
+
if (degradation === null) return { ...outcome, offerInputs: writableOffers };
|
|
273
|
+
return { ...outcome,
|
|
274
|
+
stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
|
|
275
|
+
offerInputs: writableOffers };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// One bounded, fail-open native handshake for this exact session generation.
|
|
279
|
+
// The policy comes only from the environment an owned shell bootstrap
|
|
280
|
+
// exported; an ordinary launch has none and stays durable.
|
|
281
|
+
async function bindNative({ adapter, event, hookBinding, clientVersion, platform, context, paths,
|
|
282
|
+
deadline }) {
|
|
283
|
+
return establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
|
|
284
|
+
livePolicy: livePolicyFrom(context.env), service: context.service, runtimeDir: paths.root,
|
|
285
|
+
clock: context.service.clock,
|
|
286
|
+
timeoutMs: Math.max(1, Math.min(750, deadline - Date.now())) });
|
|
287
|
+
}
|
|
288
|
+
|
|
164
289
|
const HANDLERS = {
|
|
165
290
|
async sessionStart({ event, context, adapter, adapterId, binding, paths,
|
|
166
|
-
readProcessTable }) {
|
|
167
|
-
|
|
291
|
+
readProcessTable, probeClientVersion, platform, deadline }) {
|
|
292
|
+
// A repeated start refreshes the client's version/platform. Remove the old
|
|
293
|
+
// certified facts before any probe, PID lookup, resume, or open can fail;
|
|
294
|
+
// keep only the generation identity needed for a successful resume.
|
|
295
|
+
if (binding !== null) {
|
|
296
|
+
await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
|
|
297
|
+
accSessionId: binding.accSessionId, generation: binding.generation });
|
|
298
|
+
}
|
|
299
|
+
const clientVersion = await probeClientVersion(adapter,
|
|
300
|
+
{ timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
|
|
301
|
+
const clientFacts = { clientVersion, platform };
|
|
302
|
+
const capabilities = effectiveCapabilities(adapter, clientFacts);
|
|
168
303
|
// Once per session, never per turn. A client that cannot be found yields
|
|
169
304
|
// null, and the session is then judged by age alone - which is exactly the
|
|
170
305
|
// behaviour every session had before this existed.
|
|
171
306
|
const command = adapter.client?.command ?? null;
|
|
172
307
|
const pid = command === null ? null
|
|
173
308
|
: resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
|
|
309
|
+
const clientPid = Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
310
|
+
const native = hookBinding => bindNative({ adapter, event, hookBinding, ...clientFacts,
|
|
311
|
+
context, paths, deadline });
|
|
174
312
|
const metadata = {
|
|
175
313
|
pid,
|
|
176
314
|
enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
|
|
@@ -186,7 +324,12 @@ const HANDLERS = {
|
|
|
186
324
|
...metadata,
|
|
187
325
|
});
|
|
188
326
|
if (resumed !== null) {
|
|
189
|
-
|
|
327
|
+
const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
|
|
328
|
+
...clientFacts, clientPid };
|
|
329
|
+
await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
|
|
330
|
+
...hookBinding });
|
|
331
|
+
return { accSessionId: resumed.sessionId, generation: resumed.generation,
|
|
332
|
+
...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
|
|
190
333
|
}
|
|
191
334
|
}
|
|
192
335
|
const session = await context.service.openSession({
|
|
@@ -203,9 +346,12 @@ const HANDLERS = {
|
|
|
203
346
|
...metadata,
|
|
204
347
|
descriptor: context.descriptor,
|
|
205
348
|
});
|
|
349
|
+
const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
|
|
350
|
+
...clientFacts, clientPid };
|
|
206
351
|
await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
|
|
207
|
-
|
|
208
|
-
return { accSessionId: session.sessionId, generation: session.generation
|
|
352
|
+
...hookBinding });
|
|
353
|
+
return { accSessionId: session.sessionId, generation: session.generation,
|
|
354
|
+
...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
|
|
209
355
|
},
|
|
210
356
|
|
|
211
357
|
async heartbeat({ binding, context }) {
|
|
@@ -223,107 +369,21 @@ const HANDLERS = {
|
|
|
223
369
|
return {};
|
|
224
370
|
},
|
|
225
371
|
|
|
226
|
-
async beforeTurn(
|
|
372
|
+
async beforeTurn(input) {
|
|
373
|
+
const { binding, context, adapter, event, paths, deadline } = input;
|
|
227
374
|
if (binding === null) return {};
|
|
228
375
|
// A turn is the clearest sign a session is alive. Never a reason to fail:
|
|
229
376
|
// this runs in front of somebody's prompt.
|
|
230
377
|
await context.service.heartbeatSession({ sessionId: binding.accSessionId,
|
|
231
378
|
generation: binding.generation }).catch(() => null);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
// What peers have said to this participant and no model has been shown yet.
|
|
242
|
-
// Without this the projector's peer block never ran in production: an agent
|
|
243
|
-
// saw only the subject of a message through its attention line, and the
|
|
244
|
-
// `injected` delivery state was unreachable.
|
|
245
|
-
const messages = await context.service.pendingMessages({
|
|
246
|
-
workspaceId: context.descriptor.id,
|
|
247
|
-
participantId: mine?.participantId,
|
|
248
|
-
exceptSessionId: binding.accSessionId });
|
|
249
|
-
|
|
250
|
-
// Solo costs nothing: nothing to say means nothing printed, not a banner
|
|
251
|
-
// announcing that nobody else is here. But something already said to you is
|
|
252
|
-
// not nothing - the check used to run before the inbox was read, so the
|
|
253
|
-
// answer to your own request vanished the moment the agent working on it
|
|
254
|
-
// closed and left you as the only session.
|
|
255
|
-
if (sync.solo && messages.length === 0) return { stdout: "" };
|
|
256
|
-
|
|
257
|
-
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
258
|
-
// there is no config. Validated by the protocol and, until now, never read:
|
|
259
|
-
// the projector was always called with its own default.
|
|
260
|
-
const tracksDelivery = typeof adapter.renderContextResult === "function";
|
|
261
|
-
const projectionInput = { ...sync, messages: tracksDelivery ? messages : [],
|
|
262
|
-
currentParticipantId: mine?.participantId };
|
|
263
|
-
const projectionOptions = {
|
|
264
|
-
budgetBytes: context.descriptor.policy?.contextBudgetBytes };
|
|
265
|
-
// Delivery is state, not text parsing. Peer-controlled bodies can imitate
|
|
266
|
-
// another message's visible header, so only projector metadata proves
|
|
267
|
-
// which complete groups survived the byte budget. A custom adapter without
|
|
268
|
-
// metadata may still inject text, but cannot advance a receipt from it.
|
|
269
|
-
const projection = !tracksDelivery
|
|
270
|
-
? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
|
|
271
|
-
includedMessageIds: [], includedAttentionIds: [] }
|
|
272
|
-
: await adapter.renderContextResult(projectionInput, projectionOptions);
|
|
273
|
-
const degradation = !tracksDelivery && messages.length > 0
|
|
274
|
-
? `acc: ${messages.length} pending message(s) withheld because this adapter lacks `
|
|
275
|
-
+ `structured delivery metadata; read ${messages[0].messageId} with `
|
|
276
|
-
+ `acc inbox --message ${messages[0].messageId}`
|
|
277
|
-
: null;
|
|
278
|
-
const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
|
|
279
|
-
const candidate = [projection.text, visibleDegradation].filter(Boolean).join("\n");
|
|
280
|
-
const projected = Buffer.byteLength(candidate, "utf8")
|
|
281
|
-
<= (projectionOptions.budgetBytes ?? 6_000) ? candidate : projection.text;
|
|
282
|
-
if (projected === "") {
|
|
283
|
-
return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Only what the model was actually shown is recorded as delivered. The
|
|
287
|
-
// budget can leave a message out, and a receipt reading `injected` for text
|
|
288
|
-
// nobody saw is worse than one still reading `queued` - the sender would be
|
|
289
|
-
// told it landed. A message left behind stays queued and goes out next turn.
|
|
290
|
-
const failures = [];
|
|
291
|
-
const includedMessages = new Set(projection.includedMessageIds ?? []);
|
|
292
|
-
for (const message of messages) {
|
|
293
|
-
if (!includedMessages.has(message.messageId)) continue;
|
|
294
|
-
await context.service.markDelivery({ sessionId: binding.accSessionId,
|
|
295
|
-
generation: binding.generation, messageId: message.messageId,
|
|
296
|
-
recipientParticipantId: mine.participantId, state: "injected" })
|
|
297
|
-
.catch(error => failures.push(`${message.messageId}: ${error.message}`));
|
|
298
|
-
}
|
|
299
|
-
// A note carries no ack obligation, so after its one full showing it leaves
|
|
300
|
-
// a single low-priority `unread_note` breadcrumb. Advancing that receipt
|
|
301
|
-
// injected -> seen the turn the breadcrumb is shown is what makes it
|
|
302
|
-
// one-shot: next turn the note reads `seen`, the breadcrumb stays quiet, and
|
|
303
|
-
// a delivered decision is recoverable without becoming a standing nag - the
|
|
304
|
-
// noise a reader learns to skip. Only what was actually shown is advanced,
|
|
305
|
-
// for the same reason the loop above only records what fit.
|
|
306
|
-
const includedAttention = new Set(projection.includedAttentionIds ?? []);
|
|
307
|
-
for (const item of sync.attention ?? []) {
|
|
308
|
-
if (item.kind !== "unread_note") continue;
|
|
309
|
-
if (!includedAttention.has(item.sourceId)) continue;
|
|
310
|
-
await context.service.markDelivery({ sessionId: binding.accSessionId,
|
|
311
|
-
generation: binding.generation, messageId: item.sourceId,
|
|
312
|
-
recipientParticipantId: mine.participantId, state: "seen" })
|
|
313
|
-
.catch(error => failures.push(`${item.sourceId}: ${error.message}`));
|
|
314
|
-
}
|
|
315
|
-
// Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
|
|
316
|
-
// and Claude Code want an envelope and drop a bare string.
|
|
317
|
-
// Reported rather than swallowed. The context still goes out - losing it
|
|
318
|
-
// over bookkeeping would be the worse trade - but a receipt that failed to
|
|
319
|
-
// advance has to be visible somewhere, and stdout belongs to the model.
|
|
320
|
-
const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
|
|
321
|
-
if (failures.length === 0 && degradation === null) return outcome;
|
|
322
|
-
return { ...outcome,
|
|
323
|
-
stderr: [outcome.stderr, degradation,
|
|
324
|
-
failures.length === 0 ? null
|
|
325
|
-
: `acc: delivery not recorded for ${failures.join(", ")}`]
|
|
326
|
-
.filter(Boolean).join("\n") };
|
|
379
|
+
// A native transport that became ready only after SessionStart is picked
|
|
380
|
+
// up here and a live lease is renewed: bounded, fail-open, never on a guard.
|
|
381
|
+
const nativeBinding = livePolicyFrom(context.env) === "off" ? undefined
|
|
382
|
+
: await bindNative({ adapter, event, hookBinding: binding,
|
|
383
|
+
clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
|
|
384
|
+
deadline });
|
|
385
|
+
const turn = await projectTurn(input);
|
|
386
|
+
return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
|
|
327
387
|
},
|
|
328
388
|
|
|
329
389
|
async beforeTool({ binding, context, event, adapter }) {
|
|
@@ -394,8 +454,12 @@ const HANDLERS = {
|
|
|
394
454
|
*/
|
|
395
455
|
export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
396
456
|
runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
|
|
397
|
-
readProcessTable = defaultReadProcessTable
|
|
398
|
-
|
|
457
|
+
readProcessTable = defaultReadProcessTable,
|
|
458
|
+
probeClientVersion = defaultProbeClientVersion,
|
|
459
|
+
platform = `${process.platform}-${process.arch}` }) {
|
|
460
|
+
const deadline = Date.now() + budgetMs;
|
|
461
|
+
const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
|
|
462
|
+
commitOffers: async () => {} };
|
|
399
463
|
let timer = null;
|
|
400
464
|
try {
|
|
401
465
|
const adapter = adapters?.[adapterId];
|
|
@@ -410,7 +474,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
410
474
|
const work = handler === undefined
|
|
411
475
|
? Promise.resolve({})
|
|
412
476
|
: handler({ event, context, adapter, adapterId, binding, paths: context.paths,
|
|
413
|
-
readProcessTable });
|
|
477
|
+
readProcessTable, probeClientVersion, platform, deadline });
|
|
414
478
|
|
|
415
479
|
// The loser of a race is not cancelled, so the timer is cleared explicitly:
|
|
416
480
|
// an outstanding one keeps the process alive long past its answer.
|
|
@@ -419,6 +483,24 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
419
483
|
});
|
|
420
484
|
Object.assign(result, await Promise.race([work, budget]));
|
|
421
485
|
|
|
486
|
+
const offerInputs = result.offerInputs ?? [];
|
|
487
|
+
let commitPromise = null;
|
|
488
|
+
result.commitOffers = () => {
|
|
489
|
+
if (commitPromise !== null) return commitPromise;
|
|
490
|
+
commitPromise = (async () => {
|
|
491
|
+
for (const input of offerInputs) {
|
|
492
|
+
const remaining = deadline - Date.now();
|
|
493
|
+
if (remaining <= 0) throw new Error("hook budget exhausted before offer commit");
|
|
494
|
+
// The durable transaction owns deadline cancellation. Racing it here
|
|
495
|
+
// would only reject the public promise while the losing writer kept
|
|
496
|
+
// waiting and could publish later.
|
|
497
|
+
await context.service.recordOfferSucceeded({ ...input, deadlineAt: deadline });
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
return commitPromise;
|
|
501
|
+
};
|
|
502
|
+
delete result.offerInputs;
|
|
503
|
+
|
|
422
504
|
const status = await context.service.collectStatus({
|
|
423
505
|
workspaceId: context.descriptor.id });
|
|
424
506
|
result.sessions = status.participants.filter(p => p.presence !== "offline");
|
|
@@ -428,6 +510,9 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
428
510
|
result.reason = error.message;
|
|
429
511
|
result.decision = "allow";
|
|
430
512
|
result.stdout = "";
|
|
513
|
+
// A later failure may happen after a turn prepared offer inputs. Once the
|
|
514
|
+
// fail-open path withdraws stdout, no transport boundary remains to commit.
|
|
515
|
+
result.commitOffers = async () => {};
|
|
431
516
|
} finally {
|
|
432
517
|
if (timer !== null) clearTimeout(timer);
|
|
433
518
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyNativeActivation, deactivateNative } from "./native-activation.mjs";
|
|
2
|
+
import { finalizeRemoval, missingArtifactParents, recordInstall,
|
|
3
|
+
removeEmptyOwnedDirectories, removeOwnedArtifacts } from "./ownership.mjs";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Carry out a plan, one adapter at a time.
|
|
@@ -13,7 +15,7 @@ import { recordInstall, removeOwned } from "./ownership.mjs";
|
|
|
13
15
|
* three that work, plus the name of the one that did not.
|
|
14
16
|
*/
|
|
15
17
|
export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false,
|
|
16
|
-
accVersion = null }) {
|
|
18
|
+
accVersion = null, activation = {} }) {
|
|
17
19
|
const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
|
|
18
20
|
const results = { action: plan.action, dryRun, operations: [], skipped: plan.skipped,
|
|
19
21
|
failed: [] };
|
|
@@ -30,27 +32,66 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
30
32
|
|
|
31
33
|
try {
|
|
32
34
|
if (plan.action === "install") {
|
|
33
|
-
const
|
|
35
|
+
const createdDirectories = await missingArtifactParents({ home: context.home,
|
|
36
|
+
artifacts: operation.artifacts });
|
|
37
|
+
const installContext = { ...context,
|
|
38
|
+
requestedLivePolicy: operation.livePolicy ?? "off",
|
|
39
|
+
livePolicy: operation.effectiveLivePolicy ?? "off" };
|
|
40
|
+
const outcome = await adapter.install(installContext);
|
|
41
|
+
// A consented activation is applied after the adapter's own wiring, in
|
|
42
|
+
// a fixed order; an explicit off takes a recorded one back first so the
|
|
43
|
+
// record written below describes the machine as it now is.
|
|
44
|
+
const notes = [];
|
|
45
|
+
if (operation.deactivation !== undefined) {
|
|
46
|
+
const report = await deactivateNative({ nativeActivation: operation.deactivation,
|
|
47
|
+
...activation });
|
|
48
|
+
notes.push(...describeTeardown(report));
|
|
49
|
+
}
|
|
50
|
+
let native = null;
|
|
51
|
+
let appendedRcBlock = false;
|
|
52
|
+
if (operation.nativeActivation !== undefined) {
|
|
53
|
+
const applied = await applyNativeActivation({ adapter,
|
|
54
|
+
activation: operation.nativeActivation, dataHome, ...activation });
|
|
55
|
+
native = applied.nativeActivation;
|
|
56
|
+
appendedRcBlock = applied.appendedRcBlock;
|
|
57
|
+
}
|
|
34
58
|
// Recorded after the write, so a record never claims an install that
|
|
35
59
|
// did not happen. The reverse order would leave uninstall trying to
|
|
36
60
|
// remove files nothing created.
|
|
37
61
|
await recordInstall({ dataHome, adapterId: adapter.id,
|
|
38
62
|
version: operation.clientVersion ?? null, accVersion,
|
|
39
|
-
artifacts: operation.artifacts });
|
|
40
|
-
results.operations.push({ ...operation, applied: true,
|
|
41
|
-
changes: outcome.changes ?? [], diagnostics:
|
|
63
|
+
artifacts: operation.artifacts, createdDirectories, nativeActivation: native });
|
|
64
|
+
results.operations.push({ ...operation, applied: true, appendedRcBlock,
|
|
65
|
+
changes: outcome.changes ?? [], diagnostics: [
|
|
66
|
+
...(operation.deliveryDiagnostic === undefined
|
|
67
|
+
? [] : [operation.deliveryDiagnostic]),
|
|
68
|
+
...(outcome.diagnostics ?? []),
|
|
69
|
+
...notes,
|
|
70
|
+
] });
|
|
42
71
|
} else {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
72
|
+
// Keep the record until every cleanup step succeeds. It is both the
|
|
73
|
+
// authority for deletion and the only durable recipe a retry has when
|
|
74
|
+
// the client or one of ACC's own artifacts is already gone.
|
|
75
|
+
const notes = [];
|
|
76
|
+
if (operation.deactivation !== undefined) {
|
|
77
|
+
const report = await deactivateNative({ nativeActivation: operation.deactivation,
|
|
78
|
+
...activation });
|
|
79
|
+
notes.push(...describeTeardown(report));
|
|
80
|
+
}
|
|
81
|
+
const owned = await removeOwnedArtifacts({ dataHome, adapterId: adapter.id });
|
|
46
82
|
// What ownership held back is passed on, because the adapter would
|
|
47
83
|
// otherwise remove its own layout unconditionally and undo the decision.
|
|
48
84
|
// The case that matters: someone put their own work inside a directory
|
|
49
85
|
// ACC created, and a recognised path is not a reason to delete it.
|
|
50
86
|
const outcome = await adapter.uninstall({ ...context, keep: owned.kept });
|
|
87
|
+
const directories = await removeEmptyOwnedDirectories({ home: context.home,
|
|
88
|
+
directories: owned.createdDirectories });
|
|
89
|
+
await finalizeRemoval({ dataHome, adapterId: adapter.id });
|
|
51
90
|
results.operations.push({ ...operation, applied: true,
|
|
52
91
|
changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
|
|
53
|
-
|
|
92
|
+
removedDirectories: directories.removed, keptDirectories: directories.kept,
|
|
93
|
+
missingDirectories: directories.missing,
|
|
94
|
+
diagnostics: [...(outcome.diagnostics ?? []), ...notes] });
|
|
54
95
|
}
|
|
55
96
|
} catch (error) {
|
|
56
97
|
results.failed.push({ adapterId: operation.adapterId, error: error.message });
|
|
@@ -58,3 +99,22 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
58
99
|
}
|
|
59
100
|
return results;
|
|
60
101
|
}
|
|
102
|
+
|
|
103
|
+
// What a deactivation actually did, said truthfully: a retained service is
|
|
104
|
+
// named as retained, a modified PATH block as kept.
|
|
105
|
+
function describeTeardown(report) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
if (report.shell !== null) {
|
|
108
|
+
for (const file of report.shell.removedShims) lines.push(`removed shim ${file}`);
|
|
109
|
+
for (const file of report.shell.keptShims) lines.push(`kept shim ${file} - changed since ACC wrote it`);
|
|
110
|
+
if (report.shell.rcBlock === "removed") lines.push("removed the ACC PATH block");
|
|
111
|
+
if (report.shell.rcBlock === "modified") lines.push("kept the ACC PATH block - changed since ACC wrote it");
|
|
112
|
+
if (report.shell.rcBlock === "kept") lines.push("kept the ACC PATH block - another ACC shim still uses it");
|
|
113
|
+
}
|
|
114
|
+
for (const service of report.services) {
|
|
115
|
+
lines.push(service.outcome === "stopped" ? `stopped the ${service.serviceId} service`
|
|
116
|
+
: `retained the ${service.serviceId} service (${service.outcome === "retained_pre_existing"
|
|
117
|
+
? "it existed before ACC" : "no vendor teardown exists"})`);
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { evaluateNativeEligibility } from "@agents-can-communicate/adapter-sdk";
|
|
8
|
+
|
|
9
|
+
// The launch-time check behind an owned shell shim. It answers one closed
|
|
10
|
+
// question - may this exact executable receive native delivery? - from the
|
|
11
|
+
// static minimum, a read-only adapter probe, and a keyed cache under ACC's own
|
|
12
|
+
// data home. It never throws: a shim that cannot decide launches the vendor
|
|
13
|
+
// command untouched, and this module is what makes "cannot decide" cheap.
|
|
14
|
+
//
|
|
15
|
+
// The cache key is the executable's identity - resolved path, symlink target,
|
|
16
|
+
// inode, size, mtime, and full sha256 - so an upgrade or replacement is a miss
|
|
17
|
+
// and an unchanged binary skips the version spawn and the probe. A cached
|
|
18
|
+
// failure is short-lived; a repaired client is never disabled for long.
|
|
19
|
+
|
|
20
|
+
export const BOOTSTRAP_CACHE_SCHEMA = 1;
|
|
21
|
+
export const SUPPORTED_TTL_MS = 6 * 60 * 60 * 1_000;
|
|
22
|
+
export const FAILED_TTL_MS = 5 * 60 * 1_000;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 750;
|
|
24
|
+
const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
|
|
25
|
+
|
|
26
|
+
export const cachePathFor = (dataHome, adapterId) =>
|
|
27
|
+
path.join(dataHome, "acc", "native-bootstrap", `${adapterId}.json`);
|
|
28
|
+
|
|
29
|
+
function sha256File(file) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const hash = createHash("sha256");
|
|
32
|
+
createReadStream(file).on("data", chunk => hash.update(chunk))
|
|
33
|
+
.on("error", reject).on("end", () => resolve(`sha256:${hash.digest("hex")}`));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function executableIdentity(realExecutable) {
|
|
38
|
+
const target = await realpath(realExecutable);
|
|
39
|
+
const facts = await stat(target);
|
|
40
|
+
if (!facts.isFile()) throw new Error("not a file");
|
|
41
|
+
return { path: realExecutable, target, inode: facts.ino, size: facts.size,
|
|
42
|
+
mtimeMs: Math.floor(facts.mtimeMs), executableFingerprint: await sha256File(target) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withTimeout(work, ms, label) {
|
|
46
|
+
let timer = null;
|
|
47
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
48
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`${label} timed out`),
|
|
49
|
+
{ code: "ETIMEDOUT" })), ms);
|
|
50
|
+
});
|
|
51
|
+
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Read-only: the client's own version command, nothing else.
|
|
55
|
+
export function defaultReadVersion({ realExecutable, versionArgs, timeoutMs }) {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
execFile(realExecutable, versionArgs, { timeout: timeoutMs, windowsHide: true },
|
|
58
|
+
(error, stdout, stderr) => {
|
|
59
|
+
if (error !== null) return resolve(null);
|
|
60
|
+
resolve(VERSION.exec(`${stdout}${stderr}`)?.[1] ?? null);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sameIdentity = (left, right) => left !== undefined && right !== undefined
|
|
66
|
+
&& ["path", "target", "inode", "size", "mtimeMs", "executableFingerprint"]
|
|
67
|
+
.every(key => left[key] === right[key]);
|
|
68
|
+
|
|
69
|
+
async function loadCache(file) {
|
|
70
|
+
try {
|
|
71
|
+
const record = JSON.parse(await readFile(file, "utf8"));
|
|
72
|
+
return record?.schemaVersion === BOOTSTRAP_CACHE_SCHEMA ? record : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function saveCache(file, record) {
|
|
79
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
80
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
81
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
82
|
+
await rename(temporary, file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const closedOutcome = (supported, reasonCode) => Object.freeze({ supported, reasonCode });
|
|
86
|
+
|
|
87
|
+
export async function checkNativeBootstrap({ adapter, realExecutable, platform, dataHome,
|
|
88
|
+
timeoutMs = DEFAULT_TIMEOUT_MS, clock = { now: () => new Date().toISOString() },
|
|
89
|
+
readVersion = defaultReadVersion }) {
|
|
90
|
+
try {
|
|
91
|
+
if (adapter?.nativeDelivery === undefined || typeof adapter.probeNativeDelivery !== "function") {
|
|
92
|
+
return closedOutcome(false, "native_delivery_unsupported");
|
|
93
|
+
}
|
|
94
|
+
const identity = await executableIdentity(realExecutable);
|
|
95
|
+
const file = cachePathFor(dataHome, adapter.id);
|
|
96
|
+
const now = Date.parse(clock.now());
|
|
97
|
+
const cached = await loadCache(file);
|
|
98
|
+
if (cached !== null && cached.adapterId === adapter.id && cached.platform === platform
|
|
99
|
+
&& sameIdentity(cached.identity, identity) && Date.parse(cached.expiresAt) > now) {
|
|
100
|
+
return closedOutcome(cached.supported, cached.reasonCode);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const clientVersion = await withTimeout(readVersion({ realExecutable: identity.target,
|
|
104
|
+
versionArgs: adapter.client?.versionArgs ?? ["--version"], timeoutMs }), timeoutMs,
|
|
105
|
+
"version probe").catch(() => null);
|
|
106
|
+
let probe = null;
|
|
107
|
+
let reasonCode = null;
|
|
108
|
+
if (clientVersion !== null) {
|
|
109
|
+
try {
|
|
110
|
+
probe = await withTimeout(adapter.probeNativeDelivery({ realExecutable: identity.target,
|
|
111
|
+
timeoutMs }), timeoutMs, "native probe");
|
|
112
|
+
} catch (error) {
|
|
113
|
+
probe = null;
|
|
114
|
+
reasonCode = error?.code === "ETIMEDOUT" ? "probe_timeout" : "feature_probe_failed";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let eligibility;
|
|
118
|
+
try {
|
|
119
|
+
eligibility = evaluateNativeEligibility(adapter, { clientVersion, platform, probe });
|
|
120
|
+
} catch {
|
|
121
|
+
// A malformed probe is an adapter bug, and still not a reason to change
|
|
122
|
+
// the user's launch: it fails closed like any other probe failure.
|
|
123
|
+
eligibility = { eligible: false, reasonCode: "feature_probe_failed" };
|
|
124
|
+
}
|
|
125
|
+
const supported = eligibility.eligible === true;
|
|
126
|
+
// A probe that timed out or threw is reported as such rather than as the
|
|
127
|
+
// generic verdict the missing probe would otherwise produce.
|
|
128
|
+
const outcomeReason = supported ? null : (reasonCode ?? eligibility.reasonCode
|
|
129
|
+
?? "feature_probe_failed");
|
|
130
|
+
// Only closed facts are stored: identity, versions, the eligibility verdict.
|
|
131
|
+
// Never command output, never a message body.
|
|
132
|
+
await saveCache(file, {
|
|
133
|
+
schemaVersion: BOOTSTRAP_CACHE_SCHEMA, adapterId: adapter.id, platform, identity,
|
|
134
|
+
clientVersion, supported, reasonCode: outcomeReason,
|
|
135
|
+
protocolContract: supported ? eligibility.protocolContract : null,
|
|
136
|
+
modes: supported ? [...eligibility.modes] : [],
|
|
137
|
+
checkedAt: new Date(now).toISOString(),
|
|
138
|
+
expiresAt: new Date(now + (supported ? SUPPORTED_TTL_MS : FAILED_TTL_MS)).toISOString(),
|
|
139
|
+
}).catch(() => null);
|
|
140
|
+
return closedOutcome(supported, outcomeReason);
|
|
141
|
+
} catch {
|
|
142
|
+
return closedOutcome(false, "feature_probe_failed");
|
|
143
|
+
}
|
|
144
|
+
}
|