agents-can-communicate 0.8.1 → 0.8.2
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/docs/ADAPTER_AUTHORING.md +14 -0
- package/docs/CAPABILITIES.md +1 -1
- package/docs/CLI.md +16 -0
- package/docs/CONFIGURATION.md +73 -8
- package/docs/GETTING_STARTED.md +6 -3
- package/docs/HOW_IT_WORKS.md +3 -1
- package/docs/SECURITY_MODEL.md +12 -1
- package/docs/TROUBLESHOOTING.md +32 -5
- package/docs/UPGRADING.md +29 -1
- 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-antigravity/plugin/skills/acc/SKILL.md +7 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +204 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +45 -10
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-observed-directories.mjs +62 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +41 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +64 -12
- 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/capabilities.mjs +7 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +16 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +9 -4
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +11 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +44 -6
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +20 -10
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/status.mjs +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +9 -0
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +10 -3
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/installer/src/receiver-sockets.mjs +19 -0
- 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 +30 -4
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +56 -25
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +6 -2
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
|
|
|
7
7
|
import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
|
|
8
8
|
import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
|
|
9
9
|
import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
|
|
10
|
-
import { applyPlan, detectInstallation, loadOwnership, planInstallation }
|
|
10
|
+
import { applyPlan, detectInstallation, loadOwnership, planInstallation, receiverSockets }
|
|
11
11
|
from "@agents-can-communicate/installer";
|
|
12
12
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
13
13
|
|
|
@@ -28,7 +28,7 @@ export { decideDelivery } from "./install-delivery-consent.mjs";
|
|
|
28
28
|
// pointed at the home itself writes beside them rather than inside them. That
|
|
29
29
|
// install reports success and the client never reads a byte of it.
|
|
30
30
|
export const clientContext = (home, stateRoot,
|
|
31
|
-
{ env = {}, dataHome, cwd = process.cwd() } = {}) => ({
|
|
31
|
+
{ env = {}, dataHome, cwd = process.cwd(), platform } = {}) => withReceivers({
|
|
32
32
|
home,
|
|
33
33
|
...(dataHome === undefined ? {} : { dataHome }),
|
|
34
34
|
configDir: path.join(home, ".claude"),
|
|
@@ -59,7 +59,14 @@ export const clientContext = (home, stateRoot,
|
|
|
59
59
|
// roster and record nothing, every write failing with EPERM on the writer
|
|
60
60
|
// lock. Measured with `codex exec`, which is how an agent actually runs.
|
|
61
61
|
stateRoot,
|
|
62
|
-
});
|
|
62
|
+
}, platform);
|
|
63
|
+
|
|
64
|
+
// Every socket ACC can deliver to, as each receiving adapter declares it, for
|
|
65
|
+
// the one sender that sandboxes its shell: the Codex permission profile is
|
|
66
|
+
// written and diagnosed from this list, so it covers Claude Code's inbox even
|
|
67
|
+
// when an install names Codex alone (issue #213).
|
|
68
|
+
const withReceivers = (context, platform) => ({ ...context,
|
|
69
|
+
receiverSockets: receiverSockets(ALL_ADAPTERS(), { ...context, platform }) });
|
|
63
70
|
|
|
64
71
|
export const ALL_ADAPTERS = () => [createAntigravityAdapter(), createClaudeCodeAdapter(),
|
|
65
72
|
createCodexAdapter(), createGeminiCliAdapter(), createGrokAdapter(), createKimiAdapter()];
|
|
@@ -222,7 +229,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
222
229
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
223
230
|
env: runtime.env ?? {} });
|
|
224
231
|
const context = clientContext(home, path.join(dataHome, "acc"),
|
|
225
|
-
{ env: runtime.env ?? {}, dataHome });
|
|
232
|
+
{ env: runtime.env ?? {}, dataHome, platform: runtime.platform });
|
|
226
233
|
|
|
227
234
|
const detected = await detectInstallation({ adapters, context,
|
|
228
235
|
probeTimeoutMs: probeTimeout(runtime.env) });
|
|
@@ -16,7 +16,7 @@ const usage = message => new AccError(EXIT.USAGE, message);
|
|
|
16
16
|
import { commandHelpText, describeCommand, describeCommands, helpText } from "./help.mjs";
|
|
17
17
|
import { runUpdateCommand } from "./update-command.mjs";
|
|
18
18
|
import { runConfigCommand } from "./config-command.mjs";
|
|
19
|
-
import { runInstallCommand } from "./install-command.mjs";
|
|
19
|
+
import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
|
|
20
20
|
import { runDoctor } from "./doctor-command.mjs";
|
|
21
21
|
import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
|
|
22
22
|
import { createGitProbe } from "./git-probe.mjs";
|
|
@@ -179,12 +179,49 @@ export async function recordAndOffer({ record, router, selectMessage = value =>
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
182
|
+
/**
|
|
183
|
+
* What a message sent to each stale session would meet.
|
|
184
|
+
*
|
|
185
|
+
* Presence reads the client process, so a dead one is offline and a stale
|
|
186
|
+
* session with a live process is idle. Idle is then told apart by delivery: a
|
|
187
|
+
* live binding wakes it, a client with next-turn delivery shows the message on
|
|
188
|
+
* its next turn, and anything else reads it through `acc inbox`. A session with
|
|
189
|
+
* no known process cannot be told from one that left, so it is not answering.
|
|
190
|
+
*
|
|
191
|
+
* A live binding wakes on the router's own rule: a current lease, or a lapsed
|
|
192
|
+
* one the adapter can re-verify. Without that the next send is durable.
|
|
193
|
+
*/
|
|
194
|
+
export function presenceBreakdown(participants = [], adapters = ALL_ADAPTERS()) {
|
|
195
|
+
const nextTurn = new Set(adapters.filter(adapter => adapter.capabilities?.delivery?.nextTurn === true)
|
|
196
|
+
.map(adapter => adapter.id));
|
|
197
|
+
const refreshes = new Set(adapters.filter(adapter => typeof adapter.refreshNativeSession === "function")
|
|
198
|
+
.map(adapter => adapter.id));
|
|
199
|
+
const wakes = participant => participant.liveDelivery === true
|
|
200
|
+
&& (participant.liveLeaseCurrent === true || refreshes.has(participant.harness));
|
|
201
|
+
const idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: 0 };
|
|
202
|
+
for (const participant of participants.filter(item => item.presence === "stale")) {
|
|
203
|
+
const kind = participant.processTracked !== true ? "unknown"
|
|
204
|
+
: wakes(participant) ? "wake"
|
|
205
|
+
: nextTurn.has(participant.harness) ? "nextTurn" : "inbox";
|
|
206
|
+
idle[kind] += 1;
|
|
207
|
+
}
|
|
208
|
+
return idle;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* How many are here, and what a message would meet with those not active.
|
|
213
|
+
* Without a breakdown every stale session counts as not answering, the answer
|
|
214
|
+
* before presence could tell an idle session from one whose client had left.
|
|
215
|
+
*/
|
|
216
|
+
export function describePresence({ live = 0, stale = 0 } = {},
|
|
217
|
+
idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: stale }) {
|
|
184
218
|
if (live === 0) return "0 live";
|
|
185
219
|
if (stale === 0) return `${live} live`;
|
|
186
|
-
if (
|
|
187
|
-
|
|
220
|
+
if (idle.unknown === live) return `${live} present, none answering`;
|
|
221
|
+
const parts = [[idle.wake, "idle, wake on send"], [idle.nextTurn, "idle until next turn"],
|
|
222
|
+
[idle.inbox, "idle, inbox only"], [idle.unknown, "not answering"]]
|
|
223
|
+
.filter(([count]) => count > 0).map(([count, text]) => `${count} ${text}`);
|
|
224
|
+
return `${live} live (${parts.join("; ")})`;
|
|
188
225
|
}
|
|
189
226
|
|
|
190
227
|
export function describeStatus(status) {
|
|
@@ -193,7 +230,8 @@ export function describeStatus(status) {
|
|
|
193
230
|
// stops being answered but does not disappear. Printing the number alone
|
|
194
231
|
// said "1 live" about a workspace where the last agent had left minutes
|
|
195
232
|
// before, which is the one thing a person reads this line to find out.
|
|
196
|
-
const line = `${describePresence(status.counts
|
|
233
|
+
const line = `${describePresence(status.counts, presenceBreakdown(status.participants))}; `
|
|
234
|
+
+ `${status.counts.claims} claim(s); `
|
|
197
235
|
+ `protection ${status.protection}`;
|
|
198
236
|
// A transport accepting a message is not a model reading it. Said only when
|
|
199
237
|
// it is true of something, so the everyday line stays the everyday line.
|
|
@@ -60,14 +60,19 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
60
60
|
&& b.availableModes.includes("livePush"));
|
|
61
61
|
const off = native.policySource === "installation-record" && !native.configured;
|
|
62
62
|
const leased = binding !== undefined && Date.parse(binding.leaseUntil) > Date.parse(now);
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
63
|
+
// A lapsed lease is asked about too. Nothing renews it while a session
|
|
64
|
+
// sits idle, and the router does not refuse it: it re-verifies the
|
|
65
|
+
// receiver and refreshes the lease before it offers. Stopping at the
|
|
66
|
+
// lease reported sessions that answered a live question in seconds as
|
|
67
|
+
// degraded, with advice to start a new one.
|
|
68
|
+
const delivery = off || binding === undefined ? null
|
|
67
69
|
: await verifyDelivery(byAdapterId.get(entry.adapterId), { binding, runtimeDir: root });
|
|
70
|
+
// Without a re-verification the router cannot refresh a lapsed lease
|
|
71
|
+
// either, so only a live lease keeps the old answer.
|
|
68
72
|
const runtime = off ? "inactive"
|
|
69
73
|
: binding === undefined ? "unbound"
|
|
70
|
-
:
|
|
74
|
+
: delivery === null ? (leased ? "active" : "degraded")
|
|
75
|
+
: !delivery.deliverable ? "degraded" : leased ? "active" : "idle";
|
|
71
76
|
native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
|
|
72
77
|
presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
|
|
73
78
|
lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
|
|
@@ -76,10 +81,13 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
76
81
|
// one claiming health while nothing could be delivered. If this adapter
|
|
77
82
|
// has bound sessions in this workspace and not one of them can take a
|
|
78
83
|
// push, this is not an active transport.
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
const reachable = native.sessions.some(session => ["active", "idle"].includes(session.runtime));
|
|
85
|
+
if (native.runtime === "active" && native.sessions.length > 0 && !reachable) {
|
|
81
86
|
native.runtime = "degraded";
|
|
82
87
|
}
|
|
88
|
+
// The other direction, which the binding pass cannot see: it reads a
|
|
89
|
+
// lapsed lease as unreachable, and a receiver that answered says otherwise.
|
|
90
|
+
if (native.runtime === "degraded" && reachable) native.runtime = "active";
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -131,10 +139,12 @@ function describeDelivery(session) {
|
|
|
131
139
|
: "receiver verified";
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
const RUNTIME_TEXT = { active: "local transport active", idle: "idle, lease lapsed" };
|
|
143
|
+
|
|
134
144
|
export function nativeSessionLines(adapters) {
|
|
135
145
|
return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
|
|
136
146
|
` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
|
|
137
|
-
+ `${session.runtime
|
|
138
|
-
+ [describeDelivery(session),
|
|
139
|
-
.filter(part => part !== null).join("; ")));
|
|
147
|
+
+ `${RUNTIME_TEXT[session.runtime] ?? session.runtime}; `
|
|
148
|
+
+ [describeDelivery(session), session.runtime === "idle" ? "the next send refreshes it" : null,
|
|
149
|
+
describeAttempt(session.lastAttempt)].filter(part => part !== null).join("; ")));
|
|
140
150
|
}
|
|
@@ -89,6 +89,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
89
89
|
const claims = snapshot.claims
|
|
90
90
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
91
91
|
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
92
|
+
// A live binding and its lease are reported apart. The router refreshes a
|
|
93
|
+
// lapsed lease only through the adapter's own re-verification, and which
|
|
94
|
+
// adapters have one is not a fact core may know, so the reader decides.
|
|
95
|
+
const liveBound = new Map(currentBindings
|
|
96
|
+
.filter(({ binding }) => binding.availableModes.includes("livePush"))
|
|
97
|
+
.map(({ binding, reachable }) => [binding.sessionId, reachable]));
|
|
92
98
|
// What each recipient has not fetched yet, and how far it got. Offered is
|
|
93
99
|
// not read: a transport took the bytes. Queued sits beside it so "0
|
|
94
100
|
// offered" cannot be mistaken for "nothing waits". Replaced decisions
|
|
@@ -150,6 +156,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
150
156
|
enforcement: session.enforcement ?? "advisory",
|
|
151
157
|
lifecycle: session.lifecycle ?? "manual",
|
|
152
158
|
presence,
|
|
159
|
+
// What a stale presence means depends on these. With the process known,
|
|
160
|
+
// stale is idle, since a dead process is offline; with a live binding,
|
|
161
|
+
// a push can wake it. Only a session nobody can observe is not answering.
|
|
162
|
+
processTracked: (session.pid ?? null) !== null,
|
|
163
|
+
liveDelivery: liveBound.has(session.sessionId),
|
|
164
|
+
liveLeaseCurrent: liveBound.get(session.sessionId) === true,
|
|
153
165
|
// A finished intent reads as no intent at all. `--clear` exists so a
|
|
154
166
|
// peer stops seeing work that has stopped, and reporting the summary of
|
|
155
167
|
// a done record made the command and this line contradict each other.
|
|
@@ -602,6 +602,15 @@ const HANDLERS = {
|
|
|
602
602
|
async beforeTool({ binding, context, event, adapter }) {
|
|
603
603
|
if (binding === null) return { decision: "allow" };
|
|
604
604
|
if (event.targets.length === 0) {
|
|
605
|
+
// A shell command is work too. The refresh below sat behind the write
|
|
606
|
+
// guard, so a turn spent in the shell went stale three minutes in, measured
|
|
607
|
+
// on 0.8.1. With no guard read to borrow from, the session's own record is
|
|
608
|
+
// the narrow read that says whether a heartbeat is due.
|
|
609
|
+
const own = await context.service.locateSession(binding.accSessionId).catch(() => null);
|
|
610
|
+
if (own !== null && needsRefresh(own.record.heartbeatAt, Date.now())) {
|
|
611
|
+
await context.service.heartbeatSession({ sessionId: binding.accSessionId,
|
|
612
|
+
generation: binding.generation }).catch(() => null);
|
|
613
|
+
}
|
|
605
614
|
// The runner cannot tell what a shell command touches. Saying so is the
|
|
606
615
|
// honest answer; guessing would block work at random and miss real writes.
|
|
607
616
|
return { decision: "allow", unguarded: true };
|
|
@@ -31,8 +31,10 @@ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b
|
|
|
31
31
|
* has: nothing is written, and a client that is not installed simply fails to
|
|
32
32
|
* start.
|
|
33
33
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// `env` decides where the command is looked up, so detection given an
|
|
35
|
+
// environment finds the client on that environment's PATH.
|
|
36
|
+
export const spawnProbe = async (command, args, { env } = {}) => {
|
|
37
|
+
const { stdout, stderr } = await run(command, args, env === undefined ? {} : { env });
|
|
36
38
|
return `${stdout}${stderr}`.trim();
|
|
37
39
|
};
|
|
38
40
|
|
|
@@ -122,8 +124,11 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
122
124
|
const output = await withTimeout(
|
|
123
125
|
// The declared binary, never the adapter id. Guessing the id made
|
|
124
126
|
// every client whose command differs from it look uninstalled.
|
|
127
|
+
// On the PATH the native probes below use: a caller that passes its
|
|
128
|
+
// own environment must not find a different client, or none.
|
|
125
129
|
Promise.resolve(probe(adapter.client.command,
|
|
126
|
-
adapter.client.versionArgs ?? ["--version"]
|
|
130
|
+
adapter.client.versionArgs ?? ["--version"],
|
|
131
|
+
{ env: context?.env === undefined ? undefined : { ...process.env, ...context.env } })),
|
|
127
132
|
probeTimeoutMs, `${adapter.id} version probe`);
|
|
128
133
|
if (typeof output === "string" && output !== "") {
|
|
129
134
|
entry.present = true;
|
|
@@ -160,6 +165,8 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
160
165
|
if (detected.blocked) entry.blocked = detected.blocked;
|
|
161
166
|
if (detected.outgoingDelivery) entry.outgoingDelivery = detected.outgoingDelivery;
|
|
162
167
|
if (detected.inboundDelivery) entry.inboundDelivery = detected.inboundDelivery;
|
|
168
|
+
// Whether the client asks before each ACC command, and what would stop it.
|
|
169
|
+
if (detected.commandApproval) entry.commandApproval = detected.commandApproval;
|
|
163
170
|
if (detected.nativeSetup) entry.nativeSetup = detected.nativeSetup;
|
|
164
171
|
entry.diagnostics = [...(detected.diagnostics ?? [])];
|
|
165
172
|
// What a person has to do, as opposed to what is true. Adapters that
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Detection, planning, application, and the record of what was installed.
|
|
2
2
|
export { detectInstallation, spawnProbe } from "./detect.mjs";
|
|
3
3
|
export { planInstallation } from "./plan.mjs";
|
|
4
|
+
export { receiverSockets } from "./receiver-sockets.mjs";
|
|
4
5
|
export { applyPlan } from "./apply.mjs";
|
|
5
6
|
export { finalizeRemoval, fingerprint, loadOwnership, missingArtifactParents, recordInstall,
|
|
6
7
|
removeEmptyOwnedDirectories, removeOwned, removeOwnedArtifacts, treeFingerprint, verifyOwned }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every Unix socket path ACC can deliver to on this machine.
|
|
5
|
+
*
|
|
6
|
+
* A sender whose client sandboxes its shell - Codex - has to be allowed to
|
|
7
|
+
* connect to the recipient's socket, and the recipient's adapter is the only
|
|
8
|
+
* one that knows where that socket lives. Each adapter declares its own through
|
|
9
|
+
* `inboundSockets(context)`; the sender's adapter receives the composed list as
|
|
10
|
+
* `context.receiverSockets` and never names another vendor's paths itself.
|
|
11
|
+
*
|
|
12
|
+
* Composed from every known adapter, not the ones an install names: installing
|
|
13
|
+
* Codex alone must still allow a Claude Code session installed before or after.
|
|
14
|
+
*/
|
|
15
|
+
export function receiverSockets(adapters, context) {
|
|
16
|
+
const paths = [...adapters].flatMap(adapter =>
|
|
17
|
+
typeof adapter.inboundSockets === "function" ? adapter.inboundSockets(context) ?? [] : []);
|
|
18
|
+
return [...new Set(paths.filter(item => typeof item === "string" && path.isAbsolute(item)))].sort();
|
|
19
|
+
}
|
|
@@ -64,7 +64,8 @@ async function replaceHandleBytes(handle, bytes) {
|
|
|
64
64
|
* @returns {Promise<"published" | "already_published">}
|
|
65
65
|
*/
|
|
66
66
|
export async function publishAtomic(destination, bytes,
|
|
67
|
-
{ root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted
|
|
67
|
+
{ root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted, afterStageEnsured,
|
|
68
|
+
afterStageRenamed }) {
|
|
68
69
|
assertPublicationDeadline(deadlineAt);
|
|
69
70
|
// The accepted stage lives apart from the partial a failed publication
|
|
70
71
|
// leaves, so what a file is follows from the directory it was created in
|
|
@@ -145,9 +146,8 @@ export async function publishAtomic(destination, bytes,
|
|
|
145
146
|
// The seam the race test uses, in the manner retainFile already
|
|
146
147
|
// establishes: the window this closes is invisible without one.
|
|
147
148
|
await afterAccepted?.();
|
|
148
|
-
await
|
|
149
|
-
|
|
150
|
-
await syncDirectory(stageDir);
|
|
149
|
+
await retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
|
|
150
|
+
afterStageRenamed });
|
|
151
151
|
} else {
|
|
152
152
|
// A crash/error before immutable acceptance keeps its unique partial.
|
|
153
153
|
// Retention avoids the unsafe parent-check/unlink pathname window, while
|
|
@@ -157,6 +157,32 @@ export async function publishAtomic(destination, bytes,
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// The sweep can take `stage` between any two of these steps: it renames the
|
|
161
|
+
// directory aside, and a store still opening publishes here without the writer
|
|
162
|
+
// mutex the sweep holds (CI on #217). The bytes are already published, so the
|
|
163
|
+
// retained copy goes to whichever directory carries the name when it moves, and
|
|
164
|
+
// a copy the sweep took along with the directory needs no sync here - the sweep
|
|
165
|
+
// discards that directory. The two callbacks are seams for the race tests.
|
|
166
|
+
const STAGE_RETRIES = 4;
|
|
167
|
+
|
|
168
|
+
async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
|
|
169
|
+
afterStageRenamed }) {
|
|
170
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
171
|
+
await ensureManagedDirectory(root, stageDir);
|
|
172
|
+
await afterStageEnsured?.();
|
|
173
|
+
try {
|
|
174
|
+
await rename(temporary, stage);
|
|
175
|
+
break;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error.code !== "ENOENT" || attempt >= STAGE_RETRIES) throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await afterStageRenamed?.();
|
|
181
|
+
await syncDirectory(stageDir).catch(error => {
|
|
182
|
+
if (error.code !== "ENOENT") throw error;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
160
186
|
// The opener is the seam the race tests use, and stays last so callers that do
|
|
161
187
|
// not care never see it.
|
|
162
188
|
export async function readJsonIfPresent(filePath, root, openFile) {
|
|
@@ -29,32 +29,42 @@ function absolutePath(value, name, root = value) {
|
|
|
29
29
|
return path.normalize(value);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// How many more times a create-mode check takes a directory that left its name
|
|
33
|
+
// while being checked. The stage sweep renames `stage` aside and recreates it,
|
|
34
|
+
// without regard to a store that is still opening beside it, so one more try is
|
|
35
|
+
// the real case; the bound only stops a directory that never stays put from
|
|
36
|
+
// holding its caller (CI on #217).
|
|
37
|
+
const VANISHED_RETRIES = 4;
|
|
38
|
+
|
|
32
39
|
async function inspectRealDirectory(directory, root, create) {
|
|
33
|
-
let
|
|
34
|
-
|
|
35
|
-
details = await lstat(directory);
|
|
36
|
-
} catch (error) {
|
|
37
|
-
if (error.code === "ENOENT" && !create) throw error;
|
|
38
|
-
if (error.code !== "ENOENT") {
|
|
39
|
-
throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
|
|
40
|
-
}
|
|
40
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
41
|
+
let details;
|
|
41
42
|
try {
|
|
42
|
-
await
|
|
43
|
-
} catch (
|
|
44
|
-
if (
|
|
45
|
-
throw invalidDirectory("cannot
|
|
46
|
-
mkdirError.message);
|
|
43
|
+
details = await lstat(directory);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== "ENOENT") {
|
|
46
|
+
throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
|
|
47
47
|
}
|
|
48
|
+
// Missing again right after it was created means a rename took it.
|
|
49
|
+
if (!create || attempt > VANISHED_RETRIES) throw error;
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(directory);
|
|
52
|
+
} catch (mkdirError) {
|
|
53
|
+
if (mkdirError.code !== "EEXIST") {
|
|
54
|
+
throw invalidDirectory("cannot create managed directory", directory, root,
|
|
55
|
+
mkdirError.message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
48
59
|
}
|
|
49
|
-
details
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
61
|
+
throw invalidDirectory("managed directory is not a real directory", directory, root);
|
|
62
|
+
}
|
|
63
|
+
return details;
|
|
53
64
|
}
|
|
54
|
-
return details;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
67
|
+
async function inspectManagedDirectory(rootPath, directoryPath, create, { afterInspect } = {}) {
|
|
58
68
|
const root = absolutePath(rootPath, "managed root");
|
|
59
69
|
const directory = absolutePath(directoryPath, "managed directory", root);
|
|
60
70
|
const relative = relativeWithin(root, directory);
|
|
@@ -67,7 +77,17 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
|
67
77
|
let canonicalParent = canonicalRoot;
|
|
68
78
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
69
79
|
current = path.join(current, segment);
|
|
70
|
-
|
|
80
|
+
let resolved;
|
|
81
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
82
|
+
details = await inspectRealDirectory(current, root, create);
|
|
83
|
+
await afterInspect?.(current);
|
|
84
|
+
// A create-mode caller uses this name next. A directory renamed away
|
|
85
|
+
// between the check and the resolution leaves realpath nothing to resolve
|
|
86
|
+
// (ENOENT), so it is checked and created again, then resolved again; every
|
|
87
|
+
// check below applies to whichever attempt settles.
|
|
88
|
+
resolved = await resolveSegment(current, create && attempt < VANISHED_RETRIES);
|
|
89
|
+
if (resolved !== null) break;
|
|
90
|
+
}
|
|
71
91
|
// realpath answers with the name the directory carries *now*, which is not
|
|
72
92
|
// always the name that was asked for: on Darwin it resolves by opening the
|
|
73
93
|
// path and asking the kernel for that vnode's current path, so a directory
|
|
@@ -86,7 +106,6 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
|
86
106
|
// swapped in under this name is not. ENOENT on the re-stat means it moved
|
|
87
107
|
// again between the two calls, which cannot redirect anything: whatever
|
|
88
108
|
// the caller opens through this path next fails the same way.
|
|
89
|
-
const resolved = await realpath(current);
|
|
90
109
|
if (path.dirname(resolved) !== canonicalParent
|
|
91
110
|
|| (path.basename(resolved) !== segment
|
|
92
111
|
&& !await stillTheSameDirectory(details, resolved, current, root))) {
|
|
@@ -97,6 +116,16 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
|
97
116
|
return { directory, stat: details };
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
// null when the name went away and the caller may take it again.
|
|
120
|
+
async function resolveSegment(current, retry) {
|
|
121
|
+
try {
|
|
122
|
+
return await realpath(current);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error.code === "ENOENT" && retry) return null;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
async function stillTheSameDirectory(details, resolved, current, root) {
|
|
101
130
|
const served = await stat(resolved).then(found => found, error => {
|
|
102
131
|
if (error.code === "ENOENT") return null;
|
|
@@ -105,10 +134,12 @@ async function stillTheSameDirectory(details, resolved, current, root) {
|
|
|
105
134
|
return served === null || (served.dev === details.dev && served.ino === details.ino);
|
|
106
135
|
}
|
|
107
136
|
|
|
108
|
-
|
|
109
|
-
|
|
137
|
+
// `afterInspect` is the seam the race tests use: it runs between a segment's
|
|
138
|
+
// check and its resolution, the window a concurrent rename lands in.
|
|
139
|
+
export async function assertManagedDirectory(rootPath, directoryPath, options) {
|
|
140
|
+
return inspectManagedDirectory(rootPath, directoryPath, false, options);
|
|
110
141
|
}
|
|
111
142
|
|
|
112
|
-
export async function ensureManagedDirectory(rootPath, directoryPath) {
|
|
113
|
-
return (await inspectManagedDirectory(rootPath, directoryPath, true)).directory;
|
|
143
|
+
export async function ensureManagedDirectory(rootPath, directoryPath, options) {
|
|
144
|
+
return (await inspectManagedDirectory(rootPath, directoryPath, true, options)).directory;
|
|
114
145
|
}
|
|
@@ -121,8 +121,12 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
121
121
|
// must not roll the same journal forward concurrently.
|
|
122
122
|
await recoverOpenJournals();
|
|
123
123
|
|
|
124
|
-
// Sweeping is a write, so it holds the same mutex recovery does and no
|
|
125
|
-
//
|
|
124
|
+
// Sweeping is a write, so it holds the same mutex recovery does, and no
|
|
125
|
+
// transaction publishes while the stage directory is detached. A store that is
|
|
126
|
+
// still opening beside it does: the identity record, the directory checks and
|
|
127
|
+
// the journal's first slot above run without the mutex, so the managed
|
|
128
|
+
// directory checks and publishAtomic take a `stage` that left its name once
|
|
129
|
+
// more rather than failing (CI on #217). It is bounded
|
|
126
130
|
// per pass: a store carrying a large accumulation drains over several opens
|
|
127
131
|
// rather than spending one hook's whole budget on it. The mutex is taken only
|
|
128
132
|
// on the opens that actually sweep - see sweepIfDue, which decides before it
|