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
@@ -2,9 +2,13 @@
2
2
  // that survives between two ephemeral hook processes.
3
3
  export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
4
4
  export { effectiveCapabilities, validateCertification } from "./certification.mjs";
5
+ export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
6
+ compareStableVersions, evaluateNativeEligibility, parseStableVersion,
7
+ validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
8
+ from "./native-delivery.mjs";
5
9
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
6
- export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstalledTree,
7
- runnerExists, writeHookShim }
10
+ export { assertRunner, bakeSkillCommand, defaultBootstrap, defaultChannel, defaultCli,
11
+ defaultRunner, removeInstalledTree, runnerExists, writeHookShim }
8
12
  from "./hook-shim.mjs";
9
13
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
10
14
  from "./toml-block.mjs";
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+
3
+ import { COMMAND_NAME, IDENTIFIER, NATIVE_ACTIVATION_KINDS, SHELL_SOURCE, assertReasonCode,
4
+ closed, deepFreeze, isPlainObject, isText, usage } from "./native-vocabulary.mjs";
5
+
6
+ // A native activation plan is what an adapter hands the installer: closed
7
+ // mechanisms made of executables and argument arrays, never shell source. The
8
+ // generic installer records artifact ids and executes only the apply command
9
+ // of a native service, and only during apply.
10
+
11
+ function validateCommand(value, label) {
12
+ if (value === null) return null;
13
+ closed(value, ["executable", "args"], label);
14
+ if (!isText(value.executable) || !path.isAbsolute(value.executable)) {
15
+ usage(`${label} executable must be an absolute path`);
16
+ }
17
+ if (!Array.isArray(value.args) || value.args.some(arg => typeof arg !== "string"
18
+ || SHELL_SOURCE.test(arg))) {
19
+ usage(`${label} args must be plain argument strings, never shell source`);
20
+ }
21
+ return { executable: value.executable, args: [...value.args] };
22
+ }
23
+
24
+ function validateMechanism(mechanism, index) {
25
+ const label = `native activation mechanism ${index}`;
26
+ if (!isPlainObject(mechanism) || !NATIVE_ACTIVATION_KINDS.includes(mechanism.kind)) {
27
+ usage(`${label} kind must be one of ${NATIVE_ACTIVATION_KINDS.join(", ")}`);
28
+ }
29
+ if (mechanism.kind === "shell-bootstrap") {
30
+ closed(mechanism, ["kind", "command", "realExecutable", "prefixArgs"], label);
31
+ if (!isText(mechanism.command) || !COMMAND_NAME.test(mechanism.command)) {
32
+ usage(`${label} command must be a bare command name`);
33
+ }
34
+ if (!isText(mechanism.realExecutable) || !path.isAbsolute(mechanism.realExecutable)) {
35
+ usage(`${label} realExecutable must be an absolute path`);
36
+ }
37
+ if (!Array.isArray(mechanism.prefixArgs) || mechanism.prefixArgs.some(arg => !isText(arg)
38
+ || SHELL_SOURCE.test(arg))) {
39
+ usage(`${label} prefixArgs must be plain argument strings, never shell source`);
40
+ }
41
+ return { kind: mechanism.kind, command: mechanism.command,
42
+ realExecutable: mechanism.realExecutable, prefixArgs: [...mechanism.prefixArgs] };
43
+ }
44
+ if (mechanism.kind === "native-config") {
45
+ closed(mechanism, ["kind", "artifactIds"], label);
46
+ if (!Array.isArray(mechanism.artifactIds) || mechanism.artifactIds.length === 0
47
+ || mechanism.artifactIds.some(id => !isText(id) || !IDENTIFIER.test(id))) {
48
+ usage(`${label} artifactIds must be non-empty closed identifiers`);
49
+ }
50
+ return { kind: mechanism.kind, artifactIds: [...mechanism.artifactIds] };
51
+ }
52
+ closed(mechanism, ["kind", "serviceId", "preExisting", "applyCommand", "teardownCommand"], label);
53
+ if (!isText(mechanism.serviceId) || !IDENTIFIER.test(mechanism.serviceId)) {
54
+ usage(`${label} serviceId must be a closed identifier`);
55
+ }
56
+ if (typeof mechanism.preExisting !== "boolean") usage(`${label} preExisting must be a boolean`);
57
+ const commandLabel = key => `${label} ${key} must be null or { executable, args }`;
58
+ for (const key of ["applyCommand", "teardownCommand"]) {
59
+ if (mechanism[key] !== null && !isPlainObject(mechanism[key])) usage(commandLabel(key));
60
+ }
61
+ return { kind: mechanism.kind, serviceId: mechanism.serviceId, preExisting: mechanism.preExisting,
62
+ applyCommand: validateCommand(mechanism.applyCommand, `${label} applyCommand`),
63
+ teardownCommand: validateCommand(mechanism.teardownCommand, `${label} teardownCommand`) };
64
+ }
65
+
66
+ export function validateNativeActivationPlan(value) {
67
+ closed(value, ["eligible", "reasonCode", "mechanisms"], "native activation plan");
68
+ if (typeof value.eligible !== "boolean") usage("native activation plan eligible must be a boolean");
69
+ assertReasonCode(value.reasonCode, "native activation plan");
70
+ if (!Array.isArray(value.mechanisms)) usage("native activation plan mechanisms must be an array");
71
+ if (!value.eligible && value.mechanisms.length > 0) {
72
+ usage("an ineligible native activation plan must not carry mechanisms");
73
+ }
74
+ return deepFreeze({ eligible: value.eligible, reasonCode: value.reasonCode,
75
+ mechanisms: value.mechanisms.map(validateMechanism) });
76
+ }
@@ -0,0 +1,202 @@
1
+ import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, KNOWN_BAD_REASON, NATIVE_ACTIVATION_KINDS,
2
+ NATIVE_BINDING_MODES, NATIVE_PLATFORMS, PROBE_KEYS, TIMESTAMP, assertModes,
3
+ assertReasonCode, closed, compareStableVersions, deepFreeze, isPlainObject, isText,
4
+ parseStableVersion, usage } from "./native-vocabulary.mjs";
5
+
6
+ // The native-delivery compatibility contract: a per-platform minimum that is a
7
+ // real passing capture, one or more anchors naming the captured protocol, an
8
+ // explicit denylist, and the activation kinds an adapter may ask the installer
9
+ // for. There is deliberately no maximum: a newer stable client is admitted only
10
+ // when a current read-only probe and a per-session handshake confirm the same
11
+ // protocol contract. Exact-version certification still governs every other
12
+ // capability; this rule is used for native live delivery alone.
13
+
14
+ export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
15
+ compareStableVersions, parseStableVersion } from "./native-vocabulary.mjs";
16
+ export { validateNativeActivationPlan } from "./native-activation.mjs";
17
+
18
+ const orderedModes = modes => NATIVE_BINDING_MODES.filter(mode => modes.includes(mode));
19
+
20
+ export function validateNativeDeliveryContract(value, { certification, client }) {
21
+ closed(value, ["minimumByPlatform", "anchors", "knownBad", "activationKinds"], "nativeDelivery");
22
+ const minimums = value.minimumByPlatform;
23
+ if (!isPlainObject(minimums) || Object.keys(minimums).length === 0) {
24
+ usage("nativeDelivery.minimumByPlatform must map at least one captured platform to a version");
25
+ }
26
+ for (const [platform, version] of Object.entries(minimums)) {
27
+ if (!NATIVE_PLATFORMS.includes(platform)) {
28
+ usage(`nativeDelivery.minimumByPlatform names an unknown platform ${platform}`);
29
+ }
30
+ if (parseStableVersion(version) === null) {
31
+ usage(`nativeDelivery.minimumByPlatform ${platform} must be a stable version`);
32
+ }
33
+ }
34
+ if (!Array.isArray(value.anchors) || value.anchors.length === 0) {
35
+ usage("nativeDelivery.anchors must name at least one passing capture");
36
+ }
37
+ const anchors = value.anchors.map((anchor, index) => {
38
+ closed(anchor, ["platform", "version", "protocolContract"], "nativeDelivery anchor");
39
+ const minimum = minimums[anchor.platform];
40
+ if (minimum === undefined) {
41
+ usage(`nativeDelivery anchor ${index} platform ${anchor.platform} has no minimum`);
42
+ }
43
+ if (parseStableVersion(anchor.version) === null
44
+ || compareStableVersions(anchor.version, minimum) < 0) {
45
+ usage(`nativeDelivery anchor ${index} version must be a stable version at or above the minimum`);
46
+ }
47
+ if (!isText(anchor.protocolContract) || !CONTRACT_ID.test(anchor.protocolContract)) {
48
+ usage(`nativeDelivery anchor ${index} protocolContract must be a closed identifier`);
49
+ }
50
+ const proven = (certification?.evidence ?? []).some(item => item.result === "pass"
51
+ && item.capability === "delivery.livePush" && item.client === client
52
+ && item.version === anchor.version && item.platform === anchor.platform);
53
+ if (!proven) {
54
+ usage(`nativeDelivery anchor ${anchor.version} on ${anchor.platform} has no passing `
55
+ + "delivery.livePush certification", { anchor });
56
+ }
57
+ return { ...anchor };
58
+ });
59
+ for (const [platform, minimum] of Object.entries(minimums)) {
60
+ if (!anchors.some(anchor => anchor.platform === platform && anchor.version === minimum)) {
61
+ usage(`nativeDelivery minimum ${minimum} on ${platform} must be the first passing capture: `
62
+ + "no anchor matches it");
63
+ }
64
+ }
65
+ if (!Array.isArray(value.knownBad)) usage("nativeDelivery.knownBad must be an array");
66
+ const knownBad = value.knownBad.map(entry => {
67
+ const exact = isPlainObject(entry) && Object.hasOwn(entry, "version");
68
+ closed(entry, exact ? ["version", "reasonCode"] : ["from", "to", "reasonCode"],
69
+ "nativeDelivery.knownBad entry");
70
+ for (const key of exact ? ["version"] : ["from", "to"]) {
71
+ if (parseStableVersion(entry[key]) === null) {
72
+ usage(`nativeDelivery.knownBad ${key} must be a stable version`);
73
+ }
74
+ }
75
+ if (!exact && compareStableVersions(entry.from, entry.to) > 0) {
76
+ usage("nativeDelivery.knownBad interval from must not exceed to");
77
+ }
78
+ if (entry.reasonCode !== KNOWN_BAD_REASON) {
79
+ usage(`nativeDelivery.knownBad reasonCode must be ${KNOWN_BAD_REASON}`);
80
+ }
81
+ return { ...entry };
82
+ });
83
+ const kinds = value.activationKinds;
84
+ if (!Array.isArray(kinds) || kinds.length === 0 || new Set(kinds).size !== kinds.length
85
+ || kinds.some(kind => !NATIVE_ACTIVATION_KINDS.includes(kind))) {
86
+ usage(`nativeDelivery.activationKinds must be unique entries of ${NATIVE_ACTIVATION_KINDS.join(", ")}`);
87
+ }
88
+ return deepFreeze({ minimumByPlatform: { ...minimums }, anchors, knownBad,
89
+ activationKinds: [...kinds] });
90
+ }
91
+
92
+ function knownBadHit(contract, version) {
93
+ return contract.knownBad.some(entry => (Object.hasOwn(entry, "version")
94
+ ? compareStableVersions(version, entry.version) === 0
95
+ : compareStableVersions(version, entry.from) >= 0 && compareStableVersions(version, entry.to) <= 0));
96
+ }
97
+
98
+ // The static half of the rule: platform, minimum, prerelease, and denylist.
99
+ function evaluateStatic(adapter, { clientVersion, platform }) {
100
+ const contract = adapter?.nativeDelivery;
101
+ if (contract === undefined) {
102
+ return { reasonCode: "native_delivery_unsupported", minimumVersion: null, protocolContract: null };
103
+ }
104
+ const minimumVersion = typeof platform === "string"
105
+ ? (contract.minimumByPlatform[platform] ?? null) : null;
106
+ if (minimumVersion === null) {
107
+ return { reasonCode: "platform_not_captured", minimumVersion: null, protocolContract: null };
108
+ }
109
+ const anchor = contract.anchors.find(item => item.platform === platform
110
+ && item.version === minimumVersion);
111
+ const facts = { minimumVersion, protocolContract: anchor.protocolContract };
112
+ if (!isText(clientVersion)) return { ...facts, reasonCode: "version_unavailable" };
113
+ if (parseStableVersion(clientVersion) === null) {
114
+ return { ...facts, reasonCode: "prerelease_not_captured" };
115
+ }
116
+ if (compareStableVersions(clientVersion, minimumVersion) < 0) {
117
+ return { ...facts, reasonCode: "below_minimum_version" };
118
+ }
119
+ if (knownBadHit(contract, clientVersion)) return { ...facts, reasonCode: "known_bad_version" };
120
+ return { ...facts, reasonCode: null };
121
+ }
122
+
123
+ function validateNativeProbe(probe) {
124
+ closed(probe, PROBE_KEYS, "native probe");
125
+ if (typeof probe.supported !== "boolean") usage("native probe supported must be a boolean");
126
+ if (probe.clientVersion !== null && !isText(probe.clientVersion)) {
127
+ usage("native probe clientVersion must be a string or null");
128
+ }
129
+ if (probe.protocolContract !== null
130
+ && (!isText(probe.protocolContract) || !CONTRACT_ID.test(probe.protocolContract))) {
131
+ usage("native probe protocolContract must be a closed identifier or null");
132
+ }
133
+ if (probe.executableFingerprint !== null
134
+ && (!isText(probe.executableFingerprint) || !FINGERPRINT.test(probe.executableFingerprint))) {
135
+ usage("native probe executableFingerprint must be sha256:<64 hex> or null");
136
+ }
137
+ assertModes(probe.modes, "native probe");
138
+ assertReasonCode(probe.reasonCode, "native probe");
139
+ return probe;
140
+ }
141
+
142
+ export function evaluateNativeEligibility(adapter, { clientVersion, platform, probe }) {
143
+ const rule = evaluateStatic(adapter, { clientVersion, platform });
144
+ const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
145
+ protocolContract: rule.protocolContract, modes: [] };
146
+ const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
147
+ if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
148
+ if (probe === null || probe === undefined) return closedResult("feature_probe_failed");
149
+ const facts = validateNativeProbe(probe);
150
+ if (facts.supported !== true) return closedResult(facts.reasonCode ?? "feature_probe_failed");
151
+ if (facts.clientVersion !== clientVersion) return closedResult("probe_version_mismatch");
152
+ if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
153
+ const modes = orderedModes(facts.modes);
154
+ if (!modes.includes("livePush")) return closedResult("feature_probe_failed");
155
+ return deepFreeze({ eligible: true, reasonCode: null, minimumVersion: rule.minimumVersion,
156
+ protocolContract: rule.protocolContract, modes });
157
+ }
158
+
159
+ function validateNativeHandshakeShape(handshake) {
160
+ closed(handshake, HANDSHAKE_KEYS, "native handshake");
161
+ if (typeof handshake.supported !== "boolean") usage("native handshake supported must be a boolean");
162
+ if (handshake.clientVersion !== null && !isText(handshake.clientVersion)) {
163
+ usage("native handshake clientVersion must be a string or null");
164
+ }
165
+ if (handshake.protocolContract !== null && (!isText(handshake.protocolContract)
166
+ || !CONTRACT_ID.test(handshake.protocolContract))) {
167
+ usage("native handshake protocolContract must be a closed identifier or null");
168
+ }
169
+ assertModes(handshake.modes, "native handshake");
170
+ assertReasonCode(handshake.reasonCode, "native handshake");
171
+ if (handshake.supported) {
172
+ if (!isText(handshake.opaqueEndpointRef)) {
173
+ usage("native handshake opaqueEndpointRef must be a non-empty opaque string");
174
+ }
175
+ if (!isText(handshake.leaseUntil) || !TIMESTAMP.test(handshake.leaseUntil)
176
+ || Number.isNaN(Date.parse(handshake.leaseUntil))) {
177
+ usage("native handshake leaseUntil must be a UTC timestamp");
178
+ }
179
+ } else if (handshake.opaqueEndpointRef !== null || handshake.leaseUntil !== null) {
180
+ usage("an unsupported native handshake carries no endpoint or lease");
181
+ }
182
+ return handshake;
183
+ }
184
+
185
+ // The per-session half: the same static rule again, then the adapter's live
186
+ // handshake facts. The launch-time executable fingerprint stays probe-only.
187
+ export function validateNativeHandshake(adapter, { clientVersion, platform, handshake }) {
188
+ const rule = evaluateStatic(adapter, { clientVersion, platform });
189
+ const base = { ok: false, reasonCode: null, protocolContract: rule.protocolContract, modes: [],
190
+ opaqueEndpointRef: null, leaseUntil: null };
191
+ const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
192
+ if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
193
+ if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
194
+ const facts = validateNativeHandshakeShape(handshake);
195
+ if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
196
+ if (facts.clientVersion !== clientVersion) return closedResult("handshake_version_mismatch");
197
+ if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
198
+ const modes = orderedModes(facts.modes);
199
+ if (!modes.includes("livePush")) return closedResult("handshake_failed");
200
+ return deepFreeze({ ok: true, reasonCode: null, protocolContract: rule.protocolContract, modes,
201
+ opaqueEndpointRef: facts.opaqueEndpointRef, leaseUntil: facts.leaseUntil });
202
+ }
@@ -0,0 +1,101 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ // The closed vocabulary behind native delivery - binding modes, activation
4
+ // kinds, captured platforms, reason codes - and the small validation
5
+ // primitives every native module shares. Kept apart so the contract, the
6
+ // activation plan, and later the hook helper all speak one dialect.
7
+
8
+ export const NATIVE_BINDING_MODES = Object.freeze([
9
+ "livePush", "idleWake", "busyQueue", "replyRoute",
10
+ ]);
11
+
12
+ export const NATIVE_ACTIVATION_KINDS = Object.freeze([
13
+ "shell-bootstrap", "native-config", "native-service",
14
+ ]);
15
+
16
+ export const NATIVE_PLATFORMS = Object.freeze([
17
+ "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64",
18
+ ]);
19
+
20
+ // Every reason a client is not live, closed so doctor, hooks, and docs share
21
+ // one vocabulary and vendor strings never leak through it.
22
+ export const NATIVE_REASON_CODES = Object.freeze([
23
+ "native_delivery_unsupported", "platform_not_captured", "version_unavailable",
24
+ "prerelease_not_captured", "below_minimum_version", "known_bad_version",
25
+ "feature_probe_failed", "probe_timeout", "probe_version_mismatch", "protocol_mismatch",
26
+ "handshake_failed", "handshake_timeout", "handshake_version_mismatch",
27
+ "session_generation_stale", "client_process_unknown", "unsupported_shell",
28
+ // The transport works, but ACC cannot tell which workspace the session is in,
29
+ // so it has no honest way to address it. Measured on Codex 0.152.1: native
30
+ // delivery requires `--remote unix://`, and in that mode the hook payload and
31
+ // the App Server's own thread record both report the daemon's directory
32
+ // rather than the session's. Joining a session to whatever project the daemon
33
+ // happened to start in is worse than not joining it at all.
34
+ "workspace_identity_unavailable",
35
+ ]);
36
+
37
+ export const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
38
+ export const CONTRACT_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
39
+ export const IDENTIFIER = /^[a-z][a-z0-9_-]*$/;
40
+ export const COMMAND_NAME = /^[a-z][a-z0-9_.-]*$/;
41
+ export const FINGERPRINT = /^sha256:[0-9a-f]{64}$/;
42
+ export const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
43
+ export const SHELL_SOURCE = /[;&|<>`\n\0]|\$\(/;
44
+ export const KNOWN_BAD_REASON = "known_bad_version";
45
+ export const PROBE_KEYS = ["supported", "clientVersion", "protocolContract", "executableFingerprint",
46
+ "modes", "reasonCode"];
47
+ export const HANDSHAKE_KEYS = ["supported", "clientVersion", "protocolContract", "modes",
48
+ "opaqueEndpointRef", "leaseUntil", "reasonCode"];
49
+
50
+ export function usage(message, details = {}) {
51
+ throw new AccError(EXIT.USAGE, message, details);
52
+ }
53
+
54
+ export const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
55
+ export const isText = value => typeof value === "string" && value !== "";
56
+
57
+ export function closed(value, keys, label) {
58
+ if (!isPlainObject(value)) usage(`${label} must be an object`);
59
+ for (const key of Object.keys(value)) {
60
+ if (!keys.includes(key)) usage(`unknown ${label} field ${key}`, { key });
61
+ }
62
+ for (const key of keys) {
63
+ if (!Object.hasOwn(value, key)) usage(`${label} requires ${key}`, { key });
64
+ }
65
+ }
66
+
67
+ export function deepFreeze(value) {
68
+ if (value === null || typeof value !== "object") return value;
69
+ for (const item of Object.values(value)) deepFreeze(item);
70
+ return Object.freeze(value);
71
+ }
72
+
73
+ export function parseStableVersion(text) {
74
+ const match = typeof text === "string" ? STABLE_VERSION.exec(text) : null;
75
+ return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])];
76
+ }
77
+
78
+ export function compareStableVersions(left, right) {
79
+ const a = parseStableVersion(left);
80
+ const b = parseStableVersion(right);
81
+ if (a === null || b === null) {
82
+ usage("native version comparison needs two stable semantic versions", { left, right });
83
+ }
84
+ for (let index = 0; index < 3; index += 1) {
85
+ if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ export function assertModes(modes, label) {
91
+ if (!Array.isArray(modes) || new Set(modes).size !== modes.length
92
+ || modes.some(mode => !NATIVE_BINDING_MODES.includes(mode))) {
93
+ usage(`${label} modes must be unique entries of ${NATIVE_BINDING_MODES.join(", ")}`);
94
+ }
95
+ }
96
+
97
+ export function assertReasonCode(value, label) {
98
+ if (value !== null && !NATIVE_REASON_CODES.includes(value)) {
99
+ usage(`${label} reasonCode must be null or a closed native reason code`, { reasonCode: value });
100
+ }
101
+ }
@@ -21,13 +21,26 @@ const fileFor = (runtimeDir, harnessSessionId) => path.join(runtimeDir, "binding
21
21
  * plus the exact client facts observed at attach time - no prompt, transcript,
22
22
  * or harness state.
23
23
  */
24
+ const isPid = value => Number.isInteger(value) && value > 0;
25
+
24
26
  export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
25
- generation, clientVersion, platform }) {
27
+ generation, clientVersion, platform, clientPid }) {
26
28
  const file = fileFor(runtimeDir, harnessSessionId);
27
29
  await mkdir(path.dirname(file), { recursive: true });
28
30
  const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
29
31
  if (typeof clientVersion === "string" && clientVersion !== "") record.clientVersion = clientVersion;
30
32
  if (typeof platform === "string" && platform !== "") record.platform = platform;
33
+ // The vendor process this session runs in, resolved once at SessionStart.
34
+ // A native endpoint is matched against it, so a binding without one can
35
+ // still heartbeat and deliver at the next turn but cannot go live until a
36
+ // fresh start resolves the process again.
37
+ if (clientPid !== undefined && clientPid !== null) {
38
+ if (!isPid(clientPid)) {
39
+ throw new AccError(EXIT.USAGE, "session binding clientPid must be a positive integer",
40
+ { clientPid });
41
+ }
42
+ record.clientPid = clientPid;
43
+ }
31
44
  const temporary = `${file}.${process.pid}.tmp`;
32
45
  await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
33
46
  // Replace rather than append: re-attaching supersedes the old generation, and
@@ -60,6 +73,7 @@ export async function loadSessionBinding({ runtimeDir, harnessSessionId }) {
60
73
  const binding = { accSessionId: record.accSessionId, generation: record.generation };
61
74
  if (typeof record.clientVersion === "string") binding.clientVersion = record.clientVersion;
62
75
  if (typeof record.platform === "string") binding.platform = record.platform;
76
+ if (isPid(record.clientPid)) binding.clientPid = record.clientPid;
63
77
  return binding;
64
78
  }
65
79
 
@@ -98,7 +112,8 @@ export async function listSessionBindings({ runtimeDir }) {
98
112
  accSessionId: record.accSessionId, generation: record.generation,
99
113
  ...(typeof record.clientVersion === "string"
100
114
  ? { clientVersion: record.clientVersion } : {}),
101
- ...(typeof record.platform === "string" ? { platform: record.platform } : {}) });
115
+ ...(typeof record.platform === "string" ? { platform: record.platform } : {}),
116
+ ...(isPid(record.clientPid) ? { clientPid: record.clientPid } : {}) });
102
117
  }
103
118
  return bindings;
104
119
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -49,12 +49,12 @@ export const COMMANDS = Object.freeze({
49
49
  // `--downgrade` because an older acc first on PATH will otherwise rewire every
50
50
  // client to itself, and the only symptom is a guard behaving like the version
51
51
  // it came from.
52
- install: { required: [], optional: ["adapter", "home", "delivery"],
52
+ install: { required: [], optional: ["home", "delivery"], repeated: ["adapter"],
53
53
  flags: ["dry-run", "downgrade"] },
54
54
  // `--dry-run` on both, because the preview was computed for either action and
55
55
  // only `install` could ask for it. Removal is the side that reaches into a
56
56
  // client's configuration - including a client that has left the machine.
57
- uninstall: { required: [], optional: ["adapter", "home"], flags: ["dry-run"] },
57
+ uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
58
58
  // Asking npm whether there is a newer ACC. The one command that touches the
59
59
  // network, and never on the hook path.
60
60
  update: { required: [], optional: [], flags: ["apply"] },
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { detectInstallation, loadOwnership, verifyOwned }
5
+ import { detectInstallation, livePolicyOf, loadOwnership, shellOf, verifyOwned }
6
6
  from "@agents-can-communicate/installer";
7
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
 
@@ -65,6 +65,21 @@ export function staleInstall({ recorded, running }) {
65
65
  return recorded === running ? null : { recorded, running };
66
66
  }
67
67
 
68
+ const RUNTIME_LABEL = Object.freeze({ active: "active", waiting: "waiting for a live session",
69
+ inactive: "not enabled", degraded: "degraded", unsupported: "unsupported" });
70
+
71
+ /** One human clause for a native-delivery state, next-action implied, never overclaiming. */
72
+ export function describeNative(native) {
73
+ if (native.eligibility === "unsupported") {
74
+ return `unsupported${native.reasonCode ? ` (${native.reasonCode})` : ""}`;
75
+ }
76
+ const enabled = native.configured ? `enabled (${native.policy})` : "eligible, not enabled";
77
+ const runtime = RUNTIME_LABEL[native.runtime] ?? native.runtime;
78
+ const degraded = native.eligibility === "degraded" && native.reasonCode
79
+ ? ` - ${native.reasonCode}` : "";
80
+ return `${native.eligibility} - ${enabled} - ${runtime}${degraded}`;
81
+ }
82
+
68
83
  /**
69
84
  * The runner version behind whatever ACC wrote for one client.
70
85
  *
@@ -101,15 +116,39 @@ async function findShims(root, depth) {
101
116
  return found;
102
117
  }
103
118
 
104
- async function diagnoseAdapters({ options, runtime }) {
119
+ // One closed native-delivery report per adapter, built only from detection,
120
+ // ownership, and later the live binding facts - never inferred from a
121
+ // configured shim alone. eligibility is what the client could do; configured is
122
+ // whether a policy was recorded; policy is that recorded policy; runtime is
123
+ // filled in from current bindings; modes and reasonCode carry the closed
124
+ // detail. runtime "active" never means the model read anything.
125
+ function nativeState(detected, recordedPolicy) {
126
+ const native = detected ?? { state: "unsupported", reasonCode: "native_delivery_unsupported" };
127
+ const eligibility = native.state === "eligible" ? "eligible"
128
+ : native.state === "degraded" ? "degraded" : "unsupported";
129
+ const policy = recordedPolicy ?? "off";
130
+ const configured = policy !== "off";
131
+ const modes = native.state === "eligible" && Array.isArray(native.probe?.modes)
132
+ ? [...native.probe.modes] : [];
133
+ const runtime = eligibility === "unsupported" ? "unsupported"
134
+ : !configured ? "inactive" : "waiting";
135
+ return { eligibility, configured, policy, runtime, modes, reasonCode: native.reasonCode ?? null };
136
+ }
137
+
138
+ export async function diagnoseAdapters({ options, runtime, detect = detectInstallation }) {
105
139
  // The same home `acc install --home` writes to, or the real one. Reading a
106
140
  // different home than install wrote to reports every adapter as missing.
107
141
  const home = options?.home ?? runtime?.env?.HOME ?? homedir();
108
142
  const { data: dataHome } = platformPaths({ platform: runtime?.platform,
109
143
  env: runtime?.env ?? {} });
110
- const clients = clientContext(home, path.join(dataHome, "acc"));
144
+ // The same shell and environment install reads, so detection plans the same
145
+ // shell bootstrap it would apply. Omitting them left detection with a null
146
+ // shell, which degrades every shell-bootstrap client to `unsupported_shell` -
147
+ // so a zsh machine with a working shim read as degraded on every run.
148
+ const clients = clientContext(home, path.join(dataHome, "acc"),
149
+ { shell: shellOf(runtime?.env ?? {}), env: runtime?.env ?? {} });
111
150
  const adapters = ALL_ADAPTERS();
112
- const detected = await detectInstallation({ adapters, context: clients,
151
+ const detected = await detect({ adapters, context: clients,
113
152
  probeTimeoutMs: probeTimeout(runtime?.env) });
114
153
  const record = await loadOwnership({ dataHome });
115
154
  const running = typeof runtime?.version === "function"
@@ -162,7 +201,8 @@ async function diagnoseAdapters({ options, runtime }) {
162
201
  // an npm upgrade with no `acc install`, and reporting both is what makes the
163
202
  // divergence legible rather than hidden behind a single reassuring number.
164
203
  return { ...entry, stale, wired, bundleVersion, owned: { modified: owned.modified,
165
- missing: owned.missing, intact: owned.intact.length }, remediation };
204
+ missing: owned.missing, intact: owned.intact.length },
205
+ nativeDelivery: nativeState(entry.nativeDelivery, livePolicyOf(installed)), remediation };
166
206
  }));
167
207
  }
168
208
 
@@ -214,6 +254,23 @@ export async function runDoctor({ options, context, runtime }) {
214
254
  // front.
215
255
  const service = context.service ?? await context.openService();
216
256
  const status = await service.collectStatus({});
257
+ // The runtime column is the only part that needs a live read: a current
258
+ // reachable binding for this adapter is "active", an expired one "degraded".
259
+ const bindingsByAdapter = new Map();
260
+ for (const binding of status.deliveryBindings ?? []) {
261
+ const existing = bindingsByAdapter.get(binding.adapterId);
262
+ if (existing === undefined || binding.reachable) bindingsByAdapter.set(binding.adapterId, binding);
263
+ }
264
+ for (const adapter of adapters) {
265
+ const native = adapter.nativeDelivery;
266
+ if (native.eligibility === "unsupported") continue;
267
+ const binding = bindingsByAdapter.get(adapter.adapterId);
268
+ native.runtime = binding === undefined ? (native.configured ? "waiting" : "inactive")
269
+ : binding.reachable ? "active" : "degraded";
270
+ if (binding !== undefined && Array.isArray(binding.availableModes)) {
271
+ native.modes = binding.availableModes.filter(mode => mode !== "nextTurn");
272
+ }
273
+ }
217
274
 
218
275
  const data = {
219
276
  workspaceId: context.descriptor.id,
@@ -241,6 +298,11 @@ export async function runDoctor({ options, context, runtime }) {
241
298
  ...adapters.filter(adapter => (adapter.present || adapter.installed)
242
299
  && typeof adapter.deliveryDiagnostic === "string")
243
300
  .map(adapter => ` ${adapter.deliveryDiagnostic}`),
301
+ // One concise native-delivery line per detected client, distinguishing
302
+ // eligibility, the recorded policy, and the live runtime state. It never
303
+ // claims that "active" means a model read anything.
304
+ ...adapters.filter(adapter => adapter.present)
305
+ .map(adapter => ` ${adapter.displayName} native delivery: ${describeNative(adapter.nativeDelivery)}`),
244
306
  ...data.remediation.map(line => ` ${line}`),
245
307
  // `0 of 4` is a true line that reads as a broken machine, and on an
246
308
  // MCP-only one it would read that way on every run forever. The server needs