agents-can-communicate 0.6.2 → 0.7.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 (50) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/hook-output.mjs +12 -0
  3. package/docs/CAPABILITIES.md +1 -1
  4. package/docs/CLI.md +37 -0
  5. package/docs/GETTING_STARTED.md +2 -2
  6. package/docs/TROUBLESHOOTING.md +4 -3
  7. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -3
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +2 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +57 -11
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -2
  17. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -2
  20. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +3 -1
  23. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -0
  25. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +13 -1
  26. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  27. package/node_modules/@agents-can-communicate/cli/src/main.mjs +18 -0
  28. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/core/src/attention.mjs +7 -2
  30. package/node_modules/@agents-can-communicate/core/src/intents.mjs +16 -0
  31. package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
  32. package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
  33. package/node_modules/@agents-can-communicate/core/src/status.mjs +6 -1
  34. package/node_modules/@agents-can-communicate/core/src/sync.mjs +4 -0
  35. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +60 -7
  38. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +42 -16
  39. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +26 -7
  44. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +98 -0
  45. package/node_modules/@agents-can-communicate/storage-filesystem/src/event-floor.mjs +52 -0
  46. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +272 -0
  47. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +54 -7
  48. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +174 -0
  49. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +97 -5
  50. package/package.json +1 -1
@@ -0,0 +1,241 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ import { classifySessionPresence } from "./sessions.mjs";
4
+
5
+ // What an operator can ask to reclaim. The workspace record is deliberately
6
+ // absent: removing it would not clean a workspace, it would erase one.
7
+ export const PRUNE_CLASSES = Object.freeze(["sessions", "intents", "claims", "participants"]);
8
+
9
+ function assertClasses(classes) {
10
+ for (const name of classes) {
11
+ if (!PRUNE_CLASSES.includes(name)) {
12
+ throw new AccError(EXIT.USAGE,
13
+ `prune class is one of ${PRUNE_CLASSES.join(", ")}`, { class: name });
14
+ }
15
+ }
16
+ }
17
+
18
+ const of = (envelopes, kind) => envelopes.filter(envelope => envelope.kind === kind);
19
+
20
+ const CURSOR = /^[0-9]{16}$/;
21
+ const EVENT_PAGE = 500;
22
+
23
+ function assertBefore(before) {
24
+ if (typeof before !== "string" || !CURSOR.test(before)) {
25
+ throw new AccError(EXIT.USAGE,
26
+ "before is the 16-digit cursor a previous sync returned", { before });
27
+ }
28
+ return before;
29
+ }
30
+
31
+ /**
32
+ * Read the log up to the boundary, counting it and naming what it recorded.
33
+ *
34
+ * The boundary is a cursor rather than a date on purpose: a cursor is what
35
+ * `sync` already returns and what a peer already holds, so an operator trimming
36
+ * to one names the same point its readers name. A duration would have to be
37
+ * resolved to a sequence anyway, and the resolution could move between the
38
+ * report and the apply.
39
+ */
40
+ async function readBelow(store, workspaceId, before) {
41
+ const messageIds = new Set();
42
+ let events = 0;
43
+ let cursor = null;
44
+ for (;;) {
45
+ const page = await store.eventsSince(workspaceId, cursor, EVENT_PAGE);
46
+ if (page.events.length === 0) break;
47
+ let past = false;
48
+ for (const event of page.events) {
49
+ if (event.sequence > before) {
50
+ past = true;
51
+ break;
52
+ }
53
+ events += 1;
54
+ if (event.type === "message.recorded") messageIds.add(event.payload.messageId);
55
+ }
56
+ if (past) break;
57
+ cursor = page.cursor;
58
+ }
59
+ return { events, messageIds };
60
+ }
61
+
62
+ /**
63
+ * A message is resolved when nobody is still owed it.
64
+ *
65
+ * Offered is not read and retrieved is not model attention, so only an
66
+ * acknowledged receipt settles the obligation. A recipient that no longer
67
+ * exists cannot acknowledge anything, and holding the message for it would
68
+ * keep the whole thread forever.
69
+ */
70
+ function resolvedMessages(envelopes, messageIds) {
71
+ const participants = new Set(of(envelopes, "participant").map(envelope => envelope.id));
72
+ const open = new Set(of(envelopes, "receipt").map(envelope => envelope.record)
73
+ .filter(receipt => receipt.state !== "acknowledged"
74
+ && participants.has(receipt.recipientParticipantId))
75
+ .map(receipt => receipt.messageId));
76
+ return of(envelopes, "message")
77
+ .filter(envelope => messageIds.has(envelope.id) && !open.has(envelope.id));
78
+ }
79
+
80
+ /**
81
+ * Which records describe work that has stopped for good.
82
+ *
83
+ * Only `offline` qualifies for a session, which is a confirmed dead pid or a
84
+ * full day without a heartbeat. `stale` is a session that has gone quiet, and a
85
+ * quiet session is still someone's.
86
+ */
87
+ function eligibleSessions(envelopes, now, pidIsAlive) {
88
+ // A live claim names the session that holds it, and that name is how a peer
89
+ // blocked by the claim finds someone to ask. Removing the session leaves the
90
+ // claim reading as owned by nobody, so a session keeping a live lease stays
91
+ // however long it has been quiet. Its claim expiring makes both eligible.
92
+ const holding = new Set(of(envelopes, "claim")
93
+ .filter(envelope => Date.parse(envelope.record.expiresAt) > Date.parse(now))
94
+ .map(envelope => envelope.record.ownerSessionId));
95
+ return of(envelopes, "session")
96
+ .filter(envelope => !holding.has(envelope.id)
97
+ && classifySessionPresence(envelope.record, now, pidIsAlive) === "offline");
98
+ }
99
+
100
+ /**
101
+ * A participant is kept while anything still points at it.
102
+ *
103
+ * `removedSessions` is what this pass actually takes, not what it could take.
104
+ * The difference matters when an operator names classes: asked for participants
105
+ * alone, every session stays, so every participant is still pointed at and none
106
+ * is eligible. Judging by what is merely *eligible* would remove a participant
107
+ * and leave its sessions naming a roster entry that is gone.
108
+ *
109
+ * Messages are not pruned unless a boundary says so, and the roster is where
110
+ * "who sent this" is answered, so a name on a surviving message stays too.
111
+ */
112
+ function eligibleParticipants(envelopes, removedSessions, messages) {
113
+ const surviving = new Set(of(envelopes, "session")
114
+ .filter(envelope => !removedSessions.has(envelope.id))
115
+ .map(envelope => envelope.record.participantId));
116
+ const named = new Set(messages.flatMap(message =>
117
+ [message.fromParticipantId, ...message.toParticipantIds]));
118
+ return of(envelopes, "participant")
119
+ .filter(envelope => !surviving.has(envelope.id) && !named.has(envelope.id));
120
+ }
121
+
122
+ export function createPruneService(ports) {
123
+ const { store, clock, pidIsAlive } = ports;
124
+
125
+ /**
126
+ * What `prune` would reclaim, as records rather than as counts.
127
+ *
128
+ * The plan is computed outside the writer mutex and applied inside it, so
129
+ * every entry carries the generation that proves it is still the record this
130
+ * judged. A claim renewed in between is skipped by the store rather than
131
+ * removed.
132
+ */
133
+ async function planPrune(input = {}) {
134
+ const classes = input.classes ?? PRUNE_CLASSES;
135
+ assertClasses(classes);
136
+ const workspaceId = input.workspaceId ?? store.workspaceId;
137
+ const now = clock.now();
138
+ const envelopes = await store.stateEnvelopes(workspaceId);
139
+ const wants = name => classes.includes(name);
140
+
141
+ const eligible = wants("sessions") || wants("intents") || wants("participants")
142
+ ? eligibleSessions(envelopes, now, pidIsAlive)
143
+ : [];
144
+ const sessions = wants("sessions") ? eligible : [];
145
+ const removedSessions = new Set(sessions.map(envelope => envelope.id));
146
+ const eligibleSessionIds = new Set(eligible.map(envelope => envelope.id));
147
+
148
+ // An intent belongs to one session and outlives nothing, so a session
149
+ // leaving takes its intent with it whether or not the operator named the
150
+ // class. Asking for intents alone reaches further: every intent whose
151
+ // session has stopped, including sessions this pass is keeping. Nothing
152
+ // points at an intent, so removing one leaves nothing dangling - but
153
+ // leaving one behind when its session goes would.
154
+ const owning = wants("intents") ? eligibleSessionIds : removedSessions;
155
+ const intents = of(envelopes, "intent")
156
+ .filter(envelope => owning.has(envelope.record.sessionId));
157
+ const claims = wants("claims")
158
+ ? of(envelopes, "claim")
159
+ .filter(envelope => Date.parse(envelope.record.expiresAt) <= Date.parse(now))
160
+ : [];
161
+
162
+ // History is named by a boundary rather than by a class, because there is
163
+ // no such thing as an event that has stopped being true - only one the
164
+ // operator has decided nobody will read again.
165
+ const before = input.before === undefined ? null : assertBefore(input.before);
166
+ const below = before === null ? { events: 0, messageIds: new Set() }
167
+ : await readBelow(store, workspaceId, before);
168
+ const messages = before === null ? [] : resolvedMessages(envelopes, below.messageIds);
169
+ const doomedMessages = new Set(messages.map(envelope => envelope.id));
170
+ const receipts = of(envelopes, "receipt")
171
+ .filter(envelope => doomedMessages.has(envelope.record.messageId));
172
+
173
+ // Decided after the messages, and against the ones that will survive this
174
+ // pass. A participant named only by a message the same run removes is a
175
+ // participant nothing will point at once the run finishes; judging it
176
+ // against every message that exists now would keep it, report no work
177
+ // remaining, and need a second identical run to reclaim it.
178
+ const participants = wants("participants")
179
+ ? eligibleParticipants(envelopes, removedSessions, of(envelopes, "message")
180
+ .map(envelope => envelope.record).filter(record => !doomedMessages.has(record.messageId)))
181
+ : [];
182
+
183
+ const named = { sessions, intents, claims, participants, messages, receipts };
184
+ // Ordered so that anything holding a reference goes before what it refers
185
+ // to. A pass can stop at its budget or its deadline, and what it has done
186
+ // by then is a prefix of this list - so the prefix has to be a state the
187
+ // store can be left in. A receipt names a message, a message and an intent
188
+ // and a claim each name a session, and a session names a participant.
189
+ // Removing them in that order means an interrupted pass never leaves a
190
+ // record pointing at one that is already gone.
191
+ const order = ["receipts", "messages", "intents", "claims", "sessions", "participants"];
192
+ const entries = order.flatMap(name => named[name])
193
+ .map(({ kind, id, generation }) => ({ kind, id, generation }));
194
+ return { workspaceId,
195
+ before,
196
+ counts: { ...Object.fromEntries(Object.entries(named)
197
+ .map(([name, list]) => [name, list.length])), events: below.events },
198
+ entries };
199
+ }
200
+
201
+ /**
202
+ * Reclaim what the plan named, or report it and change nothing.
203
+ *
204
+ * The dry run is the default because this is the one command that takes
205
+ * records out of the store. A caller that means it says so.
206
+ */
207
+ async function prune(input = {}) {
208
+ let plan = await planPrune(input);
209
+ if (input.apply !== true) {
210
+ return { ...plan, applied: false, reclaimed: 0, skipped: 0, remaining: false,
211
+ trimmedThrough: null };
212
+ }
213
+ // Decided again inside the writer mutex, and that second reading is the one
214
+ // acted on. Eligibility here is relational - a participant is eligible
215
+ // because no session of theirs survives - and a generation proves only that
216
+ // one record did not change. A session opening between the report and the
217
+ // apply changes the answer without changing any record this had named.
218
+ let decided = plan;
219
+ const result = await store.reclaimRecords(async () => {
220
+ decided = await planPrune(input);
221
+ return decided.entries;
222
+ }, { limit: input.limit, deadlineAt: input.deadlineAt });
223
+ if (decided.before === null) {
224
+ return { ...decided, applied: true, ...result, trimmedThrough: null };
225
+ }
226
+ plan = decided;
227
+ // Records first, then the log. A message removed while its recording event
228
+ // survives reads as history describing a record that is gone, which is
229
+ // ordinary. The reverse - an event log that starts after a message it never
230
+ // mentions - is the gap the floor is there to report.
231
+ const trimmed = await store.trimHistory(plan.before,
232
+ { limit: input.limit, deadlineAt: input.deadlineAt });
233
+ return { ...plan, applied: true,
234
+ reclaimed: result.reclaimed + trimmed.reclaimed,
235
+ skipped: result.skipped,
236
+ trimmedThrough: trimmed.trimmedThrough,
237
+ remaining: result.remaining || trimmed.remaining };
238
+ }
239
+
240
+ return { planPrune, prune };
241
+ }
@@ -5,6 +5,7 @@ import { createIntentService } from "./intents.mjs";
5
5
  import { createInboxService } from "./inbox.mjs";
6
6
  import { defaultPidIsAlive } from "./pid.mjs";
7
7
  import { assertPorts } from "./ports.mjs";
8
+ import { createPruneService } from "./prune.mjs";
8
9
  import { createReceiptService } from "./receipts.mjs";
9
10
  import { createSessionService } from "./sessions.mjs";
10
11
  import { createGuardStateService, createStatusService } from "./status.mjs";
@@ -37,6 +38,7 @@ export function createCoordinationService({ store, clock, ids,
37
38
  const sync = createSyncService(ports, sessions);
38
39
  const status = createStatusService(ports, sessions, deliveryBindings);
39
40
  const guardState = createGuardStateService(ports);
41
+ const prune = createPruneService(ports);
40
42
  return Object.freeze({
41
43
  store,
42
44
  clock,
@@ -62,6 +64,7 @@ export function createCoordinationService({ store, clock, ids,
62
64
  ...receipts,
63
65
  ...sync,
64
66
  ...status,
67
+ ...prune,
65
68
  guardState,
66
69
  });
67
70
  }
@@ -1,5 +1,6 @@
1
1
  import { classifySessionPresence } from "./sessions.mjs";
2
2
  import { computeAttention } from "./attention.mjs";
3
+ import { isCurrentIntent } from "./intents.mjs";
3
4
 
4
5
  /**
5
6
  * Protection level, reported from what is actually enforceable.
@@ -108,7 +109,11 @@ export function createStatusService(ports, sessions, deliveryBindings) {
108
109
  enforcement: session.enforcement ?? "advisory",
109
110
  lifecycle: session.lifecycle ?? "manual",
110
111
  presence,
111
- intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
112
+ // A finished intent reads as no intent at all. `--clear` exists so a
113
+ // peer stops seeing work that has stopped, and reporting the summary of
114
+ // a done record made the command and this line contradict each other.
115
+ intent: intents.find(intent => intent.sessionId === session.sessionId
116
+ && isCurrentIntent(intent))?.summary ?? null,
112
117
  })),
113
118
  // The owner is named twice on purpose. Every command that reaches a peer
114
119
  // takes a participant id, so a claim that gave only a session id sent the
@@ -86,6 +86,10 @@ export function createSyncService(ports, sessions) {
86
86
  && snapshot.claims.length === 0;
87
87
  return {
88
88
  cursor: page.cursor,
89
+ // Where the log starts, when it no longer starts at the beginning. A
90
+ // caller whose cursor precedes this was served a short page, and saying
91
+ // so is the difference between a trim and a silent gap.
92
+ trimmedThrough: page.trimmedThrough ?? null,
89
93
  scope: input.scope === "full" ? "full" : "delta",
90
94
  solo,
91
95
  attention,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,10 +3,20 @@ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer"
3
3
 
4
4
  import { establishNativeBinding, LIVE_POLICIES, livePolicyFrom } from "./native-binding.mjs";
5
5
 
6
- // Reserve time for turn projection, status and output before optional disk I/O.
6
+ // A cold worker plus the write has to finish inside this window. 250 ms was
7
+ // not enough on a busy CI runner, so the attempt file never appeared and
8
+ // doctor reported no attempt. 1.5 s still abandons a stuck disk, and the
9
+ // extra 500 ms stays available for turn projection, status and output.
10
+ const DIAGNOSTIC_BUDGET_MS = 1_500;
11
+ const DIAGNOSTIC_FLOOR_MS = 250;
12
+ const AFTER_DIAGNOSTIC_MS = 500;
13
+
7
14
  export function nativeDiagnosticDeadline(hookDeadline) {
8
15
  const now = Date.now();
9
- return hookDeadline - now >= 750 ? now + 250 : null;
16
+ const remaining = hookDeadline - now;
17
+ // Below this, even the old quarter-second write would crowd out projection.
18
+ if (remaining < DIAGNOSTIC_FLOOR_MS + AFTER_DIAGNOSTIC_MS) return null;
19
+ return now + Math.min(DIAGNOSTIC_BUDGET_MS, remaining - AFTER_DIAGNOSTIC_MS);
10
20
  }
11
21
 
12
22
  // The diagnostic has its own bounded writer; it never rewrites hook ownership.
@@ -45,12 +55,44 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
45
55
  const HINT_MAX_BYTES = 512;
46
56
  const HINT_MAX_MS = 250;
47
57
 
58
+ function asHint(value) {
59
+ if (typeof value === "string") return { line: value, release: async () => {} };
60
+ if (value !== null && typeof value === "object" && typeof value.line === "string") {
61
+ return { line: value.line,
62
+ release: typeof value.release === "function" ? value.release : async () => {} };
63
+ }
64
+ if (value !== null && typeof value === "object" && typeof value.release === "function") {
65
+ return { line: "", release: value.release };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function usableHint(line) {
71
+ return line !== "" && !/[\r\n]/.test(line) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
72
+ }
73
+
74
+ // An adapter may release with a synchronous function. Calling `.catch` on its
75
+ // return value throws when that return value is undefined, and a throw from
76
+ // the function itself must not become a failed hook.
77
+ export function callRelease(release) {
78
+ if (typeof release !== "function") return Promise.resolve();
79
+ try {
80
+ return Promise.resolve(release()).catch(() => {});
81
+ } catch {
82
+ return Promise.resolve();
83
+ }
84
+ }
85
+
48
86
  /**
49
87
  * One line an adapter asks the agent to act on, when the agent itself can fix
50
88
  * a degraded native binding - Antigravity's relay is started from the agent's
51
89
  * own shell, and nothing else can start it. Bounded and fail-open: a slow or
52
90
  * failing adapter costs the turn nothing, and anything but one short line is
53
91
  * dropped rather than trusted into model context.
92
+ *
93
+ * A string has nothing reserved. `{ line, release }` has reserved an ask; this
94
+ * function calls `release` when it drops the line, including when the adapter
95
+ * answers after the budget. The caller releases a line it does not deliver.
54
96
  */
55
97
  export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
56
98
  paths, deadline }) {
@@ -58,17 +100,28 @@ export async function nativeActivationHintFor({ adapter, event, nativeBinding, b
58
100
  const budget = Math.min(HINT_MAX_MS, deadline - Date.now() - HINT_MAX_MS);
59
101
  if (budget <= 0) return null;
60
102
  let timer = null;
103
+ let keep = false;
104
+ // The call sits inside then so a synchronous throw becomes a rejection.
105
+ // Promise.resolve(adapter.nativeActivationHint()) evaluates the call first
106
+ // and lets that throw escape the hook.
107
+ const pending = Promise.resolve().then(() => adapter.nativeActivationHint({ event, nativeBinding,
108
+ runtimeDir: paths.root, clientPid: binding?.clientPid, env: context.env })).then(asHint, () => null);
61
109
  try {
62
- const line = await Promise.race([
63
- Promise.resolve(adapter.nativeActivationHint({ event, nativeBinding, runtimeDir: paths.root,
64
- clientPid: binding?.clientPid, env: context.env })),
110
+ const taken = await Promise.race([
111
+ pending,
65
112
  new Promise(resolve => { timer = setTimeout(resolve, budget, null); }),
66
113
  ]);
67
- return typeof line === "string" && line !== "" && !/[\r\n]/.test(line)
68
- && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES ? line : null;
114
+ if (taken !== null && usableHint(taken.line)) {
115
+ keep = true;
116
+ return taken;
117
+ }
118
+ return null;
69
119
  } catch {
70
120
  return null;
71
121
  } finally {
72
122
  if (timer !== null) clearTimeout(timer);
123
+ // A late or rejected answer still holds its reservation. Do not wait for
124
+ // it: an adapter that never settles must not hold the hook.
125
+ if (!keep) pending.then(taken => callRelease(taken?.release)).catch(() => {});
73
126
  }
74
127
  }
@@ -15,7 +15,8 @@ import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runti
15
15
 
16
16
  import { resolveClientPid } from "./client-pid.mjs";
17
17
  import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
18
- import { bindNative, nativeActivationHintFor, nativeDiagnosticDeadline } from "./native-attempt.mjs";
18
+ import { bindNative, callRelease, nativeActivationHintFor, nativeDiagnosticDeadline }
19
+ from "./native-attempt.mjs";
19
20
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
20
21
  import { withSessionLifecycle } from "./session-lifecycle.mjs";
21
22
  import { appendStartOwner, appendToolOwner, ownerHeader, ownerOnlyOutcome } from "./owner-context.mjs";
@@ -264,14 +265,17 @@ async function projectTurn({ binding, context, adapter, adapterId,
264
265
  // turn carries - and only when both fit in half the budget, so peer bodies
265
266
  // always keep the rest.
266
267
  const header = ownerHeader(binding, context.workspaceCwd, context.workspaceRef);
267
- const owner = activationHint === null
268
- || byteLength(header) + 1 + byteLength(activationHint) > totalBudget / 2
269
- ? header : `${header}\n${activationHint}`;
268
+ const hintFits = activationHint !== null
269
+ && byteLength(header) + 1 + byteLength(activationHint) <= totalBudget / 2;
270
+ const owner = hintFits ? `${header}\n${activationHint}` : header;
271
+ const withHint = (outcome, attached) => ({ ...outcome,
272
+ activationHintIncluded: attached === true && outcome.stdout !== "" });
270
273
  // A peer can join after this prompt has begun. The current turn must already
271
274
  // have its own arguments when it needs inbox/reply, without reattaching or
272
275
  // waiting for another user prompt. Solo emits identity, not a peer notice.
273
276
  if (sync.solo && messages.length === 0) {
274
- return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
277
+ return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
278
+ hintFits);
275
279
  }
276
280
 
277
281
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
@@ -324,12 +328,13 @@ async function projectTurn({ binding, context, adapter, adapterId,
324
328
  // Own claims make sync non-solo but produce no peer context. They cannot
325
329
  // remove this turn's identity or consume a nonexistent body separator.
326
330
  if (body === "" && messages.length === 0) {
327
- return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
331
+ return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
332
+ hintFits);
328
333
  }
329
334
  const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
330
335
  if (projected === "") {
331
- return { stdout: "", stderr: messages.length === 0 ? ""
332
- : [ownerWarning, degradation].filter(Boolean).join("\n") };
336
+ return withHint({ stdout: "", stderr: messages.length === 0 ? ""
337
+ : [ownerWarning, degradation].filter(Boolean).join("\n") }, false);
333
338
  }
334
339
 
335
340
  // The renderer returns ids as metadata, never as text to parse. A peer body
@@ -349,9 +354,30 @@ async function projectTurn({ binding, context, adapter, adapterId,
349
354
  // callback proves that the bytes crossed.
350
355
  const outcome = { stdout: "", ...render(projected) };
351
356
  const writableOffers = outcome.stdout === "" ? [] : offerInputs;
352
- return { ...outcome,
357
+ return withHint({ ...outcome,
353
358
  stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
354
- offerInputs: writableOffers };
359
+ offerInputs: writableOffers }, hintFits && ownerFits);
360
+ }
361
+
362
+ // The adapter may already have reserved an ask. That reservation is an ask
363
+ // only when this turn's stdout carries the line. A throw discards the turn,
364
+ // so the reservation goes with it.
365
+ async function projectActivation(input, offered) {
366
+ try {
367
+ const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null });
368
+ const included = turn.activationHintIncluded === true;
369
+ delete turn.activationHintIncluded;
370
+ if (!offered) return turn;
371
+ if (!included) await callRelease(offered.release);
372
+ else if (typeof offered.release === "function") {
373
+ const release = offered.release;
374
+ turn.releaseActivationAsk = () => callRelease(release);
375
+ }
376
+ return turn;
377
+ } catch (error) {
378
+ await callRelease(offered?.release);
379
+ throw error;
380
+ }
355
381
  }
356
382
 
357
383
  const HANDLERS = {
@@ -500,9 +526,9 @@ const HANDLERS = {
500
526
  const started = await HANDLERS.sessionStart(input);
501
527
  const fresh = await loadSessionBinding({ runtimeDir: paths.root,
502
528
  harnessSessionId: event.sessionId });
503
- const activationHint = await nativeActivationHintFor({ adapter, event,
529
+ const offered = await nativeActivationHintFor({ adapter, event,
504
530
  nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
505
- const turn = await projectTurn({ ...input, binding: fresh, activationHint });
531
+ const turn = await projectActivation({ ...input, binding: fresh }, offered);
506
532
  return { ...turn, nativeBinding: started.nativeBinding };
507
533
  }
508
534
  const current = await context.service.locateSession(binding.accSessionId);
@@ -516,9 +542,9 @@ const HANDLERS = {
516
542
  const started = await HANDLERS.sessionStart(input);
517
543
  const fresh = await loadSessionBinding({ runtimeDir: paths.root,
518
544
  harnessSessionId: event.sessionId });
519
- const activationHint = await nativeActivationHintFor({ adapter, event,
545
+ const offered = await nativeActivationHintFor({ adapter, event,
520
546
  nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
521
- const turn = await projectTurn({ ...input, binding: fresh, activationHint });
547
+ const turn = await projectActivation({ ...input, binding: fresh }, offered);
522
548
  return { ...turn, nativeBinding: started.nativeBinding };
523
549
  }
524
550
  // A turn is the clearest sign a session is alive. Never a reason to fail:
@@ -530,9 +556,9 @@ const HANDLERS = {
530
556
  const nativeBinding = await bindNative({ adapter, event, hookBinding: binding,
531
557
  clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
532
558
  deadline });
533
- const activationHint = await nativeActivationHintFor({ adapter, event, nativeBinding,
559
+ const offered = await nativeActivationHintFor({ adapter, event, nativeBinding,
534
560
  binding, context, paths, deadline });
535
- const turn = await projectTurn({ ...input, activationHint });
561
+ const turn = await projectActivation(input, offered);
536
562
  return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
537
563
  },
538
564
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,11 +34,11 @@ async function bytesIfPresent(filePath, root, openFile) {
34
34
  }
35
35
  }
36
36
 
37
- function retainedStage(destination, root, tmpDir) {
37
+ function retainedStage(destination, root, stageDir) {
38
38
  const identity = createHash("sha256")
39
39
  .update(path.relative(root, destination))
40
40
  .digest("hex");
41
- return path.join(tmpDir, `${identity}.published`);
41
+ return path.join(stageDir, `${identity}.published`);
42
42
  }
43
43
 
44
44
  async function replaceHandleBytes(handle, bytes) {
@@ -63,11 +63,18 @@ async function replaceHandleBytes(handle, bytes) {
63
63
  *
64
64
  * @returns {Promise<"published" | "already_published">}
65
65
  */
66
- export async function publishAtomic(destination, bytes, { root, tmpDir, replace = false, deadlineAt }) {
66
+ export async function publishAtomic(destination, bytes,
67
+ { root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted }) {
67
68
  assertPublicationDeadline(deadlineAt);
69
+ // The accepted stage lives apart from the partial a failed publication
70
+ // leaves, so what a file is follows from the directory it was created in
71
+ // rather than from how its name ends. Defaulted from the root the way
72
+ // active-journal.mjs defaults tmpDir, so every caller keeps working.
73
+ stageDir = stageDir ?? path.join(root, "stage");
68
74
  const destinationDir = path.dirname(destination);
69
75
  await Promise.all([
70
76
  ensureManagedDirectory(root, tmpDir),
77
+ ensureManagedDirectory(root, stageDir),
71
78
  ensureManagedDirectory(root, destinationDir),
72
79
  ]);
73
80
  if (!replace) {
@@ -79,8 +86,11 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
79
86
  }
80
87
  }
81
88
 
82
- const stage = retainedStage(destination, root, tmpDir);
83
- const temporary = `${stage}.${process.pid}.${randomUUID()}.tmp`;
89
+ const stage = retainedStage(destination, root, stageDir);
90
+ // The temporary stays in tmpDir: until the bytes are accepted it is a
91
+ // partial, and a partial is exactly what tmp holds.
92
+ const temporary = path.join(tmpDir,
93
+ `${path.basename(stage)}.${process.pid}.${randomUUID()}.tmp`);
84
94
  let handle = await open(temporary, "wx");
85
95
  let stageAcceptedBytes = false;
86
96
  try {
@@ -121,9 +131,18 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
121
131
  } finally {
122
132
  await handle?.close();
123
133
  if (stageAcceptedBytes) {
124
- await assertManagedDirectory(root, tmpDir);
134
+ // `ensure`, not `assert`: the sweep empties this directory by renaming it
135
+ // aside and recreating it, and a publication that began before that swap
136
+ // arrives here to find the name gone. Both apply the same managed-root
137
+ // checks - the only difference is that this one recreates what the sweep
138
+ // has already taken away, instead of failing a write whose bytes are
139
+ // already published.
140
+ // The seam the race test uses, in the manner retainFile already
141
+ // establishes: the window this closes is invisible without one.
142
+ await afterAccepted?.();
143
+ await ensureManagedDirectory(root, stageDir);
125
144
  await rename(temporary, stage);
126
- await syncDirectory(tmpDir);
145
+ await syncDirectory(stageDir);
127
146
  } else {
128
147
  // A crash/error before immutable acceptance keeps its unique partial.
129
148
  // Retention avoids the unsafe parent-check/unlink pathname window, while