agents-can-communicate 0.7.0 → 0.8.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 (97) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/acc-bootstrap.mjs +6 -48
  3. package/bin/entrypoints/acc-claude-channel.mjs +47 -157
  4. package/docs/ADAPTER_AUTHORING.md +41 -10
  5. package/docs/ARCHITECTURE.md +28 -13
  6. package/docs/CAPABILITIES.md +19 -16
  7. package/docs/CLI.md +60 -14
  8. package/docs/CONCEPTS.md +12 -4
  9. package/docs/CONFIGURATION.md +18 -11
  10. package/docs/GETTING_STARTED.md +15 -12
  11. package/docs/GLOSSARY.md +2 -1
  12. package/docs/HOW_IT_WORKS.md +8 -4
  13. package/docs/PROTOCOL.md +56 -6
  14. package/docs/RELEASING.md +3 -2
  15. package/docs/SECURITY_MODEL.md +14 -1
  16. package/docs/TROUBLESHOOTING.md +64 -27
  17. package/docs/UPGRADING.md +49 -8
  18. package/docs/WHY_ACC.md +5 -3
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
  37. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
  44. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
  49. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
  50. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
  52. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
  54. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
  56. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
  57. package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
  58. package/node_modules/@agents-can-communicate/cli/src/main.mjs +39 -10
  59. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
  60. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  61. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
  62. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
  63. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
  65. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
  66. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +38 -1
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +11 -2
  70. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
  74. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
  75. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +21 -12
  76. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  77. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
  78. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
  79. package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
  80. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
  81. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
  82. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
  83. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
  84. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  85. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
  86. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  87. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
  89. package/package.json +1 -1
  90. package/bin/acc-bootstrap.mjs +0 -7
  91. package/bin/acc-claude-channel.mjs +0 -10
  92. package/bin/entrypoints/claude-channel-binding.mjs +0 -46
  93. package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
  94. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
  95. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
  96. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
  97. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
@@ -1,6 +1,7 @@
1
1
  import { classifySessionPresence } from "./sessions.mjs";
2
2
  import { computeAttention } from "./attention.mjs";
3
3
  import { isCurrentIntent } from "./intents.mjs";
4
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
4
5
 
5
6
  /**
6
7
  * Protection level, reported from what is actually enforceable.
@@ -87,6 +88,36 @@ export function createStatusService(ports, sessions, deliveryBindings) {
87
88
  const claims = snapshot.claims
88
89
  .filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
89
90
  const currentBindings = await deliveryBindings.currentBindings(now);
91
+ // What each recipient has not fetched yet, and how far it got. Offered is
92
+ // not read: a transport took the bytes. Queued sits beside it so "0
93
+ // offered" cannot be mistaken for "nothing waits". Replaced decisions
94
+ // leave these counts exactly as they leave the inbox.
95
+ const current = new Set(snapshot.messages.map(decisionView(snapshot.messages))
96
+ .filter(isCurrentDecision).map(message => message.messageId));
97
+ const unretrieved = new Map();
98
+ const tally = key => unretrieved.get(key) ?? { queued: 0, offered: 0 };
99
+ for (const receipt of snapshot.receipts) {
100
+ if (!current.has(receipt.messageId)
101
+ || !["queued", "offered"].includes(receipt.state)) continue;
102
+ for (const key of [receipt.recipientParticipantId, null]) {
103
+ const counts = tally(key);
104
+ unretrieved.set(key, { ...counts, [receipt.state]: counts[receipt.state] + 1 });
105
+ }
106
+ }
107
+
108
+ // A participant with no live session but messages waiting for it stays in
109
+ // the roster, by its latest session, so a sender can see where they went.
110
+ const liveParticipants = new Set(live.map(item => item.session.participantId));
111
+ const latest = new Map();
112
+ for (const { session } of classified) {
113
+ const seen = latest.get(session.participantId);
114
+ if (seen === undefined || Date.parse(session.heartbeatAt) > Date.parse(seen.heartbeatAt)) {
115
+ latest.set(session.participantId, session);
116
+ }
117
+ }
118
+ const awaited = new Set([...latest.values()].filter(session => !liveParticipants.has(session.participantId)
119
+ && (tally(session.participantId).queued + tally(session.participantId).offered) > 0)
120
+ .map(session => session.sessionId));
90
121
 
91
122
  return {
92
123
  workspaceId,
@@ -98,7 +129,8 @@ export function createStatusService(ports, sessions, deliveryBindings) {
98
129
  // after a month of work this listed sixty entries for one live session.
99
130
  // `acc status --all` is how the worktree-cleanup question is asked.
100
131
  participants: classified
101
- .filter(item => input.all === true || item.presence !== "offline")
132
+ .filter(item => input.all === true || item.presence !== "offline"
133
+ || awaited.has(item.session.sessionId))
102
134
  .map(({ session, presence }) => ({
103
135
  sessionId: session.sessionId,
104
136
  participantId: session.participantId,
@@ -114,6 +146,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
114
146
  // a done record made the command and this line contradict each other.
115
147
  intent: intents.find(intent => intent.sessionId === session.sessionId
116
148
  && isCurrentIntent(intent))?.summary ?? null,
149
+ unretrieved: tally(session.participantId),
150
+ // The client's reason when it ended the session, such as Claude Code's
151
+ // "clear". Kept in the record's extensions, so it is null for 0.7.x.
152
+ ...(presence === "offline" ? { endReason: session.extensions?.endReason ?? null } : {}),
117
153
  })),
118
154
  // The owner is named twice on purpose. Every command that reaches a peer
119
155
  // takes a participant id, so a claim that gave only a session id sent the
@@ -148,6 +184,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
148
184
  stale: live.filter(item => item.presence === "stale").length,
149
185
  claims: claims.length,
150
186
  messages: snapshot.messages.length,
187
+ unretrieved: tally(null),
151
188
  },
152
189
  };
153
190
  }
@@ -4,6 +4,7 @@ import { computeAttention } from "./attention.mjs";
4
4
  import { classifySessionPresence } from "./sessions.mjs";
5
5
  import { assertMessageId, messagePage } from "./message-pages.mjs";
6
6
  import { decisionView, isCurrentDecision } from "./decision-state.mjs";
7
+ import { offerFacts } from "./receipts.mjs";
7
8
 
8
9
  const DEFAULT_LIMIT = 100;
9
10
  const CURSOR = /^[0-9]{16}$/;
@@ -38,7 +39,7 @@ export function createSyncService(ports, sessions) {
38
39
  }
39
40
  if (exact) assertMessageId(input.messageId);
40
41
  const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
41
- { kinds: ["message"] });
42
+ { kinds: exact ? ["message", "receipt"] : ["message"] });
42
43
  const messages = snapshot.messages.map(decisionView(snapshot.messages));
43
44
  if (!exact) return messagePage(messages, input,
44
45
  { include: message => input.current !== true || isCurrentDecision(message),
@@ -47,7 +48,15 @@ export function createSyncService(ports, sessions) {
47
48
  if (message === undefined) {
48
49
  throw new AccError(EXIT.DATA, "message is not in this workspace's history");
49
50
  }
50
- return { scope: "history", view: "message", items: [message], nextCursor: null };
51
+ // Beside the message rather than inside it: the router offers `items[0]`
52
+ // to a transport, and a sender asking "did they get it" wants every
53
+ // recipient's answer. `sync --scope full` already shows all of it.
54
+ const receipts = snapshot.receipts
55
+ .filter(receipt => receipt.messageId === message.messageId)
56
+ .sort((left, right) => left.recipientParticipantId.localeCompare(right.recipientParticipantId))
57
+ .map(receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
58
+ state: receipt.state, updatedAt: receipt.updatedAt, offer: offerFacts(receipt) }));
59
+ return { scope: "history", view: "message", items: [message], receipts, nextCursor: null };
51
60
  }
52
61
 
53
62
  async function sync(input = {}) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,7 @@ import { refreshExpiredBinding } from "./refresh-binding.mjs";
5
5
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
6
6
  "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
7
7
  "unsupported_client_version"]);
8
- const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
8
+ const NAMED_LIVE_TRANSPORTS = new Set(["claude-inbox", "codex-app-server"]);
9
9
 
10
10
  const adaptersById = adapters => adapters instanceof Map
11
11
  ? adapters
@@ -72,10 +72,9 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
72
72
  readLivePolicy }) {
73
73
  const registry = adaptersById(adapters);
74
74
 
75
+ // Read at offer time, not from the binding: consent withdrawn after a session
76
+ // bound must stop the next offer, not the next rebind.
75
77
  async function policyFor(adapter, binding) {
76
- if (adapter?.nativeDelivery?.policySource !== "installation-record") {
77
- return LIVE_POLICIES.has(binding.livePolicy) ? binding.livePolicy : "off";
78
- }
79
78
  if (typeof readLivePolicy !== "function") return "off";
80
79
  try {
81
80
  const policy = await readLivePolicy({ adapter, binding });
@@ -101,7 +100,14 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
101
100
  // endpoint lease never sends a hook heartbeat: a daemon is reachability,
102
101
  // not evidence that this particular session thread is still present.
103
102
  const liveSessions = await service.listLiveSessions({ participantId, now });
104
- if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
103
+ if (liveSessions.length === 0) {
104
+ // No open session: the message waits until one starts or resumes. The
105
+ // client's own reason, when it gave one (Claude Code's /clear), is passed on.
106
+ const last = typeof service.lastSessionOf === "function"
107
+ ? await service.lastSessionOf({ participantId }).catch(() => null) : null;
108
+ return { ...durable(participantId, "recipient_offline"),
109
+ ...(last?.state === "closed" && typeof last.endReason === "string" ? { endReason: last.endReason } : {}) };
110
+ }
105
111
  if (liveSessions.length > 1) {
106
112
  return durable(participantId, "ambiguous_recipient_sessions");
107
113
  }
@@ -110,7 +116,9 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
110
116
  participantId, now, includeExpired: true }))
111
117
  .filter(binding => binding.sessionId === target.sessionId
112
118
  && binding.generation === target.generation);
113
- if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
119
+ // Online, with nothing to push through: a CLI participant, or a client
120
+ // that bound no live transport. It reads its inbox, so it is not gone.
121
+ if (bindings.length === 0) return durable(participantId, "no_live_transport");
114
122
  const evaluated = await Promise.all(bindings.map(async binding => {
115
123
  const adapter = registry.get(binding.adapterId);
116
124
  return { binding, adapter, policy: await policyFor(adapter, binding) };
@@ -118,7 +126,7 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
118
126
  const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
119
127
  if (permitted.length === 0) return durable(participantId, "delivery_disabled");
120
128
  const reachable = permitted.filter(({ binding }) => binding.availableModes.includes("livePush"));
121
- if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
129
+ if (reachable.length === 0) return durable(participantId, "no_live_transport");
122
130
  const capable = reachable.filter(({ binding, adapter }) => liveCapable(adapter, binding));
123
131
  if (capable.length === 0) {
124
132
  return durable(participantId, "unsupported_client_version");
@@ -214,6 +222,15 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
214
222
  "unsupported_client_version");
215
223
  return durable(participantId, "unsupported_client_version");
216
224
  }
225
+ // A wake put a notice in front of the client, not the body. Recording it
226
+ // as offered would make the next-turn projection treat the body as already
227
+ // shown and leave it out of the very turn the wake started. The receipt
228
+ // stays queued; the hook records the offer once its stdout carried the body.
229
+ if (adapter.nativeDelivery?.offerKind === "wake") {
230
+ // The client took the wake but holds it for its user's approval.
231
+ return { recipientParticipantId: participantId, outcome: "woken", transport,
232
+ ...(response.pendingApproval === true ? { pendingApproval: true } : {}) };
233
+ }
217
234
  try {
218
235
  await service.recordOfferSucceeded({ messageId: message.messageId,
219
236
  recipientParticipantId: participantId, targetSessionId: binding.sessionId,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  import { storeNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
2
  import { readInstalledLivePolicyState } from "@agents-can-communicate/installer";
3
3
 
4
- import { establishNativeBinding, LIVE_POLICIES, livePolicyFrom } from "./native-binding.mjs";
4
+ import { establishNativeBinding } from "./native-binding.mjs";
5
5
 
6
6
  // A cold worker plus the write has to finish inside this window. 250 ms was
7
7
  // not enough on a busy CI runner, so the attempt file never appeared and
@@ -27,13 +27,11 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
27
27
  if (Date.now() >= deadline) throw new Error("hook deadline expired");
28
28
  };
29
29
  assertBudget();
30
- const policySource = adapter?.nativeDelivery?.policySource === "installation-record"
31
- ? "installation-record" : "bootstrap-environment";
32
- const raw = context.env?.ACC_NATIVE_DELIVERY_POLICY;
33
- const { policy, policyStatus } = policySource === "installation-record"
34
- ? await readInstalledLivePolicyState({ dataHome: context.dataHome, adapterId: adapter.id })
35
- : { policy: livePolicyFrom(context.env), policyStatus: raw === undefined ? "missing"
36
- : !LIVE_POLICIES.includes(raw) ? "invalid" : raw === "off" ? "off" : "enabled" };
30
+ // The install record is the only consent a hook reads; see
31
+ // validateNativeDeliveryContract for the environment source this replaced.
32
+ const policySource = "installation-record";
33
+ const { policy, policyStatus } = await readInstalledLivePolicyState({ dataHome: context.dataHome,
34
+ adapterId: adapter.id });
37
35
  assertBudget();
38
36
  const result = await establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
39
37
  livePolicy: policy, service: context.service, runtimeDir: paths.root,
@@ -20,15 +20,21 @@ const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_cap
20
20
  "version_unavailable", "prerelease_not_captured", "below_minimum_version",
21
21
  "known_bad_version"]);
22
22
 
23
- // Only the value a successful owned shell bootstrap exported counts; anything
24
- // else - missing, blank, differently cased, a number - is off.
25
- export function livePolicyFrom(env) {
26
- const value = env?.ACC_NATIVE_DELIVERY_POLICY;
27
- return LIVE_POLICIES.includes(value) ? value : "off";
28
- }
29
-
30
23
  const isPid = value => Number.isInteger(value) && value > 0;
31
24
 
25
+ // An adapter may write its private endpoint before the static rule below judges
26
+ // the handshake, or after this hook stopped waiting. Nothing publishes such an
27
+ // endpoint, so nothing would ever retire it, and a refused client binds again
28
+ // on every turn. Fail-open and never awaited past the adapter's own answer.
29
+ function discardEndpoint(adapter, handshake, runtimeDir) {
30
+ const opaqueEndpointRef = handshake?.opaqueEndpointRef;
31
+ if (typeof opaqueEndpointRef !== "string" || opaqueEndpointRef === ""
32
+ || typeof adapter?.retireNativeSession !== "function") return Promise.resolve();
33
+ return Promise.resolve().then(() => adapter.retireNativeSession({
34
+ binding: { opaqueEndpointRef, clientVersion: handshake.clientVersion ?? null }, runtimeDir }))
35
+ .catch(() => {});
36
+ }
37
+
32
38
  export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
33
39
  platform, livePolicy, service, runtimeDir, clock, env,
34
40
  timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
@@ -57,16 +63,23 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
57
63
  let timer = null;
58
64
  try {
59
65
  const budget = Math.max(1, Math.floor(timeoutMs));
66
+ const pending = Promise.resolve().then(() => adapter.bindNativeSession({ event, clientPid,
67
+ clientVersion, runtimeDir, timeoutMs: budget, env }));
68
+ // An answer that lands after the budget has nobody left to use it.
69
+ let timedOut = false;
70
+ pending.then(late => { if (timedOut) discardEndpoint(adapter, late, runtimeDir); }, () => {});
60
71
  const handshake = await Promise.race([
61
- adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
62
- timeoutMs: budget, env }),
72
+ pending,
63
73
  new Promise((_resolve, reject) => {
64
- timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
65
- { code: "ETIMEDOUT" })), budget);
74
+ timer = setTimeout(() => {
75
+ timedOut = true;
76
+ reject(Object.assign(new Error("native handshake timed out"), { code: "ETIMEDOUT" }));
77
+ }, budget);
66
78
  }),
67
79
  ]);
68
80
  const verdict = validateNativeHandshake(adapter, { clientVersion, platform, handshake });
69
81
  if (!verdict.ok) {
82
+ await discardEndpoint(adapter, handshake, runtimeDir);
70
83
  return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
71
84
  verdict.reasonCode);
72
85
  }
@@ -237,7 +237,7 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
237
237
  // `render` is how the projected text reaches this client: before a turn it is
238
238
  // the adapter's context envelope, at the end of one it is a continuation.
239
239
  async function projectTurn({ binding, context, adapter, adapterId,
240
- render = text => adapter.injectOutcome?.(text), activationHint = null }) {
240
+ render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
241
241
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
242
242
  cursor: null, scope: "delta" });
243
243
 
@@ -251,11 +251,22 @@ async function projectTurn({ binding, context, adapter, adapterId,
251
251
  // Without this the projector's peer block never runs in production: an
252
252
  // agent sees only the obligation attention line, not the durable body that
253
253
  // may be offered after the stdout transport succeeds.
254
+ // Decided before selection: a repeat is taken out of the reminder lists, so
255
+ // asking for one this turn cannot show is how a message would go quiet.
256
+ // The ceiling a team agreed on in `acc.workspace.json`, or the default when
257
+ // there is no config. Validated by the protocol and, until now, never read:
258
+ // the projector was always called with its own default.
259
+ const effective = effectiveCapabilities(adapter, binding);
260
+ const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
261
+ const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
254
262
  const delivery = await context.service.nextTurnDelivery({
255
263
  workspaceId: context.descriptor.id,
256
264
  participantId: mine?.participantId,
257
- exceptSessionId: binding.accSessionId });
258
- const messages = delivery.queuedMessages;
265
+ exceptSessionId: binding.accSessionId,
266
+ repeats: repeats && canOfferNextTurn });
267
+ // New bodies first, so a repeat is what the byte budget drops.
268
+ const messages = [...delivery.queuedMessages, ...delivery.repeatMessages];
269
+ const repeating = new Set(delivery.repeatMessages.map(message => message.messageId));
259
270
 
260
271
  // Only this hook's payload selected the binding. Supply its own pair as
261
272
  // trusted context, outside peer bodies, rather than exporting inheritable
@@ -278,16 +289,11 @@ async function projectTurn({ binding, context, adapter, adapterId,
278
289
  hintFits);
279
290
  }
280
291
 
281
- // The ceiling a team agreed on in `acc.workspace.json`, or the default when
282
- // there is no config. Validated by the protocol and, until now, never read:
283
- // the projector was always called with its own default.
284
- const effective = effectiveCapabilities(adapter, binding);
285
- const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
286
- const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
287
292
  const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
288
293
  liveOfferedMessageIds: delivery.liveOfferedMessageIds,
289
294
  reminderMessageIds: delivery.reminderMessageIds,
290
295
  roomMessageIds: delivery.roomMessageIds,
296
+ repeatOffers: delivery.repeatOffers,
291
297
  currentParticipantId: mine?.participantId };
292
298
  // Credentials alone cannot replace the command that reaches a queued body.
293
299
  // If both cannot fit, preserve recovery and explain the missing owner pair.
@@ -346,7 +352,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
346
352
  recipientParticipantId: mine.participantId,
347
353
  targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
348
354
  transport: "next-turn", adapterId,
349
- clientVersion: binding.clientVersion }));
355
+ clientVersion: binding.clientVersion,
356
+ ...(repeating.has(message.messageId) ? { repeat: true } : {}) }));
350
357
  // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
351
358
  // and Claude Code want an envelope and drop a bare string.
352
359
  // The entry point owns the transport boundary. This handler only prepares
@@ -499,7 +506,7 @@ const HANDLERS = {
499
506
  const owner = { sessionId: binding.accSessionId, generation: binding.generation };
500
507
  const current = await context.service.locateSession(binding.accSessionId);
501
508
  if (current?.record.generation === binding.generation && current.record.state === "open") {
502
- await context.service.closeSession(owner);
509
+ await context.service.closeSession({ ...owner, endReason: event.endReason ?? null });
503
510
  } else {
504
511
  // Close may have committed before this hook died. Retire only the old
505
512
  // endpoint and binding; do not close a replacement or repeat a close event.
@@ -583,7 +590,9 @@ const HANDLERS = {
583
590
  const current = await context.service.locateSession(binding.accSessionId);
584
591
  if (current === null || current.record.state !== "open"
585
592
  || current.record.generation !== binding.generation) return {};
586
- const turn = await projectTurn({ ...input,
593
+ // A repeat is a body the model may already have. It rides on the next
594
+ // turn that happens anyway and never buys a model call of its own.
595
+ const turn = await projectTurn({ ...input, repeats: false,
587
596
  render: text => adapter.continueTurnOutcome({ reason: text, payload }) });
588
597
  return (turn.offerInputs ?? []).length === 0 ? {} : turn;
589
598
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,6 @@
1
+ import { readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
1
4
  import { applyServiceSetup } from "./service-setup.mjs";
2
5
  import { applyNativeActivation, deactivateNative } from "./native-activation.mjs";
3
6
  import { finalizeRemoval, missingArtifactParents, recordInstall,
@@ -52,12 +55,10 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
52
55
  retainedMechanisms = report.retainedMechanisms;
53
56
  }
54
57
  let native = operation.retainedNativeActivation ?? null;
55
- let appendedRcBlock = false;
56
58
  if (operation.nativeActivation !== undefined) {
57
- const applied = await applyNativeActivation({ adapter,
58
- activation: operation.nativeActivation, dataHome, ...activation });
59
+ const applied = await applyNativeActivation({
60
+ activation: operation.nativeActivation, ...activation });
59
61
  native = applied.nativeActivation;
60
- appendedRcBlock = applied.appendedRcBlock;
61
62
  }
62
63
  if (retainedMechanisms.length > 0) {
63
64
  native = { ...(native ?? operation.deactivation),
@@ -71,7 +72,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
71
72
  artifacts: operation.artifacts, createdDirectories,
72
73
  deliveryPolicy: operation.livePolicy, deliveryDecision: operation.deliveryDecision,
73
74
  nativeActivation: native });
74
- results.operations.push({ ...operation, applied: true, appendedRcBlock,
75
+ results.operations.push({ ...operation, applied: true,
75
76
  needsAction: outcome.needsAction ?? [],
76
77
  changes: outcome.changes ?? [], diagnostics: [
77
78
  ...(operation.deliveryDiagnostic === undefined
@@ -118,6 +119,19 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
118
119
  results.failed.push({ adapterId: operation.adapterId, error: error.message });
119
120
  }
120
121
  }
122
+ // The eligibility cache a 0.7.x `claude` shim read before every launch. No
123
+ // shim reads it any more, and it holds nothing a user wrote.
124
+ if (!dryRun && typeof dataHome === "string") {
125
+ await rm(path.join(dataHome, "acc", "native-bootstrap"), { recursive: true, force: true })
126
+ .catch(() => {});
127
+ // A 0.7.x Claude Channel unlinked its own registration on a clean exit;
128
+ // one that crashed left it in its workspace, naming a dead socket.
129
+ const workspaces = path.join(dataHome, "acc", "workspaces");
130
+ for (const workspace of await readdir(workspaces).catch(() => [])) {
131
+ await rm(path.join(workspaces, workspace, "native", "claude"), { recursive: true, force: true })
132
+ .catch(() => {});
133
+ }
134
+ }
121
135
  return results;
122
136
  }
123
137
 
@@ -5,7 +5,7 @@ import { capabilityEvidence, effectiveCapabilities, evaluateNativeEligibility,
5
5
  validateNativeActivationPlan }
6
6
  from "@agents-can-communicate/adapter-sdk";
7
7
 
8
- import { resolveExecutable, shellOf, shimDirFor } from "./native-activation.mjs";
8
+ import { resolveExecutable, shimDirFor } from "./native-activation.mjs";
9
9
 
10
10
  import { describeDeliveryFallback, describeNativeReason } from "./delivery-diagnostics.mjs";
11
11
 
@@ -83,8 +83,7 @@ async function detectNative(adapter, entry, { context, platform, probeTimeoutMs,
83
83
  if (facts.eligibility.eligible !== true) {
84
84
  const reasonCode = facts.eligibility.reasonCode ?? "feature_probe_failed";
85
85
  // A stored policy can be consented to before a service/session exists.
86
- // No activation is planned until the full probe passes; bootstrap-based
87
- // clients still need their verified plan to disclose launch changes.
86
+ // No activation is planned until the full probe passes.
88
87
  const consentAvailable = adapter.nativeDelivery.policySource === "installation-record"
89
88
  && ["native_endpoint_unavailable", "native_session_unavailable"].includes(reasonCode);
90
89
  return STATIC_REASONS.has(reasonCode)
@@ -100,10 +99,6 @@ async function detectNative(adapter, entry, { context, platform, probeTimeoutMs,
100
99
  return degraded("feature_probe_failed", facts);
101
100
  }
102
101
  if (!activationPlan.eligible) return degraded(activationPlan.reasonCode, facts);
103
- const shell = context?.shell ?? null;
104
- if (activationPlan.mechanisms.some(item => item.kind === "shell-bootstrap") && shell !== "zsh") {
105
- return { ...degraded("unsupported_shell", facts), activationPlan };
106
- }
107
102
  return { state: "eligible", reasonCode: null, ...facts, activationPlan };
108
103
  } catch {
109
104
  return degraded("feature_probe_failed", { realExecutable: null, probe: null, eligibility: null });
@@ -165,6 +160,7 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
165
160
  // on the machine down with it.
166
161
  if (detected.blocked) entry.blocked = detected.blocked;
167
162
  if (detected.outgoingDelivery) entry.outgoingDelivery = detected.outgoingDelivery;
163
+ if (detected.inboundDelivery) entry.inboundDelivery = detected.inboundDelivery;
168
164
  if (detected.nativeSetup) entry.nativeSetup = detected.nativeSetup;
169
165
  entry.diagnostics = [...(detected.diagnostics ?? [])];
170
166
  // What a person has to do, as opposed to what is true. Adapters that
@@ -5,13 +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";
8
+ export { BLOCK_BEGIN, BLOCK_END, SHIM_MARKER, locateBlock, uninstallShellBootstrap }
9
+ from "./shell-bootstrap.mjs";
10
+ export { LIVE_POLICIES, describeActivation, describeDeactivation, livePolicyOf,
11
+ resolveExecutable, shimDirFor } from "./native-activation.mjs";
15
12
  export { readInstalledLivePolicy, readInstalledLivePolicyState } from "./live-policy.mjs";
16
13
  export { describeDeliveryFallback, describeInstallDelivery, describeNativeReason }
17
14
  from "./delivery-diagnostics.mjs";
@@ -2,10 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { access, constants } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { defaultBootstrap } from "@agents-can-communicate/adapter-sdk";
6
-
7
- import { installShellBootstrap, planShellBootstrap, uninstallShellBootstrap }
8
- from "./shell-bootstrap.mjs";
5
+ import { uninstallShellBootstrap } from "./shell-bootstrap.mjs";
9
6
  import { LIVE_POLICIES, livePolicyOf } from "./live-policy.mjs";
10
7
 
11
8
  // The installer's side of a native activation: which owned mechanisms an
@@ -16,26 +13,15 @@ import { LIVE_POLICIES, livePolicyOf } from "./live-policy.mjs";
16
13
  // apply, and only when it did not already exist.
17
14
 
18
15
  export { LIVE_POLICIES, livePolicyOf };
19
- const MECHANISM_ORDER = ["native-config", "native-service", "shell-bootstrap"];
16
+ const MECHANISM_ORDER = ["native-config", "native-service"];
20
17
  const SERVICE_TIMEOUT_MS = 15_000;
21
18
 
22
- export const shellOf = env => {
23
- const shell = env?.SHELL;
24
- return typeof shell === "string" && shell !== "" ? path.basename(shell) : null;
25
- };
19
+ // Where ACC 0.7.x put its `claude` shim. An edited shim is kept rather than
20
+ // deleted, so detection still has to look past this directory.
26
21
  export const shimDirFor = stateRoot => path.join(stateRoot, "bin");
27
- // `.zshrc`, deliberately, and that makes the shim interactive-only: zsh reads
28
- // this file for interactive shells and not for `zsh -lc` or a script. An
29
- // interactive launch is where a client session comes from, and putting the PATH
30
- // entry somewhere every shell reads would put ACC in front of a vendor command
31
- // in scripts and CI that never asked for it. Worth knowing when checking an
32
- // install: a non-interactive shell resolving the vendor binary directly is this
33
- // choice working, not a broken bootstrap.
34
- export const rcFileFor = (home, shell) => (shell === "zsh" && typeof home === "string"
35
- ? path.join(home, ".zshrc") : null);
36
22
 
37
- // The vendor executable a shim will exec: the first executable on PATH that is
38
- // not ACC's own shim directory, so a shim never resolves itself.
23
+ // The vendor executable to probe: the first executable on PATH outside the
24
+ // excluded directories, so a kept 0.7.x shim is never probed as the client.
39
25
  export async function resolveExecutable(command, { pathEnv = "", exclude = [] } = {}) {
40
26
  const excluded = exclude.filter(Boolean).map(directory => path.resolve(directory));
41
27
  for (const directory of String(pathEnv).split(path.delimiter).filter(Boolean)) {
@@ -65,11 +51,7 @@ const renderCommand = command => [command.executable, ...command.args].join(" ")
65
51
  export function describeActivation(activation) {
66
52
  const lines = [];
67
53
  for (const mechanism of ordered(activation.mechanisms)) {
68
- if (mechanism.kind === "shell-bootstrap") {
69
- lines.push(`create shim ${path.join(activation.shimDir, mechanism.command)} for `
70
- + `${mechanism.command} (${activation.livePolicy} live delivery)`);
71
- lines.push(`add a PATH block to ${activation.rcFile}`);
72
- } else if (mechanism.kind === "native-config") {
54
+ if (mechanism.kind === "native-config") {
73
55
  lines.push(`write native config ${mechanism.artifactIds.join(", ")}`);
74
56
  } else if (mechanism.preExisting) {
75
57
  lines.push(`use the existing ${mechanism.serviceId} service`);
@@ -96,47 +78,27 @@ export function describeDeactivation(nativeActivation) {
96
78
  return lines;
97
79
  }
98
80
 
99
- export async function applyNativeActivation({ adapter, activation, dataHome,
100
- node = process.execPath, bootstrap = defaultBootstrap(), exec = defaultExec }) {
81
+ export async function applyNativeActivation({ activation, exec = defaultExec }) {
101
82
  const record = { livePolicy: activation.livePolicy,
102
83
  protocolContract: activation.protocolContract, mechanisms: [] };
103
- let shell = null;
104
- try {
105
- for (const mechanism of ordered(activation.mechanisms)) {
106
- if (mechanism.kind === "native-config") {
107
- record.mechanisms.push({ kind: mechanism.kind, artifactIds: [...mechanism.artifactIds] });
108
- } else if (mechanism.kind === "native-service") {
109
- let createdByAcc = false;
110
- if (!mechanism.preExisting && mechanism.applyCommand !== null) {
111
- await exec(mechanism.applyCommand.executable, mechanism.applyCommand.args);
112
- createdByAcc = true;
113
- }
114
- record.mechanisms.push({ kind: mechanism.kind, serviceId: mechanism.serviceId,
115
- createdByAcc, teardownCommand: mechanism.teardownCommand });
116
- } else {
117
- const plan = planShellBootstrap({ shell: activation.shell, rcFile: activation.rcFile,
118
- shimDir: activation.shimDir, runtime: { node, bootstrap, dataHome },
119
- entries: [{ adapterId: adapter.id, command: mechanism.command,
120
- realExecutable: mechanism.realExecutable, prefixArgs: mechanism.prefixArgs,
121
- livePolicy: activation.livePolicy }] });
122
- const result = await installShellBootstrap({ plan });
123
- if (!result.ok) throw new Error(`shell bootstrap refused: ${result.reasonCode}`);
124
- shell = result;
125
- record.mechanisms.push({ kind: mechanism.kind, command: mechanism.command, shimDir: activation.shimDir,
126
- ownedFiles: result.shims.map(shim => ({ path: shim.path, sha256: shim.sha256 })),
127
- rcFile: result.rcFile });
84
+ for (const mechanism of ordered(activation.mechanisms)) {
85
+ if (mechanism.kind === "native-config") {
86
+ record.mechanisms.push({ kind: mechanism.kind, artifactIds: [...mechanism.artifactIds] });
87
+ } else if (mechanism.kind === "native-service") {
88
+ let createdByAcc = false;
89
+ if (!mechanism.preExisting && mechanism.applyCommand !== null) {
90
+ await exec(mechanism.applyCommand.executable, mechanism.applyCommand.args);
91
+ createdByAcc = true;
128
92
  }
93
+ record.mechanisms.push({ kind: mechanism.kind, serviceId: mechanism.serviceId,
94
+ createdByAcc, teardownCommand: mechanism.teardownCommand });
95
+ } else {
96
+ // validateNativeActivationPlan refuses a shell bootstrap before any plan
97
+ // gets here; this is the same refusal for a hand-built one.
98
+ throw new Error("shell-bootstrap activation was removed");
129
99
  }
130
- return { nativeActivation: record, appendedRcBlock: shell?.rcFile.appended === true };
131
- } catch (error) {
132
- // Only bytes this operation wrote are taken back; a pre-existing service
133
- // or a user's own file is never touched on the way out.
134
- if (shell !== null) {
135
- await uninstallShellBootstrap({ ownership: { shims: shell.shims, shimDir: activation.shimDir,
136
- rcFile: shell.rcFile } }).catch(() => null);
137
- }
138
- throw error;
139
100
  }
101
+ return { nativeActivation: record };
140
102
  }
141
103
 
142
104
  // Compare planned mechanisms with the deletion authority saved by older installs.
@@ -28,6 +28,8 @@ export async function fingerprint(file) {
28
28
  }
29
29
  }
30
30
 
31
+ const CLIENT_ORPHAN_MARKER = ".orphaned_at";
32
+
31
33
  /**
32
34
  * Fingerprint of a whole directory ACC created.
33
35
  *
@@ -45,7 +47,11 @@ export async function treeFingerprint(root) {
45
47
  if (error.code === "ENOTDIR") return fingerprint(root);
46
48
  throw error;
47
49
  }
48
- const files = entries.filter(entry => entry.isFile())
50
+ // Claude Code marks a plugin cache version its registry no longer names with
51
+ // `.orphaned_at`, and removes the marker again if the version comes back. That
52
+ // is the client's own bookkeeping inside a copy ACC keeps on purpose, never an
53
+ // edit, and ACC never writes a file by that name.
54
+ const files = entries.filter(entry => entry.isFile() && entry.name !== CLIENT_ORPHAN_MARKER)
49
55
  .map(entry => path.relative(root, path.join(entry.parentPath ?? entry.path, entry.name)))
50
56
  .sort();
51
57
  const hash = createHash("sha256");