agents-can-communicate 0.7.1 → 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 (85) 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 +23 -13
  6. package/docs/CAPABILITIES.md +19 -16
  7. package/docs/CLI.md +48 -13
  8. package/docs/CONCEPTS.md +5 -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 +25 -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-claude-code/certification.json +14 -73
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
  35. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  39. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
  43. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  44. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
  45. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
  46. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
  47. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
  49. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
  50. package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
  51. package/node_modules/@agents-can-communicate/cli/src/main.mjs +25 -3
  52. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
  53. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  54. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
  55. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
  56. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
  58. package/node_modules/@agents-can-communicate/core/src/status.mjs +19 -1
  59. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
  61. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
  63. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
  64. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +1 -1
  65. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  66. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
  67. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
  68. package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
  69. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
  70. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
  71. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
  72. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
  73. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  75. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  76. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
  77. package/package.json +1 -1
  78. package/bin/acc-bootstrap.mjs +0 -7
  79. package/bin/acc-claude-channel.mjs +0 -10
  80. package/bin/entrypoints/claude-channel-binding.mjs +0 -46
  81. package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
  82. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
  83. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
  84. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
  85. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
@@ -7,24 +7,13 @@ import { canonicalManagerRoot, readControl, readManagedJson } from "./state.mjs"
7
7
  import { commandPrefix } from "./command-prefix.mjs";
8
8
 
9
9
  export const ENTRY_KINDS = Object.freeze([
10
- "acc", "acc-hook", "acc-mcp", "acc-bootstrap", "acc-claude-channel", "acc-antigravity-relay",
10
+ "acc", "acc-hook", "acc-mcp", "acc-antigravity-relay",
11
11
  ]);
12
12
 
13
- const BOOTSTRAP_FLAGS = new Map([["--adapter", "adapter"], ["--real-executable", "realExecutable"],
14
- ["--data-home", "dataHome"]]);
15
-
16
- /** Shared with the runtime; parse before admission without importing installer code. */
17
- export function parseBootstrapOptions(args) {
18
- const options = {};
19
- for (let index = 0; index < args.length; index += 2) {
20
- const key = BOOTSTRAP_FLAGS.get(args[index]);
21
- const value = args[index + 1];
22
- if (key === undefined || typeof value !== "string" || value === "" || key in options) return null;
23
- options[key] = value;
24
- }
25
- return BOOTSTRAP_FLAGS.values().every(key => key in options) && path.isAbsolute(options.dataHome)
26
- ? options : null;
27
- }
13
+ // Kinds a 0.7.x install wrote a launcher for. The package still ships their
14
+ // entrypoints for 0.7.x updaters and launchers; a current install removes the
15
+ // launchers themselves.
16
+ export const RETIRED_ENTRY_KINDS = Object.freeze(["acc-bootstrap", "acc-claude-channel"]);
28
17
 
29
18
  // Bootstrap resolution deliberately needs only Node built-ins: no selected
30
19
  // generation is imported until admission and its actual PID lease are durable.
@@ -43,11 +32,9 @@ export function invokedDirectly(url) {
43
32
 
44
33
  function unavailable(kind) {
45
34
  // Never reflect control file content or payloads into a client diagnostic.
46
- if (kind === "acc-hook" || kind === "acc-claude-channel" || kind === "acc-antigravity-relay") {
35
+ if (kind === "acc-hook" || kind === "acc-antigravity-relay") {
47
36
  process.stderr.write("acc: coordination unavailable during runtime update; session may continue\n");
48
37
  process.exitCode = 0;
49
- } else if (kind === "acc-bootstrap") {
50
- process.exitCode = 1; // The vendor shell shim launches its original command.
51
38
  } else {
52
39
  process.stderr.write("acc: runtime unavailable; retry after the update or run acc update to recover\n");
53
40
  process.exitCode = 4;
@@ -83,28 +70,12 @@ async function updateImplementation(packageRoot, control) {
83
70
  export async function runEntry({ kind, packageRoot, managerRoot, managedRequired = false }) {
84
71
  if (!ENTRY_KINDS.includes(kind)) throw new Error("unknown ACC entry point");
85
72
  const command = kind === "acc" ? commandPrefix(process.argv.slice(2)).command : null;
86
- const bootstrapOptions = kind === "acc-bootstrap" ? parseBootstrapOptions(process.argv.slice(2)) : null;
87
- if (kind === "acc-bootstrap" && bootstrapOptions === null) {
88
- if (process.env.ACC_BOOTSTRAP_DEBUG === "1") {
89
- process.stderr.write("acc-bootstrap: usage: --adapter <id> --real-executable <path> --data-home <path>\n");
90
- }
91
- process.exitCode = 2;
92
- return;
93
- }
94
73
  let selected = packageRoot;
95
74
  let managed = null;
96
75
  let managementOnly = false;
97
76
  let root;
98
77
  try {
99
- const requestedDataHome = bootstrapOptions === null ? null : await canonicalManagerRoot(bootstrapOptions.dataHome);
100
- const requestedRoot = requestedDataHome === null ? null
101
- : await canonicalManagerRoot(path.join(requestedDataHome, "acc", "runtime"));
102
- if (managerRoot && requestedDataHome !== null
103
- && requestedDataHome !== await canonicalManagerRoot(path.dirname(path.dirname(managerRoot)))) {
104
- throw new Error("bootstrap cache home differs from its launcher");
105
- }
106
- root = await canonicalManagerRoot(managerRoot ?? requestedRoot ?? managerLocation());
107
- if (requestedRoot !== null && root !== requestedRoot) throw new Error("bootstrap data home differs from its launcher");
78
+ root = await canonicalManagerRoot(managerRoot ?? managerLocation());
108
79
  const control = await readControl(root);
109
80
  if (control !== null) {
110
81
  selected = control.active.root;
@@ -138,13 +109,6 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
138
109
  } catch { /* Fall through to the same graceful unavailability below. */ }
139
110
  }
140
111
  unavailable(kind);
141
- if (kind === "acc-claude-channel" && selected) {
142
- try {
143
- const { startInertChannel } = await import(pathToFileURL(path.join(selected,
144
- "bin", "entrypoints", "claude-channel-stdio.mjs")).href);
145
- startInertChannel();
146
- } catch { /* The stderr notice above already fired either way. */ }
147
- }
148
112
  return;
149
113
  }
150
114
  const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { lstat, mkdtemp, open, readFile, rename, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { ENTRY_KINDS } from "./entry.mjs";
4
+ import { ENTRY_KINDS, RETIRED_ENTRY_KINDS } from "./entry.mjs";
5
5
  import { managedDirectory, syncDirectory } from "./state.mjs";
6
6
 
7
7
  const MODULES = ["entry.mjs", "command-prefix.mjs", "state.mjs", "generation-files.mjs",
@@ -14,8 +14,6 @@ async function durableFile(file, bytes, mode = 0o600) {
14
14
 
15
15
  export const stablePaths = root => ({
16
16
  cli: path.join(root, "bin", "acc.mjs"), runner: path.join(root, "bin", "acc-hook.mjs"),
17
- bootstrap: path.join(root, "bin", "acc-bootstrap.mjs"),
18
- channel: path.join(root, "bin", "acc-claude-channel.mjs"),
19
17
  antigravityRelay: path.join(root, "bin", "acc-antigravity-relay.mjs"),
20
18
  });
21
19
 
@@ -57,6 +55,10 @@ export async function writeLaunchers(root, packageRoot) {
57
55
  await rename(temporary, path.join(bin, `${kind}.mjs`));
58
56
  } finally { await rm(temporary, { force: true }); }
59
57
  }
58
+ // A 0.7.x `claude` shim or plugin copy that still reaches for one of these
59
+ // finds nothing: the shim then launches the vendor command untouched, and a
60
+ // re-laid plugin names none of them.
61
+ for (const kind of RETIRED_ENTRY_KINDS) await rm(path.join(bin, `${kind}.mjs`), { force: true });
60
62
  await syncDirectory(bin);
61
63
  return stablePaths(root);
62
64
  }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation, shellOf }
2
+ import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
3
3
  from "@agents-can-communicate/installer";
4
4
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
5
5
  import { stablePaths, writeLaunchers } from "./launchers.mjs";
@@ -17,7 +17,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
17
17
  // session open across the update keeps running from the outgoing copy until it
18
18
  // restarts. Naming it here keeps that copy; everything older than it goes.
19
19
  const context = { ...clientContext(control.home, path.join(dataHome, "acc"),
20
- { env, shell: shellOf(env), dataHome }), ...stablePaths(root),
20
+ { env, dataHome }), ...stablePaths(root),
21
21
  keepPreviousVersion: control.active.version };
22
22
  const recorded = (await loadOwnership({ dataHome })).installs;
23
23
  const detected = await detectInstallation({ adapters, context, probeTimeoutMs: probeTimeout(env) });
@@ -38,8 +38,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
38
38
  }
39
39
  return async () => {
40
40
  await writeLaunchers(root, control.pending.root);
41
- const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version,
42
- activation: { bootstrap: context.bootstrap } });
41
+ const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version });
43
42
  return { ...result, failed: result.failed.map(failure => ({ ...failure,
44
43
  paths: plan.operations.find(operation => operation.adapterId === failure.adapterId)
45
44
  ?.artifacts?.map(artifact => artifact.path).filter(file => typeof file === "string") ?? [],
@@ -34,11 +34,11 @@ export function nativeRemediation(entry) {
34
34
  + " # opt in to automatic peer requests; may spend tokens");
35
35
  } else if (native.configured && native.activation === "missing") {
36
36
  steps.push(`acc install --adapter ${entry.adapterId}`
37
- + " # complete the missing native launch setup from a supported shell");
37
+ + " # complete the missing native delivery setup");
38
38
  } else if (native.configured && native.runtime !== "active"
39
39
  && !["needed", "blocked", "unsupported"].includes(service?.state)) {
40
40
  steps.push(`${entry.displayName}: no verified live channel in this workspace; `
41
- + "open a new terminal, start a new client session and check its integration/channel prompts; "
41
+ + "start a new client session and check its integration prompts; "
42
42
  + "then run acc doctor here");
43
43
  }
44
44
  if (native.reasonCode === "native_endpoint_unavailable") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,6 +33,9 @@ const ageBand = (session, age) =>
33
33
  * @returns {"online" | "stale" | "offline"}
34
34
  * @throws {AccError} EXIT.USAGE when pidIsAlive is not a function.
35
35
  */
36
+ // A client's end reason is a short word of its own ("clear", "logout").
37
+ const END_REASON = /^[a-z][a-z0-9_-]{0,63}$/;
38
+
36
39
  export function classifySessionPresence(session, now, pidIsAlive) {
37
40
  // Required rather than defaulted. A probe that defaults to "everyone is
38
41
  // alive" turns a forgotten call site into a check that silently passes, which
@@ -149,10 +152,14 @@ export function createSessionService(ports) {
149
152
  }, { kinds: ["session"] });
150
153
  }
151
154
 
152
- async function closeSession({ sessionId, workspaceId, generation }) {
155
+ async function closeSession({ sessionId, workspaceId, generation, endReason = null }) {
156
+ // The client's own word for why, kept in extensions: an older ACC reading
157
+ // the same store skips them, and the session.closed event is unchanged.
158
+ const reason = typeof endReason === "string" && END_REASON.test(endReason) ? endReason : null;
153
159
  const close = current => {
154
160
  assertGeneration({ record: current }, generation, "close", workspaceId ?? store.workspaceId);
155
- return { ...current, state: "closed", heartbeatAt: clock.now() };
161
+ return { ...current, state: "closed", heartbeatAt: clock.now(),
162
+ ...(reason === null ? {} : { extensions: { ...(current.extensions ?? {}), endReason: reason } }) };
156
163
  };
157
164
  let ephemeral = null;
158
165
  await store.ephemeral.delete("session", sessionId, async current => {
@@ -179,6 +186,21 @@ export function createSessionService(ports) {
179
186
  }, { kinds: ["session"] });
180
187
  }
181
188
 
189
+ /** The participant's most recent session, open or closed, or null. */
190
+ async function lastSessionOf({ participantId, workspaceId }) {
191
+ const resolved = workspaceId ?? store.workspaceId;
192
+ const snapshot = resolved === undefined ? null
193
+ : await store.snapshot(resolved, { kinds: ["workspace", "session"] });
194
+ const records = snapshot?.workspace === null
195
+ ? await store.ephemeral.list("session")
196
+ : snapshot?.sessions ?? await store.ephemeral.list("session");
197
+ const [last] = records.filter(session => session.participantId === participantId)
198
+ .sort((left, right) => Date.parse(right.heartbeatAt) - Date.parse(left.heartbeatAt)
199
+ || right.sessionId.localeCompare(left.sessionId));
200
+ return last === undefined ? null : { sessionId: last.sessionId, generation: last.generation,
201
+ state: last.state, endReason: last.extensions?.endReason ?? null };
202
+ }
203
+
182
204
  async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
183
205
  const resolved = workspaceId ?? store.workspaceId;
184
206
  const snapshot = resolved === undefined ? null
@@ -198,6 +220,7 @@ export function createSessionService(ports) {
198
220
  resumeSession,
199
221
  heartbeatSession,
200
222
  closeSession,
223
+ lastSessionOf,
201
224
  listLiveSessions,
202
225
  locateSession: locate,
203
226
  ensureMaterialised: options => ensureMaterialised(ports, options),
@@ -105,6 +105,20 @@ export function createStatusService(ports, sessions, deliveryBindings) {
105
105
  }
106
106
  }
107
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));
121
+
108
122
  return {
109
123
  workspaceId,
110
124
  materialised: durable,
@@ -115,7 +129,8 @@ export function createStatusService(ports, sessions, deliveryBindings) {
115
129
  // after a month of work this listed sixty entries for one live session.
116
130
  // `acc status --all` is how the worktree-cleanup question is asked.
117
131
  participants: classified
118
- .filter(item => input.all === true || item.presence !== "offline")
132
+ .filter(item => input.all === true || item.presence !== "offline"
133
+ || awaited.has(item.session.sessionId))
119
134
  .map(({ session, presence }) => ({
120
135
  sessionId: session.sessionId,
121
136
  participantId: session.participantId,
@@ -132,6 +147,9 @@ export function createStatusService(ports, sessions, deliveryBindings) {
132
147
  intent: intents.find(intent => intent.sessionId === session.sessionId
133
148
  && isCurrentIntent(intent))?.summary ?? null,
134
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 } : {}),
135
153
  })),
136
154
  // The owner is named twice on purpose. Every command that reaches a peer
137
155
  // takes a participant id, so a claim that gave only a session id sent the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.7.1",
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.1",
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
  }
@@ -506,7 +506,7 @@ const HANDLERS = {
506
506
  const owner = { sessionId: binding.accSessionId, generation: binding.generation };
507
507
  const current = await context.service.locateSession(binding.accSessionId);
508
508
  if (current?.record.generation === binding.generation && current.record.state === "open") {
509
- await context.service.closeSession(owner);
509
+ await context.service.closeSession({ ...owner, endReason: event.endReason ?? null });
510
510
  } else {
511
511
  // Close may have committed before this hook died. Retire only the old
512
512
  // endpoint and binding; do not close a replacement or repeat a close event.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.7.1",
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";