agents-can-communicate 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +112 -91
  2. package/SECURITY.md +8 -5
  3. package/bin/acc-bootstrap.mjs +5 -54
  4. package/bin/acc-claude-channel.mjs +8 -175
  5. package/bin/acc-hook.mjs +4 -133
  6. package/bin/acc-mcp.mjs +4 -59
  7. package/bin/acc-update-worker.mjs +8 -0
  8. package/bin/acc.mjs +5 -39
  9. package/bin/entrypoints/acc-bootstrap.mjs +47 -0
  10. package/bin/entrypoints/acc-claude-channel.mjs +149 -0
  11. package/bin/entrypoints/acc-hook.mjs +58 -0
  12. package/bin/entrypoints/acc-mcp.mjs +70 -0
  13. package/bin/entrypoints/acc.mjs +51 -0
  14. package/bin/entrypoints/claude-channel-stdio.mjs +29 -0
  15. package/bin/entrypoints/hook-output.mjs +73 -0
  16. package/docs/ADAPTER_AUTHORING.md +62 -24
  17. package/docs/ARCHITECTURE.md +79 -20
  18. package/docs/CAPABILITIES.md +52 -30
  19. package/docs/CLI.md +164 -35
  20. package/docs/CONCEPTS.md +97 -93
  21. package/docs/CONFIGURATION.md +66 -45
  22. package/docs/DESIGN_DECISIONS.md +24 -15
  23. package/docs/GETTING_STARTED.md +124 -118
  24. package/docs/GLOSSARY.md +12 -6
  25. package/docs/HOW_IT_WORKS.md +136 -229
  26. package/docs/MCP.md +98 -31
  27. package/docs/PROTOCOL.md +76 -14
  28. package/docs/RELEASING.md +128 -86
  29. package/docs/SECURITY_MODEL.md +52 -15
  30. package/docs/TROUBLESHOOTING.md +88 -18
  31. package/docs/UPGRADING.md +119 -0
  32. package/docs/WHY_ACC.md +19 -16
  33. package/docs/index.md +40 -24
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +112 -9
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +6 -6
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +2 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -13
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +220 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product-evidence.json +1549 -0
  41. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product.json +27 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product-evidence.json +1549 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product.json +27 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport-evidence.json +259 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport.json +21 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product-evidence.json +1549 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product.json +27 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product-evidence.json +1549 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product.json +27 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport-evidence.json +259 -0
  51. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport.json +21 -0
  52. package/node_modules/@agents-can-communicate/adapter-codex/package.json +14 -2
  53. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +111 -8
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +26 -44
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +70 -17
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +231 -0
  57. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +1 -1
  58. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +71 -63
  59. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +107 -103
  60. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +96 -0
  61. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +3 -2
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +111 -8
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  67. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +111 -8
  68. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
  69. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +111 -8
  71. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +5 -0
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
  81. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
  82. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
  83. package/node_modules/@agents-can-communicate/cli/package.json +4 -2
  84. package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
  85. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
  86. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
  87. package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
  88. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +66 -18
  89. package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +63 -0
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +73 -0
  96. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
  97. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
  98. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
  99. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
  100. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
  101. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
  102. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
  103. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +122 -0
  104. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
  105. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
  106. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
  107. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
  109. package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
  110. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +48 -24
  111. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
  112. package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
  113. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
  114. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
  115. package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
  116. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
  117. package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
  118. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
  119. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
  120. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
  121. package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
  122. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
  124. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
  125. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
  127. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
  128. package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
  129. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
  130. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +175 -67
  131. package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
  132. package/node_modules/@agents-can-communicate/installer/package.json +4 -2
  133. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
  134. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
  135. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  137. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
  138. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
  139. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
  140. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
  141. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
  143. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
  144. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
  145. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
  146. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  147. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
  148. package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
  149. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
  150. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
  151. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  152. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
  153. package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
  154. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
  155. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
  156. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
  157. package/package.json +2 -1
@@ -1,14 +1,14 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import { realpath, stat } from "node:fs/promises";
3
4
 
4
5
  import { openWebSocketPeer } from "./ws-json-rpc.mjs";
5
6
 
6
7
  // The Codex App Server queue protocol, captured on codex-cli 0.152.1. Every
7
8
  // method here is official and present in the generated schema: initialize,
8
- // thread/loaded/list, thread/list, thread/queue/list, thread/queue/add. The
9
- // client never resumes, starts, steers, or reads a thread, and never reads
10
- // assistant transcript content. Closed safe results only; no vendor string
11
- // escapes to core.
9
+ // thread/loaded/list, thread/list, metadata-only thread/read, thread/queue/list,
10
+ // thread/queue/add. Thread history is never requested; metadata reads explicitly
11
+ // exclude turns. Closed safe results only; no vendor string escapes to core.
12
12
 
13
13
  export const PROTOCOL_CONTRACT = "codex-app-server-thread-queue-v1";
14
14
  export const MINIMUM_VERSION = "0.152.1";
@@ -42,7 +42,7 @@ export function isMethodMissing(error) {
42
42
  }
43
43
 
44
44
  export function openCodexAppServer({ socketPath, timeoutMs = 5_000 }) {
45
- return openWebSocketPeer({ socketPath, timeoutMs });
45
+ return openWebSocketPeer({ socketPath, timeoutMs, retainNotifications: false });
46
46
  }
47
47
 
48
48
  export async function initializeCodex(peer) {
@@ -52,7 +52,7 @@ export async function initializeCodex(peer) {
52
52
  return serverVersionOf(initialized?.userAgent);
53
53
  }
54
54
 
55
- export async function probeCodexQueue(peer, { threadId, minimum = MINIMUM_VERSION }) {
55
+ export async function probeCodexQueue(peer, { threadId, minimum = MINIMUM_VERSION } = {}) {
56
56
  const serverVersion = await initializeCodex(peer);
57
57
  if (serverVersion === null || parseStableVersion(serverVersion) === null) {
58
58
  return { supported: false, serverVersion, reasonCode: "prerelease_not_captured" };
@@ -61,9 +61,16 @@ export async function probeCodexQueue(peer, { threadId, minimum = MINIMUM_VERSIO
61
61
  return { supported: false, serverVersion, reasonCode: "below_minimum_version" };
62
62
  }
63
63
  try {
64
- await peer.request("thread/queue/list", { threadId });
64
+ if (threadId === undefined) {
65
+ const loaded = await pageAll(peer, "thread/loaded/list", {});
66
+ threadId = loaded.find(id => typeof id === "string" && id !== "");
67
+ if (threadId === undefined) return { supported: false, serverVersion,
68
+ reasonCode: "feature_probe_failed" };
69
+ }
70
+ queueEntries(await peer.request("thread/queue/list", { threadId }));
65
71
  } catch (error) {
66
- if (isMethodMissing(error)) return { supported: false, serverVersion, reasonCode: "protocol_mismatch" };
72
+ return { supported: false, serverVersion, reasonCode: error?.code === "ETIMEDOUT"
73
+ ? "probe_timeout" : "protocol_mismatch" };
67
74
  }
68
75
  return { supported: true, serverVersion, reasonCode: null, modes: [...QUEUE_MODES] };
69
76
  }
@@ -71,25 +78,70 @@ export async function probeCodexQueue(peer, { threadId, minimum = MINIMUM_VERSIO
71
78
  async function pageAll(peer, method, params) {
72
79
  const items = [];
73
80
  let cursor = null;
81
+ const cursors = new Set();
74
82
  for (let page = 0; page < MAX_PAGES; page += 1) {
75
83
  const response = await peer.request(method, cursor === null ? params : { ...params, cursor });
76
- items.push(...(response?.data ?? []));
84
+ if (!Array.isArray(response?.data)) throw protocolError();
85
+ items.push(...response.data);
77
86
  cursor = response?.nextCursor ?? null;
78
- if (cursor === null) break;
87
+ if (cursor === null) return items;
88
+ if (typeof cursor !== "string" || cursor === "" || cursors.has(cursor)) throw protocolError();
89
+ cursors.add(cursor);
79
90
  }
91
+ throw protocolError();
92
+ }
93
+
94
+ const protocolError = () => Object.assign(new Error("invalid queue protocol response"),
95
+ { code: "EPROTOCOL" });
96
+
97
+ function queueEntries(response) {
98
+ if (!Array.isArray(response?.data) || response.data.some(item => typeof item?.id !== "string"
99
+ || item.id === "" || (item.clientUserMessageId != null
100
+ && typeof item.clientUserMessageId !== "string"))) throw protocolError();
101
+ return response.data;
102
+ }
103
+
104
+ function threadEntries(items) {
105
+ if (items.some(item => item === null || typeof item !== "object" || Array.isArray(item)
106
+ || typeof item.id !== "string" || item.id === "")) throw protocolError();
80
107
  return items;
81
108
  }
82
109
 
83
- const listParams = cwd => ({ limit: 100, useStateDbOnly: true, ...(cwd ? { cwd } : {}) });
110
+ export async function canonicalCwd(cwd) {
111
+ if (typeof cwd !== "string" || !path.isAbsolute(cwd) || cwd.includes("\0")) return null;
112
+ try {
113
+ const resolved = await realpath(cwd);
114
+ return (await stat(resolved)).isDirectory() ? resolved : null;
115
+ } catch { return null; }
116
+ }
84
117
 
85
118
  export async function locateCodexThread(peer, { threadId, cwd }) {
86
119
  const loaded = await pageAll(peer, "thread/loaded/list", {});
87
120
  if (!loaded.includes(threadId)) return { found: false, reasonCode: "thread_not_loaded" };
88
- const threads = await pageAll(peer, "thread/list", listParams(cwd));
89
- const found = threads.find(item => item?.id === threadId);
121
+ // Filter locally after canonicalization: a server-side lexical cwd filter
122
+ // would hide a thread recorded through a symlink to the same directory.
123
+ const threads = threadEntries(await pageAll(peer, "thread/list",
124
+ { limit: 100, useStateDbOnly: true }));
125
+ const matches = threads.filter(item => item.id === threadId);
126
+ let found = matches.length === 1 ? matches[0] : null;
127
+ if (matches.length === 0) {
128
+ // A real first SessionStart/UserPromptSubmit runs before persistence has
129
+ // materialized the thread. The loaded ID still has an authoritative live
130
+ // metadata snapshot; includeTurns:false never asks for conversation history.
131
+ const response = await peer.request("thread/read", { threadId, includeTurns: false });
132
+ const metadata = response?.thread;
133
+ if (metadata?.id === threadId && Array.isArray(metadata.turns) && metadata.turns.length === 0) {
134
+ found = metadata;
135
+ }
136
+ }
90
137
  if (!found) return { found: false, reasonCode: "thread_not_found" };
91
- if (cwd !== undefined && found.cwd !== cwd) return { found: false, reasonCode: "cwd_mismatch" };
92
- return { found: true, threadId, status: found.status?.type ?? "unknown" };
138
+ const actualCwd = await canonicalCwd(found.cwd);
139
+ if (actualCwd === null || (cwd !== undefined && actualCwd !== await canonicalCwd(cwd))) {
140
+ return { found: false, reasonCode: "cwd_mismatch" };
141
+ }
142
+ const status = found.status?.type;
143
+ if (!["idle", "active"].includes(status)) return { found: false, reasonCode: "thread_not_loaded" };
144
+ return { found: true, threadId, cwd: actualCwd, status };
93
145
  }
94
146
 
95
147
  // thread/queue/list first, so a retried client message id is the same offer
@@ -97,14 +149,15 @@ export async function locateCodexThread(peer, { threadId, cwd }) {
97
149
  // clientUserMessageId.
98
150
  export async function addCodexQueueMessage(peer, { threadId, messageId, text }) {
99
151
  const listed = await peer.request("thread/queue/list", { threadId });
100
- const existing = (listed?.data ?? []).find(item => item?.clientUserMessageId === messageId);
152
+ const existing = queueEntries(listed).find(item => item.clientUserMessageId === messageId);
101
153
  if (existing) {
102
154
  return { accepted: true, duplicate: true, queuedSubmissionId: existing.id };
103
155
  }
104
156
  const added = await peer.request("thread/queue/add", { threadId,
105
157
  input: [{ type: "text", text }], clientUserMessageId: messageId });
106
158
  const submission = added?.queuedSubmission;
107
- if (!submission || submission.clientUserMessageId !== messageId) {
159
+ if (!submission || typeof submission.id !== "string" || submission.id === ""
160
+ || submission.clientUserMessageId !== messageId) {
108
161
  throw Object.assign(new Error("queue acknowledgement did not echo the client message id"),
109
162
  { code: "EPROTOCOL" });
110
163
  }
@@ -0,0 +1,231 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BEGIN, END, renderBlock } from "@agents-can-communicate/adapter-sdk";
4
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
+
6
+ const unsafe = (reason = "unrecognized TOML declaration") => {
7
+ throw new AccError(EXIT.CONFLICT, `cannot safely edit Codex config: ${reason}; `
8
+ + "repair ambiguous TOML or ACC markers before retrying", { reason });
9
+ };
10
+ const ROOT = "# ACC sandbox writable_roots created with: ";
11
+ export const sandboxOwnership = root => `${ROOT}${JSON.stringify(root)}`;
12
+
13
+ const OWNED = new Map([
14
+ [JSON.stringify(["marketplaces", "acc-local"]), ["source_type", "source"]],
15
+ [JSON.stringify(["plugins", "agents-can-communicate@acc-local"]), ["enabled"]],
16
+ [JSON.stringify(["sandbox_workspace_write"]), ["writable_roots"]],
17
+ ]);
18
+
19
+ // A bounded lexical scan, not a TOML value parser. Newlines inside strings,
20
+ // arrays and inline tables cannot introduce a header or a managed marker.
21
+ function* statements(source) {
22
+ let start = 0, code = "", quote = null;
23
+ const stack = [];
24
+ for (let i = 0; i < source.length;) {
25
+ const char = source[i];
26
+ if (quote) {
27
+ if (quote[0] === '"' && char === "\\") {
28
+ code += source.slice(i, i + 2); i += 2; continue;
29
+ }
30
+ if (source.startsWith(quote, i)) {
31
+ let count = quote.length;
32
+ if (quote.length === 3) {
33
+ while (source[i + count] === quote[0]) count++;
34
+ if (count > 5) unsafe("unsupported string quote delimiter");
35
+ }
36
+ code += source.slice(i, i + count); i += count; quote = null; continue;
37
+ }
38
+ if (quote.length === 1 && /[\r\n]/.test(char)) unsafe("newline in single-line string");
39
+ code += char; i++; continue;
40
+ }
41
+ if (char === "#") {
42
+ while (i < source.length && source[i] !== "\n") i++;
43
+ continue;
44
+ }
45
+ if (char === '"' || char === "'") {
46
+ quote = source.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
47
+ code += quote; i += quote.length; continue;
48
+ }
49
+ if (char === "[" || char === "{") stack.push(char);
50
+ if (char === "]" || char === "}") {
51
+ if (stack.pop() !== (char === "]" ? "[" : "{")) unsafe("mismatched collection delimiter");
52
+ }
53
+ code += char; i++;
54
+ if (char === "\n" && stack.length === 0) {
55
+ yield { raw: source.slice(start, i), code: code.trim() };
56
+ start = i; code = "";
57
+ }
58
+ }
59
+ if (quote || stack.length) unsafe(quote ? "unclosed string" : "unclosed collection");
60
+ if (start < source.length) yield { raw: source.slice(start), code: code.trim() };
61
+ }
62
+
63
+ function decodeKey(key) {
64
+ if (key[0] === "'") return key.slice(1, -1);
65
+ if (key[0] !== '"') return key;
66
+ return key.slice(1, -1).replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|[btnfr"\\])|\\./g,
67
+ (match, escape) => {
68
+ if (!escape) unsafe("unsupported quoted-key escape");
69
+ if (/^[uU]/.test(escape)) {
70
+ const point = Number.parseInt(escape.slice(1), 16);
71
+ if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) {
72
+ unsafe("invalid quoted-key Unicode escape");
73
+ }
74
+ return String.fromCodePoint(point);
75
+ }
76
+ return ({ b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" })[escape];
77
+ });
78
+ }
79
+
80
+ function keyPath(code) {
81
+ const keys = [];
82
+ let rest = code.trimStart();
83
+ for (;;) {
84
+ const match = /^(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\[^\r\n])*")/.exec(rest);
85
+ if (!match) unsafe("unrecognized TOML key");
86
+ keys.push(decodeKey(match[0]));
87
+ rest = rest.slice(match[0].length).trimStart();
88
+ if (!rest.startsWith(".")) return { keys, rest };
89
+ rest = rest.slice(1).trimStart();
90
+ }
91
+ }
92
+
93
+ function declaration(code) {
94
+ const header = code.startsWith("[");
95
+ const array = header && code.startsWith("[[");
96
+ const { keys, rest } = keyPath(header ? code.slice(array ? 2 : 1) : code);
97
+ if (header ? rest !== (array ? "]]" : "]") : !rest.startsWith("=")) unsafe();
98
+ return { keys, header, array, value: header ? null : rest.slice(1).trim() };
99
+ }
100
+
101
+ // An unchanged, newly generated sandbox has value provenance. A legacy or
102
+ // edited sandbox belongs to the client as a whole and remains byte-preserved.
103
+ function ownsSandbox(body) {
104
+ const metadata = body.filter(item => !item.code && item.raw.trim().startsWith(ROOT));
105
+ const values = body.filter(item => item.code);
106
+ if (metadata.length !== 1 || values.length !== 1) return false;
107
+ const value = values[0].code.match(/^writable_roots\s*=\s*(\[.*\])$/);
108
+ if (!value) return false;
109
+ try {
110
+ const root = JSON.parse(metadata[0].raw.trim().slice(ROOT.length));
111
+ const roots = JSON.parse(value[1]);
112
+ return typeof root === "string" && Array.isArray(roots)
113
+ && roots.length === 1 && roots[0] === root;
114
+ } catch { return false; }
115
+ }
116
+
117
+ function assertRegistration(body, keys) {
118
+ const values = body.filter(item => item.code).map(item => declaration(item.code));
119
+ if (values.some(item => item.keys.length !== 1 || !keys.includes(item.keys[0]))) {
120
+ unsafe("unknown key in owned table");
121
+ }
122
+ const valid = { source_type: /^["']local["']$/, source: /^(?:"(?:[^"\\]|\\.)*"|'[^']*')$/,
123
+ enabled: /^(?:true|false)$/ };
124
+ if (values.length !== keys.length || keys.some(key => values.filter(item =>
125
+ item.keys.length === 1 && item.keys[0] === key && valid[key].test(item.value)).length !== 1)) {
126
+ unsafe("changed or incomplete owned registration");
127
+ }
128
+ }
129
+
130
+ function tableBody(entries, index) {
131
+ const body = [];
132
+ for (const item of entries.slice(index + 1)) {
133
+ if (item.code && declaration(item.code).header) break;
134
+ body.push(item);
135
+ }
136
+ return body;
137
+ }
138
+
139
+ /**
140
+ * Ownership is an exact generated table inside real ACC comment markers, with
141
+ * only its generated keys. Other tables (including descendants) and comments
142
+ * are foreign. Unknown keys in an owned table are ambiguous: refuse, never
143
+ * discard them. Codex may insert foreign tables before END; retain their raw
144
+ * bytes in order and append the replacement block after them.
145
+ */
146
+ function inspect(source) {
147
+ const kept = [], declarations = [];
148
+ let inside = false, owned = null, table = [];
149
+ const entries = [...statements(source)];
150
+ for (const [index, { raw, code }] of entries.entries()) {
151
+ if (!code && raw.trim() === BEGIN) {
152
+ if (inside) unsafe("nested BEGIN marker");
153
+ inside = true; continue;
154
+ }
155
+ if (!code && raw.trim() === END) {
156
+ if (!inside) unsafe("unmatched END marker");
157
+ inside = false; continue;
158
+ }
159
+ if (!code) {
160
+ if (!inside || !owned || (raw.trim() && !(table.length === 1
161
+ && table[0] === "sandbox_workspace_write" && raw.trim().startsWith(ROOT)))) kept.push(raw);
162
+ continue;
163
+ }
164
+ const entry = declaration(code);
165
+ if (entry.header) {
166
+ table = entry.keys;
167
+ owned = inside ? OWNED.get(JSON.stringify(table)) : null;
168
+ if (owned && table.length === 1 && table[0] === "sandbox_workspace_write"
169
+ && !ownsSandbox(tableBody(entries, index))) owned = null;
170
+ if (owned && entry.array) unsafe("owned table declared as an array");
171
+ if (owned && table[0] !== "sandbox_workspace_write") {
172
+ assertRegistration(tableBody(entries, index), owned);
173
+ }
174
+ } else if (owned) {
175
+ // Markers are comments, so END does not close a TOML table. Removing its
176
+ // header would attach any following foreign assignment to another table.
177
+ if (!inside) unsafe("assignment continues owned table after END marker");
178
+ if (entry.keys.length !== 1 || !owned.includes(entry.keys[0])) {
179
+ unsafe("unknown key in owned table");
180
+ }
181
+ }
182
+ if (!owned) {
183
+ kept.push(raw);
184
+ declarations.push({ keys: entry.header ? table : [...table, ...entry.keys],
185
+ header: entry.header });
186
+ }
187
+ }
188
+ if (inside) unsafe("missing END marker");
189
+ const has = prefix => declarations.some(({ keys }) => prefix.every((key, i) => keys[i] === key));
190
+ // Assignments occupy their exact namespace: even an empty inline parent is
191
+ // closed to later child tables. Ordinary headers and sibling keys stay valid.
192
+ const assignedParent = declarations.some(({ keys, header }) => !header
193
+ && keys.length === 1 && ["marketplaces", "plugins"].includes(keys[0]));
194
+ return { source: kept.join(""),
195
+ sandbox: has(["sandbox_workspace_write"]),
196
+ registration: assignedParent || has(["marketplaces", "acc-local"])
197
+ || has(["plugins", "agents-can-communicate@acc-local"]) };
198
+ }
199
+
200
+ export function inspectConfig(source, file = "config.toml") {
201
+ try {
202
+ return inspect(source);
203
+ } catch (error) {
204
+ if (!(error instanceof AccError)) throw error;
205
+ // Structural reasons are fixed labels: never include key names or values.
206
+ throw new AccError(error.code, `${file}: ${error.message}`, { ...error.details, file });
207
+ }
208
+ }
209
+
210
+ export async function readConfig(file) {
211
+ return readFile(file, "utf8").catch(error => {
212
+ if (error.code === "ENOENT") return "";
213
+ throw error;
214
+ });
215
+ }
216
+
217
+ export async function writeTomlBlock(file, body) {
218
+ const { source } = inspectConfig(await readConfig(file), file);
219
+ const separator = source === "" || source.endsWith("\n") ? "" : "\n";
220
+ await mkdir(path.dirname(file), { recursive: true });
221
+ await writeFile(file, `${source}${separator}${renderBlock(body)}\n`);
222
+ return file;
223
+ }
224
+
225
+ export async function removeTomlBlock(file) {
226
+ const before = await readConfig(file);
227
+ const { source } = inspectConfig(before, file);
228
+ if (source === before) return false;
229
+ await writeFile(file, source);
230
+ return true;
231
+ }
@@ -148,5 +148,5 @@ export function allowOutcome() {
148
148
  * as data, because at this role the model reads text as instruction.
149
149
  */
150
150
  export function injectOutcome(context) {
151
- return { stdout: context === "" ? "" : `${context}\n`, stderr: "", exitCode: 0 };
151
+ return { stdout: context, stderr: "", exitCode: 0 };
152
152
  }
@@ -1,13 +1,20 @@
1
+ // Installation, removal, detection and planning share the exact marketplace,
2
+ // cache and config paths below; keeping them together avoids divergent ownership.
1
3
  import { cp, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
 
5
7
  import { bakeSkillCommand, blankJson, blankText, removeIfEmpty, removeInstalledTree,
6
8
  keepOnlyVersion, ownVersion, stampPluginVersion,
7
- removeTomlBlock, stripBlock, tomlString,
8
- writeForeignJson, writeHookShim, writeTomlBlock }
9
+ tomlString, writeForeignJson, writeHookShim }
9
10
  from "@agents-can-communicate/adapter-sdk";
10
11
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
12
+ import { inspectConfig, readConfig, removeTomlBlock, sandboxOwnership, writeTomlBlock } from "./config-block.mjs";
13
+
14
+ // Kept cohesive above 300 lines because Codex plugin install, cache, config,
15
+ // detection, and ownership share one client topology; splitting would duplicate
16
+ // path authority and make install/uninstall symmetry harder to audit.
17
+
11
18
 
12
19
  const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
13
20
  const PLUGIN_NAME = "agents-can-communicate";
@@ -24,6 +31,8 @@ const PLUGIN_NAME = "agents-can-communicate";
24
31
  // again to confirm a root of ACC's own is accepted and reported enabled.
25
32
  const MARKETPLACE = "acc-local";
26
33
  const QUALIFIED = `${PLUGIN_NAME}@${MARKETPLACE}`;
34
+ const HOOK_REVIEW = "hook readiness unverified: open codex; check ACC is enabled in /plugins; in /hooks, review "
35
+ + "each ACC hook and enable/trust its current definition if needed, then restart the session";
27
36
 
28
37
  // A marketplace is a root holding `.agents/plugins/marketplace.json`, and every
29
38
  // `source.path` in that manifest - `./plugins/<name>` - is resolved by this
@@ -79,11 +88,12 @@ async function readJson(file, fallback) {
79
88
  // Replace the bundle's placeholder command with the shim just written. The
80
89
  // client copies an installed plugin into a cache of its own, so the command has
81
90
  // to be absolute: a path relative to the bundle would not survive the copy.
91
+ const shellLiteral = value => `'${String(value).replaceAll("'", "'\\''")}'`;
82
92
  const withShim = (wiring, shim) => ({ ...wiring, hooks: Object.fromEntries(
83
93
  Object.entries(wiring.hooks).map(([event, entries]) => [event, entries.map(entry => ({
84
94
  ...entry,
85
95
  hooks: entry.hooks.map(hook => ({ ...hook,
86
- command: `sh "${shim}" ${hook.command.split(" ").pop()}` })),
96
+ command: `sh ${shellLiteral(shim)} ${hook.command.split(" ").pop()}` })),
87
97
  }))])) });
88
98
 
89
99
  const writeJson = async (file, value) => {
@@ -136,20 +146,20 @@ const entryFor = () => ({
136
146
  const sandboxTable = (stateRoot, theirs) => {
137
147
  if (typeof stateRoot !== "string" || stateRoot === "") return [];
138
148
  if (theirs) return [];
139
- return ["", "[sandbox_workspace_write]",
149
+ return ["", "[sandbox_workspace_write]", sandboxOwnership(stateRoot),
140
150
  `writable_roots = [${tomlString(stateRoot)}]`];
141
151
  };
142
152
 
143
- // Every spelling TOML allows for the same table: the header, a sub-table
144
- // header, a dotted key, and an inline table on one line. Missing one means ACC
145
- // appends a second declaration - which is the duplicate this exists to avoid,
146
- // and the client refuses the whole config over it.
147
- const declaresSandbox = config =>
148
- /^\s*\[sandbox_workspace_write[\].]/m.test(config)
149
- || /^\s*sandbox_workspace_write\s*[.=]/m.test(config);
153
+ const declaresSandbox = config => inspectConfig(config).sandbox;
154
+
155
+ const sandboxReview = (config, file, stateRoot) =>
156
+ typeof stateRoot === "string" && stateRoot !== "" && inspectConfig(config, file).sandbox
157
+ ? [`verify sandbox_workspace_write.writable_roots in ${file} includes ${stateRoot}; `
158
+ + "your existing sandbox configuration was preserved"]
159
+ : [];
150
160
 
151
161
  export async function installCodexPlugin({ home, agentsHome = home,
152
- codexHome = path.join(home, ".codex"), stateRoot, runner, node }) {
162
+ codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, preserveVersions = false }) {
153
163
  // Read before writing, so a manifest that will not parse is found before a
154
164
  // plugin tree is laid down that nothing will then be able to remove.
155
165
  const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
@@ -159,15 +169,26 @@ export async function installCodexPlugin({ home, agentsHome = home,
159
169
  // marketplace at this root - discovered without any config entry, under
160
170
  // whatever its manifest calls itself - the id ACC enabled was one the client
161
171
  // never forms, and the plugin sat there listed and not installed.
162
- const before = await readFile(configPath(codexHome), "utf8").catch(() => "");
172
+ const config = configPath(codexHome);
173
+ const before = await readConfig(config);
174
+ const foreign = inspectConfig(before, config);
175
+ // Preflight before touching installed files: a duplicate table makes Codex
176
+ // refuse the config, and ambiguous ownership must never delete client state.
177
+ if (foreign.registration) {
178
+ throw new AccError(EXIT.CONFLICT,
179
+ `marketplace ${MARKETPLACE} or its plugin is already registered in this config; `
180
+ + "remove it and install again", { config });
181
+ }
182
+ const theirSandbox = foreign.sandbox;
163
183
 
164
184
  const target = pluginPath(agentsHome);
165
185
  await rm(target, { recursive: true, force: true });
166
186
  await cp(bundle, target, { recursive: true });
167
187
  // The skill ships with a placeholder where the command belongs: `acc` is
168
188
  // not on PATH everywhere, and an agent that cannot run it improvises.
169
- await bakeSkillCommand({ root: target, node });
170
- const shim = await writeHookShim({ dir: target, adapterId: "codex", runner, node });
189
+ await bakeSkillCommand({ root: target, node, cli, dataHome });
190
+ const shim = await writeHookShim({ dir: target, adapterId: "codex",
191
+ dataHome, runner, node });
171
192
  await writeJson(path.join(target, "hooks.json"),
172
193
  withShim(await readJson(path.join(bundle, "hooks.json"), { hooks: {} }), shim));
173
194
 
@@ -178,16 +199,6 @@ export async function installCodexPlugin({ home, agentsHome = home,
178
199
  const others = (existing.plugins ?? []).filter(entry => entry.name !== PLUGIN_NAME);
179
200
  await writeMarketplace(file, { ...existing, plugins: [...others, entryFor()] });
180
201
 
181
- const config = configPath(codexHome);
182
- // A marketplace declared twice makes this client refuse the whole config, and
183
- // then every plugin the user has stops working. If they registered it
184
- // themselves, say so rather than appending a duplicate table.
185
- if (stripBlock(before).includes(`[marketplaces.${MARKETPLACE}]`)) {
186
- throw new AccError(EXIT.CONFLICT,
187
- `marketplace ${MARKETPLACE} is already registered in this config; `
188
- + "remove it and install again", { config });
189
- }
190
- const theirSandbox = declaresSandbox(stripBlock(before));
191
202
  await writeTomlBlock(config, [
192
203
  `[marketplaces.${MARKETPLACE}]`,
193
204
  `source_type = "local"`,
@@ -212,7 +223,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
212
223
  await cp(target, cached, { recursive: true });
213
224
  // One copy, the one just written - and only inside this plugin's own
214
225
  // directory. The marketplace cache root above it holds other people's plugins.
215
- await keepOnlyVersion({ root: path.dirname(cached), version, io: { readdir, rm } });
226
+ if (!preserveVersions) await keepOnlyVersion({ root: path.dirname(cached), version, io: { readdir, rm } });
216
227
 
217
228
  // The plugin's own directory in the cache. Not the versioned one inside it,
218
229
  // which goes stale the moment the version changes - and not the marketplace
@@ -224,11 +235,9 @@ export async function installCodexPlugin({ home, agentsHome = home,
224
235
  // while ACC invented its own marketplace name and so had a root to itself.
225
236
  // make the record stale the moment the plugin version changes.
226
237
  return { ok: true, changes: [target, file, config, cachePath(codexHome)],
238
+ needsAction: [HOOK_REVIEW, ...sandboxReview(before, config, stateRoot)],
227
239
  diagnostics: ["hooks require explicit trust in Codex before they run",
228
- ...(theirSandbox
229
- ? [`this config sets its own sandbox_workspace_write; add ${stateRoot} to `
230
- + "writable_roots, or an agent here can read the roster and record nothing"]
231
- : [])] };
240
+ ...sandboxReview(before, config, stateRoot)] };
232
241
  }
233
242
 
234
243
  /** Remove each directory that is empty, in the order given. */
@@ -239,10 +248,21 @@ async function removeEmptyDirs(directories) {
239
248
  }
240
249
 
241
250
 
251
+ export async function preflightCodexUninstall({ home, agentsHome = home,
252
+ codexHome = path.join(home, ".codex") }) {
253
+ const existing = await readJson(marketplacePath(agentsHome), null);
254
+ const before = await readFile(configPath(codexHome), "utf8").catch(error => {
255
+ if (error.code === "ENOENT") return "";
256
+ throw error;
257
+ });
258
+ return { existing, withoutOurs: inspectConfig(before, configPath(codexHome)).source };
259
+ }
260
+
242
261
  export async function uninstallCodexPlugin({ home, agentsHome = home,
243
262
  codexHome = path.join(home, ".codex"), keep = [] }) {
244
263
  const file = marketplacePath(agentsHome);
245
- const existing = await readJson(file, null);
264
+ // Direct adapter callers need the same check as the installer boundary.
265
+ const { existing, withoutOurs } = await preflightCodexUninstall({ home, agentsHome, codexHome });
246
266
  const changes = [];
247
267
  if (existing !== null) {
248
268
  const kept = (existing.plugins ?? []).filter(entry => entry.name !== PLUGIN_NAME);
@@ -259,9 +279,9 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
259
279
  // `hook: SessionStart Completed` while executing nothing, and ACC's write
260
280
  // guard is silently off while `acc doctor` reports the adapter installed.
261
281
  //
262
- // The record is granted once, by a person, and survives ACC upgrades - hashes
263
- // captured under 0.1.6 still admitted 0.1.10's hooks. Nothing ACC writes can
264
- // put it back. So it is not ACC's to remove.
282
+ // Those historical hashes admitted the historical hooks. Current Codex checks
283
+ // exact definitions, so preservation cannot establish present readiness. The
284
+ // decision remains the client's to keep or revise, never ACC's to remove.
265
285
  // The marketplace directory is ACC's too, so it goes rather than being left
266
286
  // behind empty.
267
287
  // A blank TOML config and an absent one are the same to this client, and a
@@ -292,54 +312,42 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
292
312
  marketplaceRoot(agentsHome),
293
313
  cacheRoot(codexHome),
294
314
  ]);
295
- return { ok: true, changes, diagnostics: [] };
315
+ return { ok: true, changes, diagnostics: declaresSandbox(withoutOurs)
316
+ ? ["existing sandbox_workspace_write configuration was preserved; review any retained writable_roots after removal"]
317
+ : [] };
296
318
  }
297
319
 
298
320
  export async function detectCodex({ home, agentsHome = home,
299
- codexHome = path.join(home, ".codex") }) {
321
+ codexHome = path.join(home, ".codex"), stateRoot }) {
300
322
  const marketplace = await readJson(marketplacePath(agentsHome), null);
301
323
  const published = (marketplace?.plugins ?? []).some(entry => entry.name === PLUGIN_NAME);
302
324
  const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
303
325
  const registered = config.includes(`[marketplaces.${MARKETPLACE}]`);
304
- const enabled = config.includes(`[plugins."${QUALIFIED}"]`);
326
+ const pluginEntry = config.includes(`[plugins."${QUALIFIED}"]`);
305
327
  const cached = await stat(cachePath(codexHome))
306
328
  .then(() => true).catch(() => false);
307
- // The condition that decides whether ACC runs here at all, and the only one
308
- // this client will not tell you about: with no trust record it runs no hook,
309
- // prints `hook: SessionStart Completed`, and executes nothing. Everything else
310
- // - published, registered, enabled, cached - can be true while the write guard
311
- // is off. Only asked when something is wired: a warning about hooks that do
312
- // not exist is how a real one gets ignored.
313
- const untrusted = cached && !config.includes(`hooks.state."${QUALIFIED}:`);
329
+ // Saved trust is not readiness. Codex compares every current definition's
330
+ // hash and can disable a trusted hook. Even a commented or stale single record
331
+ // previously suppressed this check. Leave verification to Codex's /hooks;
332
+ // detection neither invents its hash algorithm nor starts a client service.
314
333
  return { ok: true, changes: [], diagnostics: [
315
334
  published ? "acc plugin published in the marketplace" : "acc plugin not registered",
316
- registered && enabled
317
- ? "marketplace registered and plugin enabled"
318
- : "marketplace not registered with the client; no hook would run",
335
+ registered && pluginEntry
336
+ ? "marketplace and plugin entries found in config; activation not verified"
337
+ : "marketplace/plugin registration not verified from this config",
319
338
  // Publishing, registering and enabling are all necessary and still not
320
339
  // sufficient: hooks stay silent until the client copies the plugin into its
321
340
  // own cache. Only the client does that, so ACC names the command.
322
341
  cached
323
342
  ? "plugin installed in the client's cache"
324
343
  : `plugin not installed yet; run: codex plugin add ${QUALIFIED}`,
325
- // The condition that decides whether ACC runs here at all, and the only one
326
- // this client will not tell you about: with no trust record it runs no hook,
327
- // prints `hook: SessionStart Completed`, and executes nothing. Everything
328
- // else - published, registered, enabled, cached - can be true while the
329
- // write guard is off. Said only when there is something to trust, because a
330
- // warning on a machine with nothing wired is how a real one gets ignored.
331
- ...(untrusted
332
- ? ["hooks are not trusted, so this client reports them completed and runs "
333
- + "nothing"]
344
+ ...(cached
345
+ ? ["hook readiness is unverified by ACC; Codex checks whether each current "
346
+ + "definition is enabled and trusted"]
334
347
  : []),
335
348
  ],
336
- // Said as an action rather than an observation, because a person has to do it
337
- // and no acc command can: the client grants this once, from its own prompt.
338
- // A diagnostic alone would have stayed in `--json`, which is where the first
339
- // version of this fix put it and where nobody would have read it.
340
- needsAction: untrusted
341
- ? ["start codex once and accept the hook trust prompt "
342
- + "# its hooks run nothing until then"]
349
+ needsAction: cached
350
+ ? [HOOK_REVIEW, ...sandboxReview(config, configPath(codexHome), stateRoot)]
343
351
  : [] };
344
352
  }
345
353