agents-can-communicate 0.7.1 → 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.
- package/README.md +3 -1
- package/bin/entrypoints/acc-bootstrap.mjs +6 -48
- package/bin/entrypoints/acc-claude-channel.mjs +47 -157
- package/docs/ADAPTER_AUTHORING.md +41 -10
- package/docs/ARCHITECTURE.md +23 -13
- package/docs/CAPABILITIES.md +19 -16
- package/docs/CLI.md +48 -13
- package/docs/CONCEPTS.md +5 -4
- package/docs/CONFIGURATION.md +18 -11
- package/docs/GETTING_STARTED.md +15 -12
- package/docs/GLOSSARY.md +2 -1
- package/docs/HOW_IT_WORKS.md +8 -4
- package/docs/PROTOCOL.md +25 -6
- package/docs/RELEASING.md +3 -2
- package/docs/SECURITY_MODEL.md +14 -1
- package/docs/TROUBLESHOOTING.md +64 -27
- package/docs/UPGRADING.md +49 -8
- package/docs/WHY_ACC.md +5 -3
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
- package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +25 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
- package/node_modules/@agents-can-communicate/core/src/status.mjs +19 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
- package/package.json +1 -1
- package/bin/acc-bootstrap.mjs +0 -7
- package/bin/acc-claude-channel.mjs +0 -10
- package/bin/entrypoints/claude-channel-binding.mjs +0 -46
- package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// What Claude Code does with a wake from a sender that attests no permission
|
|
6
|
+
// mode - ACC never attests one. A session that bypasses permission prompts
|
|
7
|
+
// holds it for approval; `crossSessionInbound` overrides that both ways. ACC
|
|
8
|
+
// reads the same inputs so the sender is told what will happen, and never
|
|
9
|
+
// changes any of them.
|
|
10
|
+
|
|
11
|
+
const MAX_BYTES = 256 * 1024;
|
|
12
|
+
const RECEPTIONS = Object.freeze(["delivered", "held", "refused"]);
|
|
13
|
+
|
|
14
|
+
export const MANAGED_SETTINGS = Object.freeze({
|
|
15
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
16
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
17
|
+
win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function readSettings(file) {
|
|
21
|
+
try {
|
|
22
|
+
const text = await readFile(file, "utf8");
|
|
23
|
+
if (Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
24
|
+
const value = JSON.parse(text);
|
|
25
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const text = value => (typeof value === "string" && value !== "" ? value : null);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The two inputs, each from the first file that sets it, in Claude Code's own
|
|
35
|
+
* order: managed policy, then the project's local and shared settings, then
|
|
36
|
+
* the user's.
|
|
37
|
+
*/
|
|
38
|
+
export async function readInboundSettings({ configDir, projectDir, managedSettingsPath }) {
|
|
39
|
+
const files = [managedSettingsPath,
|
|
40
|
+
...(typeof projectDir === "string" && path.isAbsolute(projectDir)
|
|
41
|
+
? [path.join(projectDir, ".claude", "settings.local.json"),
|
|
42
|
+
path.join(projectDir, ".claude", "settings.json")] : []),
|
|
43
|
+
path.join(configDir, "settings.json")].filter(file => typeof file === "string");
|
|
44
|
+
const layers = await Promise.all(files.map(readSettings));
|
|
45
|
+
const first = pick => layers.map(layer => (layer === null ? null : pick(layer))).find(value => value !== null)
|
|
46
|
+
?? null;
|
|
47
|
+
return {
|
|
48
|
+
crossSessionInbound: first(layer => text(layer.crossSessionInbound)),
|
|
49
|
+
defaultMode: first(layer => text(layer.permissions?.defaultMode)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The permission mode a Claude Code process was started with, from its own
|
|
55
|
+
* arguments, or null. Only the two flags are read; nothing else is kept.
|
|
56
|
+
*/
|
|
57
|
+
export function permissionModeFromArgs(args) {
|
|
58
|
+
if (!Array.isArray(args)) return null;
|
|
59
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
60
|
+
const arg = args[index];
|
|
61
|
+
if (arg === "--dangerously-skip-permissions") return "bypassPermissions";
|
|
62
|
+
if (arg === "--permission-mode") return text(args[index + 1]);
|
|
63
|
+
if (typeof arg === "string" && arg.startsWith("--permission-mode=")) {
|
|
64
|
+
return text(arg.slice("--permission-mode=".length));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A process's arguments as `ps` prints them, split on spaces, or null. */
|
|
71
|
+
export function readProcessArgs(pid, { timeoutMs = 500 } = {}) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return Promise.resolve(null);
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
execFile("ps", ["-o", "args=", "-p", String(pid)], { timeout: timeoutMs, maxBuffer: 64 * 1024 },
|
|
75
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim().split(/\s+/).filter(Boolean)));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function receptionOf({ permissionMode, crossSessionInbound }) {
|
|
80
|
+
if (crossSessionInbound === "accept") return "delivered";
|
|
81
|
+
if (crossSessionInbound === "refuse") return "refused";
|
|
82
|
+
if (crossSessionInbound === "hold" || permissionMode === "bypassPermissions") return "held";
|
|
83
|
+
return "delivered";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const isReception = value => RECEPTIONS.includes(value);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What `acc doctor` tells a person who turned live delivery on: the machine-wide
|
|
90
|
+
* view, from managed policy and the user's settings. A project can still set
|
|
91
|
+
* its own value; the bind reads that one per session.
|
|
92
|
+
*/
|
|
93
|
+
export async function inboundStatus({ configDir, managedSettingsPath }) {
|
|
94
|
+
const userFile = path.join(configDir, "settings.json");
|
|
95
|
+
for (const file of [managedSettingsPath, userFile].filter(item => typeof item === "string")) {
|
|
96
|
+
const value = text((await readSettings(file))?.crossSessionInbound);
|
|
97
|
+
if (value === "accept") return { state: "accepted", diagnostic: null };
|
|
98
|
+
if (value === "refuse") {
|
|
99
|
+
return { state: "refused", diagnostic: `crossSessionInbound is "refuse" in ${file}, so `
|
|
100
|
+
+ "Claude Code drops every ACC wake; messages arrive with each next turn; set it to "
|
|
101
|
+
+ "\"accept\" for live delivery" };
|
|
102
|
+
}
|
|
103
|
+
if (value !== null) break;
|
|
104
|
+
}
|
|
105
|
+
return { state: "attention", diagnostic: "a session that bypasses permission prompts asks "
|
|
106
|
+
+ `before each ACC wake; add "crossSessionInbound": "accept" to ${userFile} to take wakes `
|
|
107
|
+
+ "without asking; the next turn shows the message either way" };
|
|
108
|
+
}
|
|
109
|
+
|