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.
- package/docs/CAPABILITIES.md +32 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/core/src/pid.mjs +33 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +9 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +12 -3
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +47 -7
- package/node_modules/@agents-can-communicate/core/src/status.mjs +17 -10
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +60 -8
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +11 -5
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +2 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +32 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +34 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +15 -3
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +5 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/package.json +1 -1
package/docs/CAPABILITIES.md
CHANGED
|
@@ -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
|
|
89
|
-
|
|
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
|
|
@@ -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
|
```
|
package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md
CHANGED
|
@@ -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
|
```
|
|
@@ -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,
|
|
17
|
+
* @param {{ store: object, clock: object, ids: object, pidIsAlive?: function,
|
|
18
|
+
* policies?: object }} ports
|
|
17
19
|
*/
|
|
18
|
-
export function createCoordinationService({ store, clock, ids,
|
|
19
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
91
|
-
.filter(
|
|
92
|
-
|
|
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
|
|
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 =>
|
|
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.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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"
|
|
@@ -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.
|
|
@@ -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 =
|
|
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
|
|
|
@@ -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 =
|
|
7
|
+
export const STORE_VERSION = 2;
|
|
8
8
|
|
|
9
9
|
export function identityPath(paths) {
|
|
10
10
|
return path.join(paths.root, "protocol.json");
|