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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
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.4",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,9 @@
1
1
  import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
3
  /**
4
- * Why a session has no live transport, as its own last native attempt said.
4
+ * Why a session has no live transport, as its own last native attempt said:
5
+ * `{ reasonCode, launchOption? }`, the option being the client launch option
6
+ * that attempt named as the cause.
5
7
  *
6
8
  * The same record `acc doctor` reads, found through the hook-owner file that
7
9
  * names this exact session generation. Null when none says: an attempt that
@@ -14,5 +16,7 @@ export async function lastNativeReason({ runtimeDir, sessionId, generation }) {
14
16
  if (owners.length !== 1) return null;
15
17
  const attempt = await loadNativeAttempt({ runtimeDir,
16
18
  harnessSessionId: owners[0].harnessSessionId, accSessionId: sessionId, generation });
17
- return attempt !== null && attempt.state !== "active" ? attempt.reasonCode : null;
19
+ if (attempt === null || attempt.state === "active") return null;
20
+ return { reasonCode: attempt.reasonCode,
21
+ ...(attempt.launchOption ? { launchOption: attempt.launchOption } : {}) };
18
22
  }
@@ -1,4 +1,5 @@
1
- import { NATIVE_REASON_CODES, evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
1
+ import { NATIVE_REASON_CODES, evaluateVersionContract, isLaunchOption }
2
+ from "@agents-can-communicate/adapter-sdk";
2
3
 
3
4
  import { lastNativeReason } from "./native-reason.mjs";
4
5
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
@@ -51,13 +52,16 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
51
52
  const registry = adaptersById(adapters);
52
53
 
53
54
  // A session that bound no live transport may have said why: the reason its
54
- // own last native attempt recorded, from the closed vocabulary only.
55
+ // own last native attempt recorded, from the closed vocabulary only, and the
56
+ // client launch option behind it when the attempt named one.
55
57
  async function withoutLiveTransport(participantId, target) {
56
- const reason = await Promise.resolve()
58
+ const found = await Promise.resolve()
57
59
  .then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
58
60
  .catch(() => null);
59
- return { ...durable(participantId, "no_live_transport"),
60
- ...(NATIVE_REASON_CODES.includes(reason) ? { nativeReasonCode: reason } : {}) };
61
+ const reason = found?.reasonCode;
62
+ if (!NATIVE_REASON_CODES.includes(reason)) return durable(participantId, "no_live_transport");
63
+ return { ...durable(participantId, "no_live_transport"), nativeReasonCode: reason,
64
+ ...(isLaunchOption(found.launchOption) ? { nativeLaunchOption: found.launchOption } : {}) };
61
65
  }
62
66
 
63
67
  // Read at offer time, not from the binding: consent withdrawn after a session
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
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
 
@@ -45,6 +45,7 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
45
45
  reasonCode: result.reasonCode, policy, policySource, policyStatus,
46
46
  clientProcess: Number.isInteger(hookBinding.clientPid) && hookBinding.clientPid > 0
47
47
  ? "identified" : "unknown",
48
+ ...(result.launchOption === undefined ? {} : { launchOption: result.launchOption }),
48
49
  } }).catch(() => {});
49
50
  }
50
51
  return result;
@@ -57,6 +58,7 @@ function asHint(value) {
57
58
  if (typeof value === "string") return { line: value, release: async () => {} };
58
59
  if (value !== null && typeof value === "object" && typeof value.line === "string") {
59
60
  return { line: value.line,
61
+ ...(usableHint(value.userMessage) ? { userMessage: value.userMessage } : {}),
60
62
  release: typeof value.release === "function" ? value.release : async () => {} };
61
63
  }
62
64
  if (value !== null && typeof value === "object" && typeof value.release === "function") {
@@ -66,7 +68,8 @@ function asHint(value) {
66
68
  }
67
69
 
68
70
  function usableHint(line) {
69
- return line !== "" && !/[\r\n]/.test(line) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
71
+ return typeof line === "string" && line !== "" && !/[\r\n]/.test(line)
72
+ && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
70
73
  }
71
74
 
72
75
  // An adapter may release with a synchronous function. Calling `.catch` on its
@@ -91,6 +94,8 @@ export function callRelease(release) {
91
94
  * A string has nothing reserved. `{ line, release }` has reserved an ask; this
92
95
  * function calls `release` when it drops the line, including when the adapter
93
96
  * answers after the budget. The caller releases a line it does not deliver.
97
+ * `{ line, userMessage }` also asks for a line the user sees and the model
98
+ * does not, held to the same bound; one that does not fit is dropped alone.
94
99
  */
95
100
  export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
96
101
  paths, deadline }) {
@@ -37,8 +37,11 @@ function discardEndpoint(adapter, handshake, runtimeDir) {
37
37
  export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
38
38
  platform, livePolicy, service, runtimeDir, clock, env,
39
39
  timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
40
- const outcome = (state, reasonCode, modes = []) =>
41
- Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
40
+ // A refusal may name the client launch option behind it; see
41
+ // validateNativeHandshake, which admits only a bare option token.
42
+ const outcome = (state, reasonCode, modes = [], launchOption = null) =>
43
+ Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]),
44
+ ...(launchOption === null ? {} : { launchOption }) });
42
45
  const sessionId = hookBinding?.accSessionId;
43
46
  const generation = hookBinding?.generation;
44
47
  if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
@@ -80,7 +83,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
80
83
  if (!verdict.ok) {
81
84
  await discardEndpoint(adapter, handshake, runtimeDir);
82
85
  return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
83
- verdict.reasonCode);
86
+ verdict.reasonCode, [], verdict.launchOption ?? null);
84
87
  }
85
88
  const now = Date.parse(clock.now());
86
89
  const lease = Date.parse(verdict.leaseUntil);
@@ -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";