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,273 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ import { classifySessionPresence } from "./sessions.mjs";
4
+ import { overlaps } from "./claims.mjs";
5
+
6
+ const DEFAULT_LIMIT = 100;
7
+
8
+ // The shape every event carries, and the only thing a caller may ask to resume
9
+ // from. `null` means the beginning, which is what a session with no cursor yet
10
+ // has.
11
+ const CURSOR = /^[0-9]{16}$/;
12
+
13
+ // The two a caller may ask for. An unknown one used to become `delta`, so
14
+ // `--scope ful` answered the one question the full scope exists for - "show me
15
+ // everything, I cannot see the rest of the system" - with a delta carrying no
16
+ // snapshot at all, and the agent concluded there was nothing to see.
17
+ const SCOPES = Object.freeze(["delta", "full"]);
18
+
19
+ function assertScope(scope) {
20
+ if (scope != null && !SCOPES.includes(scope)) {
21
+ throw new AccError(EXIT.USAGE,
22
+ `scope is one of ${SCOPES.join(", ")}; leave it out for ${SCOPES[0]}`, { scope });
23
+ }
24
+ }
25
+
26
+ function assertCursor(cursor) {
27
+ if (typeof cursor !== "string" || !CURSOR.test(cursor)) {
28
+ throw new AccError(EXIT.USAGE,
29
+ "a cursor is the 16-digit sequence a previous sync returned; "
30
+ + "leave it out to start from the beginning",
31
+ { cursor });
32
+ }
33
+ }
34
+
35
+ // Attention is computed from explicit rules, never from a hidden classifier.
36
+ // Lower priority sorts first.
37
+ //
38
+ // Exported so a test can prove every kind listed here is reachable. A fifth
39
+ // entry once sat here with no rule behind it, which read as a feature in review
40
+ // and produced nothing at runtime.
41
+ export const ATTENTION_PRIORITY = Object.freeze({
42
+ direct_request: 1,
43
+ claim_conflict: 2,
44
+ task_unblocked: 3,
45
+ coordinator_missing: 4,
46
+ request_stalled: 5,
47
+ claim_expired: 6,
48
+ });
49
+
50
+ function directRequests(snapshot, participantId) {
51
+ const items = [];
52
+ for (const receipt of snapshot.receipts ?? []) {
53
+ if (receipt.recipientParticipantId !== participantId) continue;
54
+ if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
55
+ const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
56
+ if (message === undefined || !message.requiresAck) continue;
57
+ items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
58
+ sourceId: message.messageId, summary: message.subject });
59
+ }
60
+ return items;
61
+ }
62
+
63
+ /**
64
+ * A claim of yours that has run out.
65
+ *
66
+ * A lease lapses on the clock, and nothing said so. Measured: while it held, a
67
+ * peer's write into the file was refused; three seconds later the same write
68
+ * went through, and the holder's turn was identical before and after. It went on
69
+ * working on a file it believed it had reserved, and everyone else was free to
70
+ * change it.
71
+ *
72
+ * Only for the session that took it, and only while that session is the one
73
+ * asking: a lapsed claim is news to its owner and nobody else's business.
74
+ * Re-claiming refreshes the lease and clears this; releasing it clears it too.
75
+ */
76
+ function expiredClaims(snapshot, session, now) {
77
+ if (session == null) return [];
78
+ return (snapshot.claims ?? [])
79
+ .filter(claim => claim.ownerSessionId === session.sessionId
80
+ && Date.parse(claim.expiresAt) <= Date.parse(now))
81
+ .map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
82
+ sourceId: claim.claimId,
83
+ summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
84
+ }
85
+
86
+ function claimConflicts(snapshot, session, now) {
87
+ const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
88
+ if (mine === undefined) return [];
89
+ return (snapshot.claims ?? [])
90
+ .filter(claim => claim.ownerSessionId !== session.sessionId
91
+ && Date.parse(claim.expiresAt) > Date.parse(now)
92
+ && mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
93
+ .map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
94
+ sourceId: claim.claimId,
95
+ summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
96
+ }
97
+
98
+ /**
99
+ * Work waiting on me.
100
+ *
101
+ * Addressed by participant, so a request survives the recipient restarting -
102
+ * the next session of that agent is told about it. A task already taken by one
103
+ * of my sessions matches too, since that session may have been replaced.
104
+ *
105
+ * Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
106
+ * every open task into every turn is how a coordination layer becomes noise.
107
+ */
108
+ function unblockedTasks(snapshot, session, participantId) {
109
+ const mine = task => (task.assigneeParticipantId !== null
110
+ && task.assigneeParticipantId === participantId)
111
+ || (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
112
+ return (snapshot.tasks ?? [])
113
+ .filter(task => task.state === "pending" && mine(task))
114
+ .map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
115
+ sourceId: task.taskId, summary: task.title }));
116
+ }
117
+
118
+ /**
119
+ * Work you asked for that nobody is doing any more.
120
+ *
121
+ * A task taken by a session that then crashed stayed `in_progress` for good:
122
+ * the requester was told nothing and nobody else could take it. Unlike the
123
+ * one-shot answers a request produces, this repeats until it is resolved,
124
+ * because it stays true until someone picks the work back up.
125
+ */
126
+ /**
127
+ * A question nobody is left to answer.
128
+ *
129
+ * The task rule below tells a requester when work they asked for is going
130
+ * nowhere. A `requiresAck` message had no such rule, and a message is the other
131
+ * half of the same act: an agent asked a peer a direct question, the peer's
132
+ * session ended without answering, and the asker's next turn was empty. Not
133
+ * "still waiting" - empty. Measured, with the only other agent gone and an
134
+ * unanswered question standing between them.
135
+ *
136
+ * The same kind as the task case, because it is the same fact about the world:
137
+ * you asked, and there is nobody there.
138
+ */
139
+ function unansweredQuestions(snapshot, participantId, onlineParticipants) {
140
+ const items = [];
141
+ for (const receipt of snapshot.receipts ?? []) {
142
+ if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
143
+ const message = (snapshot.messages ?? [])
144
+ .find(item => item.messageId === receipt.messageId);
145
+ if (message === undefined || !message.requiresAck) continue;
146
+ if (message.fromParticipantId !== participantId) continue;
147
+ // Not answered yet by someone who is here is ordinary waiting, and saying so
148
+ // every turn would be noise the reader learns to skip.
149
+ if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
150
+ items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
151
+ sourceId: message.messageId,
152
+ summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
153
+ }
154
+ return items;
155
+ }
156
+
157
+ function stalledRequests(snapshot, participantId, now) {
158
+ const live = new Map((snapshot.sessions ?? [])
159
+ .map(session => [session.sessionId, classifySessionPresence(session, now)]));
160
+ const onlineParticipants = new Set((snapshot.sessions ?? [])
161
+ .filter(session => classifySessionPresence(session, now) === "online")
162
+ .map(session => session.participantId));
163
+ const goingNowhere = task => {
164
+ // Taken by someone who has gone quiet.
165
+ if (task.state === "in_progress") {
166
+ return task.assigneeSessionId !== null
167
+ && live.get(task.assigneeSessionId) !== "online";
168
+ }
169
+ // Or waiting on an agent that is not here - including one that closed and
170
+ // never came back, which leaves the work addressed to nobody at all.
171
+ return task.state === "pending" && task.assigneeParticipantId !== null
172
+ && !onlineParticipants.has(task.assigneeParticipantId);
173
+ };
174
+ return [
175
+ ...(snapshot.tasks ?? [])
176
+ .filter(task => task.requestedByParticipantId === participantId
177
+ && goingNowhere(task))
178
+ .map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
179
+ sourceId: task.taskId,
180
+ summary: `${task.title} - nobody is working on it` })),
181
+ ...unansweredQuestions(snapshot, participantId, onlineParticipants),
182
+ ];
183
+ }
184
+
185
+ function coordinatorGaps(snapshot) {
186
+ return (snapshot.workstreams ?? [])
187
+ .filter(workstream => workstream.state === "open"
188
+ && workstream.coordinatorSessionId === null)
189
+ .map(workstream => ({ kind: "coordinator_missing",
190
+ priority: ATTENTION_PRIORITY.coordinator_missing,
191
+ sourceId: workstream.workstreamId, summary: workstream.title }));
192
+ }
193
+
194
+ export function computeAttention(snapshot, { session, participantId, now }) {
195
+ return [
196
+ ...directRequests(snapshot, participantId),
197
+ ...claimConflicts(snapshot, session, now),
198
+ ...expiredClaims(snapshot, session, now),
199
+ ...unblockedTasks(snapshot, session, participantId),
200
+ ...coordinatorGaps(snapshot),
201
+ ...stalledRequests(snapshot, participantId, now),
202
+ ].sort((left, right) => left.priority - right.priority
203
+ || left.sourceId.localeCompare(right.sourceId));
204
+ }
205
+
206
+ export function createSyncService(ports, sessions) {
207
+ const { store, clock } = ports;
208
+
209
+ /**
210
+ * Any session may request the full Workspace scope. Peer equality is a
211
+ * knowledge property: no session receives a reduced
212
+ * view because of its role. The bounded delta is only the ambient default.
213
+ */
214
+ async function sync(input = {}) {
215
+ // A cursor that is not a cursor answered "nothing new", every time, for as
216
+ // long as it was held. `eventsSince` compares sequences as strings, so
217
+ // `not-a-cursor` sorts after every event there has ever been - and an
218
+ // adapter holding a corrupt one, or an agent that invented one, saw a quiet
219
+ // workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
220
+ // taken as the sequence it starts with.
221
+ if (input.cursor != null) assertCursor(input.cursor);
222
+ assertScope(input.scope);
223
+ const workspaceId = input.workspaceId ?? store.workspaceId;
224
+ const now = clock.now();
225
+ const located = input.sessionId === undefined
226
+ ? null
227
+ : await sessions.locateSession(input.sessionId, workspaceId);
228
+ const session = located?.record ?? null;
229
+
230
+ const durable = await store.snapshot(workspaceId);
231
+ // A workspace that has not materialised still has a truthful roster: its
232
+ // sessions live in the ephemeral area. Reading only the durable snapshot
233
+ // would make a lone session invisible to itself, and would disagree with
234
+ // what `status` reports from the same state.
235
+ const snapshot = durable.workspace !== null
236
+ ? durable
237
+ : { ...durable,
238
+ sessions: await store.ephemeral.list("session"),
239
+ intents: await store.ephemeral.list("intent") };
240
+ const page = await store.eventsSince(workspaceId, input.cursor ?? null,
241
+ input.limit ?? DEFAULT_LIMIT);
242
+ const attention = computeAttention(snapshot, { session,
243
+ participantId: session?.participantId ?? input.participantId, now });
244
+
245
+ const roster = snapshot.sessions.map(item => ({
246
+ sessionId: item.sessionId,
247
+ participantId: item.participantId,
248
+ parentSessionId: item.parentSessionId,
249
+ harness: item.harness,
250
+ branch: item.branch ?? null,
251
+ presence: classifySessionPresence(item, now),
252
+ }));
253
+
254
+ // Solo zero-overhead: one live session, no claims and
255
+ // no attention means an empty result, not a "nothing to report" banner.
256
+ const peers = roster.filter(item => item.sessionId !== session?.sessionId
257
+ && item.presence !== "offline");
258
+ const solo = peers.length === 0 && attention.length === 0
259
+ && snapshot.claims.length === 0;
260
+
261
+ return {
262
+ cursor: page.cursor,
263
+ scope: input.scope === "full" ? "full" : "delta",
264
+ solo,
265
+ attention,
266
+ roster,
267
+ events: page.events,
268
+ ...(input.scope === "full" ? { snapshot } : {}),
269
+ };
270
+ }
271
+
272
+ return { sync };
273
+ }
@@ -0,0 +1,238 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, createId, transitionTask as stepTask, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ import { ensureMaterialised } from "./materialisation.mjs";
5
+ import { assertKnownParticipants } from "./participants.mjs";
6
+ import { classifySessionPresence } from "./sessions.mjs";
7
+ import { closeRequestReceipt, writeWorkResponse } from "./notify.mjs";
8
+
9
+ // Dependency completion unblocks tasks deterministically, inside the same
10
+ // transaction that completed the dependency. It must never depend on a model
11
+ // remembering to re-evaluate the graph.
12
+ //
13
+ // Exported because a caller may supply its own task id - an adapter mirroring
14
+ // an external tracker, for instance - and that is the only way a create can
15
+ // close a cycle. Without an explicit id the guard would be unreachable.
16
+ export function wouldCycle(tasks, taskId, dependsOn) {
17
+ const byId = new Map(tasks.map(task => [task.taskId, task]));
18
+ const seen = new Set();
19
+ const stack = [...dependsOn];
20
+ while (stack.length > 0) {
21
+ const current = stack.pop();
22
+ if (current === taskId) return true;
23
+ if (seen.has(current)) continue;
24
+ seen.add(current);
25
+ stack.push(...(byId.get(current)?.dependsOn ?? []));
26
+ }
27
+ return false;
28
+ }
29
+
30
+ const blockedBy = (task, byId) => task.dependsOn
31
+ .filter(id => (byId.get(id)?.state ?? "pending") !== "done");
32
+
33
+ /**
34
+ * Write one task inside a transaction the caller already owns.
35
+ *
36
+ * Separated so that `acc request` - which creates the task and tells the
37
+ * recipient about it - can do both as one write. A request that produced a task
38
+ * and then failed to mention it would leave work addressed to an agent that was
39
+ * never told, which is worse than no request at all.
40
+ */
41
+ export function writeTask(tx, { input, session, workspaceId, now, ids }) {
42
+ const taskId = input.taskId ?? createId("task");
43
+ const existing = tx.list("task");
44
+ const dependsOn = input.dependsOn ?? [];
45
+ for (const dependency of dependsOn) {
46
+ if (tx.get("task", dependency) === null) {
47
+ throw new AccError(EXIT.DATA, "a dependency does not exist", { dependency });
48
+ }
49
+ }
50
+ if (wouldCycle(existing, taskId, dependsOn)) {
51
+ throw new AccError(EXIT.DATA, "the dependency graph would contain a cycle", { taskId });
52
+ }
53
+ // A workstream is optional - "finish these tests for me" should not require
54
+ // inventing a project first - but a named one has to exist, or the task hangs
55
+ // off nothing and nobody notices.
56
+ const workstreamId = input.workstreamId ?? null;
57
+ if (workstreamId !== null && tx.get("workstream", workstreamId) === null) {
58
+ throw new AccError(EXIT.DATA, "the workstream does not exist", { workstreamId });
59
+ }
60
+ const record = validateRecord("task", {
61
+ schemaVersion: SCHEMA_VERSION,
62
+ taskId,
63
+ workstreamId,
64
+ workspaceId,
65
+ title: input.title,
66
+ detail: input.detail ?? null,
67
+ state: blockedBy({ dependsOn }, new Map(existing.map(task => [task.taskId, task])))
68
+ .length > 0 ? "blocked" : "pending",
69
+ // Addressed to a participant, so the request survives that agent closing
70
+ // its terminal. Whoever picks it up is recorded separately.
71
+ assigneeParticipantId: input.assigneeParticipantId ?? null,
72
+ assigneeSessionId: null,
73
+ requestedByParticipantId: input.requestedByParticipantId ?? null,
74
+ dependsOn,
75
+ acceptance: input.acceptance ?? [],
76
+ createdAt: now,
77
+ });
78
+ tx.put("task", taskId, record);
79
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
80
+ actorSessionId: session.sessionId, type: "task.created", occurredAt: now,
81
+ payload: { taskId, state: record.state,
82
+ assigneeParticipantId: record.assigneeParticipantId } });
83
+ return record;
84
+ }
85
+
86
+ export function createTaskService(ports, workstreams) {
87
+ const { store, clock, ids } = ports;
88
+
89
+ async function createTask(input) {
90
+ const session = await workstreams.requireOpenSession(input, "create a task");
91
+ const workspaceId = session.workspaceId;
92
+ await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
93
+ reason: "durable_object" });
94
+ // The same rule as addressing a message. `acc task --assignee physcis` was
95
+ // accepted and left work `pending` for a participant nobody has ever been:
96
+ // invisible to every roster, raising `task_unblocked` for nobody, and not
97
+ // even stalled, since nothing was waiting on it that could be told.
98
+ if (input.assigneeParticipantId != null) {
99
+ await assertKnownParticipants(store, workspaceId, [input.assigneeParticipantId]);
100
+ }
101
+ const now = clock.now();
102
+ let record = null;
103
+ await store.transaction(async tx => {
104
+ record = writeTask(tx, { input, session, workspaceId, now, ids });
105
+ // `writeTask` reads and writes on this handle, so its kinds are ours.
106
+ }, { kinds: ["session", "task", "workstream"] });
107
+ return record;
108
+ }
109
+
110
+ async function claimTask(input) {
111
+ const session = await workstreams.requireOpenSession(input, "claim a task");
112
+ const now = clock.now();
113
+ let record = null;
114
+ await store.transaction(async tx => {
115
+ const existing = tx.get("task", input.taskId);
116
+ if (existing === null) {
117
+ throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
118
+ }
119
+ if (existing.assigneeSessionId !== null
120
+ && existing.assigneeSessionId !== session.sessionId) {
121
+ // A holder that is gone is not a holder. Closing a session hands its
122
+ // work back, so this is the crash case: no session end ever arrived and
123
+ // presence has decayed. Staleness alone does not release it - that is
124
+ // the same rule claims follow, because an idle agent may be thinking
125
+ // rather than dead - but it can be taken over deliberately.
126
+ const holder = tx.get("session", existing.assigneeSessionId);
127
+ const presence = holder === null
128
+ ? "offline"
129
+ : classifySessionPresence(holder, now);
130
+ if (presence === "online") {
131
+ throw new AccError(EXIT.CONFLICT, "the task already has an assignee",
132
+ { taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId });
133
+ }
134
+ if (presence === "stale" && input.force !== true) {
135
+ throw new AccError(EXIT.CONFLICT,
136
+ "the task is held by a session that has gone quiet; take it with force",
137
+ { taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId,
138
+ presence });
139
+ }
140
+ }
141
+ // Work addressed to one participant is not picked up by another. Taking
142
+ // an unaddressed task is open to anyone, which is what makes a request
143
+ // with no named recipient a request to the room.
144
+ if (existing.assigneeParticipantId !== null
145
+ && existing.assigneeParticipantId !== session.participantId) {
146
+ throw new AccError(EXIT.CONFLICT, "the task is addressed to another participant",
147
+ { taskId: input.taskId,
148
+ assigneeParticipantId: existing.assigneeParticipantId });
149
+ }
150
+ record = { ...existing, assigneeSessionId: session.sessionId,
151
+ assigneeParticipantId: existing.assigneeParticipantId ?? session.participantId,
152
+ state: stepTask(existing.state, "in_progress") };
153
+ // Whoever asked is waiting on an answer, and "someone is on it" is one.
154
+ writeWorkResponse(tx, { task: record, actor: session,
155
+ workspaceId: session.workspaceId, now, ids, outcome: "accepted" });
156
+ tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
157
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
158
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
159
+ type: "task.claimed", occurredAt: now, payload: { taskId: input.taskId } });
160
+ }, { kinds: ["message", "receipt", "session", "task"] });
161
+ return record;
162
+ }
163
+
164
+ async function transitionTask(input) {
165
+ const session = await workstreams.requireOpenSession(input, "transition a task");
166
+ const now = clock.now();
167
+ let record = null;
168
+ await store.transaction(async tx => {
169
+ const existing = tx.get("task", input.taskId);
170
+ if (existing === null) {
171
+ throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
172
+ }
173
+ record = { ...existing, state: stepTask(existing.state, input.state) };
174
+ tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
175
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
176
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
177
+ type: "task.transitioned", occurredAt: now,
178
+ payload: { taskId: input.taskId, state: record.state } });
179
+
180
+ if (record.state === "done" || record.state === "review") {
181
+ writeWorkResponse(tx, { task: record, actor: session,
182
+ workspaceId: session.workspaceId, now, ids, outcome: record.state });
183
+ }
184
+ // Doing the work answers the request that asked for it.
185
+ if (record.state === "done") closeRequestReceipt(tx, { task: record, actor: session, now, ids });
186
+ if (record.state !== "done") return;
187
+ // Unblock dependents here, in the same transaction, so the graph is
188
+ // never left in a state that needs someone to notice it later.
189
+ const byId = new Map(tx.list("task").map(task => [task.taskId, task]));
190
+ byId.set(record.taskId, record);
191
+ for (const dependent of byId.values()) {
192
+ if (dependent.state !== "blocked") continue;
193
+ if (blockedBy(dependent, byId).length > 0) continue;
194
+ const unblocked = { ...dependent, state: "pending" };
195
+ tx.put("task", dependent.taskId, unblocked,
196
+ tx.generationOf("task", dependent.taskId));
197
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
198
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
199
+ type: "task.unblocked", occurredAt: now, payload: { taskId: dependent.taskId } });
200
+ }
201
+ }, { kinds: ["message", "receipt", "task"] });
202
+ return record;
203
+ }
204
+
205
+ /**
206
+ * Refuse a request, with a reason.
207
+ *
208
+ * The task returns to unclaimed rather than being deleted: the work is still
209
+ * wanted, it is just not this agent's. Leaving a request pending forever was
210
+ * the only way to say no, and it looks identical to not having read it.
211
+ */
212
+ async function declineTask(input) {
213
+ const session = await workstreams.requireOpenSession(input, "decline a task");
214
+ const now = clock.now();
215
+ let record = null;
216
+ await store.transaction(async tx => {
217
+ const existing = tx.get("task", input.taskId);
218
+ if (existing === null) {
219
+ throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
220
+ }
221
+ record = { ...existing, assigneeParticipantId: null, assigneeSessionId: null,
222
+ state: "pending" };
223
+ tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
224
+ writeWorkResponse(tx, { task: existing, actor: session,
225
+ workspaceId: session.workspaceId, now, ids, outcome: "declined",
226
+ reason: input.reason ?? null });
227
+ // Refusing is also an answer, so the request stops demanding one.
228
+ closeRequestReceipt(tx, { task: existing, actor: session, now, ids });
229
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
230
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
231
+ type: "task.declined", occurredAt: now,
232
+ payload: { taskId: input.taskId, reason: input.reason ?? null } });
233
+ }, { kinds: ["message", "receipt", "task"] });
234
+ return record;
235
+ }
236
+
237
+ return { createTask, claimTask, transitionTask, declineTask };
238
+ }
@@ -0,0 +1,109 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, createId, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ import { ensureMaterialised } from "./materialisation.mjs";
5
+ import { classifySessionPresence } from "./sessions.mjs";
6
+
7
+ // A workstream groups related collaboration. It may have zero or one
8
+ // coordinator lease, and the coordinator plans - it is never the transport,
9
+ // the durable owner, or an information gatekeeper.
10
+ export function createWorkstreamService(ports, sessions) {
11
+ const { store, clock, ids } = ports;
12
+
13
+ async function requireOpenSession(input, action) {
14
+ const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
15
+ if (existing === null || existing.record.state !== "open"
16
+ || existing.record.generation !== input.generation) {
17
+ throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
18
+ { sessionId: input.sessionId });
19
+ }
20
+ return existing.record;
21
+ }
22
+
23
+ async function createWorkstream(input) {
24
+ const session = await requireOpenSession(input, "create a workstream");
25
+ const workspaceId = session.workspaceId;
26
+ await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
27
+ reason: "durable_object" });
28
+ const now = clock.now();
29
+ const workstreamId = createId("workstream");
30
+ const record = validateRecord("workstream", {
31
+ schemaVersion: SCHEMA_VERSION,
32
+ workstreamId,
33
+ workspaceId,
34
+ title: input.title,
35
+ objective: input.objective,
36
+ // A workstream never acquires a coordinator merely by being created.
37
+ coordinatorSessionId: null,
38
+ state: "open",
39
+ createdAt: now,
40
+ });
41
+ await store.transaction(async tx => {
42
+ tx.put("workstream", workstreamId, record);
43
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
44
+ actorSessionId: session.sessionId, type: "workstream.created", occurredAt: now,
45
+ payload: { workstreamId } });
46
+ }, { kinds: ["workstream"] });
47
+ return record;
48
+ }
49
+
50
+ async function acquireCoordinator(input) {
51
+ const session = await requireOpenSession(input, "coordinate");
52
+ const now = clock.now();
53
+ const snapshot = await store.snapshot(session.workspaceId,
54
+ { kinds: ["session", "workstream"] });
55
+ let record = null;
56
+ await store.transaction(async tx => {
57
+ const existing = tx.get("workstream", input.workstreamId);
58
+ if (existing === null) {
59
+ throw new AccError(EXIT.CONFLICT, "the workstream does not exist",
60
+ { workstreamId: input.workstreamId });
61
+ }
62
+ const held = existing.coordinatorSessionId;
63
+ if (held !== null && held !== session.sessionId) {
64
+ const holder = snapshot.sessions.find(item => item.sessionId === held);
65
+ const presence = holder === undefined ? "offline"
66
+ : classifySessionPresence(holder, now);
67
+ // A coordinator lease is replaced only when the holder is genuinely
68
+ // gone or policy says so - not because a peer would like the role.
69
+ if (presence !== "offline" && input.authority !== "human"
70
+ && input.authority !== "policy") {
71
+ throw new AccError(EXIT.CONFLICT, "the workstream already has a coordinator",
72
+ { workstreamId: input.workstreamId, coordinatorSessionId: held,
73
+ coordinatorPresence: presence });
74
+ }
75
+ }
76
+ record = { ...existing, coordinatorSessionId: session.sessionId };
77
+ tx.put("workstream", input.workstreamId, record,
78
+ tx.generationOf("workstream", input.workstreamId));
79
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
80
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
81
+ type: "workstream.coordinator_acquired", occurredAt: now,
82
+ payload: { workstreamId: input.workstreamId, replaced: held } });
83
+ }, { kinds: ["workstream"] });
84
+ return record;
85
+ }
86
+
87
+ async function releaseCoordinator(input) {
88
+ const session = await requireOpenSession(input, "release coordination");
89
+ const now = clock.now();
90
+ let record = null;
91
+ await store.transaction(async tx => {
92
+ const existing = tx.get("workstream", input.workstreamId);
93
+ if (existing === null || existing.coordinatorSessionId !== session.sessionId) {
94
+ throw new AccError(EXIT.CONFLICT, "only the coordinator may release the lease",
95
+ { workstreamId: input.workstreamId });
96
+ }
97
+ record = { ...existing, coordinatorSessionId: null };
98
+ tx.put("workstream", input.workstreamId, record,
99
+ tx.generationOf("workstream", input.workstreamId));
100
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
101
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
102
+ type: "workstream.coordinator_released", occurredAt: now,
103
+ payload: { workstreamId: input.workstreamId } });
104
+ }, { kinds: ["workstream"] });
105
+ return record;
106
+ }
107
+
108
+ return { createWorkstream, acquireCoordinator, releaseCoordinator, requireOpenSession };
109
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@agents-can-communicate/hook-runner",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/runner.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ]
12
+ }