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
package/README.md CHANGED
@@ -119,7 +119,9 @@ Automatic updates are enabled on first install. Use `acc update --auto off` to d
119
119
  [update controls](docs/UPGRADING.md) for details.
120
120
 
121
121
  MIT-licensed and permanently noncommercial. Try it on one real task and
122
- [tell us where you still had to carry messages yourself](https://github.com/automatis-tools/agents-can-communicate/issues).
122
+ [tell us how it went](https://github.com/automatis-tools/agents-can-communicate/discussions/categories/show-and-tell),
123
+ including where you still had to carry messages yourself. Bugs belong in
124
+ [issues](https://github.com/automatis-tools/agents-can-communicate/issues).
123
125
 
124
126
  [Documentation](docs/index.md) ·
125
127
  [Contributing](https://github.com/automatis-tools/agents-can-communicate/blob/main/AGENTS.md)
@@ -38,6 +38,15 @@ function boundedDiagnostic(label, error) {
38
38
  return `${line}\n`;
39
39
  }
40
40
 
41
+ async function releaseActivation(release) {
42
+ if (typeof release !== "function") return;
43
+ try {
44
+ await release();
45
+ } catch {
46
+ // The line was not delivered. Releasing its reservation is not the hook.
47
+ }
48
+ }
49
+
41
50
  function tryWrite(stream, output) {
42
51
  if (output === "") return;
43
52
  try {
@@ -62,6 +71,9 @@ export async function completeHookOutput(result,
62
71
  await writeOutput(stdout, result.stdout ?? "", { deadlineAt: result.deadlineAt });
63
72
  } catch (error) {
64
73
  tryWrite(stderr, boundedDiagnostic("stdout write failed", error));
74
+ // The bytes did not cross, so a reserved activation line is not an ask.
75
+ // release may return nothing or throw; neither may fail this hook.
76
+ await releaseActivation(result.releaseActivationAsk);
65
77
  return { exitCode: 0, wroteStdout: false, committedOffers: false };
66
78
  }
67
79
 
@@ -60,7 +60,7 @@ The limitations belong next to the adapters they affect:
60
60
 
61
61
  | Adapter | Exact limitation and evidence |
62
62
  |---|---|
63
- | Antigravity CLI | 1.2.7 on darwin-arm64, captured in print mode, and every later release by the forward rule. Only `SessionStart`, `PreInvocation`, `PostInvocation` and `Stop` load; `SessionEnd`, `PreToolUse` and `PostToolUse` are accepted into the config file and silently dropped, so there is no tool guard and no session-end deregistration - a session goes offline by presence age or an explicit `acc finish`. Payloads carry no `hook_event_name`, so each registered command passes its own event name. The end-of-turn `Stop` continuation reaches the model and is a bounded nudge, not a gate: ACC continues a turn at most once and fails open, and the client caps consecutive continuations itself (vendor 1.1.9). `agy agentapi send-message` can wake an idle session - captured - but only with that session's language-server address and CSRF token, which exist in the agent's own shell and in no hook. ACC does not take that token, so live push and reply routing are false. A peer message that arrives while the model writes its last answer is carried by the `Stop` continuation instead. A write that parses can register nothing, so install and doctor read `agy -p "/hooks"` back instead of trusting the file. Live push (1.2.7, darwin-arm64, TUI only, experimental, recorded opt-in) runs through a relay the agent starts once per conversation from its own shell - the only process holding the session endpoint - after ACC's context asks it to; the operator approves that command at the client's permission prompt. An idle session wakes; a busy one sees the message after its running answer, or at the next model invocation when the turn waits on a tool. Print mode and the first session in a folder trusted at that launch get no relay. |
63
+ | Antigravity CLI | 1.2.7 on darwin-arm64, captured in print mode, and every later release by the forward rule. Only `SessionStart`, `PreInvocation`, `PostInvocation` and `Stop` load; `SessionEnd`, `PreToolUse` and `PostToolUse` are accepted into the config file and silently dropped, so there is no tool guard and no session-end deregistration - a session goes offline by presence age or an explicit `acc finish`. Payloads carry no `hook_event_name`, so each registered command passes its own event name. The end-of-turn `Stop` continuation reaches the model and is a bounded nudge, not a gate: ACC continues a turn at most once and fails open, and the client caps consecutive continuations itself (vendor 1.1.9). `agy agentapi send-message` can wake an idle session - captured - but only with that session's language-server address and CSRF token, which exist in the agent's own shell and in no hook. ACC does not take that token, so live push and reply routing are false. A peer message that arrives while the model writes its last answer is carried by the `Stop` continuation instead. A write that parses can register nothing, so install and doctor read `agy -p "/hooks"` back instead of trusting the file. Live push (1.2.7, darwin-arm64, TUI only, experimental, recorded opt-in) runs through a relay the agent starts once per conversation from its own shell - the only process holding the session endpoint - after ACC's context asks it to, up to three times while none is serving; the operator approves that command at the client's permission prompt. An idle session wakes; a busy one sees the message after its running answer, or at the next model invocation when the turn waits on a tool. Print mode and the first session in a folder trusted at that launch get no relay. |
64
64
  | Codex | Next-turn context, captured on 0.147.0, requires plugin trust. The observed stock 0.153.4 upgrade from ACC 0.3.1 to 0.4 required fresh review of five modified hook definitions; a subsequent restart retained all five active (activation evidence, not new event certification). LocalDaemon native delivery was captured through the installed package on 0.152.1 and 0.153.4, darwin-arm64; minimum 0.152.1, recorded opt-in, current feature probe and exact thread/cwd/process/version/protocol checks are required. Ordinary launch preserves the receiver workspace without ACC arguments or daemon ownership. Embedded or unreachable sessions keep their inbox. Native `replyRoute` remains false. |
65
65
  | Claude Code | 2.1.233 next-turn delivery waits for the next user prompt. A 2.1.258 Channel capture proved idle offer, busy queue-after-turn, explicit reply, duplicate suppression, and durable fallback, so `delivery.livePush` and `delivery.replyRoute` are live capabilities behind the native contract (experimental, off until opted in; Claude's development-channel warning is vendor-owned and visible). |
66
66
  | Gemini CLI | Package-shipped next-turn certification starts at 0.57.0. Its TUI has no captured external wake or queue interface and `--acp` changes launch ownership, so native delivery is fallback-only; live push and reply routing remain false. |
package/docs/CLI.md CHANGED
@@ -101,6 +101,43 @@ delivery bindings. Default `sync` reads events after a 16-digit event cursor (10
101
101
  default, up to 500). `--scope history` discovers historical message headers and reads
102
102
  selected records; `--scope full` adds an unbounded snapshot for explicit workspace forensics.
103
103
 
104
+ ### Retention
105
+
106
+ A workspace keeps what it is told to keep. `acc prune` reports what it no longer needs and
107
+ reclaims it only when asked:
108
+
109
+ ```bash
110
+ acc prune
111
+ acc prune --apply
112
+ acc prune --class claims --apply
113
+ acc prune --before event_cursor
114
+ ```
115
+
116
+ **`prune` changes nothing without `--apply`.** The reporting run is the default because this
117
+ is the one command that takes records out of the store.
118
+
119
+ | Class | Reclaimed when | Threshold |
120
+ |---|---|---|
121
+ | `sessions` | presence is `offline` | a confirmed dead pid, or 24 hours without a heartbeat |
122
+ | `intents` | the session they belong to is reclaimed | - |
123
+ | `claims` | the lease has expired | leases default to 30 minutes (`--lease-seconds`) |
124
+ | `participants` | no session of theirs survives and no surviving message names them | - |
125
+
126
+ `--before` takes a 16-digit cursor, the same one `sync` returns, and trims the event log to
127
+ it. Messages recorded at or below that point go too, but only once nobody is owed them: a
128
+ receipt that is still `queued`, `offered` or `retrieved` keeps its message, because offered is
129
+ not read and retrieved is not model attention. A recipient that no longer exists cannot
130
+ acknowledge anything, and does not hold a message forever.
131
+
132
+ After a trim, `sync` reports `trimmedThrough`. A caller whose cursor precedes it was served a
133
+ short page and can tell. **An ACC older than this feature cannot**: it will serve the same
134
+ cursor without reporting the boundary, which is why trimming history is never automatic.
135
+
136
+ Two classes need no operator at all. A retired transaction journal and a superseded retention
137
+ marker have no reader, so a bounded pass reclaims them once a day when a store is opened, and
138
+ `acc doctor --repair` reclaims them without that bound. `acc doctor` reports what is still
139
+ held.
140
+
104
141
  ### Claims
105
142
 
106
143
  ```bash
@@ -146,8 +146,8 @@ until a running turn finishes. Codex requires 0.152.1 or newer, LocalDaemon infr
146
146
  recorded opt-in, and a verified session. Supported explicit setup can prepare a missing
147
147
  service. Start the client with your normal command. A loaded daemon thread can receive
148
148
  messages after its terminal exits. Antigravity CLI 1.2.7 and later has one too: with delivery enabled,
149
- ACC's context asks the agent once per conversation to start a relay from its own shell, and
150
- you approve that command at the client's prompt.
149
+ ACC's context asks the agent to start a relay from its own shell, up to three times while
150
+ none is running, and you approve that command at the client's prompt.
151
151
  [Capabilities](CAPABILITIES.md) explains policy, versions and fallback.
152
152
 
153
153
  Delivery evidence is deliberately narrow: `queued -> offered -> retrieved -> acknowledged`.
@@ -278,9 +278,10 @@ Live delivery needs four things, and `acc doctor` shows each:
278
278
  - the live policy is on: `acc install --adapter antigravity --delivery actionable` (or `all`);
279
279
  - the session is interactive - print mode ends with its turn and never runs a relay;
280
280
  - the session attached to ACC at all (see the section above);
281
- - the agent started the relay. ACC's context asks once per conversation; if the command was
282
- declined, or never approved at the permission prompt, nothing asks again. Ask the agent to
283
- run `sh "~/.gemini/config/acc/acc-relay.sh" start`, or start a new conversation.
281
+ - the agent started the relay. While none is running, ACC's context asks up to three times
282
+ in that conversation. A declined prompt and an ignored ask look the same, so the line can
283
+ return after a decline; after the third ask it stays quiet. Run
284
+ `sh "~/.gemini/config/acc/acc-relay.sh" start` to start it directly.
284
285
 
285
286
  `acc doctor` reports how many relays are running and, per session, whether a live transport is
286
287
  active. A relay ends with its client; nothing is left running after the TUI exits or after
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-antigravity",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "acc",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Coordinate this Antigravity CLI session with other AI agent sessions working in the same workspace."
5
5
  }
@@ -84,9 +84,9 @@ export function createAntigravityAdapter() {
84
84
  probeNativeDelivery, planNativeActivation, bindNativeSession, refreshNativeSession,
85
85
  offerMessage,
86
86
  deliveryFallback: { diagnostic:
87
- `Antigravity CLI next-turn and live delivery are certified for ${ANTIGRAVITY_CLI_VERSION} and `
88
- + "later stable releases on darwin-arm64; live delivery also needs the agent to start "
89
- + "ACC's relay once per conversation, and every other case keeps durable acc inbox access" },
87
+ `Antigravity CLI next-turn and live delivery are certified from ${ANTIGRAVITY_CLI_VERSION} `
88
+ + "onward; live delivery also needs the agent to start ACC's relay once per conversation, "
89
+ + "and an older client keeps durable acc inbox access" },
90
90
 
91
91
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
92
92
 
@@ -654,7 +654,8 @@ export async function doctorAntigravity(context) {
654
654
  diagnostics.push("registered per workspace; a session opened anywhere else loads nothing");
655
655
  }
656
656
  diagnostics.push(`live delivery starts when the agent runs sh "${relayShimPath(context.home)}" `
657
- + "start once in a conversation; ACC asks it to, in its context line, when the policy is on");
657
+ + "start once in a conversation; while none is serving, ACC asks for that in its context "
658
+ + "line, up to three times, when the policy is on");
658
659
  diagnostics.push(`antigravity relays running on this machine: ${await runningRelays({
659
660
  dataHome: context.dataHome })}`);
660
661
  return { ok: true, changes: [], diagnostics };
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
- import { lstat, mkdir, open } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, rm } from "node:fs/promises";
4
5
  import net from "node:net";
5
6
  import path from "node:path";
6
7
 
@@ -144,27 +145,72 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 8
144
145
  }
145
146
  }
146
147
 
148
+ const ASK_LIMIT = 3;
147
149
  const HINT = home => "ACC: live delivery is on but not running in this conversation. To let "
148
150
  + `peers reach you while idle, run once: sh "${path.join(home, ".gemini", "config", "acc",
149
151
  "acc-relay.sh")}" start`;
150
152
 
153
+ // Each ask is its own file, created exclusively. A counter read and then
154
+ // rewritten lets overlapping calls all observe the same count and each return
155
+ // a hint. An empty file at the unsuffixed hash is the old one-ask marker: it
156
+ // is not one of these three, so it does not spend the budget. The path is a
157
+ // reservation: the caller deletes it when the line is not delivered.
158
+ async function claimAsk(directory, marker) {
159
+ try {
160
+ await mkdir(directory, { recursive: true, mode: 0o700 });
161
+ } catch {
162
+ return null;
163
+ }
164
+ for (let n = 1; n <= ASK_LIMIT; n += 1) {
165
+ const slot = `${marker}.${n}`;
166
+ let handle;
167
+ try {
168
+ handle = await open(slot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
169
+ | constants.O_NOFOLLOW, 0o600);
170
+ return slot;
171
+ } catch (error) {
172
+ if (error?.code === "EEXIST") continue;
173
+ return null;
174
+ } finally {
175
+ await handle?.close().catch(() => {});
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function releaseAsk(slot) {
182
+ return rm(slot, { force: true }).catch(() => {});
183
+ }
184
+
185
+ async function relayAlreadyServing({ runtimeDir, conversationId, clientPid, isAlive }) {
186
+ for (const record of await listRegistrations({ runtimeDir })) {
187
+ if (record.conversationId !== conversationId) continue;
188
+ if (await serving(record, { timeoutMs: 100, isAlive, clientPid }) === null) return true;
189
+ }
190
+ return false;
191
+ }
192
+
151
193
  export async function nativeActivationHint({ event, nativeBinding, runtimeDir, clientPid, env,
152
- argvOf = argvDefault }) {
194
+ argvOf = argvDefault, isAlive = defaultAlive }) {
153
195
  // Only a degraded binding can be helped by starting a relay: "off" means the
154
196
  // live policy is off, "unsupported" that the contract does not admit this
155
- // client, and "active" that a relay already serves it.
197
+ // client, and "active" that a relay already serves it. Those calls spend
198
+ // none of the asks recorded below.
156
199
  if (nativeBinding?.state !== "degraded") return null;
157
200
  const home = env?.HOME;
158
201
  if (typeof event?.sessionId !== "string" || typeof home !== "string" || home === "") return null;
159
202
  if (!Number.isInteger(clientPid) || isPrintMode(await argvOf(clientPid).catch(() => []))) return null;
203
+ // The runner asks only when its own handshake failed. A relay can still be
204
+ // serving under a stale degraded binding; telling the agent to start it again
205
+ // would be wrong, and the check does not spend an ask.
206
+ if (await relayAlreadyServing({ runtimeDir, conversationId: event.sessionId, clientPid,
207
+ isAlive })) return null;
160
208
  const asked = path.join(path.dirname(relayDir(runtimeDir)), "antigravity-asked");
161
209
  const marker = path.join(asked, createHash("sha256").update(event.sessionId).digest("hex"));
162
- try {
163
- await mkdir(asked, { recursive: true, mode: 0o700 });
164
- const handle = await open(marker, "wx", 0o600);
165
- await handle.close();
166
- } catch {
167
- return null;
168
- }
169
- return HINT(home);
210
+ // A decline and an ignored ask leave the same trace, so neither can stop the
211
+ // line by itself. Three exclusive creates are the bound. The file reserves
212
+ // the number now; release gives it back when the runner does not deliver.
213
+ const slot = await claimAsk(asked, marker);
214
+ if (slot === null) return null;
215
+ return { line: HINT(home), release: () => releaseAsk(slot) };
170
216
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
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/adapter-codex",
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",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,8 +52,8 @@ export function createGeminiCliAdapter() {
52
52
  delivery: { nextTurn: true },
53
53
  },
54
54
  deliveryFallback: { diagnostic:
55
- "Gemini CLI next-turn delivery is certified only for 0.57.0 on darwin-arm64; "
56
- + "other or unknown versions keep durable acc inbox access, and live push is unavailable" },
55
+ "Gemini CLI next-turn delivery is certified from 0.57.0 onward, on every platform; "
56
+ + "an older client keeps durable acc inbox access, and live push is unavailable" },
57
57
 
58
58
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
59
59
  endSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
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/adapter-kimi",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,8 +41,8 @@ export function createKimiAdapter() {
41
41
  delivery: { nextTurn: true },
42
42
  },
43
43
  deliveryFallback: { diagnostic:
44
- "Kimi Code next-turn delivery is certified only for 0.36.1 on darwin-arm64; "
45
- + "other or unknown versions keep durable acc inbox access, and live push is unavailable" },
44
+ "Kimi Code next-turn delivery is certified from 0.36.1 onward, on every platform; "
45
+ + "an older client keeps durable acc inbox access, and live push is unavailable" },
46
46
 
47
47
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
48
48
  heartbeat: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,7 +15,7 @@ const checkDeadline = deadline => {
15
15
  if (Date.now() >= deadline) throw new Error("native diagnostic deadline expired");
16
16
  };
17
17
 
18
- // The caller stops waiting after 250 ms at most. An in-flight filesystem call
18
+ // The caller stops waiting after 1.5 s at most. An in-flight filesystem call
19
19
  // may complete later, so retain this SEPARATE mutex until its continuation is
20
20
  // finished. Unique staging names also isolate cleanup after stale-lock recovery.
21
21
  // No diagnostic operation can touch the authoritative hook-owner file.
@@ -39,7 +39,9 @@ export async function loadNativeAttempt({ runtimeDir, harnessSessionId, accSessi
39
39
  // must not keep the vendor's short-lived hook process alive after its output.
40
40
  // The unreferenced worker owns its mutex through any late I/O continuation.
41
41
  async function writeDiagnostic(operation, input) {
42
- const deadlineAt = Math.min(input.deadlineAt ?? Infinity, Date.now() + 250);
42
+ // Same ceiling as the hook runner's diagnostic budget. A caller cannot wait
43
+ // longer than this, and a shorter deadlineAt is still honored.
44
+ const deadlineAt = Math.min(input.deadlineAt ?? Infinity, Date.now() + 1_500);
43
45
  if (Date.now() >= deadlineAt) return false;
44
46
  let worker;
45
47
  let timer;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,6 +40,12 @@ export const COMMANDS = Object.freeze({
40
40
  repeated: ["completed", "remaining", "blocker"] },
41
41
  status: { required: [], optional: ["session", "generation", "participant"], flags: ["all"] },
42
42
  doctor: { required: [], optional: ["home"], flags: ["repair"] },
43
+ // `--apply` rather than `--dry-run`: this is the one command that takes
44
+ // records out of the store, so the reporting run is what happens by
45
+ // default and meaning it is what has to be said out loud.
46
+ // No `--session`: this acts on the workspace, not as a participant in it, so
47
+ // a session id would be a credential nothing reads.
48
+ prune: { required: [], optional: ["before"], repeated: ["class"], flags: ["apply"] },
43
49
  // The one command with a subcommand. Kept as an explicit list rather than a
44
50
  // free positional: `acc config delete` should fail at the parser, not deep
45
51
  // inside a handler that has already decided what to do.
@@ -267,7 +267,19 @@ export async function runDoctor({ options, context, runtime }) {
267
267
  // command documented as saying "what to run next" said it only to `--json`.
268
268
  // A person running `acc doctor` on a client wired to an older plugin was told
269
269
  // the store was healthy and nothing else.
270
- const text = [`store healthy; ${describePresence(status.counts)}; `
270
+ // Named rather than left to be inferred: staging files are reclaimed a
271
+ // bounded amount at a time, so a store still carrying a large accumulation
272
+ // should say so instead of reading as if nothing were held.
273
+ const held = [
274
+ report.staged > 0 ? `${report.staged} staged` : null,
275
+ report.partials > 0 ? `${report.partials} partial` : null,
276
+ // A retired journal entry has no reader at all, so a count above zero says
277
+ // the automatic pass has not caught up rather than that anything is wrong.
278
+ report.retired > 0 ? `${report.retired} retired journal` : null,
279
+ report.trimmedThrough === null ? null : `history from ${report.trimmedThrough}`,
280
+ ].filter(Boolean);
281
+ const staging = held.length === 0 ? "" : ` (${held.join(", ")})`;
282
+ const text = [`store healthy${staging}; ${describePresence(status.counts)}; `
271
283
  + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
272
284
  ...adapters.filter(adapter => (adapter.present || adapter.installed)
273
285
  && adapter.nativeDelivery.reasonCode === "native_delivery_unsupported"
@@ -17,7 +17,7 @@ import { needsOwner } from "./session-owner.mjs";
17
17
  * protocol's enum choices before a caller has to attempt a mutation.
18
18
  */
19
19
  const GROUPS = Object.freeze([
20
- ["Set up", ["install", "uninstall", "doctor", "config"]],
20
+ ["Set up", ["install", "uninstall", "doctor", "config", "prune"]],
21
21
  ["In a session", ["status", "sync", "work", "claim", "release", "inbox", "reply",
22
22
  "ack", "message", "request", "finish"]],
23
23
  ["Session lifecycle", ["attach", "heartbeat", "detach"]],
@@ -30,6 +30,7 @@ const SUMMARY = Object.freeze({
30
30
  doctor: "clients, versions, install health, and what to run next",
31
31
  config: "write or check acc.workspace.json (init | validate)",
32
32
  status: "who else is here, what they hold, how protected this workspace is",
33
+ prune: "report what this workspace no longer needs; --apply to reclaim it",
33
34
  sync: "events since a cursor, or bounded message history",
34
35
  work: "publish what this session is doing, or --clear when it has stopped",
35
36
  claim: "reserve a resource; exit 5 when someone else already holds it",
@@ -379,6 +379,24 @@ const HANDLERS = Object.freeze({
379
379
  uninstall: async ({ options, runtime }) =>
380
380
  runInstallCommand({ options, runtime, action: "uninstall" }),
381
381
 
382
+ prune: async ({ options, context }) => {
383
+ const result = await context.service.prune({
384
+ classes: options.class, before: options.before,
385
+ apply: options.apply === true, limit: Infinity });
386
+ const named = Object.entries(result.counts)
387
+ .map(([name, count]) => `${count} ${name}`).join(", ");
388
+ // The reporting run says what it would do; the applying run says what the
389
+ // store gave back, which is not the same number - a record and the markers
390
+ // it owns leave together.
391
+ const text = result.applied
392
+ ? `reclaimed ${result.reclaimed} file(s) from ${named}`
393
+ + (result.trimmedThrough === null ? "" : `; history starts after ${result.trimmedThrough}`)
394
+ + (result.skipped > 0 ? `; ${result.skipped} changed and were left` : "")
395
+ + (result.remaining ? "; more remains, run it again" : "")
396
+ : `would prune ${named}; nothing was changed, add --apply to act`;
397
+ return { data: result, text };
398
+ },
399
+
382
400
  doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
383
401
 
384
402
  update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,6 +3,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
3
3
  import { overlaps } from "./claims.mjs";
4
4
  import { classifySessionPresence } from "./sessions.mjs";
5
5
  import { decisionView, isCurrentDecision } from "./decision-state.mjs";
6
+ import { isCurrentIntent } from "./intents.mjs";
6
7
 
7
8
  export const ATTENTION_PRIORITY = Object.freeze({
8
9
  reply_required: 1,
@@ -65,7 +66,11 @@ function expiredClaims(snapshot, session, now) {
65
66
  }
66
67
 
67
68
  function claimConflicts(snapshot, session, now) {
68
- const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
69
+ // Both kinds read resourceHints as a statement of what a session means to
70
+ // touch. Finishing that work retracts the statement, so a done record must
71
+ // not keep raising conflicts on either side of it.
72
+ const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId
73
+ && isCurrentIntent(intent));
69
74
  if (mine === undefined) return [];
70
75
  return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId !== session.sessionId
71
76
  && Date.parse(claim.expiresAt) > Date.parse(now)
@@ -78,7 +83,7 @@ function claimConflicts(snapshot, session, now) {
78
83
  function claimContended(snapshot, session, now) {
79
84
  if (session == null) return [];
80
85
  const peerIntents = (snapshot.intents ?? [])
81
- .filter(intent => intent.sessionId !== session.sessionId);
86
+ .filter(intent => intent.sessionId !== session.sessionId && isCurrentIntent(intent));
82
87
  return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId === session.sessionId
83
88
  && Date.parse(claim.expiresAt) > Date.parse(now)).flatMap(claim => {
84
89
  const peer = peerIntents.find(intent => intent.resourceHints
@@ -3,6 +3,22 @@ import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
3
3
 
4
4
  import { isMaterialised } from "./materialisation.mjs";
5
5
 
6
+ /**
7
+ * Does this record still answer "what is this session doing now?".
8
+ *
9
+ * `clearIntent` marks a durable intent done instead of erasing it, so the
10
+ * workspace keeps what each session worked on. That leaves every reader to
11
+ * exclude the finished ones, and this is the single place that decides which
12
+ * states count - status and attention disagreeing about the same record is
13
+ * exactly the defect this exists to prevent.
14
+ *
15
+ * Stopped is not finished. A blocked or waiting session still owns its work and
16
+ * the resourceHints that announce it, so only "done" gives them up.
17
+ */
18
+ export function isCurrentIntent(intent) {
19
+ return intent.state !== "done";
20
+ }
21
+
6
22
  // Intent answers "what is this session doing now?". It is awareness, not
7
23
  // authorisation: an edit intent never substitutes for a claim, which is why
8
24
  // resourceHints are advisory strings rather than reservations.