agents-can-communicate 0.8.5 → 0.9.1

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 +29 -0
  6. package/docs/CAPABILITIES.md +7 -6
  7. package/docs/CLI.md +5 -0
  8. package/docs/CONFIGURATION.md +41 -10
  9. package/docs/DESIGN_DECISIONS.md +0 -2
  10. package/docs/GETTING_STARTED.md +39 -6
  11. package/docs/HOW_IT_WORKS.md +13 -8
  12. package/docs/SECURITY_MODEL.md +32 -5
  13. package/docs/TROUBLESHOOTING.md +76 -8
  14. package/docs/UPGRADING.md +47 -5
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +3 -0
  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/plugin/skills/acc/SKILL.md +3 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  44. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  45. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +3 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -13
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +3 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  61. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +3 -0
  63. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  64. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  67. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  68. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +27 -1
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  81. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  82. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  84. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  85. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  96. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
  97. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  98. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  99. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  100. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  101. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  103. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  104. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  105. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  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 +1 -1
  111. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  112. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  113. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
  114. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  115. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
  116. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  117. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  118. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  119. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  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
@@ -28,15 +28,17 @@ const usage = (message, details) => {
28
28
  throw new AccError(EXIT.USAGE, message, details);
29
29
  };
30
30
 
31
+ // Pids, locks and pipe names belong to one machine, so state and cache live in
32
+ // local app data. A roaming profile would carry them to another machine, where
33
+ // every recorded pid names something else; there is no roaming fallback.
31
34
  function windowsPaths(env) {
32
- if (!set(env.APPDATA)) {
33
- usage("cannot resolve the Windows application data directory");
35
+ if (!set(env.LOCALAPPDATA)) {
36
+ usage("cannot resolve the Windows local application data directory");
34
37
  }
35
38
  return {
36
- data: env.APPDATA,
37
- config: env.APPDATA,
38
- // Roaming is the honest fallback: a cache that roams is wasteful, not wrong.
39
- cache: set(env.LOCALAPPDATA) ? env.LOCALAPPDATA : env.APPDATA,
39
+ data: env.LOCALAPPDATA,
40
+ config: set(env.APPDATA) ? env.APPDATA : env.LOCALAPPDATA,
41
+ cache: env.LOCALAPPDATA,
40
42
  };
41
43
  }
42
44
 
@@ -45,18 +47,21 @@ function requireHome(env) {
45
47
  return env;
46
48
  }
47
49
 
50
+ // POSIX paths are built with path.posix whatever the host: the resolution is a
51
+ // pure function of platform and environment, and a Windows host would turn
52
+ // every forward slash of a POSIX home into a backslash.
48
53
  function macosPaths(env) {
49
- const support = path.join(env.HOME, "Library", "Application Support");
54
+ const support = path.posix.join(env.HOME, "Library", "Application Support");
50
55
  // XDG variables are deliberately ignored here. They are common on a machine
51
56
  // that also runs Linux tooling, and letting one relocate macOS state would
52
57
  // move a user's sessions the day they install something unrelated.
53
58
  return { data: support, config: support,
54
- cache: path.join(env.HOME, "Library", "Caches") };
59
+ cache: path.posix.join(env.HOME, "Library", "Caches") };
55
60
  }
56
61
 
57
62
  function xdgPaths(env) {
58
- const fallback = { data: path.join(env.HOME, ".local", "share"),
59
- config: path.join(env.HOME, ".config"), cache: path.join(env.HOME, ".cache") };
63
+ const fallback = { data: path.posix.join(env.HOME, ".local", "share"),
64
+ config: path.posix.join(env.HOME, ".config"), cache: path.posix.join(env.HOME, ".cache") };
60
65
  return Object.fromEntries(AREAS.map(area =>
61
66
  [area, set(env[XDG[area]]) ? env[XDG[area]] : fallback[area]]));
62
67
  }
@@ -54,9 +54,10 @@ export function platformDataHome({ platform = process.platform, env = process.en
54
54
  if (typeof env.ACC_DATA_HOME === "string" && env.ACC_DATA_HOME.length > 0) {
55
55
  return env.ACC_DATA_HOME;
56
56
  }
57
+ // Machine-local: see windowsPaths in platform-paths.mjs.
57
58
  if (platform === "win32") {
58
- if (typeof env.APPDATA === "string" && env.APPDATA.length > 0) return env.APPDATA;
59
- throw new AccError(EXIT.USAGE, "cannot resolve the Windows application data directory");
59
+ if (typeof env.LOCALAPPDATA === "string" && env.LOCALAPPDATA.length > 0) return env.LOCALAPPDATA;
60
+ throw new AccError(EXIT.USAGE, "cannot resolve the Windows local application data directory");
60
61
  }
61
62
  if (typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.length > 0
62
63
  && platform !== "darwin") {
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
 
5
+ import { pathOf, resolveExecutable, runExecutable } from "@agents-can-communicate/adapter-sdk";
5
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
7
 
7
8
  import { platformPaths } from "./platform-paths.mjs";
@@ -11,6 +12,15 @@ import { runManagedUpdate, validateUpdateOptions } from "./managed-runtime/comma
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
15
+ // npm and acc are .cmd shims on Windows: found through PATHEXT, and started
16
+ // through cmd.exe. POSIX runs them by name as before.
17
+ async function runStep(command, argv, env) {
18
+ if (process.platform !== "win32") return execFileAsync(command, argv, { env });
19
+ const file = await resolveExecutable(command, { pathEnv: pathOf(env) });
20
+ if (file === null) throw new Error(`${command} is not on PATH`);
21
+ return runExecutable(file, argv, { env });
22
+ }
23
+
14
24
  /**
15
25
  * The two commands an upgrade takes, and why it is two.
16
26
  *
@@ -76,7 +86,7 @@ export async function runUpdateCommand({ options, runtime }) {
76
86
  ...steps.map(spell), "", "or run: acc update"].join("\n") };
77
87
  }
78
88
 
79
- const spawn = runtime.spawn ?? ((command, argv) => execFileAsync(command, argv, { env }));
89
+ const spawn = runtime.spawn ?? ((command, argv) => runStep(command, argv, env));
80
90
  const done = [];
81
91
  let installation = { stdout: "", stderr: "" };
82
92
  for (const [command, argv] of steps) {
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { open, realpath } from "node:fs/promises";
3
+ import { realpath } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
6
  import { AccError, CONFIG_FILENAME, EXIT, validateProjectConfig }
7
7
  from "@agents-can-communicate/protocol";
8
+ import { openNoFollow } from "@agents-can-communicate/storage-filesystem";
8
9
 
9
10
  /**
10
11
  * @typedef {{ id: string, roots: string[], source: "config" | "git" | "directory",
@@ -17,7 +18,8 @@ const stableId = value =>
17
18
  async function readConfigNoFollow(configPath) {
18
19
  let handle;
19
20
  try {
20
- handle = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
21
+ // Windows has no O_NOFOLLOW; openNoFollow keeps the refusal there too.
22
+ handle = await openNoFollow(configPath, constants.O_RDONLY);
21
23
  } catch (error) {
22
24
  if (error.code === "ENOENT") return null;
23
25
  // ELOOP is what O_NOFOLLOW reports for a symlink. A config reached through
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -91,6 +91,13 @@ export function createDeliveryBindingService(ports, sessions) {
91
91
  async function clearDeliveryBinding(input) {
92
92
  const { sessionId, generation } = input;
93
93
  const endpointScoped = Object.hasOwn(input, "opaqueEndpointRef");
94
+ // Every hook clears when live delivery is not running, and on Windows that
95
+ // is every hook. Absent, or retired for this generation, is a state the
96
+ // update below leaves exactly as it is, so it is read first without the
97
+ // writer lock; a record read this way is as current as one read under it.
98
+ const seen = await store.ephemeral.get("deliveryBinding", sessionId);
99
+ if (seen === null || seen === undefined) return;
100
+ if (seen.generation === generation && seen.retiredAt !== null && seen.retiredAt !== undefined) return;
94
101
  await store.ephemeral.update("deliveryBinding", sessionId, async current => {
95
102
  if (current === null || current === undefined) return null;
96
103
  if (current.generation !== generation) throw conflict(sessionId);
@@ -21,9 +21,19 @@ export async function isMaterialised(store, workspaceId) {
21
21
  return (await store.snapshot(workspaceId, { kinds: ["workspace"] })).workspace !== null;
22
22
  }
23
23
 
24
- export async function materialise({ store, clock, ids }, { workspaceId, descriptor, reason }) {
24
+ /**
25
+ * `opening` is a second live session that opens here, in the transaction that
26
+ * materialises, instead of first writing an ephemeral copy of itself to be
27
+ * copied and retired a moment later. It opens only while the session it joins
28
+ * is still live under the lock; otherwise it would be a lone session, which
29
+ * leaves no durable trace, and nothing is written: the result is false and the
30
+ * caller opens it ephemerally.
31
+ */
32
+ export async function materialise({ store, clock, ids },
33
+ { workspaceId, descriptor, reason, opening = null }) {
25
34
  const now = clock.now();
26
35
  const staged = [];
36
+ let joined = true;
27
37
 
28
38
  await store.transaction(async tx => {
29
39
  // Ephemeral writers use this same mutex. Reading before taking it could
@@ -31,6 +41,13 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
31
41
  for (const entry of EPHEMERAL_KINDS) {
32
42
  staged.push({ ...entry, records: await store.ephemeral.list(entry.kind) });
33
43
  }
44
+ if (opening !== null && tx.get("workspace", workspaceId) === null
45
+ && !staged.find(entry => entry.kind === "session").records.some(record =>
46
+ record.state === "open" && record.sessionId !== opening.session.sessionId)) {
47
+ joined = false;
48
+ staged.length = 0;
49
+ return;
50
+ }
34
51
  const actorSessionId = staged.find(entry => entry.kind === "session")?.records.at(-1)?.sessionId
35
52
  ?? "session_bootstrap";
36
53
  // Asked again inside the lock. `ensureMaterialised` asks before taking it,
@@ -53,8 +70,14 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
53
70
  payload: { reason } });
54
71
  }
55
72
 
73
+ // The opening session's own ephemeral copy - left by a process that died
74
+ // mid-open - is what the opening replaces, not a session to promote first:
75
+ // promoting it would record the session opening twice.
76
+ const own = opening === null ? null : staged.find(entry => entry.kind === "session")
77
+ .records.find(record => record.sessionId === opening.session.sessionId) ?? null;
56
78
  for (const entry of staged) {
57
79
  for (const record of entry.records) {
80
+ if (record === own) continue;
58
81
  // Whoever got here first may have promoted this record already, and
59
82
  // promoting is a copy: the ephemeral and durable shapes are the same,
60
83
  // so the one already there is the one this would write.
@@ -66,11 +89,25 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
66
89
  payload: { sessionId: record.sessionId } });
67
90
  }
68
91
  }
92
+
93
+ if (opening !== null) {
94
+ const { participant, session } = opening;
95
+ const current = tx.get("session", session.sessionId) ?? own;
96
+ opening.assertAvailable(current);
97
+ if (tx.get("participant", participant.participantId) === null) {
98
+ tx.put("participant", participant.participantId, participant);
99
+ }
100
+ tx.put("session", session.sessionId, session, tx.generationOf("session", session.sessionId));
101
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
102
+ actorSessionId: session.sessionId, type: "session.opened", occurredAt: now,
103
+ payload: { replaced: current?.generation ?? null } });
104
+ }
69
105
  // The promoted kinds are named by the loop above, not written literally in
70
106
  // the body, so they are derived rather than repeated: a new ephemeral kind
71
107
  // added to that list is read here without anyone remembering to say so.
72
108
  }, { kinds: ["workspace", ...EPHEMERAL_KINDS.map(entry => entry.kind)] });
73
109
 
110
+ if (!joined) return false;
74
111
  // The ephemeral copies are retired only after the durable transaction
75
112
  // committed, so a crash in between leaves a recoverable duplicate rather than
76
113
  // a hole.
@@ -79,6 +116,7 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
79
116
  await store.ephemeral.delete(entry.kind, record[entry.key]);
80
117
  }
81
118
  }
119
+ return true;
82
120
  }
83
121
 
84
122
  export async function ensureMaterialised(ports, { workspaceId, descriptor, reason }) {
@@ -100,6 +100,17 @@ export function createSessionOpener(ports, locate) {
100
100
  };
101
101
  if (await isMaterialised(store, workspaceId)) return openDurable();
102
102
 
103
+ // Another live session already here makes this the second, which
104
+ // materialises the workspace: this one opens in that same transaction
105
+ // rather than as an ephemeral copy to be copied and retired at once. The
106
+ // look is taken without the lock; materialise asks again under it.
107
+ const joining = (await store.ephemeral.list("session")).some(item => item.state === "open"
108
+ && item.sessionId !== sessionId);
109
+ if (joining && await materialise(ports, { workspaceId, descriptor: input.descriptor,
110
+ reason: "second_live_session", opening: { participant, session, assertAvailable } })) {
111
+ return session;
112
+ }
113
+
103
114
  // Preparing a participant must not rename one that already exists, or
104
115
  // recreate an ephemeral copy after promotion has taken ownership.
105
116
  await store.ephemeral.update("participant", participant.participantId, async current =>
@@ -92,8 +92,14 @@ export function createSessionService(ports) {
92
92
  // presence transitions surface through cursor sync. Read and validate under
93
93
  // the writer lock, so a pending heartbeat cannot restore an old generation.
94
94
  // Promoted copies may await cleanup; their durable record already owns writes.
95
- const ephemeral = await store.ephemeral.update("session", sessionId, async current =>
96
- current !== null && !await isMaterialised(store, current.workspaceId) ? beat(current) : null);
95
+ // The update below writes only a lone session's ephemeral record. A session
96
+ // with none, or in a materialised workspace, is durable - and stays so: a
97
+ // workspace never returns to ephemeral - so it goes straight to the durable
98
+ // record rather than taking the writer lock to find nothing.
99
+ const seen = await store.ephemeral.get("session", sessionId);
100
+ const ephemeral = seen === null || await isMaterialised(store, seen.workspaceId) ? null
101
+ : await store.ephemeral.update("session", sessionId, async current =>
102
+ current !== null && !await isMaterialised(store, current.workspaceId) ? beat(current) : null);
97
103
  if (ephemeral !== null) return ephemeral;
98
104
  return store.transaction(tx => {
99
105
  const beaten = beat(tx.get("session", sessionId));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,4 @@
1
- import path from "node:path";
1
+ import { splitWindowsCommandLine } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
3
  // Deep enough for a client that wraps its hook in a shell and a launcher, short
4
4
  // enough that a table which disagrees with itself cannot spin.
@@ -8,11 +8,35 @@ const MAX_HOPS = 16;
8
8
  // interpreter. Gemini CLI is one, measured on 0.60.0. For these, the script the
9
9
  // interpreter was given names the client.
10
10
  const SCRIPT_HOSTS = new Set(["node"]);
11
+ // What scriptsOf reports for a script inside the client's own package.
12
+ const CLIENT_PACKAGE = Symbol("client package");
11
13
  const SCRIPT_EXTENSION = /\.[mc]?js$/;
12
14
 
15
+ // Either separator: `ps` names POSIX paths, Win32_Process names Windows ones,
16
+ // and the host running the walk may be either.
17
+ const basename = value => value.split(/[\\/]/).at(-1);
18
+
19
+ /** An image name as the client's command: Windows adds `.exe` in any case. */
20
+ function imageName(comm) {
21
+ const name = basename(comm);
22
+ return /\.exe$/i.test(name) ? name.slice(0, -4).toLowerCase() : name;
23
+ }
24
+
25
+ /** The words of a command line: Windows quotes paths with spaces. */
26
+ const wordsOf = entry => (/\.exe$/i.test(entry.comm) ? splitWindowsCommandLine(entry.args)
27
+ : entry.args.trim().split(/\s+/));
28
+
13
29
  /** What the script a script host runs could be called, without extensions. */
14
- function scriptsOf(entry) {
15
- if (!SCRIPT_HOSTS.has(path.basename(entry.comm)) || typeof entry.args !== "string") return [];
30
+ function scriptsOf(entry, clientPackage) {
31
+ if (!SCRIPT_HOSTS.has(imageName(entry.comm)) || typeof entry.args !== "string") return [];
32
+ if (/\.exe$/i.test(entry.comm)) {
33
+ // Windows: the client is node.exe running its package's entry file, whose
34
+ // own name (`index.js`) says nothing; the package directory does.
35
+ const script = wordsOf(entry).slice(1).find(word => !word.startsWith("-"));
36
+ const inPackage = typeof clientPackage === "string" && typeof script === "string"
37
+ && script.replaceAll("\\", "/").includes(`/node_modules/${clientPackage}/`);
38
+ return inPackage ? [CLIENT_PACKAGE] : [];
39
+ }
16
40
  // `args` starts with the interpreter as it was invoked, which is the comm
17
41
  // itself on macOS; past it, the first word that is no option is the script -
18
42
  // unless an option written without `=` came right before it, since that word
@@ -26,7 +50,7 @@ function scriptsOf(entry) {
26
50
  for (const word of rest.trim().split(/\s+/)) {
27
51
  if (word === "") continue;
28
52
  if (word.startsWith("-")) { afterBareOption = !word.includes("="); continue; }
29
- candidates.push(path.basename(word).replace(SCRIPT_EXTENSION, ""));
53
+ candidates.push(basename(word).replace(SCRIPT_EXTENSION, ""));
30
54
  if (!afterBareOption) break;
31
55
  afterBareOption = false;
32
56
  }
@@ -44,16 +68,51 @@ function scriptsOf(entry) {
44
68
  *
45
69
  * Null is a first-class answer: it means judge this session by age alone.
46
70
  */
47
- export function resolveClientPid({ table, from, command, maxHops = MAX_HOPS }) {
71
+ export function resolveClientPid({ table, from, command, clientPackage, maxHops = MAX_HOPS }) {
72
+ return resolveClient({ table, from, command, clientPackage, maxHops })?.pid ?? null;
73
+ }
74
+
75
+ // What an adapter's identifyClientProcess said about one ancestor, or null.
76
+ // It reads a vendor's command line, so a throw is a "no", never a failed hook.
77
+ function recognised(identify, entry) {
78
+ if (typeof identify !== "function") return null;
79
+ try {
80
+ const answer = identify({ comm: entry.comm, args: typeof entry.args === "string" ? entry.args : "" });
81
+ return answer !== null && typeof answer === "object" ? answer : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The client this hook runs under: its pid, its process-table entry, and what
89
+ * the adapter recognised it as - another product it serves, and that
90
+ * product's version when the process names it. Null when nobody knows.
91
+ *
92
+ * At each ancestor the adapter's own identifier is asked first; the declared
93
+ * command still matches whatever it does not recognise. Antigravity 2.0 runs
94
+ * hooks under a language server that names its version on its command line,
95
+ * where Antigravity CLI runs them under `agy`.
96
+ */
97
+ export function resolveClient({ table, from, command, clientPackage, identify,
98
+ maxHops = MAX_HOPS }) {
48
99
  const seen = new Set();
49
100
  let current = from;
50
101
  for (let hop = 0; hop < maxHops; hop += 1) {
51
102
  const entry = table.get(current);
52
103
  if (entry === undefined || seen.has(current)) return null;
53
104
  seen.add(current);
105
+ const product = recognised(identify, entry);
106
+ if (product !== null) {
107
+ return { pid: current, entry, certificationName: product.certificationName,
108
+ version: product.version };
109
+ }
54
110
  // `ps` reports some entries bare (`claude`) and some with a path
55
111
  // (`/bin/zsh`), so the comparison has to be on the basename.
56
- if (path.basename(entry.comm) === command || scriptsOf(entry).includes(command)) return current;
112
+ if (imageName(entry.comm) === command
113
+ || scriptsOf(entry, clientPackage).some(name => name === command || name === CLIENT_PACKAGE)) {
114
+ return { pid: current, entry, certificationName: undefined, version: undefined };
115
+ }
57
116
  if (entry.ppid === current || entry.ppid <= 1) return null;
58
117
  current = entry.ppid;
59
118
  }
@@ -1,4 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFile as readText } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { resolveExecutable, runExecutable } from "@agents-can-communicate/adapter-sdk";
2
6
 
3
7
  const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
4
8
 
@@ -8,6 +12,7 @@ export function parseClientVersion(output) {
8
12
 
9
13
  /** Probe the client binary once when its real session starts. */
10
14
  export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
15
+ if (process.platform === "win32") return probeOnWindows(adapter, timeoutMs);
11
16
  return new Promise(resolve => {
12
17
  execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
13
18
  timeout: timeoutMs,
@@ -19,3 +24,78 @@ export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
19
24
  });
20
25
  });
21
26
  }
27
+
28
+ /**
29
+ * The executable a client process runs, when `ps` names it by absolute path and
30
+ * that path is the client's command: a desktop app starts its own build by full
31
+ * path (Claude.app's claude, ChatGPT.app's codex), and asking that file is
32
+ * asking the session's own build. Null otherwise, and always on Windows, whose
33
+ * process names carry no path.
34
+ */
35
+ export function ownExecutable(entry, command) {
36
+ if (process.platform === "win32" || typeof entry?.comm !== "string") return null;
37
+ return path.isAbsolute(entry.comm) && path.basename(entry.comm) === command ? entry.comm : null;
38
+ }
39
+
40
+ /** That executable's `--version`, parsed like the PATH probe's; null on any failure. */
41
+ export function probeExecutableVersion(file, versionArgs = ["--version"], { timeoutMs = 1_000 } = {}) {
42
+ return new Promise(resolve => {
43
+ execFile(file, versionArgs, { timeout: timeoutMs, killSignal: "SIGKILL", windowsHide: true },
44
+ (error, stdout, stderr) => resolve(error === null ? parseClientVersion(`${stdout}${stderr}`) : null));
45
+ });
46
+ }
47
+
48
+ // A package script the npm .cmd shim runs: `"%dp0%\<path inside the prefix>"`.
49
+ const SHIM_TARGET = /"%dp0%\\([^"%]+\.(?:c|m)?js)"/gi;
50
+ const DEPTH = 6;
51
+
52
+ /**
53
+ * The version of the package an npm .cmd shim runs, or null when the file is no
54
+ * such shim. npm writes a .cmd per bin that runs node on the package's script;
55
+ * the package whose `bin` names that script is the client, and its version is
56
+ * what the client's --version prints - without starting cmd.exe, node and, for
57
+ * Codex, the native binary behind it.
58
+ */
59
+ export async function npmShimVersion(file, { readFile = readText } = {}) {
60
+ try {
61
+ const targets = [...String(await readFile(file, "utf8")).matchAll(SHIM_TARGET)];
62
+ if (targets.length !== 1) return null;
63
+ const script = path.win32.join(path.win32.dirname(file), targets[0][1]);
64
+ let directory = path.win32.dirname(script);
65
+ for (let depth = 0; depth < DEPTH; depth += 1) {
66
+ const manifest = await readFile(path.win32.join(directory, "package.json"), "utf8")
67
+ .then(JSON.parse, error => (error.code === "ENOENT" ? null : Promise.reject(error)));
68
+ if (manifest !== null) {
69
+ const bins = typeof manifest.bin === "string" ? [manifest.bin] : Object.values(manifest.bin ?? {});
70
+ const runs = bins.some(bin => typeof bin === "string"
71
+ && path.win32.resolve(directory, bin).toLowerCase() === path.win32.resolve(script).toLowerCase());
72
+ return runs ? parseClientVersion(String(manifest.version)) : null;
73
+ }
74
+ const parent = path.win32.dirname(directory);
75
+ if (parent === directory) return null;
76
+ directory = parent;
77
+ }
78
+ return null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // Windows finds `codex.cmd` or `claude.exe`, never the bare name, and starts a
85
+ // .cmd only through cmd.exe.
86
+ async function probeOnWindows(adapter, timeoutMs) {
87
+ const started = Date.now();
88
+ try {
89
+ const file = await resolveExecutable(adapter.client.command);
90
+ if (file === null) return null;
91
+ if (/\.cmd$/i.test(file)) {
92
+ const packaged = await npmShimVersion(file);
93
+ if (packaged !== null) return packaged;
94
+ }
95
+ const { stdout, stderr } = await runExecutable(file, adapter.client.versionArgs ?? ["--version"],
96
+ { timeout: Math.max(1, timeoutMs - (Date.now() - started)), killSignal: "SIGKILL" });
97
+ return parseClientVersion(`${stdout}${stderr}`);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
@@ -1,5 +1,5 @@
1
1
  import { storeNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
- import { readInstalledLivePolicyState } from "@agents-can-communicate/installer";
2
+ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer/hook-support";
3
3
 
4
4
  import { establishNativeBinding } from "./native-binding.mjs";
5
5
 
@@ -1,11 +1,39 @@
1
- import { assertPortableId } from "@agents-can-communicate/protocol";
1
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
2
2
 
3
- const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`;
3
+ const posixQuote = value => `'${value.replaceAll("'", "'\\''")}'`;
4
+ // The model appends the header in whatever shell its client gives it, and on
5
+ // Windows that can be cmd.exe, PowerShell or Git Bash. Double quotes are the one
6
+ // quoting all three read alike, for a value with none of what one of them acts
7
+ // on inside double quotes: a double quote - PowerShell also ends a string at
8
+ // U+201C to U+201E, which NTFS allows in names - $ and a backtick, which
9
+ // PowerShell and bash expand, and %, which cmd expands. Single quotes are no
10
+ // answer: cmd takes them as part of the path. Such a value is refused rather
11
+ // than handed to a model that would read it as something else.
12
+ const DOUBLE_QUOTE_BREAKERS = /["\u201C-\u201E$`%]/;
13
+ const windowsQuote = value => {
14
+ if (!DOUBLE_QUOTE_BREAKERS.test(value)) return `"${value}"`;
15
+ throw new AccError(EXIT.USAGE, `${value} has a double quote, $, \` or % in it, which no quoting `
16
+ + "reads alike in cmd, PowerShell and Git Bash, and ACC's hooks will not hand the model a "
17
+ + "command line it would read differently; rename the directory",
18
+ { value, reasonCode: "workspace_path_unquotable" });
19
+ };
4
20
 
5
- export const ownerHeader = (binding, cwd, workspaceRef) => "ACC CLI (append): --session "
6
- + assertPortableId(binding.accSessionId, "sessionId") + " --generation "
7
- + assertPortableId(binding.generation, "generation") + " --cwd " + shellQuote(cwd)
8
- + (workspaceRef === undefined ? "" : " --workspace " + shellQuote(workspaceRef));
21
+ // Before anything opens: a session that attached and could then never be told
22
+ // its CLI arguments would look live to its peers, and every one of its turns
23
+ // would fail before their messages reached it.
24
+ export function assertOwnerQuotable(cwd, workspaceRef, platform = process.platform) {
25
+ if (platform !== "win32") return;
26
+ windowsQuote(cwd);
27
+ if (workspaceRef !== undefined) windowsQuote(workspaceRef);
28
+ }
29
+
30
+ export const ownerHeader = (binding, cwd, workspaceRef, platform = process.platform) => {
31
+ const quote = platform === "win32" ? windowsQuote : posixQuote;
32
+ return "ACC CLI (append): --session "
33
+ + assertPortableId(binding.accSessionId, "sessionId") + " --generation "
34
+ + assertPortableId(binding.generation, "generation") + " --cwd " + quote(cwd)
35
+ + (workspaceRef === undefined ? "" : " --workspace " + quote(workspaceRef));
36
+ };
9
37
 
10
38
  export function ownerOnlyOutcome(inject, owner, budgetBytes) {
11
39
  if (Buffer.byteLength(owner, "utf8") > budgetBytes) {
@@ -1,50 +1,3 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- const run = promisify(execFile);
5
-
6
- // A hook runs in front of someone's turn. Reading the table is worth a few
7
- // hundred milliseconds once per session and nothing at all if it is slow.
8
- const DEFAULT_TIMEOUT_MS = 1_000;
9
-
10
- const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
11
- // A pid, then the whole rest of the line: a command line has spaces anywhere.
12
- const ARGS_LINE = /^\s*(\d+)\s+(.+?)\s*$/;
13
- // Command lines are long; measured at 231 KB for 1,137 processes on a desktop
14
- // Mac, a quarter of execFile's default buffer.
15
- const ARGS_BUFFER = 16 * 1024 * 1024;
16
-
17
- /**
18
- * Every process on this machine, as pid -> parent, executable and, when it
19
- * could be read, command line.
20
- *
21
- * Returns an empty map rather than throwing when the platform has no `ps`
22
- * (Windows) or the call fails. An empty table resolves no client, which is the
23
- * "nobody knows" answer the caller already handles. The command lines are read
24
- * at the same time and within the same timeout; they only name a client that
25
- * is a script, so when that read fails every entry keeps what it had before.
26
- */
27
- export async function readProcessTable({ run: exec = run,
28
- timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
29
- const options = { timeout: timeoutMs, killSignal: "SIGKILL" };
30
- const [tree, commandLines] = await Promise.all([
31
- exec("ps", ["-o", "pid=,ppid=,comm=", "-A"], options).then(({ stdout }) => stdout, () => null),
32
- exec("ps", ["-A", "-o", "pid=,args="], { ...options, maxBuffer: ARGS_BUFFER })
33
- .then(({ stdout }) => stdout, () => ""),
34
- ]);
35
- if (typeof tree !== "string") return new Map();
36
- const args = new Map();
37
- for (const line of String(commandLines).split("\n")) {
38
- const match = ARGS_LINE.exec(line);
39
- if (match !== null) args.set(Number(match[1]), match[2]);
40
- }
41
- const table = new Map();
42
- for (const line of tree.split("\n")) {
43
- const match = LINE.exec(line);
44
- if (match === null) continue;
45
- const pid = Number(match[1]);
46
- table.set(pid, { ppid: Number(match[2]), comm: match[3],
47
- ...(args.has(pid) ? { args: args.get(pid) } : {}) });
48
- }
49
- return table;
50
- }
1
+ // The table lives in the adapter SDK, which adapters that read a process's
2
+ // arguments share with the hook runner; this keeps the runner's own name.
3
+ export { readProcessTable } from "@agents-can-communicate/adapter-sdk";