agents-can-communicate 0.2.0 → 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.
Files changed (86) hide show
  1. package/README.md +17 -7
  2. package/bin/acc-bootstrap.mjs +56 -0
  3. package/bin/acc-claude-channel.mjs +177 -0
  4. package/bin/acc.mjs +7 -2
  5. package/docs/ADAPTER_AUTHORING.md +34 -2
  6. package/docs/CAPABILITIES.md +25 -10
  7. package/docs/CLI.md +10 -3
  8. package/docs/CONFIGURATION.md +4 -0
  9. package/docs/HOW_IT_WORKS.md +277 -0
  10. package/docs/PROTOCOL.md +1 -1
  11. package/docs/RELEASING.md +7 -1
  12. package/docs/TROUBLESHOOTING.md +7 -1
  13. package/docs/index.md +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +4 -2
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  26. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
  27. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
  28. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
  29. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  30. package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
  31. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +4 -2
  32. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
  33. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  36. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
  37. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +4 -2
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
  42. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
  43. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
  47. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +4 -2
  49. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
  50. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +4 -2
  52. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
  54. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
  55. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
  56. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  57. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  58. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  59. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
  60. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
  62. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
  63. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +103 -11
  64. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
  67. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
  69. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  70. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  71. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
  72. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
  74. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  75. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
  76. package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
  77. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
  80. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  82. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  83. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  84. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
  85. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  86. package/package.json +2 -1
@@ -12,6 +12,7 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
12
12
 
13
13
  import { resolveClientPid } from "./client-pid.mjs";
14
14
  import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
15
+ import { establishNativeBinding, livePolicyFrom } from "./native-binding.mjs";
15
16
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
16
17
 
17
18
  // Kept cohesive above 300 lines because every handler shares one fail-open
@@ -184,6 +185,107 @@ async function openContext({ cwd, dataHome, runtime, env }) {
184
185
  service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
185
186
  }
186
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
+
187
289
  const HANDLERS = {
188
290
  async sessionStart({ event, context, adapter, adapterId, binding, paths,
189
291
  readProcessTable, probeClientVersion, platform, deadline }) {
@@ -204,6 +306,9 @@ const HANDLERS = {
204
306
  const command = adapter.client?.command ?? null;
205
307
  const pid = command === null ? null
206
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 });
207
312
  const metadata = {
208
313
  pid,
209
314
  enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
@@ -219,10 +324,12 @@ const HANDLERS = {
219
324
  ...metadata,
220
325
  });
221
326
  if (resumed !== null) {
327
+ const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
328
+ ...clientFacts, clientPid };
222
329
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
223
- accSessionId: resumed.sessionId, generation: resumed.generation, ...clientFacts });
330
+ ...hookBinding });
224
331
  return { accSessionId: resumed.sessionId, generation: resumed.generation,
225
- ...clientFacts, capabilities };
332
+ ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
226
333
  }
227
334
  }
228
335
  const session = await context.service.openSession({
@@ -239,10 +346,12 @@ const HANDLERS = {
239
346
  ...metadata,
240
347
  descriptor: context.descriptor,
241
348
  });
349
+ const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
350
+ ...clientFacts, clientPid };
242
351
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
243
- accSessionId: session.sessionId, generation: session.generation, ...clientFacts });
352
+ ...hookBinding });
244
353
  return { accSessionId: session.sessionId, generation: session.generation,
245
- ...clientFacts, capabilities };
354
+ ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
246
355
  },
247
356
 
248
357
  async heartbeat({ binding, context }) {
@@ -260,97 +369,21 @@ const HANDLERS = {
260
369
  return {};
261
370
  },
262
371
 
263
- async beforeTurn({ binding, context, adapter, adapterId }) {
372
+ async beforeTurn(input) {
373
+ const { binding, context, adapter, event, paths, deadline } = input;
264
374
  if (binding === null) return {};
265
375
  // A turn is the clearest sign a session is alive. Never a reason to fail:
266
376
  // this runs in front of somebody's prompt.
267
377
  await context.service.heartbeatSession({ sessionId: binding.accSessionId,
268
378
  generation: binding.generation }).catch(() => null);
269
- const sync = await context.service.sync({ sessionId: binding.accSessionId,
270
- cursor: null, scope: "delta" });
271
-
272
- // Sync already carries the roster. Calling collectStatus here used to read
273
- // the entire materialised store a second time on every prompt merely to
274
- // rediscover this session's participant id.
275
- const mine = sync.roster
276
- .find(participant => participant.sessionId === binding.accSessionId);
277
-
278
- // What peers have said to this participant and no model has been shown yet.
279
- // Without this the projector's peer block never runs in production: an
280
- // agent sees only the obligation attention line, not the durable body that
281
- // may be offered after the stdout transport succeeds.
282
- const delivery = await context.service.nextTurnDelivery({
283
- workspaceId: context.descriptor.id,
284
- participantId: mine?.participantId,
285
- exceptSessionId: binding.accSessionId });
286
- const messages = delivery.queuedMessages;
287
-
288
- // Solo costs nothing: nothing to say means nothing printed, not a banner
289
- // announcing that nobody else is here. But something already said to you is
290
- // not nothing - the check used to run before the inbox was read, so the
291
- // answer to your own request vanished the moment the agent working on it
292
- // closed and left you as the only session.
293
- if (sync.solo && messages.length === 0) return { stdout: "" };
294
-
295
- // The ceiling a team agreed on in `acc.workspace.json`, or the default when
296
- // there is no config. Validated by the protocol and, until now, never read:
297
- // the projector was always called with its own default.
298
- const effective = effectiveCapabilities(adapter, binding);
299
- const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
300
- const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
301
- const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
302
- liveOfferedMessageIds: delivery.liveOfferedMessageIds,
303
- roomMessageIds: delivery.roomMessageIds,
304
- currentParticipantId: mine?.participantId };
305
- const projectionOptions = {
306
- budgetBytes: context.descriptor.policy?.contextBudgetBytes };
307
- // Delivery is state, not text parsing. Peer-controlled bodies can imitate
308
- // another message's visible header, so only projector metadata proves
309
- // which complete groups survived the byte budget. A custom adapter without
310
- // metadata may still inject text, but cannot advance a receipt from it.
311
- const projection = !hasStructuredRenderer
312
- ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
313
- offeredMessageIds: [], includedAttentionIds: [] }
314
- : await adapter.renderContextResult(projectionInput, projectionOptions);
315
- const clientFactsKnown = typeof binding.clientVersion === "string"
316
- && typeof binding.platform === "string";
317
- const reason = !effective.delivery.nextTurn
318
- ? clientFactsKnown
319
- ? `client ${binding.clientVersion} on ${binding.platform} is not certified for nextTurn`
320
- : "the client version or platform is unknown"
321
- : !hasStructuredRenderer ? "this adapter lacks structured delivery metadata" : null;
322
- const degradation = reason !== null && messages.length > 0
323
- ? `acc: ${messages.length} pending message(s) withheld because ${reason}; read `
324
- + `${messages[0].messageId} with acc inbox --message ${messages[0].messageId}` : null;
325
- const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
326
- const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
327
- const projected = degradation === null ? projection.text
328
- : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
329
- if (projected === "") {
330
- return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
331
- }
332
-
333
- // The renderer returns ids as metadata, never as text to parse. A peer body
334
- // can imitate every visible label, so only a complete group selected by the
335
- // projector is eligible for the post-write offer commit.
336
- const offered = new Set(canOfferNextTurn ? projection.offeredMessageIds ?? [] : []);
337
- const offerInputs = messages.filter(message => offered.has(message.messageId))
338
- .map(message => ({ messageId: message.messageId,
339
- recipientParticipantId: mine.participantId,
340
- targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
341
- transport: "next-turn", adapterId,
342
- clientVersion: binding.clientVersion }));
343
- // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
344
- // and Claude Code want an envelope and drop a bare string.
345
- // The entry point owns the transport boundary. This handler only prepares
346
- // offer inputs; recording them here would claim delivery before stdout's
347
- // callback proves that the bytes crossed.
348
- const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
349
- const writableOffers = outcome.stdout === "" ? [] : offerInputs;
350
- if (degradation === null) return { ...outcome, offerInputs: writableOffers };
351
- return { ...outcome,
352
- stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
353
- offerInputs: writableOffers };
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 };
354
387
  },
355
388
 
356
389
  async beforeTool({ binding, context, event, adapter }) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,3 +1,4 @@
1
+ import { applyNativeActivation, deactivateNative } from "./native-activation.mjs";
1
2
  import { finalizeRemoval, missingArtifactParents, recordInstall,
2
3
  removeEmptyOwnedDirectories, removeOwnedArtifacts } from "./ownership.mjs";
3
4
 
@@ -14,7 +15,7 @@ import { finalizeRemoval, missingArtifactParents, recordInstall,
14
15
  * three that work, plus the name of the one that did not.
15
16
  */
16
17
  export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false,
17
- accVersion = null }) {
18
+ accVersion = null, activation = {} }) {
18
19
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
19
20
  const results = { action: plan.action, dryRun, operations: [], skipped: plan.skipped,
20
21
  failed: [] };
@@ -37,22 +38,46 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
37
38
  requestedLivePolicy: operation.livePolicy ?? "off",
38
39
  livePolicy: operation.effectiveLivePolicy ?? "off" };
39
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
+ }
40
58
  // Recorded after the write, so a record never claims an install that
41
59
  // did not happen. The reverse order would leave uninstall trying to
42
60
  // remove files nothing created.
43
61
  await recordInstall({ dataHome, adapterId: adapter.id,
44
62
  version: operation.clientVersion ?? null, accVersion,
45
- artifacts: operation.artifacts, createdDirectories });
46
- results.operations.push({ ...operation, applied: true,
63
+ artifacts: operation.artifacts, createdDirectories, nativeActivation: native });
64
+ results.operations.push({ ...operation, applied: true, appendedRcBlock,
47
65
  changes: outcome.changes ?? [], diagnostics: [
48
66
  ...(operation.deliveryDiagnostic === undefined
49
67
  ? [] : [operation.deliveryDiagnostic]),
50
68
  ...(outcome.diagnostics ?? []),
69
+ ...notes,
51
70
  ] });
52
71
  } else {
53
72
  // Keep the record until every cleanup step succeeds. It is both the
54
73
  // authority for deletion and the only durable recipe a retry has when
55
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
+ }
56
81
  const owned = await removeOwnedArtifacts({ dataHome, adapterId: adapter.id });
57
82
  // What ownership held back is passed on, because the adapter would
58
83
  // otherwise remove its own layout unconditionally and undo the decision.
@@ -66,7 +91,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
66
91
  changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
67
92
  removedDirectories: directories.removed, keptDirectories: directories.kept,
68
93
  missingDirectories: directories.missing,
69
- diagnostics: outcome.diagnostics ?? [] });
94
+ diagnostics: [...(outcome.diagnostics ?? []), ...notes] });
70
95
  }
71
96
  } catch (error) {
72
97
  results.failed.push({ adapterId: operation.adapterId, error: error.message });
@@ -74,3 +99,22 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
74
99
  }
75
100
  return results;
76
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
+ }
@@ -1,10 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
- import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
4
+ import { effectiveCapabilities, evaluateNativeEligibility, validateNativeActivationPlan }
5
+ from "@agents-can-communicate/adapter-sdk";
6
+
7
+ import { resolveExecutable, shellOf, shimDirFor } from "./native-activation.mjs";
5
8
 
6
9
  const run = promisify(execFile);
7
10
 
11
+ // Reasons the client itself will not change within an install: unsupported,
12
+ // as opposed to degraded, which a repaired probe or shell may lift.
13
+ const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_captured",
14
+ "version_unavailable", "prerelease_not_captured", "below_minimum_version",
15
+ "known_bad_version"]);
16
+
8
17
  const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
9
18
 
10
19
  // Clients print their version in their own shape: "codex-cli 0.147.0", a bare
@@ -37,9 +46,65 @@ const withTimeout = (work, ms, label) => new Promise((resolve, reject) => {
37
46
  * exactly the case someone is running this command to find out about, and
38
47
  * letting it throw would hide the other three behind it.
39
48
  */
49
+ const unsupported = reasonCode => ({ state: "unsupported", reasonCode, realExecutable: null,
50
+ probe: null, eligibility: null, activationPlan: null });
51
+ const degraded = (reasonCode, facts) => ({ state: "degraded", reasonCode, activationPlan: null,
52
+ ...facts });
53
+
54
+ /**
55
+ * The read-only native-delivery report for one detected client: which
56
+ * executable a shim would exec, what the adapter's probe saw, the static
57
+ * verdict, and the activation the adapter would ask for. Version, help, and
58
+ * protocol probes only; never a service mutation.
59
+ */
60
+ async function detectNative(adapter, entry, { context, platform, probeTimeoutMs, pathEnv }) {
61
+ if (adapter.nativeDelivery === undefined) return unsupported("native_delivery_unsupported");
62
+ try {
63
+ const realExecutable = await resolveExecutable(adapter.client.command, { pathEnv,
64
+ exclude: [typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null] });
65
+ if (realExecutable === null) return unsupported("version_unavailable");
66
+ const facts = { realExecutable, probe: null, eligibility: null };
67
+ try {
68
+ facts.probe = await withTimeout(Promise.resolve(adapter.probeNativeDelivery({
69
+ realExecutable, timeoutMs: probeTimeoutMs })), probeTimeoutMs,
70
+ `${adapter.id} native probe`);
71
+ } catch {
72
+ facts.probe = null;
73
+ }
74
+ try {
75
+ facts.eligibility = evaluateNativeEligibility(adapter,
76
+ { clientVersion: entry.version, platform, probe: facts.probe });
77
+ } catch {
78
+ facts.eligibility = { eligible: false, reasonCode: "feature_probe_failed" };
79
+ }
80
+ if (facts.eligibility.eligible !== true) {
81
+ const reasonCode = facts.eligibility.reasonCode ?? "feature_probe_failed";
82
+ return STATIC_REASONS.has(reasonCode)
83
+ ? { ...unsupported(reasonCode), ...facts } : degraded(reasonCode, facts);
84
+ }
85
+ let activationPlan;
86
+ try {
87
+ activationPlan = validateNativeActivationPlan(await adapter.planNativeActivation({
88
+ detection: { realExecutable, version: entry.version, platform, probe: facts.probe },
89
+ context, livePolicy: null }));
90
+ } catch {
91
+ return degraded("feature_probe_failed", facts);
92
+ }
93
+ if (!activationPlan.eligible) return degraded(activationPlan.reasonCode, facts);
94
+ const shell = context?.shell ?? null;
95
+ if (activationPlan.mechanisms.some(item => item.kind === "shell-bootstrap") && shell !== "zsh") {
96
+ return { ...degraded("unsupported_shell", facts), activationPlan };
97
+ }
98
+ return { state: "eligible", reasonCode: null, ...facts, activationPlan };
99
+ } catch {
100
+ return degraded("feature_probe_failed", { realExecutable: null, probe: null, eligibility: null });
101
+ }
102
+ }
103
+
40
104
  export async function detectInstallation({ adapters, context, probe = spawnProbe,
41
105
  probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
42
- platform = `${process.platform}-${process.arch}` }) {
106
+ platform = `${process.platform}-${process.arch}`,
107
+ pathEnv = context?.env?.PATH ?? process.env.PATH ?? "" }) {
43
108
  const entries = await Promise.all([...adapters]
44
109
  // Ordered by id so two runs can be diffed, and so a plan built from this is
45
110
  // deterministic rather than dependent on registry order.
@@ -67,6 +132,10 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
67
132
  }
68
133
  entry.capabilities = effectiveCapabilities(adapter,
69
134
  { clientVersion: entry.version, platform });
135
+ entry.nativeDelivery = entry.present
136
+ ? await detectNative(adapter, entry, { context, platform, probeTimeoutMs, pathEnv })
137
+ : unsupported(adapter.nativeDelivery === undefined
138
+ ? "native_delivery_unsupported" : "version_unavailable");
70
139
 
71
140
  try {
72
141
  const detected = await adapter.detect(context);
@@ -82,7 +151,7 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
82
151
  } catch (error) {
83
152
  entry.error = entry.error ?? error.message;
84
153
  }
85
- if (entry.capabilities?.delivery?.livePush !== true
154
+ if (entry.nativeDelivery.state !== "eligible"
86
155
  && typeof adapter.deliveryFallback?.diagnostic === "string") {
87
156
  const nextTurnDowngraded = adapter.capabilities?.delivery?.nextTurn === true
88
157
  && entry.capabilities?.delivery?.nextTurn !== true;
@@ -5,3 +5,10 @@ export { applyPlan } from "./apply.mjs";
5
5
  export { finalizeRemoval, fingerprint, loadOwnership, missingArtifactParents, recordInstall,
6
6
  removeEmptyOwnedDirectories, removeOwned, removeOwnedArtifacts, treeFingerprint, verifyOwned }
7
7
  from "./ownership.mjs";
8
+ export { BOOTSTRAP_CACHE_SCHEMA, FAILED_TTL_MS, SUPPORTED_TTL_MS, cachePathFor,
9
+ checkNativeBootstrap } from "./bootstrap-runtime.mjs";
10
+ export { BLOCK_BEGIN, BLOCK_END, SHIM_MARKER, SHIM_POLICIES, SUPPORTED_SHELLS,
11
+ installShellBootstrap, locateBlock, planShellBootstrap, renderCommandShim, renderPathBlock,
12
+ shellLiteral, uninstallShellBootstrap, validateShimEntry } from "./shell-bootstrap.mjs";
13
+ export { LIVE_POLICIES, describeActivation, describeDeactivation, livePolicyOf, rcFileFor,
14
+ resolveExecutable, shellOf, shimDirFor } from "./native-activation.mjs";