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.
Files changed (155) hide show
  1. package/README.md +87 -69
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-bootstrap.mjs +56 -0
  4. package/bin/acc-claude-channel.mjs +177 -0
  5. package/bin/acc-hook.mjs +94 -12
  6. package/bin/acc-mcp.mjs +6 -2
  7. package/bin/acc.mjs +13 -3
  8. package/docs/ADAPTER_AUTHORING.md +204 -0
  9. package/docs/ARCHITECTURE.md +131 -0
  10. package/docs/CAPABILITIES.md +117 -214
  11. package/docs/CLI.md +164 -0
  12. package/docs/CONCEPTS.md +134 -0
  13. package/docs/CONFIGURATION.md +147 -0
  14. package/docs/DESIGN_DECISIONS.md +89 -0
  15. package/docs/GETTING_STARTED.md +145 -0
  16. package/docs/GLOSSARY.md +26 -0
  17. package/docs/HOW_IT_WORKS.md +277 -0
  18. package/docs/MCP.md +94 -0
  19. package/docs/PROTOCOL.md +200 -0
  20. package/docs/RELEASING.md +115 -0
  21. package/docs/SECURITY_MODEL.md +131 -0
  22. package/docs/TROUBLESHOOTING.md +108 -0
  23. package/docs/WHY_ACC.md +61 -0
  24. package/docs/index.md +44 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
  51. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  52. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
  61. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
  66. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  67. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
  68. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  69. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  70. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  71. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
  72. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
  73. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  74. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  75. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  76. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  77. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  78. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  79. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  80. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  81. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
  82. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  83. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
  85. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  86. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  87. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
  88. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
  89. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  90. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  91. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  92. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
  93. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  94. package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
  95. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
  96. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  97. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
  98. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  101. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
  103. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  104. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  105. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  106. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  107. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  108. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  109. package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
  110. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  111. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  112. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  113. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  114. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  115. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
  116. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  117. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  118. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  119. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
  120. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
  122. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  123. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
  124. package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
  125. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  126. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
  127. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
  128. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  129. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  130. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  131. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  132. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  133. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  134. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  135. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  137. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  138. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  139. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
  140. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  141. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  143. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  144. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  145. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  146. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  147. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  148. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  149. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  150. package/package.json +20 -1
  151. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  152. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  153. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  154. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  155. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,15 +1,26 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
+ import { effectiveCapabilities, evaluateNativeEligibility, validateNativeActivationPlan }
5
+ from "@agents-can-communicate/adapter-sdk";
6
+
7
+ import { resolveExecutable, shellOf, shimDirFor } from "./native-activation.mjs";
8
+
4
9
  const run = promisify(execFile);
5
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
+
6
17
  const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
7
18
 
8
19
  // Clients print their version in their own shape: "codex-cli 0.147.0", a bare
9
20
  // "0.36.1", a banner with the number somewhere inside. The number is extracted
10
21
  // where it can be, and the raw line is kept either way - "present, version
11
22
  // unreadable" is a real state and hiding it would make the client look absent.
12
- const VERSION = /\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/;
23
+ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
13
24
 
14
25
  /**
15
26
  * Ask the operating system what a client reports as its version.
@@ -35,8 +46,65 @@ const withTimeout = (work, ms, label) => new Promise((resolve, reject) => {
35
46
  * exactly the case someone is running this command to find out about, and
36
47
  * letting it throw would hide the other three behind it.
37
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
+
38
104
  export async function detectInstallation({ adapters, context, probe = spawnProbe,
39
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS }) {
105
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
106
+ platform = `${process.platform}-${process.arch}`,
107
+ pathEnv = context?.env?.PATH ?? process.env.PATH ?? "" }) {
40
108
  const entries = await Promise.all([...adapters]
41
109
  // Ordered by id so two runs can be diffed, and so a plan built from this is
42
110
  // deterministic rather than dependent on registry order.
@@ -44,8 +112,8 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
44
112
  .map(async adapter => {
45
113
  const entry = { adapterId: adapter.id, displayName: adapter.displayName,
46
114
  present: false, version: null, versionOutput: null, installed: false,
47
- diagnostics: [], needsAction: [], capabilities: adapter.capabilities ?? {},
48
- error: null };
115
+ diagnostics: [], needsAction: [], capabilities: effectiveCapabilities(adapter),
116
+ deliveryDiagnostic: null, error: null };
49
117
 
50
118
  try {
51
119
  const output = await withTimeout(
@@ -62,10 +130,16 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
62
130
  } catch (error) {
63
131
  entry.error = error.message;
64
132
  }
133
+ entry.capabilities = effectiveCapabilities(adapter,
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");
65
139
 
66
140
  try {
67
141
  const detected = await adapter.detect(context);
68
- entry.diagnostics = detected.diagnostics ?? [];
142
+ entry.diagnostics = [...(detected.diagnostics ?? [])];
69
143
  // What a person has to do, as opposed to what is true. Adapters that
70
144
  // have nothing to ask for say nothing.
71
145
  entry.needsAction = detected.needsAction ?? [];
@@ -77,6 +151,16 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
77
151
  } catch (error) {
78
152
  entry.error = entry.error ?? error.message;
79
153
  }
154
+ if (entry.nativeDelivery.state !== "eligible"
155
+ && typeof adapter.deliveryFallback?.diagnostic === "string") {
156
+ const nextTurnDowngraded = adapter.capabilities?.delivery?.nextTurn === true
157
+ && entry.capabilities?.delivery?.nextTurn !== true;
158
+ entry.deliveryDiagnostic = nextTurnDowngraded
159
+ ? `${adapter.displayName} ${entry.version ?? "unknown version"} has no certified `
160
+ + `next-turn delivery on ${platform}; ${adapter.deliveryFallback.diagnostic}`
161
+ : adapter.deliveryFallback.diagnostic;
162
+ entry.diagnostics.push(entry.deliveryDiagnostic);
163
+ }
80
164
  return entry;
81
165
  }));
82
166
  return entries;
@@ -2,5 +2,13 @@
2
2
  export { detectInstallation, spawnProbe } from "./detect.mjs";
3
3
  export { planInstallation } from "./plan.mjs";
4
4
  export { applyPlan } from "./apply.mjs";
5
- export { fingerprint, loadOwnership, recordInstall, removeOwned, treeFingerprint,
6
- verifyOwned } from "./ownership.mjs";
5
+ export { finalizeRemoval, fingerprint, loadOwnership, missingArtifactParents, recordInstall,
6
+ removeEmptyOwnedDirectories, removeOwned, removeOwnedArtifacts, treeFingerprint, verifyOwned }
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";
@@ -0,0 +1,161 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, constants } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { defaultBootstrap } from "@agents-can-communicate/adapter-sdk";
6
+
7
+ import { installShellBootstrap, planShellBootstrap, uninstallShellBootstrap }
8
+ from "./shell-bootstrap.mjs";
9
+
10
+ // The installer's side of a native activation: which owned mechanisms an
11
+ // eligible adapter asked for, how they are applied in a fixed order, what is
12
+ // recorded so uninstall removes only ACC's bytes, and how a recorded
13
+ // activation is taken back. Adapter-owned native config is written by the
14
+ // adapter's own install; a vendor service is started only here, only during
15
+ // apply, and only when it did not already exist.
16
+
17
+ export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
18
+ const MECHANISM_ORDER = ["native-config", "native-service", "shell-bootstrap"];
19
+ const SERVICE_TIMEOUT_MS = 15_000;
20
+
21
+ export const livePolicyOf = install => (LIVE_POLICIES.includes(install?.nativeActivation?.livePolicy)
22
+ ? install.nativeActivation.livePolicy : "off");
23
+
24
+ export const shellOf = env => {
25
+ const shell = env?.SHELL;
26
+ return typeof shell === "string" && shell !== "" ? path.basename(shell) : null;
27
+ };
28
+ export const shimDirFor = stateRoot => path.join(stateRoot, "bin");
29
+ // `.zshrc`, deliberately, and that makes the shim interactive-only: zsh reads
30
+ // this file for interactive shells and not for `zsh -lc` or a script. An
31
+ // interactive launch is where a client session comes from, and putting the PATH
32
+ // entry somewhere every shell reads would put ACC in front of a vendor command
33
+ // in scripts and CI that never asked for it. Worth knowing when checking an
34
+ // install: a non-interactive shell resolving the vendor binary directly is this
35
+ // choice working, not a broken bootstrap.
36
+ export const rcFileFor = (home, shell) => (shell === "zsh" && typeof home === "string"
37
+ ? path.join(home, ".zshrc") : null);
38
+
39
+ // The vendor executable a shim will exec: the first executable on PATH that is
40
+ // not ACC's own shim directory, so a shim never resolves itself.
41
+ export async function resolveExecutable(command, { pathEnv = "", exclude = [] } = {}) {
42
+ const excluded = exclude.filter(Boolean).map(directory => path.resolve(directory));
43
+ for (const directory of String(pathEnv).split(path.delimiter).filter(Boolean)) {
44
+ if (excluded.includes(path.resolve(directory))) continue;
45
+ const candidate = path.join(directory, command);
46
+ try {
47
+ await access(candidate, constants.X_OK);
48
+ return candidate;
49
+ } catch {
50
+ // not here; keep walking PATH
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ const defaultExec = (executable, args) => new Promise((resolve, reject) => {
57
+ execFile(executable, args, { timeout: SERVICE_TIMEOUT_MS, windowsHide: true },
58
+ error => (error === null ? resolve() : reject(error)));
59
+ });
60
+
61
+ const ordered = mechanisms => [...mechanisms].sort((left, right) =>
62
+ MECHANISM_ORDER.indexOf(left.kind) - MECHANISM_ORDER.indexOf(right.kind));
63
+
64
+ const renderCommand = command => [command.executable, ...command.args].join(" ");
65
+
66
+ /** What an activation would touch, in the operator's words. */
67
+ export function describeActivation(activation) {
68
+ const lines = [];
69
+ for (const mechanism of ordered(activation.mechanisms)) {
70
+ if (mechanism.kind === "shell-bootstrap") {
71
+ lines.push(`create shim ${path.join(activation.shimDir, mechanism.command)} for `
72
+ + `${mechanism.command} (${activation.livePolicy} live delivery)`);
73
+ lines.push(`add a PATH block to ${activation.rcFile}`);
74
+ } else if (mechanism.kind === "native-config") {
75
+ lines.push(`write native config ${mechanism.artifactIds.join(", ")}`);
76
+ } else if (mechanism.preExisting) {
77
+ lines.push(`use the existing ${mechanism.serviceId} service`);
78
+ } else if (mechanism.applyCommand !== null) {
79
+ lines.push(`start the ${mechanism.serviceId} service: ${renderCommand(mechanism.applyCommand)}`);
80
+ }
81
+ }
82
+ return lines;
83
+ }
84
+
85
+ export function describeDeactivation(nativeActivation) {
86
+ const lines = [];
87
+ for (const mechanism of nativeActivation?.mechanisms ?? []) {
88
+ if (mechanism.kind === "shell-bootstrap") {
89
+ for (const file of mechanism.ownedFiles) lines.push(`remove shim ${file.path}`);
90
+ lines.push(`remove the PATH block from ${mechanism.rcFile.path} once no ACC shim remains`);
91
+ } else if (mechanism.kind === "native-service") {
92
+ lines.push(mechanism.createdByAcc && mechanism.teardownCommand !== null
93
+ ? `stop the ${mechanism.serviceId} service: ${renderCommand(mechanism.teardownCommand)}`
94
+ : `leave the ${mechanism.serviceId} service in place (${mechanism.createdByAcc
95
+ ? "no vendor teardown exists" : "it existed before ACC"})`);
96
+ }
97
+ }
98
+ return lines;
99
+ }
100
+
101
+ export async function applyNativeActivation({ adapter, activation, dataHome,
102
+ node = process.execPath, bootstrap = defaultBootstrap(), exec = defaultExec }) {
103
+ const record = { livePolicy: activation.livePolicy,
104
+ protocolContract: activation.protocolContract, mechanisms: [] };
105
+ let shell = null;
106
+ try {
107
+ for (const mechanism of ordered(activation.mechanisms)) {
108
+ if (mechanism.kind === "native-config") {
109
+ record.mechanisms.push({ kind: mechanism.kind, artifactIds: [...mechanism.artifactIds] });
110
+ } else if (mechanism.kind === "native-service") {
111
+ let createdByAcc = false;
112
+ if (!mechanism.preExisting && mechanism.applyCommand !== null) {
113
+ await exec(mechanism.applyCommand.executable, mechanism.applyCommand.args);
114
+ createdByAcc = true;
115
+ }
116
+ record.mechanisms.push({ kind: mechanism.kind, serviceId: mechanism.serviceId,
117
+ createdByAcc, teardownCommand: mechanism.teardownCommand });
118
+ } else {
119
+ const plan = planShellBootstrap({ shell: activation.shell, rcFile: activation.rcFile,
120
+ shimDir: activation.shimDir, runtime: { node, bootstrap, dataHome },
121
+ entries: [{ adapterId: adapter.id, command: mechanism.command,
122
+ realExecutable: mechanism.realExecutable, prefixArgs: mechanism.prefixArgs,
123
+ livePolicy: activation.livePolicy }] });
124
+ const result = await installShellBootstrap({ plan });
125
+ if (!result.ok) throw new Error(`shell bootstrap refused: ${result.reasonCode}`);
126
+ shell = result;
127
+ record.mechanisms.push({ kind: mechanism.kind, shimDir: activation.shimDir,
128
+ ownedFiles: result.shims.map(shim => ({ path: shim.path, sha256: shim.sha256 })),
129
+ rcFile: result.rcFile });
130
+ }
131
+ }
132
+ return { nativeActivation: record, appendedRcBlock: shell?.rcFile.appended === true };
133
+ } catch (error) {
134
+ // Only bytes this operation wrote are taken back; a pre-existing service
135
+ // or a user's own file is never touched on the way out.
136
+ if (shell !== null) {
137
+ await uninstallShellBootstrap({ ownership: { shims: shell.shims, shimDir: activation.shimDir,
138
+ rcFile: shell.rcFile } }).catch(() => null);
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ export async function deactivateNative({ nativeActivation, exec = defaultExec }) {
145
+ const report = { shell: null, services: [] };
146
+ for (const mechanism of nativeActivation?.mechanisms ?? []) {
147
+ if (mechanism.kind === "shell-bootstrap") {
148
+ report.shell = await uninstallShellBootstrap({ ownership: { shims: mechanism.ownedFiles,
149
+ shimDir: mechanism.shimDir, rcFile: mechanism.rcFile } });
150
+ } else if (mechanism.kind === "native-service") {
151
+ if (mechanism.createdByAcc && mechanism.teardownCommand !== null) {
152
+ await exec(mechanism.teardownCommand.executable, mechanism.teardownCommand.args);
153
+ report.services.push({ serviceId: mechanism.serviceId, outcome: "stopped" });
154
+ } else {
155
+ report.services.push({ serviceId: mechanism.serviceId,
156
+ outcome: mechanism.createdByAcc ? "retained_no_teardown" : "retained_pre_existing" });
157
+ }
158
+ }
159
+ }
160
+ return report;
161
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, readFile, rename, rm, rmdir, writeFile }
3
+ from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
@@ -109,7 +110,7 @@ async function saveOwnership({ dataHome, record }) {
109
110
  * runtime, and leaves the bundle inside the client exactly where it was.
110
111
  */
111
112
  export async function recordInstall({ dataHome, adapterId, version, accVersion = null,
112
- artifacts }) {
113
+ artifacts, createdDirectories = [], nativeActivation = null }) {
113
114
  const stamped = await Promise.all(artifacts.map(async artifact => ({
114
115
  path: artifact.path,
115
116
  kind: artifact.kind ?? "file",
@@ -118,14 +119,103 @@ export async function recordInstall({ dataHome, adapterId, version, accVersion =
118
119
  sha256: artifact.kind === "merge" ? null : await fingerprintFor(artifact),
119
120
  })));
120
121
  const record = await loadOwnership({ dataHome });
122
+ const previous = record.installs.find(install => install.adapterId === adapterId);
123
+ const directories = [...new Set([
124
+ ...(previous?.createdDirectories ?? []), ...createdDirectories,
125
+ ])].sort((left, right) => left.split(path.sep).length - right.split(path.sep).length
126
+ || left.localeCompare(right));
121
127
  await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
122
128
  installs: [...record.installs.filter(install => install.adapterId !== adapterId),
123
- { adapterId, version, accVersion, artifacts: stamped }] } });
129
+ { adapterId, version, accVersion, artifacts: stamped,
130
+ ...(directories.length === 0 ? {} : { createdDirectories: directories }),
131
+ // Present only for a consented native activation. A record without it
132
+ // - every 0.2 install - reads as live policy off and is never rewritten
133
+ // merely to add the field.
134
+ ...(nativeActivation === null ? {} : { nativeActivation }) }] } });
124
135
  }
125
136
 
126
137
  const installFor = (record, adapterId) =>
127
138
  record.installs.find(install => install.adapterId === adapterId) ?? null;
128
139
 
140
+ const inside = (home, candidate) => {
141
+ const relative = path.relative(home, candidate);
142
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`)
143
+ && !path.isAbsolute(relative);
144
+ };
145
+
146
+ async function hasSymlinkAncestor(home, candidate) {
147
+ let current = candidate;
148
+ while (inside(home, current)) {
149
+ const stat = await lstat(current).catch(error => {
150
+ if (error.code === "ENOENT") return null;
151
+ throw error;
152
+ });
153
+ if (stat?.isSymbolicLink()) return true;
154
+ current = path.dirname(current);
155
+ }
156
+ return false;
157
+ }
158
+
159
+ /** Return planned artifact parents that do not yet exist under the client home. */
160
+ export async function missingArtifactParents({ home, artifacts }) {
161
+ if (typeof home !== "string") return [];
162
+ const root = path.resolve(home);
163
+ const missing = new Set();
164
+ for (const artifact of artifacts) {
165
+ let directory = path.dirname(path.resolve(artifact.path));
166
+ while (inside(root, directory)) {
167
+ try {
168
+ await lstat(directory);
169
+ break;
170
+ } catch (error) {
171
+ if (error.code !== "ENOENT") throw error;
172
+ missing.add(directory);
173
+ directory = path.dirname(directory);
174
+ }
175
+ }
176
+ }
177
+ return [...missing].sort((left, right) =>
178
+ left.split(path.sep).length - right.split(path.sep).length || left.localeCompare(right));
179
+ }
180
+
181
+ /** Remove recorded parents deepest-first, but only while each remains an empty directory. */
182
+ export async function removeEmptyOwnedDirectories({ home, directories = [] }) {
183
+ const result = { removed: [], kept: [], missing: [] };
184
+ const root = typeof home === "string" ? path.resolve(home) : null;
185
+ const ordered = [...new Set(directories)].sort((left, right) =>
186
+ right.split(path.sep).length - left.split(path.sep).length || left.localeCompare(right));
187
+ for (const directory of ordered) {
188
+ if (root === null || !inside(root, path.resolve(directory))) {
189
+ result.kept.push(directory);
190
+ continue;
191
+ }
192
+ let stat;
193
+ try {
194
+ stat = await lstat(directory);
195
+ } catch (error) {
196
+ if (error.code === "ENOENT") { result.missing.push(directory); continue; }
197
+ throw error;
198
+ }
199
+ if (!stat.isDirectory() || stat.isSymbolicLink()
200
+ || await hasSymlinkAncestor(root, path.dirname(directory))) {
201
+ result.kept.push(directory);
202
+ continue;
203
+ }
204
+ try {
205
+ await rmdir(directory);
206
+ result.removed.push(directory);
207
+ } catch (error) {
208
+ if (["ENOTEMPTY", "EEXIST"].includes(error.code)) {
209
+ result.kept.push(directory);
210
+ continue;
211
+ }
212
+ if (error.code === "ENOENT") { result.missing.push(directory); continue; }
213
+ throw error;
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+
129
219
  /** Compare what was written against what is there now. Read-only. */
130
220
  export async function verifyOwned({ dataHome, adapterId }) {
131
221
  const install = installFor(await loadOwnership({ dataHome }), adapterId);
@@ -141,17 +231,12 @@ export async function verifyOwned({ dataHome, adapterId }) {
141
231
  return result;
142
232
  }
143
233
 
144
- /**
145
- * Remove the files this adapter's install wrote, and only those.
146
- *
147
- * A modified file is kept and reported. A merge artifact is never deleted at
148
- * all: the user owns that file and ACC owns some entries inside it, which is the
149
- * adapter's own uninstall to unpick because it knows the format.
150
- */
151
- export async function removeOwned({ dataHome, adapterId }) {
234
+ /** Remove owned artifacts while retaining the record as retry authority. */
235
+ export async function removeOwnedArtifacts({ dataHome, adapterId }) {
152
236
  const record = await loadOwnership({ dataHome });
153
237
  const install = installFor(record, adapterId);
154
- const result = { adapterId, removed: [], kept: [], missing: [], delegated: [] };
238
+ const result = { adapterId, removed: [], kept: [], missing: [], delegated: [],
239
+ createdDirectories: install?.createdDirectories ?? [] };
155
240
  if (install === null) return result;
156
241
 
157
242
  for (const artifact of install.artifacts) {
@@ -163,7 +248,22 @@ export async function removeOwned({ dataHome, adapterId }) {
163
248
  result.removed.push(artifact.path);
164
249
  }
165
250
 
251
+ return result;
252
+ }
253
+
254
+ /** Forget one install only after every adapter-owned cleanup step succeeded. */
255
+ export async function finalizeRemoval({ dataHome, adapterId }) {
256
+ const record = await loadOwnership({ dataHome });
257
+ if (installFor(record, adapterId) === null) return false;
258
+
166
259
  await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
167
260
  installs: record.installs.filter(entry => entry.adapterId !== adapterId) } });
261
+ return true;
262
+ }
263
+
264
+ /** Remove and finalize for callers that perform no delegated adapter cleanup. */
265
+ export async function removeOwned(options) {
266
+ const result = await removeOwnedArtifacts(options);
267
+ await finalizeRemoval(options);
168
268
  return result;
169
269
  }
@@ -1,5 +1,8 @@
1
1
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
2
 
3
+ import { LIVE_POLICIES, describeActivation, describeDeactivation, rcFileFor, shimDirFor }
4
+ from "./native-activation.mjs";
5
+
3
6
  /**
4
7
  * Turn a detection report into exactly what would happen.
5
8
  *
@@ -8,11 +11,27 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
11
  * it previews is a decoration, and the operator would find out only afterwards.
9
12
  */
10
13
  export function planInstallation({ adapters, detected, context, action = "install",
11
- recorded = [], accVersion = null, allowDowngrade = false, requested = [] }) {
14
+ recorded = [], accVersion = null, allowDowngrade = false, requested = [],
15
+ deliveryByAdapter = {} }) {
12
16
  if (!["install", "uninstall"].includes(action)) {
13
17
  throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
14
18
  }
15
19
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
20
+ // One explicit answer per selected client. A missing entry is off; a policy
21
+ // for a client that is not part of this run is a mistake to say out loud,
22
+ // not something to store for later.
23
+ if (deliveryByAdapter === null || typeof deliveryByAdapter !== "object") {
24
+ throw new AccError(EXIT.USAGE, "deliveryByAdapter must map adapter ids to policies");
25
+ }
26
+ for (const [adapterId, policy] of Object.entries(deliveryByAdapter)) {
27
+ if (!byId.has(adapterId)) {
28
+ throw new AccError(EXIT.USAGE,
29
+ `delivery policy names ${adapterId}, which is not part of this install`, { adapterId });
30
+ }
31
+ if (!LIVE_POLICIES.includes(policy)) {
32
+ throw new AccError(EXIT.USAGE, `unknown delivery policy: ${policy}`, { adapterId, policy });
33
+ }
34
+ }
16
35
  // What ACC recorded writing, by client. For an uninstall this is the
17
36
  // authority rather than detection: the record is the only account of what was
18
37
  // written, and a client's configuration directory outlives the client.
@@ -82,7 +101,32 @@ export function planInstallation({ adapters, detected, context, action = "instal
82
101
  // From the record when the client is gone, because that is what was written
83
102
  // and so what will be removed. Asking the adapter instead would describe an
84
103
  // install for a machine this one no longer is.
85
- const artifacts = (record?.artifacts ?? adapter.planInstall(context))
104
+ const delivery = deliveryByAdapter[entry.adapterId] ?? "off";
105
+ const native = entry.nativeDelivery ?? null;
106
+ const liveDeliverySupported = native?.state === "eligible"
107
+ && native.activationPlan?.eligible === true;
108
+ const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
109
+ const deliveryDiagnostic = action === "install" && delivery !== "off"
110
+ && !liveDeliverySupported
111
+ ? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
112
+ ?? `${adapter.displayName ?? adapter.id} cannot receive native delivery `
113
+ + `(${native?.reasonCode ?? "native_delivery_unsupported"}); durable fallback remains active`
114
+ : null;
115
+ const installContext = { ...context, requestedLivePolicy: delivery,
116
+ livePolicy: effectiveLivePolicy };
117
+ // A consented activation that this run keeps, activates, or takes back.
118
+ // Only an explicit off or an uninstall removes one; an absent record never
119
+ // creates one.
120
+ const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
121
+ const nativeActivation = action === "install" && effectiveLivePolicy !== "off"
122
+ ? { livePolicy: effectiveLivePolicy, protocolContract: native.eligibility.protocolContract,
123
+ shell: context?.shell ?? null, rcFile: rcFileFor(context?.home, context?.shell),
124
+ shimDir: typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null,
125
+ mechanisms: native.activationPlan.mechanisms }
126
+ : null;
127
+ const deactivation = previous !== null
128
+ && (action === "uninstall" || effectiveLivePolicy === "off") ? previous : null;
129
+ const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
86
130
  .map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
87
131
  .sort((a, b) => a.path.localeCompare(b.path));
88
132
 
@@ -96,16 +140,24 @@ export function planInstallation({ adapters, detected, context, action = "instal
96
140
  // to be inferred from a client version that is null.
97
141
  clientPresent: entry.present === true,
98
142
  alreadyInstalled: entry.installed === true,
143
+ livePolicy: delivery,
144
+ effectiveLivePolicy,
145
+ ...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
146
+ ...(nativeActivation === null ? {} : { nativeActivation }),
147
+ ...(deactivation === null ? {} : { deactivation }),
99
148
  artifacts,
100
149
  // Said in the operator's terms, not in paths: which files ACC creates
101
150
  // outright and which belong to the user and are only edited.
102
151
  summary: [
103
152
  ...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
104
153
  + "this machine; removing what ACC recorded writing"]),
154
+ ...(deliveryDiagnostic === null ? [] : [deliveryDiagnostic]),
105
155
  ...artifacts.filter(a => a.kind === "tree")
106
156
  .map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
107
157
  ...artifacts.filter(a => a.kind === "merge")
108
158
  .map(a => `${action === "install" ? "add ACC entries to" : "remove ACC entries from"} ${a.path}`),
159
+ ...(nativeActivation === null ? [] : describeActivation(nativeActivation)),
160
+ ...(deactivation === null ? [] : describeDeactivation(deactivation)),
109
161
  ],
110
162
  });
111
163
  }