agents-can-communicate 0.1.13 → 0.1.15

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 (33) 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-claude-code/plugin/skills/acc/SKILL.md +8 -1
  4. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -1
  6. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -1
  7. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -1
  10. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/core/src/claims.mjs +2 -2
  14. package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
  15. package/node_modules/@agents-can-communicate/core/src/pid.mjs +33 -0
  16. package/node_modules/@agents-can-communicate/core/src/ports.mjs +9 -0
  17. package/node_modules/@agents-can-communicate/core/src/service.mjs +12 -3
  18. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +47 -7
  19. package/node_modules/@agents-can-communicate/core/src/status.mjs +17 -10
  20. package/node_modules/@agents-can-communicate/core/src/sync.mjs +60 -8
  21. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +11 -5
  22. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +2 -2
  23. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +32 -0
  25. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +34 -0
  26. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +15 -3
  27. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +5 -1
  31. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  33. 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.13",
3
+ "version": "0.1.15",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@ skill is how you stay legible to them and they to you.
14
14
  Once you understand the request, publish one line of Intent:
15
15
 
16
16
  ```bash
17
- {{ACC}} work --summary "porting the claim model" --mode edit
17
+ {{ACC}} work --summary "porting the claim model" --mode edit --hint 'file:packages/core/**'
18
18
  ```
19
19
 
20
20
  When you stop working on something and are not starting anything else, say so with
@@ -25,6 +25,12 @@ progress.
25
25
  when the work changes character. Intent is awareness, not a reservation: it tells peers
26
26
  what you are up to, it does not stop anyone editing anything.
27
27
 
28
+ `--hint` names a file or glob you are about to touch, and repeats for more than one. It is
29
+ the part of Intent another agent's tools act on: a peer who holds a claim on that resource
30
+ is told you are heading for it, and you are told if your hint lands on a claim someone else
31
+ holds. A summary a person reads is not a hint a tool can match - leave it off and neither
32
+ warning fires.
33
+
28
34
  ## Claim before you change shared work
29
35
 
30
36
  ```bash
@@ -62,6 +68,7 @@ to the command that answers it:
62
68
  - [direct_request] message_x someone addressed this to you -> ack
63
69
  - [task_unblocked] task_x work is waiting for you -> task --take
64
70
  - [claim_conflict] claim_x someone holds what you want -> ask, or release
71
+ - [claim_contended] claim_x a peer means to touch what you hold -> reach out, or hold
65
72
  - [request_stalled] task_x you asked and nobody is on it -> ask again, or take it back
66
73
  - [request_stalled] message_x you asked and nobody is there -> ask someone else
67
74
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@ skill is how you stay legible to them and they to you.
14
14
  Once you understand the request, publish one line of Intent:
15
15
 
16
16
  ```bash
17
- {{ACC}} work --summary "porting the claim model" --mode edit
17
+ {{ACC}} work --summary "porting the claim model" --mode edit --hint 'file:packages/core/**'
18
18
  ```
19
19
 
20
20
  When you stop working on something and are not starting anything else, say so with
@@ -25,6 +25,12 @@ progress.
25
25
  when the work changes character. Intent is awareness, not a reservation: it tells peers
26
26
  what you are up to, it does not stop anyone editing anything.
27
27
 
28
+ `--hint` names a file or glob you are about to touch, and repeats for more than one. It is
29
+ the part of Intent another agent's tools act on: a peer who holds a claim on that resource
30
+ is told you are heading for it, and you are told if your hint lands on a claim someone else
31
+ holds. A summary a person reads is not a hint a tool can match - leave it off and neither
32
+ warning fires.
33
+
28
34
  ## Claim before you change shared work
29
35
 
30
36
  ```bash
@@ -62,6 +68,7 @@ to the command that answers it:
62
68
  - [direct_request] message_x someone addressed this to you -> ack
63
69
  - [task_unblocked] task_x work is waiting for you -> task --take
64
70
  - [claim_conflict] claim_x someone holds what you want -> ask, or release
71
+ - [claim_contended] claim_x a peer means to touch what you hold -> reach out, or hold
65
72
  - [request_stalled] task_x you asked and nobody is on it -> ask again, or take it back
66
73
  - [request_stalled] message_x you asked and nobody is there -> ask someone else
67
74
  ```
@@ -14,7 +14,7 @@ skill is how you stay legible to them and they to you.
14
14
  Once you understand the request, publish one line of Intent:
15
15
 
16
16
  ```bash
17
- {{ACC}} work --summary "porting the claim model" --mode edit
17
+ {{ACC}} work --summary "porting the claim model" --mode edit --hint 'file:packages/core/**'
18
18
  ```
19
19
 
20
20
  When you stop working on something and are not starting anything else, say so with
@@ -25,6 +25,12 @@ progress.
25
25
  when the work changes character. Intent is awareness, not a reservation: it tells peers
26
26
  what you are up to, it does not stop anyone editing anything.
27
27
 
28
+ `--hint` names a file or glob you are about to touch, and repeats for more than one. It is
29
+ the part of Intent another agent's tools act on: a peer who holds a claim on that resource
30
+ is told you are heading for it, and you are told if your hint lands on a claim someone else
31
+ holds. A summary a person reads is not a hint a tool can match - leave it off and neither
32
+ warning fires.
33
+
28
34
  ## Claim before you change shared work
29
35
 
30
36
  ```bash
@@ -62,6 +68,7 @@ to the command that answers it:
62
68
  - [direct_request] message_x someone addressed this to you -> ack
63
69
  - [task_unblocked] task_x work is waiting for you -> task --take
64
70
  - [claim_conflict] claim_x someone holds what you want -> ask, or release
71
+ - [claim_contended] claim_x a peer means to touch what you hold -> reach out, or hold
65
72
  - [request_stalled] task_x you asked and nobody is on it -> ask again, or take it back
66
73
  - [request_stalled] message_x you asked and nobody is there -> ask someone else
67
74
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@ skill is how you stay legible to them and they to you.
14
14
  Once you understand the request, publish one line of Intent:
15
15
 
16
16
  ```bash
17
- {{ACC}} work --summary "porting the claim model" --mode edit
17
+ {{ACC}} work --summary "porting the claim model" --mode edit --hint 'file:packages/core/**'
18
18
  ```
19
19
 
20
20
  When you stop working on something and are not starting anything else, say so with
@@ -25,6 +25,12 @@ progress.
25
25
  when the work changes character. Intent is awareness, not a reservation: it tells peers
26
26
  what you are up to, it does not stop anyone editing anything.
27
27
 
28
+ `--hint` names a file or glob you are about to touch, and repeats for more than one. It is
29
+ the part of Intent another agent's tools act on: a peer who holds a claim on that resource
30
+ is told you are heading for it, and you are told if your hint lands on a claim someone else
31
+ holds. A summary a person reads is not a hint a tool can match - leave it off and neither
32
+ warning fires.
33
+
28
34
  ## Claim before you change shared work
29
35
 
30
36
  ```bash
@@ -62,6 +68,7 @@ to the command that answers it:
62
68
  - [direct_request] message_x someone addressed this to you -> ack
63
69
  - [task_unblocked] task_x work is waiting for you -> task --take
64
70
  - [claim_conflict] claim_x someone holds what you want -> ask, or release
71
+ - [claim_contended] claim_x a peer means to touch what you hold -> reach out, or hold
65
72
  - [request_stalled] task_x you asked and nobody is on it -> ask again, or take it back
66
73
  - [request_stalled] message_x you asked and nobody is there -> ask someone else
67
74
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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,
@@ -45,6 +45,7 @@ export const ATTENTION_PRIORITY = Object.freeze({
45
45
  coordinator_missing: 4,
46
46
  request_stalled: 5,
47
47
  claim_expired: 6,
48
+ claim_contended: 7,
48
49
  });
49
50
 
50
51
  function directRequests(snapshot, participantId) {
@@ -95,6 +96,41 @@ function claimConflicts(snapshot, session, now) {
95
96
  summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
96
97
  }
97
98
 
99
+ /**
100
+ * A resource I hold that a peer has said they intend to touch.
101
+ *
102
+ * The mirror of `claimConflicts`. That one reads my own intent and warns me when
103
+ * what I mean to touch is already claimed. This one reads a peer's intent and
104
+ * warns me, the holder, that someone is heading for what I claimed. Without it
105
+ * intent's only wired reader faced inward: it protected the one declaring intent
106
+ * and told the claim holder nothing, so a claim was a wall nobody was told they
107
+ * were walking into. A claim is advisory - it does not stop the write - so being
108
+ * told early is the whole of the protection it offers.
109
+ *
110
+ * Only my own claims, and never my own intent against them: declaring intent on
111
+ * what you already hold is not someone reaching for it. The peer is named by
112
+ * participant where the roster knows it, because a session id cannot be used
113
+ * with `--to` and an id a reader cannot act on is the trap the projector warns of.
114
+ */
115
+ function claimContended(snapshot, session, now) {
116
+ if (session === null || session === undefined) return [];
117
+ const theirs = (snapshot.intents ?? [])
118
+ .filter(intent => intent.sessionId !== session.sessionId);
119
+ const nameOf = sessionId => (snapshot.sessions ?? [])
120
+ .find(item => item.sessionId === sessionId)?.participantId ?? "a peer";
121
+ return (snapshot.claims ?? [])
122
+ .filter(claim => claim.ownerSessionId === session.sessionId
123
+ && Date.parse(claim.expiresAt) > Date.parse(now))
124
+ .flatMap(claim => {
125
+ const eyeing = theirs.find(intent =>
126
+ (intent.resourceHints ?? []).some(hint => overlaps(hint, claim.resource)));
127
+ return eyeing === undefined ? [] : [{
128
+ kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
129
+ sourceId: claim.claimId,
130
+ summary: `${claim.resource} - ${nameOf(eyeing.sessionId)} means to work on what you hold` }];
131
+ });
132
+ }
133
+
98
134
  /**
99
135
  * Work waiting on me.
100
136
  *
@@ -154,11 +190,18 @@ function unansweredQuestions(snapshot, participantId, onlineParticipants) {
154
190
  return items;
155
191
  }
156
192
 
157
- function stalledRequests(snapshot, participantId, now) {
193
+ function stalledRequests(snapshot, participantId, now, pidIsAlive) {
194
+ // Classified once per session and reused below, for the same reason
195
+ // collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
196
+ // a real process.kill(pid, 0) syscall for a session with a recorded pid, so
197
+ // it is not pure given `now` any more. A second classifying pass here could
198
+ // disagree with the first - a client exiting between them would leave `live`
199
+ // saying "online" while a freshly-computed `onlineParticipants` had already
200
+ // dropped it, inside one attention computation.
158
201
  const live = new Map((snapshot.sessions ?? [])
159
- .map(session => [session.sessionId, classifySessionPresence(session, now)]));
202
+ .map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
160
203
  const onlineParticipants = new Set((snapshot.sessions ?? [])
161
- .filter(session => classifySessionPresence(session, now) === "online")
204
+ .filter(session => live.get(session.sessionId) === "online")
162
205
  .map(session => session.participantId));
163
206
  const goingNowhere = task => {
164
207
  // Taken by someone who has gone quiet.
@@ -191,20 +234,29 @@ function coordinatorGaps(snapshot) {
191
234
  sourceId: workstream.workstreamId, summary: workstream.title }));
192
235
  }
193
236
 
194
- export function computeAttention(snapshot, { session, participantId, now }) {
237
+ export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
238
+ // Required unconditionally, not only when there happen to be sessions to
239
+ // classify: `stalledRequests` reaches `classifySessionPresence` only inside a
240
+ // map/filter over `snapshot.sessions`, so an empty or absent roster let a
241
+ // missing probe through with nothing to trip over it - the same silent pass
242
+ // the classifier's own required parameter exists to close, one layer up.
243
+ if (typeof pidIsAlive !== "function") {
244
+ throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
245
+ }
195
246
  return [
196
247
  ...directRequests(snapshot, participantId),
197
248
  ...claimConflicts(snapshot, session, now),
249
+ ...claimContended(snapshot, session, now),
198
250
  ...expiredClaims(snapshot, session, now),
199
251
  ...unblockedTasks(snapshot, session, participantId),
200
252
  ...coordinatorGaps(snapshot),
201
- ...stalledRequests(snapshot, participantId, now),
253
+ ...stalledRequests(snapshot, participantId, now, pidIsAlive),
202
254
  ].sort((left, right) => left.priority - right.priority
203
255
  || left.sourceId.localeCompare(right.sourceId));
204
256
  }
205
257
 
206
258
  export function createSyncService(ports, sessions) {
207
- const { store, clock } = ports;
259
+ const { store, clock, pidIsAlive } = ports;
208
260
 
209
261
  /**
210
262
  * Any session may request the full Workspace scope. Peer equality is a
@@ -240,7 +292,7 @@ export function createSyncService(ports, sessions) {
240
292
  const page = await store.eventsSince(workspaceId, input.cursor ?? null,
241
293
  input.limit ?? DEFAULT_LIMIT);
242
294
  const attention = computeAttention(snapshot, { session,
243
- participantId: session?.participantId ?? input.participantId, now });
295
+ participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
244
296
 
245
297
  const roster = snapshot.sessions.map(item => ({
246
298
  sessionId: item.sessionId,
@@ -248,7 +300,7 @@ export function createSyncService(ports, sessions) {
248
300
  parentSessionId: item.parentSessionId,
249
301
  harness: item.harness,
250
302
  branch: item.branch ?? null,
251
- presence: classifySessionPresence(item, now),
303
+ presence: classifySessionPresence(item, now, pidIsAlive),
252
304
  }));
253
305
 
254
306
  // 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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [