agents-can-communicate 0.1.12 → 0.1.14

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 (31) hide show
  1. package/docs/CAPABILITIES.md +32 -2
  2. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  3. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  4. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  6. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  7. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/core/src/claims.mjs +2 -2
  10. package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
  11. package/node_modules/@agents-can-communicate/core/src/pid.mjs +33 -0
  12. package/node_modules/@agents-can-communicate/core/src/ports.mjs +9 -0
  13. package/node_modules/@agents-can-communicate/core/src/service.mjs +12 -3
  14. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +47 -7
  15. package/node_modules/@agents-can-communicate/core/src/status.mjs +17 -10
  16. package/node_modules/@agents-can-communicate/core/src/sync.mjs +23 -8
  17. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +11 -5
  18. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +2 -2
  19. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +32 -0
  21. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +34 -0
  22. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +15 -3
  23. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +5 -1
  27. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -4
  29. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  30. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +33 -6
  31. package/package.json +1 -1
@@ -44,6 +44,34 @@ should be re-run before claiming another platform.
44
44
  | `execution.resume` | no | no | no | no |
45
45
  | `execution.terminate` | no | no | no | no |
46
46
 
47
+ ## Resolving a client's pid is not universal either
48
+
49
+ Not a capability above - no adapter method backs it, so it has no row in the matrix - but
50
+ it is presence's other signal for telling a dead process from an idle one, and it does not
51
+ reach every client.
52
+
53
+ A session's recorded pid comes from walking its process ancestry until the adapter's own
54
+ declared binary (`client.command`) turns up in `ps -o comm=`. That only works when the
55
+ operating system's own name for the process actually is that binary: true for a native
56
+ executable, false for a script run through an interpreter, where `comm` reports the
57
+ interpreter's name rather than the script's.
58
+
59
+ | Client | `client.command` | `ps -o comm=` reports | Pid resolves |
60
+ |---|---|---|---|
61
+ | `codex` | `codex` | `codex`, a native binary | yes |
62
+ | `claude_code` | `claude` | `claude`, a native binary | yes |
63
+ | `gemini_cli` | `gemini` | `node` - `gemini.js` starts `#!/usr/bin/env node` | **no** |
64
+ | `kimi` | `kimi` | not installed on the machine this table was measured on; Kimi Code ships via npm as a Node.js CLI (`@moonshot-ai/kimi-code`), the same shape as Gemini CLI | **almost certainly no - not measured** |
65
+
66
+ A Gemini session records `pid: null` for its whole life, and Kimi's is very likely the
67
+ same, unconfirmed. `null` is the correct "nobody knows" answer and is handled identically
68
+ wherever it is read - not a correctness bug. It does change what presence delivers, though:
69
+ a confirmed-dead pid retires a session immediately and exactly, and today that is `codex`
70
+ and `claude_code` only. `gemini_cli` and `kimi` fall back to the same age-based floor every
71
+ session has for whenever a pid is unavailable - thirty minutes of silence - so their
72
+ sessions still leave, just later and on a timer instead of on the fact. See
73
+ [ARCHITECTURE.md](ARCHITECTURE.md#presence) for the full floor.
74
+
47
75
  ## What the yes values do not promise
48
76
 
49
77
  A capability says the client can do the thing. Several of them are conditional on how the
@@ -85,8 +113,10 @@ what the claim's owner asked for, and whether ACC can stop this session at all:
85
113
  Unenforceable is not the same as unknown - and neither is it the same as unclaimed.
86
114
 
87
115
  **`lifecycle.sessionEnd` on `kimi` is false and it matters.** Each `kimi -p` run leaves an
88
- attached session that only ages out on its declared 60s cadence, so a peer reading the
89
- roster inside that window sees sessions that have already exited. Interactive sessions
116
+ attached session that never closes itself - it just stops taking turns. Presence retires it
117
+ instead, most likely without ever resolving a pid (see above), which means the session
118
+ reads `offline` - and disappears from the default `acc status` view - only after thirty
119
+ minutes of silence, not on its declared 60s heartbeat cadence. Interactive sessions
90
120
  heartbeat and do not have this problem.
91
121
 
92
122
  **`lifecycle.heartbeat` is Kimi's alone.** It fires on a timer - observed at 60002, 120004
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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.1.12",
3
+ "version": "0.1.14",
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.1.12",
3
+ "version": "0.1.14",
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.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,7 +31,7 @@ export function overlaps(left, right) {
31
31
  const isLive = (claim, now) => Date.parse(claim.expiresAt) > Date.parse(now);
32
32
 
33
33
  export function createClaimService(ports, sessions) {
34
- const { store, clock, ids } = ports;
34
+ const { store, clock, ids, pidIsAlive } = ports;
35
35
 
36
36
  async function requireOwner(input, action) {
37
37
  const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
@@ -52,7 +52,7 @@ export function createClaimService(ports, sessions) {
52
52
  // releases the claim on its own.
53
53
  const ownerPresence = ownerSession === undefined
54
54
  ? "offline"
55
- : classifySessionPresence(ownerSession, now);
55
+ : classifySessionPresence(ownerSession, now, pidIsAlive);
56
56
  return new AccError(EXIT.CONFLICT, "the resource is already claimed", {
57
57
  claimId: existing.claimId,
58
58
  resource: existing.resource,
@@ -2,6 +2,7 @@
2
2
  export { createCoordinationService } from "./service.mjs";
3
3
  export { assertPorts } from "./ports.mjs";
4
4
  export { classifySessionPresence } from "./sessions.mjs";
5
+ export { defaultPidIsAlive } from "./pid.mjs";
5
6
  export { ATTENTION_PRIORITY, computeAttention } from "./sync.mjs";
6
7
  export { overlaps } from "./claims.mjs";
7
8
  export { wouldCycle } from "./tasks.mjs";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether a process with this id exists.
3
+ *
4
+ * Signal 0 runs the existence and permission checks without delivering
5
+ * anything. `ESRCH` is the only answer that means gone: `EPERM` says the
6
+ * process is there and owned by somebody else, which is still there.
7
+ *
8
+ * Deliberately not shared with the writer lock's copy in the storage package.
9
+ * `core` may not import storage (tests/package-boundaries.test.mjs), and the two
10
+ * ask the question about different subjects - a lock owner mid-write, and a
11
+ * session that may have ended hours ago.
12
+ *
13
+ * `pid` must be a real, positive process id - the argument this function
14
+ * answers a question about. It is not where "nobody knows" is answered:
15
+ * `defaultPidIsAlive(null)` returns `false`, the same as a confirmed-dead pid,
16
+ * because a non-positive integer fails the `Number.isInteger` guard below the
17
+ * same way a made-up one would. The design this function serves requires the
18
+ * opposite reading for a session with no recorded pid - "cannot tell", never
19
+ * "dead" - so every call site in this repository guards on `pid !== null`
20
+ * before calling it. This is exported as the canonical liveness probe, so a
21
+ * caller reached from outside that guard must add its own, or a pid-less
22
+ * session will read as dead here regardless of how the rest of the system
23
+ * treats it.
24
+ */
25
+ export function defaultPidIsAlive(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0) return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (error) {
31
+ return error.code !== "ESRCH";
32
+ }
33
+ }
@@ -52,5 +52,14 @@ export function assertPorts(ports) {
52
52
  { port: "store.ephemeral", method });
53
53
  }
54
54
  }
55
+ // A bare function, not an object with methods, so it does not fit REQUIRED
56
+ // above. Checked here for the same reason as everything else in this file: a
57
+ // core that constructed cleanly and only discovered the gap at the first
58
+ // presence check would raise a bare TypeError from deep inside sessions.mjs,
59
+ // not an AccError - invisible to the CLI's exit-code mapping.
60
+ if (typeof ports.pidIsAlive !== "function") {
61
+ throw new AccError(EXIT.USAGE, "the pidIsAlive port must be a function",
62
+ { port: "pidIsAlive" });
63
+ }
55
64
  return ports;
56
65
  }
@@ -1,6 +1,7 @@
1
1
  import { createClaimService } from "./claims.mjs";
2
2
  import { createCommunicationService } from "./communication.mjs";
3
3
  import { createIntentService } from "./intents.mjs";
4
+ import { defaultPidIsAlive } from "./pid.mjs";
4
5
  import { assertPorts } from "./ports.mjs";
5
6
  import { createSessionService } from "./sessions.mjs";
6
7
  import { createGuardStateService, createStatusService } from "./status.mjs";
@@ -13,10 +14,18 @@ import { createWorkstreamService } from "./workstreams.mjs";
13
14
  * randomness-dependent arrives through a port, so behaviour is reproducible and
14
15
  * no module reaches for a global.
15
16
  *
16
- * @param {{ store: object, clock: object, ids: object, policies?: object }} ports
17
+ * @param {{ store: object, clock: object, ids: object, pidIsAlive?: function,
18
+ * policies?: object }} ports
17
19
  */
18
- export function createCoordinationService({ store, clock, ids, policies = {} }) {
19
- const ports = assertPorts({ store, clock, ids });
20
+ export function createCoordinationService({ store, clock, ids,
21
+ pidIsAlive = defaultPidIsAlive, policies = {} }) {
22
+ // Defaulted here, where the default is a real implementation, and required in
23
+ // the classifier, where a default could only be a lie. Passed into
24
+ // assertPorts rather than spread on afterward, so an explicit non-function -
25
+ // `null` included, since the default above only applies to `undefined` - is
26
+ // shape-checked at construction like every other port instead of surfacing
27
+ // as a raw TypeError the first time presence is classified.
28
+ const ports = assertPorts({ store, clock, ids, pidIsAlive });
20
29
  const sessions = createSessionService(ports);
21
30
  const intents = createIntentService(ports, sessions);
22
31
  const workstreams = createWorkstreamService(ports, sessions);
@@ -9,14 +9,46 @@ import { writeWorkResponse } from "./notify.mjs";
9
9
  // rather than one global constant (docs/ARCHITECTURE.md, presence freshness).
10
10
  const STALE_CADENCE_MULTIPLE = 3;
11
11
 
12
+ // Two floors, because they answer different questions and neither subsumes the
13
+ // other. UNKNOWN_EXPIRY_MS is the "cannot tell" branch: records written before
14
+ // pids were recorded, platforms with no process table, an ancestry that did not
15
+ // resolve. HARD_EXPIRY_MS exists because pids are recycled - the hazard
16
+ // writer-mutex.mjs:72 documents - so a session whose number was reissued to
17
+ // something unrelated would otherwise read as alive forever.
18
+ const UNKNOWN_EXPIRY_MS = 30 * 60_000;
19
+ const HARD_EXPIRY_MS = 24 * 60 * 60_000;
20
+
21
+ const ageBand = (session, age) =>
22
+ age <= session.heartbeatCadenceMs * STALE_CADENCE_MULTIPLE ? "online" : "stale";
23
+
12
24
  /**
25
+ * @param {{ state: string, heartbeatAt: string, heartbeatCadenceMs: number,
26
+ * pid?: number | null }} session The record to classify. `pid` absent or
27
+ * `null` means nobody knows whether the process is alive - never that it is
28
+ * dead - so age alone judges it.
29
+ * @param {string} now An ISO timestamp, compared against `session.heartbeatAt`.
30
+ * @param {(pid: number) => boolean} pidIsAlive Required, not defaulted: the one
31
+ * thing that lets `offline` be reached before the age floors do. Called only
32
+ * when `session.pid` is a real pid, never with `null`.
13
33
  * @returns {"online" | "stale" | "offline"}
34
+ * @throws {AccError} EXIT.USAGE when pidIsAlive is not a function.
14
35
  */
15
- export function classifySessionPresence(session, now, probe = () => true) {
36
+ export function classifySessionPresence(session, now, pidIsAlive) {
37
+ // Required rather than defaulted. A probe that defaults to "everyone is
38
+ // alive" turns a forgotten call site into a check that silently passes, which
39
+ // is the failure this repository has already shipped twice.
40
+ if (typeof pidIsAlive !== "function") {
41
+ throw new AccError(EXIT.USAGE, "classifySessionPresence requires a pidIsAlive probe",
42
+ { sessionId: session?.sessionId ?? null });
43
+ }
16
44
  if (session.state === "closed") return "offline";
17
- if (!probe(session)) return "offline";
18
45
  const age = Date.parse(now) - Date.parse(session.heartbeatAt);
19
- return age <= session.heartbeatCadenceMs * STALE_CADENCE_MULTIPLE ? "online" : "stale";
46
+ if (age > HARD_EXPIRY_MS) return "offline";
47
+ const pid = session.pid ?? null;
48
+ // A pid that answers outranks the unknown floor: a live but idle session is
49
+ // exactly what kimi looks like between turns.
50
+ if (pid !== null) return pidIsAlive(pid) ? ageBand(session, age) : "offline";
51
+ return age > UNKNOWN_EXPIRY_MS ? "offline" : ageBand(session, age);
20
52
  }
21
53
 
22
54
  const sessionRecord = (input, now, generation) => validateRecord("session", {
@@ -30,6 +62,7 @@ const sessionRecord = (input, now, generation) => validateRecord("session", {
30
62
  parentSessionId: input.parentSessionId ?? null,
31
63
  checkoutRoot: input.checkoutRoot ?? null,
32
64
  branch: input.branch ?? null,
65
+ pid: input.pid ?? null,
33
66
  // Both default to the weaker reading. A session that declares nothing is a
34
67
  // session nothing intercepts - an MCP client, or a CLI user - and claiming
35
68
  // otherwise would promise enforcement that is not there.
@@ -50,7 +83,7 @@ const participantRecord = (input, now) => validateRecord("participant", {
50
83
  });
51
84
 
52
85
  export function createSessionService(ports) {
53
- const { store, clock, ids } = ports;
86
+ const { store, clock, ids, pidIsAlive } = ports;
54
87
  const workspaceOf = input => input.workspaceId ?? store.workspaceId;
55
88
 
56
89
  async function locate(sessionId, workspaceId) {
@@ -66,9 +99,16 @@ export function createSessionService(ports) {
66
99
  function assertReplaceable(existing, probe) {
67
100
  if (existing.record.state === "closed") return;
68
101
  // Presence staleness alone never replaces ownership: an idle-but-open
69
- // session may resume at any moment. Only a liveness probe reporting the
70
- // owner gone permits a replacement generation.
71
- if (probe === undefined || probe(existing.record)) {
102
+ // session may resume at any moment, and a wrong "gone" verdict there
103
+ // self-corrects the moment that session next takes a turn. A wrong
104
+ // replacement does not self-correct - it takes the generation, and the
105
+ // original session's own heartbeats fail with CONFLICT from then on. So
106
+ // only a pid confirmed dead is authority to replace; "we cannot tell" -
107
+ // a session with no recorded pid - is never enough, however long the
108
+ // silence.
109
+ const live = probe ?? (record => (record.pid ?? null) === null
110
+ || pidIsAlive(record.pid));
111
+ if (live(existing.record)) {
72
112
  throw new AccError(EXIT.CONFLICT, "the session id is already live",
73
113
  { sessionId: existing.record.sessionId });
74
114
  }
@@ -58,7 +58,7 @@ export function createGuardStateService(ports) {
58
58
  }
59
59
 
60
60
  export function createStatusService(ports, sessions) {
61
- const { store, clock } = ports;
61
+ const { store, clock, pidIsAlive } = ports;
62
62
 
63
63
  async function collectStatus(input = {}) {
64
64
  const workspaceId = input.workspaceId ?? store.workspaceId;
@@ -72,9 +72,17 @@ export function createStatusService(ports, sessions) {
72
72
  ? snapshot.sessions
73
73
  : await store.ephemeral.list("session");
74
74
  const intents = durable ? snapshot.intents : await store.ephemeral.list("intent");
75
- const live = sessionRecords
76
- .map(session => ({ session, presence: classifySessionPresence(session, now) }))
77
- .filter(item => item.presence !== "offline");
75
+ // Classified once, and reused everywhere below. `classifySessionPresence` now
76
+ // asks `pidIsAlive`, a real `process.kill(pid, 0)` syscall for a session with
77
+ // a recorded pid, so it is no longer pure given `now` - a client can exit
78
+ // between two calls inside the same response, and re-reading the same
79
+ // session later in this function can then disagree with what an earlier
80
+ // reading already said. One reading, taken once, is what keeps `counts.live`,
81
+ // the roster filter, and each listed session's `presence` describing the
82
+ // same instant rather than three independent guesses at it.
83
+ const classified = sessionRecords
84
+ .map(session => ({ session, presence: classifySessionPresence(session, now, pidIsAlive) }));
85
+ const live = classified.filter(item => item.presence !== "offline");
78
86
  const claims = snapshot.claims
79
87
  .filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
80
88
 
@@ -87,10 +95,9 @@ export function createStatusService(ports, sessions) {
87
95
  // the roster is where "which worktree was that agent in" is answered - so
88
96
  // after a month of work this listed sixty entries for one live session.
89
97
  // `acc status --all` is how the worktree-cleanup question is asked.
90
- participants: sessionRecords
91
- .filter(session => input.all === true
92
- || classifySessionPresence(session, now) !== "offline")
93
- .map(session => ({
98
+ participants: classified
99
+ .filter(item => input.all === true || item.presence !== "offline")
100
+ .map(({ session, presence }) => ({
94
101
  sessionId: session.sessionId,
95
102
  participantId: session.participantId,
96
103
  harness: session.harness,
@@ -99,7 +106,7 @@ export function createStatusService(ports, sessions) {
99
106
  branch: session.branch ?? null,
100
107
  enforcement: session.enforcement ?? "advisory",
101
108
  lifecycle: session.lifecycle ?? "manual",
102
- presence: classifySessionPresence(session, now),
109
+ presence,
103
110
  intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
104
111
  })),
105
112
  workstreams: snapshot.workstreams.map(workstream => ({
@@ -127,7 +134,7 @@ export function createStatusService(ports, sessions) {
127
134
  expiresAt: claim.expiresAt,
128
135
  })),
129
136
  attention: computeAttention(snapshot, { session: null,
130
- participantId: input.participantId, now }),
137
+ participantId: input.participantId, now, pidIsAlive }),
131
138
  counts: {
132
139
  live: live.length,
133
140
  stale: live.filter(item => item.presence === "stale").length,
@@ -154,11 +154,18 @@ function unansweredQuestions(snapshot, participantId, onlineParticipants) {
154
154
  return items;
155
155
  }
156
156
 
157
- function stalledRequests(snapshot, participantId, now) {
157
+ function stalledRequests(snapshot, participantId, now, pidIsAlive) {
158
+ // Classified once per session and reused below, for the same reason
159
+ // collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
160
+ // a real process.kill(pid, 0) syscall for a session with a recorded pid, so
161
+ // it is not pure given `now` any more. A second classifying pass here could
162
+ // disagree with the first - a client exiting between them would leave `live`
163
+ // saying "online" while a freshly-computed `onlineParticipants` had already
164
+ // dropped it, inside one attention computation.
158
165
  const live = new Map((snapshot.sessions ?? [])
159
- .map(session => [session.sessionId, classifySessionPresence(session, now)]));
166
+ .map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
160
167
  const onlineParticipants = new Set((snapshot.sessions ?? [])
161
- .filter(session => classifySessionPresence(session, now) === "online")
168
+ .filter(session => live.get(session.sessionId) === "online")
162
169
  .map(session => session.participantId));
163
170
  const goingNowhere = task => {
164
171
  // Taken by someone who has gone quiet.
@@ -191,20 +198,28 @@ function coordinatorGaps(snapshot) {
191
198
  sourceId: workstream.workstreamId, summary: workstream.title }));
192
199
  }
193
200
 
194
- export function computeAttention(snapshot, { session, participantId, now }) {
201
+ export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
202
+ // Required unconditionally, not only when there happen to be sessions to
203
+ // classify: `stalledRequests` reaches `classifySessionPresence` only inside a
204
+ // map/filter over `snapshot.sessions`, so an empty or absent roster let a
205
+ // missing probe through with nothing to trip over it - the same silent pass
206
+ // the classifier's own required parameter exists to close, one layer up.
207
+ if (typeof pidIsAlive !== "function") {
208
+ throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
209
+ }
195
210
  return [
196
211
  ...directRequests(snapshot, participantId),
197
212
  ...claimConflicts(snapshot, session, now),
198
213
  ...expiredClaims(snapshot, session, now),
199
214
  ...unblockedTasks(snapshot, session, participantId),
200
215
  ...coordinatorGaps(snapshot),
201
- ...stalledRequests(snapshot, participantId, now),
216
+ ...stalledRequests(snapshot, participantId, now, pidIsAlive),
202
217
  ].sort((left, right) => left.priority - right.priority
203
218
  || left.sourceId.localeCompare(right.sourceId));
204
219
  }
205
220
 
206
221
  export function createSyncService(ports, sessions) {
207
- const { store, clock } = ports;
222
+ const { store, clock, pidIsAlive } = ports;
208
223
 
209
224
  /**
210
225
  * Any session may request the full Workspace scope. Peer equality is a
@@ -240,7 +255,7 @@ export function createSyncService(ports, sessions) {
240
255
  const page = await store.eventsSince(workspaceId, input.cursor ?? null,
241
256
  input.limit ?? DEFAULT_LIMIT);
242
257
  const attention = computeAttention(snapshot, { session,
243
- participantId: session?.participantId ?? input.participantId, now });
258
+ participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
244
259
 
245
260
  const roster = snapshot.sessions.map(item => ({
246
261
  sessionId: item.sessionId,
@@ -248,7 +263,7 @@ export function createSyncService(ports, sessions) {
248
263
  parentSessionId: item.parentSessionId,
249
264
  harness: item.harness,
250
265
  branch: item.branch ?? null,
251
- presence: classifySessionPresence(item, now),
266
+ presence: classifySessionPresence(item, now, pidIsAlive),
252
267
  }));
253
268
 
254
269
  // Solo zero-overhead: one live session, no claims and
@@ -84,7 +84,7 @@ export function writeTask(tx, { input, session, workspaceId, now, ids }) {
84
84
  }
85
85
 
86
86
  export function createTaskService(ports, workstreams) {
87
- const { store, clock, ids } = ports;
87
+ const { store, clock, ids, pidIsAlive } = ports;
88
88
 
89
89
  async function createTask(input) {
90
90
  const session = await workstreams.requireOpenSession(input, "create a task");
@@ -120,13 +120,19 @@ export function createTaskService(ports, workstreams) {
120
120
  && existing.assigneeSessionId !== session.sessionId) {
121
121
  // A holder that is gone is not a holder. Closing a session hands its
122
122
  // work back, so this is the crash case: no session end ever arrived and
123
- // presence has decayed. Staleness alone does not release it - that is
124
- // the same rule claims follow, because an idle agent may be thinking
125
- // rather than dead - but it can be taken over deliberately.
123
+ // presence has decayed. `stale` alone does not release it - the same
124
+ // rule claims follow, because an idle agent may be thinking rather than
125
+ // dead - and taking it over needs `force`. `offline` needs no force at
126
+ // all, silence-derived cases included: a session with no recorded pid,
127
+ // quiet past the unknown floor, reads offline exactly like one whose
128
+ // pid is confirmed dead, and either way the task is simply taken. That
129
+ // is acceptable here where it would not be for a session id: the
130
+ // participant guard below still refuses a different participant, and
131
+ // taking a task back is reversible where replacing an id is not.
126
132
  const holder = tx.get("session", existing.assigneeSessionId);
127
133
  const presence = holder === null
128
134
  ? "offline"
129
- : classifySessionPresence(holder, now);
135
+ : classifySessionPresence(holder, now, pidIsAlive);
130
136
  if (presence === "online") {
131
137
  throw new AccError(EXIT.CONFLICT, "the task already has an assignee",
132
138
  { taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId });
@@ -8,7 +8,7 @@ import { classifySessionPresence } from "./sessions.mjs";
8
8
  // coordinator lease, and the coordinator plans - it is never the transport,
9
9
  // the durable owner, or an information gatekeeper.
10
10
  export function createWorkstreamService(ports, sessions) {
11
- const { store, clock, ids } = ports;
11
+ const { store, clock, ids, pidIsAlive } = ports;
12
12
 
13
13
  async function requireOpenSession(input, action) {
14
14
  const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
@@ -63,7 +63,7 @@ export function createWorkstreamService(ports, sessions) {
63
63
  if (held !== null && held !== session.sessionId) {
64
64
  const holder = snapshot.sessions.find(item => item.sessionId === held);
65
65
  const presence = holder === undefined ? "offline"
66
- : classifySessionPresence(holder, now);
66
+ : classifySessionPresence(holder, now, pidIsAlive);
67
67
  // A coordinator lease is replaced only when the holder is genuinely
68
68
  // gone or policy says so - not because a peer would like the role.
69
69
  if (presence !== "offline" && input.authority !== "human"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+
3
+ // Deep enough for a client that wraps its hook in a shell and a launcher, short
4
+ // enough that a table which disagrees with itself cannot spin.
5
+ const MAX_HOPS = 16;
6
+
7
+ /**
8
+ * The pid of the client this hook is running for, or null when nobody knows.
9
+ *
10
+ * The hook is not the client's child. Measured on macOS, a process spawned by
11
+ * Claude Code has parent `/bin/zsh` and grandparent `claude`, so `process.ppid`
12
+ * names a shell that dies with the hook. Walking until the adapter's own
13
+ * declared binary appears is what makes the answer specific rather than a guess
14
+ * about which ancestors are "real".
15
+ *
16
+ * Null is a first-class answer: it means judge this session by age alone.
17
+ */
18
+ export function resolveClientPid({ table, from, command, maxHops = MAX_HOPS }) {
19
+ const seen = new Set();
20
+ let current = from;
21
+ for (let hop = 0; hop < maxHops; hop += 1) {
22
+ const entry = table.get(current);
23
+ if (entry === undefined || seen.has(current)) return null;
24
+ seen.add(current);
25
+ // `ps` reports some entries bare (`claude`) and some with a path
26
+ // (`/bin/zsh`), so the comparison has to be on the basename.
27
+ if (path.basename(entry.comm) === command) return current;
28
+ if (entry.ppid === current || entry.ppid <= 1) return null;
29
+ current = entry.ppid;
30
+ }
31
+ return null;
32
+ }
@@ -0,0 +1,34 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const run = promisify(execFile);
5
+
6
+ // A hook runs in front of someone's turn. Reading the table is worth a few
7
+ // hundred milliseconds once per session and nothing at all if it is slow.
8
+ const DEFAULT_TIMEOUT_MS = 1_000;
9
+
10
+ const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
11
+
12
+ /**
13
+ * Every process on this machine, as pid -> parent and executable.
14
+ *
15
+ * Returns an empty map rather than throwing when the platform has no `ps`
16
+ * (Windows) or the call fails. An empty table resolves no client, which is the
17
+ * "nobody knows" answer the caller already handles.
18
+ */
19
+ export async function readProcessTable({ run: exec = run,
20
+ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
21
+ try {
22
+ const { stdout } = await exec("ps", ["-o", "pid=,ppid=,comm=", "-A"],
23
+ { timeout: timeoutMs });
24
+ const table = new Map();
25
+ for (const line of stdout.split("\n")) {
26
+ const match = LINE.exec(line);
27
+ if (match === null) continue;
28
+ table.set(Number(match[1]), { ppid: Number(match[2]), comm: match[3] });
29
+ }
30
+ return table;
31
+ } catch {
32
+ return new Map();
33
+ }
34
+ }
@@ -10,6 +10,9 @@ import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem"
10
10
  import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
11
11
  from "@agents-can-communicate/cli";
12
12
 
13
+ import { resolveClientPid } from "./client-pid.mjs";
14
+ import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
15
+
13
16
  // A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
14
17
  // let a call through than to make someone's session sit waiting on us.
15
18
  const DEFAULT_BUDGET_MS = 5_000;
@@ -154,8 +157,14 @@ async function openContext({ cwd, dataHome, runtime, env }) {
154
157
  }
155
158
 
156
159
  const HANDLERS = {
157
- async sessionStart({ event, context, adapter, adapterId, paths }) {
160
+ async sessionStart({ event, context, adapter, adapterId, paths, readProcessTable }) {
158
161
  const capabilities = adapter.capabilities ?? {};
162
+ // Once per session, never per turn. A client that cannot be found yields
163
+ // null, and the session is then judged by age alone - which is exactly the
164
+ // behaviour every session had before this existed.
165
+ const command = adapter.client?.command ?? null;
166
+ const pid = command === null ? null
167
+ : resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
159
168
  const session = await context.service.openSession({
160
169
  workspaceId: context.descriptor.id,
161
170
  participantId: participantFor(adapterId, event.sessionId, context.env),
@@ -171,6 +180,7 @@ const HANDLERS = {
171
180
  // Which checkout this agent is in. One workspace spans every worktree of
172
181
  // a repository, so this is the only thing that distinguishes them.
173
182
  checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
183
+ pid,
174
184
  branch: context.descriptor.git?.branch ?? null,
175
185
  descriptor: context.descriptor,
176
186
  });
@@ -339,7 +349,8 @@ const HANDLERS = {
339
349
  * only thing this function refuses to do is fail closed.
340
350
  */
341
351
  export async function runHook({ adapterId, payload, adapters, dataHome, env,
342
- runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS }) {
352
+ runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
353
+ readProcessTable = defaultReadProcessTable }) {
343
354
  const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [] };
344
355
  let timer = null;
345
356
  try {
@@ -354,7 +365,8 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
354
365
  const handler = HANDLERS[event.kind];
355
366
  const work = handler === undefined
356
367
  ? Promise.resolve({})
357
- : handler({ event, context, adapter, adapterId, binding, paths: context.paths });
368
+ : handler({ event, context, adapter, adapterId, binding, paths: context.paths,
369
+ readProcessTable });
358
370
 
359
371
  // The loser of a race is not cancelled, so the timer is cleared explicitly:
360
372
  // an outstanding one keeps the process alive long past its answer.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,7 +2,7 @@ import { AccError, EXIT } from "./errors.mjs";
2
2
  import { flag, id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
3
3
  resourceUri, sequence, text, timestamp } from "./fields.mjs";
4
4
 
5
- export const SCHEMA_VERSION = 1;
5
+ export const SCHEMA_VERSION = 2;
6
6
 
7
7
  const line = text();
8
8
  const prose = text({ max: 4000, multiline: true });
@@ -78,6 +78,10 @@ const RECORDS = Object.freeze({
78
78
  session: { sessionId: id, participantId: id, workspaceId: id, generation: id,
79
79
  harness: line, state: oneOf("open", "closed"), parentSessionId: nullable(id),
80
80
  checkoutRoot: nullable(line), branch: nullable(line),
81
+ // The process behind this session, when it can be named. Null means nobody
82
+ // knows - no process table, or an ancestry that did not resolve - and is
83
+ // read as "judge this one by age alone", never as "dead".
84
+ pid: nullable(positiveInteger),
81
85
  enforcement: oneOf("guarded", "advisory"), lifecycle: oneOf("managed", "manual"),
82
86
  heartbeatCadenceMs: positiveInteger, startedAt: timestamp, heartbeatAt: timestamp },
83
87
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,9 +32,9 @@ export function encode(value) {
32
32
  return Buffer.from(`${serialised}\n`, "utf8");
33
33
  }
34
34
 
35
- async function bytesIfPresent(filePath, root) {
35
+ async function bytesIfPresent(filePath, root, openFile) {
36
36
  try {
37
- return await readRegularNoFollow(filePath, root);
37
+ return await readRegularNoFollow(filePath, root, openFile);
38
38
  } catch (error) {
39
39
  if (error.code === "ENOENT") return null;
40
40
  throw error;
@@ -87,8 +87,10 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
87
87
  }
88
88
  }
89
89
 
90
- export async function readJsonIfPresent(filePath, root) {
91
- const bytes = await bytesIfPresent(filePath, root);
90
+ // The opener is the seam the race tests use, and stays last so callers that do
91
+ // not care never see it.
92
+ export async function readJsonIfPresent(filePath, root, openFile) {
93
+ const bytes = await bytesIfPresent(filePath, root, openFile);
92
94
  if (bytes === null) return null;
93
95
  try {
94
96
  return { value: JSON.parse(bytes.toString("utf8")), bytes };
@@ -4,7 +4,7 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
4
4
 
5
5
  import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
6
 
7
- export const STORE_VERSION = 1;
7
+ export const STORE_VERSION = 2;
8
8
 
9
9
  export function identityPath(paths) {
10
10
  return path.join(paths.root, "protocol.json");
@@ -22,9 +22,36 @@ function defaultPidIsAlive(pid) {
22
22
  }
23
23
  }
24
24
 
25
- async function readOwner(directory, root) {
26
- const found = await readJsonIfPresent(path.join(directory, OWNER), root);
27
- return found?.value ?? null;
25
+ /**
26
+ * Who holds the lock, or nothing if it moved while we looked.
27
+ *
28
+ * Reads inside the store refuse a parent directory whose identity changed
29
+ * between the check and the open - the defence against a directory being
30
+ * swapped under a read. This lock is the one directory whose entire life is
31
+ * being created and removed: `mkdir` grants it, `rm` releases it, so its inode
32
+ * changes every time it passes from one process to the next. Reading its owner
33
+ * through the strict path meant a contended lock raised
34
+ * "record parent directory changed while opening" and the whole command failed -
35
+ * seen on Linux CI with four agents attaching to one fresh workspace, the
36
+ * ordinary way two people start work.
37
+ *
38
+ * Here that is not an anomaly, it is the answer: the lock moved. Null says so,
39
+ * and both callers already do the right thing with it - the acquiring loop waits
40
+ * and looks again, and the releasing branch declines to remove a directory that
41
+ * is no longer the one it created. The guard itself is untouched, and still
42
+ * refuses everywhere a parent has no business changing.
43
+ */
44
+ async function readOwner(directory, root, openFile) {
45
+ try {
46
+ const found = await readJsonIfPresent(path.join(directory, OWNER), root, openFile);
47
+ return found?.value ?? null;
48
+ } catch (error) {
49
+ if (error instanceof AccError && error.code === EXIT.DATA
50
+ && /parent directory changed/.test(error.message)) {
51
+ return null;
52
+ }
53
+ throw error;
54
+ }
28
55
  }
29
56
 
30
57
  /**
@@ -58,7 +85,7 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
58
85
 
59
86
  export async function withWriterMutex(paths, options, operation) {
60
87
  const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
61
- attempts = 50, waitMs = 20 } = options;
88
+ attempts = 50, waitMs = 20, openFile } = options;
62
89
  const directory = path.join(paths.locks, "writer.lock");
63
90
  await ensureManagedDirectory(root, paths.locks);
64
91
  const token = uuid();
@@ -68,7 +95,7 @@ export async function withWriterMutex(paths, options, operation) {
68
95
  await mkdir(directory);
69
96
  } catch (error) {
70
97
  if (error.code !== "EEXIST") throw error;
71
- const owner = await readOwner(directory, root);
98
+ const owner = await readOwner(directory, root, openFile);
72
99
  if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
73
100
  await new Promise(resolve => { setTimeout(resolve, waitMs); });
74
101
  }
@@ -79,7 +106,7 @@ export async function withWriterMutex(paths, options, operation) {
79
106
  try {
80
107
  return await operation();
81
108
  } finally {
82
- const current = await readOwner(directory, root);
109
+ const current = await readOwner(directory, root, openFile);
83
110
  if (current?.token === token) await rm(directory, { recursive: true, force: true });
84
111
  }
85
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [