agents-can-communicate 0.10.1 → 0.10.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.
Files changed (22) hide show
  1. package/docs/ARCHITECTURE.md +6 -2
  2. package/docs/UPGRADING.md +13 -0
  3. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  4. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  6. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  7. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +80 -23
  14. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +14 -2
  15. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  22. package/package.json +1 -1
@@ -272,8 +272,12 @@ running. A binding that names no client process is judged by its session record
272
272
  exited, or, with no process recorded, when ACC's presence rule calls the session offline -
273
273
  closed, or 30 minutes without a heartbeat. A live recorded process, or a record that cannot be
274
274
  read, keeps it a hold. A client that wakes later runs its next hook through the launcher on
275
- the active generation. For a binding that names its client, presence TTL, `acc finish`, and
276
- delivery off do not prove native lifecycle end. Normal background updates never
275
+ the active generation. A binding that names its client stops holding when that process exits,
276
+ or when the process that now has its pid started after the binding was last written, since a
277
+ client runs when it writes its binding (#276); a start time that cannot be read decides
278
+ nothing. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
279
+ end. A pending release that cannot activate is replaced by a newer one when `acc update` runs
280
+ or an automatic check is due (#277). Normal background updates never
277
281
  restart a vendor daemon. An explicitly approved maintenance job can do so through the
278
282
  adapter lifecycle port: it waits for idle work, validates exact process identity, stops
279
283
  the service under the admission fence, refreshes integrations, and restores and verifies
package/docs/UPGRADING.md CHANGED
@@ -57,6 +57,19 @@ closed. 0.10.1 judges such a binding by its session record and removes it while
57
57
  the update, so the update from 0.9.x finishes once no 0.9.x session runs. From 0.10.0, 0.10.1
58
58
  keeps the store contract, so it activates without waiting for open sessions.
59
59
 
60
+ If `acc update` keeps retrying a pending 0.10.0 or 0.10.1 and names processes that are not your
61
+ clients, or `unknown client pid`, clear the pending release and update again:
62
+
63
+ <!-- test:illustration needs an installed ACC with a release pending; a fresh sandbox has neither -->
64
+ ```bash
65
+ acc update --pin none
66
+ acc update
67
+ ```
68
+
69
+ `--pin none` keeps following stable releases and drops a pending release that is not pinned.
70
+ From 0.10.2, the update does this by itself: a pending release that cannot activate is replaced
71
+ by a newer one, and a binding whose pid another program now has no longer holds the update.
72
+
60
73
  ## From 0.8.x
61
74
 
62
75
  Update as usual:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-antigravity",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "acc",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Coordinate this Antigravity CLI session with other AI agent sessions working in the same workspace."
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,10 +1,13 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  // Kept above 300 lines because activation and generation retirement must use
3
4
  // the same validated runtime/native hold definitions under the admission mutex.
4
5
  // Splitting their safety rules risks disagreeing about which users are live.
5
- import { readdir, rm } from "node:fs/promises";
6
+ import { readdir, rm, stat } from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import { pathToFileURL } from "node:url";
9
+ import { promisify } from "node:util";
10
+ import { readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
8
11
  import { classifySessionPresence } from "@agents-can-communicate/core";
9
12
  import { assertPortableId } from "@agents-can-communicate/protocol";
10
13
  import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
@@ -49,6 +52,46 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
49
52
  const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
50
53
  && record.clientPid > 0 ? record.clientPid : null;
51
54
 
55
+ const execFileAsync = promisify(execFile);
56
+
57
+ /** When a process started, in milliseconds since the epoch; null when it cannot be read. */
58
+ export async function processStartedAt(pid, { platform = process.platform, run = execFileAsync } = {}) {
59
+ try {
60
+ if (platform === "win32") {
61
+ const found = await readWindowsProcess(pid, { timeoutMs: 5_000 });
62
+ if (found?.pid !== pid || typeof found.start !== "string" || !/^\d+$/.test(found.start)) return null;
63
+ // A FILETIME: 100-nanosecond intervals since 1601-01-01 UTC.
64
+ return Number(BigInt(found.start) / 10_000n) - 11_644_473_600_000;
65
+ }
66
+ const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "lstart="],
67
+ { timeout: 2_000, env: { ...process.env, LC_ALL: "C" } });
68
+ const started = Date.parse(stdout.trim());
69
+ return Number.isFinite(started) ? started : null;
70
+ } catch { return null; }
71
+ }
72
+
73
+ /** A client runs when it writes its binding, so a process that started after
74
+ * the binding was last written is not that client: the operating system gave
75
+ * its pid to another program. On 2026-10-06 chrome-devtools-mcp and two IntelliJ
76
+ * helpers held three such pids and kept 0.10.1 pending (#276). `ps` counts whole
77
+ * seconds and a filesystem may too; a second of slack keeps a client's own
78
+ * process from reading as another. A start time that cannot be read decides
79
+ * nothing. */
80
+ async function pidReused(pid, file, startedAt) {
81
+ const [started, written] = await Promise.all([startedAt(pid),
82
+ stat(file).then(info => info.mtimeMs, () => null)]);
83
+ return started !== null && written !== null && started > written + 1_000;
84
+ }
85
+
86
+ // One start-time reading per pid for one pass: a vendor service can hold several bindings.
87
+ const perPass = startedAt => {
88
+ const seen = new Map();
89
+ return pid => {
90
+ if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => startedAt(pid)).catch(() => null));
91
+ return seen.get(pid);
92
+ };
93
+ };
94
+
52
95
  /** A binding that names no client process cannot be judged by its own facts;
53
96
  * its session record can. It is stale when the record is gone, when the record
54
97
  * names a process that has exited, or, with no process recorded, when ACC's own
@@ -57,7 +100,7 @@ const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeIntege
57
100
  * process, or a record or store that cannot be read, keeps the binding a hold.
58
101
  * On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
59
102
  * 0.5.10 to 0.9.0 that no client could clear (#273). */
60
- async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive) {
103
+ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt) {
61
104
  if (record?.schemaVersion !== 1) return false;
62
105
  let session;
63
106
  try {
@@ -65,15 +108,19 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive) {
65
108
  sessionId: record.accSessionId, allowLegacyIdentity: true });
66
109
  } catch { return false; }
67
110
  if (session === null) return true;
68
- if (Number.isSafeInteger(session.pid) && session.pid > 0) return confirmedDead(session.pid, pidIsAlive);
111
+ if (Number.isSafeInteger(session.pid) && session.pid > 0) {
112
+ return await confirmedDead(session.pid, pidIsAlive) || pidReused(session.pid, file, startedAt);
113
+ }
69
114
  // No process to ask, so the probe is never called.
70
115
  return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
71
116
  }
72
117
 
73
118
  /** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
74
- export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false } = {}) {
119
+ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false,
120
+ startedAt = processStartedAt } = {}) {
75
121
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
76
122
  if (!await managedDirectory(workspaces)) return [];
123
+ const started = perPass(startedAt);
77
124
  const holds = [];
78
125
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
79
126
  if (!entry.isDirectory()) {
@@ -87,15 +134,17 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
87
134
  if (!await managedDirectory(bindings)) continue;
88
135
  for (const name of await readdir(bindings)) {
89
136
  if (!name.endsWith(".json")) continue;
90
- const record = await readManagedJson(path.join(bindings, name)).catch(error => {
137
+ const file = path.join(bindings, name);
138
+ const record = await readManagedJson(file).catch(error => {
91
139
  if (strict) throw error;
92
140
  return null;
93
141
  });
94
142
  if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
95
143
  if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
96
144
  const pid = clientPidOf(record);
97
- if (pid !== null && await confirmedDead(pid, pidIsAlive)) continue;
98
- if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive)) continue;
145
+ if (pid !== null && (await confirmedDead(pid, pidIsAlive) || await pidReused(pid, file, started))) continue;
146
+ if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive,
147
+ file, started)) continue;
99
148
  holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
100
149
  storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
101
150
  }
@@ -109,17 +158,21 @@ const LIFECYCLE_LOCK_MS = 2_000;
109
158
  const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(workspaceRoot,
110
159
  "lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
111
160
 
112
- /** Remove the bindings without a client pid that listNativeHolds no longer
113
- * counts. An older generation activates its successor with its own rule, which
114
- * counts every one of them as a live client; it loads the pending generation's
115
- * refresh before it lists blockers, so that refresh calls this (#273). Each one
116
- * is judged again and removed under its session's lifecycle lock, so a hook that
117
- * renews it is either seen or comes after (AI review of #274); a lock that stays
118
- * busy leaves the binding as it is. */
119
- export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive } = {}) {
161
+ /** Remove the bindings that listNativeHolds no longer counts and an older
162
+ * generation still does: those without a client pid that are stale (#273), and
163
+ * those whose pid was given to another program (#276). An older generation
164
+ * activates its successor with its own rule, and loads the pending generation's
165
+ * refresh before it lists blockers, so that refresh calls this. A binding whose
166
+ * pid is merely dead holds nothing for either and stays, as the record a resumed
167
+ * conversation finds. Each one is judged again and removed under its session's
168
+ * lifecycle lock, so a hook that renews it is either seen or comes after (AI
169
+ * review of #274); a lock that stays busy leaves the binding as it is. */
170
+ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
171
+ startedAt = processStartedAt } = {}) {
120
172
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
121
173
  if (!await managedDirectory(workspaces)) return 0;
122
174
  const clock = { now: () => new Date().toISOString() };
175
+ const started = perPass(startedAt);
123
176
  let removed = 0;
124
177
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
125
178
  if (!entry.isDirectory()) continue;
@@ -130,10 +183,13 @@ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive
130
183
  for (const name of await readdir(bindings)) {
131
184
  if (!name.endsWith(".json")) continue;
132
185
  const file = path.join(bindings, name);
133
- const stale = async record => record !== undefined && record !== null
134
- && clientPidOf(record) === null && typeof record.harnessSessionId === "string"
135
- && !await isMcpContinuity(record, name, workspaceRoot, entry.name)
136
- && staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive);
186
+ const stale = async record => {
187
+ if (record === undefined || record === null || typeof record.harnessSessionId !== "string"
188
+ || await isMcpContinuity(record, name, workspaceRoot, entry.name)) return false;
189
+ const pid = clientPidOf(record);
190
+ if (pid !== null) return !await confirmedDead(pid, pidIsAlive) && pidReused(pid, file, started);
191
+ return staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive, file, started);
192
+ };
137
193
  const judged = await readManagedJson(file).catch(() => undefined);
138
194
  if (!await stale(judged)) continue;
139
195
  const gone = await withWriterMutex(lifecycleLocks(workspaceRoot, judged.harnessSessionId),
@@ -165,9 +221,10 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
165
221
  * generation's, or when either side declares none.
166
222
  */
167
223
  export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
168
- ignorePid = null, incomingStoreVersion = null, strictNative = false } = {}) {
224
+ ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt } = {}) {
169
225
  const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
170
- const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative });
226
+ const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative,
227
+ ...(startedAt === undefined ? {} : { startedAt }) });
171
228
  const holds = [
172
229
  ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
173
230
  storeVersion: runtime?.storeVersion ?? null })),
@@ -364,7 +421,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
364
421
 
365
422
  /** Detection is outside the fence; every integration write and commit stays inside it. */
366
423
  export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
367
- pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
424
+ pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt } = {}) {
368
425
  root = await canonicalManagerRoot(root);
369
426
  const before = await readControl(root);
370
427
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
@@ -374,7 +431,7 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
374
431
  const current = await readControl(root);
375
432
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
376
433
  if (beforeActivation) await beforeActivation(current);
377
- const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
434
+ const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid, startedAt,
378
435
  incomingStoreVersion: current.pending.storeVersion });
379
436
  if (blockers.length) {
380
437
  const notice = activationBlockerNotice(current.pending.version, blockers);
@@ -32,8 +32,18 @@ export async function performUpdate(root, { force = false, check = false, env =
32
32
  }
33
33
  if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
34
34
  // Recovery is local; the hard no-network override must not strand a partial refresh.
35
+ let baseline = initial.active.version;
36
+ let attempt = null;
35
37
  if (initial.pending && !check && (force || !networkDisabled(env))) {
36
- return { ...data, ...await activate(root, { env, ignorePid }), checked: false };
38
+ attempt = await activate(root, { env, ignorePid });
39
+ // A pending release that cannot activate kept every later one out: on
40
+ // 2026-10-06 0.10.0 stayed pending behind stale bindings, and the 0.10.1
41
+ // that fixed them reached the machine only after its pending release was
42
+ // cleared by hand (#277). Once a check is due, or when asked, a newer
43
+ // release is staged in its place.
44
+ if (attempt.activated || attempt.reason !== "processes_active" || networkDisabled(env)
45
+ || !force && !checkDue(initial)) return { ...data, ...attempt, checked: false };
46
+ baseline = initial.pending.version;
37
47
  }
38
48
  if (networkDisabled(env)) return { ...data, checked: false, reason: "network_disabled" };
39
49
  if (!force && !check && !checkDue(initial)) return { ...data, checked: false, reason: "not_due" };
@@ -44,7 +54,9 @@ export async function performUpdate(root, { force = false, check = false, env =
44
54
  const release = await discover({ pin: initial.pin, env });
45
55
  const newer = newerVersion(release.version, initial.active.version);
46
56
  const checked = { ...data, checked: true, latest: release.version, newer };
47
- if (check || !newer) return checked;
57
+ // Nothing newer than a blocked pending release: report the activation that
58
+ // waits, as before - the caller asks to restart a client service from it.
59
+ if (check || !newerVersion(release.version, baseline)) return attempt === null ? checked : { ...checked, ...attempt };
48
60
  const generation = await download(root, release, { env });
49
61
  // generation.hold has protected this candidate since it was staged;
50
62
  // release it once this publish attempt resolves either way - published,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
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.10.1",
3
+ "version": "0.10.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "accManagedUpdateProtocol": 2,
5
5
  "accStoreVersion": 7,
6
6
  "type": "module",