agents-can-communicate 0.8.1 → 0.8.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 (54) hide show
  1. package/docs/ADAPTER_AUTHORING.md +14 -0
  2. package/docs/CAPABILITIES.md +1 -1
  3. package/docs/CLI.md +16 -0
  4. package/docs/CONFIGURATION.md +73 -8
  5. package/docs/GETTING_STARTED.md +6 -3
  6. package/docs/HOW_IT_WORKS.md +3 -1
  7. package/docs/SECURITY_MODEL.md +12 -1
  8. package/docs/TROUBLESHOOTING.md +32 -5
  9. package/docs/UPGRADING.md +40 -1
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +7 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +204 -0
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +45 -10
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +9 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +3 -1
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-observed-directories.mjs +62 -0
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +41 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +5 -4
  24. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +64 -12
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +7 -0
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +16 -2
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  33. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +9 -4
  35. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +11 -4
  36. package/node_modules/@agents-can-communicate/cli/src/main.mjs +44 -6
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +3 -1
  38. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +20 -10
  39. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/core/src/status.mjs +12 -0
  41. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +9 -0
  44. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +10 -3
  46. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  47. package/node_modules/@agents-can-communicate/installer/src/receiver-sockets.mjs +19 -0
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +30 -4
  52. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +56 -25
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +6 -2
  54. package/package.json +1 -1
@@ -5,11 +5,13 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
 
8
- import { bakeSkillCommand, blankJson, defaultAntigravityRelay, ownVersion, removeIfEmpty,
9
- stampPluginVersion, writeCliShim, writeForeignJson, writeHookShim }
8
+ import { bakeSkillCommand, blankJson, defaultAntigravityRelay, isShellWord, ownVersion,
9
+ removeIfEmpty, stampPluginVersion, writeCliShim, writeForeignJson, writeHookShim }
10
10
  from "@agents-can-communicate/adapter-sdk";
11
11
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
12
12
 
13
+ import { agySettingsPath, cliWrapperPath, ensureAllowRule, inspectAllowRule,
14
+ withdrawAllowRule } from "./allow-rule.mjs";
13
15
  import { relayShimPath, runningRelays, stopRelays } from "./relays.mjs";
14
16
 
15
17
  const run = promisify(execFile);
@@ -349,7 +351,9 @@ async function installSkillPlugin(context) {
349
351
  await cp(bundle, plugin, { recursive: true });
350
352
  await stampPluginVersion({ file: path.join(plugin, "plugin.json"),
351
353
  version: await ownVersion(import.meta.url), io: { readFile, writeFile } });
352
- await bakeSkillCommand({ root: plugin, cliShim });
354
+ // Bare when the path allows it: 1.2.12 matched no allow rule against a
355
+ // command whose first word was quoted (issue #214).
356
+ await bakeSkillCommand({ root: plugin, cliShim, bareWhenSafe: true });
353
357
  await agyFor(context)(["plugin", "install", plugin], { home, env: context.env });
354
358
  } finally {
355
359
  await rm(stage, { recursive: true, force: true });
@@ -399,10 +403,13 @@ export async function installAntigravity(context) {
399
403
  await writeFile(createdMarker(context, file), `${file}\n`);
400
404
  }
401
405
  await installSkillPlugin(context);
402
- const changes = [shimDir(home), file, pluginInstallPath(home), vendorManifestPath(home)];
406
+ // After the skill, whose wrapper the rule names. Always on (#214, 2026-09-27).
407
+ const allow = await ensureAllowRule(context);
408
+ const changes = [shimDir(home), file, pluginInstallPath(home), vendorManifestPath(home),
409
+ ...allow.changes];
403
410
 
404
- const diagnostics = [];
405
- const needsAction = [];
411
+ const diagnostics = [...allow.diagnostics];
412
+ const needsAction = [...allow.needsAction];
406
413
  if (locationOf(context) === "workspace") {
407
414
  diagnostics.push("a workspace registration loads only while this project is an open "
408
415
  + `Antigravity workspace; in print mode pass --add-dir ${context.antigravityWorkspace}`);
@@ -524,8 +531,14 @@ export async function uninstallAntigravity(context) {
524
531
  await rm(manifestMarker, { force: true });
525
532
  }
526
533
 
534
+ // The allow rule ACC recorded adding, and nothing else in the operator's file.
535
+ const allow = await withdrawAllowRule(context);
536
+ changes.push(...allow.changes);
537
+ diagnostics.push(...allow.diagnostics);
538
+
527
539
  if (!keep.includes(shimDir(home))) await rm(shimDir(home), { recursive: true, force: true });
528
- return { ok: true, changes, diagnostics };
540
+ return { ok: true, changes, diagnostics,
541
+ ...(allow.needsAction.length > 0 ? { needsAction: allow.needsAction } : {}) };
529
542
  }
530
543
 
531
544
  /**
@@ -560,6 +573,12 @@ export async function detectAntigravity(context) {
560
573
  const needsAction = [];
561
574
  const blocked = locationChoice(context);
562
575
  const imported = await exists(importedPluginPath(context.home));
576
+ // Read from files only, so it is known for a client ACC did not run. It is
577
+ // the client's own approval control over what a woken session can answer.
578
+ const commandApproval = await inspectAllowRule(context);
579
+ const approval = { commandApproval,
580
+ inboundDelivery: { state: commandApproval.state, diagnostic: commandApproval.diagnostic } };
581
+ diagnostics.push(commandApproval.diagnostic);
563
582
  // The installer runs every adapter's detect and passes a null version for a
564
583
  // client it did not find. Asking agy then would spawn a binary the installer
565
584
  // could not run a moment ago, or - where it does exist but did not answer -
@@ -568,7 +587,8 @@ export async function detectAntigravity(context) {
568
587
  diagnostics.push("acc hooks not registered: Antigravity CLI was not found, so it was "
569
588
  + "not asked what it has loaded");
570
589
  if (blocked !== null) diagnostics.push(blocked.reason);
571
- return { ok: true, changes: [], diagnostics, ...(blocked === null ? {} : { blocked }) };
590
+ return { ok: true, changes: [], diagnostics, ...approval,
591
+ ...(blocked === null ? {} : { blocked }) };
572
592
  }
573
593
  let readback = null;
574
594
  try {
@@ -622,11 +642,22 @@ export async function detectAntigravity(context) {
622
642
  + "first run and loads its skill, but none of its hook names, so no ACC hook runs here "
623
643
  + "(issue #176)");
624
644
  }
645
+ // Every install writes the rule, so where ACC's wrapper is on disk a missing
646
+ // or unreadable rule is an install to finish, whatever the delivery policy.
647
+ if (await exists(cliWrapperPath(context.home))) {
648
+ if (commandApproval.state === "prompts") {
649
+ needsAction.push(`acc install --adapter antigravity # ${commandApproval.rule} is missing `
650
+ + `from permissions.allow in ${commandApproval.file}, so each ACC command waits for `
651
+ + "approval");
652
+ } else if (commandApproval.state === "unreadable") {
653
+ needsAction.push(commandApproval.diagnostic);
654
+ }
655
+ }
625
656
  if (blocked !== null) {
626
657
  diagnostics.push(blocked.reason);
627
658
  needsAction.push(blocked.reason);
628
659
  }
629
- return { ok: true, changes: [], diagnostics,
660
+ return { ok: true, changes: [], diagnostics, ...approval,
630
661
  ...(blocked === null ? {} : { blocked }),
631
662
  ...(needsAction.length > 0 ? { needsAction } : {}) };
632
663
  }
@@ -678,8 +709,12 @@ export function planAntigravityInstall(context) {
678
709
  // installer leaves to the adapter - so the installer never deletes the copy
679
710
  // ahead of `agy plugin uninstall` and strands its manifest entry. That exact
680
711
  // ordering, with the shim directory, once left `{}` behind in the user's home.
712
+ // The operator's own settings, edited for ACC's allow rule wherever one can
713
+ // match: a wrapper path that needs quotes gets none.
681
714
  const plugin = [{ path: pluginInstallPath(context.home), kind: "merge" },
682
- { path: vendorManifestPath(context.home), kind: "merge" }];
715
+ { path: vendorManifestPath(context.home), kind: "merge" },
716
+ ...(isShellWord(cliWrapperPath(context.home))
717
+ ? [{ path: agySettingsPath(context.home), kind: "merge" }] : [])];
683
718
  if (locationChoice(context) !== null) {
684
719
  return [shim, ...plugin,
685
720
  { path: globalHooksPath(context.home), kind: "merge" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,6 +5,8 @@ import certification from "../certification.json" with { type: "json" };
5
5
  import { denyOutcome, injectOutcome, injectStartOwnerOutcome, normalizeClaudeHook } from "./hooks.mjs";
6
6
  import { MIN_VERSION, PROTOCOL_CONTRACT, bindNativeSession, offerMessage, planNativeActivation,
7
7
  probeNativeDelivery, refreshNativeSession, retireNativeSession } from "./inbox-delivery.mjs";
8
+ import { observedInboxDirectories } from "./inbox-observed-directories.mjs";
9
+ import { inboxSocketDirectories } from "./inbox-socket-directories.mjs";
8
10
  import { planClaudeInstall, detectClaude, installClaudePlugin, uninstallClaudePlugin } from "./install.mjs";
9
11
 
10
12
  export const CLAUDE_CODE_VERSION = "2.1.233";
@@ -92,6 +94,13 @@ export function createClaudeCodeAdapter() {
92
94
  refreshNativeSession,
93
95
  retireNativeSession,
94
96
  offerMessage,
97
+ // Where a sender connects to wake a session; a sandboxed sender is allowed these:
98
+ // the directories this environment implies, and those of every inbox bound here.
99
+ inboundSockets: context => [
100
+ ...inboxSocketDirectories({ env: context?.env, platform: context?.platform, uid: context?.uid }),
101
+ // Ownership is this process's own uid, never the context's naming override.
102
+ ...observedInboxDirectories({ stateRoot: context?.stateRoot }),
103
+ ],
95
104
 
96
105
  denyOutcome,
97
106
  injectOutcome,
@@ -21,6 +21,8 @@ const own = info => typeof process.getuid !== "function" || info.uid === process
21
21
  const invalid = () => new Error("invalid Claude inbox endpoint registration");
22
22
 
23
23
  export const newEndpointId = () => `claude_inbox_${randomBytes(16).toString("hex")}`;
24
+ // Where, under a workspace runtime directory, the records live.
25
+ export const ENDPOINTS_DIRECTORY = "claude-inbox-endpoints";
24
26
 
25
27
  function valid(record) {
26
28
  return record !== null && typeof record === "object" && !Array.isArray(record)
@@ -41,7 +43,7 @@ async function directory(runtimeDir, create = false) {
41
43
  if (!absolute(runtimeDir)) throw invalid();
42
44
  if (create) await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
43
45
  const root = await realpath(runtimeDir);
44
- const dir = path.join(root, "claude-inbox-endpoints");
46
+ const dir = path.join(root, ENDPOINTS_DIRECTORY);
45
47
  if (create) await mkdir(dir, { mode: 0o700 }).catch(error => {
46
48
  if (error.code !== "EEXIST") throw error;
47
49
  });
@@ -0,0 +1,62 @@
1
+ import { closeSync, constants, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { ENDPOINTS_DIRECTORY } from "./inbox-endpoint.mjs";
5
+
6
+ // The inbox directories of Claude Code sessions ACC has bound on this machine,
7
+ // read from the adapter's own endpoint records in every workspace under ACC's
8
+ // state root (review of #217). The environment-derived directories cover
9
+ // sessions started from the installer's environment; a session started with
10
+ // its own absolute CLAUDE_CODE_TMPDIR or XDG_RUNTIME_DIR binds elsewhere, and
11
+ // the hook that bound it wrote down where.
12
+ //
13
+ // Synchronous: install, doctor and the managed refresh compose the grant list
14
+ // while building one context, once per command. Every workspace and every
15
+ // matching record is read - a count cap dropped whatever sorted past it, and
16
+ // doctor then called the profile configured (review of #217) - and each read
17
+ // is bounded to one small record. Nothing here throws; a record that cannot be
18
+ // read or cannot be a Claude Code inbox adds nothing. A grant widens the Codex sandbox, so a directory is taken only when
19
+ // it is named as Claude Code names its inbox directories (2.1.283:
20
+ // `cc-socks` or `cc-socks-<uid>`), whatever a file on disk says.
21
+
22
+ const RECORD = /^claude_inbox_[a-f0-9]{32}\.json$/;
23
+ const INBOX_DIRECTORY = /^cc-socks(?:-\d+)?$/;
24
+ const MAX_BYTES = 8_192;
25
+
26
+ const list = directory => {
27
+ try {
28
+ return readdirSync(directory).sort();
29
+ } catch {
30
+ return [];
31
+ }
32
+ };
33
+
34
+ function socketPathIn(file, uid) {
35
+ let fd;
36
+ try {
37
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
38
+ const info = fstatSync(fd);
39
+ if (!info.isFile() || (uid !== null && info.uid !== uid) || info.size > MAX_BYTES) return null;
40
+ return JSON.parse(readFileSync(fd, "utf8"))?.socketPath ?? null;
41
+ } catch {
42
+ return null;
43
+ } finally {
44
+ if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
45
+ }
46
+ }
47
+
48
+ export function observedInboxDirectories({ stateRoot, uid = process.getuid?.() ?? null } = {}) {
49
+ if (typeof stateRoot !== "string" || !path.isAbsolute(stateRoot)) return [];
50
+ const workspaces = path.join(stateRoot, "workspaces");
51
+ const found = new Set();
52
+ for (const workspace of list(workspaces)) {
53
+ const records = path.join(workspaces, workspace, ENDPOINTS_DIRECTORY);
54
+ for (const name of list(records).filter(item => RECORD.test(item))) {
55
+ const socketPath = socketPathIn(path.join(records, name), uid);
56
+ if (typeof socketPath !== "string" || !path.isAbsolute(socketPath) || socketPath.includes("\0")) continue;
57
+ const directory = path.dirname(path.normalize(socketPath));
58
+ if (INBOX_DIRECTORY.test(path.basename(directory))) found.add(directory);
59
+ }
60
+ }
61
+ return [...found].sort();
62
+ }
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+
3
+ // Where Claude Code binds a session's inbox socket, read from the 2.1.283
4
+ // binary (2026-09-27):
5
+ //
6
+ // `${XDG_RUNTIME_DIR || CLAUDE_CODE_TMPDIR || "/tmp"}/cc-socks/<pid>.sock`
7
+ //
8
+ // and, when that path is longer than 103 bytes, `/tmp/cc-socks-<uid>/<pid>.sock`
9
+ // (`$PREFIX/tmp` when TERMUX_VERSION is set). Claude Code's own list of default
10
+ // socket directories adds `/run/user/<uid>/cc-socks` on Linux. Observed on
11
+ // macOS: `/tmp/cc-socks/<pid>.sock`.
12
+ //
13
+ // A sender in a sandbox - a Codex shell running `acc reply` - has to be allowed
14
+ // to connect here, or every live offer to a Claude Code session fails with
15
+ // EPERM and waits for the recipient's next turn. The grant is a directory; the
16
+ // adapter still checks Claude Code's session registry before every write, so a
17
+ // wider grant cannot wake a reused pid.
18
+ //
19
+ // A relative override is resolved against the Claude Code process's own working
20
+ // directory, which no installer can know, so it is left out. Native Windows
21
+ // serves the inbox on a named pipe, which no Unix socket grant covers.
22
+
23
+ const hostOf = platform => String(platform ?? process.platform).split("-")[0];
24
+ const set = value => typeof value === "string" && value !== "";
25
+
26
+ export function inboxSocketDirectories({ env = {}, platform, uid } = {}) {
27
+ const host = hostOf(platform);
28
+ if (host === "win32") return [];
29
+ const user = Number.isInteger(uid) ? uid : (process.getuid?.() ?? 0);
30
+ // Claude Code's `||`: an empty value falls through to the next one.
31
+ const root = [env.XDG_RUNTIME_DIR, env.CLAUDE_CODE_TMPDIR].find(set) ?? "/tmp";
32
+ const fallbackRoot = set(env.TERMUX_VERSION) && set(env.PREFIX)
33
+ ? path.join(env.PREFIX, "tmp") : "/tmp";
34
+ const directories = [
35
+ path.join(root, "cc-socks"),
36
+ "/tmp/cc-socks",
37
+ ...(host === "linux" ? [`/run/user/${user}/cc-socks`] : []),
38
+ path.join(fallbackRoot, `cc-socks-${user}`),
39
+ ];
40
+ return [...new Set(directories.filter(directory => path.isAbsolute(directory)))];
41
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import { allowOutcome, denyOutcome, injectOutcome, normalizeCodexHook }
11
11
  from "./hooks.mjs";
12
12
  import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninstall,
13
13
  uninstallCodexPlugin } from "./install.mjs";
14
+ import { controlSocket } from "./live-permissions.mjs";
14
15
  // Public native wiring is enabled only with installed-product capture evidence.
15
16
 
16
17
  // Re-exported so existing importers of CODEX_QUEUE_MINIMUM keep resolving; the
@@ -67,6 +68,8 @@ export function createCodexAdapter() {
67
68
  },
68
69
  probeNativeDelivery, planNativeActivation, bindNativeSession,
69
70
  refreshNativeSession, retireNativeSession, offerMessage,
71
+ // A Codex sender reaches another Codex session through its control socket.
72
+ inboundSockets: context => [controlSocket(context)],
70
73
  ...createCodexMaintenance(),
71
74
  ...createCodexServiceSetup(),
72
75
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -161,7 +161,7 @@ const sandboxReview = (config, file, stateRoot) =>
161
161
 
162
162
  export async function installCodexPlugin({ home, agentsHome = home,
163
163
  codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, keepPreviousVersion = null,
164
- requestedLivePolicy, livePolicy, clientVersion, platform }) {
164
+ requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets }) {
165
165
  // Read before writing, so a manifest that will not parse is found before a
166
166
  // plugin tree is laid down that nothing will then be able to remove.
167
167
  const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
@@ -182,7 +182,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
182
182
  + "remove it and install again", { config });
183
183
  }
184
184
  const permissions = prepareLivePermissions(foreign.source, { home, codexHome, stateRoot,
185
- file: config, requestedLivePolicy, livePolicy, clientVersion, platform });
185
+ file: config, requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets });
186
186
  const theirSandbox = permissions.skipLegacy || inspectConfig(permissions.source, config).sandbox;
187
187
  const permissionActions = permissions.status?.state === "unverified" ? [permissions.status.diagnostic] : [];
188
188
 
@@ -338,7 +338,8 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
338
338
  }
339
339
 
340
340
  export async function detectCodex({ home, agentsHome = home,
341
- codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery }) {
341
+ codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery,
342
+ receiverSockets }) {
342
343
  const marketplace = await readJson(marketplacePath(agentsHome), null);
343
344
  const published = (marketplace?.plugins ?? []).some(entry => entry.name === PLUGIN_NAME);
344
345
  const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
@@ -347,7 +348,7 @@ export async function detectCodex({ home, agentsHome = home,
347
348
  const cached = await stat(cachePath(codexHome))
348
349
  .then(() => true).catch(() => false);
349
350
  const outgoingDelivery = outgoingStatus(config, { home, codexHome, stateRoot,
350
- file: configPath(codexHome), clientVersion, platform });
351
+ file: configPath(codexHome), clientVersion, platform, receiverSockets });
351
352
  // Saved trust is not readiness. Codex compares every current definition's
352
353
  // hash and can disable a trusted hook. Even a commented or stale single record
353
354
  // previously suppressed this check. Leave verification to Codex's /hooks;
@@ -1,5 +1,6 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import path from "node:path";
2
- import { channelSocketDirectory, tomlString } from "@agents-can-communicate/adapter-sdk";
3
+ import { tomlString } from "@agents-can-communicate/adapter-sdk";
3
4
  import { compareVersions } from "./app-server-client.mjs";
4
5
  import { addPermissions, inspectPermissions } from "./permission-ownership.mjs";
5
6
  import { scanConfig } from "./toml-scan.mjs";
@@ -23,8 +24,12 @@ const configured = (source, context) => {
23
24
  && value(["features", "network_proxy"]) === "true"
24
25
  && value(["permissions", profile, "network", "enabled"]) === "true"
25
26
  && literal(value(["permissions", profile, "filesystem", context.stateRoot])) === "write"
26
- && sockets(context).every(socket => literal(value(["permissions", profile, "network", "unix_sockets", socket])) === "allow");
27
+ && missingSockets(entries, profile, context).length === 0;
27
28
  };
29
+ // The grants a profile lacks for the sockets ACC delivers to.
30
+ const missingSockets = (entries, profile, context) => sockets(context).filter(socket =>
31
+ literal(entries.find(entry => !entry.header
32
+ && equal(entry.keys, ["permissions", profile, "network", "unix_sockets", socket]))?.value) !== "allow");
28
33
 
29
34
  const hasCustomPolicy = (source, context) => {
30
35
  const ownership = inspectPermissions(source);
@@ -48,10 +53,37 @@ const hasCustomPolicy = (source, context) => {
48
53
  || proxies.length > 1 || proxies.some(entry => entry.header || !["true", "false"].includes(entry.value));
49
54
  };
50
55
 
51
- // Match the channel's per-user temporary directory and Codex's actual home.
52
- // Grants cover only ACC's local channel namespace, never arbitrary Unix sockets.
53
- const sockets = context => [channelSocketDirectory(),
54
- path.join(context.codexHome ?? path.join(context.home, ".codex"), "app-server-control", "app-server-control.sock")];
56
+ // Where this adapter's own sessions receive: the app-server control socket in
57
+ // Codex's actual home.
58
+ export const controlSocket = context => path.join(context.codexHome ?? path.join(context.home, ".codex"),
59
+ "app-server-control", "app-server-control.sock");
60
+
61
+ // Codex 0.157.1 on macOS, measured with `codex sandbox -P` (2026-09-27): a
62
+ // granted path that exists when Codex builds the sandbox policy is compared in
63
+ // its resolved form, and one that does not is kept as written, while Seatbelt
64
+ // checks the path the kernel resolved. A `/tmp/cc-socks-501` grant made before
65
+ // that directory existed never matched `/private/tmp/cc-socks-501/<pid>.sock`;
66
+ // `/private/tmp/cc-socks-501` did, both before and after. So the existing
67
+ // parent is resolved the way the kernel will, and the last component is kept as
68
+ // spelled: a socket that is a link - Codex's own control socket - must keep
69
+ // naming the link, because its target changes with every daemon.
70
+ export function grantPath(target, realpath = realpathSync.native) {
71
+ const tail = [path.basename(target)];
72
+ for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
73
+ try {
74
+ return path.join(realpath(parent), ...tail);
75
+ } catch {
76
+ if (parent === path.dirname(parent)) return target;
77
+ tail.unshift(path.basename(parent));
78
+ }
79
+ }
80
+ }
81
+
82
+ // Every socket ACC delivers to - Codex's own and each receiving adapter's
83
+ // declaration, composed by the installer as `receiverSockets` - and nothing
84
+ // else: grants cover ACC's local receivers, never arbitrary Unix sockets.
85
+ const sockets = context => [...new Set([controlSocket(context), ...(context.receiverSockets ?? [])]
86
+ .map(socket => grantPath(socket, context.realpath)))].sort();
55
87
 
56
88
  export function outgoingStatus(source, context) {
57
89
  const state = inspectPermissions(source).state;
@@ -62,8 +94,10 @@ export function outgoingStatus(source, context) {
62
94
  : ready ? null : "sender_permissions_unverified";
63
95
  return { state: reasonCode === null ? "configured" : "unverified", reasonCode,
64
96
  setup: supported(context)
65
- ? "Codex outgoing setup: default workspace settings gain ACC state write access and an ACC local socket allowlist "
66
- + "through the network proxy (external network stays denied); custom policies are preserved. Start a new session after installation."
97
+ ? "Codex outgoing setup: default workspace settings gain ACC state write access and an allowlist of the "
98
+ + "local sockets ACC delivers to - ACC's own channel, the Codex control socket and other clients' session "
99
+ + "inboxes - through the network proxy (external network stays denied); custom policies are preserved. "
100
+ + "Start a new session after installation."
67
101
  : null,
68
102
  diagnostic: reasonCode === null
69
103
  ? "outgoing live delivery: local socket permissions configured for new sessions; active session overrides remain unverified"
@@ -73,8 +107,21 @@ export function outgoingStatus(source, context) {
73
107
  : custom
74
108
  ? "custom permission policy was preserved; manually allow ACC state and local sockets "
75
109
  + "through the network proxy in the effective workspace profile"
76
- : "ACC outgoing grants are absent; run acc install --adapter codex --delivery actionable, "
77
- + "then start a new session") };
110
+ : state === "owned"
111
+ ? `${incomplete(source, context)}; run acc install --adapter codex, then start a new session`
112
+ : "ACC outgoing grants are absent; run acc install --adapter codex --delivery actionable, "
113
+ + "then start a new session") };
114
+ }
115
+
116
+ // An owned profile ACC wrote for fewer receivers - 0.8.1 predates the Claude
117
+ // Code inbox grant - or for another state root. Reinstalling rewrites it.
118
+ function incomplete(source, context) {
119
+ const entries = scanConfig(source);
120
+ const profile = literal(entries.find(entry => !entry.header && equal(entry.keys, ["default_permissions"]))?.value);
121
+ const missing = typeof profile === "string" ? missingSockets(entries, profile, context) : [];
122
+ return missing.length > 0
123
+ ? `ACC outgoing grants do not cover every socket ACC delivers to; missing ${missing.join(", ")}`
124
+ : "ACC outgoing grants are out of date";
78
125
  }
79
126
 
80
127
  export function prepareLivePermissions(source, context) {
@@ -85,10 +132,15 @@ export function prepareLivePermissions(source, context) {
85
132
  // Once ACC owns a valid outgoing profile, turning incoming requests off must
86
133
  // not revoke the already approved ability to use ACC from the sandbox. A
87
134
  // fresh off install still creates no grants because it has no owned unit.
88
- if (!requested && owned.state === "owned") {
135
+ // An owned unit written for fewer receivers is brought up to date even so:
136
+ // the approval was for ACC's local sockets, and without this `acc install`
137
+ // under a recorded off could never repair a profile an older ACC wrote.
138
+ const stale = owned.state === "owned" && supported(context) && Boolean(context.stateRoot)
139
+ && !configured(source, context) && !hasCustomPolicy(owned.source, context);
140
+ if (!requested && owned.state === "owned" && !stale) {
89
141
  return { source, skipLegacy: true, status: outgoingStatus(source, context) };
90
142
  }
91
- if (!requested) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
143
+ if (!requested && !stale) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
92
144
  if (!supported(context) || !context.stateRoot) {
93
145
  return { source, skipLegacy: hasPermissions(scanConfig(source)), status: outgoingStatus(source, context) };
94
146
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -129,6 +129,13 @@ export function defineAdapter(manifest) {
129
129
  usage(`optional native method ${method} must be a function`, { id: manifest.id, method });
130
130
  }
131
131
  }
132
+ // `inboundSockets(context)` names the Unix socket paths - directories or
133
+ // sockets - a sender connects to when this adapter's sessions receive live
134
+ // delivery. A sandboxed sender's installer allows exactly these, so a value
135
+ // that is not a function would silently read as "nothing to allow".
136
+ if (manifest.inboundSockets !== undefined && typeof manifest.inboundSockets !== "function") {
137
+ usage("optional inboundSockets must be a function", { id: manifest.id });
138
+ }
132
139
  if (manifest.nativeDelivery !== undefined) {
133
140
  const client = manifest.client.certificationName ?? manifest.client.command;
134
141
  native.nativeDelivery = validateNativeDeliveryContract(manifest.nativeDelivery,
@@ -84,6 +84,15 @@ const PLACEHOLDER = "{{ACC}}";
84
84
  // shell's to interpret.
85
85
  const quote = value => `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
86
86
 
87
+ /**
88
+ * Whether a path is one shell word exactly as written: absolute, and made only
89
+ * of the POSIX portable filename characters and slashes. Nothing in it is
90
+ * expanded, split or quoted by a shell, so the unquoted text and the word a
91
+ * shell runs are the same string.
92
+ */
93
+ export const isShellWord = value => typeof value === "string"
94
+ && /^\/[A-Za-z0-9._/-]*$/.test(value);
95
+
87
96
  /**
88
97
  * Write the small shim a client's hook command points at.
89
98
  *
@@ -231,13 +240,18 @@ export const defaultCli = () => ownBinary("acc.mjs");
231
240
  * JSON, worked out the schema, and wrote records and events by hand - inventing
232
241
  * an event type, a harness name, and its own generation tokens. So the command
233
242
  * is pinned here for the same reason it is pinned in the shim.
243
+ *
244
+ * `bareWhenSafe` names the shim without quotes when it is one shell word. A
245
+ * client that matches allow rules against the command's first word needs it:
246
+ * Antigravity CLI 1.2.12 never matched a command whose program word was quoted,
247
+ * whatever rule was loaded (issue #214). A path that needs quotes keeps them.
234
248
  */
235
- export async function bakeSkillCommand({ root, cliShim }) {
249
+ export async function bakeSkillCommand({ root, cliShim, bareWhenSafe = false }) {
236
250
  if (typeof cliShim !== "string" || cliShim === "") {
237
251
  throw new AccError(EXIT.DATA, "refusing to bake an empty skill command", { cliShim });
238
252
  }
239
253
  // A managed data home is "Application Support"; an unquoted path splits.
240
- const command = quote(cliShim);
254
+ const command = bareWhenSafe && isShellWord(cliShim) ? cliShim : quote(cliShim);
241
255
  const baked = [];
242
256
  const walk = async directory => {
243
257
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -9,7 +9,7 @@ export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
9
9
  validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
10
10
  from "./native-delivery.mjs";
11
11
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
12
- export { assertRunner, bakeSkillCommand, defaultAntigravityRelay, defaultCli,
12
+ export { assertRunner, bakeSkillCommand, defaultAntigravityRelay, defaultCli, isShellWord,
13
13
  defaultRunner, removeInstalledTree, runnerExists, writeCliShim, writeHookShim }
14
14
  from "./hook-shim.mjs";
15
15
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@ export { describeNative } from "./native-delivery-status.mjs";
12
12
  import { nativeSessionLines, updateNativeSessions } from "./native-session-diagnostics.mjs";
13
13
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
14
14
  import { decisionOf } from "./install-delivery-consent.mjs";
15
- import { describePresence } from "./main.mjs";
15
+ import { describePresence, presenceBreakdown } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
18
  import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
@@ -116,7 +116,7 @@ export async function diagnoseAdapters({ options, runtime, detect = detectInstal
116
116
  env: runtime?.env ?? {} });
117
117
  // The same environment install reads, so detection probes the same client.
118
118
  const clients = clientContext(home, path.join(dataHome, "acc"),
119
- { env: runtime?.env ?? {} });
119
+ { env: runtime?.env ?? {}, platform: runtime?.platform });
120
120
  const adapters = ALL_ADAPTERS();
121
121
  const detected = await detect({ adapters, context: clients,
122
122
  probeTimeoutMs: probeTimeout(runtime?.env) });
@@ -192,8 +192,12 @@ export async function runDoctor({ options, context, runtime }) {
192
192
  // "unreadable protocol.json" and a person's first `acc doctor` in a new
193
193
  // project would answer that their store is broken.
194
194
  const started = await stat(path.join(root, "protocol.json")).then(() => true, () => false);
195
+ // Shaped like the storage report, so a reader of one finds every field of
196
+ // the other: without `trimmedThrough` the summary said "history from
197
+ // undefined" on every first doctor in a new project.
195
198
  const report = !started
196
- ? { healthy: true, blocked: [], corrupt: [], repaired: [] }
199
+ ? { healthy: true, blocked: [], corrupt: [], repaired: [], swept: 0, staged: 0, partials: 0,
200
+ retired: 0, trimmedThrough: null }
197
201
  : options.repair === true
198
202
  ? await repairFilesystemStore({ root, clock })
199
203
  : await diagnoseFilesystemStore({ root });
@@ -276,7 +280,8 @@ export async function runDoctor({ options, context, runtime }) {
276
280
  report.trimmedThrough === null ? null : `history from ${report.trimmedThrough}`,
277
281
  ].filter(Boolean);
278
282
  const staging = held.length === 0 ? "" : ` (${held.join(", ")})`;
279
- const text = [`store healthy${staging}; ${describePresence(status.counts)}; `
283
+ const text = [`store healthy${staging}; `
284
+ + `${describePresence(status.counts, presenceBreakdown(status.participants, ALL_ADAPTERS()))}; `
280
285
  + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
281
286
  ...adapters.filter(adapter => (adapter.present || adapter.installed)
282
287
  && adapter.nativeDelivery.reasonCode === "native_delivery_unsupported"