opencode-courier 0.2.3 → 0.3.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 (50) hide show
  1. package/README.md +124 -20
  2. package/dist/bounded.d.ts +2 -4
  3. package/dist/bounded.js +2 -4
  4. package/dist/cleanup.d.ts +8 -3
  5. package/dist/cleanup.js +24 -12
  6. package/dist/courier.d.ts +116 -11
  7. package/dist/courier.js +239 -55
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +52 -77
  11. package/dist/hub.js +68 -65
  12. package/dist/index.d.ts +4 -8
  13. package/dist/index.js +27 -20
  14. package/dist/json.d.ts +4 -0
  15. package/dist/json.js +11 -0
  16. package/dist/later.d.ts +6 -6
  17. package/dist/later.js +6 -5
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +125 -15
  21. package/dist/notices.js +304 -25
  22. package/dist/question/answer.js +6 -9
  23. package/dist/question/lifecycle.d.ts +10 -19
  24. package/dist/question/lifecycle.js +10 -19
  25. package/dist/question/pure.d.ts +3 -4
  26. package/dist/question/pure.js +2 -3
  27. package/dist/question/relay.js +13 -27
  28. package/dist/question/runtime.d.ts +2 -3
  29. package/dist/question/runtime.js +2 -3
  30. package/dist/relay.d.ts +2 -4
  31. package/dist/relay.js +2 -4
  32. package/dist/report.d.ts +48 -0
  33. package/dist/report.js +88 -0
  34. package/dist/roster.d.ts +29 -17
  35. package/dist/roster.js +57 -26
  36. package/dist/skill.d.ts +13 -0
  37. package/dist/skill.js +9 -0
  38. package/dist/stop.d.ts +41 -0
  39. package/dist/stop.js +120 -0
  40. package/dist/tools.d.ts +20 -0
  41. package/dist/tools.js +81 -12
  42. package/dist/tree.d.ts +102 -0
  43. package/dist/tree.js +58 -0
  44. package/dist/version.d.ts +4 -10
  45. package/dist/version.js +4 -10
  46. package/dist/watch.d.ts +55 -18
  47. package/dist/watch.js +219 -34
  48. package/dist/webhook.d.ts +10 -15
  49. package/dist/webhook.js +11 -20
  50. package/package.json +1 -1
package/dist/courier.js CHANGED
@@ -1,5 +1,8 @@
1
- import { childBrief, envelope } from "./notices.js";
2
- import { current, record } from "./roster.js";
1
+ import { groupStanding, holdReport, isGroupName, joinGroup, leaveGroup, memberKey, memberOf, membershipOf, standingOf, unholdForBlocked, unholdReport } from "./group.js";
2
+ import { admit } from "./limits.js";
3
+ import { childBrief, childBranch, envelope, isStatus, reportBody, STATUSES } from "./notices.js";
4
+ import { progressed, progressKey, prompted, settled, settledKey } from "./report.js";
5
+ import { current, indexedEntry, record } from "./roster.js";
3
6
  function titleOf(task) {
4
7
  const line = task.trim().split("\n")[0] ?? "";
5
8
  return line.length > 60 ? `${line.slice(0, 57)}...` : line;
@@ -24,58 +27,227 @@ async function dropWorktree(ports, directory) {
24
27
  // The spawn's own failure is the one to report.
25
28
  }
26
29
  }
27
- /** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
30
+ /**
31
+ * Creates a child session, hands it the task and returns at once; the child reports back with
32
+ * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
33
+ */
28
34
  export async function spawn(ports, parentID, input) {
35
+ // Some models send null, or an empty string, for an optional field they leave out.
36
+ const group = typeof input.group === "string" ? input.group.trim() || undefined : (input.group ?? undefined);
37
+ if (group !== undefined && !isGroupName(group))
38
+ throw new Error(`group must be a name of 1 to 60 letters, digits, dots, dashes or underscores, not ${JSON.stringify(group)}.`);
39
+ const admitted = await admit(ports, parentID);
40
+ try {
41
+ return await start(ports, parentID, input, admitted, group);
42
+ }
43
+ finally {
44
+ admitted.release();
45
+ }
46
+ }
47
+ async function start(ports, parentID, input, admitted, group) {
48
+ const { worktree, handedDown, ...child } = await create(ports, parentID, input);
49
+ admitted.reservation.sessionID = child.sessionID;
50
+ const entry = {
51
+ ...child,
52
+ parentID,
53
+ isolated: worktree !== undefined,
54
+ createdAt: ports.now(),
55
+ reports: "status",
56
+ ...(worktree ? { source: ports.directory, project: ports.projectID } : {}),
57
+ ...(group ? { group } : {}),
58
+ };
59
+ const { rosterError, groupError } = await enrol(ports, entry, group);
60
+ ports.roles.delete(parentID);
61
+ await handOver(ports, entry, admitted.depth, input.task, !rosterError, !rosterError && !groupError);
62
+ return {
63
+ sessionID: entry.sessionID,
64
+ directory: entry.directory,
65
+ ...(group && !rosterError && !groupError ? { group } : {}),
66
+ ...(rosterError ? { rosterError } : {}),
67
+ ...(groupError ? { groupError } : {}),
68
+ ...(handedDown ? { fromParent: true } : {}),
69
+ ...(handedDown?.uncommitted ? { uncommitted: true } : {}),
70
+ };
71
+ }
72
+ /** Creates the child's session, in a worktree of its own if asked, which goes again if the session cannot be created. */
73
+ async function create(ports, parentID, input) {
29
74
  // A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
30
75
  const model = await inheritedModel(ports, parentID, input.agent).catch(() => undefined);
31
- const directory = input.isolate
32
- ? (await ports.worktree.create({ projectID: ports.projectID })).directory
76
+ const handedDown = input.isolate ? await parentWork(ports, parentID) : undefined;
77
+ const worktree = input.isolate
78
+ ? (await ports.worktree.create({ projectID: ports.projectID, ...(handedDown ? { from: handedDown.directory, branch: handedDown.head } : {}) })).directory
33
79
  : undefined;
34
- const base = directory ? await ports.head(directory) : undefined;
80
+ const base = worktree ? await ports.head(worktree) : undefined;
35
81
  const title = input.title ?? titleOf(input.task);
36
82
  const child = await ports.session
37
83
  .create({
38
84
  title,
39
85
  ...(input.agent ? { agent: input.agent } : {}),
40
86
  ...(model ? { model } : {}),
41
- ...(directory ? { location: { directory } } : {}),
87
+ ...(worktree ? { location: { directory: worktree } } : {}),
42
88
  metadata: { courier: { parentID } },
43
89
  })
44
90
  .catch(async (error) => {
45
91
  // No session will ever use the fresh worktree, and nothing records it, so it goes now.
46
- if (directory)
47
- await dropWorktree(ports, directory);
92
+ if (worktree)
93
+ await dropWorktree(ports, worktree);
48
94
  throw error;
49
95
  });
50
- // Recorded before the prompt, so a child that exists is on the roster even if prompting fails. A
51
- // failed write must not keep the child from its task, so it is reported instead of thrown.
52
- const rosterError = await record(ports.storage, {
53
- sessionID: child.id,
54
- parentID,
55
- title,
56
- directory: directory ?? child.location.directory,
57
- isolated: directory !== undefined,
58
- createdAt: ports.now(),
59
- ...(directory ? { source: ports.directory, project: ports.projectID } : {}),
60
- ...(base ? { base } : {}),
61
- }).then(() => undefined, (error) => describeFailure("roster", error).message);
62
- await ports.session.prompt({ sessionID: child.id, text: childBrief(parentID, input.task) });
63
- return {
64
- sessionID: child.id,
65
- directory: directory ?? child.location.directory,
66
- ...(rosterError ? { rosterError } : {}),
67
- };
96
+ return { sessionID: child.id, title, directory: worktree ?? child.location.directory, worktree, handedDown, ...(base ? { base } : {}) };
97
+ }
98
+ /**
99
+ * The worktree and HEAD an isolated parent hands its isolated child, by the reverse index alone; undefined for any
100
+ * other parent. An unreadable HEAD fails the spawn rather than start the child without the parent's work.
101
+ */
102
+ async function parentWork(ports, parentID) {
103
+ const parent = await indexedEntry(ports.storage, parentID);
104
+ if (!parent?.isolated)
105
+ return undefined;
106
+ const head = await ports.head(parent.directory);
107
+ if (!head)
108
+ throw new Error(`the HEAD of your worktree ${parent.directory} could not be read, so an isolated session cannot start from it; nothing was started. Start it without isolate, or fix the worktree.`);
109
+ const uncommitted = (await ports.dirty(parent.directory).catch(() => undefined)) === true;
110
+ return { directory: parent.directory, head, uncommitted };
111
+ }
112
+ /**
113
+ * Records the child on the roster, before the prompt, so a child that exists is on the roster even if prompting
114
+ * fails, then in its group. A failed write must not keep the child from its task: each is reported, not thrown.
115
+ */
116
+ async function enrol(ports, entry, group) {
117
+ const rosterError = await record(ports.storage, entry).then(() => undefined, (error) => describeFailure("roster", error).message);
118
+ const groupError = group && !rosterError ? await join(ports, entry, group) : undefined;
119
+ return { rosterError, groupError };
120
+ }
121
+ /**
122
+ * Hands the child its task. `rostered` says whether its report state is kept, which a child off the roster has none
123
+ * of, and `grouped` whether it is in its group; a child that never got its task leaves both, as it will never report.
124
+ */
125
+ async function handOver(ports, entry, depth, task, rostered, grouped) {
126
+ // A baseline, until OpenCode's event for the prompt's delivery moves it on; written first, so it never overtakes that.
127
+ if (rostered)
128
+ await prompted(ports.storage, entry.sessionID, ports.now()).catch(() => undefined);
129
+ try {
130
+ await ports.session.prompt({ sessionID: entry.sessionID, text: childBrief(entry.parentID, task, depth, ports.limits, entry.isolated ? childBranch(entry.sessionID) : undefined) });
131
+ }
132
+ catch (error) {
133
+ if (rostered)
134
+ await settled(ports.storage, entry.sessionID, "failed", ports.now()).catch(() => undefined);
135
+ if (grouped && entry.group)
136
+ await leaveGroup(ports.storage, entry.parentID, entry.group, entry.sessionID, "failed", ports.now()).then((complete) => complete && ports.nudge(), (leaveError) => ports.log(`courier_spawn: ${entry.sessionID} could not leave group ${entry.group}: ${String(leaveError)}`));
137
+ throw error;
138
+ }
139
+ }
140
+ /**
141
+ * Joins a child to its group; one that cannot be joined is recorded in no group, so it reports on its own,
142
+ * and the result says so. Returns the error's message, if any.
143
+ */
144
+ async function join(ports, entry, group) {
145
+ try {
146
+ await joinGroup(ports.storage, entry.parentID, group, entry.sessionID, entry.title, ports.now());
147
+ return undefined;
148
+ }
149
+ catch (error) {
150
+ const { group: _, ...ungrouped } = entry;
151
+ // A write that failed may have landed all the same: a member left there would hold its group up for good.
152
+ await Promise.all([
153
+ record(ports.storage, ungrouped).catch(() => undefined),
154
+ ports.storage.remove(memberKey(entry.parentID, group, entry.sessionID)).catch(() => undefined),
155
+ ]);
156
+ return describeFailure("group", error).message;
157
+ }
68
158
  }
69
- /** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
159
+ /**
160
+ * Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
161
+ * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
162
+ */
70
163
  export async function send(ports, from, input) {
71
- const delivered = await ports.session.synthetic({
72
- sessionID: input.sessionID,
73
- text: envelope(from, input.message),
74
- description: `Message from ${from}`,
75
- metadata: { source: "courier", from },
76
- delivery: input.queue ? "queue" : "steer",
77
- });
78
- return { messageID: delivered.id };
164
+ // Some models send null for an optional field they leave out.
165
+ const status = input.status ?? undefined;
166
+ if (status !== undefined && !isStatus(status))
167
+ throw new Error(`status must be one of ${STATUSES.join(", ")}, not ${JSON.stringify(status)}.`);
168
+ // Noted first: the delivery may wake the parent, whose turn may end before a later note.
169
+ const noted = await noteReport(ports, from, input.sessionID, status).catch(() => undefined);
170
+ try {
171
+ const held = noted?.member && status && status !== "blocked" ? await hold(ports, noted.member, { at: noted.at, status, message: input.message, ...(input.artifacts ? { artifacts: input.artifacts } : {}) }) : undefined;
172
+ if (held)
173
+ return { status, report: true, held };
174
+ const delivered = await ports.session.synthetic({
175
+ sessionID: input.sessionID,
176
+ text: envelope(from, reportBody(input.message, input.artifacts), status ? { status } : {}),
177
+ description: `Message from ${from}`,
178
+ metadata: { source: "courier", from, ...(status ? { status } : {}) },
179
+ delivery: input.queue ? "queue" : "steer",
180
+ });
181
+ // A blocked report, once delivered, is the member's latest word: an earlier report held for it would be stale.
182
+ if (noted?.member && status === "blocked")
183
+ await unholdForBlocked(ports.storage, noted.member).catch(() => undefined);
184
+ return { messageID: delivered.id, ...(status ? { status } : {}), ...(noted ? { report: noted.report } : {}) };
185
+ }
186
+ catch (error) {
187
+ await noted?.undo().catch(() => undefined);
188
+ throw error;
189
+ }
190
+ }
191
+ /**
192
+ * Notes `from`'s message to `to` when `to` started it: as its report when it carries a status, or when an earlier
193
+ * release briefed `from`, else as progress. Returns which, when, how to take the note back, and `from`'s group membership.
194
+ */
195
+ async function noteReport(ports, from, to, status) {
196
+ const entry = await indexedEntry(ports.storage, from);
197
+ if (entry?.parentID !== to)
198
+ return undefined;
199
+ const report = status !== undefined || entry.reports !== "status";
200
+ const key = report ? settledKey(from) : progressKey(from);
201
+ const before = await ports.storage.get(key);
202
+ const at = ports.now();
203
+ await (report ? settled(ports.storage, from, "report", at, status) : progressed(ports.storage, from, at));
204
+ // Only its own note: another courier_send of the session's may have noted its report since.
205
+ const undo = async () => {
206
+ const now = (await ports.storage.get(key));
207
+ if (now?.at !== at || (report && now.by !== "report"))
208
+ return;
209
+ await (before === undefined ? ports.storage.remove(key) : ports.storage.set(key, before));
210
+ };
211
+ return { report, at, undo, member: status !== undefined ? await memberIn(ports, entry) : undefined };
212
+ }
213
+ /**
214
+ * A child's membership of an open group of its parent's, by its roster entry; none once courier_cleanup dropped it. One that
215
+ * cannot be read is dropped from the group, best effort, as the report then goes on its own and the group must not wait for it.
216
+ */
217
+ async function memberIn(ports, entry) {
218
+ if (!entry.group)
219
+ return undefined;
220
+ try {
221
+ const member = await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID);
222
+ return member?.left?.by === "dropped" ? undefined : member;
223
+ }
224
+ catch (error) {
225
+ ports.log(`courier_send: ${entry.sessionID}'s place in group ${entry.group} could not be read, so its report goes on its own: ${String(error)}`);
226
+ await ports.storage.remove(memberKey(entry.parentID, entry.group, entry.sessionID)).then(() => ports.nudge(), () => undefined);
227
+ return undefined;
228
+ }
229
+ }
230
+ /**
231
+ * Holds a member's report with its group, and nudges the scheduler if that completes the group. Undefined
232
+ * when it could not be held: the member is dropped from the group, which releases without it, and reports on its own.
233
+ */
234
+ async function hold(ports, member, report) {
235
+ try {
236
+ await holdReport(ports.storage, member, report);
237
+ }
238
+ catch (error) {
239
+ // Neither held nor dropped, the group would wait for ever: the call fails, and the sender sends it again.
240
+ await unholdReport(ports.storage, member).catch((dropError) => {
241
+ throw new Error(`the report could not be held with group ${member.group} (${String(error)}), nor the member dropped from it (${String(dropError)}); send it again.`);
242
+ });
243
+ ports.nudge();
244
+ return undefined;
245
+ }
246
+ // Held whatever the count gives; a count that cannot be read gets the nudge, as a tick that finds nothing due is cheap.
247
+ const standing = await groupStanding(ports.storage, member.parentID, member.group).catch(() => undefined);
248
+ if (standing?.complete !== false)
249
+ ports.nudge();
250
+ return { group: member.group, ...(standing ? { reported: standing.reported, members: standing.members } : {}) };
79
251
  }
80
252
  /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
81
253
  export async function status(ports, input) {
@@ -84,10 +256,7 @@ export async function status(ports, input) {
84
256
  ports.session.context({ sessionID: input.sessionID }),
85
257
  ports.pending(input.sessionID),
86
258
  ]);
87
- const last = messages.findLast((message) => message.type === "assistant");
88
- const lastText = last?.type === "assistant"
89
- ? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
90
- : undefined;
259
+ const lastText = lastReply(messages);
91
260
  return withoutUndefined({
92
261
  sessionID: info.id,
93
262
  title: info.title,
@@ -99,23 +268,38 @@ export async function status(ports, input) {
99
268
  pending: pending.length ? pending : undefined,
100
269
  });
101
270
  }
102
- /** The sessions a parent started, each with what courier_status reports, or the error it gave. */
271
+ /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
272
+ export function lastReply(messages) {
273
+ const last = messages.findLast((message) => message.type === "assistant");
274
+ return last?.type === "assistant"
275
+ ? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
276
+ : undefined;
277
+ }
278
+ /**
279
+ * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
280
+ * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
281
+ */
103
282
  export async function listChildren(ports, parentID) {
104
283
  const entries = await current(ports.storage, parentID, ports.now());
105
- return Promise.all(entries.map(async (entry) => {
106
- const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt };
107
- try {
108
- return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
109
- }
110
- catch (error) {
111
- return {
112
- sessionID: entry.sessionID,
113
- title: entry.title,
114
- ...roster,
115
- error: describeFailure("courier_status", error).message,
116
- };
117
- }
118
- }));
284
+ return Promise.all(entries.map((entry) => described(ports, entry)));
285
+ }
286
+ /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
287
+ export async function described(ports, entry) {
288
+ const group = entry.group
289
+ ? { group: { name: entry.group, report: await memberOf(ports.storage, entry.parentID, entry.group, entry.sessionID).then(standingOf, () => "unknown") } }
290
+ : {};
291
+ const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt, ...group };
292
+ try {
293
+ return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
294
+ }
295
+ catch (error) {
296
+ return {
297
+ sessionID: entry.sessionID,
298
+ title: entry.title,
299
+ ...roster,
300
+ error: describeFailure("courier_status", error).message,
301
+ };
302
+ }
119
303
  }
120
304
  /** OpenCode leaves a tool call hanging when its metadata holds `undefined`, so results drop those keys. */
121
305
  function withoutUndefined(value) {
@@ -0,0 +1,86 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { type HeldReport, type LeftMember } from "./notices.js";
3
+ import { type Storage } from "./storage.js";
4
+ type Context = Plugin.Context;
5
+ export declare const isGroupName: (value: unknown) => value is string;
6
+ export declare const memberKey: (parentID: string, group: string, sessionID: string) => string;
7
+ /** The key prefix of a parent's groups, and of one group's members. */
8
+ export declare const groupsPrefix: (parentID: string) => string;
9
+ export declare const groupPrefix: (parentID: string, group: string) => string;
10
+ /** A member of a group, as stored: its title for the release notice, and its report or how it left. */
11
+ export interface Member {
12
+ readonly title: string;
13
+ readonly joinedAt: number;
14
+ readonly report?: HeldReport;
15
+ readonly left?: {
16
+ readonly at: number;
17
+ readonly by: LeftMember["by"];
18
+ };
19
+ }
20
+ /** A member with where it is kept. */
21
+ export interface Membership extends Member {
22
+ readonly parentID: string;
23
+ readonly group: string;
24
+ readonly sessionID: string;
25
+ }
26
+ /** What is known of a member's standing: its report is held, still out, or it left, or it is no longer in an open group. */
27
+ export type Standing = "held" | "out" | LeftMember["by"] | "released";
28
+ /** A member's standing from its stored value, or `released` when there is none. */
29
+ export declare function standingOf(member: Member | undefined): Standing;
30
+ /** The member a session is in a parent's group, if the group is open and it is a member. */
31
+ export declare function memberOf(storage: Pick<Storage, "get">, parentID: string, group: string, sessionID: string): Promise<Member | undefined>;
32
+ /** Makes a spawned session a member of its parent's group, opening the group if it was not. */
33
+ export declare function joinGroup(storage: Pick<Storage, "set">, parentID: string, group: string, sessionID: string, title: string, at: number): Promise<void>;
34
+ /** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
35
+ export declare function hasCompleteGroup(storage: Pick<Storage, "scan">, parentID: string): Promise<boolean>;
36
+ /** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
37
+ export declare function holdReport(storage: Pick<Storage, "set">, membership: Membership, report: HeldReport): Promise<void>;
38
+ /** How many members of a group have reported and how many have not left, and whether the group is complete. */
39
+ export declare function groupStanding(storage: Pick<Storage, "scan">, parentID: string, group: string): Promise<{
40
+ reported: number;
41
+ members: number;
42
+ complete: boolean;
43
+ }>;
44
+ /** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
45
+ export declare function membershipOf(storage: Pick<Storage, "get">, parentID: string, group: string, sessionID: string): Promise<{
46
+ parentID: string;
47
+ group: string;
48
+ sessionID: string;
49
+ out: boolean;
50
+ title: string;
51
+ joinedAt: number;
52
+ report?: HeldReport;
53
+ left?: {
54
+ readonly at: number;
55
+ readonly by: LeftMember["by"];
56
+ };
57
+ } | undefined>;
58
+ /**
59
+ * Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
60
+ * again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
61
+ */
62
+ export declare function markLeft(storage: Pick<Storage, "get" | "set" | "scan">, membership: Membership, by: LeftMember["by"], at: number): Promise<boolean>;
63
+ /** `markLeft` for a member not read yet; undefined when it is in no open group. */
64
+ export declare function leaveGroup(storage: Pick<Storage, "get" | "set" | "scan">, parentID: string, group: string, sessionID: string, by: LeftMember["by"], at: number): Promise<boolean | undefined>;
65
+ /**
66
+ * Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
67
+ * again first: a group released meanwhile is not opened again, and a report held since is kept.
68
+ */
69
+ export declare function unholdForBlocked(storage: Pick<Storage, "get" | "set">, membership: Membership): Promise<void>;
70
+ /** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
71
+ export declare function dropMember(storage: Pick<Storage, "get" | "remove">, parentID: string, group: string, sessionID: string): Promise<boolean>;
72
+ /** Drops a member from its group whatever it holds: its report could not be held after all, so it goes on its own. */
73
+ export declare function unholdReport(storage: Pick<Storage, "remove">, membership: Membership): Promise<void>;
74
+ /** Drops every group of a parent, whose members' reports can no longer be delivered. */
75
+ export declare function dropGroups(storage: Pick<Storage, "scan" | "remove">, parentID: string): Promise<void>;
76
+ export interface GroupPorts {
77
+ readonly storage: Storage;
78
+ readonly session: Pick<Context["session"], "synthetic" | "get">;
79
+ readonly log: (message: string) => void;
80
+ }
81
+ /**
82
+ * Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
83
+ * delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
84
+ */
85
+ export declare function deliverReleased(ports: GroupPorts, claimed: Set<string>): Promise<void>;
86
+ export {};