agents-can-communicate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ now(): string }} Clock
|
|
5
|
+
* @typedef {{ next(kind: string): string }} IdSource
|
|
6
|
+
* @typedef {{ get(kind: string, id: string): object | null,
|
|
7
|
+
* generationOf(kind: string, id: string): string | null,
|
|
8
|
+
* list(kind: string, predicate?: (record: object) => boolean): object[],
|
|
9
|
+
* put(kind: string, id: string, record: object, expectedGeneration?: string | null): string,
|
|
10
|
+
* append(event: object): object }} CoordinationTransaction
|
|
11
|
+
* @typedef {{ transaction(callback: (tx: CoordinationTransaction) => unknown): Promise<unknown>,
|
|
12
|
+
* eventsSince(workspaceId: string, cursor: string | null, limit: number): Promise<object>,
|
|
13
|
+
* snapshot(workspaceId: string): Promise<object> }} CoordinationStore
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const REQUIRED = Object.freeze({
|
|
17
|
+
store: ["transaction", "eventsSince", "snapshot"],
|
|
18
|
+
clock: ["now"],
|
|
19
|
+
ids: ["next"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Ephemeral records - presence and Intent before a workspace has materialised -
|
|
23
|
+
// are storage too, so they hang off the store rather than becoming a fourth
|
|
24
|
+
// port. They are deliberately outside transactions: they append no events and
|
|
25
|
+
// vanish with their session.
|
|
26
|
+
const EPHEMERAL = ["get", "put", "delete", "list"];
|
|
27
|
+
|
|
28
|
+
// Ports are validated at construction rather than at first use. A core that
|
|
29
|
+
// silently falls back to ambient time or randomness produces tests that pass
|
|
30
|
+
// for the wrong reason and races that only appear on someone else's machine.
|
|
31
|
+
export function assertPorts(ports) {
|
|
32
|
+
for (const [name, methods] of Object.entries(REQUIRED)) {
|
|
33
|
+
const port = ports[name];
|
|
34
|
+
if (port === null || typeof port !== "object") {
|
|
35
|
+
throw new AccError(EXIT.USAGE, `the ${name} port is required`, { port: name });
|
|
36
|
+
}
|
|
37
|
+
for (const method of methods) {
|
|
38
|
+
if (typeof port[method] !== "function") {
|
|
39
|
+
throw new AccError(EXIT.USAGE, `the ${name} port must implement ${method}()`,
|
|
40
|
+
{ port: name, method });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const ephemeral = ports.store.ephemeral;
|
|
45
|
+
if (ephemeral === null || typeof ephemeral !== "object") {
|
|
46
|
+
throw new AccError(EXIT.USAGE, "the store port must expose an ephemeral area",
|
|
47
|
+
{ port: "store" });
|
|
48
|
+
}
|
|
49
|
+
for (const method of EPHEMERAL) {
|
|
50
|
+
if (typeof ephemeral[method] !== "function") {
|
|
51
|
+
throw new AccError(EXIT.USAGE, `the store ephemeral area must implement ${method}()`,
|
|
52
|
+
{ port: "store.ephemeral", method });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return ports;
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createClaimService } from "./claims.mjs";
|
|
2
|
+
import { createCommunicationService } from "./communication.mjs";
|
|
3
|
+
import { createIntentService } from "./intents.mjs";
|
|
4
|
+
import { assertPorts } from "./ports.mjs";
|
|
5
|
+
import { createSessionService } from "./sessions.mjs";
|
|
6
|
+
import { createGuardStateService, createStatusService } from "./status.mjs";
|
|
7
|
+
import { createSyncService } from "./sync.mjs";
|
|
8
|
+
import { createTaskService } from "./tasks.mjs";
|
|
9
|
+
import { createWorkstreamService } from "./workstreams.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Composition root for the domain services. Everything time- or
|
|
13
|
+
* randomness-dependent arrives through a port, so behaviour is reproducible and
|
|
14
|
+
* no module reaches for a global.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ store: object, clock: object, ids: object, policies?: object }} ports
|
|
17
|
+
*/
|
|
18
|
+
export function createCoordinationService({ store, clock, ids, policies = {} }) {
|
|
19
|
+
const ports = assertPorts({ store, clock, ids });
|
|
20
|
+
const sessions = createSessionService(ports);
|
|
21
|
+
const intents = createIntentService(ports, sessions);
|
|
22
|
+
const workstreams = createWorkstreamService(ports, sessions);
|
|
23
|
+
const tasks = createTaskService(ports, workstreams);
|
|
24
|
+
const claims = createClaimService(ports, sessions);
|
|
25
|
+
const communication = createCommunicationService(ports, sessions, claims);
|
|
26
|
+
const sync = createSyncService(ports, sessions);
|
|
27
|
+
const status = createStatusService(ports, sessions);
|
|
28
|
+
const guardState = createGuardStateService(ports);
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
store,
|
|
31
|
+
clock,
|
|
32
|
+
ids,
|
|
33
|
+
policies: Object.freeze({ ...policies }),
|
|
34
|
+
...sessions,
|
|
35
|
+
...intents,
|
|
36
|
+
...workstreams,
|
|
37
|
+
...tasks,
|
|
38
|
+
...claims,
|
|
39
|
+
...communication,
|
|
40
|
+
...sync,
|
|
41
|
+
...status,
|
|
42
|
+
guardState,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { ensureMaterialised, isMaterialised, materialise } from "./materialisation.mjs";
|
|
5
|
+
import { writeWorkResponse } from "./notify.mjs";
|
|
6
|
+
|
|
7
|
+
// A hook-only adapter heartbeats only when its harness gives it a turn, so the
|
|
8
|
+
// staleness window is a multiple of the cadence the session itself declared
|
|
9
|
+
// rather than one global constant (docs/ARCHITECTURE.md, presence freshness).
|
|
10
|
+
const STALE_CADENCE_MULTIPLE = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @returns {"online" | "stale" | "offline"}
|
|
14
|
+
*/
|
|
15
|
+
export function classifySessionPresence(session, now, probe = () => true) {
|
|
16
|
+
if (session.state === "closed") return "offline";
|
|
17
|
+
if (!probe(session)) return "offline";
|
|
18
|
+
const age = Date.parse(now) - Date.parse(session.heartbeatAt);
|
|
19
|
+
return age <= session.heartbeatCadenceMs * STALE_CADENCE_MULTIPLE ? "online" : "stale";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sessionRecord = (input, now, generation) => validateRecord("session", {
|
|
23
|
+
schemaVersion: SCHEMA_VERSION,
|
|
24
|
+
sessionId: input.sessionId,
|
|
25
|
+
participantId: input.participantId,
|
|
26
|
+
workspaceId: input.workspaceId,
|
|
27
|
+
generation,
|
|
28
|
+
harness: input.harness,
|
|
29
|
+
state: "open",
|
|
30
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
31
|
+
checkoutRoot: input.checkoutRoot ?? null,
|
|
32
|
+
branch: input.branch ?? null,
|
|
33
|
+
// Both default to the weaker reading. A session that declares nothing is a
|
|
34
|
+
// session nothing intercepts - an MCP client, or a CLI user - and claiming
|
|
35
|
+
// otherwise would promise enforcement that is not there.
|
|
36
|
+
enforcement: input.enforcement ?? "advisory",
|
|
37
|
+
lifecycle: input.lifecycle ?? "manual",
|
|
38
|
+
heartbeatCadenceMs: input.heartbeatCadenceMs,
|
|
39
|
+
startedAt: now,
|
|
40
|
+
heartbeatAt: now,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const participantRecord = (input, now) => validateRecord("participant", {
|
|
44
|
+
schemaVersion: SCHEMA_VERSION,
|
|
45
|
+
participantId: input.participantId,
|
|
46
|
+
workspaceId: input.workspaceId,
|
|
47
|
+
displayName: input.displayName ?? input.participantId,
|
|
48
|
+
kind: input.participantKind ?? "agent",
|
|
49
|
+
createdAt: now,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function createSessionService(ports) {
|
|
53
|
+
const { store, clock, ids } = ports;
|
|
54
|
+
const workspaceOf = input => input.workspaceId ?? store.workspaceId;
|
|
55
|
+
|
|
56
|
+
async function locate(sessionId, workspaceId) {
|
|
57
|
+
const ephemeral = await store.ephemeral.get("session", sessionId);
|
|
58
|
+
if (ephemeral !== null) return { record: ephemeral, durable: false };
|
|
59
|
+
const resolved = workspaceId ?? store.workspaceId;
|
|
60
|
+
if (resolved === undefined) return null;
|
|
61
|
+
const durable = (await store.snapshot(resolved, { kinds: ["session"] })).sessions
|
|
62
|
+
.find(session => session.sessionId === sessionId) ?? null;
|
|
63
|
+
return durable === null ? null : { record: durable, durable: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertReplaceable(existing, probe) {
|
|
67
|
+
if (existing.record.state === "closed") return;
|
|
68
|
+
// 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)) {
|
|
72
|
+
throw new AccError(EXIT.CONFLICT, "the session id is already live",
|
|
73
|
+
{ sessionId: existing.record.sessionId });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertGeneration(existing, generation, action) {
|
|
78
|
+
if (existing.record.generation !== generation) {
|
|
79
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} a replaced session generation`,
|
|
80
|
+
{ sessionId: existing.record.sessionId, expected: generation,
|
|
81
|
+
actual: existing.record.generation });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function openSession(input) {
|
|
86
|
+
const workspaceId = workspaceOf(input);
|
|
87
|
+
assertPortableId(workspaceId, "workspace id");
|
|
88
|
+
assertPortableId(input.participantId, "participant id");
|
|
89
|
+
const sessionId = input.sessionId ?? createId("session");
|
|
90
|
+
const existing = await locate(sessionId, workspaceId);
|
|
91
|
+
if (existing !== null) assertReplaceable(existing, input.probe);
|
|
92
|
+
|
|
93
|
+
const now = clock.now();
|
|
94
|
+
const session = sessionRecord({ ...input, workspaceId, sessionId }, now,
|
|
95
|
+
ids.next("generation"));
|
|
96
|
+
const participant = participantRecord({ ...input, workspaceId }, now);
|
|
97
|
+
|
|
98
|
+
if (await isMaterialised(store, workspaceId)) {
|
|
99
|
+
await store.transaction(async tx => {
|
|
100
|
+
const replaced = tx.get("session", sessionId)?.generation ?? null;
|
|
101
|
+
if (tx.get("participant", participant.participantId) === null) {
|
|
102
|
+
tx.put("participant", participant.participantId, participant);
|
|
103
|
+
}
|
|
104
|
+
tx.put("session", sessionId, session, tx.generationOf("session", sessionId));
|
|
105
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
106
|
+
actorSessionId: sessionId, type: "session.opened", occurredAt: now,
|
|
107
|
+
payload: { replaced } });
|
|
108
|
+
}, { kinds: ["participant", "session"] });
|
|
109
|
+
return session;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await store.ephemeral.put("participant", participant.participantId, participant);
|
|
113
|
+
await store.ephemeral.put("session", sessionId, session);
|
|
114
|
+
// The approved trigger is the SECOND live session, not the first: a lone
|
|
115
|
+
// session must be able to open and close without leaving a trace.
|
|
116
|
+
const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
|
|
117
|
+
if (live.length > 1) {
|
|
118
|
+
await materialise(ports, { workspaceId, descriptor: input.descriptor,
|
|
119
|
+
reason: "second_live_session" });
|
|
120
|
+
}
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function heartbeatSession({ sessionId, workspaceId, generation }) {
|
|
125
|
+
const existing = await locate(sessionId, workspaceId);
|
|
126
|
+
if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
127
|
+
assertGeneration(existing, generation, "heartbeat");
|
|
128
|
+
const beaten = { ...existing.record, heartbeatAt: clock.now() };
|
|
129
|
+
|
|
130
|
+
// Heartbeats never append to the semantic event feed: only open, close, and
|
|
131
|
+
// presence transitions surface through cursor sync (spec section 6.4).
|
|
132
|
+
if (!existing.durable) {
|
|
133
|
+
await store.ephemeral.put("session", sessionId, beaten);
|
|
134
|
+
return beaten;
|
|
135
|
+
}
|
|
136
|
+
await store.transaction(async tx =>
|
|
137
|
+
tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId)),
|
|
138
|
+
{ kinds: ["session"] });
|
|
139
|
+
return beaten;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function closeSession({ sessionId, workspaceId, generation }) {
|
|
143
|
+
const existing = await locate(sessionId, workspaceId);
|
|
144
|
+
if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
145
|
+
assertGeneration(existing, generation, "close");
|
|
146
|
+
const now = clock.now();
|
|
147
|
+
const closed = { ...existing.record, state: "closed", heartbeatAt: now };
|
|
148
|
+
|
|
149
|
+
if (!existing.durable) {
|
|
150
|
+
// An ephemeral-only workspace vanishes with its sessions: nothing durable
|
|
151
|
+
// was written, so nothing has to be cleaned up later.
|
|
152
|
+
await store.ephemeral.delete("session", sessionId);
|
|
153
|
+
await store.ephemeral.delete("intent", sessionId);
|
|
154
|
+
return closed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await store.transaction(async tx => {
|
|
158
|
+
tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
|
|
159
|
+
// Work in progress goes back on the table. A task held by a session that
|
|
160
|
+
// has gone stayed `in_progress` forever, nobody else could take it, and
|
|
161
|
+
// whoever asked for it was never told - a request handed to an agent that
|
|
162
|
+
// closed its terminal simply vanished.
|
|
163
|
+
for (const task of tx.list("task")) {
|
|
164
|
+
if (task.assigneeSessionId !== sessionId) continue;
|
|
165
|
+
if (task.state === "done") continue;
|
|
166
|
+
const released = { ...task, assigneeSessionId: null, state: "pending" };
|
|
167
|
+
tx.put("task", task.taskId, released, tx.generationOf("task", task.taskId));
|
|
168
|
+
writeWorkResponse(tx, { task, actor: closed, workspaceId: closed.workspaceId,
|
|
169
|
+
now, ids, outcome: "released",
|
|
170
|
+
reason: "the session working on this closed before finishing it" });
|
|
171
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
172
|
+
workspaceId: closed.workspaceId, actorSessionId: sessionId,
|
|
173
|
+
type: "task.released", occurredAt: now, payload: { taskId: task.taskId } });
|
|
174
|
+
}
|
|
175
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
176
|
+
workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
|
|
177
|
+
occurredAt: now, payload: {} });
|
|
178
|
+
// `writeWorkResponse` tells whoever asked, on this same handle.
|
|
179
|
+
}, { kinds: ["message", "receipt", "session", "task"] });
|
|
180
|
+
return closed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
openSession,
|
|
185
|
+
heartbeatSession,
|
|
186
|
+
closeSession,
|
|
187
|
+
locateSession: locate,
|
|
188
|
+
ensureMaterialised: options => ensureMaterialised(ports, options),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
|
+
import { computeAttention } from "./sync.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Protection level, reported from what is actually enforceable.
|
|
6
|
+
*
|
|
7
|
+
* A guarded claim only protects anything if every live session can be stopped
|
|
8
|
+
* from writing through it. One MCP client with no hooks, or one harness whose
|
|
9
|
+
* model edits through the shell, and the claim is advice - so the workspace is
|
|
10
|
+
* advisory however the claims were written. Reporting "guarded" there would
|
|
11
|
+
* promise enforcement that demonstrably is not present.
|
|
12
|
+
*/
|
|
13
|
+
function protectionOf(claims, live) {
|
|
14
|
+
if (claims.length === 0) return "none";
|
|
15
|
+
const enforceable = live.every(item => item.session.enforcement === "guarded");
|
|
16
|
+
if (!enforceable) return "advisory";
|
|
17
|
+
return claims.every(claim => claim.enforcement === "guarded") ? "guarded" : "advisory";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The two things the write guard has to know, and nothing else.
|
|
22
|
+
*
|
|
23
|
+
* `collectStatus` answers a person's question and reads the whole store to do
|
|
24
|
+
* it. The guard asks a much smaller one - who holds a live claim, and what is
|
|
25
|
+
* that owner called - in front of *every file an agent writes*. Reading
|
|
26
|
+
* everything there made the cost grow with the number of messages the workspace
|
|
27
|
+
* had ever carried: measured at about 1.4ms per stored record, so a workspace
|
|
28
|
+
* with a few thousand crosses the hook's five-second budget, after which it
|
|
29
|
+
* fails open and the write goes through unguarded.
|
|
30
|
+
*
|
|
31
|
+
* Sessions and claims are bounded by what is live. Messages, receipts, tasks and
|
|
32
|
+
* events are not bounded by anything, and none of them decides whether a write
|
|
33
|
+
* is allowed.
|
|
34
|
+
*/
|
|
35
|
+
export function createGuardStateService(ports) {
|
|
36
|
+
const { store, clock } = ports;
|
|
37
|
+
|
|
38
|
+
return async function guardState(input = {}) {
|
|
39
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
40
|
+
const now = clock.now();
|
|
41
|
+
const snapshot = await store.snapshot(workspaceId, { kinds: ["workspace", "session", "claim"] });
|
|
42
|
+
const sessions = snapshot.workspace !== null
|
|
43
|
+
? snapshot.sessions
|
|
44
|
+
: await store.ephemeral.list("session");
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
claims: snapshot.claims
|
|
48
|
+
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now)),
|
|
49
|
+
participants: sessions.map(session => ({
|
|
50
|
+
sessionId: session.sessionId,
|
|
51
|
+
participantId: session.participantId,
|
|
52
|
+
// So the guard can tell whether this session has looked alive recently
|
|
53
|
+
// without a second read. Bounded like the rest of what it asks for.
|
|
54
|
+
heartbeatAt: session.heartbeatAt,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createStatusService(ports, sessions) {
|
|
61
|
+
const { store, clock } = ports;
|
|
62
|
+
|
|
63
|
+
async function collectStatus(input = {}) {
|
|
64
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
65
|
+
const now = clock.now();
|
|
66
|
+
const snapshot = await store.snapshot(workspaceId);
|
|
67
|
+
|
|
68
|
+
// A workspace that has not materialised still has a truthful status: its
|
|
69
|
+
// sessions and intents live in the ephemeral area.
|
|
70
|
+
const durable = snapshot.workspace !== null;
|
|
71
|
+
const sessionRecords = durable
|
|
72
|
+
? snapshot.sessions
|
|
73
|
+
: await store.ephemeral.list("session");
|
|
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");
|
|
78
|
+
const claims = snapshot.claims
|
|
79
|
+
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
workspaceId,
|
|
83
|
+
materialised: durable,
|
|
84
|
+
protection: protectionOf(claims, live),
|
|
85
|
+
// Who is here, unless the caller asks for everyone who ever was. A closed
|
|
86
|
+
// session is never removed - a message is attributed to its sender, and
|
|
87
|
+
// the roster is where "which worktree was that agent in" is answered - so
|
|
88
|
+
// after a month of work this listed sixty entries for one live session.
|
|
89
|
+
// `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 => ({
|
|
94
|
+
sessionId: session.sessionId,
|
|
95
|
+
participantId: session.participantId,
|
|
96
|
+
harness: session.harness,
|
|
97
|
+
parentSessionId: session.parentSessionId,
|
|
98
|
+
checkoutRoot: session.checkoutRoot ?? null,
|
|
99
|
+
branch: session.branch ?? null,
|
|
100
|
+
enforcement: session.enforcement ?? "advisory",
|
|
101
|
+
lifecycle: session.lifecycle ?? "manual",
|
|
102
|
+
presence: classifySessionPresence(session, now),
|
|
103
|
+
intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
|
|
104
|
+
})),
|
|
105
|
+
workstreams: snapshot.workstreams.map(workstream => ({
|
|
106
|
+
workstreamId: workstream.workstreamId,
|
|
107
|
+
title: workstream.title,
|
|
108
|
+
state: workstream.state,
|
|
109
|
+
coordinatorSessionId: workstream.coordinatorSessionId,
|
|
110
|
+
})),
|
|
111
|
+
claims: claims.map(claim => ({
|
|
112
|
+
claimId: claim.claimId,
|
|
113
|
+
resource: claim.resource,
|
|
114
|
+
mode: claim.mode,
|
|
115
|
+
enforcement: claim.enforcement,
|
|
116
|
+
ownerSessionId: claim.ownerSessionId,
|
|
117
|
+
expiresAt: claim.expiresAt,
|
|
118
|
+
})),
|
|
119
|
+
attention: computeAttention(snapshot, { session: null,
|
|
120
|
+
participantId: input.participantId, now }),
|
|
121
|
+
counts: {
|
|
122
|
+
live: live.length,
|
|
123
|
+
stale: live.filter(item => item.presence === "stale").length,
|
|
124
|
+
claims: claims.length,
|
|
125
|
+
tasks: snapshot.tasks.length,
|
|
126
|
+
messages: snapshot.messages.length,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { collectStatus, locateSession: sessions.locateSession };
|
|
132
|
+
}
|