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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/acc-hook.mjs +53 -0
  4. package/bin/acc-mcp.mjs +46 -0
  5. package/bin/acc.mjs +24 -0
  6. package/docs/CAPABILITIES.md +153 -0
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
  8. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
  9. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
  14. package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
  16. package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
  22. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
  29. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
  30. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
  31. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
  32. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
  33. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
  34. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
  36. package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
  37. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
  38. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
  43. package/node_modules/@agents-can-communicate/cli/package.json +12 -0
  44. package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
  45. package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
  46. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
  47. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
  48. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
  49. package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
  50. package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
  53. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
  54. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
  55. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
  56. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
  57. package/node_modules/@agents-can-communicate/core/package.json +12 -0
  58. package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
  59. package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
  60. package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
  61. package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
  62. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
  63. package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
  64. package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
  65. package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
  70. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
  71. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
  73. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
  74. package/node_modules/@agents-can-communicate/installer/package.json +10 -0
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
  76. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
  77. package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
  80. package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
  84. package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
  85. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
  86. package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
  87. package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
  88. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
  89. package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
  90. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
  91. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
  92. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
  93. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
  98. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
  99. package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
  100. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
  101. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
  103. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
  104. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
  105. 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
+ }