agents-can-communicate 0.7.0 → 0.8.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 (97) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/acc-bootstrap.mjs +6 -48
  3. package/bin/entrypoints/acc-claude-channel.mjs +47 -157
  4. package/docs/ADAPTER_AUTHORING.md +41 -10
  5. package/docs/ARCHITECTURE.md +28 -13
  6. package/docs/CAPABILITIES.md +19 -16
  7. package/docs/CLI.md +60 -14
  8. package/docs/CONCEPTS.md +12 -4
  9. package/docs/CONFIGURATION.md +18 -11
  10. package/docs/GETTING_STARTED.md +15 -12
  11. package/docs/GLOSSARY.md +2 -1
  12. package/docs/HOW_IT_WORKS.md +8 -4
  13. package/docs/PROTOCOL.md +56 -6
  14. package/docs/RELEASING.md +3 -2
  15. package/docs/SECURITY_MODEL.md +14 -1
  16. package/docs/TROUBLESHOOTING.md +64 -27
  17. package/docs/UPGRADING.md +49 -8
  18. package/docs/WHY_ACC.md +5 -3
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
  37. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
  44. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
  49. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
  50. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
  52. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
  54. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
  56. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
  57. package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
  58. package/node_modules/@agents-can-communicate/cli/src/main.mjs +39 -10
  59. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
  60. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  61. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
  62. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
  63. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
  65. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
  66. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +38 -1
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +11 -2
  70. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
  74. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
  75. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +21 -12
  76. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  77. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
  78. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
  79. package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
  80. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
  81. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
  82. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
  83. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
  84. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  85. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
  86. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  87. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
  89. package/package.json +1 -1
  90. package/bin/acc-bootstrap.mjs +0 -7
  91. package/bin/acc-claude-channel.mjs +0 -10
  92. package/bin/entrypoints/claude-channel-binding.mjs +0 -46
  93. package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
  94. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
  95. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
  96. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
  97. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
@@ -2,18 +2,15 @@ import { defineAdapter, projectContext, projectContextResult }
2
2
  from "@agents-can-communicate/adapter-sdk";
3
3
  import certification from "../certification.json" with { type: "json" };
4
4
 
5
- import { PROTOCOL_CONTRACT } from "./channel.mjs";
6
5
  import { denyOutcome, injectOutcome, injectStartOwnerOutcome, normalizeClaudeHook } from "./hooks.mjs";
6
+ import { MIN_VERSION, PROTOCOL_CONTRACT, bindNativeSession, offerMessage, planNativeActivation,
7
+ probeNativeDelivery, refreshNativeSession, retireNativeSession } from "./inbox-delivery.mjs";
7
8
  import { planClaudeInstall, detectClaude, installClaudePlugin, uninstallClaudePlugin } from "./install.mjs";
8
- import { bindNativeSession, offerMessage, planNativeActivation, probeNativeDelivery, routeReply }
9
- from "./native-delivery.mjs";
10
9
 
11
10
  export const CLAUDE_CODE_VERSION = "2.1.233";
12
- export const CLAUDE_CHANNEL_MINIMUM = "2.1.258";
13
11
  export const CLAUDE_DELIVERY_FALLBACK = Object.freeze({
14
- diagnostic: "Claude Code native delivery requires client-side Channels activation; "
15
- + "a successful bootstrap or connected ACC MCP server does not prove inbound delivery "
16
- + "is enabled. Messages remain durable for certified next-turn delivery or acc inbox",
12
+ diagnostic: `Claude Code live delivery wakes a session through its inbox socket from ${MIN_VERSION}; `
13
+ + "fallback: next-turn hooks or acc inbox",
17
14
  });
18
15
 
19
16
  /**
@@ -25,7 +22,8 @@ export const CLAUDE_DELIVERY_FALLBACK = Object.freeze({
25
22
  * and a parent/child mapping claimed without observation is the kind of thing
26
23
  * that quietly maps every child onto its parent. `startupInjection` and
27
24
  * `safePointInjection` were not exercised - only the before-turn path was.
28
- * Native live delivery and reply routing are not certified for this harness.
25
+ * Native live delivery is the inbox wake, certified from 2.1.282 on
26
+ * darwin-arm64; reply routing through the transport is not claimed.
29
27
  */
30
28
  export function createClaudeCodeAdapter() {
31
29
  return defineAdapter({
@@ -44,28 +42,22 @@ export function createClaudeCodeAdapter() {
44
42
  context: { beforeTurnInjection: true },
45
43
  // PreToolUse denied both a Write and a Bash call; neither ran.
46
44
  guards: { beforeWrite: true, beforeShell: true },
47
- // nextTurn is the certified 2.1.233 hook projection; livePush and
48
- // replyRoute rest on the 2.1.258 Channel capture and the native contract
49
- // below. effectiveCapabilities() carries each of those captures forward
50
- // to later clients; the native contract is the separate live rule.
51
- delivery: { nextTurn: true, livePush: true, replyRoute: true },
45
+ // nextTurn is the certified 2.1.233 hook projection. livePush rests on
46
+ // the 2.1.282 product capture of the inbox wake; the reply is the
47
+ // ordinary `acc reply` every adapter has, so replyRoute stays false.
48
+ delivery: { nextTurn: true, livePush: true },
52
49
  },
53
- // The first passing capture is the shipped minimum; the research lower
54
- // bound (2.1.80) is documented but not admitted without its own capture.
55
- //
56
- // The 2.1.260 release capture is passing evidence beside this, deliberately
57
- // not a second anchor: an anchor is the minimum's proof, one per platform,
58
- // and this contract has no maximum precisely so a newer stable client is
59
- // admitted by probe and a generation-bound handshake instead of by another
60
- // anchor. That capture is what exercised the rule - every branch observed
61
- // on 2.1.260 while the minimum stayed here. Two minor versions of drift,
62
- // served without a capture at either of them, is the rule working.
50
+
51
+ // The first passing capture is the minimum, and there is no maximum: a
52
+ // newer stable client is admitted by the probe and the per-session
53
+ // handshake. The offer is a wake; see src/inbox-delivery.mjs.
63
54
  nativeDelivery: {
64
- minimumByPlatform: { "darwin-arm64": CLAUDE_CHANNEL_MINIMUM },
65
- anchors: [{ platform: "darwin-arm64", version: CLAUDE_CHANNEL_MINIMUM,
66
- protocolContract: PROTOCOL_CONTRACT }],
55
+ minimumByPlatform: { "darwin-arm64": MIN_VERSION },
56
+ anchors: [{ platform: "darwin-arm64", version: MIN_VERSION, protocolContract: PROTOCOL_CONTRACT }],
67
57
  knownBad: [],
68
- activationKinds: ["shell-bootstrap", "native-config"],
58
+ activationKinds: ["native-service"],
59
+ policySource: "installation-record",
60
+ offerKind: "wake",
69
61
  },
70
62
 
71
63
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -75,8 +67,7 @@ export function createClaudeCodeAdapter() {
75
67
 
76
68
  planInstall: context => planClaudeInstall(context),
77
69
  detect: context => detectClaude(context),
78
- install: context => installClaudePlugin({ ...context,
79
- livePolicy: context.livePolicy ?? "off" }),
70
+ install: context => installClaudePlugin(context),
80
71
  uninstall: context => uninstallClaudePlugin(context),
81
72
 
82
73
  doctor: async context => {
@@ -85,6 +76,9 @@ export function createClaudeCodeAdapter() {
85
76
  ...detected.diagnostics,
86
77
  "hook payloads captured from Claude Code 2.1.233",
87
78
  CLAUDE_DELIVERY_FALLBACK.diagnostic,
79
+ // Claude Code's own inbound control decides whether a wake reaches the
80
+ // model; ACC never attests a permission mode. Said only when it matters.
81
+ ...(detected.inboundDelivery.diagnostic === null ? [] : [detected.inboundDelivery.diagnostic]),
88
82
  // SessionEnd is advisory and cannot summarise a conversation that has
89
83
  // already stopped, so the handoff is written from Stop or the skill.
90
84
  "handoff is written while the model is active, not at SessionEnd",
@@ -94,8 +88,9 @@ export function createClaudeCodeAdapter() {
94
88
  probeNativeDelivery,
95
89
  planNativeActivation,
96
90
  bindNativeSession,
91
+ refreshNativeSession,
92
+ retireNativeSession,
97
93
  offerMessage,
98
- routeReply,
99
94
 
100
95
  denyOutcome,
101
96
  injectOutcome,
@@ -50,6 +50,12 @@ export function normalizeClaudeHook(payload) {
50
50
  parentSessionId: typeof payload.agent_id === "string" ? payload.session_id : null,
51
51
  tool,
52
52
  targets: writeTargets(tool, payload.tool_input),
53
+ // Documented as conditional; SessionStart carried none on 2.1.233.
54
+ permissionMode: typeof payload.permission_mode === "string" && payload.permission_mode !== ""
55
+ ? payload.permission_mode : null,
56
+ // SessionEnd says why: "clear", "logout", "prompt_input_exit", "other".
57
+ endReason: event === "SessionEnd" && typeof payload.reason === "string" && payload.reason !== ""
58
+ ? payload.reason : null,
53
59
  });
54
60
  }
55
61
 
@@ -0,0 +1,10 @@
1
+ // The facts of the inbox transport every module of it shares, kept apart so
2
+ // the endpoint record and the delivery methods import them without a cycle.
3
+
4
+ export const PROTOCOL_CONTRACT = "claude-code-inbox-socket-v1";
5
+ // The first release the inbox wake was captured on. 2.1.224 binds the socket,
6
+ // but only 2.1.282 was observed firing UserPromptSubmit for a delivered frame,
7
+ // and that is what brings the message body in with the wake.
8
+ export const MIN_VERSION = "2.1.282";
9
+ export const INBOX_MODES = Object.freeze(["livePush", "idleWake", "busyQueue"]);
10
+ export const TRANSPORT = "claude-inbox";
@@ -0,0 +1,254 @@
1
+ import net from "node:net";
2
+
3
+ import { INBOX_MODES, MIN_VERSION, PROTOCOL_CONTRACT, TRANSPORT } from "./inbox-contract.mjs";
4
+ import { claimWake, newEndpointId, readInboxEndpoint, releaseWake, removeInboxEndpoint,
5
+ sweepDeadEndpoints, writeInboxEndpoint } from "./inbox-endpoint.mjs";
6
+ import { claudeConfigDir, readSessionRecord, verifyInbox } from "./inbox-registry.mjs";
7
+ import { MANAGED_SETTINGS, permissionModeFromArgs, readInboundSettings, readProcessArgs, receptionOf }
8
+ from "./inbox-settings.mjs";
9
+
10
+ // Live delivery into a Claude Code session through the inbox socket the
11
+ // session itself binds (2.1.224 and later, no flag, every provider).
12
+ //
13
+ // The offer is a wake, never the message. Measured on 2.1.282: every frame a
14
+ // session accepts on its inbox fires UserPromptSubmit, idle or between two tool
15
+ // calls, and ACC's beforeTurn hook then projects the queued message inside its
16
+ // untrusted block, with receipts and the `acc reply` route. So the frame
17
+ // carries fixed ACC wording and the ACC message id, and no byte a peer wrote:
18
+ // Claude Code frames every inbox message as a teammate's request to act on,
19
+ // and the only thing a wake gives it to act on is ACC's own notice.
20
+ //
21
+ // The connection sends no auth line, and this module never reads the
22
+ // session's messaging token or key file. A frame carrying that token would
23
+ // pass as the session's own child and skip the inbound controls its user set.
24
+
25
+ const LEASE_MS = 120_000;
26
+ const MESSAGE_ID = /^[A-Za-z0-9_-]{1,200}$/;
27
+ const PROBE_NEEDLE = Buffer.from("messagingSocketPath");
28
+ const PROBE_MAX_BYTES = 256 * 1024 * 1024;
29
+ const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
30
+
31
+ export { INBOX_MODES, MIN_VERSION, PROTOCOL_CONTRACT, TRANSPORT };
32
+
33
+ // Claude Code shows the wake as a message from another Claude session, so the
34
+ // text names the ACC route: a model otherwise tries SendMessage first.
35
+ export const wakeText = messageId => `ACC: new peer message ${messageId} for this session. `
36
+ + "This turn's ACC context shows it. If it does not, it was already shown, or read it with "
37
+ + `acc inbox --message ${messageId}. Answer it through ACC with acc reply --message ${messageId}; `
38
+ + "SendMessage cannot deliver to an ACC participant.";
39
+
40
+ function compare(left, right) {
41
+ const a = left.split("+")[0].split(".").map(Number);
42
+ const b = right.split("+")[0].split(".").map(Number);
43
+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
44
+ return 0;
45
+ }
46
+
47
+ // The registry field only a build with the inbox writes. Read-only, bounded.
48
+ async function executableHasInbox(realExecutable) {
49
+ let handle;
50
+ try {
51
+ handle = await (await import("node:fs/promises")).open(realExecutable, "r");
52
+ } catch {
53
+ return false;
54
+ }
55
+ try {
56
+ const chunk = Buffer.alloc(1024 * 1024);
57
+ let carry = Buffer.alloc(0);
58
+ let position = 0;
59
+ for (;;) {
60
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
61
+ if (bytesRead === 0) return false;
62
+ const window = Buffer.concat([carry, chunk.subarray(0, bytesRead)]);
63
+ if (window.includes(PROBE_NEEDLE)) return true;
64
+ carry = window.subarray(Math.max(0, window.length - PROBE_NEEDLE.length));
65
+ position += bytesRead;
66
+ if (position >= PROBE_MAX_BYTES) return false;
67
+ }
68
+ } finally {
69
+ await handle.close().catch(() => null);
70
+ }
71
+ }
72
+
73
+ function defaultReadVersion(realExecutable, timeoutMs) {
74
+ return new Promise(resolve => {
75
+ import("node:child_process").then(({ execFile }) => {
76
+ execFile(realExecutable, ["--version"], { timeout: timeoutMs, windowsHide: true },
77
+ (error, stdout, stderr) => {
78
+ if (error !== null) return resolve(null);
79
+ resolve(/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(`${stdout}${stderr}`)?.[1] ?? null);
80
+ });
81
+ });
82
+ });
83
+ }
84
+
85
+ function withTimeout(work, ms) {
86
+ let timer = null;
87
+ const deadline = new Promise((_resolve, reject) => {
88
+ timer = setTimeout(() => reject(Object.assign(new Error("native probe timed out"),
89
+ { code: "ETIMEDOUT" })), ms);
90
+ });
91
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
92
+ }
93
+
94
+ /**
95
+ * Read-only: a stable version at or above the capture, a platform whose inbox
96
+ * is a Unix socket, and an executable that writes the inbox into its session
97
+ * registry. Never launches a session. Native Windows serves a named pipe that
98
+ * demands an auth line; nothing there is captured, so it keeps hook delivery.
99
+ */
100
+ export async function probeNativeDelivery({ realExecutable, timeoutMs = 750, platform = process.platform,
101
+ hasInbox = executableHasInbox, readVersion = defaultReadVersion } = {}) {
102
+ const unsupported = (reasonCode, clientVersion = null) => ({ supported: false, clientVersion,
103
+ protocolContract: PROTOCOL_CONTRACT, executableFingerprint: null, modes: [], reasonCode });
104
+ if (platform === "win32") return unsupported("native_delivery_unsupported");
105
+ if (typeof realExecutable !== "string" || realExecutable === "") return unsupported("feature_probe_failed");
106
+ const clientVersion = await withTimeout(Promise.resolve(readVersion(realExecutable, timeoutMs)), timeoutMs)
107
+ .catch(() => null);
108
+ if (clientVersion === null || !STABLE_VERSION.test(clientVersion)) {
109
+ return unsupported("feature_probe_failed", clientVersion);
110
+ }
111
+ if (compare(clientVersion, MIN_VERSION) < 0) return unsupported("below_minimum_version", clientVersion);
112
+ const present = await withTimeout(Promise.resolve(hasInbox(realExecutable)), timeoutMs).catch(() => false);
113
+ if (!present) return unsupported("protocol_mismatch", clientVersion);
114
+ return { supported: true, clientVersion, protocolContract: PROTOCOL_CONTRACT,
115
+ executableFingerprint: null, modes: [...INBOX_MODES], reasonCode: null };
116
+ }
117
+
118
+ // Claude Code runs the inbox itself. ACC starts nothing and rewrites no argument.
119
+ export function planNativeActivation({ detection }) {
120
+ if (typeof detection?.realExecutable !== "string" || detection.realExecutable === "") {
121
+ return { eligible: false, reasonCode: "feature_probe_failed", mechanisms: [] };
122
+ }
123
+ return { eligible: true, reasonCode: null, mechanisms: [{ kind: "native-service",
124
+ serviceId: "claude-code-inbox", preExisting: true, applyCommand: null, teardownCommand: null }] };
125
+ }
126
+
127
+ const closed = (clientVersion, reasonCode) => ({ supported: false, clientVersion: clientVersion ?? null,
128
+ protocolContract: PROTOCOL_CONTRACT, modes: [], opaqueEndpointRef: null, leaseUntil: null, reasonCode });
129
+ const handshake = (endpoint, now) => ({ supported: true, clientVersion: endpoint.clientVersion,
130
+ protocolContract: PROTOCOL_CONTRACT, modes: [...INBOX_MODES], opaqueEndpointRef: endpoint.endpointId,
131
+ leaseUntil: new Date(now() + LEASE_MS).toISOString(), reasonCode: null });
132
+
133
+ /**
134
+ * Bind this ACC session to the inbox of the Claude process whose hook is
135
+ * running. The socket comes from the hook's own environment, which Claude Code
136
+ * exports per session and never inherits from a parent; the registry must
137
+ * name this process and this socket before anything is published. The session
138
+ * id is checked by every offer and refresh, since after /resume the registry
139
+ * catches up only once the hook has run.
140
+ */
141
+ export async function bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
142
+ env = process.env, now = Date.now, managedSettingsPath = MANAGED_SETTINGS[process.platform],
143
+ readClientArgs = readProcessArgs } = {}) {
144
+ if (!Number.isInteger(clientPid) || clientPid <= 0) return closed(clientVersion, "client_process_unknown");
145
+ if (typeof event?.sessionId !== "string" || event.sessionId === "") {
146
+ return closed(clientVersion, "handshake_failed");
147
+ }
148
+ if (typeof clientVersion !== "string" || !STABLE_VERSION.test(clientVersion)) {
149
+ return closed(clientVersion, "version_unavailable");
150
+ }
151
+ // The contract refuses it anyway, after this returns. Refusing here keeps an
152
+ // older client - which binds again on every turn - from writing an endpoint
153
+ // record each time.
154
+ if (compare(clientVersion, MIN_VERSION) < 0) return closed(clientVersion, "below_minimum_version");
155
+ const socketPath = env?.CLAUDE_CODE_MESSAGING_SOCKET;
156
+ if (typeof socketPath !== "string" || socketPath === "") {
157
+ return closed(clientVersion, "native_endpoint_unavailable");
158
+ }
159
+ await sweepDeadEndpoints({ runtimeDir });
160
+ const configDir = claudeConfigDir(env);
161
+ const refused = await verifyInbox({ configDir, clientPid, sessionId: event.sessionId, socketPath,
162
+ anyConversation: true });
163
+ if (refused !== null) return closed(clientVersion, refused);
164
+ // Project settings sit where the session was started, which the registry
165
+ // records; the hook's cwd follows the shell.
166
+ const record = await readSessionRecord({ configDir, clientPid });
167
+ const settings = await readInboundSettings({ configDir, projectDir: record?.cwd ?? event.cwd,
168
+ managedSettingsPath });
169
+ // The hook's mode is current. SessionStart carries none, so until the first
170
+ // prompt the launch flag stands in, then the configured default.
171
+ const launchMode = event.permissionMode == null
172
+ ? permissionModeFromArgs(await readClientArgs(clientPid).catch(() => null)) : null;
173
+ const reception = receptionOf({
174
+ permissionMode: event.permissionMode ?? launchMode ?? settings.defaultMode,
175
+ crossSessionInbound: settings.crossSessionInbound });
176
+ const endpoint = { schemaVersion: 1, endpointId: newEndpointId(), socketPath, configDir, clientPid,
177
+ sessionId: event.sessionId, clientVersion, protocolContract: PROTOCOL_CONTRACT,
178
+ leaseUntil: new Date(now() + LEASE_MS).toISOString(), reception };
179
+ try {
180
+ await writeInboxEndpoint({ runtimeDir, record: endpoint });
181
+ } catch {
182
+ return closed(clientVersion, "handshake_failed");
183
+ }
184
+ return handshake(endpoint, now);
185
+ }
186
+
187
+ async function verifiedEndpoint(binding, runtimeDir) {
188
+ const endpoint = await readInboxEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
189
+ if (endpoint === null) return null;
190
+ const refused = await verifyInbox({ configDir: endpoint.configDir, clientPid: endpoint.clientPid,
191
+ sessionId: endpoint.sessionId, socketPath: endpoint.socketPath });
192
+ return refused === null ? endpoint : null;
193
+ }
194
+
195
+ // The router calls this when a lease ran out, so a session that sits idle
196
+ // between turns stays reachable. The id never changes on a refresh.
197
+ export async function refreshNativeSession({ binding, runtimeDir, now = Date.now } = {}) {
198
+ const endpoint = await verifiedEndpoint(binding, runtimeDir);
199
+ return endpoint === null ? closed(binding?.clientVersion, "handshake_failed") : handshake(endpoint, now);
200
+ }
201
+
202
+ export const retireNativeSession = ({ binding, runtimeDir }) =>
203
+ removeInboxEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
204
+
205
+ /**
206
+ * Sender side: re-verify the receiver, then write one wake line. The socket
207
+ * answers nothing, so acceptance is a line written to a verified inbox without
208
+ * an error inside the timeout. What the model then sees is recorded by the
209
+ * receiver's own hook.
210
+ */
211
+ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 2_000,
212
+ connect = net.createConnection } = {}) {
213
+ const rejected = safeErrorCode => ({ accepted: false, transport: TRANSPORT,
214
+ clientVersion: binding?.clientVersion ?? null, safeErrorCode });
215
+ if (typeof message?.messageId !== "string" || !MESSAGE_ID.test(message.messageId)) {
216
+ return rejected("transport_rejected");
217
+ }
218
+ const endpoint = await verifiedEndpoint(binding, runtimeDir);
219
+ if (endpoint === null) return rejected("recipient_unavailable");
220
+ // The receiver's own crossSessionInbound refuses unattested wakes; one would
221
+ // be dropped, so none is sent and the message waits for its next turn.
222
+ if (endpoint.reception === "refused") return rejected("delivery_disabled");
223
+ const accepted = { accepted: true, transport: TRANSPORT, clientVersion: endpoint.clientVersion,
224
+ ...(endpoint.reception === "held" ? { pendingApproval: true } : {}) };
225
+ const wake = { runtimeDir, endpointId: endpoint.endpointId, messageId: message.messageId };
226
+ // Claude Code delivers a repeated msg_id again, so the dedupe is ours.
227
+ let first;
228
+ try {
229
+ first = await claimWake(wake);
230
+ } catch {
231
+ return rejected("transport_error");
232
+ }
233
+ if (!first) return accepted;
234
+ const frame = `${JSON.stringify({ type: "user",
235
+ message: { role: "user", content: wakeText(message.messageId) },
236
+ msg_id: `acc-wake-${message.messageId}` })}\n`;
237
+ const result = await new Promise(resolve => {
238
+ const socket = connect(endpoint.socketPath);
239
+ let settled = false;
240
+ const finish = value => {
241
+ if (settled) return;
242
+ settled = true;
243
+ clearTimeout(timer);
244
+ socket.destroy();
245
+ resolve(value);
246
+ };
247
+ const timer = setTimeout(() => finish(rejected("transport_error")), timeoutMs);
248
+ socket.once("error", error => finish(rejected(["EPERM", "EACCES"].includes(error?.code)
249
+ ? "transport_permission_denied" : "recipient_unavailable")));
250
+ socket.once("connect", () => socket.end(frame, () => finish(accepted)));
251
+ });
252
+ if (!result.accepted) await releaseWake(wake);
253
+ return result;
254
+ }
@@ -0,0 +1,166 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { PROTOCOL_CONTRACT } from "./inbox-contract.mjs";
7
+ import { inboxSocketIsSafe } from "./inbox-registry.mjs";
8
+ import { isReception } from "./inbox-settings.mjs";
9
+
10
+ // One private record per hook binding, the way the Codex adapter keeps its
11
+ // daemon endpoints: core holds only the random id, and the socket path, pid and
12
+ // session id stay in a 0600 file under the workspace runtime directory.
13
+
14
+ const KEYS = ["schemaVersion", "endpointId", "socketPath", "configDir", "clientPid", "sessionId",
15
+ "clientVersion", "protocolContract", "leaseUntil", "reception"];
16
+ const ENDPOINT = /^claude_inbox_[a-f0-9]{32}$/;
17
+ const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
18
+ const MAX_BYTES = 8_192;
19
+ const absolute = value => typeof value === "string" && path.isAbsolute(value) && !value.includes("\0");
20
+ const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
21
+ const invalid = () => new Error("invalid Claude inbox endpoint registration");
22
+
23
+ export const newEndpointId = () => `claude_inbox_${randomBytes(16).toString("hex")}`;
24
+
25
+ function valid(record) {
26
+ return record !== null && typeof record === "object" && !Array.isArray(record)
27
+ && Object.keys(record).length === KEYS.length && KEYS.every(key => Object.hasOwn(record, key))
28
+ && record.schemaVersion === 1 && ENDPOINT.test(record.endpointId)
29
+ && absolute(record.socketPath) && absolute(record.configDir)
30
+ && Number.isInteger(record.clientPid) && record.clientPid > 0
31
+ && typeof record.sessionId === "string" && record.sessionId !== "" && record.sessionId.length <= 200
32
+ && typeof record.clientVersion === "string" && STABLE.test(record.clientVersion)
33
+ && record.protocolContract === PROTOCOL_CONTRACT
34
+ && typeof record.leaseUntil === "string"
35
+ && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(record.leaseUntil)
36
+ && Number.isFinite(Date.parse(record.leaseUntil))
37
+ && isReception(record.reception);
38
+ }
39
+
40
+ async function directory(runtimeDir, create = false) {
41
+ if (!absolute(runtimeDir)) throw invalid();
42
+ if (create) await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
43
+ const root = await realpath(runtimeDir);
44
+ const dir = path.join(root, "claude-inbox-endpoints");
45
+ if (create) await mkdir(dir, { mode: 0o700 }).catch(error => {
46
+ if (error.code !== "EEXIST") throw error;
47
+ });
48
+ const info = await lstat(dir);
49
+ if (!info.isDirectory() || info.isSymbolicLink() || !own(info) || (info.mode & 0o022) !== 0) throw invalid();
50
+ return dir;
51
+ }
52
+
53
+ export async function writeInboxEndpoint({ runtimeDir, record }) {
54
+ if (!valid(record) || !await inboxSocketIsSafe(record.socketPath)) throw invalid();
55
+ const text = `${JSON.stringify(record)}\n`;
56
+ if (Buffer.byteLength(text) > MAX_BYTES) throw invalid();
57
+ const dir = await directory(runtimeDir, true);
58
+ const file = path.join(dir, `${record.endpointId}.json`);
59
+ const temporary = path.join(dir, `.${record.endpointId}.${randomBytes(8).toString("hex")}.tmp`);
60
+ let handle;
61
+ try {
62
+ handle = await open(temporary, "wx", 0o600);
63
+ await handle.writeFile(text, "utf8");
64
+ await handle.close(); handle = null;
65
+ await rename(temporary, file);
66
+ } finally {
67
+ await handle?.close().catch(() => null);
68
+ await rm(temporary, { force: true }).catch(() => null);
69
+ }
70
+ }
71
+
72
+ // An expired record is metadata for re-verification, never proof of reach.
73
+ export async function readInboxEndpoint({ runtimeDir, endpointId }) {
74
+ if (typeof endpointId !== "string" || !ENDPOINT.test(endpointId)) return null;
75
+ let handle;
76
+ try {
77
+ const dir = await directory(runtimeDir);
78
+ handle = await open(path.join(dir, `${endpointId}.json`),
79
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
80
+ const info = await handle.stat();
81
+ if (!info.isFile() || !own(info) || info.size > MAX_BYTES || (info.mode & 0o077) !== 0) return null;
82
+ const record = JSON.parse(await handle.readFile("utf8"));
83
+ return valid(record) && record.endpointId === endpointId ? record : null;
84
+ } catch {
85
+ return null;
86
+ } finally {
87
+ await handle?.close().catch(() => null);
88
+ }
89
+ }
90
+
91
+ export async function removeInboxEndpoint({ runtimeDir, endpointId }) {
92
+ if (typeof endpointId !== "string" || !ENDPOINT.test(endpointId)) return;
93
+ try {
94
+ const dir = await directory(runtimeDir);
95
+ await rm(path.join(dir, `${endpointId}.json`), { force: true });
96
+ } catch { /* retirement is already recorded; cleanup is best effort */ }
97
+ try {
98
+ await rm(path.join(await wakesDirectory(runtimeDir), endpointId), { recursive: true, force: true });
99
+ } catch { /* the same */ }
100
+ }
101
+
102
+ const processExists = pid => {
103
+ try {
104
+ process.kill(pid, 0);
105
+ return true;
106
+ } catch (error) {
107
+ return error.code === "EPERM";
108
+ }
109
+ };
110
+
111
+ /**
112
+ * A Claude Code process that crashed ran no SessionEnd, so its binding was never
113
+ * retired. Only a record whose process is gone is removed: a live session whose
114
+ * registry briefly names another conversation (right after /resume) keeps its own.
115
+ */
116
+ export async function sweepDeadEndpoints({ runtimeDir, exists = processExists, limit = 64 }) {
117
+ let names;
118
+ try {
119
+ names = await readdir(await directory(runtimeDir));
120
+ } catch {
121
+ return;
122
+ }
123
+ for (const name of names.filter(item => /^claude_inbox_[a-f0-9]{32}\.json$/.test(item)).slice(0, limit)) {
124
+ const endpointId = name.slice(0, -".json".length);
125
+ const record = await readInboxEndpoint({ runtimeDir, endpointId });
126
+ if (record !== null && !exists(record.clientPid)) await removeInboxEndpoint({ runtimeDir, endpointId });
127
+ }
128
+ }
129
+
130
+ // One empty marker per message a binding woke. A wake leaves the receipt
131
+ // queued until the receiver's hook commits it, so a replayed send reaches the
132
+ // transport again; the marker keeps it to one wake per message and binding.
133
+ const WAKE_MESSAGE = /^[A-Za-z0-9_-]{1,200}$/;
134
+
135
+ async function wakesDirectory(runtimeDir, create = false) {
136
+ const dir = path.join(path.dirname(await directory(runtimeDir, create)), "claude-inbox-wakes");
137
+ if (create) await mkdir(dir, { mode: 0o700 }).catch(error => {
138
+ if (error.code !== "EEXIST") throw error;
139
+ });
140
+ const info = await lstat(dir);
141
+ if (!info.isDirectory() || info.isSymbolicLink() || !own(info) || (info.mode & 0o022) !== 0) throw invalid();
142
+ return dir;
143
+ }
144
+
145
+ /** True when this call is the first to wake the binding for the message. */
146
+ export async function claimWake({ runtimeDir, endpointId, messageId }) {
147
+ if (!ENDPOINT.test(endpointId) || !WAKE_MESSAGE.test(messageId)) throw invalid();
148
+ const dir = path.join(await wakesDirectory(runtimeDir, true), endpointId);
149
+ await mkdir(dir, { mode: 0o700 }).catch(error => {
150
+ if (error.code !== "EEXIST") throw error;
151
+ });
152
+ try {
153
+ await (await open(path.join(dir, messageId), "wx", 0o600)).close();
154
+ return true;
155
+ } catch (error) {
156
+ if (error.code === "EEXIST") return false;
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ export async function releaseWake({ runtimeDir, endpointId, messageId }) {
162
+ if (!ENDPOINT.test(endpointId) || !WAKE_MESSAGE.test(messageId)) return;
163
+ try {
164
+ await rm(path.join(await wakesDirectory(runtimeDir), endpointId, messageId), { force: true });
165
+ } catch { /* a marker left behind only suppresses a repeat wake */ }
166
+ }
@@ -0,0 +1,71 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ // Claude Code's own record of a live session: `<config>/sessions/<pid>.json`
7
+ // names the session id and the inbox socket it listens on. ACC reads that file
8
+ // and nothing beside it - never the `<pid>.<hash>.key` next to it.
9
+
10
+ const MAX_BYTES = 16_384;
11
+ const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
12
+
13
+ export const claudeConfigDir = env => typeof env?.CLAUDE_CONFIG_DIR === "string"
14
+ && path.isAbsolute(env.CLAUDE_CONFIG_DIR)
15
+ ? env.CLAUDE_CONFIG_DIR : path.join(os.homedir(), ".claude");
16
+
17
+ /** The registry entry for one pid, or null. Bounded, owner-checked, never through a link. */
18
+ export async function readSessionRecord({ configDir, clientPid }) {
19
+ if (!Number.isInteger(clientPid) || clientPid <= 0 || typeof configDir !== "string") return null;
20
+ let handle;
21
+ try {
22
+ handle = await open(path.join(configDir, "sessions", `${clientPid}.json`),
23
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
24
+ const info = await handle.stat();
25
+ if (!info.isFile() || !own(info) || info.size > MAX_BYTES) return null;
26
+ const record = JSON.parse(await handle.readFile("utf8"));
27
+ return record?.pid === clientPid && typeof record.sessionId === "string" && record.sessionId !== ""
28
+ && typeof record.messagingSocketPath === "string" && path.isAbsolute(record.messagingSocketPath)
29
+ ? { pid: record.pid, sessionId: record.sessionId, messagingSocketPath: record.messagingSocketPath,
30
+ cwd: typeof record.cwd === "string" && path.isAbsolute(record.cwd) ? record.cwd : null }
31
+ : null;
32
+ } catch {
33
+ return null;
34
+ } finally {
35
+ await handle?.close().catch(() => null);
36
+ }
37
+ }
38
+
39
+ /** A socket this user owns, reached without a link, closed to group and others. */
40
+ export async function inboxSocketIsSafe(socketPath) {
41
+ if (typeof socketPath !== "string" || !path.isAbsolute(socketPath)) return false;
42
+ try {
43
+ const info = await lstat(socketPath);
44
+ return info.isSocket() && !info.isSymbolicLink() && own(info) && (info.mode & 0o077) === 0;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether this socket is the inbox of this session, right now.
52
+ *
53
+ * The socket path alone proves nothing: pids are reused, and a new process
54
+ * gets a new registry entry under the same name. Requiring the registry to
55
+ * name both this session id and this socket is what keeps a wake from reaching
56
+ * whatever process holds the pid later. Returns null, or the reason it is not.
57
+ *
58
+ * `anyConversation` is for the bind alone. Claude Code runs SessionStart for a
59
+ * /resume before it rewrites the entry, so there the hook's own process and
60
+ * socket are the proof; every offer and refresh still requires the session id.
61
+ */
62
+ export async function verifyInbox({ configDir, clientPid, sessionId, socketPath, anyConversation = false }) {
63
+ if (!await inboxSocketIsSafe(socketPath)) return "native_endpoint_unavailable";
64
+ const record = await readSessionRecord({ configDir, clientPid });
65
+ if (record === null || (!anyConversation && record.sessionId !== sessionId)) {
66
+ return "native_session_unavailable";
67
+ }
68
+ const [registered, expected] = await Promise.all([
69
+ realpath(record.messagingSocketPath).catch(() => null), realpath(socketPath).catch(() => null)]);
70
+ return registered !== null && registered === expected ? null : "native_session_unavailable";
71
+ }