agents-can-communicate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, advanceDelivery, createId, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { ensureMaterialised } from "./materialisation.mjs";
|
|
5
|
+
import { assertKnownParticipants } from "./participants.mjs";
|
|
6
|
+
import { writeTask } from "./tasks.mjs";
|
|
7
|
+
|
|
8
|
+
const receiptId = (messageId, recipient) => `${messageId}--${recipient}`;
|
|
9
|
+
|
|
10
|
+
// Peer content is data, never authority. Messages carry attribution and a
|
|
11
|
+
// typed intent; nothing in a body can change policy or promote a proposal.
|
|
12
|
+
export function createCommunicationService(ports, sessions, claims) {
|
|
13
|
+
const { store, clock, ids } = ports;
|
|
14
|
+
|
|
15
|
+
async function requireOpenSession(input, action) {
|
|
16
|
+
const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
17
|
+
if (existing === null || existing.record.state !== "open"
|
|
18
|
+
|| existing.record.generation !== input.generation) {
|
|
19
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
|
|
20
|
+
{ sessionId: input.sessionId });
|
|
21
|
+
}
|
|
22
|
+
return existing.record;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function sendMessage(input) {
|
|
26
|
+
const session = await requireOpenSession(input, "send a message");
|
|
27
|
+
const workspaceId = session.workspaceId;
|
|
28
|
+
await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
|
|
29
|
+
reason: "durable_object" });
|
|
30
|
+
const now = clock.now();
|
|
31
|
+
const messageId = createId("message");
|
|
32
|
+
const recipients = input.toParticipantIds ?? [];
|
|
33
|
+
if (recipients.length === 0) {
|
|
34
|
+
throw new AccError(EXIT.USAGE, "a message needs at least one recipient", { messageId });
|
|
35
|
+
}
|
|
36
|
+
const record = validateRecord("message", {
|
|
37
|
+
schemaVersion: SCHEMA_VERSION,
|
|
38
|
+
messageId,
|
|
39
|
+
workspaceId,
|
|
40
|
+
fromSessionId: session.sessionId,
|
|
41
|
+
fromParticipantId: session.participantId,
|
|
42
|
+
toParticipantIds: recipients,
|
|
43
|
+
type: input.type,
|
|
44
|
+
subject: input.subject,
|
|
45
|
+
body: input.body,
|
|
46
|
+
priority: input.priority ?? "normal",
|
|
47
|
+
workstreamId: input.workstreamId ?? null,
|
|
48
|
+
taskId: input.taskId ?? null,
|
|
49
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
50
|
+
requiresAck: input.requiresAck === true,
|
|
51
|
+
artifacts: input.artifacts ?? [],
|
|
52
|
+
sentAt: now,
|
|
53
|
+
});
|
|
54
|
+
// After the record is validated, so a body carrying control characters is
|
|
55
|
+
// refused for what it is rather than for who it was addressed to.
|
|
56
|
+
await assertKnownParticipants(store, workspaceId, recipients);
|
|
57
|
+
|
|
58
|
+
await store.transaction(async tx => {
|
|
59
|
+
tx.put("message", messageId, record);
|
|
60
|
+
// One receipt per recipient: a receipt from one recipient can never move
|
|
61
|
+
// another recipient's state.
|
|
62
|
+
for (const recipient of recipients) {
|
|
63
|
+
tx.put("receipt", receiptId(messageId, recipient), validateRecord("receipt", {
|
|
64
|
+
schemaVersion: SCHEMA_VERSION,
|
|
65
|
+
messageId,
|
|
66
|
+
workspaceId,
|
|
67
|
+
recipientParticipantId: recipient,
|
|
68
|
+
state: "queued",
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
73
|
+
actorSessionId: session.sessionId, type: "message.sent", occurredAt: now,
|
|
74
|
+
payload: { messageId, type: record.type, recipients } });
|
|
75
|
+
}, { kinds: ["message", "receipt"] });
|
|
76
|
+
return record;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function markDelivery(input) {
|
|
80
|
+
const session = await requireOpenSession(input, "update delivery");
|
|
81
|
+
// Your own receipt, unless you say otherwise and mean it. A session
|
|
82
|
+
// advancing another participant's receipt would be reporting that someone
|
|
83
|
+
// else had read something.
|
|
84
|
+
const recipient = input.recipientParticipantId ?? session.participantId;
|
|
85
|
+
if (recipient !== session.participantId) {
|
|
86
|
+
throw new AccError(EXIT.CONFLICT, "a session can only mark its own receipt",
|
|
87
|
+
{ recipient, participantId: session.participantId });
|
|
88
|
+
}
|
|
89
|
+
const now = clock.now();
|
|
90
|
+
let record = null;
|
|
91
|
+
await store.transaction(async tx => {
|
|
92
|
+
const id = receiptId(input.messageId, recipient);
|
|
93
|
+
const existing = tx.get("receipt", id);
|
|
94
|
+
if (existing === null) {
|
|
95
|
+
throw new AccError(EXIT.DATA, "no receipt exists for that recipient", { id });
|
|
96
|
+
}
|
|
97
|
+
// Monotonic by construction: the protocol state machine refuses to move
|
|
98
|
+
// backwards, so an acknowledgement can never be downgraded to seen.
|
|
99
|
+
record = { ...existing, state: advanceDelivery(existing.state, input.state),
|
|
100
|
+
updatedAt: now };
|
|
101
|
+
tx.put("receipt", id, record, tx.generationOf("receipt", id));
|
|
102
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
103
|
+
workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
|
|
104
|
+
type: `message.${record.state}`, occurredAt: now,
|
|
105
|
+
payload: { messageId: input.messageId,
|
|
106
|
+
recipientParticipantId: recipient } });
|
|
107
|
+
}, { kinds: ["receipt"] });
|
|
108
|
+
return record;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function recordDecision(input) {
|
|
112
|
+
const session = await requireOpenSession(input, "record a decision");
|
|
113
|
+
const workspaceId = session.workspaceId;
|
|
114
|
+
await ensureMaterialised(ports, { workspaceId, reason: "durable_object" });
|
|
115
|
+
// A peer proposal never becomes a human-authority decision on its own.
|
|
116
|
+
if (input.authority === "human" && input.humanConfirmed !== true) {
|
|
117
|
+
throw new AccError(EXIT.CONFLICT,
|
|
118
|
+
"human authority requires an explicit human confirmation", { authority: "human" });
|
|
119
|
+
}
|
|
120
|
+
const now = clock.now();
|
|
121
|
+
const decisionId = createId("decision");
|
|
122
|
+
const record = validateRecord("decision", {
|
|
123
|
+
schemaVersion: SCHEMA_VERSION,
|
|
124
|
+
decisionId,
|
|
125
|
+
workspaceId,
|
|
126
|
+
workstreamId: input.workstreamId ?? null,
|
|
127
|
+
title: input.title,
|
|
128
|
+
outcome: input.outcome,
|
|
129
|
+
authority: input.authority,
|
|
130
|
+
decidedBy: input.decidedBy ?? [session.participantId],
|
|
131
|
+
evidence: input.evidence ?? [],
|
|
132
|
+
supersedes: input.supersedes ?? null,
|
|
133
|
+
decidedAt: now,
|
|
134
|
+
});
|
|
135
|
+
await store.transaction(async tx => {
|
|
136
|
+
if (record.supersedes !== null && tx.get("decision", record.supersedes) === null) {
|
|
137
|
+
throw new AccError(EXIT.DATA, "the superseded decision does not exist",
|
|
138
|
+
{ supersedes: record.supersedes });
|
|
139
|
+
}
|
|
140
|
+
tx.put("decision", decisionId, record);
|
|
141
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
142
|
+
actorSessionId: session.sessionId, type: "decision.recorded", occurredAt: now,
|
|
143
|
+
payload: { decisionId, authority: record.authority } });
|
|
144
|
+
}, { kinds: ["decision"] });
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Produce a handoff and release what this session owned. The semantic summary
|
|
150
|
+
* is written while the model is still active; session end only closes
|
|
151
|
+
* lifecycle ownership.
|
|
152
|
+
*/
|
|
153
|
+
/**
|
|
154
|
+
* What the successor needs, in the order they need it: what this was for, how
|
|
155
|
+
* far it got, what is left, and what is in the way.
|
|
156
|
+
*/
|
|
157
|
+
function describeHandoff(record) {
|
|
158
|
+
const section = (label, items) => (items.length === 0
|
|
159
|
+
? [] : [`${label}:`, ...items.map(item => `- ${item}`)]);
|
|
160
|
+
return [
|
|
161
|
+
// The goal is the subject; repeating it here costs a line of everyone's
|
|
162
|
+
// turn to say the same thing twice.
|
|
163
|
+
record.status,
|
|
164
|
+
...section("done", record.completed),
|
|
165
|
+
// Not "left": the status line above already uses that word for how far
|
|
166
|
+
// this got, and the two meanings sat one line apart.
|
|
167
|
+
...section("still to do", record.remaining),
|
|
168
|
+
...section("in the way", record.blockers),
|
|
169
|
+
...section("released", record.claimsToRelease),
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function finishSession(input) {
|
|
174
|
+
const session = await requireOpenSession(input, "finish");
|
|
175
|
+
const workspaceId = session.workspaceId;
|
|
176
|
+
await ensureMaterialised(ports, { workspaceId, reason: "durable_object" });
|
|
177
|
+
const now = clock.now();
|
|
178
|
+
const handoffId = createId("handoff");
|
|
179
|
+
const owned = (await store.snapshot(workspaceId, { kinds: ["claim"] })).claims
|
|
180
|
+
.filter(claim => claim.ownerSessionId === session.sessionId);
|
|
181
|
+
const successor = input.toParticipantId ?? null;
|
|
182
|
+
// The same rule as addressing anything else: handing your work to a name
|
|
183
|
+
// nobody has is a typo, and the summary is the last thing this session will
|
|
184
|
+
// ever say.
|
|
185
|
+
if (successor !== null) await assertKnownParticipants(store, workspaceId, [successor]);
|
|
186
|
+
const record = validateRecord("handoff", {
|
|
187
|
+
schemaVersion: SCHEMA_VERSION,
|
|
188
|
+
handoffId,
|
|
189
|
+
workspaceId,
|
|
190
|
+
fromSessionId: session.sessionId,
|
|
191
|
+
toParticipantId: successor,
|
|
192
|
+
goal: input.goal,
|
|
193
|
+
status: input.status ?? "partial",
|
|
194
|
+
completed: input.completed ?? [],
|
|
195
|
+
remaining: input.remaining ?? [],
|
|
196
|
+
blockers: input.blockers ?? [],
|
|
197
|
+
claimsToRelease: owned.map(claim => claim.resource),
|
|
198
|
+
verification: input.verification ?? [],
|
|
199
|
+
artifacts: input.artifacts ?? [],
|
|
200
|
+
createdAt: now,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await store.transaction(async tx => {
|
|
204
|
+
tx.put("handoff", handoffId, record);
|
|
205
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
206
|
+
actorSessionId: session.sessionId, type: "handoff.created", occurredAt: now,
|
|
207
|
+
payload: { handoffId, released: record.claimsToRelease } });
|
|
208
|
+
}, { kinds: ["handoff"] });
|
|
209
|
+
|
|
210
|
+
// A handoff nobody is told about is a note to the store. Written, durable,
|
|
211
|
+
// and reaching the agent it names through nothing at all: not their turn,
|
|
212
|
+
// not their attention, not `acc status` - only a full snapshot they would
|
|
213
|
+
// have to scan for their own name. The message type for this has been in the
|
|
214
|
+
// schema all along, and this is the half that was never built.
|
|
215
|
+
if (successor !== null) {
|
|
216
|
+
await sendMessage({
|
|
217
|
+
sessionId: session.sessionId, generation: input.generation,
|
|
218
|
+
toParticipantIds: [successor], type: "handoff",
|
|
219
|
+
subject: `handing over: ${record.goal}`,
|
|
220
|
+
body: describeHandoff(record),
|
|
221
|
+
descriptor: input.descriptor,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const claim of owned) {
|
|
226
|
+
await claims.releaseClaim({ claimId: claim.claimId, sessionId: session.sessionId,
|
|
227
|
+
generation: session.generation, workspaceId });
|
|
228
|
+
}
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Messages addressed to this participant that nothing has shown a model yet.
|
|
234
|
+
*
|
|
235
|
+
* `queued` is where `sendMessage` leaves a receipt. Anything further along has
|
|
236
|
+
* already been put in front of the recipient, and delivery states only move
|
|
237
|
+
* forward - so re-injecting would misreport what happened rather than repeat
|
|
238
|
+
* harmlessly.
|
|
239
|
+
*
|
|
240
|
+
* A session never receives its own message back. Another session of the same
|
|
241
|
+
* participant does, because it is a different reader.
|
|
242
|
+
*/
|
|
243
|
+
async function pendingMessages(input = {}) {
|
|
244
|
+
const participantId = input.participantId;
|
|
245
|
+
if (typeof participantId !== "string" || participantId === "") return [];
|
|
246
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
247
|
+
// Messages and receipts are the two unbounded kinds and this read needs
|
|
248
|
+
// both. Nothing else, though, and it runs in front of every turn.
|
|
249
|
+
const snapshot = await store.snapshot(workspaceId,
|
|
250
|
+
{ kinds: ["message", "receipt"] });
|
|
251
|
+
const waiting = new Set((snapshot.receipts ?? [])
|
|
252
|
+
.filter(receipt => receipt.recipientParticipantId === participantId
|
|
253
|
+
&& (receipt.state === "queued" || receipt.state === "recorded"))
|
|
254
|
+
.map(receipt => receipt.messageId));
|
|
255
|
+
return (snapshot.messages ?? [])
|
|
256
|
+
.filter(message => waiting.has(message.messageId)
|
|
257
|
+
&& message.fromSessionId !== input.exceptSessionId)
|
|
258
|
+
// Oldest first, and the id breaks ties so two messages sent in the same
|
|
259
|
+
// millisecond still project in a stable order.
|
|
260
|
+
.sort((left, right) => left.sentAt.localeCompare(right.sentAt)
|
|
261
|
+
|| left.messageId.localeCompare(right.messageId));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Ask another agent to do something.
|
|
266
|
+
*
|
|
267
|
+
* One write, because the two halves are useless apart. A task addressed to
|
|
268
|
+
* someone who was never told about it is work nobody knows exists, and a
|
|
269
|
+
* message describing work that was never recorded is a request with nothing
|
|
270
|
+
* to point at.
|
|
271
|
+
*
|
|
272
|
+
* The recipient learns about it twice over, and both paths already existed:
|
|
273
|
+
* the task raises a `task_unblocked` attention item for the participant it
|
|
274
|
+
* names, and the message reaches their turn as quoted peer text.
|
|
275
|
+
*/
|
|
276
|
+
async function requestWork(input) {
|
|
277
|
+
const session = await requireOpenSession(input, "request work");
|
|
278
|
+
const workspaceId = session.workspaceId;
|
|
279
|
+
const recipient = input.toParticipantId;
|
|
280
|
+
if (typeof recipient !== "string" || recipient === "") {
|
|
281
|
+
throw new AccError(EXIT.USAGE, "a request needs a recipient participant");
|
|
282
|
+
}
|
|
283
|
+
await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
|
|
284
|
+
reason: "durable_object" });
|
|
285
|
+
await assertKnownParticipants(store, workspaceId, [recipient]);
|
|
286
|
+
const now = clock.now();
|
|
287
|
+
const messageId = createId("message");
|
|
288
|
+
let task = null;
|
|
289
|
+
let message = null;
|
|
290
|
+
|
|
291
|
+
await store.transaction(async tx => {
|
|
292
|
+
task = writeTask(tx, { session, workspaceId, now, ids,
|
|
293
|
+
input: { ...input, assigneeParticipantId: recipient,
|
|
294
|
+
requestedByParticipantId: session.participantId } });
|
|
295
|
+
message = validateRecord("message", {
|
|
296
|
+
schemaVersion: SCHEMA_VERSION,
|
|
297
|
+
messageId,
|
|
298
|
+
workspaceId,
|
|
299
|
+
fromSessionId: session.sessionId,
|
|
300
|
+
fromParticipantId: session.participantId,
|
|
301
|
+
toParticipantIds: [recipient],
|
|
302
|
+
type: "work_request",
|
|
303
|
+
subject: input.title,
|
|
304
|
+
body: input.detail ?? input.title,
|
|
305
|
+
priority: input.priority ?? "normal",
|
|
306
|
+
workstreamId: task.workstreamId,
|
|
307
|
+
taskId: task.taskId,
|
|
308
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
309
|
+
// A request is a question, so the sender is entitled to know whether it
|
|
310
|
+
// was taken up. Silence and refusal are different answers.
|
|
311
|
+
requiresAck: true,
|
|
312
|
+
artifacts: input.artifacts ?? [],
|
|
313
|
+
sentAt: now,
|
|
314
|
+
});
|
|
315
|
+
tx.put("message", messageId, message);
|
|
316
|
+
tx.put("receipt", receiptId(messageId, recipient), validateRecord("receipt", {
|
|
317
|
+
schemaVersion: SCHEMA_VERSION,
|
|
318
|
+
messageId,
|
|
319
|
+
workspaceId,
|
|
320
|
+
recipientParticipantId: recipient,
|
|
321
|
+
state: "queued",
|
|
322
|
+
updatedAt: now,
|
|
323
|
+
}));
|
|
324
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
325
|
+
actorSessionId: session.sessionId, type: "work.requested", occurredAt: now,
|
|
326
|
+
payload: { taskId: task.taskId, messageId, recipient } });
|
|
327
|
+
// `writeTask` runs on this handle, so what it reads is read here.
|
|
328
|
+
}, { kinds: ["message", "receipt", "session", "task", "workstream"] });
|
|
329
|
+
return { task, message };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { sendMessage, markDelivery, pendingMessages, requestWork, recordDecision,
|
|
333
|
+
finishSession };
|
|
334
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Domain services over injected ports. No vendor, Git, or project knowledge.
|
|
2
|
+
export { createCoordinationService } from "./service.mjs";
|
|
3
|
+
export { assertPorts } from "./ports.mjs";
|
|
4
|
+
export { classifySessionPresence } from "./sessions.mjs";
|
|
5
|
+
export { ATTENTION_PRIORITY, computeAttention } from "./sync.mjs";
|
|
6
|
+
export { overlaps } from "./claims.mjs";
|
|
7
|
+
export { wouldCycle } from "./tasks.mjs";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { isMaterialised } from "./materialisation.mjs";
|
|
5
|
+
|
|
6
|
+
// Intent answers "what is this session doing now?". It is awareness, not
|
|
7
|
+
// authorisation: an edit intent never substitutes for a claim, which is why
|
|
8
|
+
// resourceHints are advisory strings rather than reservations.
|
|
9
|
+
export function createIntentService(ports, sessions) {
|
|
10
|
+
const { store, clock, ids } = ports;
|
|
11
|
+
|
|
12
|
+
async function requireOwner(sessionId, workspaceId, generation) {
|
|
13
|
+
const existing = await sessions.locateSession(sessionId, workspaceId);
|
|
14
|
+
if (existing === null) {
|
|
15
|
+
throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
16
|
+
}
|
|
17
|
+
if (existing.record.generation !== generation) {
|
|
18
|
+
throw new AccError(EXIT.CONFLICT, "intent belongs to a replaced session generation",
|
|
19
|
+
{ sessionId, expected: generation, actual: existing.record.generation });
|
|
20
|
+
}
|
|
21
|
+
if (existing.record.state !== "open") {
|
|
22
|
+
throw new AccError(EXIT.CONFLICT, "a closed session cannot publish intent", { sessionId });
|
|
23
|
+
}
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function setIntent(input) {
|
|
28
|
+
const owner = await requireOwner(input.sessionId, input.workspaceId, input.generation);
|
|
29
|
+
const workspaceId = owner.record.workspaceId;
|
|
30
|
+
const now = clock.now();
|
|
31
|
+
const intent = validateRecord("intent", {
|
|
32
|
+
schemaVersion: SCHEMA_VERSION,
|
|
33
|
+
sessionId: input.sessionId,
|
|
34
|
+
workspaceId,
|
|
35
|
+
summary: input.summary,
|
|
36
|
+
mode: input.mode,
|
|
37
|
+
resourceHints: input.resourceHints ?? [],
|
|
38
|
+
workstreamId: input.workstreamId ?? null,
|
|
39
|
+
state: input.state ?? "active",
|
|
40
|
+
updatedAt: now,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!await isMaterialised(store, workspaceId)) {
|
|
44
|
+
await store.ephemeral.put("intent", input.sessionId, intent);
|
|
45
|
+
return intent;
|
|
46
|
+
}
|
|
47
|
+
await store.transaction(async tx => {
|
|
48
|
+
tx.put("intent", input.sessionId, intent, tx.generationOf("intent", input.sessionId));
|
|
49
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
50
|
+
actorSessionId: input.sessionId, type: "intent.published", occurredAt: now,
|
|
51
|
+
payload: { mode: intent.mode, state: intent.state } });
|
|
52
|
+
}, { kinds: ["intent"] });
|
|
53
|
+
return intent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function clearIntent(input) {
|
|
57
|
+
const owner = await requireOwner(input.sessionId, input.workspaceId, input.generation);
|
|
58
|
+
const workspaceId = owner.record.workspaceId;
|
|
59
|
+
if (!await isMaterialised(store, workspaceId)) {
|
|
60
|
+
await store.ephemeral.delete("intent", input.sessionId);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const now = clock.now();
|
|
64
|
+
await store.transaction(async tx => {
|
|
65
|
+
const current = tx.get("intent", input.sessionId);
|
|
66
|
+
if (current === null) return;
|
|
67
|
+
tx.put("intent", input.sessionId, { ...current, state: "done", updatedAt: now },
|
|
68
|
+
tx.generationOf("intent", input.sessionId));
|
|
69
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
70
|
+
actorSessionId: input.sessionId, type: "intent.cleared", occurredAt: now, payload: {} });
|
|
71
|
+
}, { kinds: ["intent"] });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { setIntent, clearIntent };
|
|
75
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { SCHEMA_VERSION } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
// Lazy workspace materialisation. Attachment is
|
|
4
|
+
// universal; durable state is not created merely because a session opened
|
|
5
|
+
// somewhere. A lone session writes ephemeral presence and Intent only. The
|
|
6
|
+
// workspace materialises exactly once - at the second live session or the first
|
|
7
|
+
// durable object - and whatever exists ephemerally at that moment is recorded
|
|
8
|
+
// durably in the same transaction.
|
|
9
|
+
//
|
|
10
|
+
// Ephemeral and durable records share one shape, so materialisation is a copy
|
|
11
|
+
// rather than a translation. A translation step is where the two views drift.
|
|
12
|
+
|
|
13
|
+
const EPHEMERAL_KINDS = Object.freeze([
|
|
14
|
+
{ kind: "participant", key: "participantId", event: null },
|
|
15
|
+
{ kind: "session", key: "sessionId", event: "session.opened" },
|
|
16
|
+
{ kind: "intent", key: "sessionId", event: "intent.published" },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export async function isMaterialised(store, workspaceId) {
|
|
20
|
+
// One record answers this, and it is asked before every durable write.
|
|
21
|
+
return (await store.snapshot(workspaceId, { kinds: ["workspace"] })).workspace !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function materialise({ store, clock, ids }, { workspaceId, descriptor, reason }) {
|
|
25
|
+
const now = clock.now();
|
|
26
|
+
const staged = [];
|
|
27
|
+
for (const entry of EPHEMERAL_KINDS) {
|
|
28
|
+
staged.push({ ...entry, records: await store.ephemeral.list(entry.kind) });
|
|
29
|
+
}
|
|
30
|
+
const actorSessionId = staged.find(entry => entry.kind === "session")?.records.at(-1)?.sessionId
|
|
31
|
+
?? "session_bootstrap";
|
|
32
|
+
|
|
33
|
+
await store.transaction(async tx => {
|
|
34
|
+
// Asked again inside the lock. `ensureMaterialised` asks before taking it,
|
|
35
|
+
// which is a check-then-act across processes: agents starting together in a
|
|
36
|
+
// workspace neither had opened - the ordinary way two agents start - all saw
|
|
37
|
+
// "not materialised", and every one but the first was refused with
|
|
38
|
+
// `workspace ... changed under this transaction` and could not attach.
|
|
39
|
+
const already = tx.get("workspace", workspaceId) !== null;
|
|
40
|
+
if (!already) {
|
|
41
|
+
tx.put("workspace", workspaceId, {
|
|
42
|
+
schemaVersion: SCHEMA_VERSION,
|
|
43
|
+
workspaceId,
|
|
44
|
+
displayName: descriptor?.displayName ?? workspaceId,
|
|
45
|
+
source: descriptor?.source ?? "directory",
|
|
46
|
+
roots: descriptor?.roots ?? [],
|
|
47
|
+
createdAt: now,
|
|
48
|
+
});
|
|
49
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
50
|
+
actorSessionId, type: "workspace.materialised", occurredAt: now,
|
|
51
|
+
payload: { reason } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const entry of staged) {
|
|
55
|
+
for (const record of entry.records) {
|
|
56
|
+
// Whoever got here first may have promoted this record already, and
|
|
57
|
+
// promoting is a copy: the ephemeral and durable shapes are the same,
|
|
58
|
+
// so the one already there is the one this would write.
|
|
59
|
+
if (tx.get(entry.kind, record[entry.key]) !== null) continue;
|
|
60
|
+
tx.put(entry.kind, record[entry.key], record);
|
|
61
|
+
if (entry.event === null) continue;
|
|
62
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
63
|
+
actorSessionId: record.sessionId, type: entry.event, occurredAt: now,
|
|
64
|
+
payload: { sessionId: record.sessionId } });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// The promoted kinds are named by the loop above, not written literally in
|
|
68
|
+
// the body, so they are derived rather than repeated: a new ephemeral kind
|
|
69
|
+
// added to that list is read here without anyone remembering to say so.
|
|
70
|
+
}, { kinds: ["workspace", ...EPHEMERAL_KINDS.map(entry => entry.kind)] });
|
|
71
|
+
|
|
72
|
+
// The ephemeral copies are retired only after the durable transaction
|
|
73
|
+
// committed, so a crash in between leaves a recoverable duplicate rather than
|
|
74
|
+
// a hole.
|
|
75
|
+
for (const entry of staged) {
|
|
76
|
+
for (const record of entry.records) {
|
|
77
|
+
await store.ephemeral.delete(entry.kind, record[entry.key]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function ensureMaterialised(ports, { workspaceId, descriptor, reason }) {
|
|
83
|
+
if (await isMaterialised(ports.store, workspaceId)) return true;
|
|
84
|
+
await materialise(ports, { workspaceId, descriptor, reason });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { SCHEMA_VERSION, advanceDelivery, createId, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
const receiptId = (messageId, recipient) => `${messageId}--${recipient}`;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tell whoever asked for a task what became of it.
|
|
8
|
+
*
|
|
9
|
+
* A request is a question, so it deserves an answer: taken up, declined, or
|
|
10
|
+
* finished. Before this the task recorded who it was for and not who was
|
|
11
|
+
* waiting, so nothing could be said back and the asker had to poll and diff
|
|
12
|
+
* task states to find out.
|
|
13
|
+
*
|
|
14
|
+
* It is a message rather than an attention rule on purpose. Messages carry
|
|
15
|
+
* delivery state, so the answer is put in front of the asker exactly once
|
|
16
|
+
* instead of repeating in every turn until they act on it.
|
|
17
|
+
*
|
|
18
|
+
* Written into the caller's transaction: an accepted task whose acceptance was
|
|
19
|
+
* never sent is the same silence this exists to remove.
|
|
20
|
+
*/
|
|
21
|
+
export function writeWorkResponse(tx, { task, actor, workspaceId, now, ids, outcome,
|
|
22
|
+
reason = null }) {
|
|
23
|
+
const asker = task.requestedByParticipantId;
|
|
24
|
+
// Nobody asked, or the asker is doing it themselves - there is no one to tell.
|
|
25
|
+
if (asker === null || asker === undefined || asker === actor.participantId) return null;
|
|
26
|
+
|
|
27
|
+
const messageId = createId("message");
|
|
28
|
+
// The subject already carries the outcome and the title. The body says who
|
|
29
|
+
// and which task, so the asker can follow it up without going and looking.
|
|
30
|
+
const head = `${outcome} by ${actor.participantId} (task ${task.taskId})`;
|
|
31
|
+
const body = reason === null ? head : `${head}\n\n${reason}`;
|
|
32
|
+
const record = validateRecord("message", {
|
|
33
|
+
schemaVersion: SCHEMA_VERSION,
|
|
34
|
+
messageId,
|
|
35
|
+
workspaceId,
|
|
36
|
+
fromSessionId: actor.sessionId,
|
|
37
|
+
fromParticipantId: actor.participantId,
|
|
38
|
+
toParticipantIds: [asker],
|
|
39
|
+
type: "work_response",
|
|
40
|
+
subject: `${outcome}: ${task.title}`,
|
|
41
|
+
body,
|
|
42
|
+
priority: "normal",
|
|
43
|
+
workstreamId: task.workstreamId,
|
|
44
|
+
taskId: task.taskId,
|
|
45
|
+
inReplyTo: null,
|
|
46
|
+
// The asker is being told, not asked. Requiring an acknowledgement here
|
|
47
|
+
// would leave every finished request sitting in someone's attention list.
|
|
48
|
+
requiresAck: false,
|
|
49
|
+
artifacts: [],
|
|
50
|
+
sentAt: now,
|
|
51
|
+
});
|
|
52
|
+
tx.put("message", messageId, record);
|
|
53
|
+
tx.put("receipt", receiptId(messageId, asker), validateRecord("receipt", {
|
|
54
|
+
schemaVersion: SCHEMA_VERSION,
|
|
55
|
+
messageId,
|
|
56
|
+
workspaceId,
|
|
57
|
+
recipientParticipantId: asker,
|
|
58
|
+
state: "queued",
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
}));
|
|
61
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
62
|
+
actorSessionId: actor.sessionId, type: `work.${outcome}`, occurredAt: now,
|
|
63
|
+
payload: { taskId: task.taskId, messageId, asker } });
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Close the request a task came from, once it has been answered.
|
|
69
|
+
*
|
|
70
|
+
* `acc request` marks its message as needing an acknowledgement, which raises a
|
|
71
|
+
* `direct_request` attention item for the recipient. Finishing the work did not
|
|
72
|
+
* clear it, and nothing else could: the operation existed in the core and was
|
|
73
|
+
* reachable from no surface. So every completed request left a line repeating in
|
|
74
|
+
* the doer's turn for good.
|
|
75
|
+
*
|
|
76
|
+
* Doing the work is the acknowledgement. An explicit `acc ack` exists for
|
|
77
|
+
* messages that are not tied to a task.
|
|
78
|
+
*/
|
|
79
|
+
export function closeRequestReceipt(tx, { task, actor, now, ids }) {
|
|
80
|
+
for (const message of tx.list("message")) {
|
|
81
|
+
if (message.taskId !== task.taskId || message.type !== "work_request") continue;
|
|
82
|
+
if (!message.toParticipantIds.includes(actor.participantId)) continue;
|
|
83
|
+
const id = `${message.messageId}--${actor.participantId}`;
|
|
84
|
+
const existing = tx.get("receipt", id);
|
|
85
|
+
if (existing === null || existing.state === "acknowledged") continue;
|
|
86
|
+
const record = { ...existing, state: advanceDelivery(existing.state, "acknowledged"),
|
|
87
|
+
updatedAt: now };
|
|
88
|
+
tx.put("receipt", id, record, tx.generationOf("receipt", id));
|
|
89
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
90
|
+
workspaceId: existing.workspaceId, actorSessionId: actor.sessionId,
|
|
91
|
+
type: "message.acknowledged", occurredAt: now,
|
|
92
|
+
payload: { messageId: message.messageId,
|
|
93
|
+
recipientParticipantId: actor.participantId } });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everyone this workspace has ever seen.
|
|
5
|
+
*
|
|
6
|
+
* Participants are created when a session attaches and are never removed, so
|
|
7
|
+
* "has been here" is the right test and "is here now" is not: work is addressed
|
|
8
|
+
* to a participant precisely so it survives their session ending.
|
|
9
|
+
*
|
|
10
|
+
* Before anything durable is written the participants live in the ephemeral
|
|
11
|
+
* area, which is where a workspace with one session keeps everything.
|
|
12
|
+
*/
|
|
13
|
+
export async function knownParticipants(store, workspaceId) {
|
|
14
|
+
const snapshot = await store.snapshot(workspaceId,
|
|
15
|
+
{ kinds: ["workspace", "participant"] });
|
|
16
|
+
const durable = snapshot.workspace !== null
|
|
17
|
+
? snapshot.participants
|
|
18
|
+
: await store.ephemeral.list("participant");
|
|
19
|
+
return new Set(durable.map(participant => participant.participantId));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A recipient nobody has ever been is a typo, and saying "sent" to one is the
|
|
24
|
+
* worst answer available: `acc message --to physcis` reported success, the
|
|
25
|
+
* message went nowhere, and the agent that meant `physics` had no way to find
|
|
26
|
+
* out. `acc request` was worse - it made a task addressed to nobody, and the
|
|
27
|
+
* requester waited for an agent that does not exist.
|
|
28
|
+
*
|
|
29
|
+
* It also bounds the recipient list by construction. Nothing did: one message
|
|
30
|
+
* naming three thousand participants took 24.8 seconds, wrote three thousand
|
|
31
|
+
* receipts, and left every session in that workspace paying 5.1 seconds to
|
|
32
|
+
* attach and take one turn - past the point where a hook gives up and allows
|
|
33
|
+
* whatever it was guarding.
|
|
34
|
+
*/
|
|
35
|
+
export async function assertKnownParticipants(store, workspaceId, recipients) {
|
|
36
|
+
const known = await knownParticipants(store, workspaceId);
|
|
37
|
+
const strangers = [...new Set(recipients)].filter(name => !known.has(name));
|
|
38
|
+
if (strangers.length === 0) return;
|
|
39
|
+
// Not a usage error: the command is well formed and the workspace disagrees
|
|
40
|
+
// with it. Which matters beyond tidiness - the gate that runs every documented
|
|
41
|
+
// command holds them to being *accepted*, and every example names a peer that
|
|
42
|
+
// a fresh sandbox has never seen.
|
|
43
|
+
throw new AccError(EXIT.DATA,
|
|
44
|
+
`no participant here is called ${strangers.join(", ")}. `
|
|
45
|
+
+ `This workspace has: ${[...known].sort().join(", ") || "nobody else yet"}`,
|
|
46
|
+
{ strangers, known: [...known].sort() });
|
|
47
|
+
}
|
|
48
|
+
|