agents-can-communicate 0.1.18 → 0.2.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 (134) hide show
  1. package/README.md +78 -70
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +94 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +102 -214
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +20 -22
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +20 -22
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +20 -22
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +7 -3
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
  60. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  61. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  68. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
  69. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  70. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  77. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  78. package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  80. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  81. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  83. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  85. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  86. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  87. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  88. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  89. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  90. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  91. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  92. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  93. package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
  94. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  95. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  96. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  97. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  98. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  99. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  100. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  101. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  102. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
  103. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  104. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  105. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  106. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  107. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  108. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  109. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  110. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  111. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  112. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  113. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  114. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  115. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  116. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  117. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  118. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  119. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  120. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  122. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  123. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  124. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  129. package/package.json +19 -1
  130. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  131. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  132. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  133. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  134. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.1.18",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@ export const CONFIG_FILENAME = "acc.workspace.json";
12
12
  // the wrong place for them, and a config carrying them is either a mistake or
13
13
  // an attempt to hand a peer state it would otherwise have to earn.
14
14
  export const RUNTIME_KEYS = Object.freeze(["sessions", "participants", "messages",
15
- "claims", "receipts", "intents", "events", "tokens", "credentials"]);
15
+ "claims", "receipts", "intents", "events", "deliveryBindings", "tokens", "credentials"]);
16
16
 
17
17
  const KNOWN_KEYS = Object.freeze(["schemaVersion", "workspaceId", "displayName",
18
18
  "roots", "policy", "requiredAdapters", "extensions"]);
@@ -0,0 +1,64 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
2
+
3
+ export const VALID_OBLIGATIONS = Object.freeze({
4
+ note: Object.freeze(["none"]),
5
+ question: Object.freeze(["reply"]),
6
+ request: Object.freeze(["reply"]),
7
+ answer: Object.freeze(["none"]),
8
+ decision: Object.freeze(["none", "acknowledge"]),
9
+ handoff: Object.freeze(["none", "acknowledge"]),
10
+ });
11
+
12
+ export const MESSAGE_KINDS = Object.freeze(Object.keys(VALID_OBLIGATIONS));
13
+ // The generic send surface cannot construct reply links or structured handoff
14
+ // payloads. Those two complete shapes belong to reply and finish respectively.
15
+ export const GENERIC_MESSAGE_KINDS = Object.freeze(
16
+ ["note", "question", "request", "decision"]);
17
+ export const OBLIGATIONS = Object.freeze(["none", "acknowledge", "reply"]);
18
+
19
+ const data = (message, details) => {
20
+ throw new AccError(EXIT.DATA, message, details);
21
+ };
22
+
23
+ export function assertMessageSemantics(message) {
24
+ const room = message.toParticipantIds.length === 0;
25
+ if (room && !["note", "decision", "handoff"].includes(message.kind)) {
26
+ data(`a room message cannot have kind ${message.kind}`, { kind: message.kind });
27
+ }
28
+ if (room && message.obligation !== "none") {
29
+ data("a room message must have obligation none", { obligation: message.obligation });
30
+ }
31
+
32
+ const valid = VALID_OBLIGATIONS[message.kind] ?? [];
33
+ if (!valid.includes(message.obligation)) {
34
+ data(`message obligation ${message.obligation} is invalid for ${message.kind}`,
35
+ { kind: message.kind, obligation: message.obligation });
36
+ }
37
+
38
+ const root = message.threadId === message.messageId;
39
+ if (message.kind === "answer" && message.inReplyTo === null) {
40
+ data("an answer requires inReplyTo", { messageId: message.messageId });
41
+ }
42
+ if (message.kind === "answer" && root) {
43
+ data("an answer cannot be a thread root", { messageId: message.messageId });
44
+ }
45
+ if (message.inReplyTo === null && !root) {
46
+ data("a thread root requires threadId to equal messageId",
47
+ { threadId: message.threadId, messageId: message.messageId });
48
+ }
49
+ if (message.inReplyTo !== null && root) {
50
+ data("a thread root must have inReplyTo null", { inReplyTo: message.inReplyTo });
51
+ }
52
+
53
+ if (message.kind === "handoff" && message.handoff === null) {
54
+ data("a handoff message requires a handoff payload", { messageId: message.messageId });
55
+ }
56
+ if (!room && message.kind === "handoff" && message.obligation !== "acknowledge") {
57
+ data("an addressed handoff requires acknowledgement",
58
+ { messageId: message.messageId, obligation: message.obligation });
59
+ }
60
+ if (message.kind !== "handoff" && message.handoff !== null) {
61
+ data(`a ${message.kind} message must have handoff null`, { kind: message.kind });
62
+ }
63
+ return message;
64
+ }
@@ -3,7 +3,10 @@ export { AccError, EXIT, isAccError } from "./errors.mjs";
3
3
  export { assertPortableId, createId } from "./ids.mjs";
4
4
  export { ENVELOPE_VERSION, failure, ok } from "./envelopes.mjs";
5
5
  export { RECORD_KINDS, SCHEMA_VERSION, validateRecord } from "./schema.mjs";
6
+ export { GENERIC_MESSAGE_KINDS, MESSAGE_KINDS, OBLIGATIONS, VALID_OBLIGATIONS,
7
+ assertMessageSemantics }
8
+ from "./conversations.mjs";
6
9
  export { CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, RUNTIME_KEYS, defaultProjectConfig,
7
10
  validateProjectConfig } from "./config.mjs";
8
- export { DELIVERY_STATES, TASK_STATES, advanceDelivery, transitionTask } from "./states.mjs";
11
+ export { RECEIPT_STATES, advanceReceipt } from "./states.mjs";
9
12
  export { assertMatchableResource, normaliseResource } from "./resources.mjs";
@@ -1,8 +1,9 @@
1
+ import { assertMessageSemantics, MESSAGE_KINDS, OBLIGATIONS } from "./conversations.mjs";
1
2
  import { AccError, EXIT } from "./errors.mjs";
2
- import { flag, id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
3
+ import { id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
3
4
  resourceUri, sequence, text, timestamp } from "./fields.mjs";
4
5
 
5
- export const SCHEMA_VERSION = 2;
6
+ export const SCHEMA_VERSION = 3;
6
7
 
7
8
  const line = text();
8
9
  const prose = text({ max: 4000, multiline: true });
@@ -14,10 +15,22 @@ const artifactKind = oneOf("file", "git", "url", "report", "image", "data");
14
15
  /** @typedef {{ cursor: string, events: AccEvent[] }} EventPage */
15
16
  /** @typedef {{ kind: string, priority: number, sourceId: string, summary: string }} AttentionItem */
16
17
  /** @typedef {{ workspace: object, participants: object[], sessions: object[],
17
- * intents: object[], workstreams: object[], tasks: object[], claims: object[] }} WorkspaceSnapshot */
18
+ * intents: object[], claims: object[], messages: object[], receipts: object[] }} WorkspaceSnapshot */
19
+
20
+ function closedObject(value, field, fields) {
21
+ plainObject(value, field);
22
+ for (const key of Object.keys(value)) {
23
+ if (!Object.hasOwn(fields, key)) invalid(`${field}.${key}`, "is not a known field", value[key]);
24
+ }
25
+ for (const [key, check] of Object.entries(fields)) {
26
+ if (!Object.hasOwn(value, key)) {
27
+ throw new AccError(EXIT.DATA, `${field} requires ${key}`, { field, key });
28
+ }
29
+ check(value[key], `${field}.${key}`);
30
+ }
31
+ return value;
32
+ }
18
33
 
19
- // Field names stay mappable to the A2A Agent Card, Task, Message, and Artifact
20
- // concepts (spec section 11) without importing any A2A transport.
21
34
  const artifactRef = (value, field) => {
22
35
  plainObject(value, field);
23
36
  const known = new Set(["kind", "uri", "description", "sha256"]);
@@ -33,120 +46,70 @@ const artifactRef = (value, field) => {
33
46
  return value;
34
47
  };
35
48
 
36
- // Every event ACC itself appends. Closed on purpose: `type` used to be free
37
- // text, so a record written by hand validated cleanly and `acc doctor` called
38
- // the store healthy. That is not theoretical - a session that could not run the
39
- // CLI wrote its own events, inventing `task.completed`, and the store reported
40
- // no problem.
49
+ const handoffPayload = (value, field) => closedObject(value, field, {
50
+ status: oneOf("complete", "partial", "blocked"),
51
+ completed: listOf(line),
52
+ remaining: listOf(line),
53
+ blockers: listOf(line),
54
+ verification: listOf(artifactRef),
55
+ });
56
+
41
57
  const EVENT_TYPES = Object.freeze([
42
58
  "workspace.materialised",
43
59
  "session.opened", "session.closed",
44
60
  "intent.published", "intent.cleared",
45
- "workstream.created", "workstream.coordinator_acquired",
46
- "workstream.coordinator_released",
47
- "task.created", "task.claimed", "task.transitioned", "task.unblocked",
48
- "task.declined", "task.released",
49
61
  "claim.acquired", "claim.released", "claim.renewed", "claim.force_released",
50
- "message.sent", "decision.recorded", "handoff.created",
51
- // Delivery transitions and request outcomes are templated from their state,
52
- // so the set has to carry each one they can produce.
53
- ...["recorded", "queued", "injected", "seen", "acknowledged", "failed"]
54
- .map(state => `message.${state}`),
55
- ...["accepted", "declined", "review", "done", "released"]
56
- .map(outcome => `work.${outcome}`),
57
- "work.requested",
62
+ "message.recorded", "message.offered", "message.retrieved", "message.acknowledged",
63
+ "message.offer_succeeded", "message.offer_failed",
58
64
  ]);
59
65
 
60
66
  const eventType = oneOf(...EVENT_TYPES);
67
+ const receiptState = oneOf("queued", "offered", "retrieved", "acknowledged");
61
68
 
62
- const RECORDS = Object.freeze({
69
+ const DURABLE_RECORDS = Object.freeze({
63
70
  workspace: { workspaceId: id, displayName: line, source: oneOf("config", "git", "directory"),
64
71
  roots: listOf(line), createdAt: timestamp },
65
72
 
66
73
  participant: { participantId: id, workspaceId: id, displayName: line,
67
74
  kind: oneOf("agent", "human"), createdAt: timestamp },
68
75
 
69
- // `enforcement` and `lifecycle` are what this session's harness can actually
70
- // do, declared at attach. The harness name does not imply them: the same
71
- // client guards or does not depending on its model and its approval mode, and
72
- // a peer deciding whether to rely on a claim needs the answer, not the brand.
73
- // A workspace spans every worktree of one repository, so the workspace id
74
- // cannot say which checkout a session is sitting in. Recorded at attach from
75
- // what discovery already resolved: without it nobody can tell which worktrees
76
- // have an owner, and asking cannot answer for the agents that are not running
77
- // - which are exactly the ones a clean-up is looking for.
78
76
  session: { sessionId: id, participantId: id, workspaceId: id, generation: id,
79
77
  harness: line, state: oneOf("open", "closed"), parentSessionId: nullable(id),
80
- checkoutRoot: nullable(line), branch: nullable(line),
81
- // The process behind this session, when it can be named. Null means nobody
82
- // knows - no process table, or an ancestry that did not resolve - and is
83
- // read as "judge this one by age alone", never as "dead".
84
- pid: nullable(positiveInteger),
78
+ checkoutRoot: nullable(line), branch: nullable(line), pid: nullable(positiveInteger),
85
79
  enforcement: oneOf("guarded", "advisory"), lifecycle: oneOf("managed", "manual"),
86
80
  heartbeatCadenceMs: positiveInteger, startedAt: timestamp, heartbeatAt: timestamp },
87
81
 
88
82
  intent: { sessionId: id, workspaceId: id, summary,
89
83
  mode: oneOf("observe", "explore", "edit", "review", "coordinate", "wait"),
90
- resourceHints: listOf(resourceUri), workstreamId: nullable(id),
84
+ resourceHints: listOf(resourceUri),
91
85
  state: oneOf("active", "blocked", "waiting", "done"), updatedAt: timestamp },
92
86
 
93
- workstream: { workstreamId: id, workspaceId: id, title: line, objective: prose,
94
- coordinatorSessionId: nullable(id),
95
- state: oneOf("open", "paused", "complete", "cancelled"), createdAt: timestamp },
96
-
97
- // Two assignees, deliberately. `assigneeParticipantId` is who the work is
98
- // for and survives that agent restarting; `assigneeSessionId` is the exact
99
- // session doing it right now and dies with the process. Asking one field to
100
- // be both would either lose the request when a terminal closes or claim a
101
- // dead session is still working.
102
- task: { taskId: id, workstreamId: nullable(id), workspaceId: id, title: line,
103
- state: oneOf("pending", "in_progress", "review", "done", "blocked"),
104
- assigneeParticipantId: nullable(id), assigneeSessionId: nullable(id),
105
- // Who asked. Without it nothing could tell the requester that their work
106
- // was accepted, declined or finished - the task knew who it was for and
107
- // had no idea who was waiting on it.
108
- requestedByParticipantId: nullable(id),
109
- dependsOn: listOf(id), acceptance: listOf(line), detail: nullable(prose),
110
- createdAt: timestamp },
111
-
112
87
  claim: { claimId: id, workspaceId: id, ownerSessionId: id, resource: resourceUri,
113
88
  mode: oneOf("shared", "exclusive"), enforcement: oneOf("advisory", "guarded"),
114
89
  reason: line, acquiredAt: timestamp, expiresAt: timestamp, generation: id },
115
90
 
116
- // `fromParticipantId` beside the session: a session ends, and the one fact
117
- // that has to outlive it is who was speaking. Resolving the sender by looking
118
- // its session up meant the record could never be retired, and an agent whose
119
- // client had restarted stopped being told about its own unanswered question.
120
- message: { messageId: id, workspaceId: id, fromSessionId: id,
121
- fromParticipantId: id, toParticipantIds: listOf(id),
122
- type: oneOf("note", "question", "answer", "contract_request", "contract_response",
123
- "decision_proposal", "decision_result", "blocker", "review_request",
124
- "review_result", "handoff", "work_request", "work_response"),
125
- subject: line, body: prose, priority: oneOf("low", "normal", "high", "urgent"),
126
- workstreamId: nullable(id), taskId: nullable(id), inReplyTo: nullable(id),
127
- requiresAck: flag, artifacts: listOf(artifactRef), sentAt: timestamp },
91
+ message: { messageId: id, threadId: id, clientMessageId: id, workspaceId: id,
92
+ fromParticipantId: id, fromSessionId: id, toParticipantIds: listOf(id),
93
+ kind: oneOf(...MESSAGE_KINDS), obligation: oneOf(...OBLIGATIONS),
94
+ subject: line, body: prose, inReplyTo: nullable(id), artifacts: listOf(artifactRef),
95
+ handoff: nullable(handoffPayload), sentAt: timestamp },
128
96
 
129
97
  receipt: { messageId: id, workspaceId: id, recipientParticipantId: id,
130
- state: oneOf("recorded", "queued", "injected", "seen", "acknowledged", "failed"),
131
- updatedAt: timestamp },
132
-
133
- decision: { decisionId: id, workspaceId: id, workstreamId: nullable(id), title: line,
134
- outcome: prose, authority: oneOf("human", "workstream", "policy"),
135
- decidedBy: listOf(id), evidence: listOf(artifactRef), supersedes: nullable(id),
136
- decidedAt: timestamp },
137
-
138
- artifact: { kind: artifactKind, uri: resourceUri, description: line },
139
-
140
- handoff: { handoffId: id, workspaceId: id, fromSessionId: id, toParticipantId: nullable(id),
141
- goal: line, status: oneOf("complete", "partial", "blocked"), completed: listOf(line),
142
- remaining: listOf(line), blockers: listOf(line), claimsToRelease: listOf(resourceUri),
143
- verification: listOf(artifactRef), artifacts: listOf(artifactRef), createdAt: timestamp },
98
+ state: receiptState, updatedAt: timestamp },
144
99
 
145
100
  event: { sequence, eventId: id, workspaceId: id, actorSessionId: id, type: eventType,
146
101
  occurredAt: timestamp, payload: plainObject },
147
102
  });
148
103
 
149
- export const RECORD_KINDS = Object.freeze(Object.keys(RECORDS));
104
+ const RECORDS = Object.freeze({
105
+ ...DURABLE_RECORDS,
106
+ deliveryBinding: { sessionId: id, generation: id, adapterId: id, clientVersion: line,
107
+ availableModes: listOf(oneOf("nextTurn", "livePush", "replyRoute")),
108
+ livePolicy: oneOf("off", "actionable", "all"), opaqueEndpointRef: prose,
109
+ leaseUntil: timestamp },
110
+ });
111
+
112
+ export const RECORD_KINDS = Object.freeze(Object.keys(DURABLE_RECORDS));
150
113
 
151
114
  export function validateRecord(kind, value) {
152
115
  const fields = RECORDS[kind];
@@ -161,9 +124,7 @@ export function validateRecord(kind, value) {
161
124
  }
162
125
  for (const key of Object.keys(value)) {
163
126
  if (key === "schemaVersion" || key === "extensions") continue;
164
- if (!Object.hasOwn(fields, key)) {
165
- invalid(`${kind}.${key}`, "is not a known field", value[key]);
166
- }
127
+ if (!Object.hasOwn(fields, key)) invalid(`${kind}.${key}`, "is not a known field", value[key]);
167
128
  }
168
129
  for (const [field, check] of Object.entries(fields)) {
169
130
  if (!Object.hasOwn(value, field)) {
@@ -171,9 +132,7 @@ export function validateRecord(kind, value) {
171
132
  }
172
133
  check(value[field], field);
173
134
  }
174
- // Forward-compatible metadata is tolerated only inside a named container, so
175
- // an older reader can round-trip a newer writer's record without guessing
176
- // which unknown top-level keys are safe.
177
135
  if (Object.hasOwn(value, "extensions")) plainObject(value.extensions, "extensions");
136
+ if (kind === "message") assertMessageSemantics(value);
178
137
  return value;
179
138
  }
@@ -1,55 +1,28 @@
1
1
  import { AccError, EXIT } from "./errors.mjs";
2
2
 
3
- // recorded -> queued -> injected -> seen -> acknowledged, with failed branching
4
- // off before the message was ever exposed. States are monotonic: one
5
- // recipient's receipt can only move forwards, and never rewrites another
6
- // recipient's state.
7
- export const DELIVERY_STATES = Object.freeze(
8
- ["recorded", "queued", "injected", "seen", "acknowledged", "failed"]);
3
+ export const RECEIPT_STATES = Object.freeze(
4
+ ["queued", "offered", "retrieved", "acknowledged"]);
9
5
 
10
- const DELIVERY_NEXT = Object.freeze({
11
- recorded: ["queued", "injected", "seen", "acknowledged", "failed"],
12
- queued: ["injected", "seen", "acknowledged", "failed"],
13
- injected: ["seen", "acknowledged"],
14
- seen: ["acknowledged"],
6
+ const RECEIPT_NEXT = Object.freeze({
7
+ queued: ["offered", "retrieved", "acknowledged"],
8
+ offered: ["retrieved", "acknowledged"],
9
+ retrieved: ["acknowledged"],
15
10
  acknowledged: [],
16
- failed: [],
17
11
  });
18
12
 
19
- export const TASK_STATES = Object.freeze(
20
- ["pending", "in_progress", "review", "done", "blocked"]);
21
-
22
- const TASK_NEXT = Object.freeze({
23
- pending: ["in_progress", "blocked"],
24
- in_progress: ["review", "done", "blocked"],
25
- review: ["in_progress", "done", "blocked"],
26
- blocked: ["pending", "in_progress"],
27
- done: [],
28
- });
29
-
30
- function step(machine, allowed, label, current, next) {
31
- if (!machine.includes(current)) {
32
- throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(current)}`,
13
+ export function advanceReceipt(current, next) {
14
+ if (!RECEIPT_STATES.includes(current)) {
15
+ throw new AccError(EXIT.DATA, `unknown receipt state: ${String(current)}`,
33
16
  { current, next });
34
17
  }
35
- if (!machine.includes(next)) {
36
- throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(next)}`,
18
+ if (!RECEIPT_STATES.includes(next)) {
19
+ throw new AccError(EXIT.DATA, `unknown receipt state: ${String(next)}`,
37
20
  { current, next });
38
21
  }
39
- // Re-declaring the current state is idempotent. Adapters retry at safe
40
- // points, and a repeated receipt is not a protocol violation.
41
22
  if (current === next) return next;
42
- if (!allowed[current].includes(next)) {
23
+ if (!RECEIPT_NEXT[current].includes(next)) {
43
24
  throw new AccError(EXIT.CONFLICT,
44
- `illegal ${label} transition from ${current} to ${next}`, { current, next });
25
+ `illegal receipt transition from ${current} to ${next}`, { current, next });
45
26
  }
46
27
  return next;
47
28
  }
48
-
49
- export function advanceDelivery(current, next) {
50
- return step(DELIVERY_STATES, DELIVERY_NEXT, "delivery", current, next);
51
- }
52
-
53
- export function transitionTask(current, next) {
54
- return step(TASK_STATES, TASK_NEXT, "task", current, next);
55
- }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.18",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,230 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
6
+
7
+ import { encode, publishAtomic } from "./atomic-json.mjs";
8
+ import { withRegularNoFollow } from "./safe-file.mjs";
9
+
10
+ const ACTIVE_JOURNAL_VERSION = 2;
11
+ const AUTHORITY_BYTES_LIMIT = 1024;
12
+ const GENERATION_WIDTH = 16;
13
+ const READ_ATTEMPTS = 4;
14
+
15
+ const data = (message, details) => {
16
+ throw new AccError(EXIT.DATA, message, details);
17
+ };
18
+
19
+ export function activeJournalPath(paths, slot) {
20
+ if (slot !== 0 && slot !== 1) data("invalid active journal slot", { slot });
21
+ return path.join(paths.journal, `active.${slot}`);
22
+ }
23
+
24
+ function exactKeys(value, expected, filePath) {
25
+ if (value === null || typeof value !== "object" || Array.isArray(value)
26
+ || Object.keys(value).sort().join("\0") !== [...expected].sort().join("\0")) {
27
+ data("active journal record is not closed", { filePath });
28
+ }
29
+ }
30
+
31
+ function checksum(record) {
32
+ return createHash("sha256").update(JSON.stringify(record)).digest("hex");
33
+ }
34
+
35
+ function encodeAuthority(record) {
36
+ return encode({ activeJournalChecksum: checksum(record), record });
37
+ }
38
+
39
+ function validateActiveRecord(record, filePath) {
40
+ const base = ["activeJournalVersion", "generation", "previousChecksum", "state"];
41
+ if (record?.state === "idle") exactKeys(record, base, filePath);
42
+ else if (record?.state === "open") {
43
+ exactKeys(record, [...base, "transactionId", "firstSequence"], filePath);
44
+ } else data("invalid active journal state", { filePath, state: record?.state });
45
+
46
+ if (record.activeJournalVersion !== ACTIVE_JOURNAL_VERSION) {
47
+ data("unknown active journal version", { filePath,
48
+ activeJournalVersion: record.activeJournalVersion });
49
+ }
50
+ if (typeof record.generation !== "string"
51
+ || !/^\d{16}$/.test(record.generation)) {
52
+ data("invalid active journal generation", { filePath, generation: record.generation });
53
+ }
54
+ const generation = BigInt(record.generation);
55
+ if (generation === 0n) {
56
+ if (record.state !== "idle" || record.previousChecksum !== null) {
57
+ data("invalid active journal genesis", { filePath });
58
+ }
59
+ } else if (typeof record.previousChecksum !== "string"
60
+ || !/^[0-9a-f]{64}$/.test(record.previousChecksum)) {
61
+ data("invalid active journal previous checksum", { filePath });
62
+ }
63
+ if (record.state === "open") {
64
+ assertPortableId(record.transactionId, "transaction id");
65
+ if (typeof record.firstSequence !== "string"
66
+ || !/^\d{16}$/.test(record.firstSequence)) {
67
+ data("invalid active journal first sequence", { filePath,
68
+ firstSequence: record.firstSequence });
69
+ }
70
+ }
71
+ return generation;
72
+ }
73
+
74
+ function decodeAuthority(bytes, filePath, slot) {
75
+ let envelope;
76
+ try {
77
+ envelope = JSON.parse(bytes.toString("utf8"));
78
+ } catch (error) {
79
+ data("invalid active journal authority", { filePath, cause: error.message });
80
+ }
81
+ exactKeys(envelope, ["activeJournalChecksum", "record"], filePath);
82
+ const generation = validateActiveRecord(envelope.record, filePath);
83
+ if (Number(generation % 2n) !== slot) {
84
+ data("active journal generation chain is invalid", { filePath,
85
+ generation: envelope.record.generation, slot });
86
+ }
87
+ if (typeof envelope.activeJournalChecksum !== "string"
88
+ || envelope.activeJournalChecksum !== checksum(envelope.record)) {
89
+ data("active journal checksum mismatch", { filePath });
90
+ }
91
+ if (!bytes.equals(encodeAuthority(envelope.record))) {
92
+ data("active journal authority is not canonical", { filePath });
93
+ }
94
+ return { checksum: envelope.activeJournalChecksum, generation,
95
+ record: envelope.record, slot };
96
+ }
97
+
98
+ async function readSlotBytes(paths, root, slot, openFile) {
99
+ const filePath = activeJournalPath(paths, slot);
100
+ try {
101
+ return await withRegularNoFollow(filePath, root, constants.O_RDONLY,
102
+ async (handle, stat) => {
103
+ if (stat.size < 1 || stat.size > AUTHORITY_BYTES_LIMIT) {
104
+ data("invalid active journal authority size", { filePath, size: stat.size });
105
+ }
106
+ return handle.readFile();
107
+ }, openFile);
108
+ } catch (error) {
109
+ if (error.code === "ENOENT") return null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ function sameBytes(left, right) {
115
+ if (left === null || right === null) return left === right;
116
+ return left.equals(right);
117
+ }
118
+
119
+ // Slot zero is read twice. Because writers alternate slots and generations
120
+ // never repeat, equal reads prove that the pair existed at one instant even if
121
+ // another process published between descriptor opens.
122
+ async function readStableSlots(paths, root, openFile) {
123
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
124
+ const slot0 = await readSlotBytes(paths, root, 0, openFile);
125
+ const slot1 = await readSlotBytes(paths, root, 1, openFile);
126
+ const slot0Confirmation = await readSlotBytes(paths, root, 0, openFile);
127
+ if (sameBytes(slot0, slot0Confirmation)) return [slot0, slot1];
128
+ }
129
+ throw new AccError(EXIT.CONFLICT, "active journal changed while being read", {});
130
+ }
131
+
132
+ function validateTransition(current, previous, filePath) {
133
+ const expected = previous.state === "idle" ? "open" : "idle";
134
+ if (current.state !== expected) {
135
+ data("invalid active journal transition", { filePath,
136
+ previous: previous.state, current: current.state });
137
+ }
138
+ }
139
+
140
+ function compareGenerations(left, right) {
141
+ if (left.generation < right.generation) return -1;
142
+ if (left.generation > right.generation) return 1;
143
+ return 0;
144
+ }
145
+
146
+ function selectAuthority(paths, bytes) {
147
+ // Decode every present slot before selection. Falling back from a corrupt
148
+ // latest slot to an older valid peer would turn corruption into rollback.
149
+ const found = bytes.map((value, slot) => value === null
150
+ ? null
151
+ : decodeAuthority(value, activeJournalPath(paths, slot), slot)).filter(Boolean);
152
+ if (found.length === 0) return null;
153
+ if (found.length === 1) {
154
+ const [only] = found;
155
+ if (only.slot === 0 && only.generation === 0n) return only;
156
+ data("active journal authority peer is missing", { slot: only.slot,
157
+ generation: only.record.generation });
158
+ }
159
+ found.sort(compareGenerations);
160
+ const [previous, current] = found;
161
+ if (current.generation !== previous.generation + 1n) {
162
+ data("active journal generation chain is invalid", {
163
+ previous: previous.record.generation, current: current.record.generation });
164
+ }
165
+ if (current.record.previousChecksum !== previous.checksum) {
166
+ data("active journal checksum chain is invalid", { slot: current.slot });
167
+ }
168
+ validateTransition(current.record, previous.record,
169
+ activeJournalPath(paths, current.slot));
170
+ return current;
171
+ }
172
+
173
+ async function readCurrent(paths, root, openFile) {
174
+ return selectAuthority(paths, await readStableSlots(paths, root, openFile));
175
+ }
176
+
177
+ const genesis = () => ({ activeJournalVersion: ACTIVE_JOURNAL_VERSION,
178
+ generation: "0".repeat(GENERATION_WIDTH), previousChecksum: null, state: "idle" });
179
+
180
+ export async function initialiseActiveJournal(paths, options) {
181
+ const current = await readCurrent(paths, options.root);
182
+ if (current !== null) return current.record;
183
+ try {
184
+ await publishAtomic(activeJournalPath(paths, 0), encodeAuthority(genesis()), options);
185
+ } catch (error) {
186
+ if (error.code !== EXIT.CONFLICT) throw error;
187
+ }
188
+ return readActiveJournal(paths, options.root);
189
+ }
190
+
191
+ export async function readActiveJournal(paths, root, openFile) {
192
+ const current = await readCurrent(paths, root, openFile);
193
+ if (current === null) data("active journal has no authority", { journal: paths.journal });
194
+ return current.record;
195
+ }
196
+
197
+ function nextGeneration(current) {
198
+ const next = current.generation + 1n;
199
+ const generation = next.toString().padStart(GENERATION_WIDTH, "0");
200
+ if (generation.length !== GENERATION_WIDTH) {
201
+ data("active journal generation is exhausted", { current: current.record.generation });
202
+ }
203
+ return generation;
204
+ }
205
+
206
+ async function appendTransition(paths, options, fields, expected) {
207
+ const current = await readCurrent(paths, options.root, options.openFile);
208
+ if (current === null || !expected(current.record)) {
209
+ throw new AccError(EXIT.CONFLICT, "active journal transition conflicts",
210
+ { current: current?.record ?? null, next: fields });
211
+ }
212
+ const record = { activeJournalVersion: ACTIVE_JOURNAL_VERSION,
213
+ generation: nextGeneration(current), previousChecksum: current.checksum, ...fields };
214
+ validateActiveRecord(record, activeJournalPath(paths, 1 - current.slot));
215
+ await publishAtomic(activeJournalPath(paths, 1 - current.slot), encodeAuthority(record),
216
+ { ...options, tmpDir: options.tmpDir ?? paths.tmp ?? path.join(options.root, "tmp"),
217
+ replace: true });
218
+ return record;
219
+ }
220
+
221
+ export function activateJournal(paths, options, entry) {
222
+ return appendTransition(paths, options, { state: "open",
223
+ transactionId: entry.transactionId, firstSequence: entry.firstSequence },
224
+ current => current.state === "idle");
225
+ }
226
+
227
+ export function idleJournal(paths, options, transactionId) {
228
+ return appendTransition(paths, options, { state: "idle" },
229
+ current => current.state === "open" && current.transactionId === transactionId);
230
+ }