dsh-live-teams 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 (69) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +11 -0
  3. package/README.md +85 -0
  4. package/cordis.patch.yml +25 -0
  5. package/lib/binding.d.ts +18 -0
  6. package/lib/binding.js +42 -0
  7. package/lib/changed-paths.d.ts +26 -0
  8. package/lib/changed-paths.js +69 -0
  9. package/lib/client.js +6753 -0
  10. package/lib/command-queue.d.ts +60 -0
  11. package/lib/command-queue.js +185 -0
  12. package/lib/compatibility.js +109 -0
  13. package/lib/context-provider.d.ts +110 -0
  14. package/lib/context-provider.js +249 -0
  15. package/lib/dispatch.d.ts +174 -0
  16. package/lib/dispatch.js +624 -0
  17. package/lib/errors.d.ts +36 -0
  18. package/lib/errors.js +103 -0
  19. package/lib/git-artifacts.d.ts +50 -0
  20. package/lib/git-artifacts.js +242 -0
  21. package/lib/index.d.ts +14 -0
  22. package/lib/index.js +14 -0
  23. package/lib/mailbox.d.ts +274 -0
  24. package/lib/mailbox.js +721 -0
  25. package/lib/member-tools.d.ts +57 -0
  26. package/lib/member-tools.js +1265 -0
  27. package/lib/migrations.d.ts +17 -0
  28. package/lib/migrations.js +47 -0
  29. package/lib/plugin.d.ts +106 -0
  30. package/lib/plugin.js +1003 -0
  31. package/lib/roles.d.ts +35 -0
  32. package/lib/roles.js +284 -0
  33. package/lib/routes.d.ts +586 -0
  34. package/lib/routes.js +2816 -0
  35. package/lib/scope.d.ts +62 -0
  36. package/lib/scope.js +133 -0
  37. package/lib/session-bridge.d.ts +76 -0
  38. package/lib/session-bridge.js +147 -0
  39. package/lib/session-title.js +35 -0
  40. package/lib/storage.d.ts +9 -0
  41. package/lib/storage.js +65 -0
  42. package/lib/task-store.d.ts +729 -0
  43. package/lib/task-store.js +2205 -0
  44. package/lib/team-store.d.ts +216 -0
  45. package/lib/team-store.js +765 -0
  46. package/lib/tree-snapshot.d.ts +28 -0
  47. package/lib/tree-snapshot.js +80 -0
  48. package/lib/types/client/TeamView.d.ts +26 -0
  49. package/lib/types/client/TeamView.dom.test.d.ts +1 -0
  50. package/lib/types/client/api.d.ts +522 -0
  51. package/lib/types/client/api.test.d.ts +1 -0
  52. package/lib/types/client/attention.d.ts +65 -0
  53. package/lib/types/client/attention.test.d.ts +1 -0
  54. package/lib/types/client/index.d.ts +31 -0
  55. package/lib/types/client/locales.d.ts +577 -0
  56. package/lib/types/client/member-name.d.ts +14 -0
  57. package/lib/types/client/member-name.test.d.ts +1 -0
  58. package/lib/types/client/roster.d.ts +26 -0
  59. package/lib/types/client/roster.test.d.ts +1 -0
  60. package/lib/types/client/styles.d.ts +3 -0
  61. package/package.json +104 -0
  62. package/roles/builder.md +40 -0
  63. package/roles/delegate.md +36 -0
  64. package/roles/lead.md +46 -0
  65. package/roles/oracle.md +36 -0
  66. package/roles/researcher.md +37 -0
  67. package/roles/reviewer.md +45 -0
  68. package/roles/scout.md +36 -0
  69. package/roles/verifier.md +36 -0
package/lib/mailbox.js ADDED
@@ -0,0 +1,721 @@
1
+ import { ERROR_CODES, LiveTeamsError } from "./errors.js";
2
+ import { atomicWriteJson, clone, fileExists, readJson, withProcessLock } from "./storage.js";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ //#region src/mailbox.ts
7
+ /**
8
+ * The one delivery failure the dispatcher may retry on its own: the recipient had
9
+ * no usable binding yet. Every other failure is terminal and stays visible —
10
+ * the reconciler never resurrects a delivery for a reason it cannot name.
11
+ */
12
+ const NO_SESSION_FAILURE = "recipient has no current session binding";
13
+ function recipientLabel(recipient) {
14
+ return recipient.displayName?.trim() || recipient.memberId;
15
+ }
16
+ /** Resolve the one addressing vocabulary shared by member and human sends. */
17
+ function resolveMailboxRecipient(value, recipients) {
18
+ const requested = value.trim();
19
+ const exact = recipients.find((candidate) => candidate.memberId === requested);
20
+ if (exact !== void 0) return {
21
+ recipient: exact.memberId,
22
+ name: recipientLabel(exact)
23
+ };
24
+ if (requested === "team") return {
25
+ recipient: "team",
26
+ name: "team"
27
+ };
28
+ if (requested === "human") return {
29
+ recipient: "human",
30
+ name: "human"
31
+ };
32
+ const matching = recipients.filter((candidate) => candidate.displayName?.trim().toLocaleLowerCase() === requested.toLocaleLowerCase());
33
+ if (matching.length === 1) {
34
+ const candidate = matching[0];
35
+ if (candidate === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "recipient resolution state disappeared");
36
+ return {
37
+ recipient: candidate.memberId,
38
+ name: recipientLabel(candidate)
39
+ };
40
+ }
41
+ if (matching.length > 1) {
42
+ const candidates = matching.map((candidate) => `${candidate.memberId} (${recipientLabel(candidate)})`);
43
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `ambiguous recipient "${requested}"; candidates: ${candidates.join(", ")}`, { details: { candidates } });
44
+ }
45
+ const existing = recipients.map((candidate) => `${candidate.memberId} (${recipientLabel(candidate)})`);
46
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `unknown recipient "${requested}"; existing recipients: ${existing.length > 0 ? existing.join(", ") : "none"}`, { details: { recipients: existing } });
47
+ }
48
+ const SCHEMA_VERSION = 1;
49
+ const DEFAULT_LEASE_MS = 3e4;
50
+ const MESSAGE_KEYS = /* @__PURE__ */ new Set([
51
+ "id",
52
+ "teamId",
53
+ "from",
54
+ "to",
55
+ "kind",
56
+ "content",
57
+ "threadId",
58
+ "createdAt"
59
+ ]);
60
+ const DELIVERY_KEYS = /* @__PURE__ */ new Set([
61
+ "id",
62
+ "messageId",
63
+ "recipient",
64
+ "wakePolicy",
65
+ "ackPolicy",
66
+ "state",
67
+ "claimedAt",
68
+ "deliveredAt",
69
+ "acknowledgedAt",
70
+ "failure",
71
+ "revision"
72
+ ]);
73
+ const MESSAGE_WRAPPER_KEYS = /* @__PURE__ */ new Set([
74
+ "schemaVersion",
75
+ "teamId",
76
+ "messages"
77
+ ]);
78
+ const DELIVERY_WRAPPER_KEYS = /* @__PURE__ */ new Set([
79
+ "schemaVersion",
80
+ "teamId",
81
+ "deliveries"
82
+ ]);
83
+ function record(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
85
+ }
86
+ function text(value) {
87
+ return typeof value === "string" && value.length > 0 ? value : void 0;
88
+ }
89
+ function nonNegativeInteger(value) {
90
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
91
+ }
92
+ function positiveInteger(value) {
93
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
94
+ }
95
+ function keysAllowed(value, keys) {
96
+ return Object.keys(value).every((key) => keys.has(key));
97
+ }
98
+ function isMessageKind(value) {
99
+ return value === "message" || value === "direction" || value === "comment";
100
+ }
101
+ function isWakePolicy(value) {
102
+ return value === "never" || value === "queue" || value === "steer";
103
+ }
104
+ function isAckPolicy(value) {
105
+ return value === "delivery" || value === "consumption" || value === "formal";
106
+ }
107
+ function isDeliveryState(value) {
108
+ return value === "stored" || value === "leased" || value === "delivered" || value === "acknowledged" || value === "failed";
109
+ }
110
+ function cloneMessage(value) {
111
+ return clone(value);
112
+ }
113
+ function cloneDelivery(value) {
114
+ return clone(value);
115
+ }
116
+ function messagePolicy(kind, recipient) {
117
+ if (recipient === "human") return {
118
+ wakePolicy: "never",
119
+ ackPolicy: "consumption"
120
+ };
121
+ if (kind === "comment") return {
122
+ wakePolicy: "never",
123
+ ackPolicy: "consumption"
124
+ };
125
+ if (kind === "direction") return {
126
+ wakePolicy: "steer",
127
+ ackPolicy: "delivery"
128
+ };
129
+ return {
130
+ wakePolicy: "queue",
131
+ ackPolicy: "delivery"
132
+ };
133
+ }
134
+ /** Derive the externally reported outcome from a final delivery state and policy. */
135
+ function messageDeliveryOutcome(delivery) {
136
+ if (delivery.state === "failed") return "failed";
137
+ if (delivery.state === "delivered" || delivery.state === "acknowledged") return "delivered";
138
+ if (delivery.state === "stored" && delivery.wakePolicy === "never") return "inbox";
139
+ return "pending";
140
+ }
141
+ function checkedMessage(value, teamId) {
142
+ const candidate = record(value);
143
+ if (candidate === void 0 || !keysAllowed(candidate, MESSAGE_KEYS) || text(candidate.id) === void 0 || candidate.teamId !== teamId || candidate.from !== "human" && candidate.from !== "scheduler" && text(candidate.from) === void 0 || candidate.to !== "human" && candidate.to !== "team" && text(candidate.to) === void 0 || !isMessageKind(candidate.kind) || typeof candidate.content !== "string" || !nonNegativeInteger(candidate.createdAt) || candidate.threadId !== void 0 && text(candidate.threadId) === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid mailbox message state", { auditDetail: `invalid message in team ${teamId}` });
144
+ return clone(candidate);
145
+ }
146
+ function checkedDelivery(value, teamId) {
147
+ const candidate = record(value);
148
+ if (candidate === void 0 || !keysAllowed(candidate, DELIVERY_KEYS) || text(candidate.id) === void 0 || text(candidate.messageId) === void 0 || typeof candidate.recipient !== "string" || candidate.recipient.length === 0 || !isWakePolicy(candidate.wakePolicy) || !isAckPolicy(candidate.ackPolicy) || !isDeliveryState(candidate.state) || !positiveInteger(candidate.revision) || candidate.claimedAt !== void 0 && !nonNegativeInteger(candidate.claimedAt) || candidate.deliveredAt !== void 0 && !nonNegativeInteger(candidate.deliveredAt) || candidate.acknowledgedAt !== void 0 && !nonNegativeInteger(candidate.acknowledgedAt) || candidate.failure !== void 0 && text(candidate.failure) === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid mailbox delivery state", { auditDetail: `invalid delivery in team ${teamId}` });
149
+ if (candidate.state === "failed" && candidate.failure === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "failed delivery has no reason");
150
+ if (candidate.state === "acknowledged" && candidate.acknowledgedAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "acknowledged delivery has no timestamp");
151
+ if (candidate.state === "delivered" && candidate.deliveredAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "delivered delivery has no timestamp");
152
+ if (candidate.state === "stored" && (candidate.claimedAt !== void 0 || candidate.deliveredAt !== void 0 || candidate.acknowledgedAt !== void 0 || candidate.failure !== void 0)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "stored delivery has transition fields");
153
+ if (candidate.state === "leased" && candidate.claimedAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "leased delivery has no claim timestamp");
154
+ return clone(candidate);
155
+ }
156
+ function checkedArray(value, key, teamId) {
157
+ const wrapper = record(value);
158
+ if (wrapper === void 0 || !keysAllowed(wrapper, key === "messages" ? MESSAGE_WRAPPER_KEYS : DELIVERY_WRAPPER_KEYS) || wrapper.schemaVersion !== SCHEMA_VERSION || wrapper.teamId !== teamId || !Array.isArray(wrapper[key])) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid mailbox ${key} state`, { auditDetail: `invalid ${key}.json in team ${teamId}` });
159
+ return key === "messages" ? wrapper.messages.map((entry) => checkedMessage(entry, teamId)) : wrapper.deliveries.map((entry) => checkedDelivery(entry, teamId));
160
+ }
161
+ function requestDraft(value) {
162
+ const candidate = record(value);
163
+ if (candidate === void 0 || Object.keys(candidate).some((key) => !(/* @__PURE__ */ new Set([
164
+ "to",
165
+ "kind",
166
+ "content",
167
+ "threadId"
168
+ ])).has(key)) || typeof candidate.to !== "string" || candidate.to.length === 0 || !isMessageKind(candidate.kind) || typeof candidate.content !== "string" || candidate.threadId !== void 0 && text(candidate.threadId) === void 0 || candidate.from !== void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid mailbox message request");
169
+ return {
170
+ to: candidate.to,
171
+ kind: candidate.kind,
172
+ content: candidate.content,
173
+ ...candidate.threadId === void 0 ? {} : { threadId: candidate.threadId }
174
+ };
175
+ }
176
+ function senderActor(value) {
177
+ const candidate = record(value);
178
+ if (candidate?.type === "human") return { type: "human" };
179
+ if (candidate?.type === "scheduler") return { type: "scheduler" };
180
+ if (candidate?.type === "session" && text(candidate.sessionId) !== void 0) return {
181
+ type: "session",
182
+ sessionId: candidate.sessionId
183
+ };
184
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox sender identity is required");
185
+ }
186
+ var MailboxService = class {
187
+ workspacePath;
188
+ teamId;
189
+ queue;
190
+ listRecipients;
191
+ authorize;
192
+ resolveSessionSender;
193
+ appendAudit;
194
+ now;
195
+ leaseMs;
196
+ autoDeliver;
197
+ directory;
198
+ messagesFile;
199
+ deliveriesFile;
200
+ lockFile;
201
+ constructor(options) {
202
+ if (text(options?.workspacePath) === void 0 || text(options?.teamId) === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires workspacePath and teamId");
203
+ if (!options.queue || typeof options.queue.admit !== "function" || typeof options.queue.dispatch !== "function") throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires the existing command queue");
204
+ if (typeof options.listRecipients !== "function") throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires a recipient resolver");
205
+ this.workspacePath = path.resolve(options.workspacePath);
206
+ this.teamId = options.teamId;
207
+ this.queue = options.queue;
208
+ this.listRecipients = options.listRecipients;
209
+ this.authorize = options.authorize;
210
+ this.resolveSessionSender = options.resolveSessionSender;
211
+ this.appendAudit = options.appendAudit;
212
+ this.now = options.now ?? Date.now;
213
+ this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
214
+ if (!positiveInteger(this.leaseMs)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox leaseMs must be positive");
215
+ this.autoDeliver = options.autoDeliver ?? true;
216
+ this.directory = path.join(this.workspacePath, ".dsh-live-teams", "teams", this.teamId);
217
+ this.messagesFile = path.join(this.directory, "messages.json");
218
+ this.deliveriesFile = path.join(this.directory, "deliveries.json");
219
+ this.lockFile = path.join(this.directory, ".mailbox.lock");
220
+ }
221
+ async listMessages() {
222
+ return (await this.snapshot()).messages.map(cloneMessage);
223
+ }
224
+ async listDeliveries() {
225
+ return (await this.snapshot()).deliveries.map(cloneDelivery);
226
+ }
227
+ async getMessage(messageId) {
228
+ return (await this.listMessages()).find((message) => message.id === messageId);
229
+ }
230
+ async getDelivery(deliveryId) {
231
+ return (await this.listDeliveries()).find((delivery) => delivery.id === deliveryId);
232
+ }
233
+ async deliveriesFor(messageId) {
234
+ return (await this.listDeliveries()).filter((delivery) => delivery.messageId === messageId);
235
+ }
236
+ /**
237
+ * Internal send path.
238
+ *
239
+ * Deliberately private: an actor argument that any caller may supply would let
240
+ * a member send as `human` or as another member, which ADR 0004 forbids. The
241
+ * public entries derive the sender instead — from the calling Session binding
242
+ * for member traffic, and from the host itself for human and scheduler paths.
243
+ */
244
+ async send(draftValue, actorValue) {
245
+ const draft = requestDraft(draftValue);
246
+ const actor = senderActor(actorValue);
247
+ const from = await this.resolveFrom(actor);
248
+ const result = await withProcessLock(this.lockFile, async () => {
249
+ const snapshot = await this.readSnapshot();
250
+ const availableRecipients = await this.listRecipients();
251
+ const resolved = resolveMailboxRecipient(draft.to, availableRecipients);
252
+ const recipients = resolved.recipient === "team" ? availableRecipients.filter((recipient) => recipient.active !== false) : [];
253
+ if (this.authorize !== void 0) await this.authorize({
254
+ from,
255
+ to: resolved.recipient
256
+ });
257
+ const message = {
258
+ id: randomUUID(),
259
+ teamId: this.teamId,
260
+ from,
261
+ to: resolved.recipient,
262
+ kind: draft.kind,
263
+ content: draft.content,
264
+ ...draft.threadId === void 0 ? {} : { threadId: draft.threadId },
265
+ createdAt: this.now()
266
+ };
267
+ const recipientIds = resolved.recipient === "team" ? recipients.map((recipient) => recipient.memberId) : [resolved.recipient];
268
+ if (recipientIds.some((recipient) => typeof recipient !== "string" || recipient.length === 0)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox recipient is invalid");
269
+ const deliveries = recipientIds.map((recipient) => {
270
+ const policy = messagePolicy(draft.kind, recipient);
271
+ return {
272
+ id: randomUUID(),
273
+ messageId: message.id,
274
+ recipient,
275
+ ...policy,
276
+ state: "stored",
277
+ revision: 1
278
+ };
279
+ });
280
+ await this.persist({
281
+ messages: [...snapshot.messages, message],
282
+ deliveries: [...snapshot.deliveries, ...deliveries]
283
+ });
284
+ await this.audit({
285
+ kind: "message/sent",
286
+ teamId: this.teamId,
287
+ messageId: message.id,
288
+ from: message.from,
289
+ actor: message.from,
290
+ to: message.to,
291
+ messageKind: message.kind,
292
+ at: this.now()
293
+ });
294
+ return {
295
+ message: cloneMessage(message),
296
+ deliveries: deliveries.map(cloneDelivery)
297
+ };
298
+ });
299
+ if (!this.autoDeliver) return result;
300
+ const delivered = [];
301
+ for (const delivery of result.deliveries) delivered.push(await this.deliver(delivery.id));
302
+ return {
303
+ message: result.message,
304
+ deliveries: delivered
305
+ };
306
+ }
307
+ async sendFromSession(sessionId, draft) {
308
+ return this.send(draft, {
309
+ type: "session",
310
+ sessionId
311
+ });
312
+ }
313
+ async sendFromHuman(draft) {
314
+ return this.send(draft, { type: "human" });
315
+ }
316
+ async sendFromScheduler(draft) {
317
+ return this.send(draft, { type: "scheduler" });
318
+ }
319
+ async claimDelivery(deliveryId) {
320
+ let leased;
321
+ await withProcessLock(this.lockFile, async () => {
322
+ const snapshot = await this.readSnapshot();
323
+ const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
324
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
325
+ const current = snapshot.deliveries[index];
326
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
327
+ if (current.state === "delivered" || current.state === "acknowledged") {
328
+ leased = cloneDelivery(current);
329
+ return;
330
+ }
331
+ if (current.state === "leased" && current.claimedAt !== void 0 && current.claimedAt + this.leaseMs > this.now()) return;
332
+ if (current.state === "failed") return;
333
+ const next = {
334
+ ...current,
335
+ state: "leased",
336
+ claimedAt: this.now(),
337
+ revision: current.revision + 1
338
+ };
339
+ delete next.failure;
340
+ delete next.deliveredAt;
341
+ delete next.acknowledgedAt;
342
+ snapshot.deliveries[index] = next;
343
+ await this.persist(snapshot);
344
+ await this.audit({
345
+ kind: "delivery/leased",
346
+ teamId: this.teamId,
347
+ deliveryId: next.id,
348
+ messageId: next.messageId,
349
+ recipient: next.recipient,
350
+ at: this.now(),
351
+ revision: next.revision
352
+ });
353
+ leased = cloneDelivery(next);
354
+ });
355
+ return leased;
356
+ }
357
+ async deliver(deliveryId) {
358
+ const existing = await this.getDelivery(deliveryId);
359
+ if (existing === void 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
360
+ if (existing.state === "delivered" || existing.state === "acknowledged") return existing;
361
+ if (existing.recipient === "human" || existing.wakePolicy === "never") return existing;
362
+ const claimed = await this.claimDelivery(deliveryId);
363
+ if (claimed === void 0) {
364
+ const current = await this.getDelivery(deliveryId);
365
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
366
+ if (current.state === "leased") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is held");
367
+ if (current.state === "failed") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, current.failure ?? "mailbox delivery failed");
368
+ return current;
369
+ }
370
+ if (claimed.state === "delivered" || claimed.state === "acknowledged") return claimed;
371
+ const message = await this.getMessage(claimed.messageId);
372
+ if (message === void 0) return this.fail(claimed.id, "mailbox message is missing");
373
+ if (claimed.recipient === "human" || claimed.wakePolicy === "never") return claimed;
374
+ const recipient = (await this.listRecipients()).find((entry) => entry.memberId === claimed.recipient && entry.active !== false);
375
+ if (recipient === void 0 || recipient.sessionId === void 0 || recipient.bindingGeneration === void 0) return this.fail(claimed.id, NO_SESSION_FAILURE);
376
+ try {
377
+ const senderName = message.from === "human" || message.from === "scheduler" ? void 0 : (await this.listRecipients()).find((entry) => entry.memberId === message.from)?.displayName;
378
+ const first = await this.attemptDelivery(message, claimed, recipient, claimed.wakePolicy, senderName);
379
+ if (first.kind === "delivered") return this.markDelivered(claimed.id);
380
+ if (claimed.wakePolicy !== "steer") return this.fail(claimed.id, first.reason);
381
+ await this.audit({
382
+ kind: "delivery/steer-fallback",
383
+ teamId: this.teamId,
384
+ deliveryId: claimed.id,
385
+ messageId: message.id,
386
+ reason: first.reason,
387
+ at: this.now()
388
+ });
389
+ const second = await this.attemptDelivery(message, claimed, recipient, "queue", senderName);
390
+ if (second.kind === "delivered") return this.markDelivered(claimed.id);
391
+ return this.fail(claimed.id, second.reason);
392
+ } catch (error) {
393
+ return this.fail(claimed.id, error instanceof LiveTeamsError ? error.auditDetail ?? error.message : error instanceof Error ? error.message : String(error));
394
+ }
395
+ }
396
+ /**
397
+ * One transport attempt in one mode.
398
+ *
399
+ * The idempotency key carries the mode, because the key is bound to the first
400
+ * command admitted under it: reusing one key for a queue retry after a steer
401
+ * attempt would return the steer command and change nothing. The mode is part
402
+ * of what makes this attempt distinct, and `(messageId, recipient)` still
403
+ * identifies the delivery itself.
404
+ */
405
+ async attemptDelivery(message, claimed, recipient, mode, senderName) {
406
+ try {
407
+ const admission = await this.admitOnce(message, claimed, recipient, mode, senderName);
408
+ const command = await this.queue.dispatch(admission.record.commandId);
409
+ if (command.state === "admitted" || command.state === "applied") return { kind: "delivered" };
410
+ return {
411
+ kind: "refused",
412
+ reason: command.failureDetail ?? command.failureCode ?? "command queue did not admit delivery"
413
+ };
414
+ } catch (error) {
415
+ return {
416
+ kind: "refused",
417
+ reason: error instanceof LiveTeamsError ? error.auditDetail ?? error.message : error instanceof Error ? error.message : String(error)
418
+ };
419
+ }
420
+ }
421
+ async admitOnce(message, claimed, recipient, mode, senderName) {
422
+ const attributed = message.from === "human" ? `Human instruction (${message.kind}): ${message.content}` : message.from === "scheduler" ? `Scheduler notification (${message.kind}): ${message.content}` : `Peer message from ${senderName ?? message.from} (${message.kind}): ${message.content}`;
423
+ return this.queue.admit({
424
+ idempotencyKey: `${message.id}:${claimed.recipient}:${mode}`,
425
+ sessionId: recipient.sessionId,
426
+ bindingGeneration: recipient.bindingGeneration,
427
+ mode,
428
+ reason: `mailbox ${message.kind}`,
429
+ causationId: message.id,
430
+ content: [{
431
+ type: "text",
432
+ text: attributed
433
+ }]
434
+ });
435
+ }
436
+ async retryExpiredLeases() {
437
+ const candidates = (await this.snapshot()).deliveries.filter((delivery) => delivery.state === "leased" && delivery.claimedAt !== void 0 && delivery.claimedAt + this.leaseMs <= this.now());
438
+ const results = [];
439
+ for (const candidate of candidates) try {
440
+ results.push(await this.deliver(candidate.id));
441
+ } catch {}
442
+ return results;
443
+ }
444
+ async retryDelivery(deliveryId) {
445
+ await withProcessLock(this.lockFile, async () => {
446
+ const snapshot = await this.readSnapshot();
447
+ const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
448
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
449
+ const current = snapshot.deliveries[index];
450
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
451
+ if (current.state === "delivered" || current.state === "acknowledged") return;
452
+ if (current.state === "leased" && current.claimedAt !== void 0 && current.claimedAt + this.leaseMs > this.now()) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is held");
453
+ if (current.ackPolicy !== "delivery" && current.ackPolicy !== "formal" && current.ackPolicy !== "consumption") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox acknowledgement policy cannot be retried");
454
+ const next = {
455
+ ...current,
456
+ state: "stored",
457
+ revision: current.revision + 1
458
+ };
459
+ delete next.claimedAt;
460
+ delete next.deliveredAt;
461
+ delete next.acknowledgedAt;
462
+ delete next.failure;
463
+ snapshot.deliveries[index] = next;
464
+ await this.persist(snapshot);
465
+ });
466
+ return this.deliver(deliveryId);
467
+ }
468
+ async acknowledge(messageId, recipient, _formal = false) {
469
+ if (text(messageId) === void 0 || text(recipient) === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "acknowledgement requires messageId and recipient");
470
+ let result;
471
+ await withProcessLock(this.lockFile, async () => {
472
+ const snapshot = await this.readSnapshot();
473
+ const index = snapshot.deliveries.findIndex((delivery) => delivery.messageId === messageId && delivery.recipient === recipient);
474
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
475
+ const current = snapshot.deliveries[index];
476
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
477
+ if (current.state === "acknowledged" || current.ackPolicy === "delivery" && current.state === "delivered") {
478
+ result = {
479
+ delivery: cloneDelivery(current),
480
+ duplicate: true
481
+ };
482
+ return;
483
+ }
484
+ if (current.state !== "stored" && current.state !== "delivered") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery is not acknowledgeable");
485
+ if (current.ackPolicy === "delivery") {
486
+ result = {
487
+ delivery: cloneDelivery(current),
488
+ duplicate: true
489
+ };
490
+ return;
491
+ }
492
+ const next = {
493
+ ...current,
494
+ state: "acknowledged",
495
+ acknowledgedAt: this.now(),
496
+ revision: current.revision + 1
497
+ };
498
+ snapshot.deliveries[index] = next;
499
+ await this.persist(snapshot);
500
+ await this.audit({
501
+ kind: "delivery/acknowledged",
502
+ teamId: this.teamId,
503
+ deliveryId: next.id,
504
+ messageId: next.messageId,
505
+ recipient: next.recipient,
506
+ actor: next.recipient,
507
+ at: this.now(),
508
+ revision: next.revision
509
+ });
510
+ result = {
511
+ delivery: cloneDelivery(next),
512
+ duplicate: false
513
+ };
514
+ });
515
+ if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "acknowledgement produced no result");
516
+ return result;
517
+ }
518
+ async resolveFrom(actor) {
519
+ if (actor.type === "human" || actor.type === "scheduler") return actor.type;
520
+ if (this.resolveSessionSender === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "session sender resolver is unavailable");
521
+ const memberId = await this.resolveSessionSender(actor.sessionId);
522
+ if (memberId === void 0 || memberId.length === 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "calling session is not bound to an active team member");
523
+ return memberId;
524
+ }
525
+ /**
526
+ * A message persisted without its delivery rows is a message nobody will ever see: the snapshot is
527
+ * two files, and a process that dies between them leaves exactly that. The reconciler calls this,
528
+ * and the orphan becomes a visible failed delivery instead of silence (external review, mailbox
529
+ * atomicity).
530
+ */
531
+ async healOrphanMessages() {
532
+ let healed = [];
533
+ await withProcessLock(this.lockFile, async () => {
534
+ const snapshot = await this.readSnapshot();
535
+ const withDeliveries = new Set(snapshot.deliveries.map((delivery) => delivery.messageId));
536
+ const orphans = snapshot.messages.filter((message) => !withDeliveries.has(message.id));
537
+ if (orphans.length === 0) return;
538
+ const rows = orphans.map((message) => ({
539
+ id: `delivery-${randomUUID()}`,
540
+ messageId: message.id,
541
+ recipient: message.to,
542
+ wakePolicy: "never",
543
+ ackPolicy: "delivery",
544
+ state: "failed",
545
+ failure: "the message was stored without a delivery row: the process stopped between the two writes",
546
+ revision: snapshot.deliveries.length + 1
547
+ }));
548
+ const next = {
549
+ ...snapshot,
550
+ deliveries: [...snapshot.deliveries, ...rows]
551
+ };
552
+ await atomicWriteJson(this.deliveriesFile, next);
553
+ for (const row of rows) await this.audit({
554
+ kind: "delivery/failed",
555
+ teamId: this.teamId,
556
+ deliveryId: row.id,
557
+ messageId: row.messageId,
558
+ recipient: row.recipient,
559
+ reason: row.failure ?? "orphan message",
560
+ at: this.now(),
561
+ revision: row.revision
562
+ });
563
+ healed = rows.map((row) => ({
564
+ messageId: row.messageId,
565
+ recipient: row.recipient
566
+ }));
567
+ });
568
+ return healed;
569
+ }
570
+ async markDelivered(deliveryId) {
571
+ let result;
572
+ await withProcessLock(this.lockFile, async () => {
573
+ const snapshot = await this.readSnapshot();
574
+ const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
575
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
576
+ const current = snapshot.deliveries[index];
577
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
578
+ if (current.state === "delivered" || current.state === "acknowledged") {
579
+ result = cloneDelivery(current);
580
+ return;
581
+ }
582
+ if (current.state !== "leased") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is not held");
583
+ const next = {
584
+ ...current,
585
+ state: "delivered",
586
+ deliveredAt: this.now(),
587
+ revision: current.revision + 1
588
+ };
589
+ delete next.failure;
590
+ snapshot.deliveries[index] = next;
591
+ await this.persist(snapshot);
592
+ await this.audit({
593
+ kind: "delivery/delivered",
594
+ teamId: this.teamId,
595
+ deliveryId: next.id,
596
+ messageId: next.messageId,
597
+ recipient: next.recipient,
598
+ at: this.now(),
599
+ revision: next.revision
600
+ });
601
+ result = cloneDelivery(next);
602
+ });
603
+ if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "delivery transition produced no result");
604
+ return result;
605
+ }
606
+ async fail(deliveryId, reason) {
607
+ const failure = text(reason) ?? "delivery failed";
608
+ let result;
609
+ await withProcessLock(this.lockFile, async () => {
610
+ const snapshot = await this.readSnapshot();
611
+ const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
612
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
613
+ const current = snapshot.deliveries[index];
614
+ if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
615
+ if (current.state === "delivered" || current.state === "acknowledged") {
616
+ result = cloneDelivery(current);
617
+ return;
618
+ }
619
+ const next = {
620
+ ...current,
621
+ state: "failed",
622
+ failure,
623
+ revision: current.revision + 1
624
+ };
625
+ snapshot.deliveries[index] = next;
626
+ await this.persist(snapshot);
627
+ await this.audit({
628
+ kind: "delivery/failed",
629
+ teamId: this.teamId,
630
+ deliveryId: next.id,
631
+ messageId: next.messageId,
632
+ recipient: next.recipient,
633
+ reason: failure,
634
+ at: this.now(),
635
+ revision: next.revision
636
+ });
637
+ result = cloneDelivery(next);
638
+ });
639
+ if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "failure transition produced no result");
640
+ return result;
641
+ }
642
+ async snapshot() {
643
+ return withProcessLock(this.lockFile, () => this.readSnapshot());
644
+ }
645
+ /**
646
+ * Comments this member has not acknowledged, read synchronously.
647
+ *
648
+ * The membership context is assembled synchronously, and a comment is stored with
649
+ * `wakePolicy: never` — nothing wakes the Session for it. Without a sync reader the recipient only
650
+ * ever learns about a comment if it happens to ask (external review, finding 13).
651
+ */
652
+ unreadCommentsForSync(memberId) {
653
+ try {
654
+ if (!existsSync(this.messagesFile) || !existsSync(this.deliveriesFile)) return [];
655
+ const messages = JSON.parse(readFileSync(this.messagesFile, "utf8"));
656
+ const deliveries = JSON.parse(readFileSync(this.deliveriesFile, "utf8"));
657
+ const unread = new Set((deliveries.deliveries ?? []).filter((delivery) => delivery.recipient === memberId && delivery.state !== "acknowledged" && delivery.state !== "failed").map((delivery) => delivery.messageId));
658
+ return (messages.messages ?? []).filter((message) => message.kind === "comment" && message.to === memberId && unread.has(message.id)).sort((left, right) => left.createdAt - right.createdAt).slice(-3).map((message) => ({
659
+ from: message.from,
660
+ content: message.content,
661
+ at: message.createdAt
662
+ }));
663
+ } catch {
664
+ return [];
665
+ }
666
+ }
667
+ async readSnapshot() {
668
+ if (await fileExists(this.messagesFile) !== await fileExists(this.deliveriesFile)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox state is torn: only one of the two files exists");
669
+ const messages = await this.readMessages();
670
+ const deliveries = await this.readDeliveries();
671
+ const ids = new Set(messages.map((message) => message.id));
672
+ if (ids.size !== messages.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox contains duplicate message ids");
673
+ if (new Set(deliveries.map((delivery) => delivery.id)).size !== deliveries.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox contains duplicate delivery ids");
674
+ if (new Set(deliveries.map((delivery) => `${delivery.messageId}:${delivery.recipient}`)).size !== deliveries.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox repeats a message for one recipient");
675
+ if (deliveries.some((delivery) => !ids.has(delivery.messageId))) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery references a missing message");
676
+ return {
677
+ messages,
678
+ deliveries
679
+ };
680
+ }
681
+ async readMessages() {
682
+ if (!await fileExists(this.messagesFile)) return [];
683
+ let parsed;
684
+ try {
685
+ parsed = await readJson(this.messagesFile);
686
+ } catch (error) {
687
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox messages state is not readable JSON", { cause: error });
688
+ }
689
+ return checkedArray(parsed, "messages", this.teamId);
690
+ }
691
+ async readDeliveries() {
692
+ if (!await fileExists(this.deliveriesFile)) return [];
693
+ let parsed;
694
+ try {
695
+ parsed = await readJson(this.deliveriesFile);
696
+ } catch (error) {
697
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox deliveries state is not readable JSON", { cause: error });
698
+ }
699
+ return checkedArray(parsed, "deliveries", this.teamId);
700
+ }
701
+ async persist(snapshot) {
702
+ await atomicWriteJson(this.messagesFile, {
703
+ schemaVersion: SCHEMA_VERSION,
704
+ teamId: this.teamId,
705
+ messages: snapshot.messages
706
+ });
707
+ await atomicWriteJson(this.deliveriesFile, {
708
+ schemaVersion: SCHEMA_VERSION,
709
+ teamId: this.teamId,
710
+ deliveries: snapshot.deliveries
711
+ });
712
+ }
713
+ async audit(event) {
714
+ if (this.appendAudit !== void 0) await this.appendAudit(event);
715
+ }
716
+ };
717
+ function createMailbox(options) {
718
+ return new MailboxService(options);
719
+ }
720
+ //#endregion
721
+ export { MailboxService, NO_SESSION_FAILURE, createMailbox, messageDeliveryOutcome, messagePolicy, resolveMailboxRecipient };