agents-can-communicate 0.4.0 → 0.4.2

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 (56) hide show
  1. package/README.md +102 -73
  2. package/bin/entrypoints/hook-output.mjs +7 -3
  3. package/docs/ADAPTER_AUTHORING.md +11 -1
  4. package/docs/ARCHITECTURE.md +9 -1
  5. package/docs/CAPABILITIES.md +1 -1
  6. package/docs/CLI.md +27 -5
  7. package/docs/GETTING_STARTED.md +20 -8
  8. package/docs/RELEASING.md +1 -1
  9. package/docs/SECURITY_MODEL.md +3 -1
  10. package/docs/TROUBLESHOOTING.md +69 -7
  11. package/docs/UPGRADING.md +51 -4
  12. package/docs/index.md +2 -2
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -3
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +10 -0
  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/native-delivery.mjs +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -9
  23. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +5 -3
  24. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +9 -0
  25. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -0
  28. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +64 -0
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +65 -0
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +1 -0
  31. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +17 -52
  33. package/node_modules/@agents-can-communicate/cli/src/help.mjs +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +22 -46
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +1 -0
  36. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +6 -1
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +1 -0
  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/cli/src/runtime-paths.mjs +1 -1
  41. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +43 -0
  45. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +30 -0
  46. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +28 -41
  47. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +36 -0
  49. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +23 -10
  50. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -1
  51. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +10 -3
  52. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +6 -1
  53. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  54. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  56. package/package.json +1 -1
@@ -32,7 +32,8 @@ export const clientContext = (home, stateRoot, { shell = null, env = {}, dataHom
32
32
  codexHome: typeof env.CODEX_HOME === "string" && env.CODEX_HOME !== ""
33
33
  ? env.CODEX_HOME : path.join(home, ".codex"),
34
34
  kimiHome: path.join(home, ".kimi-code"),
35
- grokHome: path.join(home, ".grok"),
35
+ grokHome: typeof env.GROK_HOME === "string" && env.GROK_HOME !== ""
36
+ ? env.GROK_HOME : path.join(home, ".grok"),
36
37
  // The user's login shell and PATH, for the optional native shell bootstrap:
37
38
  // which rc file could carry an ACC PATH block and which real executable a
38
39
  // shim would exec. Detection reads them; nothing here writes.
@@ -147,8 +148,8 @@ export function describeOutcome({ action, acted, failed = [], skipped = [],
147
148
  ...operations.filter(operation => operation.applied)
148
149
  .flatMap(operation => describeChanges(operation, home)),
149
150
  ...operations.filter(operation => operation.applied
150
- && typeof operation.deliveryDiagnostic === "string")
151
- .map(operation => ` ${operation.deliveryDiagnostic}`),
151
+ && typeof (operation.deliverySummary ?? operation.deliveryDiagnostic) === "string")
152
+ .map(operation => ` ${operation.deliverySummary ?? operation.deliveryDiagnostic}`),
152
153
  ...operations.filter(operation => operation.applied)
153
154
  .flatMap(operation => (operation.needsAction ?? [])
154
155
  .map(step => ` ${operation.adapterId}: ${step}`)),
@@ -202,42 +203,21 @@ export function actedOn(result) {
202
203
  const isInteractive = runtime => typeof runtime.isInteractive === "function"
203
204
  && runtime.isInteractive() === true;
204
205
 
205
- /**
206
- * One default-No question per eligible client, asked only after detection has
207
- * finished and only when a person is at both ends of the terminal.
208
- *
209
- * Written for someone who has never heard of this project. It used to open with
210
- * "Enable native live delivery" over a list of internal artefact names, which
211
- * told a first-time reader neither what they would gain nor what it would cost -
212
- * and left out the part that matters most: saying yes makes their own `claude`
213
- * start with the vendor's `--dangerously-load-development-channels` flag. A
214
- * consent prompt that hides the loudest consequence is not consent.
215
- *
216
- * So the order is the reader's, not the installer's: what changes for them,
217
- * what it costs, what it touches, and that "no" costs them nothing.
218
- */
219
- function questionFor(entry, context, { onlyEligible = false } = {}) {
220
- const bootstrap = entry.nativeDelivery.activationPlan.mechanisms
206
+ // One default-No question per supported client. Disclose token use and any
207
+ // repeated channel prompt; declining only promises a verified fallback.
208
+ function questionFor(entry) {
209
+ const bootstrap = entry.nativeDelivery.activationPlan?.mechanisms
221
210
  .find(mechanism => mechanism.kind === "shell-bootstrap") ?? null;
222
- // The presence of a flag is what makes this worth asking about at all: it is
223
- // why the client will warn at every start from now on. Which flag it is, and
224
- // that a shim adds it, are answers to a question nobody is asking here - the
225
- // shim `exec`s and is gone, so nothing runs "through" acc afterwards either.
226
- const flags = (bootstrap?.prefixArgs ?? []).filter(argument => argument.startsWith("--"));
227
- void context;
228
- // Why the default is No, and why only one client is being asked about, are
229
- // the two questions the bare prompt provokes and never answered: a `[y/N]`
230
- // reads as "not recommended" on its own, and a single question among four
231
- // installed clients reads as an omission. Both have the same short answer.
232
- const aside = ["experimental", ...(onlyEligible ? [`${entry.displayName} only`] : [])]
233
- .join("; ");
211
+ const warns = (bootstrap?.prefixArgs ?? []).some(argument => argument.startsWith("--"));
234
212
  return [
235
- `Let idle agents answer each other while you are away? (${aside})`,
236
- ...(flags.length === 0 || bootstrap === null
237
- ? [" Yes: they reply without waiting for you."]
238
- : [` Yes: they reply without waiting for you, and ${entry.displayName} asks you`,
239
- " to allow development channels every time it starts."]),
240
- " No: messages still arrive, at the session's next turn.",
213
+ `Let ${entry.displayName} answer peer requests while idle? (experimental)`,
214
+ " Yes: automatic turns can spend tokens without waiting for you.",
215
+ ...(warns ? [" Allow development channels when prompted; check the client's startup notice."] : []),
216
+ ...(entry.nativeDelivery.state !== "eligible"
217
+ ? [" Save consent now; delivery waits for an available client service."] : []),
218
+ entry.capabilities?.delivery?.nextTurn === true
219
+ ? " No: use next-turn hooks (when enabled) or acc inbox."
220
+ : " No: read messages with acc inbox; automatic next-turn delivery is unavailable.",
241
221
  ].join("\n");
242
222
  }
243
223
 
@@ -249,22 +229,18 @@ function questionFor(entry, context, { onlyEligible = false } = {}) {
249
229
  * client is off unless a person answers yes here, and a dry run or a
250
230
  * non-interactive stdin/stdout makes no interactive choice at all.
251
231
  */
252
- export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
232
+ export async function decideDelivery({ options, detected, recorded, runtime, dryRun }) {
253
233
  const explicit = options.delivery;
254
234
  if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
255
235
  throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
256
236
  }
257
237
  const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
258
- // Whether this client is the only one that could be asked at all. When it is,
259
- // the question says so, because otherwise being asked about one of four
260
- // installed clients looks like the other three were forgotten.
261
- const onlyEligible = detected
262
- .filter(candidate => candidate.nativeDelivery?.state === "eligible").length === 1;
263
238
  const deliveryByAdapter = {};
264
239
  const asked = [];
265
240
  let withheld = 0;
266
241
  for (const entry of detected) {
267
- const eligible = entry.nativeDelivery?.state === "eligible";
242
+ const eligible = entry.nativeDelivery?.state === "eligible"
243
+ || entry.nativeDelivery?.consentAvailable === true;
268
244
  const previous = livePolicyOf(recordedById.get(entry.adapterId));
269
245
  if (explicit !== undefined) {
270
246
  deliveryByAdapter[entry.adapterId] = explicit;
@@ -274,7 +250,7 @@ export async function decideDelivery({ options, detected, recorded, runtime, dry
274
250
  deliveryByAdapter[entry.adapterId] = "off";
275
251
  if (eligible) withheld += 1;
276
252
  } else {
277
- const yes = await runtime.confirm(questionFor(entry, context, { onlyEligible }),
253
+ const yes = await runtime.confirm(questionFor(entry),
278
254
  { input: runtime.input, output: runtime.output }) === true;
279
255
  deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
280
256
  asked.push(entry.adapterId);
@@ -332,7 +308,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
332
308
  // Decided only after detection, from the operator's explicit answer, the
333
309
  // recorded consent, or a default of off; never from inference.
334
310
  const decided = action === "install"
335
- ? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
311
+ ? await decideDelivery({ options, detected, recorded, runtime, dryRun })
336
312
  : { deliveryByAdapter: {}, asked: [], notes: [] };
337
313
  const plan = planInstallation({ adapters, detected, context, action, recorded,
338
314
  accVersion, allowDowngrade: options.downgrade === true, requested,
@@ -32,6 +32,7 @@ export async function runManagedUpdate({ options, runtime }) {
32
32
  }
33
33
  return writeControl(root, { ...current,
34
34
  auto: options.auto === undefined ? current.auto : options.auto === "on", pin,
35
+ autoPreference: options.auto === undefined ? current.autoPreference ?? current.auto : options.auto === "on",
35
36
  pending: options.pin !== undefined && current.pending?.version !== pin ? null : current.pending,
36
37
  notice: null });
37
38
  });
@@ -37,8 +37,12 @@ export async function installManaged({ packageRoot, managerRoot, dataHome, home,
37
37
  throw new Error("install from the active managed runtime; use acc update to switch versions");
38
38
  }
39
39
  const runtime = { version: candidate.version, root: candidate.root };
40
+ const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
41
+ // Full removal pauses workers without revoking the user's choice. Keep a
42
+ // partial-removal pause until explicit opt-in; this install may omit its failed target.
43
+ const auto = previous?.targets.length ? previous.auto : autoPreference;
40
44
  const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
41
- auto: previous?.auto ?? true, pin: previous?.pin ?? null,
45
+ auto, autoPreference, pin: previous?.pin ?? null,
42
46
  checkedAt: previous?.checkedAt ?? null, home,
43
47
  targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
44
48
  await writeControl(root, control);
@@ -66,6 +70,7 @@ export async function uninstallManaged({ managerRoot, apply }) {
66
70
  .map(operation => operation.adapterId));
67
71
  const targets = control.targets.filter(id => !removed.has(id));
68
72
  await writeControl(root, { ...control, targets,
73
+ autoPreference: control.autoPreference ?? control.auto,
69
74
  auto: targets.length > 0 && result.failed.length === 0 && control.auto,
70
75
  pending: targets.length > 0 ? control.pending : null, notice: null });
71
76
  return result;
@@ -94,6 +94,7 @@ export async function validateRuntime(root, runtime) {
94
94
  async function validateControl(root, value) {
95
95
  if (!value || value.schemaVersion !== 1 || !["ready", "activating"].includes(value.phase)
96
96
  || typeof value.auto !== "boolean" || !nullableString(value.pin)
97
+ || value.autoPreference !== undefined && typeof value.autoPreference !== "boolean"
97
98
  || !nullableString(value.checkedAt) || !nullableString(value.notice)
98
99
  || !nonempty(value.home) || !path.isAbsolute(value.home)
99
100
  || !Array.isArray(value.targets) || !value.targets.every(nonempty)
@@ -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.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
+ }
@@ -40,7 +40,7 @@ export function runtimePaths({ dataHome, workspaceId, workspaceRoots = [] }) {
40
40
  `${workspaceRoot} holds ACC's own state at ${path.join(dataHome, "acc")}, `
41
41
  + "so it cannot be a workspace. Run acc inside a project, or point "
42
42
  + "ACC_DATA_HOME outside this directory.",
43
- { root, dataHome, workspaceRoot });
43
+ { root, dataHome, workspaceRoot, reasonCode: "workspace_contains_runtime" });
44
44
  }
45
45
  }
46
46
  return Object.freeze(Object.fromEntries([["root", root],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
+ }
@@ -0,0 +1,30 @@
1
+ import { assertPortableId } from "@agents-can-communicate/protocol";
2
+
3
+ export const ownerHeader = binding => "ACC CLI (append): --session "
4
+ + assertPortableId(binding.accSessionId, "sessionId") + " --generation "
5
+ + assertPortableId(binding.generation, "generation");
6
+
7
+ export function ownerOnlyOutcome(inject, owner, budgetBytes) {
8
+ if (Buffer.byteLength(owner, "utf8") > budgetBytes) {
9
+ return { stdout: "", stderr: "acc: context budget cannot fit owner arguments; increase contextBudgetBytes" };
10
+ }
11
+ return { stdout: "", ...inject(owner) };
12
+ }
13
+
14
+ // This is ownership context only. No peer projection, receipt, new session or
15
+ // inherited environment belongs here. A client may deliver the line after the
16
+ // tool has closed its owner; owned CLI operations must still validate the pair.
17
+ export async function appendToolOwner(result, { event, binding, context, adapter }) {
18
+ if (event.kind !== "beforeTool" || result.decision !== "allow" || binding === null
19
+ || typeof adapter.injectToolOwnerOutcome !== "function") return result;
20
+ const current = await context.service.locateSession(binding.accSessionId, context.descriptor.id);
21
+ if (current?.record.state !== "open" || current.record.generation !== binding.generation) return result;
22
+
23
+ const owner = ownerHeader(binding);
24
+ const injected = adapter.injectToolOwnerOutcome({ owner, tool: event.tool });
25
+ if (injected === null) return result;
26
+ const fitted = ownerOnlyOutcome(() => injected, owner,
27
+ context.descriptor.policy?.contextBudgetBytes ?? 6_000);
28
+ return { ...result, stdout: fitted.stdout,
29
+ stderr: [result.stderr, fitted.stderr].filter(Boolean).join("\n") };
30
+ }
@@ -3,20 +3,20 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { realpath } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
- import { clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
6
+ import { clearNativeAttempt, clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
7
7
  from "@agents-can-communicate/adapter-sdk";
8
8
  import { createCoordinationService } from "@agents-can-communicate/core";
9
- import { readInstalledLivePolicy } from "@agents-can-communicate/installer";
10
- import { assertPortableId, createId } from "@agents-can-communicate/protocol";
9
+ import { AccError, createId } from "@agents-can-communicate/protocol";
11
10
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
12
11
  import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
13
12
  from "@agents-can-communicate/cli";
14
13
 
15
14
  import { resolveClientPid } from "./client-pid.mjs";
16
15
  import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
17
- import { establishNativeBinding, livePolicyFrom } from "./native-binding.mjs";
16
+ import { bindNative, nativeDiagnosticDeadline } from "./native-attempt.mjs";
18
17
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
19
18
  import { withSessionLifecycle } from "./session-lifecycle.mjs";
19
+ import { appendToolOwner, ownerHeader, ownerOnlyOutcome } from "./owner-context.mjs";
20
20
 
21
21
  // Kept cohesive above 300 lines because every handler shares one fail-open
22
22
  // hook boundary, binding lifecycle, and client-specific outcome contract.
@@ -53,13 +53,6 @@ function fitDegradation(projection, visibleDegradation, messages, budgetBytes) {
53
53
  return projection;
54
54
  }
55
55
 
56
- function ownerOnlyOutcome(adapter, owner, budgetBytes) {
57
- if (byteLength(owner) > budgetBytes) {
58
- return { stdout: "", stderr: "acc: context budget cannot fit owner arguments; increase contextBudgetBytes" };
59
- }
60
- return { stdout: "", ...adapter.injectOutcome?.(owner) };
61
- }
62
-
63
56
  // Declared by this process on the session it opens, so peers can tell an idle
64
57
  // session from a dead one. Only one of the four clients fires a heartbeat event,
65
58
  // so the rest refresh here: on every turn, and during a long one whenever the
@@ -227,15 +220,13 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
227
220
  // Only this hook's payload selected the binding. Supply its own pair as
228
221
  // trusted context, outside peer bodies, rather than exporting inheritable
229
222
  // credentials or teaching the CLI to guess from a public roster.
230
- const owner = "ACC CLI (append): --session "
231
- + assertPortableId(binding.accSessionId, "sessionId") + " --generation "
232
- + assertPortableId(binding.generation, "generation");
223
+ const owner = ownerHeader(binding);
233
224
  const totalBudget = context.descriptor.policy?.contextBudgetBytes ?? 6_000;
234
225
  // A peer can join after this prompt has begun. The current turn must already
235
226
  // have its own arguments when it needs inbox/reply, without reattaching or
236
227
  // waiting for another user prompt. Solo emits identity, not a peer notice.
237
228
  if (sync.solo && messages.length === 0) {
238
- return ownerOnlyOutcome(adapter, owner, totalBudget);
229
+ return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
239
230
  }
240
231
 
241
232
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
@@ -282,7 +273,9 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
282
273
  : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
283
274
  // Own claims make sync non-solo but produce no peer context. They cannot
284
275
  // remove this turn's identity or consume a nonexistent body separator.
285
- if (body === "" && messages.length === 0) return ownerOnlyOutcome(adapter, owner, totalBudget);
276
+ if (body === "" && messages.length === 0) {
277
+ return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
278
+ }
286
279
  const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
287
280
  if (projected === "") {
288
281
  return { stdout: "", stderr: messages.length === 0 ? ""
@@ -311,22 +304,6 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
311
304
  offerInputs: writableOffers };
312
305
  }
313
306
 
314
- // One bounded, fail-open native handshake for this exact session generation.
315
- // New adapters read durable consent from their installation record; legacy
316
- // adapters keep the environment exported by an owned shell bootstrap.
317
- async function bindNative({ adapter, event, hookBinding, clientVersion, platform, context, paths,
318
- deadline }) {
319
- assertHookBudget(deadline);
320
- const livePolicy = adapter?.nativeDelivery?.policySource === "installation-record"
321
- ? await readInstalledLivePolicy({ dataHome: context.dataHome, adapterId: adapter.id })
322
- : livePolicyFrom(context.env);
323
- assertHookBudget(deadline);
324
- return establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
325
- livePolicy, service: context.service, runtimeDir: paths.root,
326
- clock: context.service.clock, env: context.env,
327
- timeoutMs: Math.max(1, Math.min(750, deadline - Date.now())) });
328
- }
329
-
330
307
  const HANDLERS = {
331
308
  async sessionStart({ event, context, adapter, adapterId, binding, paths,
332
309
  readProcessTable, probeClientVersion, platform, deadline }) {
@@ -362,12 +339,12 @@ const HANDLERS = {
362
339
  branch: context.descriptor.git?.branch ?? null,
363
340
  };
364
341
  if (event.kind === "beforeTurn" && binding !== null) {
365
- // A real prompt may continue after finish closed this native owner's
366
- // record. Recheck after the probes: a CLI replacement can run outside
367
- // the native lifecycle lock. Never adopt that replacement's identity.
342
+ // A genuine prompt can resume after an ephemeral removal or a durable
343
+ // close. Recheck after probes: a CLI replacement can run outside the
344
+ // native lifecycle lock. Never adopt that replacement's identity.
368
345
  const previous = await context.service.locateSession(binding.accSessionId);
369
- if (previous?.record.state !== "closed"
370
- || previous.record.generation !== binding.generation) {
346
+ if (previous !== null && (previous.record.state !== "closed"
347
+ || previous.record.generation !== binding.generation)) {
371
348
  throw new Error("the completed hook owner changed during turn registration");
372
349
  }
373
350
  }
@@ -438,6 +415,11 @@ const HANDLERS = {
438
415
  await context.service.clearDeliveryBinding(owner);
439
416
  }
440
417
  await clearSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId });
418
+ const diagnosticDeadline = nativeDiagnosticDeadline(deadline);
419
+ if (diagnosticDeadline !== null) {
420
+ await clearNativeAttempt({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
421
+ ...binding, deadlineAt: diagnosticDeadline });
422
+ }
441
423
  return {};
442
424
  },
443
425
 
@@ -456,9 +438,11 @@ const HANDLERS = {
456
438
  return { ...turn, nativeBinding: started.nativeBinding };
457
439
  }
458
440
  const current = await context.service.locateSession(binding.accSessionId);
459
- if (current?.record.state === "closed" && current.record.generation === binding.generation) {
460
- // finish ends an ACC incarnation, not the native conversation. Only a
461
- // genuine new user turn can start another one; tool hooks cannot. Reuse
441
+ if (current === null
442
+ || current.record.state === "closed" && current.record.generation === binding.generation) {
443
+ // A solo detach removes its ephemeral record; a durable close retains it.
444
+ // Both end the ACC incarnation while the native conversation may continue.
445
+ // Only a genuine user turn can start another one; tool hooks cannot. Reuse
462
446
  // the crash-safe opening path, retaining its full published client facts
463
447
  // and its single native handshake rather than binding a second time.
464
448
  const started = await HANDLERS.sessionStart(input);
@@ -575,9 +559,10 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
575
559
  const binding = await loadSessionBinding({ runtimeDir: context.paths.root,
576
560
  harnessSessionId: event.sessionId });
577
561
  assertHookBudget(deadline);
578
- return handler === undefined ? {} : handler({ event, context, adapter, adapterId,
562
+ const result = handler === undefined ? {} : await handler({ event, context, adapter, adapterId,
579
563
  binding, paths: context.paths,
580
564
  readProcessTable, probeClientVersion, platform, deadline });
565
+ return appendToolOwner(result, { event, binding, context, adapter });
581
566
  };
582
567
  const work = lifecycle
583
568
  ? withSessionLifecycle({ root: context.paths.root, sessionId: event.sessionId,
@@ -621,6 +606,8 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
621
606
  return await Promise.race([execute(), budget]);
622
607
  } catch (error) {
623
608
  return { ...fallback, failed: true, reason: error.message,
609
+ ...(error instanceof AccError && error.details?.reasonCode === "workspace_contains_runtime"
610
+ ? { failureCode: "workspace_contains_runtime" } : {}),
624
611
  ...(Date.now() >= deadline ? { timedOut: true } : {}) };
625
612
  } finally {
626
613
  clearTimeout(timer);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,36 @@
1
+ // Shared presentation of observed delivery facts. Vendor probes provide closed
2
+ // reasons; installation and doctor must not turn a version floor into readiness.
3
+ const REASONS = Object.freeze({
4
+ native_delivery_unsupported: "this adapter has no native delivery channel",
5
+ platform_not_captured: "native delivery is not verified on this platform",
6
+ version_unavailable: "the client version could not be verified",
7
+ prerelease_not_captured: "this client version is not a verified stable release",
8
+ below_minimum_version: "the client is below the native delivery minimum version",
9
+ known_bad_version: "this client version has a known native delivery failure",
10
+ native_endpoint_unavailable: "the client's local delivery service is unavailable",
11
+ native_session_unavailable: "the local delivery service has no loaded client session",
12
+ feature_probe_failed: "the native protocol probe did not succeed",
13
+ probe_timeout: "the native protocol probe timed out",
14
+ probe_version_mismatch: "the local service and CLI versions differ",
15
+ protocol_mismatch: "the local service did not confirm the required protocol",
16
+ unsupported_shell: "automatic launch setup requires zsh",
17
+ });
18
+
19
+ export function describeNativeReason(reason, { clientVersion, minimumVersion } = {}) {
20
+ if (reason === "below_minimum_version" && clientVersion && minimumVersion) {
21
+ return `client ${clientVersion} needs version ${minimumVersion} or newer for native delivery`;
22
+ }
23
+ return REASONS[reason] ?? reason ?? "readiness is unverified";
24
+ }
25
+ export const describeDeliveryFallback = entry => entry.capabilities?.delivery?.nextTurn === true
26
+ ? "next-turn hooks (when enabled) or acc inbox" : "acc inbox";
27
+
28
+ export function describeInstallDelivery(entry, policy, effectivePolicy) {
29
+ const state = policy === "off" ? "off" : effectivePolicy === "off"
30
+ ? `consent saved (${policy}); not active` : `enabled (${policy}); waiting for a verified session`;
31
+ const reason = entry.nativeDelivery?.reasonCode;
32
+ return `${entry.displayName ?? entry.adapterId} live delivery: ${state}`
33
+ + (reason ? `; ${describeNativeReason(reason, { clientVersion: entry.version,
34
+ minimumVersion: entry.nativeDelivery?.eligibility?.minimumVersion })}` : "")
35
+ + `; fallback: ${describeDeliveryFallback(entry)}`;
36
+ }