agents-can-communicate 0.8.4 → 0.9.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 (142) hide show
  1. package/README.md +11 -7
  2. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
  3. package/bin/entrypoints/acc-hook.mjs +33 -19
  4. package/bin/entrypoints/hook-output.mjs +14 -5
  5. package/docs/ADAPTER_AUTHORING.md +32 -0
  6. package/docs/ARCHITECTURE.md +7 -4
  7. package/docs/CAPABILITIES.md +7 -6
  8. package/docs/CLI.md +5 -0
  9. package/docs/CONFIGURATION.md +15 -10
  10. package/docs/DESIGN_DECISIONS.md +0 -2
  11. package/docs/GETTING_STARTED.md +39 -6
  12. package/docs/HOW_IT_WORKS.md +13 -8
  13. package/docs/SECURITY_MODEL.md +26 -5
  14. package/docs/TROUBLESHOOTING.md +74 -12
  15. package/docs/UPGRADING.md +50 -5
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
  24. package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
  25. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
  26. package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
  27. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
  28. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
  29. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
  30. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
  31. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
  32. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
  33. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
  35. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  44. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  46. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +87 -13
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -9
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +66 -26
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  58. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  60. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  62. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  63. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  65. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  66. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  67. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  68. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +11 -2
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +82 -17
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  77. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  78. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  80. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  81. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  84. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
  94. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +15 -4
  95. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  96. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  97. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  98. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  101. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  102. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  103. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  104. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
  107. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
  109. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
  110. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +7 -2
  111. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  112. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  113. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  114. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +167 -34
  115. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  116. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -2
  117. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  118. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  119. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  120. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
  121. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  122. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
  124. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
  125. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
  133. package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
  134. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
  135. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
  136. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
  137. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
  138. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
  139. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
  140. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
  141. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
  142. package/package.json +1 -5
@@ -156,19 +156,44 @@ async function removeChannelConfig(versionsRoot) {
156
156
  { force: true })));
157
157
  }
158
158
 
159
+ // The bundle's commands are `sh "${CLAUDE_PLUGIN_ROOT}/hooks/acc-hook.sh" <event>`.
160
+ const SHELL_HOOK = /^sh "\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/acc-hook\.sh" (\w+)$/;
161
+
162
+ /**
163
+ * Claude Code on Windows runs a hook's shell form through Git Bash, or through
164
+ * PowerShell where Git is absent, and neither can be relied on to have `sh`.
165
+ * The exec form - `command` plus `args`, Claude Code 2.1.139 and later - starts
166
+ * the pinned node directly on the Node shim, with no shell to quote for.
167
+ */
168
+ async function execFormHooks(file, node) {
169
+ const document = JSON.parse(await readFile(file, "utf8"));
170
+ for (const groups of Object.values(document.hooks)) {
171
+ for (const group of groups) {
172
+ group.hooks = group.hooks.map(hook => {
173
+ const [, event] = SHELL_HOOK.exec(hook.command ?? "") ?? [];
174
+ if (event === undefined) return hook;
175
+ return { type: "command", command: node,
176
+ args: ["${CLAUDE_PLUGIN_ROOT}/hooks/acc-hook.mjs", event] };
177
+ });
178
+ }
179
+ }
180
+ await writeFile(file, `${JSON.stringify(document, null, 2)}\n`);
181
+ }
182
+
159
183
  /** A plugin tree with the shim written and the skill's command baked in. */
160
- async function layOutPlugin(target, { runner, node, cli }) {
184
+ async function layOutPlugin(target, { runner, node, cli, hostPlatform }) {
161
185
  await rm(target, { recursive: true, force: true });
162
186
  await cp(bundle, target, { recursive: true });
163
187
  // The skill ships with a placeholder where the command belongs: `acc` is not
164
188
  // on PATH everywhere, and an agent that cannot run it improvises. The shim
165
189
  // carries the pinning so each example can name one path. No data home is
166
190
  // pinned here: an operator's own ACC_DATA_HOME still wins for this client.
167
- const cliShim = await writeCliShim({ dir: target, cli, node });
168
- await bakeSkillCommand({ root: target, cliShim });
191
+ const cliShim = await writeCliShim({ dir: target, cli, node, platform: hostPlatform });
192
+ await bakeSkillCommand({ root: target, cliShim, platform: hostPlatform });
169
193
  // The bundle's hooks.json names this script, and nothing else writes it.
170
194
  await writeHookShim({ dir: path.join(target, "hooks"), adapterId: "claude_code",
171
- runner, node });
195
+ runner, node, platform: hostPlatform });
196
+ if (hostPlatform === "win32") await execFormHooks(path.join(target, "hooks", "hooks.json"), node);
172
197
  // The copy the client reads says which ACC wrote it. The shipped manifest
173
198
  // carries no version, so there is nothing in the repository to fall out of
174
199
  // step - which is how every client came to report 0.1.6 while running 0.1.9.
@@ -177,7 +202,7 @@ async function layOutPlugin(target, { runner, node, cli }) {
177
202
  }
178
203
 
179
204
  export async function installClaudePlugin({ configDir, runner, cli, keepPreviousVersion = null,
180
- node = process.execPath, now = new Date() }) {
205
+ node = process.execPath, now = new Date(), hostPlatform = process.platform }) {
181
206
  // Everything this will merge into, read before a byte is written. A settings
182
207
  // file that will not parse used to be discovered after the plugin tree was
183
208
  // already on disk, and the install then failed with nineteen files left
@@ -194,12 +219,12 @@ export async function installClaudePlugin({ configDir, runner, cli, keepPrevious
194
219
  const source = sourceDir(configDir);
195
220
  const cached = cachePath(configDir, version);
196
221
 
197
- await layOutPlugin(source, { runner, node, cli });
222
+ await layOutPlugin(source, { runner, node, cli, hostPlatform });
198
223
  await writeJson(marketplaceFile(configDir), marketplaceManifest());
199
224
  // The copy the client runs from. Written here rather than asking the user to
200
225
  // run `claude plugin install`, exactly as the Codex adapter does, because the
201
226
  // command's only effect is this copy plus the two registry entries below.
202
- await layOutPlugin(cached, { runner, node, cli });
227
+ await layOutPlugin(cached, { runner, node, cli, hostPlatform });
203
228
  // The copy just written, plus the one an upgrade moved off. A client caches a
204
229
  // plugin under its version, so every upgrade would otherwise leave the previous
205
230
  // release's tree beside this one - invisible while the version never moved,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,9 +1,12 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
1
3
  import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { realpath, stat } from "node:fs/promises";
4
6
 
5
- import { compareVersionOrder, versionOrder } from "@agents-can-communicate/adapter-sdk";
7
+ import { compareVersionOrder, isWindowsPlatform, versionOrder } from "@agents-can-communicate/adapter-sdk";
6
8
 
9
+ import { DAEMON_LAYOUTS } from "./daemon-layouts.mjs";
7
10
  import { openWebSocketPeer } from "./ws-json-rpc.mjs";
8
11
 
9
12
  // The Codex App Server queue protocol, captured on codex-cli 0.152.1. Every
@@ -42,8 +45,70 @@ export function isMethodMissing(error) {
42
45
  || (error?.code === INVALID_REQUEST && /unknown variant/.test(String(error?.message ?? "")));
43
46
  }
44
47
 
45
- export function openCodexAppServer({ socketPath, timeoutMs = 5_000 }) {
46
- return openWebSocketPeer({ socketPath, timeoutMs, retainNotifications: false });
48
+ const fileExists = file => stat(file).then(found => found.isFile(), () => false);
49
+
50
+ /**
51
+ * Windows: the command that relays stdio to the control socket, run from the
52
+ * package the daemon runs from, found the way maintenance finds it: the
53
+ * standalone install when the home has one, else the copy `app-server daemon
54
+ * start` installs (measured on 0.159.3). An .exe, so it starts without cmd.exe
55
+ * whatever the socket path holds. Null when the home holds neither.
56
+ */
57
+ export async function proxyCommand(socketPath, { exists = fileExists } = {}) {
58
+ const codexHome = path.win32.dirname(path.win32.dirname(socketPath));
59
+ for (const layout of DAEMON_LAYOUTS) {
60
+ for (const name of layout.executables.filter(item => item.endsWith(".exe"))) {
61
+ const file = path.win32.join(codexHome, ...layout.packageDir.split("/"), ...name.split("/"));
62
+ if (await exists(file)) return { file, args: ["app-server", "proxy", "--sock", socketPath] };
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // `spawnProxy` is the seam the tests start a stand-in with.
69
+ async function startProxy(socketPath, { resolveProxy, spawnProxy }) {
70
+ if (spawnProxy !== undefined) return spawnProxy(socketPath);
71
+ const command = await resolveProxy(socketPath);
72
+ if (command === null) {
73
+ throw Object.assign(new Error(`no codex.exe under ${path.win32.dirname(path.win32.dirname(socketPath))}`
74
+ + " to relay to its daemon"), { code: "ENOENT" });
75
+ }
76
+ return spawn(command.file, command.args, { stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
77
+ }
78
+
79
+ // A child's stdio, once it is started, as the duplex the WebSocket peer speaks
80
+ // over. The peer writes only after "connect".
81
+ function proxyStream(started) {
82
+ const stream = new EventEmitter();
83
+ stream.destroyed = false;
84
+ let child = null;
85
+ stream.write = data => (stream.destroyed || child === null ? false : child.stdin.write(data));
86
+ stream.destroy = () => {
87
+ if (stream.destroyed) return;
88
+ stream.destroyed = true;
89
+ child?.stdin.destroy();
90
+ child?.kill();
91
+ };
92
+ Promise.resolve(started).then(running => {
93
+ child = running;
94
+ if (stream.destroyed) {
95
+ child.kill();
96
+ return;
97
+ }
98
+ child.once("spawn", () => stream.emit("connect"));
99
+ child.stdout.on("data", data => stream.emit("data", data));
100
+ child.stdin.on("error", error => stream.emit("error", error));
101
+ child.once("error", error => stream.emit("error", error));
102
+ child.once("exit", () => stream.emit("close"));
103
+ }, error => stream.emit("error", error));
104
+ return stream;
105
+ }
106
+
107
+ export function openCodexAppServer({ socketPath, timeoutMs = 5_000, platform = process.platform,
108
+ spawnProxy, resolveProxy = proxyCommand }) {
109
+ if (!isWindowsPlatform(platform)) return openWebSocketPeer({ socketPath, timeoutMs, retainNotifications: false });
110
+ return openWebSocketPeer({ timeoutMs, retainNotifications: false,
111
+ connect: () => proxyStream(startProxy(socketPath, { resolveProxy, spawnProxy })) });
47
112
  }
48
113
 
49
114
  export async function initializeCodex(peer) {
@@ -0,0 +1,13 @@
1
+ // The two packages a Codex daemon runs from, measured. The standalone install
2
+ // keeps its PID in app-server.pid (0.154.0-0.159.0); a home with no standalone
3
+ // package gets the daemon's own package from `app-server daemon start` on
4
+ // 0.157.1 and newer, with its PID in daemon.pid (0.159.0, 2026-09-29). A home
5
+ // that has the standalone package keeps using it, so it is looked for first.
6
+ export const DAEMON_LAYOUTS = Object.freeze([
7
+ { name: "standalone", packageDir: "packages/standalone",
8
+ executables: ["current/bin/codex", "current/codex", "current/bin/codex.exe"],
9
+ pidFile: "app-server-daemon/app-server.pid" },
10
+ { name: "self-installed", packageDir: "packages/app-server-daemon",
11
+ executables: ["current/bin/codex", "current/bin/codex.exe"],
12
+ pidFile: "app-server-daemon/daemon.pid" },
13
+ ]);
@@ -1,6 +1,5 @@
1
- import path from "node:path";
1
+ import { readProcessArgs } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
- import { runMaintenanceCommand } from "./maintenance-host.mjs";
4
3
 
5
4
  // Which process runs a hook tells a daemon-hosted thread from an embedded chat.
6
5
  // Measured on 0.158.0: a daemon thread's hook runs under
@@ -20,20 +19,23 @@ const VALUE_OPTIONS = new Set(["-c", "--config", "-m", "--model", "-p", "--profi
20
19
  "--disable", "--local-provider"]);
21
20
 
22
21
  export async function hostArgv(pid) {
23
- const result = await runMaintenanceCommand("/bin/ps", ["-o", "args=", "-p", String(pid)],
24
- { timeout: 500 });
25
- if (result.status !== 0) throw new Error("host process unreadable");
26
- return result.stdout.trim().split(/\s+/).filter(Boolean);
22
+ const argv = await readProcessArgs(pid, { ps: "/bin/ps", timeoutMs: 500 });
23
+ if (argv === null) throw new Error("host process unreadable");
24
+ return argv;
27
25
  }
28
26
 
27
+ // Either separator, and Windows' `.exe` in any case: `codex`, `/usr/local/bin/codex`,
28
+ // `C:\Users\dana\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe`.
29
+ const programName = word => (word ?? "").split(/[\\/]/).at(-1).replace(/\.exe$/i, "").toLowerCase();
30
+
29
31
  // The arguments after Codex itself, or null for any process that is not Codex:
30
32
  // `codex ...`, or a node script named codex, as `node [options] codex.js ...`.
31
33
  function codexArguments(argv) {
32
34
  const [program, ...rest] = argv;
33
- if (path.basename(program ?? "") === "codex") return rest;
34
- if (path.basename(program ?? "") !== "node") return null;
35
+ if (programName(program) === "codex") return rest;
36
+ if (programName(program) !== "node") return null;
35
37
  const script = rest.findIndex(word => !word.startsWith("-"));
36
- return script >= 0 && path.basename(rest[script]).replace(/\.[mc]?js$/, "") === "codex"
38
+ return script >= 0 && programName(rest[script]).replace(/\.[mc]?js$/, "") === "codex"
37
39
  ? rest.slice(script + 1) : null;
38
40
  }
39
41
 
@@ -55,11 +57,83 @@ export function interactiveHost(argv) {
55
57
  return true;
56
58
  }
57
59
 
58
- /** True only when the host was read and is an interactive TUI; never throws. */
59
- export async function runsEmbedded(pid, argvOf = hostArgv) {
60
+ // Options that make Codex run a chat on its own embedded app server even while
61
+ // the daemon runs. Measured on 0.159.1, which says so only behind its status
62
+ // line: "Running without the shared background server: command-line
63
+ // configuration overrides (-c, --enable, --disable, or --search) requires
64
+ // embedded mode." The same sentence names --profile, --oss, --strict-config,
65
+ // --dangerously-bypass-hook-trust and --no-daemon.
66
+ const EMBEDDING_OPTIONS = new Set(["-c", "--config", "--enable", "--disable", "--search", "-p",
67
+ "--profile", "--oss", "--strict-config", "--dangerously-bypass-hook-trust", "--no-daemon"]);
68
+ const OPENS_CHAT = new Set(["resume", "fork"]);
69
+
70
+ // `--config=x` and `-cx` carry their value; both name the option they spell.
71
+ const optionName = word => word.startsWith("--") ? word.split("=", 1)[0] : word.slice(0, 2);
72
+
73
+ /**
74
+ * The first option on this interactive command line that keeps the chat
75
+ * embedded, as typed, or null. Scanning stops at the prompt: `ps` drops quotes,
76
+ * so a later word may be the prompt's own text.
77
+ */
78
+ export function embeddingOption(argv) {
79
+ const words = codexArguments(argv);
80
+ if (words === null) return null;
81
+ let value = false;
82
+ let opened = false;
83
+ for (const word of words) {
84
+ if (value) { value = false; continue; }
85
+ // `--` ends the options: what follows is the prompt, however it reads.
86
+ if (word === "--") return null;
87
+ if (word.startsWith("-")) {
88
+ if (EMBEDDING_OPTIONS.has(optionName(word))) return optionName(word);
89
+ value = VALUE_OPTIONS.has(word);
90
+ continue;
91
+ }
92
+ if (opened || !OPENS_CHAT.has(word)) return null;
93
+ opened = true;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Whether this command line is the Codex app's own app server: `codex
100
+ * app-server` that listens nowhere, run from inside an app bundle. Read on the
101
+ * Codex app in ChatGPT.app 26.928: every window serves its threads over stdio
102
+ * this way, never through the shared daemon (openai/codex#41014), so no other
103
+ * process can reach them.
104
+ */
105
+ export function desktopAppServer(argv) {
106
+ const words = codexArguments(argv);
107
+ if (words === null || !/\.app\/Contents\//.test(argv[0] ?? "")) return false;
108
+ if (words.some(word => word === "--listen" || word.startsWith("--listen="))) return false;
109
+ // The app puts its config overrides before the subcommand:
110
+ // `codex -c features.code_mode_host=true app-server ...` (26.928).
111
+ return subcommandOf(words) === "app-server";
112
+ }
113
+
114
+ // The first word that is neither an option nor an option's value.
115
+ function subcommandOf(words) {
116
+ let value = false;
117
+ for (const word of words) {
118
+ if (value) { value = false; continue; }
119
+ if (word.startsWith("-")) { value = VALUE_OPTIONS.has(word); continue; }
120
+ return word;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * For a host that was read and is an interactive TUI, `{ launchOption }` - the
127
+ * option that keeps it embedded, or null when its command line shows none. For
128
+ * the Codex app's own app server, `{ launchOption: null, desktop: true }`.
129
+ * Null for any other host or an unreadable one; never throws.
130
+ */
131
+ export async function embeddedHost(pid, argvOf = hostArgv) {
60
132
  try {
61
- return interactiveHost(await argvOf(pid));
133
+ const argv = await argvOf(pid);
134
+ if (interactiveHost(argv)) return { launchOption: embeddingOption(argv) };
135
+ return desktopAppServer(argv) ? { launchOption: null, desktop: true } : null;
62
136
  } catch {
63
- return false;
137
+ return null;
64
138
  }
65
139
  }
@@ -138,15 +138,25 @@ export function allowOutcome() {
138
138
  /**
139
139
  * Context for the next turn.
140
140
  *
141
- * This client wraps nothing: a UserPromptSubmit hook's stdout arrives at the
142
- * model as a `developer` role message, verbatim. So the injection is plain
143
- * text - emitting Claude Code's JSON envelope would put the envelope itself
144
- * into the conversation, the same mistake it would be on Kimi Code.
141
+ * This client wraps nothing: a UserPromptSubmit hook's plain stdout arrives at
142
+ * the model as a `developer` role message, verbatim, so the ordinary injection
143
+ * is plain text. Codex also parses a JSON answer - 0.147.0, 0.155.1 and 0.159.1
144
+ * took `hookSpecificOutput.additionalContext` into the same developer message
145
+ * and kept the envelope out of the conversation, measured 2026-09-29 - which
146
+ * is how a notice for the user rides beside the context.
145
147
  *
146
148
  * The developer role is the most direct channel of the four, which is a reason
147
149
  * to be careful with what goes into it: anything a peer wrote must stay framed
148
150
  * as data, because at this role the model reads text as instruction.
149
151
  */
150
- export function injectOutcome(context) {
151
- return { stdout: context, stderr: "", exitCode: 0 };
152
+ export function injectOutcome(context, notice) {
153
+ if (typeof notice?.userMessage !== "string") return { stdout: context, stderr: "", exitCode: 0 };
154
+ // A line the user must see rides as the hook's systemMessage, which Codex
155
+ // shows in the chat and never sends to the model; the context goes to the
156
+ // model as additionalContext, the same developer message plain text becomes.
157
+ // Measured with a probe hook on 0.147.0, 0.155.1 and 0.159.1. Only the ask's
158
+ // one turn prints JSON; every other turn keeps the plain text above.
159
+ return { stdout: JSON.stringify({ systemMessage: notice.userMessage,
160
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } }),
161
+ stderr: "", exitCode: 0 };
152
162
  }
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { bakeSkillCommand, blankJson, blankText, removeIfEmpty, removeInstalledTree,
8
8
  keepVersions, ownVersion, stampPluginVersion,
9
- tomlString, writeCliShim, writeForeignJson, writeHookShim }
9
+ tomlString, windowsHookCommand, writeCliShim, writeForeignJson, writeHookShim }
10
10
  from "@agents-can-communicate/adapter-sdk";
11
11
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
12
12
  import { inspectConfig, readConfig, sandboxOwnership, writeTomlBlock } from "./config-block.mjs";
@@ -92,11 +92,18 @@ async function readJson(file, fallback) {
92
92
  // client copies an installed plugin into a cache of its own, so the command has
93
93
  // to be absolute: a path relative to the bundle would not survive the copy.
94
94
  const shellLiteral = value => `'${String(value).replaceAll("'", "'\\''")}'`;
95
- const withShim = (wiring, shim) => ({ ...wiring, hooks: Object.fromEntries(
95
+ // On Windows Codex runs `commandWindows` in place of `command`, through
96
+ // PowerShell: the pinned node on the Node shim.
97
+ const withShim = (wiring, shim, windows = null) => ({ ...wiring, hooks: Object.fromEntries(
96
98
  Object.entries(wiring.hooks).map(([event, entries]) => [event, entries.map(entry => ({
97
99
  ...entry,
98
- hooks: entry.hooks.map(hook => ({ ...hook,
99
- command: `sh ${shellLiteral(shim)} ${hook.command.split(" ").pop()}` })),
100
+ hooks: entry.hooks.map(hook => {
101
+ const event = hook.command.split(" ").pop();
102
+ if (windows === null) return { ...hook, command: `sh ${shellLiteral(shim)} ${event}` };
103
+ return { ...hook,
104
+ command: windowsHookCommand("portable", { node: windows.node, shim, args: [event] }),
105
+ commandWindows: windowsHookCommand("powershell", { node: windows.node, shim, args: [event] }) };
106
+ }),
100
107
  }))])) });
101
108
 
102
109
  const writeJson = async (file, value) => {
@@ -163,7 +170,8 @@ const sandboxReview = (config, file, stateRoot) =>
163
170
 
164
171
  export async function installCodexPlugin({ home, agentsHome = home,
165
172
  codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, keepPreviousVersion = null,
166
- requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets }) {
173
+ requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets,
174
+ hostPlatform = process.platform }) {
167
175
  // Read before writing, so a manifest that will not parse is found before a
168
176
  // plugin tree is laid down that nothing will then be able to remove.
169
177
  const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
@@ -194,12 +202,13 @@ export async function installCodexPlugin({ home, agentsHome = home,
194
202
  // The skill ships with a placeholder where the command belongs: `acc` is
195
203
  // not on PATH everywhere, and an agent that cannot run it improvises. The
196
204
  // shim carries the pinning so each example can name one path.
197
- const cliShim = await writeCliShim({ dir: target, cli, node, dataHome });
198
- await bakeSkillCommand({ root: target, cliShim });
205
+ const cliShim = await writeCliShim({ dir: target, cli, node, dataHome, platform: hostPlatform });
206
+ await bakeSkillCommand({ root: target, cliShim, platform: hostPlatform });
199
207
  const shim = await writeHookShim({ dir: target, adapterId: "codex",
200
- dataHome, runner, node });
208
+ dataHome, runner, node, platform: hostPlatform });
201
209
  await writeJson(path.join(target, "hooks.json"),
202
- withShim(await readJson(path.join(bundle, "hooks.json"), { hooks: {} }), shim));
210
+ withShim(await readJson(path.join(bundle, "hooks.json"), { hooks: {} }), shim,
211
+ hostPlatform === "win32" ? { node: node ?? process.execPath } : null));
203
212
 
204
213
  const file = marketplacePath(agentsHome);
205
214
  // Ownership is the entry's own name. Recording it as an extra key beside the
@@ -1,28 +1,29 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
- import { access, lstat, open, realpath } from "node:fs/promises";
3
+ import { access, lstat, realpath } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+
7
+ import { closedTo, isWindowsPlatform, openRegularNoFollow, readWindowsProcess, resolveExecutable,
8
+ runExecutable } from "@agents-can-communicate/adapter-sdk";
9
+
6
10
  import { compareVersions, versionOrder } from "./app-server-client.mjs";
11
+ import { DAEMON_LAYOUTS } from "./daemon-layouts.mjs";
12
+ import { socketIsReady } from "./native-endpoint.mjs";
7
13
 
8
14
  export const MINIMUM_MAINTENANCE_CLI = "0.154.0";
9
15
  export const absolute = value => typeof value === "string" && path.isAbsolute(value)
10
16
  && !/[\0\r\n]/.test(value);
11
17
  export const failMaintenance = reasonCode => { throw Object.assign(new Error(reasonCode), { reasonCode }); };
12
18
  const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
19
+ // `ps -o lstart` on POSIX; a FILETIME string on Windows, where Codex records one
20
+ // and WMI reports one to the microsecond (measured on 0.159.3).
21
+ const FILETIME = /^\d{15,19}$/;
13
22
  export const startTimeValid = value => typeof value === "string"
14
- && /^[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d\d:\d\d:\d\d \d{4}$/.test(value);
15
- // The two packages a Codex daemon runs from, measured. The standalone install
16
- // keeps its PID in app-server.pid (0.154.0-0.159.0); a home with no standalone
17
- // package gets the daemon's own package from `app-server daemon start` on
18
- // 0.157.1 and newer, with its PID in daemon.pid (0.159.0, 2026-09-29). A home
19
- // that has the standalone package keeps using it, so it is looked for first.
20
- const DAEMON_LAYOUTS = Object.freeze([
21
- { name: "standalone", packageDir: "packages/standalone", executables: ["current/bin/codex", "current/codex"],
22
- pidFile: "app-server-daemon/app-server.pid" },
23
- { name: "self-installed", packageDir: "packages/app-server-daemon", executables: ["current/bin/codex"],
24
- pidFile: "app-server-daemon/daemon.pid" },
25
- ]);
23
+ && (/^[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d\d:\d\d:\d\d \d{4}$/.test(value) || FILETIME.test(value));
24
+ // WMI keeps a creation time in microseconds and a FILETIME counts tenths of one.
25
+ const sameStart = (observed, recorded) => (FILETIME.test(observed ?? "") && FILETIME.test(recorded ?? "")
26
+ ? BigInt(observed) / 10n === BigInt(recorded) / 10n : observed === recorded);
26
27
  export const managedExecutablePaths = codexHome => DAEMON_LAYOUTS.flatMap(layout =>
27
28
  layout.executables.map(name => path.join(codexHome, layout.packageDir, name)));
28
29
 
@@ -41,6 +42,13 @@ export async function resolveLsof({ access: check = access } = {}) {
41
42
  }
42
43
 
43
44
  export function runMaintenanceCommand(command, args, options) {
45
+ // Windows finds codex as an npm .cmd, which starts only through cmd.exe.
46
+ if (process.platform === "win32") {
47
+ return runExecutable(command, args, { timeout: 20_000, ...options, maxBuffer: 131_072 })
48
+ .then(({ stdout, stderr }) => ({ status: 0, stdout, stderr }),
49
+ error => ({ status: typeof error?.code === "number" ? error.code : null,
50
+ stdout: error?.stdout ?? "", stderr: error?.stderr ?? "" }));
51
+ }
44
52
  return new Promise(resolve => execFile(command, args,
45
53
  { timeout: 20_000, ...options, maxBuffer: 131_072, windowsHide: true },
46
54
  (error, stdout, stderr) => resolve({ status: error ?
@@ -96,6 +104,11 @@ export async function metadataExists(file) {
96
104
  }
97
105
 
98
106
  async function resolveCli(pathEnv) {
107
+ if (process.platform === "win32") {
108
+ const found = await resolveExecutable("codex", { pathEnv });
109
+ if (found !== null) return found;
110
+ failMaintenance("maintenance_cli_unavailable");
111
+ }
99
112
  for (const directory of String(pathEnv ?? "").split(path.delimiter)) {
100
113
  if (!absolute(directory)) continue;
101
114
  const candidate = path.join(directory, "codex");
@@ -130,12 +143,12 @@ export async function probeMaintenanceInstall(paths, run) {
130
143
  return { cliPath, cliVersion: version, managedVersion };
131
144
  }
132
145
 
133
- export async function readMaintenancePid(file) {
146
+ export async function readMaintenancePid(file, { platform = process.platform } = {}) {
134
147
  let handle;
135
148
  try {
136
- handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
149
+ handle = await openRegularNoFollow(file, undefined, { platform });
137
150
  const info = await handle.stat();
138
- if (!info.isFile() || !own(info) || info.size > 4_096 || (info.mode & 0o022) !== 0) {
151
+ if (!info.isFile() || !own(info) || info.size > 4_096 || !closedTo(info, 0o022, { platform })) {
139
152
  failMaintenance("daemon_identity_unavailable");
140
153
  }
141
154
  const value = JSON.parse(await handle.readFile("utf8"));
@@ -146,7 +159,23 @@ export async function readMaintenancePid(file) {
146
159
  } finally { await handle?.close(); }
147
160
  }
148
161
 
149
- export async function observeMaintenanceProcess(pid, paths, run) {
162
+ const processAlive = pid => {
163
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
164
+ };
165
+ // Windows reads one process through WMI. An empty answer cannot tell a gone
166
+ // process from an unreadable one, so whether the pid lives is asked first.
167
+ const WINDOWS_SYSTEM = Object.freeze({ alive: processAlive,
168
+ readProcess: pid => readWindowsProcess(pid, { timeoutMs: 5_000 }) });
169
+
170
+ export async function observeMaintenanceProcess(pid, paths, run, { system = WINDOWS_SYSTEM } = {}) {
171
+ if (isWindowsPlatform(paths.platform)) {
172
+ if (!system.alive(pid)) return { state: "dead" };
173
+ const found = await system.readProcess(pid).catch(() => null);
174
+ if (found?.pid !== pid || !startTimeValid(found.start) || typeof found.cmd !== "string") {
175
+ return { state: "unknown" };
176
+ }
177
+ return { state: "alive", processStartTime: found.start, command: found.cmd };
178
+ }
150
179
  const response = await run("/bin/ps", ["-p", String(pid), "-o", "lstart=,command="], paths.options);
151
180
  if (response.status === 1 && response.stdout === "" && response.stderr === "") return { state: "dead" };
152
181
  if (response.status !== 0) return { state: "unknown" };
@@ -170,16 +199,28 @@ export function socketListedIn(stdout, socketPaths) {
170
199
  // `--managed-daemon` appended. The executable must live in the package tree of
171
200
  // the installed layout and be the managed binary itself, however it is named.
172
201
  const DAEMON_COMMAND = /^(\/.+?) app-server --listen unix:\/\/(?: --managed-daemon)?$/;
173
-
174
- export async function verifyMaintenanceProcess(snapshot, paths, run, { lsof = resolveLsof } = {}) {
175
- const process = await observeMaintenanceProcess(snapshot.pid, paths, run);
176
- const executable = DAEMON_COMMAND.exec(process.command ?? "")?.[1] ?? null;
202
+ // Windows quotes the path and gives it the \\?\ prefix (measured on 0.159.3).
203
+ const WINDOWS_DAEMON_COMMAND = /^"(?:\\\\\?\\)?([^"]+)" app-server --listen unix:\/\/(?: --managed-daemon)?$/;
204
+
205
+ export async function verifyMaintenanceProcess(snapshot, paths, run, { lsof = resolveLsof,
206
+ system = WINDOWS_SYSTEM } = {}) {
207
+ const windows = isWindowsPlatform(paths.platform);
208
+ const process = await observeMaintenanceProcess(snapshot.pid, paths, run, { system });
209
+ const executable = (windows ? WINDOWS_DAEMON_COMMAND : DAEMON_COMMAND).exec(process.command ?? "")?.[1]
210
+ ?? null;
177
211
  const packages = (paths.packageDir ?? path.join(paths.codexHome, "packages", "standalone")) + path.sep;
178
- if (process.state !== "alive" || process.processStartTime !== snapshot.processStartTime
179
- || executable === null || !executable.startsWith(packages)
180
- || await realpath(executable).catch(() => null) !== await realpath(paths.managedPath)) {
212
+ const inside = executable !== null && (windows
213
+ ? executable.toLowerCase().startsWith(packages.toLowerCase()) : executable.startsWith(packages));
214
+ if (process.state !== "alive" || !sameStart(process.processStartTime, snapshot.processStartTime)
215
+ || !inside || await realpath(executable).catch(() => null) !== await realpath(paths.managedPath)) {
181
216
  failMaintenance("daemon_identity_unavailable");
182
217
  }
218
+ // Windows has no lsof, and Node's lstat refuses the AF_UNIX socket file; Codex
219
+ // keeps the socket's directory to its user, and that directory lists it.
220
+ if (windows) {
221
+ if (!await socketIsReady(paths.socketPath, { platform: "win32" })) failMaintenance("daemon_socket_unproven");
222
+ return;
223
+ }
183
224
  // 0.157.1 lists the socket by the path it listens on, not by the symlink it
184
225
  // reports; either names the same socket.
185
226
  const listening = [paths.socketPath, await realpath(paths.socketPath).catch(() => null)]
@@ -192,8 +233,8 @@ export async function verifyMaintenanceProcess(snapshot, paths, run, { lsof = re
192
233
  }
193
234
  }
194
235
 
195
- export async function approvedProcessIsDead(expected, paths, run) {
196
- const observed = await observeMaintenanceProcess(expected.pid, paths, run);
236
+ export async function approvedProcessIsDead(expected, paths, run, { system = WINDOWS_SYSTEM } = {}) {
237
+ const observed = await observeMaintenanceProcess(expected.pid, paths, run, { system });
197
238
  return observed.state === "dead" || (observed.state === "alive"
198
- && observed.processStartTime !== expected.processStartTime);
239
+ && !sameStart(observed.processStartTime, expected.processStartTime));
199
240
  }