agents-can-communicate 0.4.1 → 0.4.3

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 (57) hide show
  1. package/README.md +17 -4
  2. package/docs/ARCHITECTURE.md +7 -1
  3. package/docs/CLI.md +23 -3
  4. package/docs/CONFIGURATION.md +25 -0
  5. package/docs/GETTING_STARTED.md +17 -5
  6. package/docs/PROTOCOL.md +4 -0
  7. package/docs/RELEASING.md +1 -1
  8. package/docs/SECURITY_MODEL.md +7 -0
  9. package/docs/TROUBLESHOOTING.md +60 -7
  10. package/docs/UPGRADING.md +36 -19
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -3
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +2 -4
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +10 -0
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +2 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/channel-directory.mjs +8 -0
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +3 -0
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +64 -0
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +65 -0
  33. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +1 -0
  34. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +19 -52
  36. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +23 -45
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +3 -1
  38. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +88 -0
  39. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +64 -0
  40. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
  42. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +43 -0
  46. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +17 -27
  47. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -0
  49. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +36 -0
  50. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +28 -12
  51. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -1
  52. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +10 -3
  53. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +12 -2
  54. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  57. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Worker } from "node:worker_threads";
5
+
6
+ import { NATIVE_REASON_CODES, TIMESTAMP } from "./native-vocabulary.mjs";
7
+
8
+ // Optional diagnostic metadata beside the hook-owner file, never an event
9
+ // history or injected context. Copy only closed values: vendor errors, prompts,
10
+ // endpoints and extra properties must not survive persistence or doctor reads.
11
+ export function nativeAttemptFrom(value) {
12
+ if (!value || typeof value.at !== "string" || !TIMESTAMP.test(value.at)
13
+ || !Number.isFinite(Date.parse(value.at))
14
+ || !["sessionStart", "beforeTurn"].includes(value.event)
15
+ || !["active", "off", "degraded", "unsupported"].includes(value.state)
16
+ || !(value.reasonCode === null || NATIVE_REASON_CODES.includes(value.reasonCode))
17
+ || !["off", "actionable", "all"].includes(value.policy)
18
+ || !["bootstrap-environment", "installation-record"].includes(value.policySource)
19
+ || !["enabled", "off", "missing", "invalid", "unavailable"].includes(value.policyStatus)
20
+ || !["identified", "unknown"].includes(value.clientProcess)) return null;
21
+ return { at: value.at, event: value.event, state: value.state, reasonCode: value.reasonCode,
22
+ policy: value.policy, policySource: value.policySource, policyStatus: value.policyStatus,
23
+ clientProcess: value.clientProcess };
24
+ }
25
+
26
+ const keyFor = harnessSessionId => createHash("sha256").update(String(harnessSessionId))
27
+ .digest("hex").slice(0, 32);
28
+ const fileFor = (runtimeDir, harnessSessionId) =>
29
+ path.join(runtimeDir, "native-attempts", `${keyFor(harnessSessionId)}.json`);
30
+ export async function loadNativeAttempt({ runtimeDir, harnessSessionId, accSessionId, generation }) {
31
+ try {
32
+ const record = JSON.parse(await readFile(fileFor(runtimeDir, harnessSessionId), "utf8"));
33
+ return record.schemaVersion === 1 && record.accSessionId === accSessionId
34
+ && record.generation === generation ? nativeAttemptFrom(record.attempt) : null;
35
+ } catch { return null; }
36
+ }
37
+
38
+ // Isolate filesystem requests as well as JS waiting: even a stuck fs request
39
+ // must not keep the vendor's short-lived hook process alive after its output.
40
+ // The unreferenced worker owns its mutex through any late I/O continuation.
41
+ async function writeDiagnostic(operation, input) {
42
+ const deadlineAt = Math.min(input.deadlineAt ?? Infinity, Date.now() + 250);
43
+ if (Date.now() >= deadlineAt) return false;
44
+ let worker;
45
+ let timer;
46
+ try {
47
+ worker = new Worker(new URL("./native-attempt-writer.mjs", import.meta.url), {
48
+ workerData: { operation, input: { runtimeDir: input.runtimeDir,
49
+ harnessSessionId: input.harnessSessionId, accSessionId: input.accSessionId,
50
+ generation: input.generation, nativeAttempt: nativeAttemptFrom(input.nativeAttempt), deadlineAt } },
51
+ });
52
+ const result = new Promise(resolve => {
53
+ worker.once("message", value => resolve(value === true));
54
+ worker.once("error", () => resolve(false));
55
+ worker.once("exit", () => resolve(false));
56
+ timer = setTimeout(() => resolve(false), Math.max(0, deadlineAt - Date.now()));
57
+ });
58
+ worker.unref();
59
+ return await result;
60
+ } catch { return false; }
61
+ finally { clearTimeout(timer); }
62
+ }
63
+
64
+ export const storeNativeAttempt = input => writeDiagnostic("store", input);
65
+ export const clearNativeAttempt = input => writeDiagnostic("clear", input);
@@ -23,6 +23,7 @@ export const NATIVE_REASON_CODES = Object.freeze([
23
23
  "native_delivery_unsupported", "platform_not_captured", "version_unavailable",
24
24
  "prerelease_not_captured", "below_minimum_version", "known_bad_version",
25
25
  "feature_probe_failed", "probe_timeout", "probe_version_mismatch", "protocol_mismatch",
26
+ "native_endpoint_unavailable", "native_session_unavailable",
26
27
  "handshake_failed", "handshake_timeout", "handshake_version_mismatch",
27
28
  "session_generation_stale", "client_process_unknown", "unsupported_shell",
28
29
  // The current session's canonical workspace identity could not be verified.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,10 +2,15 @@ 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, livePolicyOf, loadOwnership, shellOf, verifyOwned }
5
+ import { describeDeliveryFallback, detectInstallation, livePolicyOf, loadOwnership, shellOf, verifyOwned }
6
6
  from "@agents-can-communicate/installer";
7
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
 
9
+ import { describeNative, nativeRemediation, nativeState, updateNativeRuntime } from "./native-delivery-status.mjs";
10
+ export { describeNative } from "./native-delivery-status.mjs";
11
+
12
+ import { nativeSessionLines, updateNativeSessions } from "./native-session-diagnostics.mjs";
13
+
9
14
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
10
15
  import { describePresence } from "./main.mjs";
11
16
  import { platformPaths } from "./platform-paths.mjs";
@@ -66,21 +71,6 @@ export function staleInstall({ recorded, running }) {
66
71
  return recorded === running ? null : { recorded, running };
67
72
  }
68
73
 
69
- const RUNTIME_LABEL = Object.freeze({ active: "active", waiting: "waiting for a live session",
70
- inactive: "not enabled", degraded: "degraded", unsupported: "unsupported" });
71
-
72
- /** One human clause for a native-delivery state, next-action implied, never overclaiming. */
73
- export function describeNative(native) {
74
- if (native.eligibility === "unsupported") {
75
- return `unsupported${native.reasonCode ? ` (${native.reasonCode})` : ""}`;
76
- }
77
- const enabled = native.configured ? `enabled (${native.policy})` : "eligible, not enabled";
78
- const runtime = RUNTIME_LABEL[native.runtime] ?? native.runtime;
79
- const degraded = native.eligibility === "degraded" && native.reasonCode
80
- ? ` - ${native.reasonCode}` : "";
81
- return `${native.eligibility} - ${enabled} - ${runtime}${degraded}`;
82
- }
83
-
84
74
  /**
85
75
  * The runner version behind whatever ACC wrote for one client.
86
76
  *
@@ -117,25 +107,6 @@ async function findShims(root, depth) {
117
107
  return found;
118
108
  }
119
109
 
120
- // One closed native-delivery report per adapter, built only from detection,
121
- // ownership, and later the live binding facts - never inferred from a
122
- // configured shim alone. eligibility is what the client could do; configured is
123
- // whether a policy was recorded; policy is that recorded policy; runtime is
124
- // filled in from current bindings; modes and reasonCode carry the closed
125
- // detail. runtime "active" never means the model read anything.
126
- function nativeState(detected, recordedPolicy) {
127
- const native = detected ?? { state: "unsupported", reasonCode: "native_delivery_unsupported" };
128
- const eligibility = native.state === "eligible" ? "eligible"
129
- : native.state === "degraded" ? "degraded" : "unsupported";
130
- const policy = recordedPolicy ?? "off";
131
- const configured = policy !== "off";
132
- const modes = native.state === "eligible" && Array.isArray(native.probe?.modes)
133
- ? [...native.probe.modes] : [];
134
- const runtime = eligibility === "unsupported" ? "unsupported"
135
- : !configured ? "inactive" : "waiting";
136
- return { eligibility, configured, policy, runtime, modes, reasonCode: native.reasonCode ?? null };
137
- }
138
-
139
110
  export async function diagnoseAdapters({ options, runtime, detect = detectInstallation }) {
140
111
  // The same home `acc install --home` writes to, or the real one. Reading a
141
112
  // different home than install wrote to reports every adapter as missing.
@@ -203,7 +174,9 @@ export async function diagnoseAdapters({ options, runtime, detect = detectInstal
203
174
  // divergence legible rather than hidden behind a single reassuring number.
204
175
  return { ...entry, stale, wired, bundleVersion, owned: { modified: owned.modified,
205
176
  missing: owned.missing, intact: owned.intact.length },
206
- nativeDelivery: nativeState(entry.nativeDelivery, livePolicyOf(installed)), remediation };
177
+ nativeDelivery: nativeState(entry.nativeDelivery, livePolicyOf(installed), {
178
+ contract: adapters.find(adapter => adapter.id === entry.adapterId)?.nativeDelivery,
179
+ activation: installed?.nativeActivation }), remediation };
207
180
  }));
208
181
  }
209
182
 
@@ -259,22 +232,10 @@ export async function runDoctor({ options, context, runtime }) {
259
232
  // front.
260
233
  const service = context.service ?? await context.openService();
261
234
  const status = await service.collectStatus({});
262
- // The runtime column is the only part that needs a live read: a current
263
- // reachable binding for this adapter is "active", an expired one "degraded".
264
- const bindingsByAdapter = new Map();
265
- for (const binding of status.deliveryBindings ?? []) {
266
- const existing = bindingsByAdapter.get(binding.adapterId);
267
- if (existing === undefined || binding.reachable) bindingsByAdapter.set(binding.adapterId, binding);
268
- }
235
+ updateNativeRuntime(adapters, status.deliveryBindings);
236
+ await updateNativeSessions(adapters, { service, status, root, now: clock.now() });
269
237
  for (const adapter of adapters) {
270
- const native = adapter.nativeDelivery;
271
- if (native.eligibility === "unsupported") continue;
272
- const binding = bindingsByAdapter.get(adapter.adapterId);
273
- native.runtime = binding === undefined ? (native.configured ? "waiting" : "inactive")
274
- : binding.reachable ? "active" : "degraded";
275
- if (binding !== undefined && Array.isArray(binding.availableModes)) {
276
- native.modes = binding.availableModes.filter(mode => mode !== "nextTurn");
277
- }
238
+ adapter.remediation.push(...nativeRemediation(adapter));
278
239
  }
279
240
 
280
241
  const data = {
@@ -301,13 +262,19 @@ export async function runDoctor({ options, context, runtime }) {
301
262
  const text = [`store healthy; ${describePresence(status.counts)}; `
302
263
  + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
303
264
  ...adapters.filter(adapter => (adapter.present || adapter.installed)
265
+ && adapter.nativeDelivery.reasonCode === "native_delivery_unsupported"
304
266
  && typeof adapter.deliveryDiagnostic === "string")
305
267
  .map(adapter => ` ${adapter.deliveryDiagnostic}`),
306
268
  // One concise native-delivery line per detected client, distinguishing
307
269
  // eligibility, the recorded policy, and the live runtime state. It never
308
270
  // claims that "active" means a model read anything.
309
271
  ...adapters.filter(adapter => adapter.present)
310
- .map(adapter => ` ${adapter.displayName} native delivery: ${describeNative(adapter.nativeDelivery)}`),
272
+ .map(adapter => ` ${adapter.displayName} live delivery: `
273
+ + `${describeNative(adapter.nativeDelivery, { clientVersion: adapter.version })}; `
274
+ + `fallback: ${describeDeliveryFallback(adapter)}`),
275
+ ...nativeSessionLines(adapters),
276
+ ...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
277
+ .map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
311
278
  ...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
312
279
  + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(manager.notice ? [` ${manager.notice}`] : [])]),
313
280
  ...data.remediation.map(line => ` ${line}`),
@@ -148,11 +148,13 @@ export function describeOutcome({ action, acted, failed = [], skipped = [],
148
148
  ...operations.filter(operation => operation.applied)
149
149
  .flatMap(operation => describeChanges(operation, home)),
150
150
  ...operations.filter(operation => operation.applied
151
- && typeof operation.deliveryDiagnostic === "string")
152
- .map(operation => ` ${operation.deliveryDiagnostic}`),
151
+ && typeof (operation.deliverySummary ?? operation.deliveryDiagnostic) === "string")
152
+ .map(operation => ` ${operation.deliverySummary ?? operation.deliveryDiagnostic}`),
153
153
  ...operations.filter(operation => operation.applied)
154
154
  .flatMap(operation => (operation.needsAction ?? [])
155
155
  .map(step => ` ${operation.adapterId}: ${step}`)),
156
+ ...operations.filter(operation => operation.applied)
157
+ .flatMap(operation => (operation.setupNotes ?? []).map(note => ` ${note}`)),
156
158
  ...skipped.map(entry => ` skip ${entry.adapterId}: ${entry.reason}`),
157
159
  ...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`),
158
160
  // Said once, where it is needed: the reader has just been shown a list of
@@ -203,42 +205,22 @@ export function actedOn(result) {
203
205
  const isInteractive = runtime => typeof runtime.isInteractive === "function"
204
206
  && runtime.isInteractive() === true;
205
207
 
206
- /**
207
- * One default-No question per eligible client, asked only after detection has
208
- * finished and only when a person is at both ends of the terminal.
209
- *
210
- * Written for someone who has never heard of this project. It used to open with
211
- * "Enable native live delivery" over a list of internal artefact names, which
212
- * told a first-time reader neither what they would gain nor what it would cost -
213
- * and left out the part that matters most: saying yes makes their own `claude`
214
- * start with the vendor's `--dangerously-load-development-channels` flag. A
215
- * consent prompt that hides the loudest consequence is not consent.
216
- *
217
- * So the order is the reader's, not the installer's: what changes for them,
218
- * what it costs, what it touches, and that "no" costs them nothing.
219
- */
220
- function questionFor(entry, context, { onlyEligible = false } = {}) {
221
- const bootstrap = entry.nativeDelivery.activationPlan.mechanisms
208
+ // One default-No question per supported client. Disclose token use and any
209
+ // repeated channel prompt; declining only promises a verified fallback.
210
+ function questionFor(entry) {
211
+ const bootstrap = entry.nativeDelivery.activationPlan?.mechanisms
222
212
  .find(mechanism => mechanism.kind === "shell-bootstrap") ?? null;
223
- // The presence of a flag is what makes this worth asking about at all: it is
224
- // why the client will warn at every start from now on. Which flag it is, and
225
- // that a shim adds it, are answers to a question nobody is asking here - the
226
- // shim `exec`s and is gone, so nothing runs "through" acc afterwards either.
227
- const flags = (bootstrap?.prefixArgs ?? []).filter(argument => argument.startsWith("--"));
228
- void context;
229
- // Why the default is No, and why only one client is being asked about, are
230
- // the two questions the bare prompt provokes and never answered: a `[y/N]`
231
- // reads as "not recommended" on its own, and a single question among four
232
- // installed clients reads as an omission. Both have the same short answer.
233
- const aside = ["experimental", ...(onlyEligible ? [`${entry.displayName} only`] : [])]
234
- .join("; ");
213
+ const warns = (bootstrap?.prefixArgs ?? []).some(argument => argument.startsWith("--"));
235
214
  return [
236
- `Let idle agents answer each other while you are away? (${aside})`,
237
- ...(flags.length === 0 || bootstrap === null
238
- ? [" Yes: they reply without waiting for you."]
239
- : [` Yes: they reply without waiting for you, and ${entry.displayName} asks you`,
240
- " to allow development channels every time it starts."]),
241
- " No: messages still arrive, at the session's next turn.",
215
+ `Let ${entry.displayName} answer peer requests while idle? (experimental)`,
216
+ " Yes: automatic turns can spend tokens without waiting for you.",
217
+ ...(entry.outgoingDelivery?.setup ? [` ${entry.outgoingDelivery.setup}`] : []),
218
+ ...(warns ? [" Allow development channels when prompted; check the client's startup notice."] : []),
219
+ ...(entry.nativeDelivery.state !== "eligible"
220
+ ? [" Save consent now; delivery waits for an available client service."] : []),
221
+ entry.capabilities?.delivery?.nextTurn === true
222
+ ? " No: use next-turn hooks (when enabled) or acc inbox."
223
+ : " No: read messages with acc inbox; automatic next-turn delivery is unavailable.",
242
224
  ].join("\n");
243
225
  }
244
226
 
@@ -250,22 +232,18 @@ function questionFor(entry, context, { onlyEligible = false } = {}) {
250
232
  * client is off unless a person answers yes here, and a dry run or a
251
233
  * non-interactive stdin/stdout makes no interactive choice at all.
252
234
  */
253
- export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
235
+ export async function decideDelivery({ options, detected, recorded, runtime, dryRun }) {
254
236
  const explicit = options.delivery;
255
237
  if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
256
238
  throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
257
239
  }
258
240
  const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
259
- // Whether this client is the only one that could be asked at all. When it is,
260
- // the question says so, because otherwise being asked about one of four
261
- // installed clients looks like the other three were forgotten.
262
- const onlyEligible = detected
263
- .filter(candidate => candidate.nativeDelivery?.state === "eligible").length === 1;
264
241
  const deliveryByAdapter = {};
265
242
  const asked = [];
266
243
  let withheld = 0;
267
244
  for (const entry of detected) {
268
- const eligible = entry.nativeDelivery?.state === "eligible";
245
+ const eligible = entry.nativeDelivery?.state === "eligible"
246
+ || entry.nativeDelivery?.consentAvailable === true;
269
247
  const previous = livePolicyOf(recordedById.get(entry.adapterId));
270
248
  if (explicit !== undefined) {
271
249
  deliveryByAdapter[entry.adapterId] = explicit;
@@ -275,7 +253,7 @@ export async function decideDelivery({ options, detected, recorded, runtime, dry
275
253
  deliveryByAdapter[entry.adapterId] = "off";
276
254
  if (eligible) withheld += 1;
277
255
  } else {
278
- const yes = await runtime.confirm(questionFor(entry, context, { onlyEligible }),
256
+ const yes = await runtime.confirm(questionFor(entry),
279
257
  { input: runtime.input, output: runtime.output }) === true;
280
258
  deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
281
259
  asked.push(entry.adapterId);
@@ -333,7 +311,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
333
311
  // Decided only after detection, from the operator's explicit answer, the
334
312
  // recorded consent, or a default of off; never from inference.
335
313
  const decided = action === "install"
336
- ? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
314
+ ? await decideDelivery({ options, detected, recorded, runtime, dryRun })
337
315
  : { deliveryByAdapter: {}, asked: [], notes: [] };
338
316
  const plan = planInstallation({ adapters, detected, context, action, recorded,
339
317
  accVersion, allowDowngrade: options.downgrade === true, requested,
@@ -137,7 +137,9 @@ function recordedText(message, delivery) {
137
137
  const diagnostics = delivery.map(item => item.outcome === "offered"
138
138
  ? `live offered to ${item.recipientParticipantId} via ${item.transport}`
139
139
  : item.outcome === "queued"
140
- ? `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
140
+ ? item.errorCode === "transport_permission_denied"
141
+ ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
142
+ : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
141
143
  : `delivery already ${item.outcome}`);
142
144
  return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
143
145
  }
@@ -0,0 +1,88 @@
1
+ import { describeNativeReason } from "@agents-can-communicate/installer";
2
+
3
+ export function describeNative(native, { clientVersion } = {}) {
4
+ const differs = native.runtime === "active" && native.sessionPolicy
5
+ && native.sessionPolicy !== native.policy;
6
+ const enabled = (native.configured ? `enabled (${native.policy})` : "off")
7
+ + (differs ? " for new sessions" : "");
8
+ const availability = native.eligibility === "eligible" ? "available"
9
+ : `${native.eligibility === "unsupported" ? "unavailable" : "readiness unverified"}: `
10
+ + describeNativeReason(native.reasonCode, { clientVersion, minimumVersion: native.minimumVersion });
11
+ const runtime = native.runtime === "active"
12
+ ? `local transport active${differs ? ` (session policy: ${native.sessionPolicy})` : ""}`
13
+ : native.runtime === "degraded" ? "channel unreachable"
14
+ : native.configured && native.runtime === "waiting"
15
+ ? "no live channel bound in this workspace" : null;
16
+ return [availability, enabled, ...(runtime ? [runtime] : [])].join("; ");
17
+ }
18
+
19
+ export function nativeRemediation(entry) {
20
+ const native = entry.nativeDelivery;
21
+ if (!(entry.present || entry.installed) || native.eligibility === "unsupported") return [];
22
+ const steps = [];
23
+ if (!native.configured && native.runtime !== "active") {
24
+ steps.push(`acc install --adapter ${entry.adapterId} --delivery actionable`
25
+ + " # opt in to automatic peer requests; may spend tokens");
26
+ } else if (native.configured && native.activation === "missing") {
27
+ steps.push(`acc install --adapter ${entry.adapterId}`
28
+ + " # complete the missing native launch setup from a supported shell");
29
+ } else if (native.configured && native.runtime !== "active") {
30
+ steps.push(`${entry.displayName}: no verified live channel in this workspace; `
31
+ + "open a new terminal, start a new client session and check its integration/channel prompts; "
32
+ + "then run acc doctor here");
33
+ }
34
+ if (native.reasonCode === "native_endpoint_unavailable") {
35
+ steps.push(entry.nativeSetup ?? `${entry.displayName}: check the client's local delivery service setup; `
36
+ + "ACC does not start or restart that service");
37
+ } else if (native.reasonCode === "native_session_unavailable") {
38
+ steps.push(`${entry.displayName}: open a session connected to the client's local delivery service`);
39
+ }
40
+ return steps;
41
+ }
42
+
43
+ // One closed native-delivery report per adapter, built only from detection,
44
+ // ownership, and later the live binding facts - never inferred from a
45
+ // configured shim alone. eligibility is what the client could do; configured is
46
+ // whether a policy was recorded; policy is that recorded policy; runtime is
47
+ // filled in from current bindings; modes and reasonCode carry the closed
48
+ // detail. runtime "active" never means the model read anything.
49
+ export function nativeState(detected, recordedPolicy, { contract, activation } = {}) {
50
+ const native = detected ?? { state: "unsupported", reasonCode: "native_delivery_unsupported" };
51
+ const eligibility = native.state === "eligible" ? "eligible"
52
+ : native.state === "degraded" ? "degraded" : "unsupported";
53
+ const policy = recordedPolicy ?? "off";
54
+ const configured = policy !== "off";
55
+ const modes = native.state === "eligible" && Array.isArray(native.probe?.modes)
56
+ ? [...native.probe.modes] : [];
57
+ const runtime = eligibility === "unsupported" ? "unsupported"
58
+ : !configured ? "inactive" : "waiting";
59
+ const setupRequired = contract?.activationKinds?.some(kind => kind !== "native-service") === true;
60
+ return { eligibility, configured, policy, runtime, modes, reasonCode: native.reasonCode ?? null,
61
+ minimumVersion: native.eligibility?.minimumVersion ?? null,
62
+ policySource: contract?.policySource ?? null,
63
+ activation: !setupRequired ? "not_required"
64
+ : activation?.mechanisms?.length > 0 ? "recorded" : "missing", sessionPolicy: null };
65
+ }
66
+
67
+ // Native runtime is workspace-specific. A hook-only binding cannot prove it;
68
+ // legacy launch policies may outlive the policy selected for future launches.
69
+ export function updateNativeRuntime(adapters, bindings) {
70
+ const byAdapter = new Map();
71
+ for (const binding of bindings ?? []) {
72
+ if (!binding.availableModes?.includes("livePush")) continue;
73
+ const previous = byAdapter.get(binding.adapterId);
74
+ if (previous === undefined || binding.reachable) byAdapter.set(binding.adapterId, binding);
75
+ }
76
+ for (const entry of adapters) {
77
+ const native = entry.nativeDelivery;
78
+ if (native.eligibility === "unsupported") continue;
79
+ const binding = byAdapter.get(entry.adapterId);
80
+ const disabled = native.policySource === "installation-record" && !native.configured;
81
+ native.runtime = disabled ? "inactive"
82
+ : binding === undefined ? (native.configured ? "waiting" : "inactive")
83
+ : binding.reachable ? "active" : "degraded";
84
+ native.sessionPolicy = disabled || !binding ? null
85
+ : native.policySource === "installation-record" ? native.policy : binding.livePolicy;
86
+ if (binding) native.modes = binding.availableModes.filter(mode => mode !== "nextTurn");
87
+ }
88
+ }
@@ -0,0 +1,64 @@
1
+ import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
+ import { describeNativeReason } from "@agents-can-communicate/installer";
3
+
4
+ // Session diagnostics are read only here, never projected into agent context.
5
+ // Verify the owner generation against core before attributing any last attempt.
6
+ export async function updateNativeSessions(adapters, { service, status, root, now }) {
7
+ const owners = await listSessionBindings({ runtimeDir: root }).catch(() => []);
8
+ const transports = new Map();
9
+ for (const entry of adapters) {
10
+ const native = entry.nativeDelivery;
11
+ native.sessions = [];
12
+ if (native.reasonCode === "native_delivery_unsupported") continue;
13
+ for (const peer of status.participants.filter(p => p.harness === entry.adapterId)) {
14
+ const current = (await service.locateSession(peer.sessionId))?.record;
15
+ if (current?.state !== "open") continue;
16
+ const matches = owners.filter(owner => owner.accSessionId === peer.sessionId
17
+ && owner.generation === current.generation);
18
+ const owner = matches.length === 1 ? matches[0] : null;
19
+ if (!transports.has(peer.participantId)) {
20
+ transports.set(peer.participantId, await service.listDeliveryBindings({
21
+ participantId: peer.participantId, now, includeExpired: true }));
22
+ }
23
+ const binding = transports.get(peer.participantId).find(b => b.sessionId === peer.sessionId
24
+ && b.generation === current.generation && b.adapterId === entry.adapterId
25
+ && b.availableModes.includes("livePush"));
26
+ const runtime = native.policySource === "installation-record" && !native.configured
27
+ ? "inactive" : !binding ? "unbound"
28
+ : Date.parse(binding.leaseUntil) > Date.parse(now) ? "active" : "degraded";
29
+ native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
30
+ presence: peer.presence, runtime,
31
+ lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
32
+ }
33
+ }
34
+ }
35
+
36
+ const failures = {
37
+ client_process_unknown: "the client process could not be identified; start a new client session",
38
+ handshake_failed: "the session handshake failed; check the client's integration/channel setup",
39
+ handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
40
+ handshake_version_mismatch: "the session and CLI versions differ; start a new client session",
41
+ session_generation_stale: "the session was replaced during the handshake",
42
+ };
43
+
44
+ function describeAttempt(attempt) {
45
+ if (attempt === null) return "no native binding attempt observed for this generation; "
46
+ + "check client hooks; an older hook runtime or a failed diagnostic write can also leave no record";
47
+ let detail;
48
+ if (attempt.state === "off") {
49
+ const source = attempt.policySource === "bootstrap-environment" ? "launch" : "installation";
50
+ detail = attempt.policyStatus === "missing" ? `${source} delivery policy was absent`
51
+ : attempt.policyStatus === "invalid" ? `${source} delivery policy was invalid`
52
+ : attempt.policyStatus === "unavailable" ? `${source} delivery policy could not be read`
53
+ : `${source} delivery policy was off`;
54
+ } else if (attempt.state === "active") detail = "local handshake succeeded";
55
+ else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
56
+ return `last attempt ${attempt.at}: ${detail}`;
57
+ }
58
+
59
+ export function nativeSessionLines(adapters) {
60
+ return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
61
+ ` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
62
+ + `${session.runtime === "active" ? "local transport active" : session.runtime}; `
63
+ + describeAttempt(session.lastAttempt)));
64
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,6 +10,7 @@ export const SAFE_OFFER_ERROR_CODES = Object.freeze([
10
10
  "recipient_unavailable",
11
11
  "transport_error",
12
12
  "transport_rejected",
13
+ "transport_permission_denied",
13
14
  "unsupported_client_version",
14
15
  ]);
15
16
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
2
2
 
3
3
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
4
- "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
4
+ "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
5
5
  "unsupported_client_version"]);
6
6
  const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
7
7
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,43 @@
1
+ import { storeNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
+ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer";
3
+
4
+ import { establishNativeBinding, LIVE_POLICIES, livePolicyFrom } from "./native-binding.mjs";
5
+
6
+ // Reserve time for turn projection, status and output before optional disk I/O.
7
+ export function nativeDiagnosticDeadline(hookDeadline) {
8
+ const now = Date.now();
9
+ return hookDeadline - now >= 750 ? now + 250 : null;
10
+ }
11
+
12
+ // The diagnostic has its own bounded writer; it never rewrites hook ownership.
13
+ // A failed or slow disk leaves the functional hook result intact.
14
+ export async function bindNative({ adapter, event, hookBinding, clientVersion, platform,
15
+ context, paths, deadline }) {
16
+ const assertBudget = () => {
17
+ if (Date.now() >= deadline) throw new Error("hook deadline expired");
18
+ };
19
+ assertBudget();
20
+ const policySource = adapter?.nativeDelivery?.policySource === "installation-record"
21
+ ? "installation-record" : "bootstrap-environment";
22
+ const raw = context.env?.ACC_NATIVE_DELIVERY_POLICY;
23
+ const { policy, policyStatus } = policySource === "installation-record"
24
+ ? await readInstalledLivePolicyState({ dataHome: context.dataHome, adapterId: adapter.id })
25
+ : { policy: livePolicyFrom(context.env), policyStatus: raw === undefined ? "missing"
26
+ : !LIVE_POLICIES.includes(raw) ? "invalid" : raw === "off" ? "off" : "enabled" };
27
+ assertBudget();
28
+ const result = await establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
29
+ livePolicy: policy, service: context.service, runtimeDir: paths.root,
30
+ clock: context.service.clock, env: context.env,
31
+ timeoutMs: Math.max(1, Math.min(750, deadline - Date.now())) });
32
+ const diagnosticDeadline = nativeDiagnosticDeadline(deadline);
33
+ if (adapter.nativeDelivery && diagnosticDeadline !== null) {
34
+ await storeNativeAttempt({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
35
+ accSessionId: hookBinding.accSessionId, generation: hookBinding.generation, deadlineAt: diagnosticDeadline, nativeAttempt: {
36
+ at: context.service.clock.now(), event: event.kind, state: result.state,
37
+ reasonCode: result.reasonCode, policy, policySource, policyStatus,
38
+ clientProcess: Number.isInteger(hookBinding.clientPid) && hookBinding.clientPid > 0
39
+ ? "identified" : "unknown",
40
+ } }).catch(() => {});
41
+ }
42
+ return result;
43
+ }